레이어드 아키텍처
비유로 시작하기
패스트푸드 음식점을 생각해보세요. 손님이 카운터(Presentation)에서 주문하면, 카운터 직원은 주방(Business Logic)에 전달합니다. 주방은 냉장고/창고(Persistence)에서 재료를 꺼내 조리합니다. 손님은 주방과 직접 대화하지 않고, 주방은 손님의 얼굴을 볼 필요가 없습니다. 각 역할이 명확하게 분리되어 있습니다.
레이어드 아키텍처(Layered Architecture)는 소프트웨어를 이처럼 책임에 따라 수평적으로 나눈 구조입니다. 가장 오래되고 가장 널리 사용되는 아키텍처 패턴이며, 대부분의 Spring 애플리케이션이 이 구조를 따릅니다.
기본 구조
graph TD
P[Presentation Layer
Controller, View, DTO] B[Business Layer
Service, Domain Logic] Per[Persistence Layer
Repository, DAO, ORM] DB[(Database)] P --> B B --> Per Per --> DB style P fill:#AED6F1 style B fill:#A9DFBF style Per fill:#F9E79F
Controller, View, DTO] B[Business Layer
Service, Domain Logic] Per[Persistence Layer
Repository, DAO, ORM] DB[(Database)] P --> B B --> Per Per --> DB style P fill:#AED6F1 style B fill:#A9DFBF style Per fill:#F9E79F
핵심 규칙: 의존성은 위에서 아래 방향으로만 흐릅니다. Presentation은 Business를 알지만, Business는 Presentation을 몰라야 합니다.
각 계층 역할
Presentation Layer (표현 계층)
사용자나 클라이언트와의 입출력을 담당합니다.
@RestController
@RequestMapping("/api/orders")
@RequiredArgsConstructor
public class OrderController {
private final OrderService orderService;
@PostMapping
public ResponseEntity<OrderResponse> createOrder(
@RequestBody @Valid CreateOrderRequest request) {
OrderDto orderDto = orderService.createOrder(request.toServiceDto());
return ResponseEntity.ok(OrderResponse.from(orderDto));
}
@GetMapping("/{id}")
public ResponseEntity<OrderResponse> getOrder(@PathVariable Long id) {
OrderDto orderDto = orderService.findById(id);
return ResponseEntity.ok(OrderResponse.from(orderDto));
}
}
책임:
- HTTP 요청/응답 처리
- 입력 유효성 검사 (
@Valid) - DTO 변환 (Request → ServiceDto, ServiceDto → Response)
- 인증/인가 (Spring Security 필터)
Business Layer (비즈니스 계층)
핵심 비즈니스 로직을 담당합니다. 트랜잭션 경계도 이 계층에서 관리합니다.
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class OrderService {
private final OrderRepository orderRepository;
private final ProductRepository productRepository;
private final InventoryService inventoryService;
private final NotificationService notificationService;
@Transactional
public OrderDto createOrder(CreateOrderServiceDto dto) {
// 재고 확인
dto.items().forEach(item ->
inventoryService.validateStock(item.productId(), item.quantity())
);
// 주문 생성
Order order = Order.create(dto.customerId(), buildOrderItems(dto.items()));
Order saved = orderRepository.save(order);
// 재고 차감
inventoryService.deductStock(saved);
// 알림
notificationService.sendOrderConfirmation(saved);
return OrderDto.from(saved);
}
public OrderDto findById(Long id) {
return orderRepository.findById(id)
.map(OrderDto::from)
.orElseThrow(() -> new OrderNotFoundException(id));
}
private List<OrderItem> buildOrderItems(List<OrderItemDto> itemDtos) {
return itemDtos.stream()
.map(item -> {
Product product = productRepository.findById(item.productId())
.orElseThrow(() -> new ProductNotFoundException(item.productId()));
return new OrderItem(product, item.quantity());
})
.toList();
}
}
Persistence Layer (영속 계층)
데이터 저장/조회를 담당합니다.
public interface OrderRepository extends JpaRepository<Order, Long> {
List<Order> findByCustomerId(Long customerId);
@Query("""
SELECT o FROM Order o
JOIN FETCH o.items
WHERE o.status = :status
AND o.createdAt >= :from
""")
List<Order> findByStatusAndCreatedAtAfter(
@Param("status") OrderStatus status,
@Param("from") LocalDateTime from
);
@Query(value = """
SELECT DATE(created_at) as date, COUNT(*) as count, SUM(total_amount) as revenue
FROM orders
WHERE created_at BETWEEN :from AND :to
GROUP BY DATE(created_at)
""", nativeQuery = true)
List<DailySummaryProjection> findDailySummary(
@Param("from") LocalDateTime from,
@Param("to") LocalDateTime to
);
}
4계층 확장 모델
실무에서는 3계층보다 4계층이 더 자주 쓰입니다.
graph TD
P[Presentation Layer
Controller] A[Application Layer
Facade, Orchestration] B[Domain/Business Layer
Service, Entity, Domain Logic] Per[Infrastructure Layer
Repository, External API, Cache] P --> A A --> B B --> Per
Controller] A[Application Layer
Facade, Orchestration] B[Domain/Business Layer
Service, Entity, Domain Logic] Per[Infrastructure Layer
Repository, External API, Cache] P --> A A --> B B --> Per
| 계층 | 역할 | 예시 |
|---|---|---|
| Presentation | HTTP In/Out | Controller, ExceptionHandler |
| Application | 유스케이스 조합 | OrderFacade (여러 서비스 오케스트레이션) |
| Domain/Business | 핵심 비즈니스 | OrderService, InventoryService |
| Infrastructure | 기술 인프라 | JPA Repository, RedisTemplate, Kafka Producer |
패키지 구조
레이어 기반 구조 (전통적)
com.example
├── controller
│ ├── OrderController.java
│ └── ProductController.java
├── service
│ ├── OrderService.java
│ └── ProductService.java
├── repository
│ ├── OrderRepository.java
│ └── ProductRepository.java
└── domain
├── Order.java
└── Product.java
장점: 단순, 이해하기 쉬움 단점: 규모가 커지면 각 패키지에 파일이 수십 개 쌓여 응집도가 낮아짐
도메인 기반 구조 (권장)
com.example
├── order
│ ├── OrderController.java
│ ├── OrderService.java
│ ├── OrderRepository.java
│ └── Order.java
└── product
├── ProductController.java
├── ProductService.java
├── ProductRepository.java
└── Product.java
장점: 도메인 응집도 높음, 모듈화 용이 단점: 처음엔 어색할 수 있음
레이어 간 데이터 전달
graph LR
REQ[RequestDTO] -->|Controller| SVC_IN[ServiceDTO]
SVC_IN -->|Service| ENT[Entity]
ENT -->|Service| SVC_OUT[ServiceDTO]
SVC_OUT -->|Controller| RES[ResponseDTO]
// Request DTO (Presentation)
public record CreateOrderRequest(
Long customerId,
@NotEmpty List<OrderItemRequest> items
) {
public CreateOrderServiceDto toServiceDto() {
return new CreateOrderServiceDto(customerId,
items.stream().map(OrderItemRequest::toServiceDto).toList());
}
}
// Service DTO (Business ↔ Presentation 경계)
public record CreateOrderServiceDto(Long customerId, List<OrderItemDto> items) {}
// Entity (Business ↔ Persistence)
@Entity
public class Order { ... }
// Response DTO (Presentation)
public record OrderResponse(Long id, String status, BigDecimal totalAmount) {
public static OrderResponse from(OrderDto dto) {
return new OrderResponse(dto.id(), dto.status().name(), dto.totalAmount());
}
}
장단점 분석
장점
- 이해하기 쉬움: 새 팀원 온보딩이 빠름
- 역할 분리: Controller는 HTTP, Service는 비즈니스, Repository는 DB
- 검증된 패턴: 수십 년간 실무에서 검증됨
- Spring 기본 지원: Spring MVC, Spring Data JPA가 이 패턴을 기본으로 지원
단점
- DB 중심 설계로 흐르기 쉬움: DB 테이블 구조가 그대로 Entity → Service → Controller로 올라옴
- 단위 테스트 어려움: Service 테스트 시 Repository Mock 필요
- 레이어 스킵 유혹: Controller → Repository 직접 호출, 비즈니스 로직이 Controller로 새어나옴
- 순환 의존성 위험: Service A → Service B → Service A
안티패턴
뚱뚱한 Controller (Fat Controller)
// 나쁜 예
@PostMapping("/order")
public ResponseEntity<?> createOrder(@RequestBody OrderRequest request) {
// 비즈니스 로직이 컨트롤러에
if (request.getQuantity() <= 0) throw new BadRequestException("수량 오류");
Product product = productRepository.findById(request.getProductId()).orElseThrow();
if (product.getStock() < request.getQuantity()) throw new OutOfStockException();
Order order = new Order();
order.setCustomerId(request.getCustomerId());
// ... 수십 줄의 비즈니스 로직
}
레이어 스킵
// 나쁜 예 - Controller가 Repository를 직접 참조
@RestController
public class OrderController {
private final OrderRepository orderRepository; // Service 없이 직접 접근
}
빈약한 도메인 모델 (Anemic Domain Model)
// 나쁜 예 - Entity에 로직이 없고 Service에 모든 로직이 집중
public class Order {
private Long id;
private String status; // getter/setter만 존재
}
public class OrderService {
public void cancelOrder(Long orderId) {
Order order = orderRepository.findById(orderId).orElseThrow();
if (order.getStatus().equals("SHIPPED")) throw new Exception("...");
order.setStatus("CANCELLED"); // 도메인 로직이 서비스에 있음
}
}
헥사고날 아키텍처와 비교
| 항목 | 레이어드 | 헥사고날 |
|---|---|---|
| 진입 장벽 | 낮음 | 높음 |
| 인프라 교체 용이성 | 낮음 | 높음 |
| 단위 테스트 | 보통 | 쉬움 |
| 코드량 | 적음 | 많음 |
| 소규모/단기 | 최적 | 과도 |
| 대규모/장기 | 유지보수 어려움 | 적합 |
언제 레이어드 아키텍처를 써야 하나
- 팀 규모 5인 이하, 요구사항이 단순/명확할 때
- 빠른 MVP 개발이 목표일 때
- 도메인 로직이 복잡하지 않은 CRUD 위주 시스템
- 레거시 코드베이스 유지보수
레이어드 아키텍처는 “나쁜” 아키텍처가 아닙니다. 팀과 도메인 복잡도에 맞는 선택이 중요합니다. 복잡한 비즈니스 도메인이라면 헥사고날 + DDD를 검토하세요.