레거시 현대화 · Legacy Modernization

같은 DTO인데 API마다 필수값이 달랐다: Validation Group을 호출별로 조합한 이유

같은 다운로드 DTO를 재사용하는 레거시 API마다 필수 필드가 달라, 표준 Bean Validation 제약과 호출별 검증 맥락을 분리한 설계 과정입니다.

Spring MVCBean ValidationSpring AOPLegacy Modernization

문제의 출발점

개선 전 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으로 검증할지”만 메서드 경계에 올렸다. 경로 보안과 주문 소유 관계는 별도의 책임으로 남겨 검증 통과의 의미를 명확하게 했다.

TECHNICAL SERIES

DNA2 레거시 현대화

연결 생명주기와 DB 경계부터 요청 계약, 다국어 처리, 검증과 런타임 충돌까지 이어지는 현대화 과정입니다.

시리즈 전체 보기

현재 5/6

  1. 1요청마다 Connection Pool과 SessionFactory를 만들면 생기는 문제
  2. 2DB Link를 제거한 뒤 남은 문제: 복수 DataSource의 트랜잭션 경계
  3. 3레거시 URL이 언어를 결정할 때: 기존 계약을 유지하며 Spring Locale 처리로 전환하기
  4. 4레거시 요청 계약을 유지한 DTO 파라미터 매핑 구조
  5. 5같은 DTO인데 API마다 필수값이 달랐다: Validation Group을 호출별로 조합한 이유
  6. 6JVM Class Loading 로그로 추적한 Oracle JDBC Driver 충돌