문제의 출발점
개선 전 Service에는 필수값과 파일 경로 검증이 메서드마다 반복돼 있었다. 같은 요청 DTO가 여러 다운로드와 조회 기능에서 재사용됐지만, 기능마다 필요한 필드는 서로 달랐다. DTO 전체에 같은 필수 제약을 걸면 사용하지 않는 필드 때문에 정상 요청까지 실패할 수 있었다.
아래 코드는 문제 해결 구조를 설명하기 위한 pseudocode이며 실제 클래스·테이블·설정 이름과는 무관합니다.
if (request.getOrderKey() == null || request.getItemSequence() == null) {
throw new RequestValidationException("invalid request");
}
if (request.getFileName() == null || request.getFileName().contains("..")) {
throw new RequestValidationException("invalid path");
}검증 책임을 세 계층으로 나눴다
| 검증 계층 | 예시 | 필요한 정보 |
|---|---|---|
| 구조·필수값 | @NotBlank, @NotNull, Validation Group | 요청 DTO |
| 보안·경로 | 상위 경로 이동 문자열, 허용 경로 확인 | 요청 문자열과 경로 규칙 |
| 업무 정합성 | 주문과 요청 사용자의 소유 관계 | DB 주문 정보와 법인 조건 |
구조 검증은 Bean Validation이 담당한다. 파일 경로 보안은 값의 존재 여부와 다른 책임이고, 주문 소유 관계는 DB 조회가 필요한 업무 규칙이다. 세 계층을 하나의 Validator에 합치지 않고 실패 원인과 재사용 범위를 분리했다.
DTO에는 필드의 검증 의미만 선언했다
아래 예시는 실제 커스텀 Annotation, DTO, 메시지 코드와 Group 이름을 모두 가명으로 바꾼 pseudocode다.
아래 코드는 문제 해결 구조를 설명하기 위한 pseudocode이며 실제 클래스·테이블·설정 이름과는 무관합니다.
public class DownloadRequest {
@NotBlank(groups = USER_KEY.class)
private String userKey;
@NotBlank(groups = ORDER_KEY.class)
private String orderKey;
@NotNull(groups = ITEM_SEQUENCE.class)
private Integer itemSequence;
@NotBlank(groups = FILE_NAME.class)
private String fileName;
}DTO는 필드가 어떤 검증 의미를 갖는지만 선언한다. 어느 API가 그 필드를 필수로 사용하는지는 호출 메서드가 결정한다.
호출 메서드가 필요한 Group을 조합했다
동일한 DTO를 받더라도 다운로드 종류에 따라 Group 조합을 다르게 선언했다. 공개 글에서는 실제 Annotation과 메서드명을 일반적인 이름으로 바꿨다.
아래 코드는 문제 해결 구조를 설명하기 위한 pseudocode이며 실제 클래스·테이블·설정 이름과는 무관합니다.
@ValidateRequest(
type = DownloadRequest.class,
groups = { ORDER_KEY.class, ITEM_SEQUENCE.class, FILE_NAME.class }
)
public FileResult downloadOne(DownloadRequest request) {
return fileService.downloadOne(request);
}
@ValidateRequest(
type = DownloadRequest.class,
groups = { ORDER_KEY.class, FILE_NAME.class }
)
public ViewResult openPreview(DownloadRequest request) {
return fileService.openPreview(request);
}AOP는 메서드 실행 전에 선언된 DTO와 Group을 찾아 표준 Validator에 전달한다. 첫 검증 실패에서는 원래 메서드를 실행하지 않고 공통 예외 경계로 넘긴다.
아래 코드는 문제 해결 구조를 설명하기 위한 pseudocode이며 실제 클래스·테이블·설정 이름과는 무관합니다.
for (Class<?> group : activeGroups) {
Set<ConstraintViolation<Object>> violations =
validator.validate(target, group);
if (!violations.isEmpty()) {
throw new RequestValidationException("validation failed");
}
}메시지와 오류 화면 책임
검증 메시지는 기존 다국어 메시지 체계와 연결하되 실제 메시지 코드, Bean 이름과 오류 처리 클래스명은 공개하지 않는다. 검증 계층은 실패 정보만 전달하고, 사용자에게 어떤 화면과 문구를 보여줄지는 공통 예외 처리 계층이 담당한다.
브라우저 이전 화면으로 돌아가야 하는 일부 요청도 있었지만, 실제 Payload 타입과 Annotation 이름을 공개하지 않고 “오류 응답에 화면 동작 힌트를 함께 전달했다”는 구조만 남겼다.
경로 보안과 소유 관계는 별도 책임으로 유지했다
필수값 검증을 통과한 뒤에는 파일 경로 규칙과 주문 소유 관계를 별도로 확인했다.
아래 코드는 문제 해결 구조를 설명하기 위한 pseudocode이며 실제 클래스·테이블·설정 이름과는 무관합니다.
pathPolicy.validate(request.getFileName());
ownershipPolicy.validateOwnership(
request.getUserKey(),
request.getOrderKey()
);validateOwnership()은 요청 값의 형식이 아니라 실제 업무상 접근 가능 여부를 확인한다. 구조가 유효하다는 사실과 업무 권한이 있다는 사실을 같은 의미로 취급하지 않았다.
미확정 적용 개수는 정성화했다
기존 글에는 Controller·메서드·DTO·Validation Group 개수와 개별 Group 이름이 소스 집계값으로 실려 있었다. 이 수치는 최신 경력 마스터에 근거와 산출 기준이 없고 이번 작업에서 신규 경력 사실로 확정하지 않으므로 제거했다.
확정해 공개할 수 있는 범위는 다음과 같다.
- 같은 DTO를 사용하는 여러 호출에서 필요한 Group 조합을 분리했다.
- 필수값 검증, 경로 보안과 주문 소유 관계를 서로 다른 책임으로 유지했다.
- 프로젝트 기능테스트 범위에 이 변경이 포함됐다.
검증 범위
같은 DTO를 사용하는 메서드별 Group 조합, 선택 필드, 필수값 실패 시 본문 미실행, 다국어 메시지, 경로 보안과 소유 관계의 후속 검증을 확인했다.
기능테스트 20개 시나리오·557개 테스트 케이스는 이 Validation 설계만의 전용 수치가 아니다. 최초 548건 성공·9건 실패 후 결함을 보완해 최종 557건 전체 통과를 확인했다. 본사·유럽법인 통합테스트는 별도로 수행했으며 557개 수치와 연결하지 않는다. Validation AOP만을 위한 별도 테스트 건수는 확인되지 않았다.
한계와 운영상 주의점
- 검증 Annotation이 누락된 메서드는 이 AOP 검증을 받지 않으므로 코드 리뷰 기준이 필요하다.
- 상속형 DTO를 도입하면 타입 탐색 규칙을 다시 검토해야 한다.
- 한 Group 안에 여러 위반이 있으면 표시 순서에 의존하지 않아야 한다.
- 경로 보안과 업무 정합성 검증의 호출 누락 여부도 별도로 점검해야 한다.
- Proxy를 거치지 않는 내부 호출에는 같은 방식이 자동 적용된다고 가정하면 안 된다.
정리
핵심은 AOP로 Validation을 새로 만든 것이 아니다. 표준 Bean Validation은 유지하고, 반복되던 “어떤 DTO를 어떤 Group으로 검증할지”만 메서드 경계에 올렸다. 경로 보안과 주문 소유 관계는 별도의 책임으로 남겨 검증 통과의 의미를 명확하게 했다.