SOLID 개요 SRP·OCP LSP·ISP·DIP 응집·결합·조합 디자인 패턴 좋은 객체 설계 📝 문제풀이
◀ 심화 CH 08 📋 목차 심화 CH 10 ▶
🏗️ 심화 CHAPTER 09 · 5년차 트랙

바꾸기 쉬운 코드는 어떻게 설계되는가 — SOLID와 패턴

동작하는 코드와 오래 사는 코드는 다릅니다. 요구사항은 늘 바뀌고, 좋은 설계는 그 변화를 국소화해요. 이번 장은 SOLID 다섯 원칙, 조합과 상속의 선택, 실무에서 반복되는 디자인 패턴, 그리고 불변·equals·예외까지 — "왜 이렇게 설계하는가"를 파고듭니다.

🎯 이 장을 끝내면
🧩
SOLID 개요
설계 원칙은 "변화 비용"을 낮추는 도구
규칙을 외우는 게 아니라, 왜 그 규칙이 변화에 강한지를 이해해요.

SOLID는 로버트 마틴(Uncle Bob)이 정리한 객체지향 설계 5원칙의 머리글자예요. 공통 목표는 하나 — 요구사항이 바뀔 때 고쳐야 할 범위를 최소화하는 것. 각 원칙은 "결합을 줄이고 응집을 높이는" 서로 다른 각도의 처방입니다.

약자원칙한 줄 정의
S단일 책임(SRP)클래스가 바뀌어야 할 이유는 단 하나여야 한다
O개방-폐쇄(OCP)확장엔 열려 있고 변경엔 닫혀 있어야 한다
L리스코프 치환(LSP)하위 타입은 상위 타입을 대체해도 프로그램이 깨지지 않아야 한다
I인터페이스 분리(ISP)쓰지 않는 메서드에 의존을 강요하지 마라
D의존 역전(DIP)고수준·저수준 모두 추상(인터페이스)에 의존하라
🖼️ 그림으로 보기 — SOLID 다섯 원칙 한눈에
S
단일 책임
바뀔 이유는 단 하나여야 해요
O
개방-폐쇄
확장 O, 변경 X
L
리스코프 치환
하위가 상위를 대체해도 OK
I
인터페이스 분리
안 쓰는 메서드 강요 금지
D
의존 역전
양쪽 모두 추상에 의존
💡
원칙은 목적이 아니라 수단이에요. "SOLID를 지켰다"가 목표가 아니라, 변경이 국소화되고 테스트가 쉬워졌는가가 목표예요. 과도한 추상화(불필요한 인터페이스·계층)는 오히려 결합을 늘리고 가독성을 해칩니다 — 원칙을 냄새(smell)를 발견하는 렌즈로 쓰세요.
📐
SRP · OCP
책임을 나누고, 변경 대신 확장하기
가장 자주 어기고, 가장 자주 리팩터링하게 되는 두 원칙.

SRP(단일 책임 원칙) — "한 클래스는 하나의 액터(변경을 요구하는 주체)에게만 책임진다"가 정확한 표현이에요. 아래 Report계산·서식·저장 세 책임을 한 몸에 담아, 서로 다른 이유로 자꾸 바뀝니다.

// ❌ SRP 위반: 계산 + 서식 + 저장이 한 클래스에
class Report {
    double calcTotal() { /* 회계 로직 */ return 0; }
    String toHtml()    { /* 표현 로직 */ return ""; }
    void saveToDb()    { /* 영속 로직 */ }
}

// ✅ 개선: 책임별로 분리 (바뀔 이유가 하나씩)
class ReportCalculator { double calcTotal() { return 0; } }
class ReportRenderer   { String toHtml(Report r) { return ""; } }
class ReportRepository { void save(Report r) { } }
🚪 OCP(개방-폐쇄 원칙) 새 요구사항이 생길 때 기존 코드를 수정하는 대신 새 코드를 추가해 대응할 수 있어야 해요. 핵심 도구는 다형성 — 변하는 부분을 인터페이스 뒤로 숨깁니다. 할인 정책이 늘어날 때마다 if-else를 고치는 대신, DiscountPolicy 구현체를 추가만 하면 기존 코드는 손대지 않아요.
// ❌ OCP 위반: 종류가 늘 때마다 이 메서드를 계속 수정
double discount(String grade, double price) {
    if (grade.equals("GOLD"))   return price * 0.8;
    if (grade.equals("SILVER")) return price * 0.9;
    return price; // 새 등급마다 여기 또 손댐
}

