Software Architecture/Architectural Styles

Architectural Styles - 02. 계층형 아키텍처

계층형 아키텍처 — 가장 흔한 구조가 품은 함정

Spring Boot 프로젝트를 하나 열어보자. controller, service, repository 패키지가 보인다. 어쩌면 자바 웹 애플리케이션의 90%가 이 구조로 돼 있다. 이름은 계층형 아키텍처(layered architecture). 너무 흔해서 '스타일'로 의식하지 못할 정도다. 하지만 이 흔함이 숨기는 함정이 있다 — 계층을 나눴다고 해서 결합이 낮아지는 건 아니다. 계층 사이의 규칙이 결합을 낮춘다.

비유로 감 잡기 — 식당의 층별 배치

3층짜리 식당을 상상하자. 1층은 홀(손님 응대, 메뉴 설명). 2층은 주방(요리). 3층은 창고(재료 보관). 손님이 1층에서 주문하면 주문서가 2층으로 올라가고, 2층 주방이 3층 창고에서 재료를 꺼내 요리한 뒤 결과를 다시 1층으로 내려보낸다.

계층형의 핵심 규칙은 계단을 통해서만 층을 오갈 수 있다는 것이다. 1층 홀 직원이 3층 창고에 직접 뛰어들어 재료를 꺼내 오는 건 규칙 위반이다. 창고 관리자(3층)는 홀 직원(1층)에게 직접 알려주지 않는다 — 모든 소통은 2층(주방)을 거친다.

계층형 아키텍처의 이상이 이 식당이다. 그런데 현실은 어떤가. 바쁜 저녁에 2층 주방이 병목이면, 1층 홀 직원이 "빨리빨리" 하며 직접 3층 창고로 뛰어올라가 재료를 꺼내 오는 일이 벌어진다. 이게 계층 침투다. 식당은 여전히 "3층"이지만, 층 간 규칙이 무너진 카오스가 됐다.

비유의 한계 — 식당 비유는 물리적 층을 강조하지만, 소프트웨어 계층은 '논리적' 분리지 물리적 분리가 아니다. 같은 프로세스, 같은 메모리에 있다. 계단(메서드 호출)은 싼 반면, 층 침투(import 한 줄)도 싸다. 비유는 '규칙 강제의 어려움'에서 무너진다.

계층형의 기본 형태

Martin Fowler가 (2002)에서 정리한 세 계층이 사실상 표준이 됐다.

  • Presentation 계층 — UI, 컨트롤러, API 엔드포인트. 외부 입력을 받고 응답을 내보낸다.
  • Business(Domain) 계층 — 비즈니스 규칙, 서비스 로직. 이 시스템이 '무엇을 하는가'를 담는다.
  • Persistence(Data) 계층 — DB 접근, 외부 API 호출. 데이터를 영속화하고 가져온다.

Mark Richards와 Neal Ford가 (2020)에서 잡는 정의는 이를 일반화한다 — "역할에 따라 수평 계층으로 나누고, 각 계층은 인접한 계층과만 통신하는 구조". 기능별(주문/결제/배송)이 아니라 역할별(UI/로직/데이터)로 자르는 게 핵심이다.

flowchart TD
    P[Presentation 계층<br/>controller, REST endpoint] -->|호출| B[Business 계층<br/>service, domain logic]
    B -->|호출| D[Persistence 계층<br/>repository, DAO]
    D -->|SQL/API| DB[(Database / External)]
    P -.->|계층 침투 - 금지| DB
    D -.->|역방향 의존 - 금지| B

엄격한 계층 vs 느슨한 계층

Fowler와 Richards-Ford가 구분하는 두 가지.

  • 엄격한 계층(strictly layered) — 한 계층은 바로 아래 계층에만 의존한다. Presentation은 Business에만 닿고, Persistence를 직접 부를 수 없다.
  • 느슨한 계층(loosely layered) — 한 계층은 아래의 어떤 계층에든 의존할 수 있다. Presentation이 Business를 건너뛰고 Persistence를 직접 부르는 걸 허용한다.

느슨한 계층은 편하다. 빠른 구현에 유리하다. 하지만 Fowler가 경고하듯, "편함"이 곧 "경계 무너짐"으로 이어진다. Presentation이 Persistence를 직접 부르는 순간, 비즈니스 규칙이 Business 계층이 아니라 Presentation 곳곳에 흩어지기 시작한다. 같은 '주문 생성' 규칙이 세 군데 컨트롤러에서 각기 다르게 구현되는 일이 벌어진다.

