컴퓨터공학

SRP vs OCP, 실제 적용 사례

nyambu 2025. 5. 22. 08:00

SRP vs OCP, 실제 적용 사례
SRP vs OCP, 실제 적용 사례

1. 이론과 현실 사이의 간극

1-1. SRP와 OCP는 언제 충돌하는가

 SOLID 원칙은 객체지향 설계에서 이상적인 구조를 안내해주는 나침반 같은 존재다. 그러나 이상은 이상일 뿐, 실무에서는 그 원칙들이 충돌하거나 현실과 괴리되는 순간들이 적지 않다. 특히 SRP(단일 책임 원칙)과 OCP(개방-폐쇄 원칙)은 애초에 지향하는 방향 자체가 다르기 때문에 실제 개발 현장에서는 둘 사이의 갈등이 빈번히 발생한다.

 

 SRP는 책임을 명확하게 나누라고 하고, OCP는 기존 코드를 수정하지 말고 확장하라고 요구한다. 그런데 책임을 나누다 보면 단일 클래스가 너무 작아지고, 그 조합을 위한 코드가 중첩되며, 새로운 기능이 들어올 때마다 확장을 고려해야 하는 OCP와 상충하게 된다. 반대로, 확장을 위해 공통된 인터페이스나 부모 클래스를 만들면, 그 구조 안에 여러 책임이 교차하면서 SRP를 위반하게 된다. 즉, 둘은 방향이 다른 두 레일을 동시에 놓는 행위와 같다.

1-2. 실무에서 자주 겪는 딜레마

 개발자라면 누구나 이런 고민을 해봤을 것이다. “이 기능은 책임이 다르니까 나눠야 하나?”, “이건 확장될 수 있으니 인터페이스로 감싸야 하나?” 그러나 현실은 그렇게 깔끔하지 않다. 업무 요구사항은 언제든 바뀌고, 새로운 정책은 예고 없이 떨어진다. 예컨대 OrderService 클래스가 초기에는 주문 생성과 결제를 담당하다가, 나중에는 취소, 환불, 할인 정책까지 다루기 시작한다. 이때 SRP 관점에서는 “이건 다 쪼개야 해”라고 말한다. 그러나 OCP 관점에서는 “확장할 수 있도록 하나의 추상 타입으로 묶어야 해”라고 말한다. 이럴 때 개발자는 깊은 고민에 빠진다. 쪼개면 조립이 복잡해지고, 확장 구조로 가면 책임이 뒤섞인다.

1-3. 예시로 보는 충돌 지점

가장 흔하게 등장하는 충돌 예시는 “결제 처리”다. 다음과 같은 구조를 보자

class OrderService {
    void processPayment(Order order) {
        if (order.getPaymentMethod() == CARD) { ... }
        else if (order.getPaymentMethod() == KAKAO_PAY) { ... }
    }
}

 이 구조는 분명히 OCP를 위반하고 있다. 새로운 결제 수단이 추가되면 if 문이 늘어나야 하기 때문이다. 이를 고치기 위해 PaymentProcessor 인터페이스를 도입하고, 각 결제 수단마다 구현 클래스를 나누면 OCP는 만족된다. 하지만 이 클래스들을 관리하고 조합하는 상위 클래스에서 책임이 분산되면서, SRP는 흐려진다. 즉, 기능을 확장하면서 책임이 하나의 클래스에 모이기 시작하면 두 원칙은 충돌한다.

1-4. 트레이드오프의 인식이 중요하다

 이런 상황에서는 두 원칙을 무리하게 동시에 만족시키려 하기보다, 지금 내 프로젝트 상황에서 “어느 쪽이 더 중요한가”를 분명히 정해야 한다. 만약 시스템이 빈번히 확장되거나 새로운 기능이 수시로 추가된다면 OCP를 우선하고, 명확한 책임 분리와 테스트가 더 중요하다면 SRP를 우선하는 것이 낫다. 모든 설계는 선택의 문제이며, 원칙은 판단의 근거일 뿐 절대적인 답이 아니다. 실무의 복잡성은 ‘예외 없는 규칙’을 허락하지 않는다. 설계자는 원칙의 이름보다 현재의 의도를 더 신중히 판단해야 한다.


2. 실제 사례: 알림 발송 시스템

2-1. 초기 설계 – 단일 책임 기반의 단순 구조

 실무에서 흔히 마주치는 기능 중 하나가 알림 발송 시스템이다. 초기에는 단순히 이메일만 발송하는 기능이 필요해서 NotificationService 같은 클래스를 만든다. 이 클래스는 오직 sendEmail()이라는 단일 메서드만 가지며, 이는 매우 SRP에 충실한 구조이다. 책임이 하나뿐이고, 변경의 이유도 오직 “이메일 발송 방식이 바뀔 때” 하나뿐이다. 하지만 실무에서 이 구조가 오랫동안 유지되는 경우는 거의 없다. 서비스가 커지고 고객 커뮤니케이션 채널이 늘어나면서, SMS, 알림톡, 앱푸시 등 다양한 형태의 알림 수단이 요구된다. 이 시점부터 이 단순 구조는 위험해지기 시작한다.