// ✅ 개선: 확장은 새 구현 추가로, 기존 코드는 닫힘
interface DiscountPolicy { double apply(double price); }
class GoldPolicy   implements DiscountPolicy { public double apply(double p){ return p*0.8; } }
class SilverPolicy implements DiscountPolicy { public double apply(double p){ return p*0.9; } }
// 새 등급? 새 클래스만 추가하면 됨
⚠️
OCP는 "모든 것을 미리 추상화하라"가 아니에요. 실제로 변할 축을 식별해 그 축만 열어두는 거예요. 아직 변한 적 없는 곳까지 인터페이스로 감싸면 YAGNI 위반이 됩니다. 보통 "두 번째로 비슷한 변경이 왔을 때" 추상화를 도입하는 게 실용적이에요.
🔄
LSP · ISP · DIP
치환 가능성 · 인터페이스 분리 · 의존 역전
"추상에 의존하라"가 이 셋을 관통해요.

LSP(리스코프 치환 원칙) — 하위 타입은 상위 타입의 계약(사전조건·사후조건·불변식)을 지켜, 어디서든 상위 타입 자리에 넣어도 동작이 깨지지 않아야 해요. 유명한 정사각형-직사각형 문제가 대표적 위반입니다.

// ❌ LSP 위반: Square는 Rectangle의 계약을 깬다
class Rectangle {
    protected int w, h;
    void setW(int w){ this.w = w; }
    void setH(int h){ this.h = h; }
    int area(){ return w * h; }
}
class Square extends Rectangle {          // "정사각형 is-a 직사각형"의 함정
    void setW(int w){ this.w = this.h = w; }   // 너비 바꿨는데 높이도 바뀜
    void setH(int h){ this.w = this.h = h; }
}
// setW(3); setH(4); 후 area()가 12가 아니라 16 → 상위 타입 기대를 위반
🔌 ISP(인터페이스 분리 원칙) "뚱뚱한 인터페이스" 하나 대신 역할별로 잘게 쪼갠 인터페이스를 쓰라는 원칙이에요. 구현체가 필요 없는 메서드까지 억지로 구현(보통 UnsupportedOperationException)하게 만들면 위반입니다. 예: Machine{ print(); scan(); fax(); } 하나보다 Printer·Scanner·Fax로 나누면, 팩스 없는 프린터는 Printer만 구현하면 돼요.

DIP(의존 역전 원칙)고수준 모듈이 저수준 모듈에 직접 의존하지 않고, 둘 다 추상(인터페이스)에 의존해야 해요. 그리고 그 추상을 소유하는 쪽은 고수준입니다("의존의 방향이 역전"된다는 이름의 유래).

// ❌ DIP 위반: 고수준(주문서비스)이 저수준(구체 클래스)에 직접 의존
class OrderService {
    private MySqlOrderRepository repo = new MySqlOrderRepository(); // 콘크리트에 못박힘
}

// ✅ 개선: 양쪽 모두 추상(OrderRepository)에 의존, 구현은 주입
interface OrderRepository { void save(Order o); }
class OrderService {
    private final OrderRepository repo;               // 추상에 의존
    OrderService(OrderRepository repo){ this.repo = repo; } // DI로 주입
}
class MySqlOrderRepository implements OrderRepository { public void save(Order o){} }
💡
DIP와 DI는 다른 층위예요. DIP는 설계 원칙("추상에 의존하라"), DI(의존성 주입)는 그걸 실현하는 기법(생성자로 구현체를 넣어줌)이에요. 스프링 컨테이너는 이 DI를 자동화한 도구고, 덕분에 테스트에서 OrderRepositoryMock으로 갈아끼울 수 있어요.
🧲
응집 · 결합 · 조합
높은 응집, 낮은 결합 — 그리고 "상속보다 조합"
SOLID의 밑바탕에 깔린 두 개의 계기판.
지표의미목표
응집도(Cohesion)한 모듈 안의 요소들이 얼마나 한 목적으로 뭉쳐 있는가🔼 높을수록 좋음
결합도(Coupling)모듈들이 서로 얼마나 얽혀 있는가🔽 낮을수록 좋음

응집이 높으면 바뀔 이유가 한 곳에 모여 수정이 쉽고, 결합이 낮으면 한 모듈을 고쳐도 파급이 작아요. SOLID 원칙 대부분이 결국 이 둘을 좋게 만드는 방법입니다.

