TL;DR
각 계층별로 역할과 책임을 분리해 유효성 검사를 수행하는 것이 유지보수성과 테스트 측면에서 더 나을 것이라 판단했고 이에 따라 DTO는 입력 형식, Domain은 도메인 규칙, Service는 비즈니스 로직 기반의 검증을 담당하도록 구조를 조정했다.
왜 validation 위치를 고민하게 되었는가
최근 사이드 프로젝트를 새로 시작하면서 TDD를 적극적으로 적용해보기로 했다.
레이어드 아키텍처를 기반으로 테스트 작성이 편하도록 책임을 나누고 의존성을 최소화하는 구조를 설계했다.
그러다 평소처럼 service 에 유효성 검사를 작성하던 중 문득 이런 생각이 들었다.
이렇게 서비스에 마구잡이로 validation을 몰아넣는 게 맞을까? 도메인에서는 어떤 검증을 맡는 게 더 적절할까?
테스트와 설계를 함께 고민하다 보니 유효성 검사의 위치와 책임에 대해 다시 한 번 생각해보게 되었다.
이번 글에서는 그 고민을 정리해보려 한다.
기존 방식의 문제점
기존에는 대부분 유효성 검사를 service 계층에서 처리해왔다.
class Service {
...
if (email == null || !email.matched(REGEX)) {...}
이 방식은 구현은 단순하지만 API 명세에서 확인하기 어렵고 책임이 몰려있다는 느낌이 들었다.
그러다 보니 테스트도 점점 불편해졌고 책임을 분리할 필요성을 느끼게 되었다.
계층별 역할에 대한 고민
- 유효성 검사를 어디에 두어야 깔끔할까?
- 단순한 입력 형식 검증은 어디서 처리해야할까?
- 도메인 생성 규칙에 관련된 검증은 뭐가 있을까?
- service에서 검증할 규칙은 뭐가 있을까?
일단 현재 프로젝트의 구조를 다시 들여다보았다.
- interface (Controller / DTO)
- application (Facade / Info)
- domain (Service / Model)
- infrastructure (Repository)
이 구조에 따라 유효성 검사를 처리할 위치를 크게 세 곳으로 나눠보고, 각 계층에서 검증의 목적을 고민해보았다.
| 계층 | 목적 |
| DTO | - API 명세에 반영 가능 - 컨트롤러 진입 전 필터링 가능 |
| Facade / Service | - 비즈니스 로직 조건 검증 - 여러 객체를 사용하는 검증 - DB 조회 결과 기반 검증 |
| Model | - 잘못된 객체 생성 차단 (무결성 보장) |
적용 기준에 따른 구현 예시
위에서 정리한 목적에 따라 현재 구조에서 아래와 같이 사용하도록 정리해보았다.
DTO
단순한 입력 형식은 @NotNull, @Pattern 같은 annotation 을 사용해 검증
public record JoinRequest(
@NotNull @Pattern(regexp = "^[a-zA-Z0-9]{1,10}$", message = "아이디는 영문 및 숫자 10자 이내로만 작성해야 합니다.")
String userId,
@NotNull @Pattern(regexp = "^[a-z]+@[a-z]+\\\\.[a-z]{2,}$", message = "이메일은 xx@yy.zz 형식으로 작성해야 합니다.")
String email
) {...}
Domain
도메인 객체 생성 시 내부 규칙에 따라 잘못된 객체가 생성되지 않도록 생성자 또는 정적 팩토리에서 검증
@Builder
public UserModel(String userId, String email) {
if (userId == null || !userId.matches("^[a-zA-Z0-9]{1,10}$")) {
throw new CoreException(ErrorType.BAD_REQUEST, "아이디는 영문 및 숫자 10자 이내로만 작성해야 합니다.");
}
if (email == null || !email.matches("^[a-z]+@[a-z]+\\\\.[a-z]{2,}$")) {
throw new CoreException(ErrorType.BAD_REQUEST, "이메일은 xx@yy.zz 형식으로 작성해야 합니다.");
}
this.userId = userId;
this.email = email;
}
Service
DB 조회 결과를 바탕으로 한 조건 검증, 여러 도메인을 사용해야하는 경우 Facade에서 검증
public class UserService {
...
public UserModel save(UserModel userModel) {
// 가입 확인
if (userRepository.existsByUserId(userModel.getUserId())) {
throw new CoreException(ErrorType.BAD_REQUEST, "이미 존재하는 아이디 입니다.");
}
마무리
이렇게 각 계층의 역할과 책임에 따라 유효성 검증의 위치를 나름대로 분리해보았다.
물론 여전히 고민은 남아 있다.
- 계층마다 중복된 검증이 발생할 수 있다
- 정책이 바뀌면 수정해야 할 위치도 그만큼 늘어난다.
하지만 TDD를 병행하며 사용자의 입력값을 신뢰하지 않는 것이 훨씬 안전하다는 것을 느꼈다.
조금 과해 보일지라도 여러 계층에 방어 로직을 두는 것이 안정성 측면에서 훨씬 나은 선택이라는 생각이 들었다.
검증 포인트가 늘어난 만큼 코드가 복잡해졌다고 느낄 수도 있지만 오히려 책임이 명확해지고 테스트가 수월해지면서 유지보수성이 더 좋아졌다고 생각된다.
앞으로도 유효성 검증을 할 때, 단순히 검사한다는 관점이 아니라 누가 어떤 책임으로 검사해야 하는가? 를 더 깊이 고민하며 구조를 만들어봐야겠다.
참고
https://mangkyu.tistory.com/398
https://meetup.nhncloud.com/posts/223
'Study > Architecture' 카테고리의 다른 글
| 주문과 결제, 어디까지 한 트랜잭션으로 묶어야 할까? (2) | 2025.08.29 |
|---|---|
| 장애 대응 시스템 구축하기 (0) | 2025.08.22 |
| 캐시 구조 개선 (0) | 2025.08.22 |
| 읽기 성능 개선 보고서 (4) | 2025.08.15 |
| 멱등성을 고려한 좋아요 기능 설계 (5) | 2025.08.01 |