SOLID 원칙
- 3.1 SRP와 흔한 오해
- 3.2 우발적 중복과 병합
- 3.3 SRP 해결책
- 3.4 OCP 사고 실험
- 3.5 방향성 제어와 정보 은닉
- 3.6 LSP와 상속의 활용
- 3.7 정사각형/직사각형 문제
- 3.8 LSP와 아키텍처
- 3.9 ISP와 불필요한 의존
- 3.10 DIP와 안정된 추상화
- 3.11 추상 팩토리와 구체 컴포넌트
SOLID는 함수 수준의 원칙이 아니야. 컴포넌트와 아키텍처 수준에서 진짜 힘을 발휘하는 원칙들이지.
첫 번째, SRP -- 단일 책임 원칙(Single Responsibility Principle). SRP에 대한 가장 흔한 오해부터 잡고 가야 해. "모든 모듈은 단 하나의 일만 해야 한다" -- 이건 SRP가 아니야. 이건 함수에 대한 원칙이지, 모듈에 대한 원칙이 아니거든. SRP의 실제 정의는 이거야: 하나의 모듈은 하나의, 오직 하나의 액터(actor)에 대해서만 책임져야 한다. "하나의 일"이 아니라 "하나의 변경 이유"에 관한 원칙이라는 게 핵심이지.
SRP를 위반하면 두 가지 징후가 나타나. 첫째, 우발적 중복. Employee 클래스에 calculatePay(), reportHours(), save() 세 메서드가 있다고 치자. calculatePay는 CFO 조직이, reportHours는 COO 조직이, save는 CTO 조직이 요구해. 세 액터가 하나의 클래스에 결합되어 있으니, CFO 팀이 공유 알고리즘을 변경했는데 그게 reportHours에도 영향을 미치는 상황이 생기지. 둘째, 병합 충돌. 같은 소스 파일을 서로 다른 팀이 동시에 수정하면 충돌이 발생해.
해결책은 데이터와 함수를 분리하는 거야. PayCalculator, HourReporter, EmployeeSaver -- 각 액터에 대한 메서드를 별도 클래스로 분리해. 세 클래스가 서로를 모르니까 우발적 중복도 없고 병합 충돌도 줄어들지. 퍼사드(Facade) 패턴을 쓰면 하나로 묶어주는 편의 인터페이스도 제공할 수 있어. SRP는 컴포넌트 수준에서는 **공통 폐쇄 원칙(CCP)**으로, 아키텍처 수준에서는 경계를 만드는 변경의 축으로 나타나.
두 번째, OCP -- 개방-폐쇄 원칙(Open-Closed Principle). 소프트웨어 개체는 확장에는 열려 있어야 하고, 변경에는 닫혀 있어야 해. 사고 실험을 해보자. 재무 보고서를 웹 페이지에 표시하는 시스템이 있는데, "같은 데이터를 흑백 프린터로 출력해줘"라는 요청이 들어와. 좋은 아키텍처라면 기존 코드를 얼마나 수정해야 할까? 이상적으로는 제로야. 새로운 코드를 추가하는 것만으로 새 기능이 동작해야 하지.
어떻게? 비즈니스 규칙(Interactor)이 가장 보호받아야 할 핵심이고, Controller가 입력을 받아서 전달하고, Presenter가 결과를 가공하고, View가 표시해. 의존성 방향이 중요해 -- 모든 의존성이 비즈니스 규칙 쪽으로 향해야 해. 새 출력 방식을 추가할 때 Presenter와 View만 만들면 되고 Interactor는 건드릴 필요 없지. 방향성 제어를 위해 인터페이스를 사용해. Interactor가 정의한 출력 인터페이스를 Presenter가 구현하면 소스 코드 의존성이 제어 흐름과 반대 방향으로 가게 돼. 정보 은닉도 중요해 -- 추이 종속성(transitive dependency)을 차단해서 컴포넌트들이 서로의 내부를 알 필요 없게 만들어야 하지.
세 번째, LSP -- 리스코프 치환 원칙(Liskov Substitution Principle). 부모 클래스를 쓰는 곳에 자식 클래스를 넣어도 문제없이 동작해야 한다는 거야. 전형적인 예시로 License 인터페이스에 calcFee() 메서드가 있고, PersonalLicense와 BusinessLicense가 이를 구현하면 -- 어떤 구현체가 오든 신경 쓰지 않고 동작해야 해.
LSP 위반의 고전적 예시가 정사각형/직사각형 문제야. 정사각형을 직사각형의 하위 타입으로 만들면 문제가 생기지. 직사각형은 너비와 높이를 독립적으로 설정할 수 있는데, 정사각형은 너비를 바꾸면 높이도 같이 바뀌어야 하니까. if문으로 타입을 체크해야 하는 순간 LSP가 깨진 거야. LSP는 단순한 상속 가이드를 넘어서 아키텍처 수준에서도 적용돼. 저자가 택시 파견 서비스 사례를 드는데, 여러 택시 회사가 같은 REST API를 구현하는데 한 회사가 destination 대신 dest를 써. 이런 예외가 쌓이면 아키텍처가 오염되지.
네 번째, ISP -- 인터페이스 분리 원칙(Interface Segregation Principle). User1이 op1만 쓰고, User2가 op2만 쓰고, User3이 op3만 쓰는데 세 오퍼레이션이 하나의 클래스에 다 들어있으면 문제야. op2가 바뀌면 User1도 재컴파일/재배포해야 할 수 있거든. 안 쓰는 것에 의존하고 있기 때문이지. 해결은 인터페이스를 분리하는 거야. 각 사용자는 자기가 쓰는 오퍼레이션만 포함된 인터페이스에 의존하면 돼. 정적 타입 언어에서 특히 중요한 원칙이야. 아키텍처 수준에서도 마찬가지야 -- 불필요한 의존성은 아키텍처 수준에서도 문제를 만들어.
다섯 번째, DIP -- 의존성 역전 원칙(Dependency Inversion Principle). 소스 코드 의존성이 추상에 의존해야지, 구체에 의존하면 안 돼. 자바로 말하면 import문이 인터페이스나 추상 클래스를 가리켜야지, 구체 클래스를 가리키면 안 된다는 뜻이야. 물론 String 같은 안정적인 구체 클래스는 의존해도 괜찮아. DIP가 말하는 건 변동성이 큰(volatile) 구체 클래스에 의존하지 말라는 거야.
안정된 추상화 -- 인터페이스는 구현체보다 변경이 적어. 좋은 아키텍트는 인터페이스의 변동성을 줄이려고 노력하지. 코딩 실천법으로는: 변동성이 큰 구체 클래스를 참조하지 마라, 구체 클래스로부터 파생하지 마라, 구체 함수를 오버라이드하지 마라.
객체를 생성하려면 어쩔 수 없이 구체 클래스에 의존해야 하는 순간이 있잖아. 이걸 해결하는 게 추상 팩토리(Abstract Factory) 패턴이야. 인터페이스에 생성 메서드를 정의하고, 구체적인 생성 로직은 구현체에 넣어. 여기서 아키텍처 경계선이 생기지. 추상 컴포넌트(비즈니스 규칙)와 구체 컴포넌트(구현 세부사항)가 나뉘고, 소스 코드 의존성은 항상 추상 쪽으로 향해. DIP 위반은 완전히 없앨 수 없고, 구체적 의존성을 소수의 구체 컴포넌트에 집중시키는 게 전략이야. 대부분의 시스템에는 최소한 하나의 구체 컴포넌트가 존재하는데, 흔히 이를 main이라 부르지.
정리
3장 읽고 기억할 거 세 가지:
- SRP는 "하나의 일"이 아니라 "하나의 액터"에 대한 원칙이야. 같은 변경 이유를 가진 코드를 함께 묶고, 다른 변경 이유를 가진 코드는 분리해. 이게 컴포넌트 수준의 CCP, 아키텍처 수준의 경계로 확장되지.
- OCP와 DIP는 의존성 방향을 제어하는 쌍둥이야. 인터페이스를 끼워 넣어서 소스 코드 의존성을 제어 흐름과 반대로 만들면, 고수준 정책이 저수준 세부사항의 변경으로부터 보호받게 돼.
- LSP와 ISP는 "필요 없는 것에 의존하지 마라"의 변주야. 치환 가능성이 깨지면 예외 처리가 시스템을 오염시키고, 불필요한 의존은 예상치 못한 재컴파일과 재배포를 유발하지.