2-2. 확장과 함께 붕괴되는 SRP

 채널이 늘어나면서 NotificationService에 다음과 같은 코드가 들어가기 시작한다

void send(NotificationType type, String to, String message) {
    if (type == EMAIL) { ... }
    else if (type == SMS) { ... }
    else if (type == KAKAO) { ... }
}

 이제 이 클래스는 더 이상 단일 책임을 지지 않는다. 채널 수만큼 if-else가 늘어나고, 각각의 메시지 형식, 인증 키, 전송 방식이 다르기 때문에 메서드 내부는 점점 복잡해진다. 이 상태에서 새로운 채널(예: WhatsApp)이 추가되면 기존 메서드 내부를 다시 고쳐야 한다. 이건 SRP도, OCP도 모두 위반하고 있는 것이다. 여기에 더해, 테스트 코드도 모든 채널의 조합을 고려해야 하며, 작은 변경 하나가 전체 시스템에 영향을 주는 구조로 바뀐다.

2-3. OCP 중심 구조로의 전환

 이 문제를 해결하려면 OCP 중심으로 구조를 재설계해야 한다. 우선 공통된 NotificationSender 인터페이스를 만든다

interface NotificationSender {
    boolean supports(NotificationType type);
    void send(String to, String message);
}

 그리고 이메일, SMS, 카카오, 앱푸시 등 각각을 독립된 구현체로 분리한다. 그런 다음, NotificationService는 단지 “적절한 Sender를 골라서 위임”하는 역할만 하게 한다. 이 구조는 새로운 채널이 생겨도 기존 코드를 수정할 필요 없이 NotificationSender를 구현한 새 클래스를 추가하고, 등록만 하면 된다. 책임도 각 클래스로 분리돼서 SRP도 자연스럽게 만족된다.

2-4. 설계의 핵심: 역할을 중심으로 추상화

 이 리팩토링에서 핵심은 "기능"이 아니라 "역할"을 추상화한 것이다. NotificationService는 발송이 아니라 “전략 선택자” 역할로 포지셔닝했고, 발송은 각 구현체에게 위임했다. 이처럼 “누가 무엇을 책임지고, 그 책임은 언제 바뀌는가”를 기준으로 설계하면 자연스럽게 SRP와 OCP가 균형을 맞추게 된다. 이게 바로 객체지향 설계에서 말하는 유지보수가 용이한 확장 가능한 구조의 핵심이다.


3. 분리 기준은 “변경의 성격”이다

3-1. 무조건 쪼개는 게 아니라 “왜 바뀌는가”에 집중하라

 SRP를 적용할 때 초보 개발자들이 자주 빠지는 함정이 있다. 바로 “클래스를 작게 나누는 것” 자체가 SRP를 지키는 것이라고 착각하는 것이다. 하지만 진짜 핵심은 ‘변경의 이유’다. 어떤 클래스가 여러 가지 이유로 동시에 수정되어야 한다면 그것은 분명히 SRP를 위반한 구조다. 예컨대 UserService 클래스가 로그인, 회원가입, 이메일 인증을 모두 담당한다면, 이 클래스는 보안 정책이 바뀔 때, 회원가입 폼이 바뀔 때, 인증 방식이 바뀔 때마다 수정되어야 한다. 이건 하나의 책임이 아닌 것이다. 반면 단지 메서드가 많거나 코드가 길다고 해서 그것이 반드시 SRP 위반인 것은 아니다.

3-2. 변화가 잦은 부분은 OCP로 대응한다

 만약 어떤 기능이 빈번하게 변경되거나, 확장될 가능성이 높은 경우라면 SRP보다는 OCP 중심의 설계가 더 중요하다. 예를 들어 포인트 정책, 쿠폰 로직, 정산 계산 방식 등은 사업 변화에 따라 자주 바뀌므로, 추상화된 정책 인터페이스를 중심으로 설계하고 구현체를 교체할 수 있도록 구조를 잡는 것이 좋다. 이렇게 하면 정책이 추가되거나 조건이 달라져도 기존 코드는 전혀 건드리지 않고 새 구현체만 붙이면 된다. 이게 바로 변화의 방향에 따라 구조를 달리해야 한다는 실전적인 설계 전략이다.

