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가
- Presentation 계층 — UI, 컨트롤러, API 엔드포인트. 외부 입력을 받고 응답을 내보낸다.
- Business(Domain) 계층 — 비즈니스 규칙, 서비스 로직. 이 시스템이 '무엇을 하는가'를 담는다.
- Persistence(Data) 계층 — DB 접근, 외부 API 호출. 데이터를 영속화하고 가져온다.
Mark Richards와 Neal Ford가
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가
- CRUD 중심 애플리케이션 — 비즈니스 규칙이 단순하고, 주된 일이 데이터 입력/조회라면 3계층이면 충분하다.
- 팀 규모가 작고 도메인 복잡도가 낮을 때 — 헥사고날이나 클린의 분리는 오버엔지니어링이 될 수 있다.
- 레거시 정비의 출발점 — 진흙 덩어리에서 헥사고날로 한 번에 가긴 어렵다. 계층 분리부터 하는 게 합리적 첫걸음.
반대로 다음 상황에선 계층형의 한계가 드러난다.
- 비즈니스 규칙이 복잡하고 도메인이 풍부할 때 (Domain 계층을 강제할 필요가 커짐 → 헥사고날/클린)
- 다양한 진입점(REST, gRPC, 배치, CLI)이 같은 비즈니스 로직을 호출할 때 (진입점 교체 비용이 큼 → 포트-어댑터)
- 프레임워크(Spring, JPA) 의존이 도메인까지 스며들어 교체가 어려울 때 (의존성 역전 → 클린)
계층형에서 다음 스타일로
계층형은 다음 세 편의 출발점이다. 헥사고날(03편)은 계층형의 "도메인을 외부로부터 격리"를 극한으로 밀어 "포트와 어댑터"로 형식화한다. 클린 아키텍처(04편)는 계층형의 "역할별 분리"를 동심원으로 재배치하고 의존성 규칙을 명시적으로 만든다. 그리고 파이프라인(10편)은 역할별 분할이 아니라 '단계별' 분할이라는 또 다른 축을 보여준다.
계층형의 진짜 가치는 '계층을 나눴다'에 있지 않다. 계층 사이의 규칙을 강제하고, 비즈니스 규칙이 도메인에 머물게 하며, 침투를 기계적으로 막는 데 있다. 그 강제 없이 패키지만 나눈 계층형은, 구조가 있다는 착각 속에 쌓여가는 진흙 덩어리일 뿐이다.
참고
- Fowler —
(Addison-Wesley, 2002), 계층화 패턴 절 — 접근 2026-07-20 - Richards, Ford —
(O'Reilly, 2020), Ch.3 (계층형 스타일) - Ousterhout — (2nd ed, 2021), deep vs shallow module
- Bass, Clements, Kazman —
(4th ed, Addison-Wesley, 2021), 아키텍처 스타일 장
'Software Architecture > Architectural Styles' 카테고리의 다른 글
| Architectural Styles - 06. 마이크로서비스의 함정 (0) | 2026.07.20 |
|---|---|
| Architectural Styles - 05. 마이크로서비스 (0) | 2026.07.20 |
| Architectural Styles - 04. 클린 아키텍처 (0) | 2026.07.20 |
| Architectural Styles - 03. 헥사고날 아키텍처 (0) | 2026.07.20 |
| Architectural Styles - 01. 모놀리스 (1) | 2026.07.20 |