비유로 시작하기

패스트푸드 음식점을 생각해보세요. 손님이 카운터(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

핵심 규칙: 의존성은 위에서 아래 방향으로만 흐릅니다. 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
계층 역할 예시
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());
    }
}

장단점 분석

장점

  1. 이해하기 쉬움: 새 팀원 온보딩이 빠름
  2. 역할 분리: Controller는 HTTP, Service는 비즈니스, Repository는 DB
  3. 검증된 패턴: 수십 년간 실무에서 검증됨
  4. Spring 기본 지원: Spring MVC, Spring Data JPA가 이 패턴을 기본으로 지원

단점

  1. DB 중심 설계로 흐르기 쉬움: DB 테이블 구조가 그대로 Entity → Service → Controller로 올라옴
  2. 단위 테스트 어려움: Service 테스트 시 Repository Mock 필요
  3. 레이어 스킵 유혹: Controller → Repository 직접 호출, 비즈니스 로직이 Controller로 새어나옴
  4. 순환 의존성 위험: 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를 검토하세요.