실제 서비스를 개발하다 보면 “이건 분명 검증을 해놨는데 왜 이런 에러가 나지?”라는 순간을 자주 마주한다.
이번 글은 검증 로직의 위치(@Valid vs Service) 에 대한 이야기이기도 하지만,
- 검증 로직이 흩어졌을 때 생기는 문제
- Mock 기반 테스트가 왜 ‘허구의 안정감’을 줄 수 있는지
- 그리고 왜 Service 레이어 단일 검증 전략을 선택하게 되었는지
를 다룬다.
하지만 그보다 더 중요한 건
“문제가 어떻게 드러났고, 왜 혼자서는 끝까지 발견하지 못했는가”에 대한 기록이다.
1. 문제가 생기게 된 원인
API 기능 구현을 마쳤을 때, 스스로는 이렇게 생각했다.
- 요구사항은 모두 반영했다
- 예외 처리도 했다
- 공통 응답 포맷도 정리했다
이제 남은 건 테스트 코드 작성뿐이라고 생각했다.
하지만, 컨트롤러 / 서비스 테스트를 하나씩 작성하면서
이상한 순간들이 계속 등장했다.
이에 잘못된 길을 가기 시작했다.
1) 테스트 결과에 맞춰 코드를 수정하기 시작했다
문제는 여기서부터였다.
테스트가 실패하면,
- “아, 이건 @Valid가 있어야 하나?”
- “DTO에 @NotNull을 붙이면 테스트가 통과하네”
이런 식으로 테스트 결과에 맞춰 코드를 수정하기 시작했다.
그 결과,
- 검증 로직이 DTO, Controller, Service, 여러 계층에 흩어지기 시작했다.
- 왜 이 검증이 여기 있는지,
스스로도 명확하게 설명하기 어려운 상태가 됐다.
2) 문제가 있다는 건 인지했고, 리팩토링을 했다
이 상태가 좋지 않다는 건 분명히 느끼고 있었다.
그래서 한 번 관련된 코드를 큰 리팩토링을 진행했다.
- 공통 응답 포맷 도입
- 예외를 CustomException 중심으로 정리
- 검증 로직을 Service 쪽으로 모은다고 “생각했다”
3) 미처 발견하지 못했던 문제들
리팩토링 이후에도 놓친 것들이 있었다.
- DTO에 여전히 남아 있는 @NotNull
- Controller에 남아 있는 @Valid
- Swagger 문서와 실제 검증 로직의 불일치
- 테스트는 통과하지만, 실제 흐름상 위험한 지점들
이 문제들은 혼자 개발하면서는 쉽게 눈에 들어오지 않았다.
4) 코드리뷰에서 드러난 진짜 문제
코드리뷰를 통해 혼자서는 미처 발견하지 못한 문제점들을 잡을 수 있었다.
- “이 API는 어디에서 검증하는 게 기준인가요?”
- “왜 어떤 에러는 Spring 기본 에러고, 어떤 건 CustomException인가요?”
- “이 테스트는 실제 검증을 보장하나요, 아니면 Mock이 대신 던져주나요?”
그제서야 문제가 명확해졌다.
문제는 ‘검증 방식’이 아니라
검증에 대한 ‘기준’이 없었다는 것
다음은,
이 리팩토링 과정을 통해 정리한 내용이다.
1. 검증 전략이 섞여 있을 때의 문제
@Valid vs Service 검증
검증에는 크게 두 가지 접근이 있다.
1. 선언적 검증 (@Valid, Bean Validation)
- DTO에 @NotNull, @NotBlank 등을 선언
- 컨트롤러 진입 시 Spring이 자동 검증
- 실패 시 MethodArgumentNotValidException 발생
장점
- 코드가 간결하다
- 단순 필수값 검증에 적합하다
한계
- 비즈니스 규칙(날짜 비교, 상태 조합 등)을 표현하기 어렵다
- 기본 예외가 발생해 커스텀 ErrorCode 관리가 까다롭다
2. Service 내부 명시적 검증
if (요청값이 null이거나 조건을 위반하면) {
throw new CustomException(ErrorCode.XXX);
}
장점
- 비즈니스 규칙을 그대로 코드로 표현할 수 있다
- 어떤 에러 코드가 내려가는지 명확하다
- 공통 응답 포맷과 잘 맞는다
⚠️ 문제는 ‘혼용’이었다
- 어떤 API는 @Valid로 검증하고
- 어떤 API는 Service에서 직접 검증했다
그 결과, 어떤 요청은 Spring 기본 에러, 어떤 요청은 CustomException 으로 떨어지며,
응답 형식이 섞여버렸다.
클라이언트 입장에서는 에러를 예측하기 어려운 API가 된다.
2. 테스트가 ‘허구의 보증’이 되는 순간
Mocking의 함정
“이 테스트는 실제로는 아무것도 보장하지 않는다.”
컨트롤러 테스트에서 흔히 하는 실수
given(service.method()).willThrow(CustomException);
이렇게 설정하면,
- 실제 Service에 검증 로직이 있든 없든
- 테스트는 항상 실패를 기대하고 통과한다
즉,
- 실수로 Service 검증 로직을 삭제해도, 테스트는 빨간불을 켜주지 않는다
-> 테스트가 실제 동작을 보증하지 못하는 상태
역할 분리가 필요했다
- Controller Test : 인증 여부, URL 매핑, 응답 포맷
- Service Test : null 처리, 날짜 비교, 비즈니스 규칙
검증 로직은 반드시 Service 테스트에서 직접 검증되어야 한다.
3. 테스트 오염과 검증 기준의 통일
SecurityContext 오염 문제
보안 정보는 ThreadLocal에 저장된다.
테스트 종료 후 이를 정리하지 않으면,
- A 테스트의 로그인 정보가
- B 테스트에 남아버리는 현상이 발생한다
그 결과, 실패해야 할 테스트가 성공할 수 있다.
-> 테스트 종료 시 보안 컨텍스트 초기화는 필수다.
메시지 vs 코드 검증
에러 메시지는 언제든 바뀔 수 있다.
- ❌ "존재하지 않는 리소스입니다"
- ✅ ERROR_CODE.RESOURCE_NOT_FOUND
테스트는 반드시
‘메시지’가 아니라 ‘코드’를 기준으로 검증해야 한다.
5. 실제로 발생했던 모순 사례
🔸 부분 수정 API인데 필수값 강제
- DTO에는 @NotNull
- Service 로직은 “값 있으면 수정”
-> PATCH API인데 PATCH답지 않게 동작
해결
- DTO에서는 nullable 허용
- Service에서 “들어온 값만 검증”
🔸 DB는 NOT NULL, Service는 무대응
- 요청에서는 null 허용
- Service에서 검증 누락
- DB에서 예외 발생
-> 에러가 DB까지 내려가면, 원인 추적이 훨씬 어려워진다.
6. 최종 선택: Service 단일 검증 전략
왜 Service로 통일했는가?
- 모든 에러가 CustomException으로 떨어진다
- ErrorCode 관리가 일관된다
- Swagger 문서와 실제 로직을 맞출 수 있다
- 테스트 책임이 명확해진다
| Swagger | 문서 (안내) |
| @Valid | 입구 컷 |
| Service 검증 | 비즈니스 판단 |
| DB 제약 | 최후의 방어선 |
7. 수정 방향 요약
✔️ 검증 전략
- @Valid, Bean Validation 제거
- DTO는 표현만 담당
- 모든 검증은 Service에서 수행
✔️ Swagger
- 실제 필수값만 명시
- “문서용 required”와 “로직”을 일치
✔️ 테스트
- Controller Test: 응답 코드 중심
- Service Test: null / 경계값 / 비즈니스 규칙 중심
8. 원인 다시 정리
1️⃣ 테스트를 너무 늦게 작성했다
- 기능 거의 완성 → 테스트 몰아서 작성
- 테스트가 깨지면 원인을 이해하기보다 빨리 맞추는 방향으로 수정
2️⃣ AI로 테스트 코드를 빠르게 만들었다
AI는 “통과하는 테스트”를 잘 만들어준다.
하지만,
- 이 테스트가 실제 로직을 검증하는지
- 아니면 Mock이 만들어낸 허구의 안정감인지
그 판단은 결국 개발자의 몫이다.
👉 테스트 코드가 로직을 끌고 가는 상황이 발생했다.
3️⃣ 하나의 브랜치(PR)를 너무 오래 사용했다
- 3주 넘게 같은 브랜치에서 작업
- 중간중간 다른 기능도 함께 수정
- “왜 이 코드가 이렇게 됐지?”를 스스로도 추적하기 어려워짐
👉 커밋 히스토리는 있어도 의사결정의 맥락은 남아 있지 않았다.
4️⃣ 기록이 없었다
- 왜 @Valid를 넣었는지
- 왜 이 검증을 여기서 하는지
- 당시 어떤 문제가 있었는지
어떤 것도 남아 있지 않았다.
마무리
이번 리팩토링을 통해
- 테스트를 대하는 태도
- 코드리뷰의 의미
- AI 사용 방식
- 개발 기록의 중요성
이 모든 걸 다시 생각하게 됐다.
다음에는
“기능을 다 만든 후 테스트”가 아니라,
“테스트를 쓰면서 설계를 점검하는 흐름”으로
개발해보려고 한다.
'Backend > Trouble Shooting' 카테고리의 다른 글
| JPA 자동 스키마 관리에서 Flyway 기반 DB 마이그레이션 (0) | 2026.02.03 |
|---|---|
| [Spring Boot] Swagger(Springdoc)에서 제네릭 공통 응답 스키마가 꼬이는 문제 해결기 (0) | 2026.01.22 |
| [SpringBoot] UTC 시간 처리, 코드가 아니라 설정으로 정리하기 (Hibernate 6 & Jackson) (0) | 2026.01.21 |
| [SpringBoot] 날짜와 시간 처리를 UTC로 표준화하기 (0) | 2026.01.10 |
| [SpringBoot] 프로젝트에 SonarCloud 연동하기: Gradle + GitHub Actions (0) | 2026.01.05 |