1. 애플리케이션의 각 계층에서 수행되는 입력값 검증의 범위와 책임을 어떻게 나눌 것인지에 대해 설명해주세요. 특히 중복 검증을 피하면서도 안정성을 확보하는 방안과, 이와 관련된 트레이드오프에 대해 설명해주세요.
- Controller
- 주로 컨트롤러에서는 도메인의 형식 및 필수값 검증을 담당
- 데이터 타입, 필수 필드 존재 여부 검증
- DTO에서 Bean Validation (ex: @NotNull, @NotBlank, @Size 등)
- Service
- 주로 비즈니스 로직과 연관된 도메인 규칙 검증을 담당
- 비즈니스 규칙 위반 여부 검증(ex: 중복 가입 여부, 주문 가능 여부 등)
- Repository
- 주로 데이터 무결성 검증 및 제약조건 관리를 담당
- 데이터베이스 제약조건(Unique 제약, FK제약 등)
- 실제 DB 제약조건으로 강제
중복 검증을 피하면서 안정성 확보 방안
- 계층별 책임 명확화
- Controller : 요청 데이터의 형식만 검증
- 형식 오류는 Controller 단계에서 빠르게 실패 처리(ex: @Valid 및 Bean Validation 활용)
- Service : 비즈니스 로직을 기반으로 한 검증 수행
- 형식이 올바른 데이터에 대해서만 중복 여부나 상태 전이 등의 검증(ex: 이메일 중복 가입 여부 확인)
- Repository : 데이터베이스 무결성을 위한 제약조건 설정
- DB 자체에서 중복 방지(ex: Unique 인덱스 생성)
- 적극적인 데이터베이스 제약조건 활용
- 최종 데이터 무결성은 반드시 데이터베이스 차원에서 강제
- Repository에서 DB 예외 처리 후, Service로 다시 비즈니스 예외로 변환하여 전달
- 중복된 요청이 동시에 들어왔을 때 경쟁 상태를 방지함
중복 검증을 피하기 위한 트레이드오프
- Controller ↔ Service 검증 중복 문제
- 중복 발생 원인 : Controller에서 비즈니스 검증까지 수행하면 로직이 중복됨
- 해결방안 :
- Controller는 형식 검증만 명확히 한정
- 비즈니스 로직은 Service 계층에서만 처리하여 중복 제거
- Service ↔ Repository 검증 중복 문제
- 중복 발생 원인 : Service에서 미리 중복 체크 후 저장을 시도했지만, DB 레벨에서 제약조건을 다시 체크하여 중복 발생
- 해결 방안 :
- 서비스는 중복 여부의 선제적 확인, Repository는 최종 방어
- 예외 발생 시점은 Repository로 통일, 서비스는 DB 예외를 처리하여 일관된 예외로 반환
- 트레이드 오프
| 계층 | 무엇을 | 장점 | 단점 | 도움말 |
| Controller/DTO | 형식·타입·범위·크기, 기본 정규화(trim 등) | 입구에서 빨리 차단, 리소스 절약, 에러 응답 통일 | 비즈니스 규칙·동시성은 못 막음, UI와 규칙 중복 우려 | @Valid + @ControllerAdvice로 포맷만 검증 |
| Service/도메인 | 비즈니스 규칙, 상태 전이, 교차 필드/권한 체크 | 도메인 중심으로 의미 검증, 친절한 메시지 | 사전조회는 경합 취약, 트랜잭션 관리 필요 | 사전검사 + DB 예외 매핑(둘 다)로 안전하게 |
| Repository/DB | 절대 무결성: NOT NULL, UNIQUE, FK, CHECK | 최종 안전망, 동시성에 강함 | 에러 메시지 기술적, 스키마 변경 비용 | 유니크·참조 무결성은 반드시 DB로 보장 |
나만의 언어로 정리해보기
각 계층에서 수행되는 입력값 검증의 범위와 책임
1. Controller
- 도메인의 형식 및 필수값 검증을 담당한다.
- 데이터 타입 및 필수 필드 존재 여부를 검증한다.
- DTO에서 Bean Validation을 통해 검증한다.
2. Service
- 비즈니스 로직과 연관된 도메인 규칙 검증을 담당
- 비즈니스 규칙 위반 여부 검증
3.Repository
- 데이터 무결성 검증 및 제약조건 관리를 담당
- 데이터베이스 제약조건
- 실제 DB 제약 조건으로 강제
중복 검증을 피하면서 안정성 확보 방안
- 계층별 책임 명확화
- 적극적인 데이터 베이스 제약조건 활용
※ 중복 검증을 피하기 위한 트레이드 오프는 위의 표 참조
2. 테스트에서 사용되는 Mockito의 Mock, Stub, Spy 개념을 각각 설명하고, 어떤 상황에서 어떤 방식을 선택해야 하는지 구체적인 예시와 함께 설명하세요.
Mock
- 개념 : 메서드 호출 여부 및 특정 행위를 확인하기 위해 사용하는 가짜 객체
- 특징 :
- 실제 로직을 사용하지 않는다.
- 모든 메서드가 기본적으로 빈 값이나 null을 반환한다.
- 모든 행동은 when() 또는 given()과 같은 메서드로 미리 정의해야한다.
Stub
- 개념 : 테스트에서 호출될 때 미리 준비된 값을 반환함으로써, 테스트 대상 코드가 의도한 특정 상태에서 동작하도록 만드는 가짜 객체
- 특징 :
- 메서드 호출 시 원하는 값을 반환하도록 정의한다.
- 특정 메서드 호출에만 초점을 맞춘다.
- 메서드 호출 여부나 횟수는 검증 X
- 테스트에 필요한 데이터를 제공하여 특정 상태를 흉내 낸다.
Spy
- 개념 : 실제 객체를 기반으로 동작하면서, 테스트에 필요한 특정 메서드의 행동만 선택적으로 바꿀 수 있는 부분적인 가짜 객체
- 특징 :
- 기본적으로 실제 로직 실행
- 필요한 메서드만 Stub으로 재정의 가능
- 특정 메서드의 행동만 선택적으로 변경 가능
각 상황 별 구체적인 활용 예시
예) 공통 시나리오
주문(Order)을 처리하는 OrderService를 테스트.
ItemRepository(상품 재고를 조회하고 차감하는 역할)와 NotificationClient(주문이 완료되면 사용자에게 알림(이메일, SMS 등)을 보내는 역할)의 두가지 외부 의존성을 가짐.
예시 코드)
// 테스트 대상 클래스 (System Under Test, SUT)
public class OrderService {
private ItemRepository itemRepository;
private NotificationClient notificationClient;
// 생성자 주입
public OrderService(ItemRepository itemRepository, NotificationClient notificationClient) {
this.itemRepository = itemRepository;
this.notificationClient = notificationClient;
}
// 주문 처리 메서드
public void placeOrder(String itemName, int quantity) {
int stock = itemRepository.getStock(itemName);
if (stock < quantity) {
throw new RuntimeException("재고가 부족합니다.");
}
itemRepository.deductStock(itemName, quantity); // 재고 차감
// 주문 완료 알림 발송
notificationClient.send(itemName + " 주문이 완료되었습니다.");
}
}
Stub : 상태 제어
- 테스트에 필요한 특정 상황이나 데이터를 만들어주기 위해 사용
- placeOrder 메서드를 테스트하려면, 먼저 상품의 재고가 충분한 '상태'를 만들어야 함. 실제 데이터베이스에 연결하는 대신, ItemRepository의 getStock() 메서드가 항상 특정 재고량을 반환하도록 Stub을 생성.
- 상황
- placeOrder가 재고가 충분할 때 정상적으로 실행되는지 확인
- 선택
- ItemRepository를 가짜로 만들고(mock), getStock("테스트상품")이 호출되면 100을 반환하도록 행동을 정의(stubbing).(itemRepository는 Stub의 역할을 수행)
예시 코드)
@Test
void 재고가_충분하면_주문이_성공해야_한다() {
// 1. Stub 설정 (ItemRepository가 Stub 역할)
// ItemRepository의 가짜 객체를 만들고,
ItemRepository itemRepository = Mockito.mock(ItemRepository.class);
// getStock("사과")가 호출되면 무조건 100을 반환하도록 정의 (stubbing)
Mockito.when(itemRepository.getStock("사과")).thenReturn(100);
// NotificationClient는 이 테스트의 관심사가 아니므로 가짜로 만듦
NotificationClient notificationClient = Mockito.mock(NotificationClient.class);
// 2. 테스트 실행
OrderService orderService = new OrderService(itemRepository, notificationClient);
orderService.placeOrder("사과", 10); // 재고(100) > 주문(10) 이므로 성공해야 함
// 3. (선택적) 검증
// 이 테스트의 핵심은 아니지만, 재고 차감 메서드가 호출되었는지 확인할 수 있음
Mockito.verify(itemRepository).deductStock("사과", 10);
}
Mock : 행위 검증
- 특정 객체의 메서드가 예상대로 호출되었는지, 몇 번 호출되었는지 확인하기 위해 사용
- 주문이 성공적으로 완료되면, NotificationClient의 send() 메서드가 정확히 한 번 호출되어야 함. 이 "호출되었는가?" 라는 행위 자체가 검증의 핵심
- 상황
- placeOrder가 성공했을 때, 사용자에게 알림을 보내는 send() 메서드가 정말로 호출되었는지 확인
- 선택
- NotificationClient를 Mock 객체로 생성. 이 Mock 객체는 send() 메서드가 호출되었는지 여부를 기억하고 있다가, 나중에 verify()를 통해 우리가 확인할 수 있게 함.
예시 코드)
@Test
void 주문이_성공하면_알림이_전송되어야_한다() {
// 1. Stub 설정 (테스트 환경 구성)
ItemRepository itemRepository = Mockito.mock(ItemRepository.class);
Mockito.when(itemRepository.getStock("사과")).thenReturn(100);
// 2. Mock 객체 생성 (검증 대상)
// NotificationClient의 가짜 객체를 만들어서 행위를 검증할 준비
NotificationClient notificationClient = Mockito.mock(NotificationClient.class);
// 3. 테스트 실행
OrderService orderService = new OrderService(itemRepository, notificationClient);
orderService.placeOrder("사과", 10);
// 4. 행위 검증 (Verification)
// notificationClient의 send() 메서드가 "사과 주문이 완료되었습니다." 라는 메시지와 함께
// '정확히 1번' 호출되었는지 검증한다.
Mockito.verify(notificationClient, Mockito.times(1)).send("사과 주문이 완료되었습니다.");
}
Spy : 실제 객체의 일부만 변경
- 대부분 실제 객체의 로직을 그대로 사용하면서, 특정 메서드만 우리의 의도대로 동작하게 만들고 싶을 때 사용
- OrderService에 주문 이력을 저장하는 saveHistory라는 내부 메서드가 있고, 이 메서드는 실제 파일 시스템에 접근해서 테스트가 느려진다고 가정했을 때, placeOrder의 전체적인 로직은 실제 코드로 테스트하되, saveHistory만 아무것도 하지 않도록 만들고 싶음.
- 상황
- OrderService의 전체적인 흐름은 실제 코드로 테스트하고 싶은데, 외부 시스템과 연동되거나 무거운 saveHistory 메서드만 동작하지 않도록 하고 싶음.
- 선택
- Spy를 사용해 실제 OrderService 객체를 감싼다. 이렇게 하면 모든 메서드 호출은 기본적으로 실제 객체로 전달되지만, saveHistory 메서드만 우리가 원하는 대로 가짜 행동을 정의할 수 있다.
예시 코드)
// Spy를 적용하기 위해 OrderService에 가상의 메서드 추가
public void saveHistory(String message) {
// 실제로는 파일에 주문 이력을 기록하는 복잡하고 느린 로직
System.out.println("이력 저장: " + message);
}
public void placeOrder(String itemName, int quantity) {
// ... (기존 로직과 동일) ...
itemRepository.deductStock(itemName, quantity);
notificationClient.send(itemName + " 주문이 완료되었습니다.");
// 이력 저장 메서드 호출
saveHistory(itemName + " " + quantity + "개 주문됨");
}
// --- 테스트 코드 ---
@Test
void 주문시_파일저장_로직은_실행하지_않고_테스트하고_싶다() {
// 1. 의존성 객체 준비 (Stub/Mock)
ItemRepository itemRepository = Mockito.mock(ItemRepository.class);
Mockito.when(itemRepository.getStock("사과")).thenReturn(100);
NotificationClient notificationClient = Mockito.mock(NotificationClient.class);
// 2. Spy 객체 생성
// 실제 OrderService 객체를 생성하고 Spy로 감싼다.
OrderService realService = new OrderService(itemRepository, notificationClient);
OrderService spyOrderService = Mockito.spy(realService);
// 3. Spy의 일부 메서드 행동 정의
// spyOrderService의 saveHistory() 메서드가 호출되어도 실제 로직을 타지 않도록 설정
// when(spy.method())는 실제 메서드를 호출하므로 doNothing()을 사용해야 안전하다.
Mockito.doNothing().when(spyOrderService).saveHistory(Mockito.anyString());
// 4. 테스트 실행
// Spy 객체의 메서드를 호출한다.
spyOrderService.placeOrder("사과", 10);
// 5. 검증
// placeOrder의 다른 로직들은 실제 코드대로 실행되었는지 확인
Mockito.verify(notificationClient).send("사과 주문이 완료되었습니다.");
// saveHistory가 호출은 되었지만, 실제 내용은 실행되지 않았음을 검증
Mockito.verify(spyOrderService).saveHistory("사과 10개 주문됨");
}
나만의 언어로 정리해보기
Mockito의 Mock, Stub, Spy 개념
1. Mock
- 메서드 호출 여부 및 특정 행위를 확인하기 위해 사용하는 가짜 객체
- 특정 객체의 메서드가 예상대로 호출되었는지, 몇 번 호출되었는지 확인하기 위해 사용 (행위 검증)
- 실제 로직을 사용하지 않고 모든 메서드가 기본적으로 빈 값이나 nul 값을 반환
2. Stub
- 테스트에서 호출될 때, 미리 준비된 값을 반환함으로써, 테스트 대상 코드가 의도한 특정 상태에서 동작하도록 만드는 가짜 객체
- 테스트에 필요한 특정 상황이나 데이터를 만들어 주기 위해 사용 (상태 제어)
- 메서드 호출 여부나 횟수는 검증 X
3.Spy
- 실제 객체를 기반으로 동작하면서, 테스트에 필요한 특정 메서드의 행동만 선택적으로 바꿀 수 있는 부분적인 가짜 객체
- 특정 메서드만 우리의 의도대로 동작하게 만들고 싶을 때 사용 (실제 객체의 일부만 변경)
- 기본적으로 실제 로직을 실행하지만 필요한 메서드만 stub으로 재정의 가능
'코드잇 BE스프린트' 카테고리의 다른 글
| 위클리 페이퍼 - 12주차 (0) | 2026.04.29 |
|---|---|
| 위클리 페이퍼 - 11주차 (0) | 2026.04.28 |
| 위클리 페이퍼 - 9주차 (0) | 2026.04.16 |
| 위클리 페이퍼 - 8주차 (1) | 2026.04.15 |
| 위클리 페이퍼 - 7주차 (1) | 2026.04.14 |