|

SOLID 원칙, 실무에서는 어떻게 봐야 할까

SOLID 원칙 실무 적용 대표 이미지
SOLID를 암기가 아니라 설계 판단 기준으로 다시 보기

SOLID 원칙은 이제 너무 유명해서, 다들 한 번쯤은 들어봤습니다. 그런데 막상 실무에서는 이 원칙을 몰라서보다, 어디까지 적용해야 하는지 감이 안 잡혀서 더 자주 흔들립니다.

처음 설계를 배울 때는 다섯 글자가 꽤 그럴듯하게 보입니다. 하지만 실제 프로젝트에 들어가면 상황이 금방 복잡해집니다. 요구사항은 자꾸 바뀌고, 일정은 짧고, 이미 돌아가는 코드는 쉽게 건드리기 어렵기 때문입니다.

그래서 이 글은 SOLID를 다시 외우는 글이 아닙니다. 다섯 원칙을 변경 비용을 줄이는 설계 판단이라는 눈으로 다시 보려는 글입니다.

결론부터 말하면 SOLID는 여전히 쓸모 있습니다. 다만 만능 주문처럼 들고 다니면 코드가 좋아지기보다, 추상화만 늘고 읽기는 더 어려워질 수 있습니다.


SOLID 오해 포인트

SOLID가 자주 오해되는 이유는 단순합니다. 설명은 깔끔한데, 현실 코드는 그렇게 깔끔하지 않기 때문입니다. 교육 자료에서는 다섯 항목을 따로 떼서 보여주지만, 실무에서는 이 다섯 가지가 한 코드 안에서 같이 얽혀 움직입니다.

  • SRP를 클래스 작게 나누기 규칙으로만 받아들인다
  • OCP를 미래를 위한 인터페이스 미리 깔아두기로 이해한다
  • LSP를 상속 문법 차원의 문제로만 좁혀 본다
  • ISP를 인터페이스를 최대한 잘게 찢는 기술로 오해한다
  • DIP를 concrete class를 쓰면 안 되는 규칙처럼 받아들인다

이렇게 되면 원칙이 설계를 도와주는 기준이 아니라, 코드를 쓰기 전부터 사람을 긴장시키는 체크리스트가 됩니다. 그러면 코드가 좋아지는 게 아니라 설명만 복잡해집니다.

실무적으로 다시 읽으려면 출발점을 바꿔야 합니다. SOLID가 줄이려는 것은 코드 줄 수가 아니라 변경의 파급 범위다. 이 감각이 먼저 잡히면 뒤가 훨씬 편해집니다.

SOLID 원칙을 암기 중심으로 볼 때와 변경 비용 중심으로 볼 때의 흐름 비교
원칙 암기 중심 설계와 변경 비용 중심 설계의 차이

SRP와 변경 이유

SRP (Single Responsibility Principle)를 처음 배울 때 가장 많이 하는 오해가 있습니다. 클래스가 작으면 무조건 좋고, 메서드가 많으면 책임이 많다고 생각하는 겁니다.

그런데 실무에서는 코드 크기보다 무슨 이유로 바뀌는지가 더 중요합니다. 책임은 기능 개수보다 변경 이유에 더 가깝습니다.

예를 들어 주문 완료 서비스 안에 가격 계산, 주문 저장, 완료 메일 발송이 한 메서드에 같이 들어 있다고 해보겠습니다. 할인 정책이 바뀌는 날도 있고, 메일 템플릿만 바뀌는 날도 있고, DB 저장 방식이 바뀌는 날도 있습니다. 겉으로는 주문 완료라는 한 기능이지만, 실제로는 서로 다른 이유로 자주 수정됩니다.

이런 경우 가격 정책은 가격 정책끼리, 알림은 알림끼리 경계를 나누는 편이 더 자연스럽습니다. 그래야 쿠폰 규칙을 손볼 때 메일 발송 코드까지 같이 읽지 않아도 됩니다.

  • 함께 둬야 하는 경우: 할인 계산, 쿠폰 적용, 반올림 규칙처럼 같은 정책 변화에 함께 흔들리는 로직
  • 분리해야 하는 경우: 가격 계산과 이메일 발송처럼 수정 이유와 담당자가 자주 달라지는 로직
  • 실무 체크: 이 둘은 같은 회의에서 함께 바뀌는가, 아니면 서로 다른 티켓으로 따로 바뀌는가

