본문으로 건너뛰기
Advertisement

7.6 SOLID 원칙

SOLID는 객체지향 설계를 위한 5가지 핵심 원칙의 앞글자를 딴 것입니다. 로버트 C. 마틴(Robert C. Martin, "엉클 밥")이 정리한 이 원칙들을 따르면 변경에 강하고, 이해하기 쉽고, 재사용하기 좋은 코드를 만들 수 있습니다.

S - 단일 책임 원칙 (Single Responsibility Principle)

"클래스는 단 하나의 변경 이유만 가져야 한다."

클래스는 하나의 기능(책임)만 가져야 합니다. 여러 기능이 섞이면 한 기능의 변경이 다른 기능에 영향을 줍니다.

// ❌ SRP 위반: 사용자 관련 모든 것을 한 클래스가 처리
class User {
String name;
String email;

void saveToDatabase() { /* DB 저장 로직 */ } // DB 책임
void sendWelcomeEmail() { /* 이메일 로직 */ } // 이메일 책임
String formatUserInfo() { return name + " <" + email + ">"; } // 포맷 책임
}

// ✅ SRP 준수: 각 책임을 별도 클래스로 분리
class User {
String name;
String email;
}

class UserRepository { // DB 저장 책임
void save(User user) { System.out.println("DB에 저장: " + user.name); }
}

class EmailService { // 이메일 책임
void sendWelcome(User user) { System.out.println("이메일 전송: " + user.email); }
}

class UserFormatter { // 포맷 책임
String format(User user) { return user.name + " <" + user.email + ">"; }
}

O - 개방-폐쇄 원칙 (Open/Closed Principle)

"소프트웨어 요소는 확장에는 열려 있고, 변경에는 닫혀 있어야 한다."

새로운 기능을 추가할 때 기존 코드를 수정하지 않고 확장할 수 있어야 합니다.

// ❌ OCP 위반: 새 할인 유형 추가 시 calculateDiscount() 수정 필요
class DiscountCalculator {
double calculateDiscount(String type, double price) {
if (type.equals("VIP")) return price * 0.2;
if (type.equals("STUDENT")) return price * 0.1;
// 새 타입 추가 때마다 여기를 수정해야 함!
return 0;
}
}

// ✅ OCP 준수: 인터페이스로 확장 가능하게
interface DiscountPolicy {
double calculate(double price);
}

class VipDiscount implements DiscountPolicy {
public double calculate(double price) { return price * 0.2; }
}

class StudentDiscount implements DiscountPolicy {
public double calculate(double price) { return price * 0.1; }
}

class SeniorDiscount implements DiscountPolicy { // 새 유형 추가: 기존 코드 무수정!
public double calculate(double price) { return price * 0.15; }
}

class OrderService {
// 어떤 DiscountPolicy든 받을 수 있음 - 변경에 닫혀 있음
double finalPrice(double price, DiscountPolicy policy) {
return price - policy.calculate(price);
}
}

L - 리스코프 치환 원칙 (Liskov Substitution Principle)

"자식 클래스는 부모 클래스를 완전히 대체할 수 있어야 한다."

부모 타입을 사용하는 코드에 자식 타입을 넣어도 프로그램이 올바르게 동작해야 합니다.

// ❌ LSP 위반: 정사각형이 직사각형을 상속하면 문제 발생
class Rectangle {
protected int width, height;
void setWidth(int w) { this.width = w; }
void setHeight(int h) { this.height = h; }
int area() { return width * height; }
}

class Square extends Rectangle {
@Override void setWidth(int w) { this.width = this.height = w; } // 가로세로 동시 설정
@Override void setHeight(int h) { this.width = this.height = h; }
}

// Rectangle을 기대하는 코드에 Square를 넣으면?
Rectangle r = new Square();
r.setWidth(5);
r.setHeight(10);
System.out.println(r.area()); // 기대: 50, 실제: 100 ← LSP 위반!

