12주차 위클리페이퍼 주제이다.
- 애플리케이션의 각 계층에서 수행되는 입력값 검증의 범위와 책임을 어떻게 나눌 것인지에 대해 설명해주세요. 특히 중복 검증을 피하면서도 안정성을 확보하는 방안과, 이와 관련된 트레이드오프에 대해 설명해주세요.
- 테스트에서 사용되는 Mockito의 Mock, Stub, Spy 개념을 각각 설명하고, 어떤 상황에서 어떤 방식을 선택해야 하는지 구체적인 예시와 함께 설명하세요.
1. 각 계층 별 입력값 검증의 범위와 책임
애플리케이션의 각 계층이란?
현대의 애플리케이션은 복잡성을 제어하기 위해 MVC 패턴과 계층형 아키텍처라는 기술을 사용한다. 이후 모바일 시장이 등장하면서 클라이언트의 역할이 중요해지고, 서버와 클라이언트를 분리하는 클라이언트(SPA) - 서버(API) 아키텍처로 변화한다. 따라서, 입력값 검증의 범위와 책임도 각 계층의 관심사에 따라 분리되어야 한다.

위 그림은 서버의 계층형 아키텍처를 5Layer로 표현하고 있다.
- Presentaiton Layer(Model, View, Controller)
- Business Layer(Service
- Persistence layer(Data Access Layer, Repository)
- Database Layer(DB)
- Domain Layer(Entity)
계층 별 입력값 검증 목적에 따른 범위와 책임
위에서 살펴본 것 처럼 애플리케이션은 관심사 분리를 위해 계층형 아키텍처를 사용한다.
그렇다면 입력값의 검증 범위와 책임도 각 계층의 관심사에 맞게 설계해야 할 것이다.
아래는 각 계층의 입력값 검증의 목적에 따른 범위와 책임을 설명한다.
- 클라이언트 계층
- 목적 및 책임: 사용자 경험 향상 및 불필요한 서버 요청 방지
- 검증 범위: 필수 파라미터 누락, 비밀번호/이메일 등 형식, 파라미터 길이 등
- 주의점: 클라이언트 검증은 개발자 도구 등으로 우회할 수 있으므로 보안 목적으로 신뢰할 수 없음
- 프레젠테이션 계층
- 목적 및 책임: 서버의 첫 번째 계층으로, 들어온 데이터의 요청이 문법적으로 올바른지 확인함
- 검증 범위: 데이터 타입 검증, 페이로드의 구조적 유효성(JSON, UUID 등), NULL, 공백 등
- 구현: 주로 DTO에 @Valid와 관련된 어노테이션을 사용
// UserRequestDto.java
public record UserRequestDto(
@NotBlank(message = "이메일은 필수 입력값입니다.")
@Email(message = "올바른 이메일 형식이 아닙니다.")
String email,
@NotBlank(message = "비밀번호는 필수 입력값입니다.")
@Size(min = 8, message = "비밀번호는 최소 8자 이상이어야 합니다.")
String password
) {}
// UserController.java
public class UserController {
@PostMapping("/signup")
public ResponseEntity<String> signUp(@Valid @RequestBody UserRequestDto request) {
userService.register(request);
return ResponseEntity.ok("회원가입 성공");
}
}
- 비즈니스 계층
- 목적 및 책임: 데이터가 실제 비즈니스 규칙과 시스템에 부합하는지 확인함
- 검증 범위: 중복 데이터 검증(이메일, Username 등), 권한에 따른 비즈니스 로직 적용
- 구현: 실질적인 비즈니스 규칙에 맞는 로직 직접 구현
// UserService.java
public class UserService {
private final UserRepository userRepository;
public void register(UserRequestDto request) {
// 의미론적 검증: "이미 가입된 이메일인가?"
if (userRepository.existsByEmail(request.email())) {
throw new AlreadyExistsException("이미 사용 중인 이메일입니다.");
}
// 비즈니스 룰 검증: "특정 정책에 따라 가입이 제한되는가?"
if (isBlacklisted(request.email())) {
throw new PolicyViolationException("가입이 제한된 사용자입니다.");
}
}
}
- 도메인 계층
- 목적 및 책임: 엔티티 스스로가 생성부터 소멸까지 항상 도메인 규칙 및 시스템 상 유효한 상태임을 유지
- 검증 범위: 객체 생성 및 수정 시 논리적 무결성 검증
- 구현: 생성자에서 검증 로직 구현
// User.java (Entity)
public class User {
private String email;
private String password;
private User(String email, String password) {
Assert.hasText(email, "이메일은 비어있을 수 없습니다.");
Assert.hasText(password, "비밀번호는 비어있을 수 없습니다.");
this.email = email;
this.password = password;
}
}
- 영속성 계층(Repository, DB)
- 목적 및 책임: 서버 내 영구적으로 저장되는 데이터의 무결성을 보장하는 것
- 검증 범위: 저장되는 엔티티의 테이블에 해당하는 제약 조건(NOT NULL, UNIQUE 등)
- 구현: 테이블 작성 시 제약 조건에 해당하는 SQL문
CREATE TABLE users (
id BIGINT PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE, -- 물리적인 UNIQUE, NOT NULL 보장
password VARCHAR(255) NOT NULL,
age INT CHECK (age > 0) -- DB 수준의 값 범위 검증
);
최종적으로 표로 정리하면 다음과 같다.
| Layer | 주요 역할 및 책임 | 검증 내용 | 검증 방법 |
| 클라이언트 | 불필요한 서버 요청 방지 | 필수값 누락, 이메일 및 비밀번호 형식, 글자 수 제한 등 |
JS의 required 등 |
| 프레젠테이션 | 데이터 형식 검사 (구문 검증) |
데이터 타입, Null 여부, 정규식 매칭, 숫자 범위 등 |
DTO의 @Valid 등 |
| 비즈니스 | 비즈니스 규칙 검사 (의미적 검증) |
중복 데이터 확인, 권한 확인, 비즈니스 규칙 준수 확인 |
비즈니스 규칙에 맞는 쿼리 작성 |
| 도메인 | 객체 무결성 보장 | 객체 생성 시 필드값의 무결성 | 생성자 내 Assert 등 |
| 영속성 | DB 데이터 무결성 보장 | 제약 조건 준수 여부 | SQL의 제약 조건 |
핵심은 각 계층별 관심사에 맞게 검증의 범위와 책임을 가지는 것이다.
중복 검증 회피 및 안정성 확보 방안 + 트레이드오프
계층별로 책임을 나누었지만, "앞단에서 검증한 데이터를 뒷단에서 또 검증해야 하는가?"에 대한 딜레마가 발생할 수 있다..
이는 시스템의 안정성과 코드의 유지보수성 사이의 트레이드오프이다.
두 가지 대표적인 예외 상황을 통해 이를 구분할 수 있다.
상황 A: 알 수 없는 장애로 인한 데이터 손실
- 상황: 프레젠테이션 계층(DTO)에서 Null 검증을 완벽히 통과했는데, 네트워크 패킷 손실이나 프레임워크 오류 등 알 수 없는 이유로 서비스 계층에 Null이 전달된 경우.
- 대응: 서비스 계층에서 DTO에서 했던 Null 검증을 중복으로 수행하지 않음. 이러한 상황은 정상적인 비즈니스 흐름이 아닌 서버 오류임. 엔티티 생성 시점의 Assert나 500 에러(Internal Server Error)로 처리하여 시스템을 빠르게 실패(Fail-fast)시켜야 함.
- 트레이드오프: 극단적인 예외 상황까지 서비스 로직에서 방어하려 들면 코드가 비대해지고 유지보수가 어려워지므로(DRY 원칙 위배), 발생 확률이 희박한 시스템 결함은 인프라 계층의 예외로 넘겨야 함.
상황 B: 동시성 이슈로 인한 논리적 예외
- 상황: 서비스 계층에서 existsByEmail로 이메일 중복을 확인(통과)하고 DB에 저장하려는 찰나, 레이스 컨디션(Race Condition)으로 인해 동시에 같은 이메일이 가입되어 DB 수준에서 중복 예외가 발생하는 경우.
- 대응: 이는 분산 환경에서 발생하는 흔하고 논리적인 예외이므로 반드시 처리해야 함. DB 계층의 UNIQUE 제약조건을 최후의 방어선으로 삼고, 서비스 계층에서 DB 예외(DataIntegrityViolationException)를 잡아 비즈니스 예외(409 Conflict)로 전환해서 사용자에게 전달해야함.
- 트레이드오프: 앞선 계층의 검증을 통과했더라도 동시성 문제 앞에서는 무력할 수 있음. 따라서 성능상 비용이 들더라도 핵심 데이터 무결성에 대해서는 여러 계층에 걸쳐 방어적으로 검증해야함.
2. Mockito의 Mock, Stub, Spy
Mockito란?
백엔드 애플리케이션 코드를 작성하다보면 복잡한 로직을 마주하게 된다. 이때 작성한 로직이 의도한대로 잘 작동하는지 확인하기 위해서는 실제로 환경을 구축해서 API 요청을 하면서 테스트를 해볼 수도 있다. 하지만 이는 환경 구축의 오버헤드와 부분적인 문제를 알기 어렵다는 문제점이 있다. 이를 해결하기 위해 부분적으로 기능을 테스트하는 단위테스트가 있다. 단위 테스트에서는 의존성을 격리하기 위해 Mockito를 사용한다.
Mockito는 Java에서 테스트를 작성할 때 사용되는 대표적인 Mock(가짜) 객체 생성 프레임워크이다. 테스트하고자 하는 계층이 다른 계층을 강하게 의존하고 있을 때, 해당 의존성을 Mock 객체로 대체하여 오직 테스트 계층의 로직에만 집중할 수 있게 한다.
테스트의 종류
- 통합 테스트
- @SpringBootTest를 사용하여 애플리케이션의 모든 빈(Bean)과 실제 데이터베이스까지 전부 연결하여 테스트함.
- 실제 운영 환경과 유사하게 검증할 수 있지만, 실행 속도가 느리고 오류 발생 시 원인을 파악하기 어렵다는 단점이 있음.
- 슬라이스 테스트
- @WebMvcTest, @DataJpaTest 등을 사용하여 애플리케이션의 특정 계층(Layer)만 얇게 잘라내어 테스트함.
- 컨트롤러 계층의 API 요청/응답 형식이나, 리포지토리 계층의 쿼리가 잘 작동하는지 확인함.
- 단위 테스트
- 스프링 컨테이너(IoC)를 아예 띄우지 않고, 순수한 Java 코드만 메모리에 올려 테스트하며 속도가 매우 빠름.
- 단위 테스트의 주 대상은 '비즈니스 계층(Service)'임. 애플리케이션의 핵심 로직과 정책이 모여있는 서비스 계층은 외부 인프라(DB 등)에 구애받지 않고 언제든 빠르고 독립적으로 검증되어야 하기 때문임.
단위 테스트에서 의존성을 해소하는 방법
그렇다면 스프링 없이 서비스 계층을 단위 테스트하려면 어떻게 해야 할까?
실제 코드를 보면서 알아보자.
아래와 같이 작성된 서비스 로직이 있다.
// UserService
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository; // 의존성 1
private final UserMapper userMapper; // 의존성 2
public UserResponseDto registerUser(UserRequestDto request) {
// 1. 중복 이메일 검증
if (userRepository.existsByEmail(request.email())) {
throw new IllegalArgumentException("이미 사용 중인 이메일입니다.");
}
// 2. DTO -> Entity 변환
User user = userMapper.toEntity(request);
// 3. DB 저장
User savedUser = userRepository.save(user);
// 4. Entity -> DTO 변환 후 반환
return userMapper.toDto(savedUser);
}
}
이 UserService의 registerUser는 회원가입 기능이고 이를 테스트 하기 위해서는 UserRepository와 UserMapper라는 외부 인프라에 의존성을 해결해야 한다. 이때 등장하는 것이 Mockito이다.
Mockito에는 @Mock, @Stub, @Spy 등의 어노테이션이 있고 단위테스트에서는 서술한 3가지를 사용한다.
먼저 Mock 객체이다.
- 개념: 실제 객체와 겉모습(메서드 시그니처)는 동일하되, 내부는 텅 비어있는 가짜 객체
- 사용법: Mock 객체로 만들 계층에 선언적 어노테이션을 작성한다.
@ExtendWith(MockitoExtension.class)
class UserServiceTest {
@Mock
private UserRepository userRepository; // DB 통신을 차단하기 위한 완전한 가짜 객체
@Spy
private UserMapper userMapper = new UserMapperImpl(); // (Spy는 아래에서 설명)
@InjectMocks
private UserService userService; // 가짜 객체들을 진짜 Service에 조립(주입)해줌
}
다음으로는 Stub이다.
- 개념: Mock으로 만든 텅 빈 가짜 객체가 특정 상황(주로 검증할 상황)에서 어떤 값을 반환할지 미리 약속하는 행위이다.
- 사용법: Mock객체의 특정 상황(메서드와 파라미터)의 리턴값을 설정한다
@Test
@DisplayName("이미 존재하는 이메일이면 예외가 발생한다")
void duplicateEmailTest() {
// Given: Stubbing (대본 작성)
// 가짜 userRepository에게 "이 이메일로 중복 검사 들어오면 무조건 true라고 대답해!" 라고 지시합니다.
UserRequestDto request = new UserRequestDto("test@test.com", "password");
BDDMockito.given(userRepository.existsByEmail(request.email())).willReturn(true);
// When & Then: 실제 서비스 로직을 실행하면, 위에서 작성한 대본(true)에 의해 예외가 터져야 함
assertThrows(IllegalArgumentException.class, () -> userService.registerUser(request));
}
마지막으로는 Spy이다.
- 개념: 기존 로직은 그대로 수정하면서, 개발자가 Stub을 작성해 원하는대로 작동하게 하거나 호출 여부를 감시할 수 있다.
- 사용법: 사용할 대상에 선언적 어노테이션을 작성하고 구현체를 직접 주입한다.
@Test
@DisplayName("정상적인 정보가 주어지면 회원가입에 성공한다")
void registerSuccessTest() {
// Given
UserRequestDto request = new UserRequestDto("test@test.com", "password");
User savedUser = new User(1L, "test@test.com", "password"); // DB에서 반환될 가짜 엔티티
// DB 조회는 가짜(Stub)로 막아줍니다.
BDDMockito.given(userRepository.existsByEmail(request.email())).willReturn(false);
BDDMockito.given(userRepository.save(any(User.class))).willReturn(savedUser);
// When
// 이때 userMapper.toEntity() 와 toDto() 는 @Spy 객체이므로 "실제 변환 로직"이 실행됩니다.
UserResponseDto response = userService.registerUser(request);
// Then
assertNotNull(response);
assertEquals("test@test.com", response.email());
}
어떤 상황에서 어떤 방법을 선택해야 하는지?
사실 이에 대한 대답은 위에서 전부 했다. 의존성이 커서 그 의존성을 끊어내야 실질적인 단위 테스트가 되는 대상은 Mock 객체로 만들고 반환값은 Stub으로 작성한다. 그 외에 의존성이 작은 단순 매퍼나 유틸리티 클래스는 반환값을 매번 직접 작성하기 어려우므로 이때는 Spy 객체로 만들고, 의존성을 끊어내야 할 때 Stub으로 의존성을 끊어내면 된다.