SOLID 원칙 심화 — 코드로 증명하는 객체지향 설계 5원칙

"SOLID 원칙을 코드로 설명해주세요"

면접에서 SOLID를 물으면 정의를 읊는 건 누구나 합니다. 차별점은 코드 입니다. 위반 코드를 보여주고, 왜 문제인지 설명하고, 어떻게 리팩토링하는지 보여줄 수 있어야 합니다.

이 글에서는 각 원칙마다 위반 → 리팩토링 쌍을 보여줍니다.


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

클래스를 변경해야 하는 이유는 하나만 있어야 한다.

"책임 = 변경의 이유"라는 게 핵심입니다. 메서드가 하나라도 변경 이유가 두 가지면 SRP 위반입니다.

위반 예시

JAVA
// 변경 이유가 3가지: 주문 로직 변경, 영수증 포맷 변경, 이메일 서비스 변경
public class OrderService {

    public void placeOrder(Order order) {
        // 1. 주문 비즈니스 로직
        validateStock(order);
        calculateTotal(order);
        saveToDatabase(order);

        // 2. 영수증 생성 (포맷이 바뀌면 이 클래스를 수정해야 함)
        String receipt = generateReceipt(order);

        // 3. 이메일 발송 (메일 서비스가 바뀌면 이 클래스를 수정해야 함)
        sendEmail(order.getCustomerEmail(), receipt);
    }

    private String generateReceipt(Order order) {
        return "주문번호: " + order.getId() + "\n"
             + "합계: " + order.getTotal() + "원";
    }

    private void sendEmail(String to, String body) {
        // SMTP 설정, 전송 로직...
    }
}

영수증 포맷을 바꿔야 할 때도, 이메일 서비스를 교체할 때도, 주문 로직을 수정할 때도 — 전부 이 클래스를 건드려야 합니다.

리팩토링

JAVA
// 각 책임을 별도 클래스로 분리
public class OrderService {
    private final ReceiptGenerator receiptGenerator;
    private final NotificationSender notificationSender;

    public OrderService(ReceiptGenerator receiptGenerator,
                        NotificationSender notificationSender) {
        this.receiptGenerator = receiptGenerator;
        this.notificationSender = notificationSender;
    }

    public void placeOrder(Order order) {
        // 이 클래스의 유일한 책임: 주문 비즈니스 로직
        validateStock(order);
        calculateTotal(order);
        saveToDatabase(order);

        String receipt = receiptGenerator.generate(order);
        notificationSender.send(order.getCustomerEmail(), receipt);
    }
}

// 영수증 생성 책임
public class ReceiptGenerator {
    public String generate(Order order) {
        return "주문번호: " + order.getId() + "\n"
             + "합계: " + order.getTotal() + "원";
    }
}

// 알림 발송 책임
public class NotificationSender {
    public void send(String to, String body) {
        // SMTP 설정, 전송 로직
    }
}

이제 영수증 포맷이 바뀌면 ReceiptGenerator만, 메일 서비스가 바뀌면 NotificationSender만 수정하면 됩니다.


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

확장에는 열려 있고, 수정에는 닫혀 있어야 한다.

새 요구사항이 생겼을 때 ** 기존 코드를 수정하지 않고 새 코드를 추가 **해서 해결할 수 있어야 합니다.

위반 예시

JAVA
public class DiscountCalculator {

    public double calculate(Order order) {
        // 새 할인 유형이 추가될 때마다 이 메서드를 수정해야 함
        return switch (order.getCustomerType()) {
            case "REGULAR"  -> order.getTotal() * 0.05;
            case "VIP"      -> order.getTotal() * 0.10;
            case "EMPLOYEE" -> order.getTotal() * 0.20;
            // 새 고객 유형 추가 → 기존 코드 수정 필요
            default -> 0;
        };
    }
}

PARTNER, WHOLESALE 같은 새 유형이 추가될 때마다 이 switch문을 수정해야 합니다.

리팩토링 — 전략 패턴

JAVA
// 할인 전략 인터페이스
public interface DiscountStrategy {
    double calculate(Order order);
}

// 각 전략을 별도 클래스로
public class RegularDiscount implements DiscountStrategy {
    public double calculate(Order order) {
        return order.getTotal() * 0.05;
    }
}

public class VipDiscount implements DiscountStrategy {
    public double calculate(Order order) {
        return order.getTotal() * 0.10;
    }
}

public class EmployeeDiscount implements DiscountStrategy {
    public double calculate(Order order) {
        return order.getTotal() * 0.20;
    }
}

// 계산기는 더 이상 수정할 필요 없음
public class DiscountCalculator {
    private final Map<String, DiscountStrategy> strategies;

    public DiscountCalculator(Map<String, DiscountStrategy> strategies) {
        this.strategies = strategies;
    }