흔한 트레이드오프: 초기엔 느슨하게 시작해 속도를 내고, 복잡도가 올라가면 엄격하게 당긴다. 하지만 "나중에 당기겠다"는 약속은 지켜지지 않는 경우가 많다. 느슨함이 만든 의존성이 쌓이고, 나중에 당기려면 전체를 다시 짜야 한다는 게 드러난다.

계층 침투 — 가장 흔한 위반

계층형이 실패하는 가장 흔한 패턴. Presentation이 Persistence를 직접 부르거나, Business가 다른 Business의 내부 구현을 건드리는 일이다.

// 전형적인 계층 침투 — 컨트롤러가 리포지토리 직접 호출
@RestController
class OrderController {
    private final OrderRepository orderRepository; // 계층 건너뜀
    private final PaymentGateway paymentGateway;   // 외부 API 직접 호출

    @PostMapping("/orders")
    public Order createOrder(@RequestBody OrderRequest req) {
        // 비즈니스 로직이 컨트롤러에 새겨짐
        if (req.total().compareTo(BigDecimal.ZERO) <= 0) {
            throw new IllegalArgumentException("총액은 0보다 커야 함");
        }
        Order order = new Order(req);
        orderRepository.save(order);          // persistence 직접
        paymentGateway.charge(order.total()); // 외부 직접
        return order;
    }
}

이 코드는 돌아간다. 그래서 더 위험하다. '잘 돌아가는' 상태에서 규칙 위반이 쌓인다. 6개월 뒤 같은 '총액 검증' 규칙이 AdminController, MobileOrderController, BatchOrderJob에 각각 다시 구현돼 있다. 한 곳의 규칙을 바꾸면 세 곳을 찾아 고쳐야 한다 — 변경 증폭(change amplification). John Ousterhout가 (2nd ed, 2021)에서 지적한 '얕은 모듈'의 함정이 여기서 드러난다.

계층 침투 감지 — 코드에서 어떻게 알아내나

신호 의미
Controller에 @Repository, EntityManager, JDBC가 직접 등장 Presentation → Persistence 침투
Controller에 비즈니스 규칙(if/validate)이 보임 Business 계층 부재
Service가 다른 Service의 private 필드를 리플렉션으로 건드림 캡슐화 붕괴
DTO가 여러 계층을 통과해 한 객체가 UI·DB 표현을 동시에 담 계층 분리 안 됨
한 요구사항 변경이 여러 계층의 여러 클래스를 건드림 변경 증폭 — 경계가 안 새겨짐
"이거 고쳤는데 다른 쪽이 왜 깨지지?" 암묵적 계층 간 의존

이 신호들이 많으면, 그 프로젝트는 '계층형'이라는 이름의 진흙 덩어리다.

설계 사례 — 계층 침투에서 제대로 된 계층형으로

주문 생성을 세 단계로 정비해 본다.

1단계 — 컨트롤러에 다 몰아넣기 (계층 부재)

@RestController
class OrderController {
    private final JdbcTemplate jdbc;
    private final RestClient paymentClient;

    @PostMapping("/orders")
    public ResponseEntity<?> create(@RequestBody OrderRequest req) {
        // 검증 + 비즈니스 로직 + 영속화 + 외부 호출이 한 메서드에
        if (req.items().isEmpty()) return ResponseEntity.badRequest().build();
        BigDecimal total = req.items().stream()
            .map(i -> i.price().multiply(BigDecimal.valueOf(i.qty())))
            .reduce(BigDecimal.ZERO, BigDecimal::add);
        Long id = jdbc.queryForObject(
            "INSERT INTO orders(total) VALUES(?) RETURNING id", Long.class, total);
        paymentClient.post().body(Map.of("orderId", id, "amount", total)).retrieve();
        return ResponseEntity.ok(Map.of("id", id));
    }
}

한 메서드가 검증·계산·DB insert·외부 API 호출을 다 한다. '계층'이라는 개념 자체가 없다. 비즈니스 규칙이 컨트롤러에 새겨져 있어 재사용이 안 된다. 배치 작업에서 같은 주문 생성을 하려면 코드를 복붙해야 한다.

2단계 — Service 계층 도입

@RestController
class OrderController {
    private final OrderService orderService;

    @PostMapping("/orders")
    public ResponseEntity<?> create(@RequestBody OrderRequest req) {
        Long id = orderService.placeOrder(req); // 위임만
        return ResponseEntity.ok(Map.of("id", id));
    }
}

