Skip to content
ioob.dev
Go back

객체지향 설계 원칙 1편 — SRP와 OCP

· 9분 읽기
OOP & SOLID 시리즈 (1/5)
  1. 객체지향 설계 원칙 1편 — SRP와 OCP
  2. 객체지향 설계 원칙 2편 — LSP와 ISP
  3. 객체지향 설계 원칙 3편 — DIP
  4. 객체지향 설계 원칙 4편 — TDA, 디미터 법칙, CQS
  5. 객체지향 설계 원칙 5편 — Composition over Inheritance
Table of contents

Table of contents

SOLID — 다섯 글자에 담긴 설계 철학

객체지향 프로그래밍을 배울 때 클래스, 상속, 다형성 같은 문법은 비교적 빨리 익힌다. 문제는 그 다음이다. 어떻게 나눌 것인가, 어디에 무엇을 둘 것인가라는 질문 앞에서 대부분의 개발자가 막힌다.

Robert C. Martin(일명 Uncle Bob)이 2000년대 초에 정리한 SOLID 원칙은 이 질문에 대한 다섯 가지 가이드라인이다.

이 다섯 원칙이 지향하는 방향은 하나다. 변경에 강한 코드. 요구사항은 반드시 바뀐다. 그 바뀜이 코드 전체를 흔들지 않도록 설계하는 게 목표다.

flowchart LR
    SOLID([SOLID 원칙]) --> SRP[SRP<br/>변경 이유를 하나로]
    SOLID --> OCP[OCP<br/>확장에 열림]
    SOLID --> LSP[LSP<br/>대체 가능성]
    SOLID --> ISP[ISP<br/>인터페이스 분리]
    SOLID --> DIP[DIP<br/>의존성 역전]
    SRP --> Goal([변경에 강한 코드])
    OCP --> Goal
    LSP --> Goal
    ISP --> Goal
    DIP --> Goal

이번 시리즈에서는 다섯 원칙을 하나씩 코드와 함께 파헤친다. 1편에서는 SRP와 OCP를 다룬다.

SRP — 클래스는 변경의 이유가 하나여야 한다

SRP(Single Responsibility Principle)의 정의는 단순하다.

A class should have only one reason to change. — Robert C. Martin

여기서 핵심은 책임이라는 단어의 해석이다. SRP에서 말하는 책임은 이 클래스가 하는 일이 아니라 이 클래스가 변경되는 이유다. 메서드가 몇 개든 상관없다. 변경의 축이 하나이면 SRP를 지키고 있는 것이고, 변경의 축이 둘 이상이면 위반이다.

위반 사례 — 모든 걸 하는 클래스

주문을 처리하는 클래스를 하나 보자.

public class OrderService {

    public Order createOrder(Cart cart) {
        // 주문 생성 로직
        Order order = new Order(cart.getItems(), calculateTotal(cart));
        saveToDatabase(order);
        sendConfirmationEmail(order);
        return order;
    }

    private BigDecimal calculateTotal(Cart cart) {
        // 가격 계산 — 할인율, 세금, 배송비
        BigDecimal subtotal = cart.getItems().stream()
                .map(item -> item.getPrice().multiply(BigDecimal.valueOf(item.getQuantity())))
                .reduce(BigDecimal.ZERO, BigDecimal::add);
        BigDecimal tax = subtotal.multiply(new BigDecimal("0.1"));
        return subtotal.add(tax);
    }

    private void saveToDatabase(Order order) {
        // JDBC로 직접 저장
        String sql = "INSERT INTO orders (id, total, status) VALUES (?, ?, ?)";
        // ... DB 연결, PreparedStatement, 실행
    }

    private void sendConfirmationEmail(Order order) {
        // SMTP로 이메일 발송
        String subject = "주문 확인: " + order.getId();
        // ... 메일 서버 연결, 전송
    }
}

이 클래스는 겉보기에 주문 처리라는 하나의 책임을 가진 것 같다. 하지만 변경의 이유를 세어 보면 이야기가 달라진다.

