🟣 패턴보다 먼저, 무엇이 문제였나
디자인 패턴을 배우기 전에 답해야 할 질문이 하나 있다. 도대체 무엇이 문제라서 패턴이라는 게 생겼나. 답은 단순하다. 요구사항이 계속 바뀌기 때문이다. 처음 한 번 잘 짠 코드도 다음 스프린트의 새 요구사항 앞에서 무너진다. 패턴은 그 무너짐을 미리 예상하고, 자주 바뀌는 지점을 미리 떼어 놓는 기법들의 모음이다.
그래서 이 장은 특정 패턴이 아니라 모든 패턴의 밑에 깔린 원칙을 먼저 정리한다. 캡슐화, 구성, 그리고 SOLID다. 이걸 세워 두지 않으면 뒤에 나올 팩토리나 전략이 왜 그런 모양인지 이해가 안 되고, 그냥 클래스 다이어그램 암기로 끝난다.
🟣 상속으로 재사용하려다 생기는 일
객체지향을 배우면 재사용의 첫 도구로 상속을 만난다. 공통 기능을 부모에 올리고 자식이 물려받는다. 처음에는 깔끔해 보인다. 문제는 자식마다 예외가 생기기 시작할 때다.
간단한 예를 들어 본다. 여러 종류의 알림을 보내는 앱이 있고, Notification이라는 부모 클래스에 send()와 formatBody()를 뒀다고 하자. 자식으로 PushNotification, EmailNotification이 물려받는다. 여기까지는 좋다. 그런데 "어떤 알림은 소리를 내고 어떤 알림은 진동만 한다"는 요구가 들어온다. 부모에 playSound()를 넣으면 진동만 하는 자식도 그걸 물려받아 버린다. 자식에서 playSound()를 빈 함수로 덮어써서 막는 식으로 대응하기 시작하면, 그때부터 상속 트리는 예외를 막는 코드로 뒤덮인다.
핵심 문제는 상속이 컴파일 시점에 고정된다는 데 있다. 소리를 낼지 말지는 실행 중에 바뀔 수 있는 행위인데, 상속으로 묶으면 클래스 정의에 못 박힌다. 자주 바뀌는 것을 가장 안 바뀌는 자리에 넣은 셈이다.
빨간 노드가 문제의 냄새다. 물려받았지만 쓰지 않으려고 빈 함수로 덮어쓰는 순간, 그 상속은 잘못 걸려 있다는 신호다.
🟣 변하는 것을 캡슐화한다
해법의 출발점은 한 문장이다. 변하는 것을 찾아서 변하지 않는 것으로부터 분리한다. 알림이라는 개념 자체는 잘 안 바뀐다. 바뀌는 건 "어떻게 소리를 낼 것인가"라는 행위다. 그러면 그 행위를 클래스 계층에서 빼내서 별도의 인터페이스로 세운다.
Swift로 옮기면 이렇다. 소리 내는 행위를 프로토콜로 뽑고, 알림은 그걸 상속이 아니라 속성으로 들고 있는다.
protocol AlertSound {
func play()
}
struct ChimeSound: AlertSound {
func play() { /* 소리 재생 */ }
}
struct SilentSound: AlertSound {
func play() { /* 아무것도 안 함 */ }
}
final class Notification {
private let sound: AlertSound // 상속이 아니라 구성으로 들고 있다
init(sound: AlertSound) {
self.sound = sound
}
func send() {
sound.play() // 무엇을 재생할지는 주입받은 객체가 정한다
// ...
}
}이렇게 하면 소리 정책이 바뀌어도 Notification은 손대지 않는다. SilentSound를 넣으면 조용하고, ChimeSound를 넣으면 소리가 난다. 심지어 실행 중에 교체할 수도 있다. "상속으로 물려받기"를 "인터페이스를 가진 객체를 끼워 넣기"로 바꾼 것이다. 이 끼워 넣기를 구성(composition)이라 부르고, "상속보다 구성을 우선하라"는 말이 여기서 나온다.
한 가지 덧붙이면, 캡슐화는 흔히 "필드를 private으로 감추는 것"으로만 배운다. 하지만 진짜 캡슐화는 데이터를 감추는 게 아니라 바뀔 지점을 감추는 것이다. 위에서 감춘 건 sound라는 필드가 아니라 "소리 정책은 앞으로 바뀔 수 있다"는 사실 그 자체다.
🟣 SOLID: 다섯 개의 냄새 감지기
앞의 판단을 매번 직관으로 하기는 어렵다. SOLID는 그 직관을 다섯 개의 점검 항목으로 정리한 것이다. 규칙이라기보다 "지금 이 코드가 나중에 아플 자리인가"를 감지하는 도구로 읽는 편이 실용적이었다.
단일 책임 원칙(SRP). 한 클래스가 바뀌어야 하는 이유는 하나여야 한다. Notification이 소리 정책이 바뀔 때도 고쳐지고, 전송 프로토콜이 바뀔 때도 고쳐진다면 책임이 둘이다. 앞에서 소리를 빼낸 것이 바로 책임 하나를 떼어 낸 작업이었다.
개방-폐쇄 원칙(OCP). 확장에는 열려 있고 수정에는 닫혀 있어야 한다. 새 소리를 추가할 때 AlertSound를 구현한 타입 하나만 새로 만들면 되고 Notification은 안 고친다. 이게 열려 있고 닫혀 있는 상태다. 뒤에 나올 대부분의 패턴이 결국 이 원칙을 지키기 위한 구체적 방법들이다.
리스코프 치환 원칙(LSP). 자식은 부모 자리에 넣어도 프로그램이 깨지지 않아야 한다. 유명한 반례가 정사각형과 직사각형이다. 직사각형을 상속한 정사각형은 "너비만 바꿔도 높이가 따라 바뀐다"는 예상 밖 동작을 하므로, 직사각형을 기대한 코드에서 오작동한다. 상속 관계가 "말이 되는가"를 넘어 "동작이 대체 가능한가"까지 봐야 한다는 원칙이다.
인터페이스 분리 원칙(ISP). 쓰지도 않는 메서드를 구현하도록 강요하는 큰 인터페이스는 잘게 쪼갠다. 앞의 상속 함정에서 진동만 하는 자식이 playSound()를 빈 함수로 채워야 했던 것이 ISP 위반의 냄새였다. 인터페이스가 잘게 나뉘어 있으면 필요한 것만 구현한다.
의존성 역전 원칙(DIP). 구체적인 것이 아니라 추상적인 것에 의존한다. 이게 다섯 중 가장 실무 파급이 크다.
🟣 의존성 역전: 화살표의 방향을 뒤집다
DIP를 처음 볼 때 "역전"이라는 말이 잘 안 잡혔다. 무엇을 무엇으로 뒤집는다는 건가.
자연스럽게 코드를 짜면 상위 모듈이 하위 모듈을 직접 부른다. Notification이 ChimeSound를 직접 new 해서 쓰는 식이다. 그러면 화살표가 상위에서 하위 구현으로 곧장 꽂힌다. 상위가 하위 구현을 알고 있으니, 하위가 바뀌면 상위도 흔들린다.
DIP는 이 화살표 사이에 인터페이스를 끼우고, 하위가 그 인터페이스를 향하도록 방향을 뒤집는다. Notification은 AlertSound라는 추상만 알고, ChimeSound는 그 추상을 구현하는 쪽에 선다. 이제 상위도 하위도 둘 다 추상을 바라본다. 하위 구현을 아무리 갈아끼워도 상위는 모른다.
오른쪽 그림에서 ChimeSound의 화살표가 위를 향한다. 이게 "역전"이다. 원래는 상위가 하위를 가리켰는데, 이제 하위가 추상을 가리키러 올라온다. 이 방향 덕분에 상위 모듈은 구체 구현이 아무리 늘어나도 코드가 그대로다.
DIP가 실무에서 값어치를 하는 자리는 테스트다. Notification이 AlertSound라는 추상에만 의존하니, 테스트에서는 진짜 소리 대신 "재생됐는지만 기록하는" 가짜 구현을 넣을 수 있다. 이것이 의존성 주입(DI)과 목(mock)의 기반이고, 구체 타입에 못 박아 두면 이런 대체가 아예 불가능하다.
🟣 정리
- 패턴이 존재하는 이유는 요구사항이 바뀌기 때문이다. 모든 패턴은 자주 바뀌는 지점을 인터페이스 뒤로 격리하는 같은 동작을 대상만 바꿔 반복한다.
- 상속으로 행위를 재사용하면 컴파일 시점에 고정되어 예외를 막는 코드가 쌓인다. 행위는 프로토콜로 뽑아 구성으로 끼워 넣는 편이 실행 중 교체까지 가능하다.
- SOLID는 규칙이 아니라 "이 코드가 나중에 아플 자리인가"를 감지하는 다섯 개의 점검 항목이다. 특히 OCP는 뒤에 나올 패턴 대부분이 지키려는 목표다.
- 의존성 역전은 상위와 하위 사이에 추상을 끼워 화살표 방향을 뒤집는다. 구체 타입 대신 프로토콜에 의존하면 구현을 갈아끼워도 상위가 흔들리지 않는다.
- iOS 실무로 옮기면 이 원칙들이 그대로 나타난다. UIKit의
UITableViewDataSource는 인터페이스 분리와 의존성 역전의 사례이고, 프로토콜 기반 DI는 XCTest에서 목을 주입하는 표준 방식이다. 면접에서 "상속과 구성 중 언제 무엇을 쓰나"를 물으면, 여기서 정리한 "변하는 것을 격리한다"로 답하면 이야기가 한 단 더 깊어진다.
원칙은 세웠다. 그런데 아무리 추상에 의존하려 해도, 어딘가에서는 결국 구체 타입을 new 해야 한다. 그 생성 지점이 코드 여기저기 흩어져 있으면 DIP는 다시 새어 나간다. 다음 장은 그 생성 자체를 격리하는 방법, 생성 패턴이다.