@Service
class OrderService {
    private final OrderRepository repo;       // persistence 계층
    private final PaymentGateway gateway;     // 외부 추상화

    public Long placeOrder(OrderRequest req) {
        if (req.items().isEmpty()) throw new IllegalArgumentException();
        BigDecimal total = computeTotal(req);
        Order order = new Order(total, req.items());
        repo.save(order);
        gateway.charge(order);
        return order.id();
    }
}

컨트롤러는 HTTP와 직렬화만 담당한다. 비즈니스 규칙(빈 항목 검증, 총액 계산)은 Service로 내려왔다. 이제 placeOrder를 컨트롤러뿐 아니라 배치 작업·테스트에서도 재사용할 수 있다. 계층 분리의 가치가 드러난다.

하지만 여전히 비즈니스 규칙(computeTotal, 빈 항목 검증)이 Service에 섞여 있다. Service가 비대해지면 다시 진흙이 된다. DDD 관점에서, 이 규칙들은 Order라는 '도메인 객체' 안에 들어가야 한다.

3단계 — 도메인 모델 분리

// 도메인 객체가 규칙을 스스로 안다
public record Order(List<Item> items) {
    public Order {
        if (items.isEmpty()) throw new IllegalArgumentException("항목 필요");
    }
    public BigDecimal total() {
        return items.stream()
            .map(i -> i.price().multiply(BigDecimal.valueOf(i.qty())))
            .reduce(BigDecimal.ZERO, BigDecimal::add);
    }
}

@Service
class OrderService {
    public Long placeOrder(OrderRequest req) {
        Order order = new Order(req.items()); // 규칙이 도메인 객체 안에
        OrderRecord saved = repo.save(order);
        gateway.charge(saved);
        return saved.id();
    }
}

이 단계에서 Service는 '사용 사례(use case) 조율'만 한다. 비즈니스 규칙은 Order라는 도메인 객체 안에 들어있다. 계층형의 Business 계층이 다시 두 가지로 나뉜 셈이다 — Service(조율)와 Domain(규칙). 이 구조는 다음 편 헥사고날과 클린 아키텍처에서 더 명시적으로 분리된다.

계층형이 여전히 좋은 경우

계층형이 '구식'이라는 평가는 틀렸다. Fowler가 에서 정리한 지 20년이 넘었지만, 이 구조가 주는 단순함은 여전히 가치 있다. 적합한 상황은 다음과 같다.

  • CRUD 중심 애플리케이션 — 비즈니스 규칙이 단순하고, 주된 일이 데이터 입력/조회라면 3계층이면 충분하다.
  • 팀 규모가 작고 도메인 복잡도가 낮을 때 — 헥사고날이나 클린의 분리는 오버엔지니어링이 될 수 있다.
  • 레거시 정비의 출발점 — 진흙 덩어리에서 헥사고날로 한 번에 가긴 어렵다. 계층 분리부터 하는 게 합리적 첫걸음.

반대로 다음 상황에선 계층형의 한계가 드러난다.

  • 비즈니스 규칙이 복잡하고 도메인이 풍부할 때 (Domain 계층을 강제할 필요가 커짐 → 헥사고날/클린)
  • 다양한 진입점(REST, gRPC, 배치, CLI)이 같은 비즈니스 로직을 호출할 때 (진입점 교체 비용이 큼 → 포트-어댑터)
  • 프레임워크(Spring, JPA) 의존이 도메인까지 스며들어 교체가 어려울 때 (의존성 역전 → 클린)

계층형에서 다음 스타일로

계층형은 다음 세 편의 출발점이다. 헥사고날(03편)은 계층형의 "도메인을 외부로부터 격리"를 극한으로 밀어 "포트와 어댑터"로 형식화한다. 클린 아키텍처(04편)는 계층형의 "역할별 분리"를 동심원으로 재배치하고 의존성 규칙을 명시적으로 만든다. 그리고 파이프라인(10편)은 역할별 분할이 아니라 '단계별' 분할이라는 또 다른 축을 보여준다.

계층형의 진짜 가치는 '계층을 나눴다'에 있지 않다. 계층 사이의 규칙을 강제하고, 비즈니스 규칙이 도메인에 머물게 하며, 침투를 기계적으로 막는 데 있다. 그 강제 없이 패키지만 나눈 계층형은, 구조가 있다는 착각 속에 쌓여가는 진흙 덩어리일 뿐이다.


참고