flowchart TB
    OrderService([OrderService]) --> BizLogic["비즈니스 로직<br/>가격 계산, 할인율, 세금"]
    OrderService --> Persistence["데이터 저장<br/>JDBC, SQL, 테이블 구조"]
    OrderService --> Notification["알림 발송<br/>SMTP, 이메일 템플릿"]

    BizLogic -->|"세금 정책 변경"| Change1([변경 이유 1])
    Persistence -->|"DB를 MongoDB로 교체"| Change2([변경 이유 2])
    Notification -->|"이메일 → 카카오톡"| Change3([변경 이유 3])

    style Change1 fill:#ff6b6b,color:#fff
    style Change2 fill:#ff6b6b,color:#fff
    style Change3 fill:#ff6b6b,color:#fff

세금 정책이 바뀌면 이 클래스를 고쳐야 한다. DB를 MySQL에서 MongoDB로 바꿔도 이 클래스를 고쳐야 한다. 알림 방식을 이메일에서 카카오톡으로 바꿔도 이 클래스를 고쳐야 한다. 변경의 이유가 셋이다. SRP 위반이다.

왜 문제인가

변경의 이유가 여러 개라는 건 서로 다른 이해관계자가 같은 코드를 건드린다는 뜻이다. 세금 정책을 수정하는 사람과 DB 마이그레이션을 하는 사람이 같은 파일을 열어야 한다. 충돌이 생기고, 한쪽 변경이 다른 쪽을 깨뜨릴 수 있다.

테스트도 문제다. 가격 계산 로직만 테스트하고 싶은데 DB 연결과 메일 서버가 필요하다. 목(mock) 객체를 잔뜩 만들어야 하고, 테스트가 느려진다.

분리 — 변경의 축을 기준으로 나누기

해법은 직관적이다. 변경의 이유가 서로 다르면 다른 클래스로 분리한다.

가격 계산을 담당하는 클래스를 먼저 추출한다.

public class PriceCalculator {

    public BigDecimal calculate(Cart cart) {
        BigDecimal subtotal = cart.getItems().stream()
                .map(item -> item.getPrice().multiply(BigDecimal.valueOf(item.getQuantity())))
                .reduce(BigDecimal.ZERO, BigDecimal::add);
        BigDecimal tax = subtotal.multiply(new BigDecimal("0.1"));
        return subtotal.add(tax);
    }
}

주문 저장을 담당하는 클래스를 분리한다.

public class OrderRepository {

    public void save(Order order) {
        String sql = "INSERT INTO orders (id, total, status) VALUES (?, ?, ?)";
        // ... DB 저장 로직
    }
}

알림 발송을 담당하는 클래스를 분리한다.

public class OrderNotifier {

    public void notify(Order order) {
        String subject = "주문 확인: " + order.getId();
        // ... 이메일 발송 로직
    }
}

이제 OrderService는 이들을 조합만 한다.

public class OrderService {

    private final PriceCalculator priceCalculator;
    private final OrderRepository orderRepository;
    private final OrderNotifier orderNotifier;

    public OrderService(PriceCalculator priceCalculator,
                        OrderRepository orderRepository,
                        OrderNotifier orderNotifier) {
        this.priceCalculator = priceCalculator;
        this.orderRepository = orderRepository;
        this.orderNotifier = orderNotifier;
    }

    public Order createOrder(Cart cart) {
        BigDecimal total = priceCalculator.calculate(cart);
        Order order = new Order(cart.getItems(), total);
        orderRepository.save(order);
        orderNotifier.notify(order);
        return order;
    }
}

세금 정책이 바뀌면 PriceCalculator만 고친다. DB를 교체하면 OrderRepository만 바꾼다. 알림 방식이 달라지면 OrderNotifier만 수정한다. 각 클래스가 하나의 변경 이유만 갖게 됐다.

SRP의 오해 — 메서드가 하나가 아니다