// ✅ LSP 준수: 공통 인터페이스로 설계
interface Shape { int area(); }
class Rectangle implements Shape { /* ... */ }
class Square implements Shape { /* ... */ }

I - 인터페이스 분리 원칙 (Interface Segregation Principle)

"클라이언트는 자신이 사용하지 않는 메서드에 의존하면 안 된다."

하나의 거대한 인터페이스보다 여러 개의 작고 집중된 인터페이스가 낫습니다.

// ❌ ISP 위반: 모든 기능을 하나의 인터페이스에
interface Worker {
void work();
void eat(); // 로봇은 밥을 먹지 않는데...
void sleep(); // 로봇은 잠을 자지 않는데...
}

class RobotWorker implements Worker {
public void work() { System.out.println("로봇이 작업합니다."); }
public void eat() { throw new UnsupportedOperationException("로봇은 먹지 않음"); }
public void sleep() { throw new UnsupportedOperationException("로봇은 안 잠"); }
}

// ✅ ISP 준수: 인터페이스를 역할별로 분리
interface Workable { void work(); }
interface Eatable { void eat(); }
interface Sleepable{ void sleep(); }

class HumanWorker implements Workable, Eatable, Sleepable {
public void work() { System.out.println("사람이 작업합니다."); }
public void eat() { System.out.println("밥을 먹습니다."); }
public void sleep() { System.out.println("잠을 잡니다."); }
}

class RobotWorker implements Workable { // 필요한 것만 구현
public void work() { System.out.println("로봇이 작업합니다."); }
}

D - 의존성 역전 원칙 (Dependency Inversion Principle)

"고수준 모듈은 저수준 모듈에 의존해서는 안 된다. 둘 다 추상화에 의존해야 한다."

구체적인 구현 클래스 대신 인터페이스(추상화)에 의존해야 합니다.

// ❌ DIP 위반: 고수준이 저수준 구현에 직접 의존
class MySQLDatabase {
void save(String data) { System.out.println("MySQL에 저장: " + data); }
}

class UserService {
private MySQLDatabase db = new MySQLDatabase(); // 구체 클래스에 의존!

void saveUser(String user) { db.save(user); }
// MySQL을 PostgreSQL로 바꾸려면 UserService를 수정해야 함
}

// ✅ DIP 준수: 추상화에 의존
interface Database {
void save(String data);
}

class MySQLDatabase implements Database {
public void save(String data) { System.out.println("MySQL: " + data); }
}

class PostgreSQLDatabase implements Database {
public void save(String data) { System.out.println("PostgreSQL: " + data); }
}

class UserService {
private final Database db; // 추상화에 의존

UserService(Database db) { // 생성자 주입 (DI)
this.db = db;
}

void saveUser(String user) { db.save(user); }
}

// 사용: DB 구현체를 외부에서 주입 (스프링이 이걸 자동으로 해줌!)
UserService service1 = new UserService(new MySQLDatabase());
UserService service2 = new UserService(new PostgreSQLDatabase());

고수 팁

SOLID를 언제 적용할까?

SOLID는 프로젝트가 성장할수록 진가를 발휘합니다. 처음에는 단순하게 시작하되, 다음 신호가 보이면 리팩토링 시점입니다:

  • SRP: 클래스 수정 이유가 2가지 이상 → 분리
  • OCP: 새 기능 추가마다 기존 클래스 수정 → 추상화
  • LSP: 오버라이딩 메서드에서 예외 던지거나 빈 구현 → 상속 재검토
  • ISP: 구현 클래스가 throw new UnsupportedOperationException() 남발 → 인터페이스 분리
  • DIP: new 키워드로 구체 클래스 직접 생성 → 인터페이스 + 의존성 주입

실무에서는 Spring의 @Autowired, @Service, @Repository 등이 DIP와 DI를 자동으로 지원합니다.

Advertisement