
SOLID 원칙: 똥 코드를 피하는 5가지 십계명
객체지향의 거장 로버트 마틴(Uncle Bob)이 정립한 5가지 설계 원칙. SRP, OCP, LSP, ISP, DIP가 무엇인지, 왜 지켜야 하는지, 실제 타입스크립트 예제로 정리해본다.

객체지향의 거장 로버트 마틴(Uncle Bob)이 정립한 5가지 설계 원칙. SRP, OCP, LSP, ISP, DIP가 무엇인지, 왜 지켜야 하는지, 실제 타입스크립트 예제로 정리해본다.
내 서버는 왜 걸핏하면 뻗을까? OS가 한정된 메모리를 쪼개 쓰는 처절한 사투. 단편화(Fragmentation)와의 전쟁.

로버트 C. 마틴(Uncle Bob)이 제안한 클린 아키텍처의 핵심은 무엇일까요? 양파 껍질 같은 계층 구조와 의존성 규칙(Dependency Rule)을 통해 프레임워크와 UI로부터 독립적인 소프트웨어를 만드는 방법을 정리합니다.

미로를 탈출하는 두 가지 방법. 넓게 퍼져나갈 것인가(BFS), 한 우물만 팔 것인가(DFS). 최단 경로는 누가 찾을까?

이름부터 빠릅니다. 피벗(Pivot)을 기준으로 나누고 또 나누는 분할 정복 알고리즘. 왜 최악엔 느린데도 가장 많이 쓰일까요?