3-3. 도메인 주도 설계 관점에서 바라보기

 도메인 주도 설계(DDD)의 핵심은 ‘업무 용어로 코드 구조를 설계하는 것’이다. 이 관점에서 보면 변경의 이유는 결국 업무 용어의 주체별로 구분된다는 의미다. 예컨대 ‘결제 승인’과 ‘정산 로직’은 둘 다 돈을 다루지만, 전자는 결제팀의 책임이고 후자는 회계팀의 책임이다. 이런 업무 주체에 따라 코드를 나누는 것이 바로 SRP이며, 그 안에서 동작 방식이 다양하게 바뀔 수 있다면 해당 로직은 추상화로 감싸서 OCP를 만족시켜야 한다.

3-4. 조직 구조와 책임 분리가 일치해야 한다

 이러한 책임 분리는 단지 코드 내부의 일이 아니다. 서비스 규모가 커지면 팀 단위로 분업이 이뤄지게 되며, 각 팀이 책임지는 모듈도 분리돼야 한다. 예컨대 마케팅 팀은 쿠폰 정책을, 서비스 운영 팀은 공지사항 로직을, CS팀은 환불 정책을 다루는 식이다. 이런 상황에서 하나의 모듈에 여러 팀의 책임이 얽혀 있다면 협업 충돌이 발생한다. SRP는 조직 내 협업 비용을 줄이는 가장 강력한 도구이기도 하다.


4. 너무 일찍 추상화하지 말자

4-1. 과도한 OCP 적용의 부작용

 OCP(Open/Closed Principle)를 지키겠다는 명분으로 처음부터 모든 기능에 인터페이스와 추상화를 덧씌우는 경우가 많다. 하지만 이런 구조는 실제로는 한 번도 확장되지 않는 코드에 불필요한 레이어를 추가하게 되는 함정으로 이어진다. 대표적인 예가 "혹시 나중에 필요할지 몰라서 인터페이스부터 만들자"는 식의 습관이다. 결과적으로 생성자 주입할 클래스가 하나뿐인데도 ServiceImpl이라는 이름이 붙고, 테스트 코드도 구체 클래스가 아니라 인터페이스를 억지로 주입받게 된다. 결국 아무도 교체하지 않는 구현체를 위한 인터페이스만 남게 된다. 이런 방식은 기능이 아닌 구조 중심의 코딩이 되어버린다.

 

 실무에서는 이런 “미리 걱정하는 추상화”가 오히려 개발 속도와 유지보수성을 떨어뜨린다. 새로운 개발자가 구조를 이해하려면 클래스보다는 인터페이스를 먼저 따라가야 하고, 실제로 사용하는 구현체를 추적하기 위해 코드를 오가야 한다. 그리고 변경되지 않을 코드에 대한 확장 포인트를 유지해야 하기 때문에, 코드 리딩과 유지의 피로도가 증가한다. OCP는 분명 좋은 원칙이지만, 그 적용 시점을 잘못 선택하면 ‘프레임워크 오버엔지니어링’이라는 부작용을 만든다.

4-2. YAGNI 원칙과의 충돌

 YAGNI(You Aren’t Gonna Need It)는 애자일 철학의 핵심 원칙 중 하나로, “당장 필요하지 않은 기능은 만들지 말라”는 것이다. 이 원칙은 코드의 단순성, 가독성, 유지보수성을 높이기 위한 지침이다. 그런데 OCP는 ‘확장을 고려해서 설계하라’는 원칙이고, YAGNI는 ‘확장될지도 모르는 걸 고려하지 말라’고 하니, 겉보기엔 충돌하는 것처럼 보인다.

 

 하지만 실제로는 이 둘은 시점만 다를 뿐 방향은 같다. YAGNI는 “지금 당장 필요 없는 추상화를 하지 말자”는 것이고, OCP는 “확장이 필요해졌을 때 기존 코드를 깨지 않고 추가하자”는 것이다. 즉, 확장의 필요성이 확인되기 전까지는 SRP 중심으로 작게 설계하고, 확장이 반복되거나 패턴이 보일 때 그제서야 OCP를 적용하는 것이 가장 균형 있는 접근이다.

4-3. 진짜 확장이 필요할 때 리팩토링하자

 OCP는 구조 설계가 아닌 리팩토링 타이밍에서 진가를 발휘하는 원칙이다. 현실적으로 모든 기능이 한 번에 추상화되지 않고, 하나의 기능이 3번 이상 반복될 때 추상화하거나, 기능 분기가 3개 이상 늘어날 때 전략 패턴으로 전환하는 식의 ‘트리거 조건’을 기준으로 삼으면 좋다. 이런 기준은 실무에서 매우 유용하다. “지금은 단일 클래스로 충분하지만, 이후 반복되면 추상화하자”는 전략은 설계의 유연성을 확보하면서도, 필요 없는 설계 비용을 줄여준다.

 

 예를 들어 할인 정책이 한두 가지일 때는 간단한 if-else로 처리하고, 3개 이상으로 늘어나고 정책이 부서별로 관리되기 시작할 때 인터페이스를 만들고 정책을 구현체로 분리하는 구조로 전환하는 것이다. 이렇게 하면 SRP는 유지하면서, OCP가 필요한 시점에만 효율적으로 반영된다.

