코딩 · 디자인 패턴
예제로 이해하는 SOLID 원칙
핵심 답변
SOLID는 객체 지향 설계의 다섯 가지 원칙인 단일 책임(Single responsibility), 개방-폐쇄(Open/closed), 리스코프 치환(Liskov substitution), 인터페이스 분리(Interface segregation), 의존 역전(Dependency inversion)의 머리글자입니다. Robert C. Martin이 정리했고 약어는 Michael Feathers가 만들었습니다. 이 원칙들은 코드를 바꾸기 쉽게 만드는 것이 목표입니다. 각 클래스는 변경 이유가 하나뿐이고, 새 동작은 기존 코드를 고치지 않고 추가하며, 하위 타입은 안전하게 대체할 수 있고, 인터페이스는 작게 유지하며, 상위 수준 코드는 추상화에 의존합니다.
SOLID의 의미
| 글자 | 원칙 | 한 줄 규칙 |
|---|---|---|
| S | 단일 책임 원칙 (SRP) | 클래스는 변경 이유가 하나뿐이어야 한다. |
| O | 개방-폐쇄 원칙 (OCP) | 소프트웨어는 확장에는 열려 있고 수정에는 닫혀 있어야 한다. |
| L | 리스코프 치환 원칙 (LSP) | 하위 타입은 상위 타입이 기대되는 모든 곳에서 쓸 수 있어야 한다. |
| I | 인터페이스 분리 원칙 (ISP) | 클라이언트가 사용하지 않는 메서드에 의존하도록 강요해서는 안 된다. |
| D | 의존 역전 원칙 (DIP) | 구체적인 세부 사항이 아니라 추상화에 의존하라. |
이 아이디어들은 약어보다 오래되었습니다. 개방-폐쇄 원칙은 Bertrand Meyer의 책 Object-Oriented Software Construction(1988)에서, 치환 원칙은 Barbara Liskov의 1987년 기조연설 Data Abstraction and Hierarchy에서 나왔습니다. Robert C. Martin이 2000년경 글에서 다섯 가지를 모두 설명했고, 이후 Michael Feathers가 SOLID라는 단어로 배열했습니다.
S: 단일 책임 원칙
클래스는 변경 이유가 하나여야 합니다. 즉 하나의 이해관계자 또는 하나의 관심사만 담당해야 합니다. 비즈니스 규칙, 서식, 저장을 한 클래스가 섞어 처리하면 그중 무엇을 바꿔도 나머지가 깨질 위험이 있습니다.
변경 전: 한 클래스가 보고서를 계산하고, 서식을 만들고, 저장합니다.
class Report {
constructor(private sales: number[]) {}
total(): number { return this.sales.reduce((a, b) => a + b, 0); }
toHtml(): string { return `<p>Total: ${this.total()}</p>`; }
saveToFile(path: string): void { /* write this.toHtml() to disk */ }
}변경 후: 각 관심사를 작은 단위로 나눴으므로 새 출력 형식을 추가해도 계산 코드를 건드리지 않습니다.
class SalesReport {
constructor(private sales: number[]) {}
total(): number { return this.sales.reduce((a, b) => a + b, 0); }
}
class HtmlFormatter {
format(r: SalesReport): string { return `<p>Total: ${r.total()}</p>`; }
}
class ReportWriter {
save(content: string, path: string): void { /* write to disk */ }
}
const html = new HtmlFormatter().format(new SalesReport([10, 20, 5]));
console.log(html); // <p>Total: 35</p>O: 개방-폐쇄 원칙
이미 동작하고 테스트된 코드를 고치지 않고, 새 코드를 추가하는 것으로 새 동작을 더할 수 있어야 합니다. 보통 인터페이스와 다형성을 이용합니다.
변경 전: 도형이 늘 때마다 area 함수를 고쳐야 합니다.
type Shape = { kind: "circle"; r: number } | { kind: "square"; side: number };
function area(s: Shape): number {
if (s.kind === "circle") return Math.PI * s.r ** 2;
if (s.kind === "square") return s.side ** 2;
throw new Error("unknown shape");
}변경 후: 새 도형은 새 클래스로 추가하고 기존 코드는 그대로 둡니다.
interface Shape { area(): number; }
class Circle implements Shape {
constructor(private r: number) {}
area(): number { return Math.PI * this.r ** 2; }
}
class Square implements Shape {
constructor(private side: number) {}
area(): number { return this.side ** 2; }
}
const totalArea = (shapes: Shape[]) => shapes.reduce((sum, s) => sum + s.area(), 0);
console.log(totalArea([new Square(2), new Square(3)])); // 13L: 리스코프 치환 원칙
코드가 상위 타입으로 동작한다면 어떤 하위 타입을 넘겨도 계속 동작해야 합니다. 하위 타입은 사전 조건을 강화하거나, 보장을 약화하거나, 상위 타입이 던지지 않는 곳에서 예외를 던져서는 안 됩니다.
변경 전: 고전적인 정사각형/직사각형 문제입니다. Square가 Rectangle을 상속하지만 너비와 높이가 서로 독립이라는 기대를 깨뜨립니다.
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 stretch(r: Rectangle): number {
r.setWidth(5);
r.setHeight(4);
return r.area(); // caller expects 20
}
console.log(stretch(new Rectangle(1, 1))); // 20
console.log(stretch(new Square(1, 1))); // 16, surprise변경 후: 이 관계를 상속으로 모델링하지 않습니다. 두 타입이 읽기 전용 공통 인터페이스를 구현하며, 정사각형의 변을 독립적으로 바꿀 거라고 기대하는 쪽이 없습니다.
interface Shape { area(): number; }
class Rectangle implements Shape {
constructor(readonly width: number, readonly height: number) {}
area(): number { return this.width * this.height; }
}
class Square implements Shape {
constructor(readonly side: number) {}
area(): number { return this.side ** 2; }
}
console.log([new Rectangle(5, 4), new Square(4)].map((s) => s.area())); // [ 20, 16 ]I: 인터페이스 분리 원칙
큰 인터페이스는 클래스가 지원할 수 없는 메서드까지 구현하게 만들고, 대개 오류를 던지는 식으로 때웁니다. 인터페이스를 작고 초점이 분명한 것으로 쪼개고, 각 클래스는 실제로 하는 일만 구현하게 하세요.
변경 전: 단순한 프린터가 스캔과 팩스도 되는 척해야 합니다.
interface Machine {
print(doc: string): void;
scan(): string;
fax(doc: string): void;
}
class BasicPrinter implements Machine {
print(doc: string): void { console.log("print", doc); }
scan(): string { throw new Error("not supported"); }
fax(doc: string): void { throw new Error("not supported"); }
}변경 후: 조합해서 쓸 수 있는 작은 역할 인터페이스.
interface Printer { print(doc: string): void; }
interface Scanner { scan(): string; }
class BasicPrinter implements Printer {
print(doc: string): void { console.log("print", doc); }
}
class OfficeMachine implements Printer, Scanner {
print(doc: string): void { console.log("print", doc); }
scan(): string { return "scanned page"; }
}
function printAll(p: Printer, docs: string[]): void { docs.forEach((d) => p.print(d)); }
printAll(new BasicPrinter(), ["a.pdf"]); // print a.pdfD: 의존 역전 원칙
상위 수준 정책은 특정 데이터베이스나 이메일 서비스 같은 하위 수준 세부 사항에 의존해서는 안 됩니다. 둘 다 추상화에 의존해야 하며, 보통 그 추상화는 상위 수준 모듈이 소유합니다. 실무에서는 의존 대상을 안에서 만들지 않고 밖에서 넘겨줍니다(의존성 주입).
변경 전: 서비스가 구체적인 데이터베이스 클라이언트를 직접 만들기 때문에 테스트하거나 바꿀 수 없습니다.
class MySqlUserStore {
find(id: string): string { return "user " + id; }
}
class UserService {
private store = new MySqlUserStore(); // hard-wired detail
greet(id: string): string { return "Hello, " + this.store.find(id); }
}변경 후: 서비스가 인터페이스에 의존하므로, 테스트용 가짜 객체를 포함해 어떤 구현이든 주입할 수 있습니다.
interface UserStore { find(id: string): string; }
class UserService {
constructor(private store: UserStore) {}
greet(id: string): string { return "Hello, " + this.store.find(id); }
}
class InMemoryUserStore implements UserStore {
constructor(private users: Record<string, string>) {}
find(id: string): string { return this.users[id] ?? "unknown"; }
}
const service = new UserService(new InMemoryUserStore({ u1: "Ada" }));
console.log(service.greet("u1")); // Hello, AdaSOLID를 적용할 때와 적용하지 않을 때
SOLID 원칙은 자주 바뀌는 코드를 위한 지침이지 법칙이 아닙니다. 무작정 적용하면 구현이 하나뿐인 작은 클래스와 인터페이스가 잔뜩 생겨 오히려 코드를 따라가기 어려워집니다.
- 원칙이 해결하려는 불편을 실제로 느낄 때 적용하세요. 무엇을 바꿔도 깨지는 클래스, 실제 데이터베이스가 있어야 하는 테스트, 여러 곳에 복사된 switch 문 같은 경우입니다.
- 모든 클래스에 미리 인터페이스를 만들어 두지 마세요. 두 번째 구현이나 테스트 더블이 생길 때 추출하면 됩니다.
- 작은 스크립트나 프로토타입에서는 단순하고 직접적인 코드가 대체로 더 낫습니다.
- 많은 디자인 패턴은 SOLID의 구체적인 적용입니다. 전략 패턴과 데코레이터 패턴은 개방-폐쇄를, 어댑터 패턴과 퍼사드 패턴은 세부 사항의 격리를, 추상 팩토리 패턴은 의존 역전을 뒷받침합니다.
자주 묻는 질문
SOLID 원칙은 누가 만들었나요?
Robert C. Martin이 다섯 원칙을 정리하고 설명했으며, 그 바탕에는 Bertrand Meyer(개방-폐쇄)와 Barbara Liskov(치환)의 앞선 연구가 있습니다. SOLID라는 약어는 Michael Feathers가 만들었습니다.
SOLID는 함수형 프로그래밍에도 적용되나요?
아이디어는 그대로 옮겨집니다. 한 가지 목적을 가진 작은 함수, 동작을 고치는 대신 조합하기, 의존성을 매개변수로 넘기기가 그렇습니다. 클래스 기반의 표현은 객체 지향 코드에 한정된 것입니다.
의존 역전과 의존성 주입은 무엇이 다른가요?
의존 역전은 상위 수준 코드가 추상화에 의존해야 한다는 원칙입니다. 의존성 주입은 생성자 등을 통해 그 의존 대상을 바깥에서 공급하는 기법입니다.
SOLID 원칙 중 가장 중요한 것은 무엇인가요?
공식적인 순위는 없습니다. 단일 책임과 의존 역전이 코드를 이해하고 테스트하기 쉽게 만들어 주므로 보통 효과를 가장 빨리 봅니다.
SOLID 원칙을 어기면 나쁜가요?
항상 그렇지는 않습니다. 모든 원칙에는 코드가 늘어나는 비용이 따릅니다. 더 단순한 설계가 더 명확하고 원칙이 막아 주려는 변경이 일어날 가능성이 낮다면 의도적으로 어겨도 됩니다.