🕵️ 프로젝트를 진행하면서 바쁘게 기능 구현에만 집중하다 보니 정작 유지보수나 큰 수정이 필요할 때마다 코드가 발목을 잡는 경우가 많았다.
물론 SOLID
원칙을 항상 100% 지킬 수는 없지만 일정 수준이라도 원칙을 의식하며 작성한 코드가 더 읽기 쉽고 유지보수도 훨씬 수월하다는 걸 몸소 느꼈다. 이번 글에서는 이러한 경험을 바탕으로 객체지향 설계의 핵심인 SOLID
원칙에 대해 정리해보려고 한다.
SOLID 란?
컴퓨터 프로그래밍에서 SOLID
란 로버트 C. 마틴이 2000년대 초반에 명명한 객체 지향 프로그래밍 및 설계의 다섯 가지 기본 원칙을 마이클 페더스가 두문자어 기억술로 소개한 것이다. 프로그래머가 시간이 지나도 유지 보수와 확장이 쉬운 시스템을 만들고자 할 때 이 원칙들을 함께 적용할 수 있다. 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) ➡ 프로그래머는 "추상화에 의존해야지, 구체화에 의존하면 안된다." 의존성 주입은 이 원칙을 따르는 방법 중 하나다. |
단일 책임 원칙 : 한 클래스(또는 모듈)는 하나의 책임만 가져야 한다.
❌ 위반된 예시 : 유저 데이터를 가져오고, UI를 업데이트
class UserManager {
getUserData() // API 호출
renderUserProfile() // DOM 조작
}
✅ 개선된 예시
class UserService {
getUserData() // API 호출
}
class UserProfileUI {
renderUserProfile() // DOM 조작
}
🕵️ UserService는 데이터만 처리하고 UserProfileUI는 UI만 담당한다. 하나의 책임만 맡는다.
개방-폐쇄 원칙 : 확장에는 열려있고 변경에는 닫혀 있어야 한다.
❌ 나쁜 설계: 새 타입 추가할 때마다 조건문 수정 필요
function paymentMethod(method: string) {
if (method === 'card') {
// 카드 결제 처리
} else if (method === 'mobile') {
// 모바일 결제 처리
}
}
✅ 좋은 설계: 새로운 방식은 확장만 하면 됨
interface PaymentStrategy } {
pay(amount: number): void;
}
class CardPayment implements PaymentStrategy {
pay(ammount: number) {
// 카드 결제 처리
}
}
class MobilePayment implements PaymentStrategy {
pay(amount: number) {
// 모바일 결제 처리
}
}
function processPayment(strategy: PaymentStrategy, ammount: number) {
strategy.pay(amount);
}
🕵️ 조건문을 늘리는 대신 새 클래스만 추가하면 되므로 기존 코드 수정 없이 확장 가능하다.
인터페이스 분리 원칙 : 클라이언트는 자신이 사용하지 않는 메서드에 의존하지 않아야 한다
❌ 나쁜 설계: 하나의 인터페이스가 너무 많은 역할
interface Worker {
work(): void;
eat(): void;
}
class Robot implements Worker {
work() { console.log('Robot 일함') }
eat() { throw new Error('Robot은 안 먹음') } ❌
}
✅ 좋은 설계: 인터페이스를 역할별로 분리
interface Workable {
work(): void;
}
interface Eatable {
eat(): void;
}
class Human implements Workable, Eatable {
work(): { console.log('Human 일함')}
eat(): { console.log('Human 먹음')}
}
class Robot implements Workable {
work(): { console.log('Robot 일함') }
}
🕵️ 사용하는 기능만 의존하게 만들면 변경에 안전하고 코드도 유연해진다.
의존 역전 원칙 : 고수준 모듈은 저수준 모듈에 의존하면 안 되고 추상화에 의존해야 한다.
❌ 나쁜 설계: 직접 클래스에 의존
class LightBulb {
turnOn() { console.log('불 켜짐') }
}
class Switch {
constructor (private bulb: LightBulb) {}
operate() {
this.bulb.turnOn(); ❌ 구체 클래스에 직접 의존
}
}
✅ 좋은 설계: 인터페이스 도입으로 DIP 적용
interface Switchable {
turnOn(): void;
}
class LightBulb implements Switchable {
trurnOn() { console.log('전구 켜짐) }
}
class Fan implements Switchable {
trurnOn() { console.log('선풍기 켜짐) }
}
class Switch {
constructor (private device: Switchable) {}
operate() {
this.device.turnOn(); ✅ 추상에 의존
}
}
🕵️ 의존성 주입을 통해 다양한 장치를 연결할 수 있는 유연한 구조를 만듭니다.
원칙 | 요약 | TypeScript 설계 포인트 |
---|---|---|
SRO | 하나의 책임만 가져라 | UI, 비즈니스 로직, API 분리 |
OCP | 확장에는 열려 있고 변경에는 닫혀라 | Strategy, Factory 패턴 사용 |
LCP | 하위 타입은 상위 타입을 대체할 수 있어야 한다. | 잘못된 상속 피하기 |
ISP | 인터페이스는 작게 나눠라 | 역할 기반 인터페이스 분리 |
DIP | 추상에 의존하고 구현에 의존하지 않는다 | 인터페이스 + 의존성 주입 |