사용자의 입력값이 기본적인 형식 요건을 충족하는지 검증하는 1차 방어선이다.
ex. 문자열 길이 제한, 이메일 형식, null 허용 여부 등
일반적으로 @Valid
, @NotNull
, @Size
, @Email
등 Bean Validation 을 사용하여 DTO 필드의 형식을 검증한다.
@PatchMapping(path = "/{messageId}")
@Override
public ResponseEntity<MessageDto> update(
@PathVariable UUID messageId,
@Valid @RequestBody MessageUpdateRequest messageUpdateRequest
) {
...
}
💡 Bean Validation이란?
어노테이션을 통해 객체의 필드에 제약 조건을 선언적으로 정의할 수 있는 검증 API이다.
이를 사용하면 검증 로직이 명확해지고, 코드의 가독성과 유지보수성을 높일 수 있다.
또한, 표준화된 방식으로 검증을 수행하여 일관된 검증을 구현할 수 있다.
비즈니스 규칙에 따른 유효성을 검증한다.
ex. 이메일 중복 여부, 사용자 권한 검증, 상태값 검증 등
검증 실패 시 적절한 예외를 발생시켜 도메인 계층에 잘못된 요청이 전달되지 않도록 한다.
if (userRepository.existsByEmail(email)) {
log.warn("유저 생성 실패: 이미 존재하는 이메일");
throw UserAlreadyExistsException.byEmail(email);
}
객체 자체가 지켜야 할 불변 조건을 검증한다.
잘못된 상태의 객체 생성을 방지하기 위해, 주로 생성자나 정적 팩토리 메서드 내부에서 검증을 수행한다.
ex. 이메일은 null일 수 없음, 상태는 enum 범위 내여야 함 등
public User(String email, String name) {
if (email == null || email.isBlank()) {
throw new IllegalArgumentException("이메일은 필수입니다.");
}
this.email = email;
this.name = name;
}
📌 왜 도메인(Entity)에서도 유효성 검증이 필요한가?
@Valid
를 활용한 DTO 검증과 도메인 내부의 검증이 유사하게 느껴져 '중복 검사 아닌가?' 하는 의문이 들 수 있다.
하지만 이는 컨트롤러를 거치지 않고 객체가 직접 생성되거나 조작되는 경우에도 유효성을 보장하기 위한 안전 장치다. 즉, 생성 시점의 불변 조건 검증은 도메인의 책임으로, 이는 중복이 아닌 계층적 책임 분리로 이해해야 한다.
데이터베이스 제약 조건을 통해 데이터 무결성을 보장하는 최종 방어선 역할을 한다.
앞 계층에서 모든 유효성 검증을 수행했더라도, 동시성 이슈나 예외 상황 등으로 인해 잘못된 데이터가 저장될 수 있다.
애플리케이션 코드 외부에서 발생하는 비정상 입력도 막을 수 있어야 한다.
주요 제약 조건:
UNIQUE
: 중복 방지FOREIGN KEY
: 참조 무결성 보장NOT NULL
: 필수 컬럼 누락 방지CHECK
: 값의 범위나 조건 제약계층 | 검증 목적 | 예시 및 기술 방식 |
---|---|---|
Presentation (Controller, DTO) | 사용자 입력 형식 검증 | @Valid , @NotBlank , @Email 등 |
Business (Service) | 비지니스 규칙 검증 (중복, 상태 등) | 이메일 중복, 권한 체크 |
Domain (Entity) | 객체의 불변 조건 보장 | 생성자/팩토리 메서드에서 null, enum 등 검증 |
Persistence (DB) | 데이터 무결성 보장 | UNIQUE , NOT NULL , FOREIGN KEY , CHECK 등 |
동일한 검증 로직(ex. 이메일 형식 체크, 중복 여부 등)이 여러 계층에서 반복되는 현상이다.
이는 코드 중복, 유지보수 비용 증가, 버그 유발 가능성으로 인해 지양해야 한다.
각 계층의 책임에 따라 검증 로직을 명확히 분리한다.
단, 예외 상황이나 동시성 이슈에 대비해 일부 검증은 의도적인 중첩 방어 전략으로 허용되어야 한다.
💡 의도적인 중첩 방어가 필요한 경우
ex. 이메일 중복
-> Service 단에서 중복 체크 + DB에서UNIQUE
제약으로 최종 무결성 보장
-> 이는 단순한 중복이 아니라, 시스템 신뢰성과 무결성을 확보하기 위한 방어적 설계로 볼 수 있다.
👍 장점
계층 간 책임 분리로 인해 유지보수가 쉬워지고 테스트가 용이하다.
검증 로직의 일관성, 재사용성, 가독성이 높아진다.
중복을 줄여 코드 품질이 향상된다.
👎 단점
계층간 책임이 명확하지 않으면 책임 누락/중복 가능성이 생길 수 있다.
예외 상황에 대한 방어력이 낮아질 수 있다. (ex. 동시성, 외부 API 등)
➡️ 중복 검증이 불가피한 경우, "의도적인 중첩 방어"임을 명시하고, 주석이나 문서를 통해 그 목적을 기록해두어야 한다.
📌 트레이드 오프란?
어떤 것을 얻기 위해 다른 것을 포기해야 하는 상황을 말하며, 현실적인 선택을 위해서는 선택 간의 상충 관계를 명확히 파악하고 우선순위를 고려해야 한다.
@Test
void 파일업로드_호출확인() {
// given (Mock 객체 생성)
FileUploader mockUploader = Mockito.mock(FileUploader.class);
// when
mockUploader.upload("simple.txt");
// then
verify(mockUploader).upload("simple.txt"); // 호출 검증
}
Mock + when().thenReturn()
형태로 사용된다.
@Test
void 파일업로드_성공() {
// given (Mock 객체의 동작 정의)
FileUploader stubUploader = Mockito.mock(FileUploader.class);
when(stubUploader.upload(anyString())).thenReturn(true);
// when
boolean result = stubUploader.upload("simple.txt");
// then
assertTrue(result);
}
@Test
void 파일업로드_일부상황만_조작() {
// given (실제/Spy 객체 생성)
FileUploader realUploader = new FileUploader();
FileUploader spyUploader = Mockito.spy(realUploader);
// 메서드 동작 정의 (특정 상황만 업로드 실패)
doReturn(false).when(spyUploader).upload("bad/file.txt");
// when
boolean normal = spyUploader.upload("good/file.txt"); // 실제 동작
boolean blocked = spyUploader.upload("bad/file.txt"); // 가짜 동작
// then
assertTrue(normal); // 실제 업로드
assertFalse(blocked); // 강제로 실패 처리
verify(spyUploader).upload("bad/file.txt");
}
⚠️ 단, Spy는 실제 객체의 상태에 의존하므로, 복잡한 로직 테스트에서는 주의가 필요하다.
Mock : 호출 유무와 횟수 등 행위 자체를 검증
- ex. upload()
메서드를 누가 호출했는지 알고 싶어
Stub : 특정 입력에 대해 고정된 응답만 반환
- ex. upload()
메서드를 호출하면 항상 "true"를 반환해줘
Spy : 실제 객체처럼 동작하지만, 일부 동작만 조작 가능
- ex. bad.txt
파일을 업로드 할때만 "false"를 반환해줘
구분 | 객체 생성 방식 | 실제 동작 여부 | 주로 사용하는 목적 |
---|---|---|---|
Mock | mock() | ❌ 실행 안 됨 | 호출 여부 검증, 외부 API 대체 |
Stub | mock() + when().thenReturn() | ❌ 실행 안 됨 | 특정 입력 → 고정된 반환값 지정 |
Spy | spy() | ⭕ 실제 동작 (일부 ❌) | 실제 동작 유지 + 일부 동작만 조작 가능 |
알찬 내용 잘 보고 갑니다!👍🏻