🧬 상속(inheritance) vs 조합(composition) 상속is-a(자식은 부모의 한 종류) 관계, 조합has-a(다른 객체를 필드로 품어 위임) 관계예요. Effective Java의 유명한 조언이 "상속보다 조합을 선호하라"입니다. 상속은 부모 구현에 강하게 결합돼요. 부모의 내부 동작이 바뀌면 자식이 조용히 깨지는 깨지기 쉬운 상위 클래스(fragile base class) 문제가 생깁니다. 조합은 공개 API에만 의존하고 위임하므로 훨씬 안정적이에요.
🖼️ 그림으로 보기 — 상속 vs 조합
🧬
상속 (is-a) · 강결합
자식이 부모의 내부 구현에 강하게 묶여요. 부모 동작이 바뀌면 자식이 조용히 깨지는 fragile base class 문제가 생깁니다.
🧩
조합 (has-a) · 유연
다른 객체를 필드로 품어 위임해요. 공개 API에만 의존하므로 내부 구현이 바뀌어도 견고합니다.
// ❌ 상속 남용: HashSet의 내부 구현에 결합 → addAll이 add를 호출하면 이중 카운트
class CountingSet<E> extends HashSet<E> {
    int count = 0;
    public boolean add(E e){ count++; return super.add(e); }
    public boolean addAll(Collection<? extends E> c){ count += c.size(); return super.addAll(c); }
}

// ✅ 조합 + 전달(forwarding): 내부 Set에 위임, 구현 변화에 견고
class CountingSet<E> {
    private final Set<E> delegate;      // has-a
    private int count = 0;
    CountingSet(Set<E> s){ this.delegate = s; }
    public boolean add(E e){ count++; return delegate.add(e); }
    public int count(){ return count; }
}
⚠️
상속을 아예 쓰지 말라는 건 아니에요. 진짜 is-a이고, 상위 클래스가 상속을 염두에 두고 설계·문서화됐을 때(또는 sealed로 계층을 통제할 때)는 상속이 옳아요. 애매하면 조합이 안전한 기본값이고, 그래서 상위 클래스는 상속 금지를 원하면 final로 막는 게 좋습니다.
🏛️
디자인 패턴
반복되는 설계 문제의 검증된 해법
GoF 분류: 생성 · 구조 · 행위. 그리고 스프링이 이걸 어떻게 쓰는가.

패턴은 공통 어휘예요. "여기 전략 패턴 쓰자"는 한마디로 팀이 같은 그림을 그리죠. 유형은 크게 셋 — 객체를 어떻게 만드는가(생성), 어떻게 조립하는가(구조), 어떻게 협력하는가(행위).

분류패턴언제 쓰나
생성Factory Method생성 로직을 캡슐화해, 어떤 구현체를 만들지 서브클래스/설정이 결정
Builder선택 파라미터가 많은 객체를 가독성 있게·불변으로 조립(점층적 생성자·telescoping 회피)
Singleton인스턴스를 단 하나로 보장(설정·레지스트리 등). 스프링 빈은 기본이 싱글턴 스코프
구조Adapter호환 안 되는 인터페이스를 변환해 끼워 맞춤(레거시·외부 API 연동)
Decorator상속 없이 기능을 겹겹이 덧입힘(예: BufferedInputStream)
Proxy실제 객체 앞단에서 접근 제어·지연로딩·부가기능(로깅·트랜잭션)
행위Strategy교체 가능한 알고리즘을 인터페이스로 캡슐화해 런타임에 갈아끼움
Observer상태 변화를 구독자들에게 통지(이벤트·리스너 모델)
Template Method알고리즘 뼈대는 상위가 고정, 변하는 단계만 하위가 채움

Strategy는 OCP를 실현하는 대표 패턴이에요. 앞서 본 DiscountPolicy가 바로 전략 패턴 — 알고리즘을 인터페이스 뒤에 두고 런타임에 주입·교체합니다.

// Strategy: 정렬 기준을 런타임에 교체 (Comparator가 곧 전략)
List<User> users = ...;
Comparator<User> byName = Comparator.comparing(User::name);
Comparator<User> byAge  = Comparator.comparingInt(User::age);
users.sort(byName);   // 전략 주입
users.sort(byAge);    // 다른 전략으로 교체 — sort 코드는 그대로
🖼️ 그림으로 보기 — 전략(Strategy) 패턴 구조
Context list.sort(cmp) 전략 주입 «interface» Comparator<User> byName 이름순 정렬 byAge 나이순 정렬 byEmail 이메일순 정렬 구현체(교체 가능)
🌱 스프링은 이 패턴들의 전시장 Proxy@Transactional·@Async·AOP는 빈을 프록시로 감싸 트랜잭션 시작/커밋·로깅 같은 부가기능을 실제 메서드 앞뒤에 끼워 넣어요(JDK 동적 프록시 또는 CGLIB).
Template MethodJdbcTemplate·RestTemplate연결·예외처리·자원반납이라는 뼈대를 고정하고, 변하는 SQL·매핑 로직만 콜백으로 받아요.
StrategyPlatformTransactionManager·Resource교체 가능한 구현을 인터페이스로 두고 상황에 맞는 빈을 주입합니다. 그래서 스프링을 "안다"는 건 이 패턴들이 어디서 어떻게 개입하는지를 안다는 뜻이에요. 특히 프록시 기반이라 자기호출(self-invocation) 시 @Transactional이 안 먹는 함정이 여기서 나와요.
💡
람다 시대의 패턴. 자바 8 이후 전략·템플릿·옵저버는 굳이 클래스를 만들지 않고 함수형 인터페이스 + 람다로 간결하게 표현돼요. Comparator(전략), Runnable·콜백(템플릿), Consumer 리스너(옵저버)가 그 예. "패턴을 코드로 무겁게 구현"하기보다 언어 기능으로 가볍게 표현하는 흐름이에요.
💎
좋은 객체 설계
불변 · 방어적 복사 · equals/hashCode · 예외 · Optional
Effective Java가 반복해서 강조하는 실무 규칙들.

