[워밍업 클럽 3기 BE 클린코드&테스트] - 레이어드 아키텍처 주요 특징

Spring에서 애플리케이션을 설계할 때 흔히 사용하는 Layered Architecture(레이어드 아키텍처)는 다음과 같은 3가지 주요 계층으로 구성

[Presentation Layer][Business Layer][Persistence Layer]
     (Controller)         (Service)           (Repository)

Layered Architecture의 핵심 특징

  • 계층 분리 : 각 계층이 독립된 책임을 가지며, 역할이 명확히 나뉨

  • 독립성 : 상위 계층은 하위 계층을 의존하지만, 하위 계층은 상위 계층을 모름

  • 유연성 : 특정 계층을 변경하더라도 다른 계층에 영향이 적음

     

  • 테스트 용이성 : 계층별 단위 테스트가 가능해 테스트 작성이 명확하고 쉬움


1. Presentation Layer (UI 계층 - Controller)

역할

  • 사용자 요청을 받아서 적절한 응답을 반환 (주로 JSON 형태)

  • HTTP 관련 처리 (@RestController, @GetMapping 등)

테스트 전략

  • @WebMvcTest를 사용하여 컨트롤러만 테스트

  • MockMvc + ObjectMapper로 HTTP 요청/응답 흐름을 검증

  • Service는 @MockBean으로 주입

📌 예시 코드

WebMvcTest(controllers = OrderController.class)
class OrderControllerTest {

    @Autowired MockMvc mockMvc;
    @Autowired ObjectMapper objectMapper;
    @MockBean OrderService orderService;

    @Test
    void 신규_주문_등록_성공() throws Exception {
        OrderCreateRequest request = OrderCreateRequest.builder()
                .productNumbers(List.of("001"))
                .build();

        mockMvc.perform(post("/api/v1/orders/new")
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(request)))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.code").value("200"))
                .andExpect(jsonPath("$.message").value("OK"));
    }

    @Test
    void 상품번호_없을때_예외() throws Exception {
        OrderCreateRequest request = OrderCreateRequest.builder().build();

        mockMvc.perform(post("/api/v1/orders/new")
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(request)))
                .andExpect(status().isBadRequest())
                .andExpect(jsonPath("$.message").value("상품 번호 리스트는 필수입니다."));
    }
}

2. Business Layer (서비스 계층 - Service)

역할

  • 비즈니스 로직 처리의 중심

  • 트랜잭션 처리, 여러 레포지토리 호출, 외부 API 조합 등

테스트 전략

  • @SpringBootTest 또는 순수 JUnit 테스트로 비즈니스 로직 검증

  • Repository는 필요 시 가짜(Mock) 또는 실제 DB 사용

  • Happy Path + 예외 케이스 + 경계값 등 다양한 시나리오 테스트

📌 예시 코드

@SpringBootTest
class ProductServiceTest {

    @Autowired
    private ProductService productService;

    @Autowired
    private ProductRepository productRepository;


    @AfterEach
    void tearDown() {
        productRepository.deleteAllInBatch();
        ;
    }

    @DisplayName("신규 상품을 등록한다. 상품번호는 가장 최근 상품의 상품번호에서 1증가한 것이다.")
    @Test
    void createProduct() {
        //given
        Product product = createProduct("001", HANDMADE, SELLING, "아메리카노", 4000);
        productRepository.save(product);
        ProductCreateServiceRequest request = ProductCreateServiceRequest.builder()
                .type((HANDMADE))
                .sellingStatus((SELLING))
                .name("카푸치노")
                .price(5000)
                .build();

        //when
        ProductResponse productResponse = productService.createProduct(request);

        //then
        assertThat(productResponse)
                .extracting("productNumber", "type", "sellingStatus", "name", "price")
                .contains("002", HANDMADE, SELLING, "카푸치노", 5000);

        List<Product> products = productRepository.findAll();

        assertThat(products).hasSize(2)
                .extracting("productNumber", "type", "sellingStatus", "name", "price")
                .containsExactlyInAnyOrder(
                        tuple("001", HANDMADE, SELLING, "아메리카노", 4000),
                        tuple("002", HANDMADE, SELLING, "카푸치노", 5000)
                );
    }

주의: 테스트에서 @Transactional을 사용할 경우, 실제 코드와 작동 방식이 달라질 수 있음 → 필요에 따라 deleteAllInBatch()로 정리


3. Persistence Layer (저장소 계층 - Repository)

역할

  • 데이터베이스와 직접 상호작용 (CRUD, 조건 검색 등)

  • JPA, MyBatis 등 ORM/쿼리 도구 사용

테스트 전략

  • @DataJpaTest를 사용하여 가벼운 JPA 테스트

  • 테스트 종료 시 자동으로 Rollback 처리

  • 실제 DB 조회/저장 동작 검증

📌 예시 코드

@ActiveProfiles("test")
//@SpringBootTest
@DataJpaTest
class ProductRepositoryTest {

    @Autowired
    private ProductRepository productRepository;

    @DisplayName("원하는 판매상태를 가진 상품들을 조회한다.")
    @Test
    void findAllBySellingStatusIn() {
        //given
        Product product1 = createProduct("001", HANDMADE, SELLING, "아메리카노", 4000);

        Product product2 = createProduct("002", HANDMADE, HOLD, "카페라떼", 4500);

        Product product3 = createProduct("003", HANDMADE, STOP_SELLING, "팥빙수", 7000);

        productRepository.saveAll(List.of(product1, product2, product3));

        //when
        List<Product> products = productRepository.findAllBySellingStatusIn(List.of(SELLING, HOLD));

        //then

        assertThat(products).hasSize(2)
                .extracting("productNumber", "name", "sellingStatus")
                .containsExactlyInAnyOrder(
                        tuple("001", "아메리카노", SELLING),
                        tuple("002", "카페라떼", HOLD)
                );
    }

요약

  • Controller : HTTP 요청/응답 처리, @WebMvcTest + MockMvc로 요청 파라미터, 상태 코드, JSON 응답 값 등을 테스트

  • Service : 비즈니스 로직 처리, @SpringBootTest or JUnit + Mockito로 로직 분기, 예외 처리, 흐름 제어 등 테스트

  • Repository : 데이터 CRUD 및 조회, @DataJpaTest로 쿼리 정확성, 연관 관계 매핑, 조건 검색 등을 테스트

댓글을 작성해보세요.


채널톡 아이콘