"단단한" 객체 지향 설계를 위해 고려해야 할 5가지 원칙 - SOLID 원칙

박가은·2025년 4월 21일

SOLID 원칙은 객체 지향 설계의 5가지 핵심 원칙 입니다.
이 원칙을 따르면 유지 보수성확장성 측면에서 더 나은 설계를 할 수 있습니다.

이제 5가지 원칙에 대해서 그 원칙이 무엇이고, 어떻게 지켜야하고, 위반한, 잘 지킨 예시는 어떤것이 있는지 하나하나 알아보겠습니다.

구분약어영문명설명
SSRPSingle Responsibility Principle단일 책임 원칙
OOCPOpen Closed Principle개방 폐쇄 원칙
LLSPLiskov Substitution Principle리스코프 치환 원칙
IISPInterface Segregation Principle인터페이스 분리 원칙
DDIPDependency Inversion Principle의존 역전 원칙

원칙1 'S'

SRP - Single Responsibility Principle
단일 책임 원칙: 클래스는 하나의 책임 만 가져야 하며, 변경 이유도 하나여야 한다.

이 말은 한 클래스가 여러 이유로 변경되지 않도록 해야한다는 의미입니다.

예를 들어서

@Service
public class UserService {
    public void register(User user) {
        validateUser(user);   // 1. 유효성 검증
        userRepository.save(user); // 2. 사용자 저장
        mailService.sendWelcomeMail(user.getEmail()); // 3. 이메일 발송
    }
}

이 코드에서는 UserService 가 바뀌는 이유가 유효성 검사 검증 기준이 변경되거나, 사용자 저장 방식이 바뀌거나 등등 다양합니다.
→ 따라서 이 클래스는 SRP, 단일 책임 원칙을 지키고 있지 않습니다.

그러므로 코드를 아래와 같이 개선할 수 있습니다.

@Service
public class UserService {
    private final UserValidator validator;
    private final MailService mailService;

    public void register(User user) {
        validator.validate(user);
        userRepository.save(user);
        mailService.sendWelcomeMail(user.getEmail());
    }
}
  • UserValidator : 유효성 검사 책임
  • UserRepository : 데이터 저장 책임
  • MailService : 이메일 발송 책임

→ 각각은 하나의 이유로만 변경됨 → SRP 준수

원칙2 'O'

OCP - Open Closed Principle
개방 폐쇄 원칙: 소프트웨어 요소는 확장에는 열려 있고, 수정에는 닫혀 있어야 한다.

예시와 함께 OCP 에 대해서 더 알아보겠습니다.

public class DiscountService {
    public int getDiscountPrice(String grade, int price) {
        if ("NORMAL".equals(grade)) {
            return price - 1000;
        } else if ("VIP".equals(grade)) {
            return price - 3000;
        } else {
            return price;
        }
    }
}

이런 코드라면 새로운 할인 정책이 추가될 때마다 if-else문 을 추가하는 번거로움을 겪게 됩니다. 확장에는 열려 있고 수정에는 닫혀 있어야 하는 OCP 를 위반하게 된 것이죠.

따라서 아래와 같이 코드를 개선할 수 있습니다.

interface PaymentMethod {
    void pay();
}

class CardPayment implements PaymentMethod {
    @Override
    public void pay() {
        // 카드 결제 처리
    }
}

class CashPayment implements PaymentMethod {
    @Override
    public void pay() {
        // 현금 결제 처리
    }
}

class PaymentProcessor {
    void pay(PaymentMethod method) {
        method.pay();
    }
}
  • PaymentMethod 라는 인터페이스를 통해 결제 방식 추가가 자유롭습니다.
  • 새로운 결제 수단이 생겨도 PaymentProcessor 는 수정할 필요가 없습니다.
    → OCP 를 만족하는 구조

원칙3 'L'

LSP - Likov Substituition Principle
리스코프 치환 원칙: 하위 클래스는 상위 클래스를 완벽하게 대체할 수 있어야 한다.

이 의미는 부모 클래스 객체를 사용하는 곳에 자식 클래스 객체를 넣어도 동일하게 작동해야 한다는 의미입니다. 자식 클래스가 부모의 규칙을 깨거나 예외를 발생시키면 LSP 가 위반됩니다. 아래의 예시와 함께 더 자세히 알아보겠습니다.

class Bird {
    public void fly() {
        // 날 수 있음
    }
    
    public void layEgg() { 
        // 알 낳기 
    }
}

class Chicken extends Bird {
    @Override
    public void fly() {
        throw new UnsupportedOperationException("닭은 날 수 없습니다.");
    }
}
  • Birdfly() 가 가능한 객체로 설계됨 (부모 클래스의 규칙)
  • ChickenBird 를 상속하지만 fly() 불가능 (= 자식 클래스가 부모 클래스의 규칙을 깸)
    Bird 를 사용하는 코드에서 Chicken 를 넣으면 에러 발생
    → LSP 위반