    public double calculate(Order order) {
        DiscountStrategy strategy = strategies.getOrDefault(
            order.getCustomerType(),
            _ -> 0  // 기본 전략: 할인 없음
        );
        return strategy.calculate(order);
    }
}

새 고객 유형이 추가되면? PartnerDiscount 클래스를 만들고 Map에 등록만 하면 됩니다. DiscountCalculator는 전혀 수정하지 않습니다.


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

하위 타입은 상위 타입을 대체할 수 있어야 한다.

상위 클래스 타입으로 참조하는 코드가 하위 클래스 인스턴스로 교체해도 정상 동작 해야 합니다.

위반 예시 — 직사각형과 정사각형

JAVA
public class Rectangle {
    protected int width;
    protected int height;

    public void setWidth(int width)   { this.width = width; }
    public void setHeight(int height) { this.height = height; }
    public int area()                 { return width * height; }
}

// 정사각형은 직사각형의 특수한 경우? → 위험한 생각
public class Square extends Rectangle {
    @Override
    public void setWidth(int width) {
        this.width = width;
        this.height = width;  // 정사각형이니까 높이도 같이 변경
    }

    @Override
    public void setHeight(int height) {
        this.width = height;  // 정사각형이니까 너비도 같이 변경
        this.height = height;
    }
}

왜 위반인가요?

JAVA
// 상위 타입(Rectangle)의 계약: width와 height는 독립적
public void resize(Rectangle rect) {
    rect.setWidth(5);
    rect.setHeight(10);
    assert rect.area() == 50;  // Rectangle이면 당연히 50

    // Square를 넘기면? area()는 100 → 계약 위반!
}

Rectangle을 기대하는 코드에 Square를 넣으면 동작이 달라집니다. 이게 LSP 위반입니다.

리팩토링 — 상속 대신 공통 인터페이스

JAVA
// 공통 인터페이스
public interface Shape {
    int area();
}

// 각자 독립적인 구현
public class Rectangle implements Shape {
    private final int width;
    private final int height;

    public Rectangle(int width, int height) {
        this.width = width;
        this.height = height;
    }

    public int area() { return width * height; }
}

public class Square implements Shape {
    private final int side;

    public Square(int side) {
        this.side = side;
    }

    public int area() { return side * side; }
}

SquareRectangle을 상속하지 않으므로 LSP 위반의 여지가 없습니다. 둘 다 Shape의 계약(area() 반환)을 정확히 지킵니다.

**핵심 : "IS-A 관계"가 현실 세계에서 성립한다고 해서 코드에서도 상속이 맞는 건 아닙니다. 정사각형은 수학적으로 직사각형이지만, ** 행동(behavior)이 다르면 상속하면 안 됩니다.


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

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

뚱뚱한 인터페이스를 쪼개라는 원칙입니다.

위반 예시

JAVA
// 모든 기능을 하나의 인터페이스에 몰아넣음
public interface Worker {
    void work();
    void eat();
    void sleep();
    void attendMeeting();
}

// 로봇은 eat(), sleep()이 필요 없는데 구현해야 함
public class Robot implements Worker {
    public void work()          { /* 작업 수행 */ }
    public void eat()           { /* 할 수 없음 — 빈 구현 */ }
    public void sleep()         { /* 할 수 없음 — 빈 구현 */ }
    public void attendMeeting() { /* 참석 */ }
}

Roboteat()sleep()에 빈 구현을 넣어야 합니다. 이런 빈 메서드가 쌓이면 코드의 의도가 흐려지고, 실수로 호출했을 때 아무 일도 일어나지 않아 버그를 추적하기 어려워집니다.

리팩토링

JAVA
// 역할별로 인터페이스를 분리
public interface Workable {
    void work();
}

public interface Feedable {
    void eat();
}

public interface Sleepable {
    void sleep();
}

public interface MeetingAttendable {
    void attendMeeting();
}

// 사람: 모든 인터페이스 구현
public class HumanWorker implements Workable, Feedable,
                                     Sleepable, MeetingAttendable {
    public void work()          { /* 작업 */ }
    public void eat()           { /* 식사 */ }
    public void sleep()         { /* 수면 */ }
    public void attendMeeting() { /* 회의 참석 */ }
}

// 로봇: 필요한 것만 구현
public class Robot implements Workable, MeetingAttendable {
    public void work()          { /* 작업 수행 */ }
    public void attendMeeting() { /* 참석 */ }
}

각 클라이언트 코드는 자신이 필요한 인터페이스만 의존합니다:

JAVA
// 작업을 시킬 수 있는 것만 필요한 코드
public void assignTask(Workable worker) {
    worker.work();
    // eat(), sleep()에 대해 알 필요 없음
}

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

상위 모듈이 하위 모듈에 의존하면 안 된다. 둘 다 추상화에 의존해야 한다.

위반 예시