불변 객체(immutable)는 생성 후 상태가 절대 바뀌지 않는 객체예요(String·Integer·LocalDate·record). 이점이 많아요 — 스레드 안전(공유해도 동기화 불필요), 부작용 없음, 안심하고 캐싱/공유, 방어적 복사 불필요. 만드는 법은 final 필드 + setter 없음 + final 클래스입니다.

// 불변 객체 + 방어적 복사 (가변 필드가 새어 나가지 않게)
final class Period {
    private final Date start, end;   // Date는 가변! 그래서 복사 필요
    Period(Date start, Date end) {
        this.start = new Date(start.getTime());  // 생성자 방어적 복사 (입력 보호)
        this.end   = new Date(end.getTime());
        if (this.start.after(this.end)) throw new IllegalArgumentException();
    }
    public Date start() { return new Date(start.getTime()); } // 게터도 복사 (내부 보호)
}
⚠️
방어적 복사의 순서가 중요해요. 생성자에서는 복사를 먼저 하고, 그 복사본으로 유효성 검사를 해야 해요. 반대로 하면 검사와 저장 사이에 원본이 다른 스레드에서 바뀌는 TOCTOU(Time-Of-Check-To-Time-Of-Use) 공격에 뚫립니다. 애초에 Date 대신 불변인 java.time(Instant·LocalDate)을 쓰면 복사 자체가 필요 없어요.
⚖️ equals / hashCode 계약 둘은 항상 함께 재정의해야 해요. equals반사·대칭·추이·일관성을 지켜야 하고, 핵심 규칙은 "a.equals(b)면 반드시 a.hashCode()==b.hashCode()"입니다. equals만 재정의하고 hashCode를 빼먹으면, 같다고 판단한 두 객체가 다른 버킷으로 가서 HashMap·HashSet에서 값을 못 찾는 버그가 생겨요. record는 이 둘(+toString)을 자동 생성해줍니다.
예외 유형성격언제
Checked(체크)컴파일러가 처리 강제. 복구 가능한 상황호출자가 대응할 수 있고 해야 하는 조건(예: 파일 없음 → 재시도)
Unchecked(런타임)강제 없음. 프로그래밍 오류IllegalArgument·NullPointer계약 위반. 대개 복구 불가
ErrorJVM 수준 심각 오류OutOfMemoryError 등 — 앱이 잡으려 하지 말 것
💡
예외 변환(exception translation)과 Optional. 저수준 예외(SQLException)를 그대로 위로 던지면 추상화가 샙니다 — 계층에 맞는 예외(DataAccessException)로 감싸서 던지되 원인(cause)을 보존하세요(스프링이 정확히 이렇게 해요). 그리고 "값이 없을 수 있음"을 표현할 땐 null 대신 Optional반환 타입으로 써서 NPE 가능성을 타입으로 드러내세요. 단 Optional필드·컬렉션 원소·파라미터로는 쓰지 않는 게 관례예요(빈 컬렉션은 그냥 빈 리스트로).
🧠 이 장 핵심 요약
📝
문제풀이 · 점검
시니어 관점으로 점검
면접·코드리뷰에서 실제로 묻는 결의 문제예요. 틀려도 바로 해설이 나옵니다.
🧪
개념을 "안다"와 "설명할 수 있다"는 달라요. 네 유형(객관식 · O/X · 빈칸 · 코드결과)으로 점검해보세요.
심화 CH 10 📨 메시징과 이벤트 드리븐 — 메시지 큐, 이벤트 소싱, 최종 일관성, 스프링 이벤트 (제작 예정)