따라서 아래와 같이 코드를 개선할 수 있습니다.

interface Bird {
    void layEgg();
}

interface Flyable {
    void fly();
}

class Parrot implements Bird, Flyable {
    public void layEgg() { 
        // 알 낳기 
    }
    
    public void fly() { 
        // 날기 
    }
}

class Chicken implements Bird {
    public void layEgg() { 
        // 알 낳기 
    }
}
  • Bird 는 공통 기능인 layEgg() 만 포함합니다.
  • Flyable 은 날 수 있는 새에만 필요한 기능인 fly() 를 분리합니다.
  • Chicken 는 둘 다 구현 → 날 수 있는 새
  • ChickenBird 만 구현 → 날 수 없는 새
    → LSP 를 만족하는 코드

원칙4 'I'

ISP - Interface Segregation Principle
인터페이스 분리 원칙: 클라이언트는 자신이 사용하지 않는 인터페이스에 의존하지 않아야 한다.

이 원칙은 하나의 거대한 인터페이스보다, 작고 명확한 인터페이스 여러 개로 나누는 것이 좋다는 의미를 포함하고 있습니다.

아래의 예시와 함께 더 자세히 알아보겠습니다.

interface Worker {
    void work();
    void eat();
}

class Robot implements Worker {
    public void work() { }
    public void eat() {
        throw new UnsupportedOperationException("로봇은 밥 안 먹음");
    }
}
  • Roboteat() 이 필요 없음
  • 하지만 Worker 를 구현하므로 eat() 을 구현해야 함
    → ISP 위반

따라서 아래와 같이 코드를 개선할 수 있습니다.

interface Workable {
    void work();
}

interface Eatable {
    void eat();
}

class Human implements Workable, Eatable {
    public void work() { }
    public void eat() { }
}

class Robot implements Workable {
    public void work() { }
}
  • 각 기능을 인터페이스로 분리
  • 각 클래스는 자신이 필요한 인터페이스만 구현
    → ISP 를 만족하는 코드

원칙5 'D'

DIP - Dependency Injection Principle
의존 역전 원칙: 고수준 모듈은 저수준 모듈에 의존하면 안된다. 둘 다 추상화에 의존해야 한다.

DIP 를 위반하면, 고수준 모듈(비즈니스 로직)이 저수준 모듈(구현 클래스)에 직접 의존하게 되고 이러면 변경에 취약해집니다. 따라서 인터페이스(추상화) 를 통해 서로 느슨하게 연결해야 합니다.

아래의 예시와 함께 더 자세히 알아보겠습니다.

class MySQLDatabase {
    public void save(String data) {
        // MySQL에 저장
    }
}

class DataService {
    private MySQLDatabase db = new MySQLDatabase();

    public void save(String data) {
        db.save(data);
    }
}
  • DataService (비지니스 로직) 는 MySQLDatabase (구현 클래스) 에 직접 의존
  • MySQL에서 다른 DB로 교체하려면 DataService 도 수정해야 함
    → DIP 위반

따라서 아래와 같이 코드를 개선할 수 있습니다.

interface Database {
    void save(String data);
}

class MySQLDatabase implements Database {
    public void save(String data) {
        // MySQL에 저장
    }
}

class DataService {
    private final Database db;

    public DataService(Database db) {
        this.db = db;
    }

    public void save(String data) {
        db.save(data);
    }
}
  • Database 라는 인터페이스에 의존 → 실제 구현은 외부에서 주입
  • MySQL, MongoDB 등 다양한 구현체를 쉽게 교체 가능 → DIP 준수

SOLID 원칙은 꼭 지켜야 하는 것일까?

SOLID 원칙은 객체 지향 설계에 있어서 "필수 규칙" 이 아니라, "권장되는 설계 지침" 입니다.
이 말은 SOLID 원칙을 반드시 따라야 하는 것은 아니라는 의미 입니다.

첫번재 이유
프로젝트 규모가 작거나 단기간에 끝나는 프로젝트라면, SOLID 원칙을 엄격히 적용하면, 과설계가 발생할 수 있습니다.

두번재 이유
모든 원칙을 다 지키려고 하면 코드 구조가 복잡해져서 협업이나 유지보수가 어려워질 수 있습니다.

결론
SOLID 원칙을 모두 적용하려면 많은 시간과 노력이 요구됩니다.
따라서 성능, 유지보수성 등의 우선 순위를 고려하여 이에 따라 필요한 정도로 적당히 원칙을 적용하는 것이 중요합니다.

0개의 댓글