동작하는 코드와 오래 사는 코드는 다릅니다. 요구사항은 늘 바뀌고, 좋은 설계는 그 변화를 국소화해요. 이번 장은 SOLID 다섯 원칙, 조합과 상속의 선택, 실무에서 반복되는 디자인 패턴, 그리고 불변·equals·예외까지 — "왜 이렇게 설계하는가"를 파고듭니다.
equals/hashCode 계약·예외 설계·Optional로 견고한 API를 설계해요.SOLID는 로버트 마틴(Uncle Bob)이 정리한 객체지향 설계 5원칙의 머리글자예요. 공통 목표는 하나 — 요구사항이 바뀔 때 고쳐야 할 범위를 최소화하는 것. 각 원칙은 "결합을 줄이고 응집을 높이는" 서로 다른 각도의 처방입니다.
| 약자 | 원칙 | 한 줄 정의 |
|---|---|---|
| S | 단일 책임(SRP) | 클래스가 바뀌어야 할 이유는 단 하나여야 한다 |
| O | 개방-폐쇄(OCP) | 확장엔 열려 있고 변경엔 닫혀 있어야 한다 |
| L | 리스코프 치환(LSP) | 하위 타입은 상위 타입을 대체해도 프로그램이 깨지지 않아야 한다 |
| I | 인터페이스 분리(ISP) | 쓰지 않는 메서드에 의존을 강요하지 마라 |
| D | 의존 역전(DIP) | 고수준·저수준 모두 추상(인터페이스)에 의존하라 |
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) { } }
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; } }
// 새 등급? 새 클래스만 추가하면 됨
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 → 상위 타입 기대를 위반
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){} }
OrderRepository를 Mock으로 갈아끼울 수 있어요.| 지표 | 의미 | 목표 |
|---|---|---|
| 응집도(Cohesion) | 한 모듈 안의 요소들이 얼마나 한 목적으로 뭉쳐 있는가 | 🔼 높을수록 좋음 |
| 결합도(Coupling) | 모듈들이 서로 얼마나 얽혀 있는가 | 🔽 낮을수록 좋음 |
응집이 높으면 바뀔 이유가 한 곳에 모여 수정이 쉽고, 결합이 낮으면 한 모듈을 고쳐도 파급이 작아요. SOLID 원칙 대부분이 결국 이 둘을 좋게 만드는 방법입니다.
// ❌ 상속 남용: 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; }
}
sealed로 계층을 통제할 때)는 상속이 옳아요. 애매하면 조합이 안전한 기본값이고, 그래서 상위 클래스는 상속 금지를 원하면 final로 막는 게 좋습니다.패턴은 공통 어휘예요. "여기 전략 패턴 쓰자"는 한마디로 팀이 같은 그림을 그리죠. 유형은 크게 셋 — 객체를 어떻게 만드는가(생성), 어떻게 조립하는가(구조), 어떻게 협력하는가(행위).
| 분류 | 패턴 | 언제 쓰나 |
|---|---|---|
| 생성 | 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 코드는 그대로
@Transactional·@Async·AOP는 빈을 프록시로 감싸 트랜잭션 시작/커밋·로깅 같은 부가기능을 실제 메서드 앞뒤에 끼워 넣어요(JDK 동적 프록시 또는 CGLIB).JdbcTemplate·RestTemplate은 연결·예외처리·자원반납이라는 뼈대를 고정하고, 변하는 SQL·매핑 로직만 콜백으로 받아요.PlatformTransactionManager·Resource 등 교체 가능한 구현을 인터페이스로 두고 상황에 맞는 빈을 주입합니다.
그래서 스프링을 "안다"는 건 이 패턴들이 어디서 어떻게 개입하는지를 안다는 뜻이에요. 특히 프록시 기반이라 자기호출(self-invocation) 시 @Transactional이 안 먹는 함정이 여기서 나와요.
Comparator(전략), Runnable·콜백(템플릿), Consumer 리스너(옵저버)가 그 예. "패턴을 코드로 무겁게 구현"하기보다 언어 기능으로 가볍게 표현하는 흐름이에요.불변 객체(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()); } // 게터도 복사 (내부 보호)
}
Date 대신 불변인 java.time(Instant·LocalDate)을 쓰면 복사 자체가 필요 없어요.equals는 반사·대칭·추이·일관성을 지켜야 하고, 핵심 규칙은 "a.equals(b)면 반드시 a.hashCode()==b.hashCode()"입니다.
equals만 재정의하고 hashCode를 빼먹으면, 같다고 판단한 두 객체가 다른 버킷으로 가서 HashMap·HashSet에서 값을 못 찾는 버그가 생겨요. record는 이 둘(+toString)을 자동 생성해줍니다.
| 예외 유형 | 성격 | 언제 |
|---|---|---|
| Checked(체크) | 컴파일러가 처리 강제. 복구 가능한 상황 | 호출자가 대응할 수 있고 해야 하는 조건(예: 파일 없음 → 재시도) |
| Unchecked(런타임) | 강제 없음. 프로그래밍 오류 | IllegalArgument·NullPointer 등 계약 위반. 대개 복구 불가 |
| Error | JVM 수준 심각 오류 | OutOfMemoryError 등 — 앱이 잡으려 하지 말 것 |
SQLException)를 그대로 위로 던지면 추상화가 샙니다 — 계층에 맞는 예외(DataAccessException)로 감싸서 던지되 원인(cause)을 보존하세요(스프링이 정확히 이렇게 해요). 그리고 "값이 없을 수 있음"을 표현할 땐 null 대신 Optional을 반환 타입으로 써서 NPE 가능성을 타입으로 드러내세요. 단 Optional은 필드·컬렉션 원소·파라미터로는 쓰지 않는 게 관례예요(빈 컬렉션은 그냥 빈 리스트로).@Transactional)·Template Method(JdbcTemplate)·Strategy(교체 가능한 빈)를 적극 활용.equals/hashCode 함께, 예외 변환, null 대신 Optional(반환용).