코드 리뷰 시간, 시니어가 제 코드를 보더니 혀를 찼습니다.
"이거...
UserService가 회원가입, 이메일 발송, 로그 저장, 결제 처리까지 다 하네?"
저: "네, 한 파일에 다 있으니까 보기 편하잖아요."
시니어: "넌 지금 SOLID 원칙을 5개 다 어겼어. 이렇게 짜면 나중에 이메일 모듈 하나 바꿀 때 회원가입 기능까지 다 뜯어고쳐야 해."
그때까지 저는 "코드는 짧을수록, 파일은 적을수록 좋다"라는 착각을 하고 있었습니다. 1,000줄짜리 클래스가 5개보다 200줄짜리 클래스 하나가 낫다고 생각했죠. 하지만 이건 완전히 틀렸습니다. 코드의 길이가 아니라 "변경했을 때 얼마나 안전한가"가 중요했던 겁니다. 이메일 전송 로직 하나 바꾸려고 회원가입 전체를 다시 테스트해야 한다면, 그 코드는 이미 망가진 설계입니다.
SOLID 원칙을 처음 듣고 나서도 한동안 이해가 안 갔습니다. "단일 책임 원칙? 그럼 함수 하나당 줄 하나씩만 써야 하나?" 같은 오해를 하기도 했죠. 하지만 실제로 여러 번 삽질을 겪고 나니 결국 이거였다라는 깨달음이 왔습니다.
소프트웨어는 Software입니다. 하드웨어와 달리 변경하기 쉬워야(Soft) 합니다. 하지만 코드가 엉켜있으면 변경할 때마다 버그가 터지는 Fragile(잘 부서지는) 상태가 됩니다.
저희 서비스에서 결제 수단을 추가하는 작업이 있었습니다. 카카오페이를 추가하면 되는 간단한 일이었죠. 그런데 PaymentService 클래스가 1,500줄이었고, 신용카드/토스/네이버페이 로직이 if-else로 도배되어 있었습니다. 카카오페이 로직을 추가했는데 기존 토스 결제가 깨졌습니다. 왜? PaymentService의 한 변수를 여러 곳에서 공유하고 있었기 때문입니다. 이게 바로 Tight Coupling(강한 결합) 때문에 생긴 문제였습니다.
SOLID는 "변경에 유연하고, 이해하기 쉽고, 재사용 가능한" 소프트웨어를 만들기 위한 5가지 원칙입니다. 로버트 마틴(Uncle Bob)이 정립했고, 현대 객체지향 설계의 기초가 되었죠. 이 원칙들을 알고 나니, 제가 짠 코드가 왜 그렇게 자주 망가졌는지 와닿았습니다.
"클래스는 단 하나의 변경 이유만 가져야 한다."
SRP는 SOLID 중에서도 가장 직관적이지만, 실제로 지키기는 가장 어렵다고 받아들였습니다. "책임"이라는 단어가 애매하기 때문입니다. "회원가입 로직을 처리하는 것" 자체가 하나의 책임 아닌가요? 하지만 핵심은 "변경의 이유"가 하나여야 한다는 것입니다.
예를 들어 UserService가 DB 저장, 이메일 발송, 로그 기록을 모두 한다면?
UserService 수정UserService 수정UserService 수정3가지 이유로 변경됩니다. 이게 문제입니다.
class UserService {
register(user: User) {
// 1. DB 저장
db.save(user);
// 2. 이메일 발송
emailClient.send(user.email, "Welcome!");
// 3. 로그
logger.log("User registered");
}
}
실제로 저는 이런 코드를 짰습니다. 처음엔 편했습니다. 그런데 이메일 템플릿을 HTML로 바꾸는 작업을 하다가 db.save() 부분에서 오타를 냈고, 결과적으로 회원가입 자체가 망가졌습니다. 이메일 작업 때문에 DB 로직이 깨진 겁니다. 이때 정리해본다: 한 클래스가 너무 많은 일을 하면, 하나를 고치다가 다른 것이 깨진다.
class UserService {
constructor(
private userRepository: UserRepository,
private emailService: EmailService,
private logger: Logger
) {}
register(user: User) {
this.userRepository.save(user);
this.emailService.sendWelcome(user);
this.logger.log("User registered");
}
}
이제 EmailService 코드를 어떻게 고치든 UserRepository는 영향을 받지 않습니다. 각 클래스가 자기 일만 하니까요. 이게 SRP입니다.
"확장에는 열려있고(Open), 수정에는 닫혀있어야(Closed) 한다."
이 원칙이 가장 멋있게 다가왔습니다. 코드를 "안 고치고도 기능을 추가할 수 있다"니, 마법 같은 이야기잖아요. 하지만 처음엔 이해가 안 갔습니다. "코드 안 고치고 어떻게 기능을 추가해?" 싶었죠.
그러다 실제로 if-else 지옥을 경험했습니다.
class PaymentProcessor {
pay(type: string, amount: number) {
if (type === 'CARD') {
this.payCard(amount);
} else if (type === 'PAYPAL') {
this.payPaypal(amount);
} else if (type === 'BITCOIN') { // ← 새로운 결제 수단 추가할 때마다 코드를 고쳐야 함
this.payBitcoin(amount);
}
}
}
저희 서비스에 "토스페이" 결제를 추가해야 했습니다. 그래서 else if (type === 'TOSS')를 추가했는데, 배포 후 카카오페이 결제가 깨졌습니다. 왜? 제가 코드를 수정하면서 괄호를 잘못 닫았거든요. 기존에 잘 작동하던 코드를 건드렸기 때문에 버그가 생긴 겁니다.
이때 결국 이거였다: "새로운 기능을 추가할 때 기존 코드를 고치면, 기존 기능이 깨질 위험이 있다."
interface PaymentMethod {
pay(amount: number): void;
}
class CardPayment implements PaymentMethod {
pay(amount: number) { /* 카드 결제 로직 */ }
}
class PaypalPayment implements PaymentMethod {
pay(amount: number) { /* 페이팔 로직 */ }
}
class PaymentProcessor {
pay(paymentMethod: PaymentMethod, amount: number) {
paymentMethod.pay(amount); // ← 코드를 수정할 필요가 없음!
}
}
새로운 결제 수단(토스페이)을 추가하려면? TossPayment 클래스만 새로 만들면 됩니다. PaymentProcessor는 전혀 건드리지 않습니다. 기존에 테스트 완료된 코드는 그대로 두고, 새 파일만 추가하는 겁니다. 이게 OCP입니다.
현대 프레임워크의 플러그인 시스템(WordPress, Webpack, Babel)이 모두 OCP를 따릅니다. 플러그인을 추가해도 코어 코드는 수정하지 않잖아요? 그게 바로 "확장에는 열려있고, 수정에는 닫혀있는" 설계입니다.
"자식 클래스는 부모 클래스를 대체할 수 있어야 한다."
이 원칙은 SOLID 중 가장 이해하기 어렵습니다. 이름도 어렵고(리스코프가 뭐야?), 개념도 추상적이죠. 저는 한동안 "자식이 부모를 상속받으면 당연히 대체할 수 있는 거 아닌가?"라고 생각했습니다. 그런데 유명한 "정사각형-직사각형 문제"를 보고 아차 싶었습니다.
수학 시간에 배운 걸 떠올려보면, "정사각형은 직사각형의 특수한 경우다." 맞습니다. 그래서 객체지향에서도 Square extends Rectangle로 상속받으면 되겠죠? 아닙니다. 이게 LSP 위반의 대표적인 예입니다.
class Rectangle {
constructor(protected width: number, protected height: number) {}
setWidth(w: number) { this.width = w; }
setHeight(h: number) { this.height = h; }
getArea() { return this.width * this.height; }
}
class Square extends Rectangle {
// 정사각형은 가로=세로여야 하니까 강제로 맞춤
setWidth(w: number) { this.width = w; this.height = w; }
setHeight(h: number) { this.width = h; this.height = h; }
}
function resize(rect: Rectangle) {
rect.setWidth(10);
rect.setHeight(5);
// 직사각형이라면 넓이가 50이어야 함.
// 하지만 Square가 들어오면? setHeight(5) 때문에 가로도 5가 되어 넓이가 25가 됨.
console.log(rect.getArea());
}
resize 함수는 Rectangle을 기대하고 짰습니다. 근데 자식인 Square를 넣으니 예상과 다르게 동작합니다. 이게 LSP 위반입니다. 자식 클래스가 부모의 행동 규약(contract)을 깼기 때문입니다.
저는 이 예제를 보고 이해했다: "수학적 관계와 프로그래밍의 상속 관계는 다르다." 수학에서는 정사각형이 직사각형이지만, 코드에서는 아닙니다. 왜? Rectangle을 사용하는 코드(resize)가 "가로와 세로를 독립적으로 바꿀 수 있다"는 전제로 짜여졌기 때문입니다. Square는 이 전제를 깹니다.
상속을 끊는 게 답입니다. Shape 인터페이스를 각자 구현하세요. 혹은 조합(Composition) 패턴을 쓰세요. 현대 프로그래밍에서는 상속을 지양하는 이유가 바로 LSP 위반 위험 때문입니다. Go와 Rust는 아예 클래스 상속이 없습니다.
"클라이언트는 자신이 사용하지 않는 메서드에 의존하면 안 된다."
ISP는 SRP의 인터페이스 버전이라고 받아들였습니다. "한 인터페이스가 너무 많은 메서드를 요구하면 안 된다"는 뜻입니다.
제가 실수했던 사례: 관리자 페이지를 만들면서 AdminInterface를 정의했습니다.
interface AdminInterface {
viewUsers(): void;
deleteUsers(): void;
editContent(): void;
approvePayments(): void;
exportLogs(): void;
}
문제는 저희 서비스에 "콘텐츠 관리자(Content Manager)"라는 역할이 있었는데, 이 사람은 editContent()만 필요했습니다. 그런데 AdminInterface를 구현하려니 deleteUsers(), approvePayments() 같은 걸 억지로 구현해야 했습니다. 결국 throw new Error("권한 없음")로 막아버렸죠.
이게 ISP 위반입니다.
interface SmartDevice {
print(): void;
fax(): void;
scan(): void;
}
class OldPrinter implements SmartDevice {
print() { /* 인쇄 */ }
fax() { throw new Error("지원 안 함"); } // ← 억지로 구현
scan() { throw new Error("지원 안 함"); } // ← 억지로 구현
}
"지원 안 함" 예외를 던지는 순간, 그 인터페이스는 너무 뚱뚱합니다. 비유하자면, USB만 필요한 사람한테 USB+HDMI+VGA 케이블을 다 꽂으라고 강요하는 것과 같습니다.
interface Printer { print(): void; }
interface Scanner { scan(): void; }
interface Fax { fax(): void; }
class AllInOnePrinter implements Printer, Scanner, Fax { /* 다 구현 */ }
class OldPrinter implements Printer { /* print만 구현 */ }
이제 복합기는 3개 다 구현하고, 구형 프린터는 Printer만 구현하면 됩니다. 필요한 것만 구현하는 겁니다. 이게 ISP의 핵심입니다.
"추상화에 의존하라. 구체적인 것에 의존하지 마라."
DIP는 SOLID의 꽃이라고 받아들였습니다. 이 원칙을 이해하면 Dependency Injection (의존성 주입)이 왜 필요한지, Spring/NestJS 같은 프레임워크가 왜 DI Container를 제공하는지 와닿습니다.
처음엔 "의존성 역전"이라는 말이 어려웠습니다. 뭘 역전한다는 건지 감이 안 왔죠. 그런데 실제로 이런 일을 겪었습니다.
저희 서비스가 날씨 센서를 읽어서 데이터를 표시하는 기능이 있었습니다. 처음엔 삼성 센서만 썼는데, 나중에 LG 센서도 지원해야 했습니다.
class WeatherTracker {
private sensor: SamsungSensor; // ← 삼성 센서에 강하게 결합됨!
constructor() {
this.sensor = new SamsungSensor(); // ← 직접 생성
}
}
LG 센서를 추가하려면? WeatherTracker 클래스를 뜯어고쳐야 합니다. SamsungSensor를 LGSensor로 바꾸거나, if-else로 분기해야 하죠. 이게 강한 결합(Tight Coupling)입니다.
그런데 "의존성을 역전"시키면 어떻게 될까요? 기존에는 WeatherTracker가 SamsungSensor에 의존했습니다(구체적인 것에 의존). 이걸 뒤집어서 WeatherTracker가 Sensor 인터페이스에 의존하게 만드는 겁니다(추상화에 의존). 이게 "역전"의 의미였습니다.
interface Sensor {
getTemperature(): number;
}
class WeatherTracker {
private sensor: Sensor; // ← "삼성"인지 "LG"인지 모름. 그냥 Sensor임.
constructor(sensor: Sensor) { // ← 외부에서 주입받음 (DI)
this.sensor = sensor;
}
}
// 사용
const tracker1 = new WeatherTracker(new SamsungSensor());
const tracker2 = new WeatherTracker(new LGSensor());
이제 WeatherTracker는 센서의 구체적인 브랜드를 모릅니다. 그냥 "온도를 읽을 수 있는 무언가(Sensor)"만 알 뿐입니다. LG 센서로 바꾸고 싶으면 생성 시점에 new LGSensor()를 넘기면 됩니다. WeatherTracker 코드는 1줄도 수정 안 해도 됩니다.
이게 DIP이고, 이게 의존성 주입입니다. 테스트할 때도 엄청 편합니다. MockSensor를 만들어서 주입하면 되니까요.
텍스트 에디터를 만드는데, "마크다운 파서", "HTML 파서" 기능을 계속 추가해야 하는 상황을 생각해보면. 저는 실제로 이런 일을 겪었습니다. 처음엔 마크다운만 지원하다가, 나중에 HTML도, LaTeX도 추가해야 했죠.
Step 1: 인터페이스 정의interface Parser {
parse(content: string): string;
}
Step 2: 플러그인 구현
class MarkdownParser implements Parser {
parse(content: string) { return marked(content); }
}
class HtmlParser implements Parser {
parse(content: string) { return sanitize(content); }
}
Step 3: 편집기 (수정 불필요)
class Editor {
constructor(private parser: Parser) {}
render(content: string) {
return this.parser.parse(content);
}
}
이제 새로운 LatexParser를 추가해도 Editor 클래스는 1줄도 수정할 필요가 없습니다. 이것이 OCP의 위력입니다. WordPress의 플러그인 시스템도, VS Code의 확장(Extension) 시스템도 모두 이 원칙을 따릅니다.
여기까지 읽으면 "그럼 무조건 SOLID를 지켜야 하는구나!"라고 생각할 수 있습니다. 하지만 아닙니다. SOLID를 무조건 지키면 오히려 Over-Engineering(과잉 설계)에 빠지기 쉽습니다.
제가 실수했던 사례: 간단한 관리자 페이지 스크립트(총 50줄)를 짜는데, SOLID를 지켜야겠다고 생각했습니다. UserRepository 인터페이스를 만들고, EmailService 인터페이스를 만들고... 결과적으로 파일이 10개가 되었고, 코드는 300줄이 되었습니다. 50줄짜리 스크립트가 300줄이 된 겁니다.
시니어가 보더니 웃었습니다. "이건 한 번 쓰고 버릴 스크립트인데 왜 이렇게 복잡하게 짰어?"
결국 이거였습니다: SOLID는 "변경이 자주 일어나는 코드"에만 적용해야 합니다. 일회성 스크립트, Proof of Concept(POC), 프로토타입에는 적용하지 않는 게 낫습니다. 오히려 YAGNI(You Aren't Gonna Need It) 원칙을 따라 "필요할 때 리팩토링"하는 게 현명합니다.
하지만 서비스의 핵심 로직(결제, 인증, 주문 처리)은 반드시 SOLID를 따라야 합니다. 이 부분은 계속 변경되고, 버그가 터지면 비즈니스가 흔들리니까요.
payment.pay()를 불렀는데 카드는 긁히고 현금은 영수증이 나옴).