SRP를 처음 접하면 “클래스에 메서드가 하나만 있어야 하는 건가?”라고 오해하기 쉽다. 그렇지 않다. PriceCalculatorcalculateSubtotal(), calculateTax(), applyDiscount() 같은 메서드가 여럿 있어도 된다. 이 메서드들이 모두 가격 계산이라는 같은 변경 축 위에 있기 때문이다.

반대로 메서드가 딱 하나인 클래스라도, 그 안에서 두 가지 이유로 변경될 수 있다면 SRP를 위반하고 있는 것이다. 숫자가 아니라 변경의 방향을 본다.

OCP — 확장에 열리고 수정에 닫힌다

OCP(Open-Closed Principle)는 Bertrand Meyer가 1988년에 처음 제안하고, Robert C. Martin이 SOLID에 포함시킨 원칙이다.

Software entities should be open for extension, but closed for modification. — Bertrand Meyer, Object-Oriented Software Construction

새로운 기능을 추가할 때 기존 코드를 수정하지 않고 확장할 수 있어야 한다. 기존 코드를 건드리는 순간 이미 테스트된 동작이 깨질 위험이 생기기 때문이다.

위반 사례 — if-else의 늪

결제 수단에 따라 처리 방식이 달라지는 코드를 보자.

public class PaymentProcessor {

    public PaymentResult process(Payment payment) {
        if (payment.getType().equals("CREDIT_CARD")) {
            // 신용카드 결제 처리
            return processCreditCard(payment);
        } else if (payment.getType().equals("BANK_TRANSFER")) {
            // 계좌이체 처리
            return processBankTransfer(payment);
        } else if (payment.getType().equals("KAKAO_PAY")) {
            // 카카오페이 처리
            return processKakaoPay(payment);
        }
        throw new IllegalArgumentException("지원하지 않는 결제 수단: " + payment.getType());
    }

    private PaymentResult processCreditCard(Payment payment) { /* ... */ }
    private PaymentResult processBankTransfer(Payment payment) { /* ... */ }
    private PaymentResult processKakaoPay(Payment payment) { /* ... */ }
}

이 코드는 잘 동작한다. 문제는 네이버페이를 추가해 달라는 요구가 들어올 때 시작된다. process() 메서드를 열어서 else if를 하나 더 추가해야 한다. 토스페이가 추가되면 또 열어야 한다. 새로운 결제 수단이 생길 때마다 기존 코드를 수정해야 하므로 OCP를 위반한다.

flowchart TB
    subgraph before["OCP 위반 — 수정이 필요한 구조"]
        PP[PaymentProcessor] --> IF{"if-else 분기"}
        IF -->|CREDIT_CARD| CC[신용카드 처리]
        IF -->|BANK_TRANSFER| BT[계좌이체 처리]
        IF -->|KAKAO_PAY| KP[카카오페이 처리]
        IF -->|"??? 새 수단 추가"| NEW["기존 코드 수정 필요!"]
        style NEW fill:#ff6b6b,color:#fff
    end

다형성으로 풀어내기

OCP의 핵심 도구는 다형성(Polymorphism)이다. 인터페이스를 정의하고, 각 결제 수단을 구현체로 분리한다.

결제 전략 인터페이스를 선언한다.

public interface PaymentStrategy {

    boolean supports(String paymentType);

    PaymentResult process(Payment payment);
}

각 결제 수단이 이 인터페이스를 구현한다.

public class CreditCardPayment implements PaymentStrategy {

    @Override
    public boolean supports(String paymentType) {
        return "CREDIT_CARD".equals(paymentType);
    }

    @Override
    public PaymentResult process(Payment payment) {
        // 신용카드 결제 처리
        return new PaymentResult(true, "신용카드 결제 완료");
    }
}

public class BankTransferPayment implements PaymentStrategy {

    @Override
    public boolean supports(String paymentType) {
        return "BANK_TRANSFER".equals(paymentType);
    }

