JUN0.DEV
JUN0.DEV

반복 null 체크를 줄인 엔티티 검증 로직 리팩토링

Published on
  • avatarJunyoung Yang

GRIT에서 사용자 프로필 초기화와 수정 기능을 구현한 뒤 코드를 다시 보니, 필드마다 같은 null 체크와 공백 검증이 반복되고 있었다. 기능은 동작했지만, 프로필 필드가 늘어나면 검증 위치와 대입 위치를 계속 함께 수정해야 하는 구조였다.

처음에는 단순히 코드 줄 수를 줄이는 문제로 보였다. 하지만 다시 보니 핵심은 "검증을 어디에서 책임질 것인가"였다. 이 글은 User 엔티티가 자기 상태를 변경할 때 필요한 검증을 직접 관리하도록 정리한 기록이다.

처음 보인 문제

프로필 초기화와 수정 로직은 아래와 같은 형태였다.

public void initializeProfile(String nickname, String introduction, String image) {
    if (this.role != Role.PENDING) {
        throw new ProfileAlreadyInitializedException("이미 프로필이 초기화된 회원이다.");
    }
    if (StringUtils.isBlank(nickname) || StringUtils.isBlank(introduction)
            || StringUtils.isBlank(image)) {
        throw new IllegalArgumentException();
    }
    this.nickname = nickname;
    this.introduction = introduction;
    this.image = image;
    this.role = Role.USER;
}

public void updateProfile(String nickname, String introduction, String image) {
    validateNotBlankIfPresent(nickname, "닉네임");
    validateNotBlankIfPresent(introduction, "자기소개");
    validateNotBlankIfPresent(image, "이미지");

    if (nickname != null) {
        this.nickname = nickname;
    }
    if (introduction != null) {
        this.introduction = introduction;
    }
    if (image != null) {
        this.image = image;
    }
}

문제는 명확했다. 수정 로직에서 같은 필드에 대해 검증할 때 한 번, 대입할 때 한 번 null 여부를 다시 보고 있었다. 지금은 세 필드뿐이지만, 필드가 늘어나면 실수할 가능성이 커지는 구조였다.

해결 방안

먼저 초기화 로직에서는 필수 값 검증과 대입이 분리되지 않도록 정리했다.

private void validateRoleForInitialization() {
    if (this.role != Role.PENDING) {
        throw new ProfileAlreadyInitializedException("이미 프로필이 초기화된 회원이다.");
    }
}

private String validateAndGet(String value, String fieldName) {
    if (value == null || value.isBlank()) {
        throw new InvalidProfileFieldException(fieldName + "은(는) 필수이며 공백일 수 없다.");
    }
    return value;
}

public void initializeProfile(String nickname, String introduction, String image) {
    validateRoleForInitialization();
    this.nickname = validateAndGet(nickname, "닉네임");
    this.introduction = validateAndGet(introduction, "자기소개");
    this.image = validateAndGet(image, "이미지");
    this.role = Role.USER;
}

validateAndGet은 검증을 통과한 값을 그대로 반환한다. 덕분에 호출부에서 어떤 필드가 어떤 검증을 거쳐 대입되는지 한 줄로 드러난다.

적용 및 구현

프로필 수정은 PATCH에 가까웠다. null로 들어온 필드는 변경하지 않고, 값이 있는 필드만 검증 후 반영해야 했다.

그래서 Consumer<String>을 받아 값이 있을 때만 검증과 대입을 수행하는 메서드로 묶었다.

private void updateIfPresent(String value, Consumer<String> setter, String fieldName) {
    if (value != null) {
        setter.accept(validateAndGet(value, fieldName));
    }
}

public void updateProfile(String nickname, String introduction, String image) {
    updateIfPresent(nickname, value -> this.nickname = value, "닉네임");
    updateIfPresent(introduction, value -> this.introduction = value, "자기소개");
    updateIfPresent(image, value -> this.image = value, "이미지");
}

이렇게 바꾸면서 검증과 대입이 한 흐름으로 묶였다. 필드를 추가할 때도 검증 호출과 대입 호출을 따로 맞출 필요가 줄었다.

선택하지 않은 방식

처음에는 Assert.hasText 같은 Spring 유틸을 써도 되는지 검토했다.

Assert.hasText(nickname, "닉네임은 필수이며 공백일 수 없다.");

코드는 짧아지지만, 이 방식은 실패 시 IllegalArgumentException을 던진다. GRIT에서는 클라이언트에 내려줄 에러 코드와 메시지를 프로젝트 기준으로 맞춰야 했다.

그래서 단순 유틸을 쓰기보다 InvalidProfileFieldException 같은 비즈니스 예외를 두고, 검증 실패를 공통 에러 응답과 연결할 수 있게 했다. 코드가 조금 늘더라도 프로젝트의 예외 처리 흐름에 맞는 편이 낫다고 판단했다.

마무리

이번 리팩토링은 코드를 짧게 만드는 작업이라기보다, 엔티티가 자기 상태를 바꾸는 규칙을 직접 갖게 만드는 작업이었다.

정리한 기준은 아래와 같다.

  • 검증과 대입이 같은 필드를 두 번 다루지 않게 한다.
  • 엔티티 상태 변경 규칙은 가능하면 엔티티 내부에 둔다.
  • 프레임워크 유틸은 편하지만, 프로젝트 예외 정책과 맞는지 확인한다.
  • 규칙이 더 복잡해지면 Nickname 같은 Value Object로 분리할 수 있었다.

현재는 문자열 공백 검증 수준이지만, 닉네임 길이 제한이나 특수문자 제한이 늘어난다면 엔티티 안의 문자열 검증 메서드보다 값 객체로 분리하는 편이 더 자연스러울 수 있다.