4-4. 실무에서의 기준: 의도 있는 단순함

 좋은 설계는 복잡함 속에 숨어 있는 단순함이 아니라, 처음부터 단순함을 유지하기 위한 선택이다.
OCP는 처음부터 적용할수록 구조가 복잡해지기 쉬우며, 실제로 필요하지 않은 코드가 전체 흐름을 방해한다. 따라서 실무에서는 이런 기준이 필요하다.

  • 해당 기능이 3개 이상으로 반복되는가?
  • 정책이나 시나리오의 수가 늘어나는 경향이 있는가?
  • 다른 팀이나 모듈에서 해당 기능의 일부를 가져다 써야 하는가?

 이 질문에 “예”라고 답할 수 있을 때, 그제서야 추상화를 적용하는 것이 합리적이다. 즉, OCP는 언제나 “지금 적용해야 하나?”가 아니라 “이제 적용할 때인가?”를 묻는 질문에서 시작해야 한다.


5. 프레임워크 설계에서의 적용

5-1. 프레임워크는 OCP 중심으로 설계된다

 스프링(Spring), NestJS, Django 같은 대형 프레임워크들은 대부분 OCP의 정수를 담고 있는 시스템이다. 사용자는 프레임워크의 내부 코드를 수정할 수 없지만, 확장은 자유롭게 할 수 있다. 예컨대 스프링의 Bean 구조는 기존의 흐름을 수정하지 않고, 새로운 구현체를 주입하는 방식으로 확장을 가능하게 한다. 또한 @Configuration과 @Bean은 설정을 외부에서 바꿀 수 있도록 설계돼 있다. 이는 곧 OCP를 극단적으로 실현한 구조다.

 

 이처럼 프레임워크를 만드는 사람들은 기존 코드 변경 없이 유저가 동작을 바꿀 수 있게 하기 위해 추상화, 템플릿 메서드, 전략 패턴, 이벤트 리스너 등을 적극 사용한다. 개발자는 “내부를 몰라도 동작을 커스터마이징”할 수 있기 때문에, OCP가 핵심으로 작용한다. 즉, 프레임워크 설계자는 유저가 얼마나 편하게 확장할 수 있는가를 중심으로 코드를 짠다.

5-2. 도메인 코드는 SRP 중심으로 구성되어야 한다

 반대로, 프레임워크를 사용하는 개발자 입장에서 도메인 로직을 작성할 때는 SRP가 더 중요한 기준이 된다. 예를 들어 OrderService는 주문 관련된 단일 책임만을 가져야 하며, 내부에서 결제, 쿠폰, 포인트, 배송 등 다양한 책임이 혼재되어 있다면 그건 설계가 잘못된 것이다. 또한 이런 로직이 테스트 대상이 될 경우, 각각의 책임이 명확히 나눠져 있어야 단위 테스트가 가능하다. 따라서 도메인 로직은 프레임워크처럼 확장보다는 내부 변경에 강하고, 유지보수하기 쉬운 구조로 설계되어야 한다.

5-3. 프레임워크 설계자 vs 프레임워크 사용자

 OCP는 프레임워크 설계자에게, SRP는 프레임워크 사용자에게 더 중요하다. 이 차이를 명확히 이해하면, 언제 어떤 설계 원칙을 더 강조해야 하는지 방향이 잡힌다. 예를 들어 AOP(Aspect Oriented Programming)는 프레임워크 차원에서는 굉장히 유연한 확장 지점이지만, 도메인에서 이를 잘못 사용하면 책임의 흐름이 흐려져 SRP가 깨질 수 있다. 즉, SRP와 OCP는 상호 배타적인 게 아니라 역할에 따라 강조점이 달라지는 것뿐이다.

5-4. 최종 목표는 내부는 SRP, 외부는 OCP

 이상적인 시스템은 이렇게 구성된다. 내부는 작고 책임이 분명한 구성요소들이고, 외부는 이를 유연하게 조립할 수 있도록 추상화된 구조를 가진다. 모듈 내부에서는 SRP를 통해 각 기능이 깔끔하게 분리되고, 외부에서는 OCP 기반의 확장성이 존재한다. 이러한 구조는 자연스럽게 재사용성, 확장성, 테스트 용이성, 협업 효율성을 모두 만족시킨다.