Table of contents
- 레이어드 아키텍처란
- 각 계층의 책임
- Spring MVC의 전형적인 흐름
- 패키지 구조
- 레이어드 아키텍처의 장점
- 레이어드 아키텍처의 한계
- 의존 방향의 문제
- 한계를 인식한다는 것
- 핵심 정리
레이어드 아키텍처란
레이어드 아키텍처는 코드를 수평적 계층으로 나누는 구조다. 각 계층은 고유한 책임을 가지며, 상위 계층이 하위 계층을 호출하는 단방향 흐름을 따른다. 가장 전형적인 형태는 3계층 구조다.
flowchart TB
subgraph layered["레이어드 아키텍처 — 3계층"]
direction TB
P["Presentation Layer<br/>사용자 요청 수신, 응답 반환"]
B["Business Layer<br/>비즈니스 로직 처리"]
D["Persistence Layer<br/>데이터 저장·조회"]
end
P -->|"호출"| B
B -->|"호출"| D
style P fill:#339af0,color:#fff
style B fill:#51cf66,color:#fff
style D fill:#ffd43b,color:#000
위에서 아래로 흐르는 단방향 의존이 이 아키텍처의 핵심이다. Presentation은 Business를 알지만 Business는 Presentation을 모른다. Business는 Persistence를 알지만 Persistence는 Business를 모른다.
이 구조는 1990년대부터 엔터프라이즈 애플리케이션의 기본 패턴으로 자리 잡았다. 2003년 Martin Fowler의 Patterns of Enterprise Application Architecture에서 체계적으로 정리되었고, Spring Framework가 이 패턴을 그대로 수용하면서 자바/코틀린 생태계에서 사실상의 표준이 되었다.
각 계층의 책임
Presentation Layer — 요청의 입구와 출구
Presentation 계층은 외부 세계와 애플리케이션을 연결한다. HTTP 요청을 받아서 파싱하고, 비즈니스 계층에 위임한 뒤, 결과를 HTTP 응답으로 변환하여 돌려보내는 게 전부다.
Spring MVC에서는 @Controller나 @RestController가 이 역할을 맡는다.
@RestController
@RequestMapping("/api/orders")
class OrderController(
private val orderService: OrderService
) {
@PostMapping
fun createOrder(@RequestBody request: CreateOrderRequest): ResponseEntity<OrderResponse> {
val order = orderService.create(request.toCommand())
return ResponseEntity.status(HttpStatus.CREATED)
.body(OrderResponse.from(order))
}
@GetMapping("/{id}")
fun getOrder(@PathVariable id: Long): ResponseEntity<OrderResponse> {
val order = orderService.findById(id)
return ResponseEntity.ok(OrderResponse.from(order))
}
}
이 계층에서 주의할 점은 비즈니스 로직을 넣지 않는 것이다. 주문 금액이 10만 원 이상이면 할인은 컨트롤러가 아니라 서비스에 있어야 한다. 컨트롤러는 요청을 받고, 위임하고, 응답을 만드는 세 가지만 한다.
Business Layer — 핵심 로직의 집
Business 계층은 애플리케이션의 존재 이유다. 주문을 생성한다, 결제를 처리한다, 재고가 충분한지 확인한다 같은 비즈니스 규칙이 여기에 위치한다.
Spring에서는 @Service로 표시하는 것이 관례다.
@Service
@Transactional
class OrderService(
private val orderRepository: OrderRepository,
private val productRepository: ProductRepository,
private val paymentService: PaymentService
) {
fun create(command: CreateOrderCommand): Order {
val product = productRepository.findById(command.productId)
?: throw ProductNotFoundException(command.productId)
// 비즈니스 규칙: 재고 확인
require(product.stock >= command.quantity) {
"재고가 부족합니다. 현재 재고: ${product.stock}"
}
// 재고 차감
product.decreaseStock(command.quantity)
productRepository.save(product)
// 주문 생성
val order = Order.create(
productId = product.id,
quantity = command.quantity,
totalAmount = product.price * command.quantity
)
orderRepository.save(order)
// 결제 처리
paymentService.pay(order)
return order
}
fun findById(id: Long): Order {
return orderRepository.findById(id)
?: throw OrderNotFoundException(id)
}
}
Business 계층은 Persistence 계층의 리포지토리를 호출하여 데이터를 가져오고, 비즈니스 규칙을 적용한 뒤, 결과를 다시 저장한다. 트랜잭션의 경계도 보통 이 계층에서 결정된다.
Persistence Layer — 데이터의 저장과 조회
Persistence 계층은 데이터베이스와 직접 소통하는 곳이다. SQL을 실행하거나, ORM(Object-Relational Mapping)을 통해 객체와 테이블을 매핑한다.
Spring Data JPA를 사용하면 인터페이스만 선언해도 구현체를 자동으로 만들어준다.
@Repository
interface OrderRepository : JpaRepository<OrderEntity, Long> {
fun findByStatus(status: OrderStatus): List<OrderEntity>
fun findByUserIdOrderByCreatedAtDesc(userId: Long): List<OrderEntity>
}
@Repository
interface ProductRepository : JpaRepository<ProductEntity, Long>
이 계층이 DB 기술에 종속되는 것은 자연스럽다. JPA를 쓰든, MyBatis를 쓰든, 순수 JDBC를 쓰든 — Persistence 계층 안에서 결정할 문제다. 다른 계층은 이 결정을 알 필요가 없다(이상적으로는).
Spring MVC의 전형적인 흐름
Spring Boot 프로젝트에서 레이어드 아키텍처가 실현되는 전형적인 흐름을 시퀀스 다이어그램으로 보자.
sequenceDiagram
participant Client as Client
participant Controller as OrderController
participant Service as OrderService
participant Repository as OrderRepository
participant DB as Database
Client->>Controller: POST /api/orders
Controller->>Controller: 요청 파싱, 유효성 검증
Controller->>Service: create(command)
Service->>Repository: findById(productId)
Repository->>DB: SELECT * FROM products
DB-->>Repository: ProductEntity
Repository-->>Service: Product
Service->>Service: 비즈니스 규칙 적용
Service->>Repository: save(order)
Repository->>DB: INSERT INTO orders
DB-->>Repository: OK
Repository-->>Service: Order
Service-->>Controller: Order
Controller->>Controller: 응답 변환
Controller-->>Client: 201 Created + OrderResponse
요청이 들어오면 Controller → Service → Repository → DB 순으로 내려가고, 응답은 역순으로 올라온다. 이 예측 가능한 흐름이 레이어드 아키텍처의 가장 큰 장점이다.
패키지 구조
레이어드 아키텍처를 Spring 프로젝트에 적용하면 보통 두 가지 패키지 전략이 나온다.
계층 기준 패키지 (package by layer)
com.example.shop/
├── controller/
│ ├── OrderController.kt
│ ├── ProductController.kt
│ └── UserController.kt
├── service/
│ ├── OrderService.kt
│ ├── ProductService.kt
│ └── UserService.kt
├── repository/
│ ├── OrderRepository.kt
│ ├── ProductRepository.kt
│ └── UserRepository.kt
├── entity/
│ ├── OrderEntity.kt
│ ├── ProductEntity.kt
│ └── UserEntity.kt
└── dto/
├── CreateOrderRequest.kt
├── OrderResponse.kt
└── ...
직관적이고 학습 비용이 낮다. 컨트롤러는 controller 패키지, 서비스는 service 패키지라는 규칙이 명확하기 때문이다. 그런데 프로젝트가 커지면 문제가 보인다. service/ 패키지에 50개의 서비스 클래스가 쌓이고, 어떤 서비스가 어떤 도메인에 속하는지 패키지만 봐서는 알기 어렵다.
도메인 기준 패키지 (package by feature)
com.example.shop/
├── order/
│ ├── OrderController.kt
│ ├── OrderService.kt
│ ├── OrderRepository.kt
│ ├── OrderEntity.kt
│ └── dto/
├── product/
│ ├── ProductController.kt
│ ├── ProductService.kt
│ ├── ProductRepository.kt
│ └── ProductEntity.kt
└── user/
├── UserController.kt
├── UserService.kt
├── UserRepository.kt
└── UserEntity.kt
도메인 단위로 묶으면 주문에 관한 코드는 order 패키지에 다 있다는 응집도 높은 구조가 된다. 계층은 클래스 이름의 접미사(Controller, Service, Repository)로 자연스럽게 구분된다.
실무에서는 후자가 더 자주 쓰인다. 프로젝트가 커질수록 이 기능에 관련된 코드를 한 곳에서 보고 싶다는 요구가 강해지기 때문이다.
레이어드 아키텍처의 장점
관심사 분리
각 계층이 하나의 관심사에 집중한다. HTTP 처리는 Presentation, 비즈니스 로직은 Business, 데이터 접근은 Persistence. 한 계층의 코드를 읽을 때 다른 계층의 세부사항을 생각하지 않아도 된다.
학습 용이
대부분의 스프링 입문서와 튜토리얼이 이 구조를 따르기 때문에 팀의 온보딩 비용이 낮다. Controller에서 Service를 호출하고, Service에서 Repository를 호출한다는 규칙은 주니어 개발자도 금방 익힌다.
독립적 교체 가능성(이론적으로)
Persistence 계층만 교체하면 JPA에서 MyBatis로 전환할 수 있다. Presentation 계층만 바꾸면 REST API를 gRPC로 변경할 수 있다. 계층 간 인터페이스가 잘 정의되어 있다면 가능한 이야기다.
테스트 계층 분리
각 계층을 독립적으로 테스트하는 전략을 세울 수 있다. Controller는 @WebMvcTest로, Service는 단위 테스트로, Repository는 @DataJpaTest로.
레이어드 아키텍처의 한계
장점만 있다면 다른 아키텍처를 찾을 이유가 없었을 것이다. 레이어드 구조에는 실무에서 반복적으로 부딪히는 한계가 있다.
도메인이 인프라에 종속된다
레이어드 아키텍처에서 의존의 방향은 위에서 아래로 흐른다.
flowchart TB
C["Controller"] --> S["Service"]
S --> R["Repository"]
S --> E["Entity<br/>JPA 어노테이션 포함"]
R --> E
style E fill:#ff6b6b,color:#fff
Entity에 @Entity, @Column, @Id 같은 JPA 어노테이션이 붙어 있다. Service 계층이 이 Entity를 직접 다루므로, 비즈니스 로직이 JPA라는 특정 기술에 종속된다.
아래 코드를 보자. 비즈니스 로직이 Entity 클래스와 강하게 결합되어 있다.
@Entity
@Table(name = "orders")
class OrderEntity(
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long = 0,
@Column(nullable = false)
var status: String = "CREATED",
@Column(nullable = false)
val totalAmount: Long = 0,
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id")
val user: UserEntity? = null
) {
fun cancel() {
if (status != "CREATED") {
throw IllegalStateException("취소할 수 없는 상태입니다: $status")
}
status = "CANCELLED"
}
}
cancel()이라는 비즈니스 메서드가 @Entity 어노테이션이 붙은 클래스 안에 있다. 비즈니스 규칙(CREATED 상태에서만 취소 가능)이 JPA Entity와 한 몸이 된 것이다. 이 규칙을 테스트하려면 JPA 컨텍스트를 신경 써야 한다.
데이터베이스 중심 사고
레이어드 아키텍처에서 개발을 시작하면 흔히 이런 순서를 따른다.
- 테이블 스키마 설계
- Entity 클래스 작성 (테이블 매핑)
- Repository 작성
- Service 작성 (Entity를 가공)
- Controller 작성
아래에서 위로, DB부터 시작해서 UI를 만든다. 이것이 데이터베이스 중심 설계(database-driven design)다. 비즈니스 규칙이 아니라 테이블 구조가 설계의 출발점이 된다.
문제는 테이블 구조와 비즈니스 모델이 반드시 일치하지 않는다는 점이다. 정규화된 테이블은 저장에 최적화된 구조이고, 비즈니스 규칙은 행위에 최적화된 구조다. 둘을 하나의 클래스(Entity)로 표현하면 어느 한쪽이 타협하게 된다.
레이어 건너뛰기 유혹
“Controller에서 Repository를 직접 호출하면 안 되나?” 단순 조회 API를 만들 때 이 유혹이 찾아온다.
// 유혹에 빠진 코드 — Controller가 Repository를 직접 호출
@GetMapping("/{id}")
fun getOrder(@PathVariable id: Long): ResponseEntity<OrderEntity> {
val order = orderRepository.findById(id)
.orElseThrow { OrderNotFoundException(id) }
return ResponseEntity.ok(order)
}
동작은 한다. 하지만 이 하나가 전례가 되어 팀 전체가 레이어를 건너뛰기 시작하면 계층의 의미가 사라진다. 이건 단순하니까 건너뛰어도 돼의 기준이 사람마다 다르기 때문에, 한번 허용하면 통제가 어렵다.
그렇다고 단순 조회를 위해 Service에 아무 로직 없는 위임 메서드를 만드는 것도 답답하다.
// 아무 로직 없는 패스스루 메서드
@Service
class OrderService(private val orderRepository: OrderRepository) {
fun findById(id: Long): Order {
return orderRepository.findById(id)
.orElseThrow { OrderNotFoundException(id) }
}
}
이런 코드가 쌓이면 “Service 계층의 존재 이유가 뭐지?”라는 의문이 든다. 레이어드 아키텍처의 구조적 딜레마다.
횡단 관심사의 처리
로깅, 인증, 트랜잭션 같은 횡단 관심사(cross-cutting concern)는 여러 계층에 걸쳐 있다. 레이어드 아키텍처는 수평 분리에는 강하지만, 수직으로 관통하는 관심사를 구조적으로 다루는 방법이 부족하다. Spring AOP나 인터셉터로 해결하긴 하지만, 이는 아키텍처 자체의 해법이라기보다 프레임워크의 보완이다.
의존 방향의 문제
레이어드 아키텍처의 가장 근본적인 한계는 의존 방향에 있다. Business 계층이 Persistence 계층에 의존한다는 것은, 비즈니스 로직이 데이터 접근 기술을 알고 있다는 뜻이다.
flowchart LR
subgraph problem["레이어드의 의존 방향"]
direction LR
BL["Business Layer<br/>핵심 로직"]
PL["Persistence Layer<br/>JPA, MyBatis, JDBC"]
BL -->|"의존"| PL
end
subgraph ideal["이상적인 의존 방향"]
direction LR
BL2["Business Layer<br/>핵심 로직"]
PL2["Persistence Layer<br/>JPA, MyBatis, JDBC"]
PL2 -->|"의존"| BL2
end
style BL fill:#51cf66,color:#fff
style PL fill:#ff6b6b,color:#fff
style BL2 fill:#51cf66,color:#fff
style PL2 fill:#ffd43b,color:#000
왼쪽이 레이어드의 현실이고, 오른쪽이 DIP(Dependency Inversion Principle, 의존성 역전 원칙)를 적용한 이상적인 방향이다. 비즈니스 로직은 애플리케이션의 핵심이므로 다른 계층이 비즈니스를 향해 의존해야 자연스럽다. 그런데 레이어드 구조는 그 반대다.
이 방향의 문제는 구체적으로 이렇게 드러난다.
Service 클래스가 JpaRepository의 메서드 시그니처에 맞춰 코드를 작성하게 된다. Page<Entity>, Optional<Entity> 같은 JPA 전용 타입이 비즈니스 계층에 스며든다. 나중에 JPA를 걷어내고 다른 기술을 쓰려면 Service 계층 전체를 수정해야 한다. 교체 가능하다는 이론적 장점이 실제로는 실현되기 어려운 것이다.
한계를 인식한다는 것
레이어드 아키텍처의 한계를 나열했지만, 이것이 레이어드를 쓰지 말라는 뜻은 아니다. 이 구조는 다음과 같은 상황에서 여전히 좋은 선택이다.
- 도메인 복잡도가 낮은 CRUD 중심 애플리케이션
- 팀원 대부분이 주니어이고, 학습 비용을 낮춰야 하는 프로젝트
- 프로토타입이나 MVP(Minimum Viable Product, 최소 기능 제품)
- Spring Boot의 자동 설정과 컨벤션을 최대한 활용하고 싶을 때
반면 도메인이 복잡하고, 비즈니스 로직이 자주 변경되며, 인프라 기술에 독립적이어야 하는 프로젝트라면 레이어드의 한계가 빠르게 드러난다. 이런 경우에 등장하는 것이 다음 편에서 다룰 헥사고날 아키텍처다.
한계를 인식하는 것과 대안을 아는 것은 다른 문제다. 레이어드의 어떤 부분이 왜 힘든지를 정확히 아는 사람만이 헥사고날이나 클린 아키텍처로 전환했을 때 실질적인 이점을 얻을 수 있다. 헥사고날이 좋다더라는 말을 듣고 무작정 바꾸면 오히려 복잡도만 올라갈 수 있다.
핵심 정리
| 항목 | 내용 |
|---|---|
| 구조 | Presentation → Business → Persistence 단방향 의존 |
| Spring 매핑 | Controller → Service → Repository |
| 장점 | 관심사 분리, 낮은 학습 비용, 예측 가능한 흐름 |
| 한계 | 도메인-인프라 종속, DB 중심 설계, 레이어 건너뛰기 유혹 |
| 근본 문제 | 비즈니스가 인프라를 향해 의존 (DIP 위반) |
| 적합한 상황 | CRUD 중심, 낮은 도메인 복잡도, 빠른 시작 |
다음 편에서는 레이어드의 근본 문제인 의존 방향을 뒤집는 아키텍처를 다룬다. Alistair Cockburn이 제안한 헥사고날 아키텍처, 즉 Ports & Adapters 패턴이다. 안과 밖을 명확히 나누면 어떤 것이 달라지는지 살펴보자.




Loading comments...