    @Override
    public PaymentResult process(Payment payment) {
        // 계좌이체 처리
        return new PaymentResult(true, "계좌이체 완료");
    }
}

public class KakaoPayPayment implements PaymentStrategy {

    @Override
    public boolean supports(String paymentType) {
        return "KAKAO_PAY".equals(paymentType);
    }

    @Override
    public PaymentResult process(Payment payment) {
        // 카카오페이 처리
        return new PaymentResult(true, "카카오페이 결제 완료");
    }
}

이제 PaymentProcessor는 전략 목록을 받아서 위임만 한다.

public class PaymentProcessor {

    private final List<PaymentStrategy> strategies;

    public PaymentProcessor(List<PaymentStrategy> strategies) {
        this.strategies = strategies;
    }

    public PaymentResult process(Payment payment) {
        return strategies.stream()
                .filter(strategy -> strategy.supports(payment.getType()))
                .findFirst()
                .orElseThrow(() -> new IllegalArgumentException(
                        "지원하지 않는 결제 수단: " + payment.getType()))
                .process(payment);
    }
}

네이버페이를 추가해야 한다고 해보자. NaverPayPayment라는 새 클래스를 만들고 PaymentStrategy를 구현하면 끝이다. PaymentProcessor의 코드는 한 줄도 건드리지 않는다.

flowchart TB
    subgraph after["OCP 준수 — 확장만으로 추가"]
        PP2[PaymentProcessor] --> PS["List&lt;PaymentStrategy&gt;"]
        PS --> CC2[CreditCardPayment]
        PS --> BT2[BankTransferPayment]
        PS --> KP2[KakaoPayPayment]
        PS -.->|"새 클래스 추가"| NP[NaverPayPayment]
        style NP fill:#51cf66,color:#fff
    end

Strategy 패턴 — OCP의 대표적 구현

지금 본 구조가 바로 Strategy 패턴이다. GoF(Gang of Four)의 디자인 패턴 중 하나로, 알고리즘 군을 정의하고 각각을 캡슐화해서 교환 가능하게 만드는 패턴이다.

Strategy 패턴의 구조를 정리하면 이렇다.

classDiagram
    class Context {
        -strategy: Strategy
        +execute()
    }
    class Strategy {
        <<interface>>
        +algorithm()
    }
    class ConcreteStrategyA {
        +algorithm()
    }
    class ConcreteStrategyB {
        +algorithm()
    }
    class ConcreteStrategyC {
        +algorithm()
    }

    Context --> Strategy
    Strategy <|.. ConcreteStrategyA
    Strategy <|.. ConcreteStrategyB
    Strategy <|.. ConcreteStrategyC

Context는 Strategy 인터페이스에만 의존한다. 구체적인 전략이 뭔지 모르고, 알 필요도 없다. 새로운 전략이 추가되어도 Context는 변경되지 않는다.

Kotlin에서의 OCP — sealed class 활용

Kotlin을 사용한다면 sealed class와 when 식을 조합해 OCP를 적용하는 방법도 있다.

sealed interface PaymentStrategy {
    fun process(payment: Payment): PaymentResult
}

class CreditCardPayment : PaymentStrategy {
    override fun process(payment: Payment): PaymentResult {
        return PaymentResult(success = true, message = "신용카드 결제 완료")
    }
}

class BankTransferPayment : PaymentStrategy {
    override fun process(payment: Payment): PaymentResult {
        return PaymentResult(success = true, message = "계좌이체 완료")
    }
}

class KakaoPayPayment : PaymentStrategy {
    override fun process(payment: Payment): PaymentResult {
        return PaymentResult(success = true, message = "카카오페이 결제 완료")
    }
}

sealed interface를 사용하면 when 식에서 모든 구현체를 처리했는지 컴파일 타임에 검증할 수 있다. 새로운 구현체를 추가하면 when 식에서 처리하지 않은 경우를 컴파일러가 경고해준다. OCP를 지키면서도 타입 안전성을 보장받는 셈이다.

