|

클린 아키텍처: 무엇을 남길까

클린 아키텍처를 실무 관점에서 다시 보는 대표 이미지
원칙보다 먼저 볼 것은 의존 방향과 구조 비용이다

클린 아키텍처는 좋은 말이 많지만, 막상 프로젝트에 넣으려 하면 갑자기 손이 무거워집니다. 인터페이스를 어디까지 만들어야 하는지, Use case를 모든 기능마다 둬야 하는지, DTO와 domain model을 항상 나눠야 하는지부터 헷갈리기 시작하기 때문입니다. 이번 글에서는 클린 아키텍처가 왜 어렵게 느껴지는지, 그리고 의존 방향과 테스트 가치, ceremony 비용을 기준으로 무엇을 먼저 남기고 무엇은 나중으로 미뤄도 되는지 실무적으로 정리해보겠습니다.


클린 아키텍처가 먼저 해결하려는 문제

클린 아키텍처의 출발점은 폴더 구조가 아닙니다. 컨트롤러나 ViewModel이 요청 해석, 비즈니스 규칙, DB 저장, 외부 API 호출을 한꺼번에 떠안고 있는지, 같은 정책이 여러 진입점에 복사되는지, 핵심 규칙을 테스트하려고 해도 프레임워크를 같이 띄워야 하는지부터 봐야 합니다. 문제의 본질은 코드가 지저분해 보인다는 감상이 아니라, UI나 저장 방식의 변화가 핵심 규칙까지 끌고 들어오는 데 있습니다.

Uncle Bob의 Clean Architecture 글은 핵심 애플리케이션 규칙이 UI, framework, database에 끌려다니지 않아야 한다는 방향을 분명히 말합니다. Martin Fowler도 presentation, domain logic, data access를 나누는 큰 이유 중 하나로 한 번에 생각해야 할 범위를 줄이는 것을 듭니다. 즉 클린 아키텍처의 핵심은 예쁜 동심원이 아니라 핵심 규칙을 바깥 변화로부터 덜 흔들리게 만드는 것입니다.


왜 이렇게 어렵게 느껴질까

의존 방향은 보이지 않는데 파일 수는 바로 보인다

클린 아키텍처의 핵심 가치는 dependency direction인데, 이 가치는 처음에는 잘 보이지 않습니다. 반면 interface, mapper, request model, response model, repository, use case를 나누기 시작하면 클래스 수와 보일러플레이트는 바로 눈에 들어옵니다. 그래서 많은 팀이 좋아진 구조보다 멀리 돌아가는 느낌을 먼저 받습니다. 독자가 거부감을 느끼는 이유가 게으름이어서가 아니라, 비용이 눈앞에 먼저 보이기 때문입니다.

테스트 이득이 모든 팀에 같은 크기로 오지 않는다

핵심 규칙이 프레임워크 바깥에 있으면 테스트가 쉬워지는 것은 맞습니다. 다만 그 가치의 크기는 시스템마다 다릅니다. 단순 CRUD가 대부분인 내부 관리자 도구에서는 구조 비용이 더 크게 느껴질 수 있고, 결제·정산·재고처럼 규칙이 자주 바뀌고 실패 비용이 큰 서비스에서는 테스트 가능한 경계가 훨씬 큰 값을 가집니다. 테스트성은 늘 좋은 가치이지만, 지금 얼마까지 비용을 낼 만한지는 프로젝트 맥락에 따라 달라집니다.

레이어는 쉬운데 경계는 어렵다

presentation, application, domain, infrastructure라는 이름 자체는 어렵지 않습니다. 어려운 것은 실제 코드에서 어디까지를 domain 규칙으로 보고, 어디까지를 application 흐름으로 볼지 정하는 일입니다. 할인 계산, 재시도 정책, 응답 모델 변환, 외부 실패 보상 흐름은 현실 프로젝트에서 자주 서로 붙어 있습니다. 그래서 레이어 이름을 외우는 것보다 경계 감각을 익히는 데 시간이 더 오래 걸립니다.

많은 예제가 완성형 구조만 보여준다

입문 자료는 대개 잘 정리된 최종 상태를 보여줍니다. 하지만 실제 팀은 대부분 레거시 위에서 조금씩 옮겨갑니다. 처음부터 use case, repository interface, mapper, presenter를 모두 깔아두기보다, 분리할 가치가 큰 경계부터 잘라내는 경우가 더 많습니다. 완성형 예시만 많이 보면 지금 프로젝트는 너무 지저분하거나, 반대로 다 넣기엔 너무 과하다고 느끼기 쉽습니다. 둘 다 자연스러운 반응입니다.


정말 남겨야 할 것

의존 방향

실무에서 가장 먼저 남겨야 할 것은 의존 방향입니다. 핵심 규칙이 DB 라이브러리, HTTP 프레임워크, 특정 SDK 호출 순서에 직접 끌려가면 구조는 금방 흔들립니다. Microsoft의 N-tier 설명도 레이어가 책임을 나누고 의존성을 관리한다고 말합니다. 클린 아키텍처식으로 보면, 안쪽 정책 코드가 바깥 구현 세부사항을 직접 모르도록 만드는 것만으로도 이미 큰 개선이 일어납니다.

