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