☝ Single Responsibility ( 단일 책임의 원칙 )

"In any well-designed system, objects should only have a single responsibility." by Robert Martine.

잘 설계 된 모든 시스템에서 객체들은 오로지 1개의 책임만을 가지고 있어야 한다.

즉, 객체를 구현하는 코드는 오로지 한가지 일만을 효율적인 방법으로 처리하는데 집중해야 한다는 말이다. 이 말을 제대로 이해하기 위해서는 이러한 원칙을 위반하는 코드를 살펴보는 것이 좋다.

우선, 드론을 기반으로 배달을 하는 서비스를 지원하는 소프트웨어를 개발하고 있다고 가정해보자.
다음 코드는 Drone 이 배달과정에서 수행해야하는 몇 가지 책임들을 정의하는 코드라고 볼 수 있다.

// NavigateTo applies any required changes to the drone's speed 
// vector so that its eventual position matches dst.
func (d *Drone) NavigateTo(dst Vec3) error { //... }

// Position returns the current drone position vector.
func (d *Drone) Position() Vec3 { //... }

// Position returns the current drone speed vector.
func (d *Drone) Speed() Vec3 { //... }

// DetectTargets captures an image of the drone's field of view (FoV) using
// the on-board camera and feeds it to a pre-trained SSD MobileNet V1 neural
// network to detect and classify interesting nearby targets. For more info
// on this model see: 
// https://github.com/tensorflow/models/tree/master/research/object_detection
func (d *Drone) DetectTargets() ([]*Target, error) { //... }

상기 코드는 SRP(Single Responsibility Principle)원칙을 위반하고 있다.
그 이유는 2가지 복합적인 책임을 동시에 수행하고 있다고 볼 수 있기 때문이다.

  • Navigation ( 비행 )
  • Detection ( 탐지 )

사실 비행과 탐지라는 두가지 책임을 동시에 Drone 이라고 하는 객체 타입에 같이 선언하여 개발한다고 하더라도 처음 프로그램 동작에는 문제가 없을 수 있다. 하지만, 문제는 그 이후에 발생한다. 비행과 탐지라는 두 가지 책임이 동시에 존재하기 때문에 Coupling (결합) 이 발생하게 되고 그로 인해 추가적인 개발과 확장에 어려움을 가중 시킬 수 있다.

예를 들면, 탐지에 사용되는 인공지능 모델을 다른 것으로 사용한다든지, 다른 종류의 Drone에 기존의 동일한 탐지 코드를 사용한다든지 하고 싶을 떄 어쩔 수 없이 존재하는 결합상태 때문에 비행과 관련한 코드를 항상 유지해야 한다는 것이다.

그렇다면 어떻게 설계를 개선할 수 있을 까?

우선, 모든 드론들이 카메라를 탑재하고 있다는 전제하에 카메라를 이용해 이미지를 캡처하고 반환하는 메소드를 Drone 객체에 정의할 수 있다. 물론 누군가는 이미지를 찍는 거는 Navigation 과는 다른 별도의 책임이라고 볼 수 있지 않냐? 라고 할 수 있는데 모두 관점의 차이일 뿐이다. 이 부분은 매우 주관적인 일이지만, 또 한편으로는 비행을 하기 위해서는 어쩔 수 없이 여러 센서의 데이터가 필요할 수 있고 카메라 또한 그 중에 일부 이기 때문에 그런 면에서는 개선하고자 하는 방향이 단일 책임 원칙을 위반하고 있지는 않다.

두번째로, 대상을 탐지하는 코드를 별도의 객체로 옮긴다면 “물체 인식 알고리즘 모델"을 사용하고자 하는 비즈니스 요구사항을 Drone 타입의 코드를 건드리지 않고 만족시킬 수 있다.

지금 까지 설명한 내용을 코드로 반영하자면 아래와 같을 수 있다.

// NavigateTo applies any required changes to the drone's speed vector 
// so that its eventual position matches dst.
func (d *Drone) NavigateTo(dst Vec3) error { //... }

// Position returns the current drone position vector.
func (d *Drone) Position() Vec3 { //... }

// Position returns the current drone speed vector.
func (d *Drone) Speed() Vec3 { //... }

// CaptureImage records and returns an image of the drone's field of 
// view using the on-board drone camera.
func (d *Drone) CaptureImage() (*image.RGBA, error) { //... }

기존 DetectTargets 메소드를 MobileNet 구조체로 빼내어서 정의하고 CaptureImage 메소드만 Drone 타입에 남김으로써 “비행"에 대한 책임만을 가질 수 있게 되었다.

때문에 향후 MobileNet이라는 모델을 이용한 탐지를 하는 DetectTargets 로직에 변경사항이 생겨도 Drone 타입의 코드는 변하지 않게 된다. 그리고 Drone 에서 CaptureImage 이외에 다양한 센서 추가로 인한 메소드 추가도 DetectTargets의 코드에 영향을 주지 않는 것이다.

// MobileNet performs target detection for drones using the 
// SSD MobileNet V1 NN.
// For more info on this model see:
// https://github.com/tensorflow/models/tree/master/research/object_detection
type MobileNet {
    // various attributes...
}

// DetectTargets captures an image of the drone's field of view and feeds
// it to a neural network to detect and classify interesting nearby 
// targets.
func (mn *MobileNet) DetectTargets(d *drone.Drone) ([]*Target, error){
    //...
}

Drone 에 관한 예시가 어렵다면 또 다른 예시를 보자.

이벤트를 기반으로 데이터를 처리해야하는 파이프라인 어플리케이션이 있다고 할 때

우리는 다음과 같은 코드를 정의할 수 있다.

package pipelinesrpviolated

type Pipeline struct {
}

func (p Pipeline) Read() {

}
func (p Pipeline) Process(data []byte) error {

	return nil
}
func (p Pipeline) Write(data []byte) error {
	return nil
}

Pipeline 이라는 구조체는 3가지 책임을 가지고 있다.

읽고, 처리하고, 쓰기.

여기서 발생하는 문제점은 무엇일까?

만약에 읽어와야 하는 곳이 한 곳이 아니라 여러 곳이라면?

Pipeline 타입을 수정할 수 밖에 없다. Read 메소드에서 코드 변경이 일어나야 하기 때문에 Pipeline을 수정하게 되고, Process 메소드에서는 데이터를 어떻게 처리할 것인가에 대한 고민만 해야하는데 개발자는 Read()를 개발하기 위해서 Process(), Write()를 하는 책임까지 지니고 있는 Pipeline을 고치게 되는 것이다.

그렇다면 당연히 Process, Write를 분리해줘야 Read에서 처리해야할 업무에만 영향이 있을 수 있도록 해야겠다.

package pipelinesrpgood

type Reader struct {
}

func (r Reader) Read() {

}

type Processor struct {
}

func (p Processor) Process(data []byte) error {

	return nil
}

type Writer struct {
}

func (w Writer) Write(data []byte) error {
	return nil
}

이렇게 3 가지 타입으로 객체를 나누게 되면 각자 비즈니스 요구사항이 변경될 때마다 독립된 타입만 변경하면 된다.

해당 코드만 보면 아직 Reader, Processor, Writer간 데이터를 어떻게 주고 받는지는 나타나 있지 않지만 개별 타입으로 선언되었다는 것만으로도 Reader 는 읽어서 []byte 를 Processor에게 넘겨주고, Processor는 처리된 데이터를 []byte 로 Writer에게 넘겨주면 된다라는 생각이 든다.

이렇게 SRP 원칙을 적용함으로 코드 변경에 대한 좀더 유연한 대응이 가능하고 책임간 상호 영향도를 낮출 수 있다는 장점이 있다는 것을 기억하자.

profile
Principal Software Engineer at Nuance by Microsoft

0개의 댓글