fun describe(strategy: PaymentStrategy): String = when (strategy) {
    is CreditCardPayment -> "신용카드"
    is BankTransferPayment -> "계좌이체"
    is KakaoPayPayment -> "카카오페이"
    // 새 구현체 추가 시 컴파일러가 여기를 추가하라고 경고
}

다만 sealed class 방식은 모든 구현체가 같은 모듈에 있어야 한다는 제약이 있다. 외부 모듈에서 전략을 추가해야 하는 경우라면 일반 인터페이스가 더 적합하다.

OCP의 경계 — 모든 것을 미리 열어둘 필요는 없다

OCP를 처음 배우면 “모든 곳에 인터페이스를 만들어야 하나?”라는 생각이 든다. 그렇지 않다. 변경이 예상되는 지점에만 확장 포인트를 만들면 된다.

결제 수단은 비즈니스 요구에 따라 계속 추가될 가능성이 높다. 여기에 Strategy 패턴을 적용하는 건 합리적이다. 반면 주문 상태가 생성 → 결제 → 배송 → 완료로 고정되어 있고 바뀔 일이 거의 없다면, 그걸 굳이 인터페이스로 열어둘 필요는 없다.

어디를 열어두고 어디를 닫아둘지 판단하는 게 설계자의 역할이다. 미래의 모든 변경을 예측하는 건 불가능하고, 그러려고 시도하면 오히려 코드가 복잡해진다. 첫 번째 변경이 발생했을 때 확장 포인트를 도입하는 전략이 실무에서는 더 현실적이다.

SRP와 OCP의 관계

두 원칙은 서로 다른 이야기를 하는 것 같지만, 실은 같은 방향을 가리키고 있다.

SRP가 변경의 이유를 하나로 만들어라고 말한다면, OCP는 그 변경이 일어나도 기존 코드를 건드리지 마라고 말한다. SRP로 책임을 잘 분리해두면, 그 분리된 단위 위에 OCP를 적용하기 쉬워진다.

앞서 본 OrderService 예제를 다시 떠올려 보면, SRP를 적용해 PriceCalculator를 분리한 뒤 OCP를 적용할 수 있다. 할인 정책이 다양해진다면 DiscountStrategy 인터페이스를 만들고, PriceCalculator가 이를 사용하게 하면 된다. SRP가 먼저 깔려 있지 않았다면 이런 확장은 훨씬 어려웠을 것이다.

flowchart LR
    SRP["SRP<br/>책임 분리"] -->|"분리된 단위 위에"| OCP["OCP<br/>확장 포인트 설계"]
    OCP -->|결과| Goal["변경에 강한 코드"]

핵심 정리

SRP와 OCP의 핵심을 한 줄씩으로 요약하면 이렇다.

원칙핵심 질문적용 방법
SRP”이 클래스가 변경되는 이유가 몇 가지인가?”변경의 축이 다르면 클래스를 분리한다
OCP”새 기능을 추가할 때 기존 코드를 열어야 하는가?”인터페이스와 다형성으로 확장 포인트를 만든다

두 원칙 모두 결국 변경의 영향 범위를 최소화하는 데 목적이 있다. 코드를 처음 작성할 때는 느끼기 어렵지만, 유지보수가 시작되면 이 원칙들의 가치가 분명해진다. 요구사항이 바뀔 때 코드 한 곳만 고치면 되는 구조와, 여러 파일을 동시에 수정해야 하는 구조의 차이는 크다.

다음 편에서는 SOLID의 나머지 두 원칙, LSP와 ISP를 다룬다. 자식이 부모를 진짜로 대체할 수 있는지, 인터페이스는 얼마나 작아야 하는지를 코드로 살펴볼 것이다.


2편: LSP와 ISP


Related Posts

Share this post on:

Comments

Loading comments...


Previous Post
Terraform 15편 — 실전 패턴과 함정
Next Post
객체지향 설계 원칙 2편 — LSP와 ISP