핵심 규칙의 분리

모든 코드를 domain model로 만들 필요는 없습니다. 하지만 시스템의 핵심 판단이 되는 규칙은 화면이나 컨트롤러에서 떨어져 나와야 합니다. 어떤 주문이 취소 가능한지, 어떤 쿠폰이 적용 가능한지, 어떤 사용자가 이 기능을 실행할 수 있는지, 어떤 상태 전이가 허용되는지 같은 규칙이 여기저기 흩어지면 구조는 금방 불안해집니다. 반대로 이런 규칙이 비교적 안정된 중심부에 모여 있으면 나머지 계층은 훨씬 덜 흔들립니다.

테스트 가능한 경계

모든 것을 단위 테스트해야 한다는 뜻은 아닙니다. 다만 자주 바뀌거나 잘못되면 위험한 핵심 규칙은 프레임워크 없이 검증할 수 있는 경계가 있는 편이 좋습니다. 이런 경계는 실행 속도와 피드백 루프를 개선하고, 변경에 대한 자신감을 올려줍니다. 특히 사람 수가 적은 팀일수록 머릿속으로만 안전성을 보장하기 어려워서 이 이점이 더 크게 다가옵니다.


자주 과해지는 것

구현 하나뿐인 인터페이스

구현체가 하나뿐인데도 습관적으로 interface와 impl을 나누는 경우가 있습니다. OrderRepository처럼 저장 기술을 바꾸거나 테스트 대체가 필요한 경계는 인터페이스가 의미 있을 수 있지만, 내부 계산 유틸리티까지 모두 추상화하면 구조 소음만 커질 수 있습니다. 질문은 단순합니다. 이 추상화가 핵심 정책을 보호하는가, 아니면 모양만 그럴듯하게 만드는가입니다.

모든 요청마다 Use case 클래스 만들기

use case는 여러 단계의 흐름 조율, 보상 처리, 여러 진입점 재사용, 핵심 시나리오 테스트 경계 만들기에 특히 유용합니다. 하지만 repository 한 줄 호출하고 끝나는 CRUD까지 기능마다 클래스를 하나씩 만드는 관성은 금방 ceremony가 됩니다. use case는 이름이 멋져서 쓰는 것이 아니라, 흐름을 명확히 하고 변경 범위를 줄일 때 쓰는 도구로 보는 편이 맞습니다.

모델의 완전 분리

외부 API 응답 구조가 자주 바뀌거나 저장 모델과 비즈니스 모델의 수명이 다를 때는 모델 분리가 큰 가치를 줍니다. 하지만 작은 서비스에서 DTO, Entity, Domain Model, Response Model이 거의 같은 필드만 복사하고 있다면, 그건 경계 보호보다 ceremony 쪽일 가능성이 큽니다. 모델 분리는 경계 보호 가치가 복제 비용보다 클 때 정당화됩니다.


코드로 보면 왜 체감이 갈릴까

아래 코드는 관리자 백오피스에서 주문 승인 기능을 급하게 붙인 예시입니다. 처음에는 꽤 빨라 보이지만, 승인 조건이 늘고 알림 채널이 늘고 진입점이 API와 배치로 나뉘기 시작하면 흔들리기 쉬운 구조입니다. 핵심 규칙, 외부 알림, 저장 세부사항, 응답 형식이 한곳에 같이 있기 때문입니다.

public class OrderController {

    public ApproveOrderResponse approve(Long orderId) {
        OrderEntity order = orderRepository.findById(orderId)
            .orElseThrow(() -> new IllegalArgumentException("주문이 없습니다."));

        if (!order.getStatus().equals("PAID")) {
            throw new IllegalStateException("결제 완료 주문만 승인할 수 있습니다.");
        }

        if (order.getTotalPrice() >= 300000) {
            slackClient.send("고액 주문 승인: " + orderId);
        }

        order.setStatus("APPROVED");
        orderRepository.save(order);

        return new ApproveOrderResponse(order.getId(), order.getStatus());
    }
}

아래처럼 나누면 클래스는 조금 늘지만 중심 흐름은 더 또렷해집니다. 주문 승인 규칙은 Order 안으로 들어오고, 외부 알림은 경계 뒤로 밀리고, API 응답 형식은 결과 객체 바깥에서 다룰 수 있게 됩니다. 포인트는 클린 아키텍처답게 보이느냐가 아니라, 정책과 구현 상세를 읽는 문맥이 분리되었느냐입니다.

public class ApproveOrderUseCase {

    private final OrderRepository orderRepository;
    private final ApprovalNotifier approvalNotifier;

    public ApproveOrderUseCase(
            OrderRepository orderRepository,
            ApprovalNotifier approvalNotifier
    ) {
        this.orderRepository = orderRepository;
        this.approvalNotifier = approvalNotifier;
    }

