2025. 11. 1. 21:33ㆍ좋은 코드 (Good Code)/좋은 설계 (Good Design)
SOLID
오랜만에 아키텍쳐 관련 글을 다시 읽으면서 SOLID 원칙에 대해 다시금 생각나게 해주었다.
- 단일 책임 원칙 (Single Responsibility Principle)
- 개방-폐쇄 원칙 (Open-Closed Principle)
- 리스코브 치환 원칙 (Liskov Substitution Principle)
- 인터페이스 분리 원칙 (Interface Segregation Principle)
- 의존성 역전 원칙 (Dependency Inversion Principle)
그리고 이 원칙을 처음 제안한 시기가 언제인지 문득 궁금해졌다.
| 원칙 | 제안자 | 출판 |
| 단일 책임 원칙 | Robert C. Martin | 『Design Principles and Design Patterns』 (2000) |
| 개방-폐쇄 원칙 | Bertrand Meyer | 『Object-Oriented Software Construction』 (1988) |
| 리스코브 치환 원칙 | Barbara Liskov Jeannette Wing |
『A Behavioral Notion of Subtyping』 (1987) |
| 인터페이스 분리 원칙 | Robert C. Martin | 『The Interface Segregation Principle』 (C++ Report, 1996) |
| 의존성 역전 원칙 | Robert C. Martin | 『The Dependency Inversion Principle』 (C++ Report, 1996) |
20 - 30년이 아득히 지난 현재, 우리는 여전히 SOLID를 불변의 원칙으로 배우고 기억하고 있다.
과연 현대에 이르러서는 동일하게 적용할 수 있을까?
먼저 가장 먼저 생각난 단일 책임 원칙부터 다뤄보려고 한다.
Single Responsibility Principle
The Single Responsibility Principle (SRP) states that each software module should have one and only one reason to change. This sounds good, and seems to align with Parnas’ formulation. However it begs the question:
What defines a reason to change?
- The Clean Code Blog by Robert C. Martin (Uncle Bob)
소프트웨어 모듈은 단 하나의 이유로만 변경되어야 한다.
대우로 표현하면, 두 개 이상의 이유가 있다면 소프트웨어 모듈은 변경되면 안된다.
참으로 멋진 말이다.
사실 어떤 프로젝트의 코드를 변경할 이유는 산처럼 쌓였기 때문에,
이 원칙이 없었다면 어디서부터 접근할 지에 대해 감각적으로 밖에 판단할 수 없을 것이다.
단순하게 생각해보면, 변경해야할 이유가 생겼다는 말은 해당 모듈이 기존과는 다른 동작을 수행해야 된다는 말이다.
그리고 이유가 단 하나이므로, 그 동작은 단 한 가지를 위해 만들어졌을 것이다.
Single Role?
그리고 많은 개발자들이 가끔 이 원칙의 진정한 의미를 혼동한다.
필자가 봤을 때 '역할'과 '책임'을 같은 맥락에서 보기 때문이라고 생각한다.
즉, 단일 책임 원칙이 아닌 단일 역할 원칙을 지키려고 한다는 것이다.
예를 들어 본인이 사과 가게를 운영하고 있고, 가격표를 만들어야 한다고 해보자.
interface AppleProvider {
fun provide(id: Long): Apple
}
interface PriceDecisioner {
fun decide(apple: Apple): Int
}
그런데 어느 날, 지역 행사로 특별히 특정 품종만 50% 가격으로 할인 해주려고 한다.
할인 정보를 어디에 넣어야 할까?
대부분의 단일 역할 원칙자들은 눈에 불을 켜고 PriceDecisioner를 고치자고 말한다.
50% 가격을 할인하는 건 가격을 결정하는 사람의 역할이지, 사과를 제공하는 사람의 역할은 아니기 때문이라고 말이다.
근본을 중시하는 단일 역할 원칙자들은 엔티티 설계부터 글러먹었다고 생각할 것이다.
애초부터 AppleProvider가 정보를 제대로 주었으면 이런 일은 없을꺼라고 하며 AppleProvider를 겨냥한다.
조금 깨어있는 단일 역할 원칙자들은 새로운 역할을 만들자고 제안한다.
아래의 결과를 조합해서 만들면 기존 인터페이스를 변경하지 않아도 되니까.
interface DiscountDecisioner {
fun decide(apple: Apple): DiscountRate
}
믿기지 않겠지만 실무에서는 뭐 이 밖에도 별의별 단일 역할 원칙자들이 존재한다.
그래도 사실 어떤 선택을 하든 단일 역할 원칙에는 위배되지 않는다.
필자도 초창기에 그랬듯, 결국 개발자들은 입맛에 따라 역할로써 설명하고 시스템을 개편할 것이다.
이렇게 해서 심지어 전체를 뜯어고친다고 해도 잘 굴러간다. (해피엔딩?)
그러나 수많은 방법 모두 해답이 될 수 있다는 점이 상당히 꺼림칙하다.
그렇다. 사실 단일 역할 따위로 정답을 설명할 수 없다.
가령 가격을 결정하는 역할이 잘못되었으니 PriceDecisioner를 겨냥했다고 가정해보자.
interface PriceDecisioner {
fun decide(apple: Apple): Int
}
이제 할인율을 이용해 가격을 결정한다고 해보자.
interface PriceDecisioner {
fun decide(apple: Apple, discountRate: Float): Int
}
그러나 이렇게되면 기존에 사용하던 함수는 못 쓰게 되니 기존 함수는 그대로 둔다.
interface PriceDecisioner {
fun decide(apple: Apple): Int
fun decide(apple: Apple, discountRate: Float): Int
}
이제 한 개의 클래스에 가격을 결정하는 역할이 두 개가 되기 때문에 단일 역할 원칙에 위배한다.
따라서 이를 해결하고자 클래스를 하나 더 만들었다.
interface PriceDecisioner {
fun decide(apple: Apple): Int
}
interface DiscountPriceDecisioner {
fun decide(apple: Apple, discountRate: Float): Int
}
이제 DiscountPriceDecisioner는 할인 가격을 결정하는 한 가지 역할만 수행한다.
끝났다.
어색하다고? 그러나 여기서 우리는 OOP의 기본 혜택인 상속을 절대 사용하지 못한다.
만약 상속을 통해 이렇게 만든다면, DiscountPriceDecisioner은 가격을 결정하는 역할이 2개이기 때문이다.
interface PriceDecisioner {
fun decide(apple: Apple): Int
}
interface DiscountPriceDecisioner : PriceDecisioner {
fun decide(apple: Apple, discountRate: Float): Int
}
그러나 어느 개발자가 상속을 포기하겠는가.
그래서 이렇게 개발하고 역할을 그럴듯하게 다시 포장하여 설명할 것이다.
DiscountPriceDecisioner는 가격을 결정하는 단 하나의 역할만 가지고 있습니다.
물론 여기에는 정가와 할인된 가격을 결정하는 것도 포함합니다.
그리고 대부분의 단일 역할 원칙자들은 또 수긍한다. 말 그대로 그럴듯하니까.
하나 더 해보자. 만약 특정 품종이 너무 귀해서 프리미엄 가격을 붙여야 한다고 한다.
interface PriceDecisioner {
fun decide(apple: Apple): Int
}
interface DiscountPriceDecisioner : PriceDecisioner {
fun decide(apple: Apple, discountRate: Float): Int
}
interface PremiumPriceDecisioner : DiscountPriceDecisioner {
fun decide(apple: Apple, discountRate: Float, premiumRate: Float): Int
}
PremiumPriceDecisioner은 가격을 결정하는 단 하나의 역할만 가지고 있습니다.
물론 여기에는 정가와 할인된 가격과 프리미엄 가격을 결정하는 것도 포함합니다.
아직도 이 설명이 맞는 것 같다면, 이제 사과가 아니라 멜론 가격도 매기고 싶을 때 어떻게 만들지 생각해보자.
다음 코드 중 뭐가 맞을까?
interface PriceDecisioner {
fun decide(apple: Apple): Int
fun decide(melon: Melon): Int
}
interface PriceDecisioner {
fun decide(apple: Apple): Int
}
interface MelonPriceDecisioner {
fun decide(melon: Melon): Int
}
interface PriceDecisioner {
fun decide(apple: Apple): Int
}
interface MelonApplePriceDecisioner : PriceDecisioner {
fun decide(melon: Melon): Int
}
아, 설명했는가
그럼 이제 바나나, 체리, 포도가 추가된 버전도 한 번 설명해보자.
Single Responsibility
우리는 역할이 변화무쌍하다는 걸 인정해야 한다.
시스템이 새로 릴리즈 될 때, 무언가 변경되었기 때문이고 - 그게 퇴보하는 경우는 없다.
즉 어떤 역할들은 지금보다 더 고도화되고, 효율적이기 위해서 더 많은 역할을 만들어낼 것이다.
결국 복잡한 문제를 해결할 기준은 - 책임이다.
이전 코드를 보고, 할인 이벤트를 다시 적용해보자.
interface AppleProvider {
fun provide(id: Long): Apple
}
interface PriceDecisioner {
fun decide(apple: Apple): Int
}
고객에게 할인된 가격을 제공했을 때, 잘못된 가격을 줄 수 있다. (예를 들어 50% 할인해야 되는데 정가에 준 경우)
과연 누가 책임을 져야할까?
만일 여기서 책임을 둘 다에게 묻는다면, 다시 복잡성을 증가시킨다.
이게 바로 단일 책임 원칙을 위배하는 것이다.
만일 책임이 AppleProvider에 있다고 해보자.
즉, 할인 이벤트를 하던지 말던지 잘못된 가격에는 PriceDecisioner의 책임은 없다.
따라서 AppleProvider가 Apple을 제공할 때, 할인된 가격이 반영된 Apple을 제공하는 로직을 추가로 구현해야 된다.
interface AppleProvider {
fun provide(id: Long): Apple
}
interface DiscountAppleProvider : AppleProvider {
fun provide(id: Long, discountRate: Float): Apple
}
반대로 PriceDecisioner이라고 해보자.
즉, 할인 이벤트를 하던지 말던지 잘못된 가격에는 AppleProvider의 책임은 없다.
interface PriceDecisioner {
fun decide(apple: Apple): Int
}
interface DiscountPriceDecisioner : PriceDecisioner {
fun decide(apple: Apple, discountRate: Float): Int
}
이렇게 책임 소재를 물으니 답이 너무 명확해졌다.
후자의 경우만 바라본다면, 할인된 가격을 잘못 준 책임은 오롯이 'DiscountPriceDecisioner'에만 있다.
이제 할인 이벤트가 끝나고, 기존 정가만 주는 걸로 돌아왔다고 가정했을 때, 잘못된 가격을 주는 일이 발생했다고 해보자.
이때 DiscountPriceDecisioner의 책임은 없다. 왜냐하면 그건 PriceDecisioner의 잘못이기 때문이다!
이제 PriceDecisioner 책임이라고 하고, 다양한 과일을 다루는 문제로 돌아와보자.
interface PriceDecisioner {
fun decide(apple: Apple): Int
fun decide(melon: Melon): Int
}
interface PriceDecisioner {
fun decide(apple: Apple): Int
}
interface MelonPriceDecisioner {
fun decide(melon: Melon): Int
}
interface PriceDecisioner {
fun decide(apple: Apple): Int
}
interface MelonApplePriceDecisioner : PriceDecisioner {
fun decide(melon: Melon): Int
}
정답은 3번이다.
1번은 사과 뿐 아니라 멜론의 가격에 대한 책임을 물을 수 있다. 따라서 단일 책임 원칙에 위배된다.
2번은 OOP의 상속을 활용하지 못한다. 굳이 그럴 필요는 없다.
3번에 있어서 사과의 가격이 잘못되었다면, 그건 PriceDecisioner의 책임이다. 반대로 멜론의 가격이 잘못되었다면 MelonApplePriceDecisioner의 책임이다. 각각 사과의 가격과 멜론의 가격만을 결정할 책임만 있기 때문이다.
Constructor vs Static Factory Method
필자는 단일 책임 원칙을 설명할 때, 생성자와 정적 팩토리 메서드를 비교하곤한다.
생성자의 책임은, 최초 인자를 받아서 그대로 객체의 속성을 초기화하는 책임만 가지고 있다.
class Apple private constructor(
val id: Long,
val price: Long
) {
constructor(id: Long, price: Int) =
this(id, price)
}
혹여나 내가 전달한 price가 Apple 객체의 price와 다르다면, 누구에게 책임을 물을 것인가.
생성자에게 책임을 물을 것이다. '왜 그대로 초기화를 안 시켰냐고'
따라서 이런 코드는 잘못되었다.
class Apple private constructor(
val id: Long,
val price: Long
) {
constructor(id: Long, price: Int, discountRate: Float) =
this(id, price * discountRate)
}
여기에는 생성자에게 초기화 뿐만 아니라 price를 계산해야되는 책임까지 가져야 하기 때문이다.
즉, 우리가 만일 price가 정가가 아닐 때 생성자에게 따져 물어야 하는 아이러니한 상황이 발생한다.
반면 정적 팩토리 메서드는 본인 클래스의 객체를 만들어 주지만, 로직을 넣어줘도 된다.
class Apple private constructor(
val id: Long,
val price: Long
) {
companion object {
fun withDiscountPrice(id: Long, price: Int, discountRate: Int): Apple {
val newPrice: Int = price * discountRate
return Apple(id, newPrice)
}
}
constructor(id: Long, price: Int) =
this(id, price)
}
withDiscountPrice(...) 함수는 최초 인자를 받아서 객체의 속성을 초기화하는 책임이 없다. 그건 생성자의 책임이다.
본인은 price를 만들 때 할인된 가격을 할당하는 책임만 있다.
그렇다면 이런 코드는 어떨까?
class Apple private constructor(
val id: Long,
val price: Long
) {
companion object {
fun of(id: Long, price: Int) =
Apple(id, price)
}
constructor(id: Long, price: Int) =
this(id, price)
}
이것도 단일 책임 원칙을 위반한다.
of(...) 함수는 어떠한 책임도 없기 때문이다.
한 가지 이유만으로 of(...) 함수를 바꿔야 하는데, of(...) 함수 구현은 없으니 바꿀 이유도 없다.
같은 원리로, 사실 constructor(...)도 단일 책임 원칙을 위반한다.
이미 코틀린이 저 속성들에 대해 생성자를 기본으로 만들어주기 때문이다.
따라서 이렇게 만들어야 한다.
class Apple(
val id: Long,
val price: Long
)
Modern Responsibility Principle
현대에 와서는 고전적 의미의 단일 책임 원칙이 잘 지켜지고 있을까?
필자도 그렇듯, 그렇지 않다고 대답한다.
다음은 spring-data-jpa 라이브러리 중 JpaRepository 인터페이스 코드이다.
@NoRepositoryBean
public interface JpaRepository<T, ID> extends ... {
void flush();
<S extends T> S saveAndFlush(S entity);
<S extends T> List<S> saveAllAndFlush(Iterable<S> entities);
void deleteAllInBatch(Iterable<T> entities);
...
이 레포지토리는 어떤 책임을 가지고 있을까?
flush()를 해야할 책임, save를 해야할 책임, delete를 해야할 책임... 적어도 단일 책임은 아니다.
단일 책임 원칙에 따르면 FlushRepository, SaveRepository, DeleteRepository, ... 이렇게 설계해야 될 것이다.
그러나 필자를 포함한, 그 어떤 개발자도 이렇게 개발하지는 않는다.
그렇다면 단일 책임 원칙의 경계가 없어진 것일까?
우리는 모듈이 가진 책임의 가짓수와 생산성 사이에서 적당히 타협해야 된다고 본다.
여기서 생산성이란 당연히 우리가 모듈을 포함한 전체 프로젝트를 개발하여 발전시키는 노력을 의미한다.
따라서 모듈이 책임을 n개 가지고 있어도, n-1개 가졌을 때보다 더 생산성이 높다면 얼마든지 n개 가져도 된다.
예를 들어 JpaRepository 인터페이스는 ListCrudRepository를 상속받는다.
public interface JpaRepository<T, ID> extends ListCrudRepository<T, ID>, ...
JpaRepository가 List 자료형을 조회하고, 변경하고, 생성할 책임까지 모두 가지고 있어도 되지만,
그것을 따로 분리했을 때 생산성이 더 높다면 그렇게 해도 되는 것이다.
만일 DeleteCrudRepository를 만들어서 따로 분리했을 때 생산성이 더 높다면 그렇게 해도 되고, 아니라면 마는 것이다.
그리고 필자는 생산성을 판단할 주요한 잣대가, 바로 목적이라고 본다.
목적 책임 원칙, 소프트웨어 모듈은 한 가지 목적을 완수할 이유로만 변경되어야 한다는 것이다.
우리는 이미 아래 클래스를 마주했었다.
interface PriceDecisioner {
fun decide(apple: Apple): Int
}
interface DiscountPriceDecisioner : PriceDecisioner {
fun decide(apple: Apple, discountRate: Float): Int
}
PriceDecisioner을 변경하는 건 정가를 결정할 목적으로 변경할 것이다.
DiscountPriceDecisioner을 변경하는 건 할인가를 결정할 목적으로 변경할 것이다.
이건 어떨까?
interface PriceDecisioner {
fun decide(apple: Apple): Int
fun decide(melon: Melon): Int
}
PriceDecisioner을 변경하는 건 정가를 결정할 목적으로 변경한다는 데 토를 달 수는 없다.
사과든 멜론이든 잘못된 정가를 결정한 책임을 묻는다면, PriceDecisioner에게만 따져야 할 것이다.
그렇다, 현대에 이르러서 필자는 이런 식의 인터페이스도 용인해야 된다는 입장이다. (물론 더 나은 설계도 있다)
그렇다면 함수 단위는 어떨까. 아래 saveAllAndFlush() 함수를 보자.
@NoRepositoryBean
public interface JpaRepository<T, ID> extends ... {
void flush();
<S extends T> S saveAndFlush(S entity);
<S extends T> List<S> saveAllAndFlush(Iterable<S> entities);
void deleteAllInBatch(Iterable<T> entities);
...
내부 구현에는 ListCrudRepository의 saveAll()을 호출하고, flush() 함수를 호출할 것이다. (마치 퍼사드처럼)
이 함수는 엔티티들을 저장할 목적을 가지고 있다. 그리고 그 과정에서 많은 경우를 핸들링해줄 것이다.
예를 들어 다음과 같은 코드를 작성하고 실행했다고 해보자.
val apples = listOf(Apple(...), Apple(...))
apples[0].price = 3000
appleRepository.saveAllAndFlush(apples)
테이블을 열어보니 한 개만 저장되어 있었다면 누구에게 책임을 물을 것인가?
엔티티들을 충실하게 저장해야 할 목적을 가진 친구는 3번째 라인인 걸 쉽게 찾을 수 있다.
그러나 여전히 그게 saveAll의 책임인지, flush의 책임인지 따질 수 없다.
하지만 그게 아래 코드보다 더 생산적이라면, 우리는 saveAllAndFlush() 함수의 단일 책임들이 아닌, 목적에 대한 단 한 가지 책임을 바라보아야 하는 것이다.
val apples = listOf(Apple(...), Apple(...))
apples[0].price = 3000
appleRepository.saveAll(apples)
appleRepository.flush()
'좋은 코드 (Good Code) > 좋은 설계 (Good Design)' 카테고리의 다른 글
| 제어를 역전하면 할수록 쓸만해진다. (0) | 2025.11.10 |
|---|---|
| 나쁜 설계는 다시 시작할 수 있다 (0) | 2025.11.05 |
| 인증 & 인가 서비스를 위한 Swagger 기반의 ERD 그리기 2 (0) | 2024.05.14 |
| 인증 & 인가 서비스를 위한 Swagger 기반의 ERD 그리기 1 (0) | 2024.05.14 |
| 좋은 설계를 시작하면서 (0) | 2024.05.14 |