JAVA
// 상위 모듈(OrderService)이 하위 모듈(MySQLRepository)에 직접 의존
public class OrderService {
    private final MySQLOrderRepository repository;  // 구체 클래스에 의존

    public OrderService() {
        this.repository = new MySQLOrderRepository();  // 직접 생성
    }

    public void placeOrder(Order order) {
        // 비즈니스 로직...
        repository.save(order);
    }
}

public class MySQLOrderRepository {
    public void save(Order order) {
        // MySQL 관련 코드...
    }
}

문제점:

  • DB를 PostgreSQL로 교체하려면 OrderService를 수정해야 함
  • 테스트할 때 실제 MySQL이 필요함 (목 객체 교체 불가)
  • OrderServiceMySQLOrderRepository의 변경에 취약함

리팩토링

JAVA
// 추상화 — 상위 모듈과 하위 모듈 모두 이 인터페이스에 의존
public interface OrderRepository {
    void save(Order order);
    Optional<Order> findById(String id);
}

// 상위 모듈: 추상화에만 의존
public class OrderService {
    private final OrderRepository repository;  // 인터페이스에 의존

    public OrderService(OrderRepository repository) {  // 생성자 주입
        this.repository = repository;
    }

    public void placeOrder(Order order) {
        repository.save(order);
    }
}

// 하위 모듈: 추상화를 구현
public class MySQLOrderRepository implements OrderRepository {
    public void save(Order order) { /* MySQL 코드 */ }
    public Optional<Order> findById(String id) { /* ... */ }
}

public class PostgreSQLOrderRepository implements OrderRepository {
    public void save(Order order) { /* PostgreSQL 코드 */ }
    public Optional<Order> findById(String id) { /* ... */ }
}
PLAINTEXT
Before: OrderService → MySQLOrderRepository
         (상위 → 하위)

After:  OrderService → OrderRepository ← MySQLOrderRepository
         (상위 → 추상화 ← 하위)

이것이 "역전"의 의미입니다. 의존 방향이 바뀌었습니다. 하위 모듈이 추상화를 구현하는 형태로요.

Spring에서의 DIP

Spring의 DI(Dependency Injection) 컨테이너가 DIP를 자연스럽게 구현해줍니다:

JAVA
@Service
public class OrderService {
    private final OrderRepository repository;

    // Spring이 OrderRepository 구현체를 자동 주입
    public OrderService(OrderRepository repository) {
        this.repository = repository;
    }
}

@Repository
public class JpaOrderRepository implements OrderRepository {
    // Spring Data JPA가 구현 제공
}

SOLID 원칙은 서로 연결되어 있다

PLAINTEXT
SRP → 클래스를 작게 유지

ISP → 인터페이스도 작게 유지

DIP → 구체 클래스가 아닌 작은 인터페이스에 의존

OCP → 새 구현체를 추가해서 확장 (기존 코드 수정 없음)

LSP → 새 구현체가 기존 계약을 지킴

한 원칙을 잘 지키면 다른 원칙도 자연스럽게 따라옵니다.


과도한 SOLID는 오히려 독

원칙을 알면 반드시 해야 할 경고도 있습니다:

JAVA
// 이런 건 과설계
public interface StringProvider { String get(); }
public interface StringValidator { boolean validate(String s); }
public interface StringFormatter { String format(String s); }
public interface StringSaver { void save(String s); }

// 문자열 하나 저장하는데 인터페이스 4개?

SOLID는 복잡성이 충분할 때 적용해야 효과가 있습니다. 간단한 코드에 억지로 원칙을 적용하면 오히려 코드가 복잡해집니다.

  • ** 변경이 예상되지 않으면** 직접 의존해도 됩니다
  • ** 구현체가 하나뿐이면** 인터페이스가 꼭 필요하지 않습니다
  • 3번 이상 반복되기 전까지는 추상화를 미루는 게 좋습니다

정리

원칙한 줄 정의위반 신호
SRP변경 이유가 하나만한 클래스가 여러 팀에 의해 수정됨
OCP확장에 열려 있고 수정에 닫혀 있음if/switch에 새 분기가 계속 추가됨
LSP하위 타입이 상위 타입을 대체 가능하위 클래스에 빈 메서드나 UnsupportedOperationException
ISP사용하지 않는 메서드에 의존하지 않음구현체에 빈 메서드가 많음
DIP추상화에 의존new ConcreteClass()가 비즈니스 로직에 산재

공부하면서 가장 중요하다고 느낀 건, SOLID가 "항상 적용해야 하는 규칙"이 아니라 "복잡성이 일정 수준을 넘었을 때의 처방전" 이라는 것입니다. 면접에서 원칙을 설명한 후 "하지만 과도한 추상화도 경계해야 합니다"라고 덧붙이면, 실무 감각이 있다는 인상을 줄 수 있습니다.

댓글 로딩 중...