    public ApprovedOrderResult execute(Long orderId) {
        Order order = orderRepository.get(orderId);
        order.approve();
        orderRepository.save(order);

        if (order.requiresAttention()) {
            approvalNotifier.notifyHighValueApproval(order);
        }

        return new ApprovedOrderResult(order.getId(), order.getStatus());
    }
}

작은 팀과 중간 규모 팀의 기준

1~3명 정도의 작은 팀이나 혼자 운영하는 서비스라면 처음부터 완성형 구조를 강하게 밀어붙일 필요는 없습니다. 컨트롤러나 UI 코드에 핵심 규칙을 오래 두지 않고, 저장소·외부 API 같은 경계는 핵심 흐름과 직접 뒤섞지 않고, 자주 바뀌는 정책을 프레임워크 없이 검증 가능한 함수나 객체로 옮기는 것만으로도 큰 효과를 볼 수 있습니다. 이 단계에서는 구현 하나뿐인 인터페이스를 전부 나누거나, 모델을 무조건 네 겹으로 분리하거나, use case 클래스를 기능마다 하나씩 만드는 일은 늦춰도 됩니다.

반대로 팀이 커지거나 서비스가 여러 기능으로 분화되면 구조적 합의가 코드 품질만큼 중요해집니다. 같은 규칙을 여러 API, 배치, 관리자 도구가 함께 쓰고, 수정이 자주 번지고, 리뷰어가 어디가 정책이고 어디가 구현인지 자주 헷갈린다면 use case, repository boundary, model mapping이 실제 이득을 주기 시작합니다. 큰 팀에서 구조가 필요한 이유는 이론이 더 중요해서가 아니라 사람 사이의 충돌 비용이 커지기 때문입니다.


현실적인 도입 순서

  1. 핵심 규칙이 프레젠테이션과 저장소 사이에 섞여 있는 지점부터 찾는다
  2. 자주 바뀌는 규칙을 함수나 도메인 객체로 먼저 뽑아낸다
  3. 외부 시스템 호출을 경계 뒤로 밀어 핵심 흐름을 안정화한다
  4. 여러 진입점에서 반복되는 흐름이 보이면 use case나 application service를 정리한다
  5. 모델 복제 비용보다 경계 보호 가치가 커질 때 매핑을 늘린다

이 순서의 장점은 구조가 문제를 따라 자란다는 점입니다. 교과서 구조를 먼저 깔고 현실을 끼워 맞추는 것보다, 실제 변경 비용이 큰 지점부터 분리하는 편이 훨씬 실용적입니다.


빠르게 점검하는 체크리스트

  • UI나 컨트롤러 변경이 핵심 규칙 수정으로 자주 번지는가
  • 같은 비즈니스 규칙이 여러 진입점에 복사되어 있는가
  • 핵심 정책을 테스트하려면 DB나 외부 API까지 같이 붙여야 하는가
  • 저장 방식이나 SDK 선택이 정책 코드에 직접 새어 들어오는가
  • 팀원이 늘면서 어디서 무엇을 고쳐야 할지 합의 비용이 커졌는가

여기에 여러 개가 그렇다라면 클린 아키텍처식 분리가 도움될 가능성이 큽니다. 반대로 대부분 아니다라면, 아직은 구조를 더 늘리기보다 단순한 흐름을 유지하는 편이 낫습니다. 없는 복잡성을 미리 상상해서 너무 일찍 구조를 무겁게 만드는 것이 더 큰 문제일 수 있습니다.


정리

클린 아키텍처는 어렵게 느껴질 만합니다. 좋은 설계 원칙이지만, 이득은 늦게 보이고 비용은 빨리 보이기 때문입니다. 그래서 모든 레이어를 완성형으로 갖추는 것보다 먼저 지켜야 할 것은 의존 방향, 핵심 규칙 분리, 테스트 가능한 경계입니다. 나머지 인터페이스, use case, 매핑, 모델 분리는 변화 축이 분명해질 때 늘려도 늦지 않습니다. 결국 중요한 질문은 지금 내 팀과 내 서비스에서 무엇이 핵심 정책이고, 무엇이 바깥 구현 상세인가입니다. 이 구분이 선명해질수록 클린 아키텍처는 교조적 패턴이 아니라 꽤 실용적인 도구가 됩니다.

함께 보면 좋은 글로는 레이어드 아키텍처란 무엇인가, 의존성 역전 원칙은 왜 어렵게 느껴질까, SOLID 원칙, 실무에서는 어떻게 봐야 할까, 안드로이드 클린 아키텍처는 꼭 필요할까를 이어서 읽어보면 좋습니다. 외부 참고 자료로는 Uncle Bob의 Clean Architecture 글, Martin Fowler의 Presentation Domain Data Layering, Microsoft Learn의 N-tier Architecture를 함께 보면 맥락이 더 선명해집니다.

함께보면 좋은 글