솔솔
[Test] Layered Architecture 구조적 이해와 단계별 테스트 작성법 본문
🍀 Layered Architecture란?
Layered Architecture(계층화 아키텍처)는 소프트웨어 시스템의 구성 요소를 목표한 대상의 역할과 책임에 따라 계층별로 분리(서로 독립적으로 동작)하여 설계하는 아키텍처 패턴이다.
먼저 "아키텍처"라는 용어의 본질을 살펴보면,
"목표한 대상의 구성과 동작 원리, 관계, 환경 등을 설명하는 설계도"
즉, 소프트웨어 아키텍처는 시스템이 목표로 하는 동작과 목적을 달성하기 위해 구성 요소(모듈) 간의 역할, 책임, 그리고 상호작용을 명확히 정의하는 체계를 의미한다.
🍀 3-Layer Architecture 와 3-Tier Architecture 차이
* 둘다 자주 쓰는 용어인데 헷갈려서 정확히 구분하기 위해 찾아봄
3-Tier Architecture : 애플리케이션 전체의 물리적 배포와 구성에 초점을 둔 설계 방식
3-Layer Architecture : 소프트웨어 설계의 논리적 계층화를 목적으로 한 아키텍처
구분 | 3-Layer Architecture | 3-Tier Architecture |
초점 | 애플리케이션의 논리적 설계 구조 | 시스템의 물리적 배포 구조 |
구성요소 | 프레젠테이션, 비즈니스, 데이터 접근 계층 | 클라이언트(Presentation), 서버(Application), 데이터베이스(Data) |
운영방식 | 계층별로 논리적으로 분리된 코드로 동작 | 계층별로 다른 서버에 배포 가능 |
목적 | 유지보수성, 코드 재사용성, 계층 간 책임 분리 | 시스템 확장성, 네트워크 통신 최적화, 물리적 분리 |
에시 | Controller + Service + Repository | Vue.js(클라이언트) + Spring(서버) + MySQL(데이터베이스) |
🍀 3-Layer Architecture 구성 요소와 역할
1. Presentation Layer (프레젠테이션 계층)
- 사용자와 애플리케이션 간의 인터페이스 역할을 하며, 요청을 받아 비즈니스 로직에 전달하고, 결과를 사용자에게 반환함
- @Controller 클래스
2. Business Layer (비즈니스 계층)
- 애플리케이션의 핵심 비즈니스 로직을 처리하며, Presentation Layer와 Persistence Layer 간의 중재 역할
- @Service 클래스
3. Persistence Layer (데이터 접근 계층)
- 데이터베이스와 상호작용하며, 데이터를 저장, 검색, 수정, 삭제하는 역할
- @Repository 클래스
🍀 3-Layer Architecture 각 계층별 테스트 작성법
💡 Layered Architecture에서 계층별 테스트를 해야하는 이유
앞서 설명한 Layered Architecture의 핵심은 각 계층이 "목표한 대상"을 정확히 처리하도록 설계된 것이다.
각 계층의 책임과 역할이 분리되어 있기 때문에, 테스트도 이에 맞게 독립적으로 수행되어야 한다.
결론적으로 유지보수성과 확장성을 높이기 위한 목적!!
1. Presentation Layer 테스트
- 테스트 목표: 입력 값이 올바르게 검증되고, 비즈니스 계층과 정확히 상호작용하는지 확인
- 테스트 방식: HTTP 요청/응답 단위 테스트
// @WebMvcTest : Spring MVC 관련 컴포넌트만 테스트할 수 있도록 도와주는 어노테이션
// 컨트롤러 계층만을 테스트하려는 경우에 사용
@WebMvcTest(controllers = OrderController.class)
class OrderControllerTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper; // post에서 objectMapper 필요함
// @MockitoBean : @WebMvcTest와 함께 사용하여 서비스 계층이나 리포지토리 계층을 모킹(mocking)하여 테스트
@MockitoBean
private OrderService orderService;
@DisplayName("신규 상품을 등록한다.")
@Test
void createProduct() throws Exception {
//given
OrderCreateRequest request = OrderCreateRequest.builder()
.productNumbers(List.of("001"))
.build();
//when & then
mockMvc.perform(
post("/api/v1/orders/new")
.content(objectMapper.writeValueAsString(request))
.contentType(MediaType.APPLICATION_JSON)
)
.andDo(print())
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value("200"))
.andExpect(jsonPath("$.status").value("OK"))
.andExpect(jsonPath("$.message").value("OK"));
}
}
2. Service Layer 테스트
- 테스트 목표: 비즈니스 규칙이 정확히 적용되고, 다양한 시나리오를 올바르게 처리하는지 확인
- 테스트 방식: 비즈니스 로직에 대한 단위 테스트
// @SpringBootTest : 전체 Spring 컨텍스트를 로드하여 실제 애플리케이션의 동작 환경에서 통합 테스트를 수행할 수 있도록 해주는 어노테이션
@SpringBootTest
class OrderServiceTest {
@Autowired
private OrderService orderService;
@AfterEach
void tearDown() {
orderRepository.deleteAllInBatch();
}
@DisplayName("중복되는 상품번호 리스트로 주문을 생성할 수 있다.")
@Test
void createOrderWithDuplicateProductNumbers() {
//given
Product product1 = createProduct(HANDMADE, "001", 1000);
Product product2 = createProduct(HANDMADE, "002", 3000);
Product product3 = createProduct(HANDMADE, "003", 5000);
productRepository.saveAll(List.of(product1, product2, product3));
OrderCreateServiceRequest request = OrderCreateServiceRequest.builder()
.productNumbers(List.of("001", "001"))
.build();
//when
LocalDateTime registeredDateTime = LocalDateTime.now();
OrderResponse orderResponse = orderService.createOrder(request, registeredDateTime);
//then
assertThat(orderResponse.getId()).isNotNull();
assertThat(orderResponse)
.extracting("registeredDateTime", "totalPrice")
.contains(registeredDateTime, 2000);
assertThat(orderResponse.getProducts()).hasSize(2)
.extracting("productNumber", "price")
.containsExactlyInAnyOrder(
tuple("001", 1000),
tuple("001", 1000)
);
}
}
3. Persistens Layer 테스트:
- 테스트 목표: 데이터베이스와의 연동이 올바르게 동작하며, SQL 쿼리가 예상한 대로 작동하는지 확인.
- 테스트 방식: Entity 또는 Repository의 CRUD 동작 테스트
// @DataJpaTest : JPA 관련 컴포넌트만 로드하여, 데이터베이스와 관련된 테스트를 수행하는 데 사용되는 어노테이션
@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)
);
}
}