SRP를 너무 얇게 적용하면 래퍼 클래스만 늘어납니다. 반대로 너무 넓게 잡으면 수정 이유가 다른 코드가 한 덩어리로 엉깁니다. 그래서 SRP는 분해 기술이라기보다 경계 설정 기술에 더 가깝습니다.

SRP를 더 자세히 보고 싶다면 객체지향에서 책임을 잘 나누는 기준 글도 자연스럽게 이어집니다.


OCP와 확장 지점

OCP (Open-Closed Principle)는 확장에는 열려 있고 수정에는 닫혀 있어야 한다는 말로 유명합니다. 문장만 보면 멋있지만, 그대로 믿고 들어가면 미래를 너무 많이 상상하게 됩니다.

실무에서는 모든 가능성을 미리 구조로 만들어두는 팀보다, 반복되는 변화 패턴이 보일 때 정확히 여는 팀이 보통 더 잘 갑니다.

예를 들어 초기에 카드 결제 하나만 있는 서비스가 있다고 해보겠습니다. 이 단계에서 PaymentProcessor 인터페이스, 전략 패턴, 팩토리, 주입 설정을 한꺼번에 다 만들어두면 언뜻 준비성이 좋아 보일 수 있습니다. 그런데 실제로 6개월 동안 결제 수단이 하나도 안 늘어나면, 그 추상화는 변화를 막아준 게 아니라 현재 코드를 더 멀리 돌아가게 만든 셈이 됩니다.

반대로 카드, 계좌이체, 간편결제, 포인트 결제가 계속 추가되는 흐름이 보이면 이야기가 달라집니다. 그때는 결제 수단별 분기문을 핵심 흐름 밖으로 밀어내는 것이 확실히 이득입니다.

  • OCP가 잘 맞는 경우: 같은 종류의 옵션이 반복해서 추가될 때
  • OCP가 잘 맞는 경우: if-else 분기가 비슷한 형태로 계속 자랄 때
  • 서두르지 않아도 되는 경우: 구현이 하나뿐이고 변형의 방향이 아직 보이지 않을 때

OCP를 잘 쓰는 팀은 ‘절대 수정하지 않는다’보다 ‘핵심 흐름을 덜 흔들리게 만든다’에 집중합니다. 자주 바뀌는 축을 주변으로 밀어내는 감각이 핵심입니다.


LSP와 계약

LSP (Liskov Substitution Principle)는 이름만 보면 어렵지만, 실무 감각으로 바꾸면 꽤 단순합니다. 부모 타입을 기대하는 코드가 자식 타입을 받아도 당황하지 않아야 한다는 뜻입니다.

여기서 당황한다는 건 컴파일이 깨진다는 뜻만이 아닙니다. 반환값의 의미가 바뀌거나, 예외 정책이 달라지거나, 호출 순서의 전제가 달라지는 것도 모두 문제입니다.

예를 들어 `DiscountPolicy` 인터페이스가 있다고 해보겠습니다. 호출하는 쪽은 어떤 구현이 오더라도 할인 금액이 0 이상이라고 믿고 계산합니다. 그런데 어느 날 한 구현이 음수를 반환해서 사실상 추가 요금을 의미하게 만들면, 호출 코드는 갑자기 방어 로직을 추가해야 합니다. 타입은 같지만 약속은 이미 달라진 겁니다.

비슷한 예로 파일 저장소 인터페이스의 구현 하나는 파일이 없으면 null을 반환하고, 다른 구현은 예외를 던진다고 해보겠습니다. 사용하는 쪽은 매번 구현별 분기를 알아야 하니, 더 이상 치환 가능한 구조라고 보기 어렵습니다.

  • 문제 신호: 어떤 구현은 실패 시 null을 주고, 다른 구현은 예외를 던진다
  • 문제 신호: 어떤 구현은 메서드 호출 전에 추가 초기화를 요구한다
  • 문제 신호: 같은 이름의 메서드인데 부작용 범위가 구현마다 다르다

