JUN0.DEV
JUN0.DEV

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

Published on
  • avatarJunyoung Yang
GitHubDon-zo/GRIT실시간 화상 스터디 플랫폼 GRIT 레포지토리

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로 분리할 수 있었다.

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