서론
우리 모두는 단일 책임 원칙 (Single Responsibility Principle, SRP) 에 대해 이미 알고 있습니다.
하나의 모듈은 하나의 책임만 가져야한다는 객체 지향 프로그래밍의 설계 원칙입니다. 너무 명쾌한 내용이기에 이 원칙을 공감하지 못하는 분은 없을겁니다. 하지만 그럼에도 불구하고 우리는 비대한 모듈을 만들곤 합니다. 왜 일까요? 바로 "하나의 책임"이라는 말이 가지는 모호함때문입니다. 과연 어디까지 책임의 범위로 취급해야 할까요? 커피를 만드는 과정으로 예를 들어보겠습니다.
1. 물을 끊인다.
2. 원두를 갈아 넣는다.
3. 물을 커피에 붓는다.
4. 커피를 컵에 따른다.
커피를 만드는 과정을 위 4가지 단계로 나눠보겠습니다. 그러면 각 단계가 각각 하나의 책임일까요?
한번 딴지를 걸어보겠습니다. 2. 원두를 갈아 넣는다 의 단계는 세부적으로
- 생산된지 6개월 미만의 원두를 준비한다.
- 분당 30회의 속도로 그라인더를 회전시킨다.
- 그라인더에 원두를 50g넣는다.
의 단계가 있습니다. 그럼 각 단계를 하나의 책임으로 보고 모듈을 나눈다면 2. 원두를 갈아 넣는다 는 너무 많은 책임을 수행하고 있는 거 아닐까요? 위 상황에 대해서 아래와 같은 코드 리뷰가 달릴지 모릅니다.
00님 원두를 갈아 넣는다 모듈은 너무 많은 기능을 포함하고 있는데
단일 책임에 위배되기 때문에 적절한 모듈화가 필요할 것 같습니다
라는 리뷰에 어떻게 답변해야 할까요? 이렇듯 단일 책임이라는 말은 너무 모호합니다.
그렇기 때문에 작업의 범위에 대한 합의가 필요한데 그 합의점의 기준은 추상화 계층입니다.
동등한 추상화 계층, 그것이 해당 모듈에서 책임의 범위를 결정하는 기준입니다.
동료를 설득하는데 실패하고 위 리뷰를 반영해서 CoffeeMaker 모듈에 위 네가지 단계중
`2. 원두를 갈아 넣는다.`의 과정을 저수준 추상화 모듈로 구현해보겠습니다.
아래 리스트의 각각의 항목이 모듈이라고 생각하시면 됩니다.
1. 물을 끊인다.
2.1. 생산된지 6개월 미만의 원두를 준비한다.
2.2. 그라인더에 원두를 50g넣는다.
2.3. 분당 30회의 속도로 그라인더를 회전시킨다.
3.주전자에 물을 200ml 담는다.
4. 커피를 컵에 따른다.
뭔가 어색하죠? 갑자기 궁금하지도 않은 너무 세세한 정보들이 쏟아져 나와서 오히려 맥락을 해치는 기분이 듭니다.
"왜 갑자기 이 부분에서만 불필요할 정도로 상세하게 설명하는거야?"라는 생각이 듭니다. 이런 위화감의 원인이 추상화의 레벨이 다르기 때문입니다. 2.1~2.3을 분리해서 다른 모듈(1,3,4)과 동등한 추상화 레벨의 모듈로 분리하고 싶은 욕구가 마구 솟구칩니다. 이제 다시 4개의 비슷한 수준으로 추상화된 4가지 작업으로 나뉘었습니다.
만약 여기서 1~4번의 작업이 독립된 모듈이 아닌 하나의 모듈로 작업되어 있다면 어떨까요? 이 때는 자신있게 "이 모듈은 너무 많은 작업을 하고 있네요 4개의 모듈로 관심사를 분리할 수 있을 것 같습니다"라는 리뷰를 남길 수 있을 것 같습니다.
우리는 단일 책임에 근거하여 하나의 추상화 계층 속에서 하위 작업에 대한 4가지 추상화 모듈을 나눔으로써 다음의 장점을 얻을 수 있습니다.
1. 모듈의 재사용성이 높아졌다.
- `물을 끊인다.` 모듈을 범용적으로 사용할 수 있습니다.
2. 테스트가 쉬워졌다
- 하위 작업의 책임, '물을 잘 끓이는지'에 대해서만 테스트하면 됩니다.
3. 가독성
- 커다란 작업을 맥락 별로 모듈화했기 때문에 커다란 흐름을 파악하기 용이하다.
4. 확장성
- 각 모듈에 인터페이스를 추상화하고 모듈을 상황에 맡게 주입하면 확장성이 높아진다.
- eg) 원두에 따라 원두를 가는 방식을 변경하는 상황.
이제 다시 단일책임 원칙의 책임의 범위에 대해 생각해보겠습니다.
책임의 범위는 해당 모듈이 가진 추상화 계층보다 한 단계 낮은 저 수준의 추상화 작업으로 정의할 수 있을 것 같습니다. 즉 책임의 범위는 모듈의 추상화 수준에 의해 결정됩니다. 물론 추상화 레벨의 기준조차 모호할 수 있습니다. "이게 서로 다른 추상화 계층이다"라는 명백한 증거를 기반으로 동료를 설득하기는 어렵습니다. 따라서 작업의 범위와 재사용성을 기준으로 팀의 합의가 필요합니다.
굳이 추상화 계층을 나누는 기준을 정해보자면 하나의 기능에 대해 수정이 발생할 수 있는 코드의 범위로 말할 수 있을 것 같습니다. 즉, 얼마나 세부적으로 변화를 줄 것이냐에 따라 추상화 계층의 수준이 달라진다고 할 수 있습니다.
본론으로 돌아와 아래 예제에서 어떤 기준으로 추상화 계층을 나눴는지 살펴보시길 바랍니다.
class WaterBoiler {
prepareWater() {
console.log('Preparing water...');
}
startBoiling() {
console.log('Starting to boil water...');
}
waitUntilBoiled() {
console.log('Waiting until water is boiled...');
}
boil() {
this.prepareWater();
this.startBoiling();
this.waitUntilBoiled();
}
}
class CoffeeGrinder {
prepareBeans() {
console.log('Preparing coffee beans...');
}
loadBeans() {
console.log('Loading beans into grinder...');
}
grindBeans() {
console.log('Grinding coffee beans...');
}
grind() {
this.prepareBeans();
this.loadBeans();
this.grindBeans();
}
}
class CoffeeBrewer {
prepareWater() {
console.log('Preparing boiled water...');
}
prepareGroundCoffee() {
console.log('Preparing ground coffee...');
}
pourWater() {
console.log('Pouring water over ground coffee...');
}
waitBrew() {
console.log('Waiting for coffee to brew...');
}
brew() {
this.prepareWater();
this.prepareGroundCoffee();
this.pourWater();
this.waitBrew();
}
}
class CoffeePourer {
prepareCup() {
console.log('Preparing cup...');
}
filterCoffee() {
console.log('Filtering coffee into cup...');
}
pour() {
this.prepareCup();
this.filterCoffee();
}
}
class CoffeeMaker {
constructor() {
this.waterBoiler = new WaterBoiler();
this.coffeeGrinder = new CoffeeGrinder();
this.coffeeBrewer = new CoffeeBrewer();
this.coffeePourer = new CoffeePourer();
}
makeCoffee() {
this.waterBoiler.boil();
this.coffeeGrinder.grind();
this.coffeeBrewer.brew();
this.coffeePourer.pour();
}
}
const coffeeMaker = new CoffeeMaker();
coffeeMaker.makeCoffee();
위 코드를 추상화 레벨에 따라 구분해보면 다음과 같습니다.
높은 추상화 레벨: CoffeeMaker
- 하위 레벨의 클래스를 실질적으로 실행하는 최종 단계의 코드
- 각 하위 단계의 상호작용을 컨트롤한다.
- 각 단계에 대한 세부 작업은 하위 클래스에 위임한다.
- 세부 작업이 아닌 시스템적 주요 기능을 담당한다.
class CoffeeMaker {
constructor() {
this.waterBoiler = new WaterBoiler();
this.coffeeGrinder = new CoffeeGrinder();
this.coffeeBrewer = new CoffeeBrewer();
this.coffeePourer = new CoffeePourer();
}
makeCoffee() {
this.waterBoiler.boil();
this.coffeeGrinder.grind();
this.coffeeBrewer.brew();
this.coffeePourer.pour();
}
}
중간 추상화 레벨: WaterBoiler
- 각 클래스는 중간 추상화 레벨에서 하나의 책임을 다룬다.
- 각 책임을 수행하는 데 필요한 세부 단계를 메서드로 정의한다.
- 중간 레벨 클래스간 상호작용에 대해서는 상위 클래스에 위임한다.
- 각 단계의 책임에만 집중한다.
class WaterBoiler {
boil() {
this.prepareWater();
this.startBoiling();
this.waitUntilBoiled();
}
prepareWater() {
console.log('Preparing water...');
}
startBoiling() {
console.log('Starting to boil water...');
}
waitUntilBoiled() {
console.log('Waiting until water is boiled...');
}
}
낮은 추상화 단계: method
- 각 메서드는 구체적인 작업을 수행하며, 더 작은 단위로 나누어진다.
- 클래스의 단일 책임을 달성하기 위한 세부 단계이다.
class CoffeeGrinder {
grind() {
this.prepareBeans();
this.loadBeans();
this.grindBeans();
}
prepareBeans() {
console.log('Preparing coffee beans...');
}
loadBeans() {
console.log('Loading beans into grinder...');
}
grindBeans() {
console.log('Grinding coffee beans...');
}
}
정리
커피 만들기 예제를 통해 단일 책임원칙에 근거하여 코드를 분리할 때 해당 모듈의 추상화 레벨이 기준이 된다는 것을 이야개 해봤습니다. 바꿔말하면 단일 책임 원칙은 각 모듈의 추상화 수준에 대한 하나의 책임을 수행한다로 정리 할 수 있을 것 같습니다. 추상화 레벨에 근거하여 모듈을 분리하게 됐을 때 각 모듈간의 관계가 명확해지고 구조적 일관성을 유지할 수 있으며 변경이 쉬운 구조를 갖게 됩니다. 예를 들어 커피를 갈아 넣는다의 단계를 하나의 모듈로 추상화했기 때문에 커피를 가는 과정에서 문제가 발생했을 때 바로 해당 모듈로 이동해 디버깅할 수 있고 원두에 종류에 따라 커피를 가는 방식을 변경할 확장 가능성이 생겼습니다.(이 경우 glinder를 인터페이스로 추출하고 CoffeeMaker는 인터페이스를 의존해야 함)
'programming' 카테고리의 다른 글
견고한 소프트웨어를 위한 오류 핸들링 (0) | 2024.07.01 |
---|---|
[설계] 오용하기 어려운 코드 설계하기 (0) | 2024.06.30 |