그래서 LSP는 상속을 예쁘게 쓰는 규칙이 아니라, 치환 가능한 약속을 지키는 규칙이라고 보는 편이 맞습니다. 이 관점이 잡히면 상속보다 조합이 왜 더 안전한지까지 자연스럽게 이어집니다.


ISP와 소비자 기준

ISP (Interface Segregation Principle)도 자주 오해됩니다. 큰 인터페이스를 나누라는 말만 남으면, 인터페이스를 잘게 쪼개는 행위 자체가 목표가 되기 쉽습니다.

그런데 실무에서는 인터페이스의 크기보다 누가 무엇을 억지로 알아야 하는지를 보는 편이 더 정확합니다.

예를 들어 관리자 백오피스에서는 `save`, `delete`, `publish`, `archive`가 모두 필요할 수 있습니다. 하지만 사용자에게 목록만 보여주는 조회 화면도 같은 `ArticleService`를 바라보면서 이 메서드들을 전부 의존하고 있다면, 그건 구조가 너무 무겁다는 신호입니다.

조회 화면은 목록 조회와 상세 조회만 알면 충분한데, 관리 기능까지 같은 인터페이스에 섞여 있으면 읽는 사람도 헷갈리고 테스트 더블도 쓸데없이 커집니다. 이런 경우 `ArticleQueryService`와 `ArticleCommandService`처럼 소비자 기준으로 나누는 편이 훨씬 낫습니다.

  • 핵심 질문: 이 소비자는 정말 이 메서드들을 모두 알아야 하는가
  • 좋은 분리: 조회 전용 기능과 관리 기능의 사용자가 다를 때
  • 과한 분리: 항상 같이 쓰는 메서드까지 의미 없이 찢어놓을 때

ISP는 결국 인터페이스의 크기를 다루는 원칙이 아니라, 불필요한 의존성의 무게를 줄이는 원칙입니다. 파일 수를 늘리는 것이 아니라 소비자의 이해 비용을 줄여야 합니다.


DIP와 핵심 흐름

DIP (Dependency Inversion Principle)를 형식적으로 배우면 인터페이스를 하나 더 만드는 일 자체가 좋아 보일 때가 있습니다. 하지만 구현이 하나뿐인 얇은 추상화는 종종 팀에 아무 자유도 주지 못합니다.

DIP의 진짜 핵심은 높은 수준의 정책 코드가 낮은 수준의 기술 상세에 끌려다니지 않게 만드는 것입니다. 즉 주문의 핵심 흐름이 특정 DB 라이브러리, 외부 SDK, 프레임워크 호출 순서 때문에 자꾸 흔들리지 않게 보호하는 쪽이 더 중요합니다.

예를 들어 주문 완료 서비스가 직접 `FirebaseMessaging`, `JavaMailSender`, 특정 ORM 저장 호출을 모두 알고 있다고 해보겠습니다. 이 상태에서는 알림 수단을 바꾸거나 저장소를 교체할 때 핵심 서비스 코드까지 같이 흔들립니다.

반대로 `NotificationSender`, `OrderRepository` 같은 경계 뒤로 세부 구현을 밀어두면, 주문 완료라는 중심 흐름은 비교적 안정적으로 유지할 수 있습니다. 테스트에서도 핵심 시나리오를 검증할 때 외부 인프라를 전부 세팅하지 않아도 됩니다.

  • 좋은 적용: 핵심 정책이 외부 메시지 전송기나 저장 방식 교체에 덜 흔들리게 만들기
  • 좋은 적용: 테스트에서 진짜 의미 있는 대체가 가능하도록 경계 만들기
  • 과한 적용: 구현 하나를 감추기 위해 이름만 다른 인터페이스를 습관적으로 추가하기

DIP가 잘 먹히는 곳은 대개 시스템 경계입니다. 저장소, 외부 결제, 알림 발송, 파일 시스템 같은 영역입니다. 반대로 단순한 내부 유틸리티까지 전부 추상화하면 보호해야 할 핵심보다 구조적 소음이 더 커질 수 있습니다.

인터페이스의 의미 자체를 더 실무적으로 보고 싶다면 인터페이스는 왜 필요할까 글도 같이 읽기 좋습니다.


