SOLID: 변경에 강한 객체지향 설계를 만드는 다섯 가지 원칙
SOLID는 로버트 C. 마틴(Robert C. Martin)이 정리한 객체지향 설계 원칙 다섯 가지의 머리글자다. 단일 책임, 개방-폐쇄, 리스코프 치환, 인터페이스 분리, 의존성 역전 원칙은 코드가 변경에 흔들리지 않도록 결합도(coupling)를 낮추고 응집도(cohesion)를 높이는 기준을 제공한다. 이 글은 각 원칙의 정의와 위반 사례, 개선 코
By 송민성
1. 개념
SOLID는 객체지향 설계에서 유지보수성과 확장성을 높이기 위한 다섯 가지 원칙의 머리글자다.
| 약자 | 원칙 | 한 줄 정의 |
|---|---|---|
| S | 단일 책임 원칙(SRP, Single Responsibility Principle) | 클래스(모듈)는 변경되는 이유가 하나여야 한다 |
| O | 개방-폐쇄 원칙(OCP, Open-Closed Principle) | 확장에는 열려 있고 수정에는 닫혀 있어야 한다 |
| L | 리스코프 치환 원칙(LSP, Liskov Substitution Principle) | 하위 타입은 상위 타입을 대체할 수 있어야 한다 |
| I | 인터페이스 분리 원칙(ISP, Interface Segregation Principle) | 클라이언트는 쓰지 않는 메서드에 의존하지 않아야 한다 |
| D | 의존성 역전 원칙(DIP, Dependency Inversion Principle) | 상위 수준 모듈은 하위 수준 모듈이 아닌 추상화에 의존해야 한다 |
SOLID는 알고리즘이나 문법이 아니라 설계 판단의 기준이다. 법칙처럼 항상 지켜야 하는 규칙이 아니라, 코드가 변경에 취약해지는 신호를 알아보는 도구에 가깝다.
2. 왜 사용하는가
소프트웨어 비용의 대부분은 최초 작성이 아니라 이후의 변경에서 발생한다. 설계가 나쁘면 요구사항 하나를 바꿀 때 여러 파일을 동시에 고치게 되고, 의도하지 않은 곳에서 버그가 생긴다.
SOLID가 줄이려는 문제는 다음과 같다.
- 파급 효과(ripple effect): 한 곳의 수정이 무관한 기능을 깨뜨린다.
- 테스트 어려움: 구체 클래스에 직접 의존해서 단위 테스트에서 DB나 외부 API를 분리할 수 없다.
- 조건문 증식: 새 케이스가 생길 때마다
if/switch가 늘어난다. - 불필요한 의존: 쓰지도 않는 기능 때문에 재컴파일, 재배포, 모킹 부담이 생긴다.
3. 동작 원리
3.1 SRP: 변경의 이유를 기준으로 나눈다
"하나의 일만 한다"가 아니라 "변경을 요구하는 주체(액터)가 하나"라는 뜻이다. 예를 들어 주문 금액 계산 규칙은 기획팀 요구로 바뀌고, 영수증 출력 형식은 운영팀 요구로 바뀐다면 한 클래스에 두지 않는다.
3.2 OCP: 분기 대신 다형성으로 확장한다
새 기능을 추가할 때 기존 코드를 수정하지 않고 새 구현체를 추가하는 구조를 만든다. 핵심 수단은 인터페이스와 다형성(polymorphism)이다.
3.3 LSP: 계약을 지키는 상속
하위 타입은 상위 타입의 사전 조건(precondition)을 강화하면 안 되고, 사후 조건(postcondition)을 약화하면 안 된다. 상위 타입을 쓰는 코드가 하위 타입이 들어와도 올바르게 동작해야 한다. 타입 시그니처만 맞는다고 충족되는 것이 아니라 행위 계약까지 맞아야 한다.
3.4 ISP: 인터페이스를 역할 단위로 쪼갠다
큰 인터페이스 하나보다 역할별 작은 인터페이스 여러 개가 낫다. 구현체는 자신이 지원하는 역할만 구현한다.
3.5 DIP: 의존 방향을 뒤집는다
고수준 정책(비즈니스 로직)이 저수준 세부 사항(DB, 메일 발송, HTTP 클라이언트)에 직접 의존하면 세부 사항 변경이 정책을 흔든다. 고수준 모듈이 인터페이스를 소유하고 저수준 모듈이 그것을 구현하게 하면 의존 방향이 역전된다. 구현체를 외부에서 넣어주는 방식이 의존성 주입(DI, Dependency Injection)이다.
4. 코드 예제
아래 코드는 tsc 또는 npx tsx로 바로 실행할 수 있다.
4.1 SRP + DIP: 주문 서비스
// 위반 예: 계산, 저장, 알림이 한 클래스에 섞여 있다.
class BadOrderService {
place(items: { price: number; qty: number }[]): void {
const total = items.reduce((s, i) => s + i.price * i.qty, 0);
console.log(`[DB] INSERT order total=${total}`); // 저장 세부사항
console.log(`[MAIL] 주문 완료 total=${total}`); // 알림 세부사항
}
}
// 개선: 책임 분리 + 추상화에 의존
interface OrderRepository {
save(total: number): void;
}
interface Notifier {
notify(message: string): void;
}
class ConsoleRepository implements OrderRepository {
save(total: number): void {
console.log(`[DB] INSERT order total=${total}`);
}
}
class ConsoleNotifier implements Notifier {
notify(message: string): void {
console.log(`[MAIL] ${message}`);
}
}
class OrderService {
constructor(
private readonly repo: OrderRepository,
private readonly notifier: Notifier,
) {}
place(items: { price: number; qty: number }[]): void {
const total = items.reduce((s, i) => s + i.price * i.qty, 0);
this.repo.save(total);
this.notifier.notify(`주문 완료 total=${total}`);
}
}
new OrderService(new ConsoleRepository(), new ConsoleNotifier()).place([
{ price: 1000, qty: 2 },
{ price: 500, qty: 1 },
]);테스트에서는 ConsoleRepository 대신 메모리 기반 가짜(fake) 구현을 주입하면 된다.
4.2 OCP: 할인 정책 확장
interface DiscountPolicy {
apply(price: number): number;
}
class NoDiscount implements DiscountPolicy {
apply(price: number): number {
return price;
}
}
class RateDiscount implements DiscountPolicy {
constructor(private readonly rate: number) {}
apply(price: number): number {
return price * (1 - this.rate);
}
}
class FixedDiscount implements DiscountPolicy {
constructor(private readonly amount: number) {}
apply(price: number): number {
return Math.max(0, price - this.amount);
}
}
// checkout은 새 정책이 추가돼도 수정되지 않는다.
function checkout(price: number, policy: DiscountPolicy): number {
return policy.apply(price);
}
console.log(checkout(10000, new NoDiscount())); // 10000
console.log(checkout(10000, new RateDiscount(0.1))); // 9000
console.log(checkout(10000, new FixedDiscount(3000))); // 70004.3 LSP: 계약을 깨는 상속
// 위반 예: 정사각형이 "너비와 높이를 독립적으로 바꿀 수 있다"는 계약을 깬다.
class Rectangle {
constructor(public width: number, public height: number) {}
setWidth(w: number): void { this.width = w; }
setHeight(h: number): void { this.height = h; }
area(): number { return this.width * this.height; }
}
class Square extends Rectangle {
setWidth(w: number): void { this.width = w; this.height = w; }
setHeight(h: number): void { this.width = h; this.height = h; }
}
function resize(r: Rectangle): number {
r.setWidth(5);
r.setHeight(4);
return r.area(); // Rectangle이면 20을 기대
}
console.log(resize(new Rectangle(1, 1))); // 20
console.log(resize(new Square(1, 1))); // 16 -> 치환 시 결과가 달라진다
// 개선: 상속 대신 공통 인터페이스로 모델링
interface Shape {
area(): number;
}
class Rect implements Shape {
constructor(private readonly w: number, private readonly h: number) {}
area(): number { return this.w * this.h; }
}
class Sq implements Shape {
constructor(private readonly side: number) {}
area(): number { return this.side * this.side; }
}
console.log(new Rect(5, 4).area(), new Sq(4).area()); // 20 164.4 ISP: 역할별 인터페이스
// 위반 예: 모든 기기가 모든 기능을 구현해야 한다.
interface BigMachine {
print(doc: string): void;
scan(doc: string): void;
fax(doc: string): void;
}
// 개선: 역할을 분리
interface Printer { print(doc: string): void; }
interface Scanner { scan(doc: string): void; }
class SimplePrinter implements Printer {
print(doc: string): void { console.log(`print: ${doc}`); }
}
class MultiFunction implements Printer, Scanner {
print(doc: string): void { console.log(`print: ${doc}`); }
scan(doc: string): void { console.log(`scan: ${doc}`); }
}
function printReport(p: Printer): void {
p.print("report"); // Printer 역할만 필요하다
}
printReport(new SimplePrinter());
printReport(new MultiFunction());5. 시간 복잡도 또는 성능 특성
SOLID는 알고리즘이 아니므로 시간 복잡도로 평가하지 않는다. 대신 다음 특성을 이해해 두면 된다.
- 런타임 비용: 인터페이스를 통한 동적 디스패치(dynamic dispatch)는 직접 호출보다 약간의 간접 비용이 있다. 다만 일반적인 웹 서버 애플리케이션에서는 I/O 대기 시간에 비해 무시할 수 있는 수준인 경우가 대부분이다. 성능이 문제라면 추측하지 말고 프로파일링으로 확인한다.
- 코드 구조 비용: 파일과 타입 수가 늘어나고, 코드를 따라가는 단계가 많아진다.
- 변경 비용: 요구사항 변경 시 수정 범위가 좁아지고 테스트 격리가 쉬워진다. SOLID의 이득은 주로 여기에서 나온다.
6. 실무 사용 사례
- 계층형 백엔드 구조: 서비스 계층이
Repository인터페이스에 의존하고, 구현체(PostgreSQL, 인메모리)를 주입한다. NestJS, Spring 같은 프레임워크의 DI 컨테이너가 DIP를 지원한다. - 결제 수단, 알림 채널, 할인 정책: 종류가 계속 늘어나는 영역은 OCP 적용 후보다. 전략 패턴(Strategy Pattern)이 대표적 구현이다.
- 테스트 더블: 외부 API 클라이언트를 인터페이스 뒤에 두면 단위 테스트에서 가짜 구현으로 교체할 수 있다.
- 프론트엔드: React 컴포넌트가 데이터 조회, 변환, 렌더링을 모두 하지 않도록 커스텀 훅과 컴포넌트로 나누는 것은 SRP의 적용이다. props 타입을 필요한 필드만으로 좁히는 것은 ISP와 같은 맥락이다.
7. 주의할 점
- 과잉 추상화: 구현체가 하나뿐이고 변경 가능성도 낮은데 인터페이스부터 만들면 복잡도만 늘어난다. 변경 압력이 실제로 나타났을 때 리팩터링하는 편이 낫다.
- SRP를 "메서드 하나당 클래스 하나"로 오해하지 않는다. 기준은 크기가 아니라 변경의 이유다.
- LSP는 타입 검사로 잡히지 않는다. 하위 클래스에서 예외를 새로 던지거나, 상위 클래스가 보장하던 불변식(invariant)을 깨면 컴파일은 통과해도 위반이다.
- 상속보다 합성(composition): LSP 위반 대부분은 부적절한 상속에서 나온다. "is-a" 관계가 행위까지 성립하는지 확인하고, 아니면 합성을 쓴다.
- DIP와 DI는 같은 말이 아니다. DIP는 의존 방향에 대한 원칙이고, DI는 이를 구현하는 기법 중 하나다.
- 원칙 간 충돌: 원칙을 기계적으로 적용하면 서로 충돌하거나 코드가 불필요하게 분산될 수 있다. 팀의 맥락과 변경 빈도를 기준으로 판단한다.
8. 핵심 정리
- SOLID는 변경 비용을 낮추기 위한 객체지향 설계 기준 다섯 가지다: SRP, OCP, LSP, ISP, DIP.
- SRP는 변경 이유로 책임을 나누고, OCP는 다형성으로 수정 없이 확장하게 한다.
- LSP는 하위 타입이 상위 타입의 행위 계약을 지키도록 요구하며, 컴파일러가 대신 검사해 주지 않는다.
- ISP는 역할별로 작은 인터페이스를 두게 하고, DIP는 고수준 정책이 추상화에 의존하도록 의존 방향을 뒤집는다.
- 원칙은 도구이지 목표가 아니다. 실제 변경 압력이 보이는 지점에 적용하고, 그렇지 않은 곳에서는 단순함을 유지한다.