원칙의 연결

실무에서 SOLID를 어려워하는 또 하나의 이유는 원칙을 각각 따로 기억하기 때문입니다. 실제 설계에서는 SRP, OCP, DIP가 한 번에 같이 움직이는 경우가 많습니다.

예를 들어 가격 정책을 주문 완료 서비스에서 분리하면, 우선 변경 이유가 갈라지므로 SRP 관점에서 이득이 있습니다. 동시에 새로운 할인 정책을 추가할 때 핵심 흐름을 덜 건드리게 되니 OCP와도 연결됩니다. 그 정책을 바깥에서 주입받게 만들면 DIP의 이점도 같이 생깁니다.

반대로 원칙 하나를 과하게 밀어붙이면 다른 쪽이 불편해지기도 합니다. OCP를 너무 의식해서 추상화를 빨리 만들면 SRP보다 탐색 비용이 더 커질 수 있고, ISP를 지나치게 쪼개면 시스템의 개념 구조가 흐려질 수 있습니다.

그래서 SOLID를 잘 쓴다는 건 다섯 개를 각각 체크하는 일이 아닙니다. 변경, 계약, 의존성, 탐색 비용 사이에서 균형을 잡는 일에 가깝습니다.


과한 추상화

실무에서 SOLID가 욕을 먹는 이유는 원칙이 틀려서가 아니라, 원칙이 좋은 명분이 되어 과한 추상화를 정당화하기 쉬워서입니다.

  • 구현은 하나뿐인데 인터페이스부터 있는 구조
  • 클래스가 다른 클래스 메서드를 그대로 위임만 하는 구조
  • 분기문 하나를 감추려다 파일 수가 과하게 늘어난 구조
  • 핵심 흐름을 따라가려면 여러 계층을 계속 왕복해야 하는 구조

이 상태가 되면 변경 비용보다 탐색 비용이 먼저 커집니다. 코드를 고치기 전에 어디를 읽어야 하는지부터 헷갈리기 시작하면, 설계는 이미 팀의 속도를 떨어뜨리고 있는 것입니다.

여기서 자주 같이 떠오르는 개념이 Martin Fowler의 Anemic Domain Model입니다. 객체는 데이터만 들고 있고 판단은 모두 서비스 계층에 몰려 있으면, 겉으로는 분리되어 보여도 실제로는 도메인 모델이 힘을 잃기 쉽습니다.

중요한 것은 추상화의 개수가 아닙니다. 추상화가 실제로 읽기 쉬움, 변경의 안전성, 테스트 가능성 중 하나라도 분명하게 개선하는가를 물어야 합니다.


코드 예시

아래 코드는 주문 완료 과정에서 가격 계산, 저장, 알림을 한 메서드에 함께 넣은 예시입니다. 초기에는 이렇게 시작해도 됩니다. 문제는 요구사항이 붙기 시작할 때입니다. 수정 이유가 다른 코드가 한 지점으로 몰리면, 작은 변경도 점점 부담스러워집니다.

public class OrderService {

    public void complete(Order order) {
        int total = order.getPrice() - order.getCouponDiscount();

        if (order.isVip()) {
            total -= 1000;
        }

        order.setTotalPrice(total);
        orderRepository.save(order);
        mailSender.send(order.getUserEmail(), "주문이 완료되었습니다.");
    }
}

이 메서드 안에는 최소 세 가지 변화 축이 들어 있습니다. 가격 정책, 저장 방식, 알림 방식입니다. 각각 바뀌는 이유도 다르고, 테스트하는 관심사도 다릅니다. 그래서 기능이 조금만 늘어나도 if 문과 외부 의존이 함께 자라기 쉽습니다.

아래처럼 경계를 나누면 클래스 수는 늘어날 수 있지만, 중심 흐름은 오히려 더 또렷해집니다. 중요한 것은 파일 개수가 아니라 무엇이 정책이고 무엇이 구현 상세인지가 한눈에 보인다는 점입니다.

public class OrderService {

    private final PricingPolicy pricingPolicy;
    private final OrderRepository orderRepository;
    private final NotificationSender notificationSender;

    public OrderService(
            PricingPolicy pricingPolicy,
            OrderRepository orderRepository,
            NotificationSender notificationSender
    ) {
        this.pricingPolicy = pricingPolicy;
        this.orderRepository = orderRepository;
        this.notificationSender = notificationSender;
    }

    public void complete(Order order) {
        int total = pricingPolicy.calculate(order);
        order.complete(total);
        orderRepository.save(order);
        notificationSender.sendOrderCompleted(order);
    }
}

물론 두 번째 코드가 언제나 정답은 아닙니다. 정책 변화가 거의 없고 시스템도 아주 작다면 첫 번째 구조가 더 단순할 수 있습니다. 핵심은 ‘SOLID답게 보이느냐’가 아니라 실제로 변경 범위가 줄고 읽는 흐름이 또렷해졌는가입니다.


잘 맞는 상황

  • 같은 종류의 변경이 반복해서 들어올 때
  • 분기문이 계속 늘어나며 확장 지점이 눈에 보일 때
  • 핵심 정책 코드가 외부 구현 상세 때문에 자주 흔들릴 때
  • 테스트 범위를 줄이고 영향도를 예측해야 할 때

이런 상황에서는 SOLID가 추상적인 표어가 아니라 꽤 실용적인 도구가 됩니다. 특히 여러 사람이 같이 만지는 코드베이스에서는 변경 충돌 범위를 줄여준다는 점이 큽니다.


과한 적용 신호

  • 아직 변화 축이 보이지 않는데 미래 확장을 너무 많이 상상할 때
  • 작은 기능에도 인터페이스와 계층을 관성처럼 붙일 때
  • 원칙 이름을 지키는 일이 독자와 팀원의 이해보다 앞설 때
  • 실제 변경 비용보다 파일 수와 추상화 수가 더 빨리 늘어날 때

이 단계로 가면 SOLID는 설계 원칙이 아니라 일종의 의식이 됩니다. 코드는 딱히 안전해지지 않았는데, 설명만 거창해지는 상태입니다. 새로 합류한 사람에게 특히 비싸게 작동하는 구조이기도 합니다.


실무 체크 질문

  1. 지금 이 코드는 무엇 때문에 바뀌는가
  2. 그 변화 이유는 서로 같은가, 아니면 다른가
  3. 이 분리는 변경 범위를 줄이는가, 아니면 파일 탐색만 늘리는가
  4. 이 추상화는 실제 소비자에게 어떤 자유를 주는가
  5. 타입을 바꿔 끼웠을 때 호출 코드가 안정적으로 유지되는가
  6. 이 구조는 한 달 뒤의 팀원이 읽었을 때 더 빨리 이해될까

이 질문들로 보면 SOLID는 훨씬 덜 추상적으로 느껴집니다. 원칙 이름을 기억하는 것보다, 어떤 경계가 자주 깨지고 어디서 변경이 퍼지는지 보는 감각이 더 중요합니다.


마무리 정리

SOLID는 실무에서 쓸모없어서 오해되는 것이 아닙니다. 오히려 너무 많이 소개되다 보니, 원칙이 나온 맥락보다 표어만 남아서 오해되는 경우가 많습니다.

그래서 다시 볼 때는 이렇게 정리하면 됩니다. 역할을 잘 나누고, 치환 가능한 계약을 지키고, 핵심 정책이 세부 구현에 끌려가지 않게 만들되, 그 과정에서 현재 코드를 불필요하게 무겁게 만들지는 말 것.

결국 좋은 설계는 원칙 이름을 많이 붙인 설계가 아닙니다. 변경이 덜 퍼지고, 읽는 흐름이 덜 끊기고, 팀이 더 안전하게 고칠 수 있는 설계가 좋은 설계입니다. SOLID는 그 판단을 도와줄 때 가장 쓸모가 있습니다.

관련해 함께 읽으면 좋은 글로는 객체지향 설계에서 결합도가 중요한 이유, 추상 클래스와 인터페이스 차이, 상속 vs 조합이 있습니다.

외부 참고로는 SOLID 개요, SRP 설명, Anemic Domain Model 정리를 참고할 수 있습니다.

함께보면 좋은 글