이번 글은 “애플리케이션 내부에는 null이 없다” 라는 전제에서 출발한다. 여기서 「내부」는 객체가 한 번 만들어진 뒤의 세계다. null은 주로 바깥 — 사용자가 보낸 요청에 담겨 들어온 값, DB에서 읽은 값처럼 내가 만들지 않은 값 — 에서 들어오는데, 그 값이 객체로 만들어지는 순간(생성자)에 한 번 걸러 내면 그 뒤로는 null이 돌아다니지 않는다. 문자열/객체형은 null을 허용하지 않고, null이 가능한 경우에만 변수명에 orNull을 명시한다(OrNull 접미사). 이 전제를 지키면 팀 내부 규칙이 아주 단순해진다.

  • 내부 로직에서는 null 체크를 하지 않는다 (이미 생성 단계에서 막았기 때문이다)
  • null이 가능한 데이터는 변수명에서 바로 알 수 있다
  • 객체는 생성 직후부터 완전한 상태를 유지한다

즉, 상태를 생성 시점에 고정하고 이후에는 흔들리지 않게 유지하는 철학이다. 아래에서 원칙을 상세히 설명한다.


1. 생성자에서 완전한 상태를 보장한다

객체는 생성 직후 완전하고 유효한 상태여야 한다. 생성자에서 필수값 검증과 기본값을 확정해야 null 유입을 막을 수 있다. 여기서 중요한 점은 생성자가 끝났다는 사실 자체가 유효성의 보증이 되어야 한다는 것이다.

  • 생성자를 호출한 쪽은 객체의 유효성을 의심하지 않아도 된다
  • null 체크는 생성자 내부에서 끝나고, 외부 로직에서 사라진다
  • 테스트도 단순해진다 (생성 실패 케이스만 검증하면 됨) — 단위 테스트에서 “필수값에 null을 넣고 생성하면 예외가 난다”만 확인하면, 나머지 메서드마다 “이 필드가 null이면?”을 따로 시험할 필요가 없다

아래 코드에서 생성자가 private인 것은 일부러다. 바깥에서는 이 생성자를 직접 부르지 못하고, 3장에서 볼 정적 팩토리 메서드를 거쳐서만 객체를 만든다. 그래도 최종적으로 객체를 만드는 곳은 이 생성자 하나이므로, 위의 “생성자를 호출한 쪽”은 결국 정적 팩토리를 부른 쪽이 된다. LocalDateTime은 날짜와 시각을 담는 자바 표준 클래스(LocalDateTime)이고, Status는 회원 상태를 나타내는 타입으로 3장에서 정체를 설명한다. 클래스 선언의 final은 이 클래스를 아무도 상속하지 못하게 막는다. 누군가 자식 클래스를 만들어 값을 바꾸는 메서드를 더하면, 겉으로는 UserProfile인데 값이 바뀌는 객체가 생겨 이 글이 지키려는 불변이 깨지기 때문이다.

public final class UserProfile {
    private final String userId;
    private final String email;
    private final String displayName;
    private final LocalDateTime createdAt;
    private final String profileImageUrlOrNull;
    private final Status status;

    private UserProfile(
            String userId,
            String email,
            String displayName,
            LocalDateTime createdAt,
            String profileImageUrlOrNull,
            Status status
    ) {
        this.userId = Objects.requireNonNull(userId, "userId");
        this.email = Objects.requireNonNull(email, "email");
        this.displayName = Objects.requireNonNull(displayName, "displayName");
        this.createdAt = Objects.requireNonNull(createdAt, "createdAt");
        this.status = Objects.requireNonNull(status, "status");

        // null 허용은 이름으로 명확히 표현
        this.profileImageUrlOrNull = profileImageUrlOrNull;
    }
}

한 줄씩 따라가기. Objects.requireNonNull(userId, "userId")는 자바가 기본으로 제공하는 메서드로, 첫 번째 인자(userId)가 null이 아니면 그 값을 그대로 돌려주고, null이면 두 번째 인자(“userId”)를 메시지로 삼아 그 자리에서 NullPointerException을 던진다. 그래서 이 줄이 끝까지 실행됐다는 것 자체가 “userId는 null이 아니다”를 보장한다 — 생성자 안에서 모든 필수 필드에 이 메서드를 거치므로, 생성자를 통과한 객체는 그 필드들이 전부 값이 차 있다.


2. 빌더는 생성자 위에서만 사용한다

빌더 패턴은 UserProfile.builder().userId("u1").email("a@b.com").build()처럼, 생성자에 값을 한 번에 몰아넣는 대신 메서드를 하나씩 이어 붙여 필드를 채운 뒤 마지막 build() 호출로 객체를 완성하는 방식이다. userId(...)까지만 부르고 아직 email(...)을 부르지 않은 시점에는, 일부 필드만 채워진 빌더 객체가 그 자체로 존재한다 — 이게 중간 상태다. 생성자라면 Objects.requireNonNull 검증을 한 번에 통과해야만 객체가 생기지만, 빌더는 build()를 부르기 전까지 이 검증을 거치지 않은 반쪽짜리 상태가 코드 어딘가에 돌아다닐 수 있다는 게 문제다. 가독성은 좋지만 이 문제가 있으니, 빌더를 쓰더라도 생성자 위에 @Builder를 붙여서 최종 객체는 생성자를 통해 완전한 상태로만 만들어지도록 한다.

@Builder처럼 @로 시작하는 표시는 그 자체로는 아무 일도 하지 않는다. Lombok이 컴파일 도중에 끼어들어(애너테이션 프로세서) 이 표시를 보고 빌더 코드를 만들어 넣는다. 「생성자 위」가 중요한 이유는 다른 자리와 견줘 보면 보인다. Lombok 공식 문서에 따르면 @Builder를 클래스 위에 붙이면 Lombok이 모든 필드를 받는 생성자를 따로 하나 만들어 그걸로 객체를 만든다(직접 쓴 생성자가 없을 때만 그렇다). 그 생성자는 Lombok이 만든 것이라 Objects.requireNonNull 검증이 들어 있지 않다. 그러면 빌더로 만든 객체는 검증 없이 생기고, 1장에서 세운 “생성자를 통과하면 유효하다”가 깨진다. 내가 검증을 적어 둔 생성자 위에 붙여야 빌더도 그 검증을 거친다.

그리고 빌더 자체는 밖으로 노출하지 않는다. Lombok은 private 생성자에 붙여도 public builder() 메서드를 만들기 때문에, access = AccessLevel.PRIVATE로 막아야 한다. 빌더는 클래스 내부의 조립 도구이고, 외부에 보이는 입구는 정적 팩토리 메서드(객체를 만들어 돌려주는 static 메서드, 3장)여야 한다.

이 방식의 핵심은 다음과 같다.

  • 빌더가 있더라도 최종 생성은 생성자에서만 이루어진다
  • 생성자 검증 로직이 단일 위치에만 존재한다
  • build() 시점에만 객체가 완성되므로, 내부 철학과 충돌하지 않는다 — 반쪽짜리 빌더가 생기기는 하지만, builder()를 private으로 막았으니 그 빌더는 클래스 안의 메서드 한 줄 안에서만 잠깐 존재하고 밖으로 새어 나가지 않는다. 밖에서 받는 것은 build()가 생성자를 거쳐 돌려준 완성된 객체뿐이다
@Builder(access = AccessLevel.PRIVATE)
private UserProfile(
        String userId,
        String email,
        String displayName,
        LocalDateTime createdAt,
        String profileImageUrlOrNull,
        Status status
) {
    // 생성자 검증 로직 동일
}

3. 객체 생성은 정적 팩토리 메서드로 제한한다

생성자는 숨기고, 정적 팩토리 메서드로만 객체를 생성한다. 입력값 검증과 기본값 초기화를 한곳에서 통제할 수 있다. 여기서는 특히 두 가지 이점이 크다.

  1. 이름으로 의도를 표현할 수 있다 (createNew, fromDatabase, createAdmin)
  2. 필수/선택값을 깔끔하게 분리할 수 있다 (필수만 받는 메서드 제공)

그리고 무엇보다, 객체 생성 규칙이 밖으로 노출되지 않는다. 생성자 파라미터 순서나 개수에 대한 의존이 사라진다. 생성자를 밖에서 직접 부르게 하면 new UserProfile(id, email, name, ...)처럼 String 여러 개를 순서대로 넘겨야 하는데, email과 displayName 자리를 바꿔 넣어도 둘 다 String이라 컴파일이 된다. 필드가 하나 늘면 생성자를 부르는 곳을 전부 고쳐야 한다. 정적 팩토리를 입구로 두면 그 순서와 개수는 클래스 안에서만 알면 된다.

아래 메서드는 UserProfile 클래스 안에 들어 있다. 2장에서 builder()를 private으로 막았지만, private은 같은 클래스 안에서는 부를 수 있으므로 여기서는 쓸 수 있다.

public static UserProfile createNew(String email, String displayName) {
    return UserProfile.builder()
            .userId("USER_" + UUID.randomUUID())
            .email(email)
            .displayName(displayName)
            .createdAt(LocalDateTime.now())
            .profileImageUrlOrNull(null)
            .status(Status.ACTIVE)
            .build();
}

Status.ACTIVE의 Status는 enum이다 — 회원이 가질 수 있는 상태를 미리 정해 둔 값들의 모음으로, 뒤에서 public enum Status { ACTIVE, DEACTIVATED } 형태로 정의된다. Status.ACTIVE는 그중 “활성 상태”를 뜻하는 상수이고, 새 회원을 만드는 이 시점에는 항상 이 값으로 시작한다. "USER_" + UUID.randomUUID()는 겹치지 않는 UUID를 새로 만들어 앞에 USER_를 붙인 값을 회원 ID로 쓰는 줄이다.


4. setter는 사용하지 않고, 행위는 의도를 드러낸다

setter는 행위의 의미를 숨긴다. 내부 상태를 외부가 임의로 변경하게 만들 뿐 아니라, 그 변경이 무엇을 뜻하는지도 알려주지 않는다. 이 철학에서는 상태 변경의 의도를 반드시 메서드명에 드러내야 한다.

  • setEmail()은 단순 대입인지, 검증인지, 알림까지 포함인지 알 수 없다
  • changeEmailAndVerify()는 의도가 즉시 드러난다
// 모호한 행위
user.setEmail("a@b.com");

// 의도가 명확한 행위
user.changeEmailAndVerify("a@b.com");

반면 단순 조회는 getXxx() 그대로 둔다. fetchDisplayName()이 getDisplayName()보다 의도를 더 드러내지는 않으면서, JavaBeans 관례에서 벗어나 Lombok @Getter(필드마다 get으로 시작하는 조회 메서드를 자동으로 만들어 주는 표시라, fetch로 짓고 싶으면 손으로 다 써야 한다)와 Jackson 직렬화를 쓸 수 없게 만들기 때문이다. Jackson은 자바 객체와 JSON을 서로 바꿔 주는 라이브러리이고, 그 변환 과정이 직렬화다. get으로 시작하는 이름이어야 Jackson이 “이건 꺼내 쓸 수 있는 값이다”라고 알아보고 JSON으로 바꿔 내보낸다.

public String getDisplayName() {
    return displayName;
}

즉 의도를 드러내야 하는 것은 상태를 바꾸는 행위이지, 값을 꺼내는 일이 아니다.


5. 불변 객체는 변경 대신 새 객체를 만든다

객체 내부 상태를 바꾸지 않고, 새로운 객체를 생성한다. 예측 가능성과 안정성이 극대화된다. 이 방식을 선택하면 다음이 보장된다.

  • 동일 객체는 언제나 동일한 상태를 가진다
  • 멀티스레드 환경에서도 안전하다 — 스레드들은 같은 객체를 함께 보는데, 아무도 그 값을 바꾸지 않으니 한 스레드가 바꾸는 도중의 어중간한 값을 다른 스레드가 읽을 일이 없다(불변 객체)
  • “이 객체가 언제 바뀌었지?” 같은 디버깅이 사라진다
public UserProfile updateProfileImage(String imageUrl) {
    return UserProfile.builder()
            .userId(this.userId)
            .email(this.email)
            .displayName(this.displayName)
            .createdAt(this.createdAt)
            .profileImageUrlOrNull(imageUrl)
            .status(this.status)
            .build();
}

이 메서드는 원래 객체를 고치지 않는다. 지금 객체(this)의 값을 그대로 옮겨 담되 profileImageUrlOrNull만 새 값으로 바꾼 다른 객체를 만들어 돌려준다. 그래서 부르는 쪽은 profile = profile.updateProfileImage(url);처럼 반환값을 받아야 한다. profile.updateProfileImage(url);만 쓰고 반환값을 버리면 profile은 옛 이미지 그대로다.


6. 엔티티는 타협이 필요하다

JPA/Hibernate는 동일 인스턴스 변경을 추적(Dirty Checking) 하기 때문에, 엔티티는 완전 불변 객체로 만들기 어렵다. JPA는 메서드 하나만 부르면 SQL을 대신 만들어 주는 규칙이고, Hibernate는 그 규칙을 실제로 구현한 라이브러리다. Dirty Checking은 이 라이브러리가 객체 값이 조회 시점과 달라졌는지 스스로 비교해, 달라졌으면 저장 메서드를 따로 부르지 않아도 트랜잭션이 끝나 커밋될 때 UPDATE문(DB에 있는 행의 값을 바꾸라는 SQL 명령)을 알아서 만들어 DB로 보내는 동작이다. 그래서 Entity의 필드를 바꾸는 메서드 자체는 남겨 둬야 이 추적이 동작하고, 완전한 불변 객체(변경 시 새 객체를 만드는 방식)로는 만들 수 없다. 프레임워크(여기서는 JPA/Hibernate)가 요구하는 구조를 무시하면 업데이트가 누락되거나 의도치 않은 merge(git의 merge와는 다른, JPA가 분리된 객체를 다시 영속 상태로 올리는 동작)가 발생할 수 있다.

왜 누락되는지 단계로 따라가면 이렇다. JPA는 DB에서 꺼낸 객체를 영속성 컨텍스트라는 보관소에 넣고 지켜본다 — 이렇게 지켜보는 상태를 「영속 상태」라고 한다. 5장 방식대로 엔티티를 바꿀 때마다 새 객체를 만들면, JPA가 지켜보는 것은 여전히 옛 객체이고 새 객체는 JPA가 모르는 객체(분리(detached) 상태와 같은 처지)다. 트랜잭션이 커밋될 때 Dirty Checking이 비교하는 대상은 그 옛 객체인데, 옛 객체는 값이 그대로이니 “바뀐 게 없다”고 보고 UPDATE를 보내지 않는다. 새 객체를 저장하려면 merge로 다시 올려야 하는데, merge는 넘긴 객체가 아니라 또 다른 객체를 돌려주므로 어느 객체가 진짜인지 헷갈리게 된다.

  • setter는 금지
  • 의도 기반 변경 메서드만 허용
@Entity
class UserEntity {
    @Id
    private String id;

    @Column(nullable = false)
    private String email;

    protected UserEntity() {}

    public static UserEntity create(String email) {
        UserEntity e = new UserEntity();
        e.id = "USER_" + UUID.randomUUID();
        e.email = email;
        return e;
    }

    // 의도 기반 변경 메서드
    public void changeEmail(String newEmail) {
        this.email = newEmail;
    }
}

한 줄씩 따라가기. @Entity는 이 클래스가 DB 테이블(가로세로 칸으로 된 표. 세로줄이 컬럼, 가로줄 한 줄이 행이다)과 짝지어진 엔티티임을 표시하고, @Id는 id 필드가 그 행을 구별하는 기본키(PK)임을 표시한다. @Column(nullable = false)는 email 필드와 짝지어진 DB 컬럼이 비어 있을 수 없다는 제약이다 — nullable = false를 주지 않으면 DB 컬럼은 기본적으로 null을 허용하므로, 이 표시가 없으면 이메일 없는 행이 저장될 수 있다. protected UserEntity() {}는 아무 일도 하지 않는 빈 생성자인데, 이게 있는 이유는 이 클래스를 쓰는 사람이 아니라 JPA 때문이다 — Hibernate가 DB에서 값을 읽어 UserEntity 객체를 만들 때는 이 빈 생성자로 먼저 객체를 만든 뒤 리플렉션으로 필드를 채워 넣는다. private이 아니라 protected로 둔 이유는 두 가지다. 첫째, JPA 명세(JPA라는 규칙을 적어 둔 공식 문서)가 이 빈 생성자를 public이나 protected로 두라고 정해 두었다. 둘째, Hibernate는 실행 중에 이 클래스를 상속한 프록시(원본 대신 세워 두는 대리 객체) 클래스를 만들어 쓰는데, 자바에서 자식 클래스의 생성자는 부모 생성자를 반드시 부르고 부모 생성자가 private이면 자식이 부를 수 없다. Hibernate 공식 가이드도 실행 중 프록시를 만들려면 생성자를 적어도 package-private(접근 제어자를 아무것도 안 적었을 때의 기본 범위) 이상으로 두라고 적어 두었다. public으로 열어두지 않는 이유는 그러면 new UserEntity()로 아무 값도 없는 빈 엔티티를 밖에서 바로 만들 수 있게 되어, “생성은 정적 팩토리로만”이라는 규칙이 깨지기 때문이다. create() 안의 UUID.randomUUID()는 UUID를 하나 새로 만들어 id 값으로 쓰는 줄이다 — 번호를 1부터 순서대로 매기지 않아도 서로 겹치지 않는 값을 바로 얻을 수 있다.

즉, 엔티티는 불변성 철학을 완벽하게 적용할 수 없고, 대신 변경을 통제하는 방식으로 타협한다. 핵심은 setter를 없애되, 의도 기반 메서드로 변경을 강제하는 것이다.


7. DTO는 불변 클래스 + @Getter로 만든다

DTO는 Data Transfer Object의 줄임말로, 계층과 계층 사이에서 값만 옮기는 그릇이다. 여기서 「계층」은 코드를 Controller · Service · Repository처럼 역할별로 나눈 층을 말한다(3계층 구조). 앞서 본 Entity는 DB 테이블과 묶여 있어 완전한 불변 객체로 만들 수 없었지만, DTO는 테이블과 상관없이 값만 담아 옮기므로 불변 원칙을 그대로 적용할 수 있다. Controller가 받는 요청 값, 돌려주는 응답 값이 보통 이 형태다.

record를 쓰거나 @JsonAutoDetect로 필드를 직접 노출하면 getter 없이도 직렬화가 가능하다. @JsonAutoDetect는 Jackson이 필드를 어느 공개 수준(public, private 등)부터 스스로 찾아 쓸지 정해 주는 표시로, 이걸로 private 필드까지 찾게 하면 getter를 거치지 않고 필드 값을 바로 읽어 JSON으로 내보낸다. 하지만 DTO에서는 getter를 그대로 쓴다.

앞서 정리했듯 문제가 되는 것은 setter이지 조회가 아니다. DTO는 값을 전달하는 것이 존재 이유이므로, 값을 꺼내는 메서드가 있는 것이 자연스럽다. @Getter 하나면 충분하고, Jackson과 다른 도구들의 기본 관례에도 맞는다.

@Getter
public class UserResponse {

    private final String email;
    private final String name;

    private UserResponse(String email, String name) {
        this.email = Objects.requireNonNull(email, "email");
        this.name = Objects.requireNonNull(name, "name");
    }

    public static UserResponse of(String email, String name) {
        return new UserResponse(email, name);
    }
}

클래스 위의 @Getter 한 줄 덕분에 Lombok이 getEmail()과 getName()을 컴파일할 때 만들어 넣는다. 손으로 쓰지 않았지만 이 두 메서드가 있으니 Jackson이 email과 name을 JSON으로 내보낸다. DTO도 1장과 같이 생성자에서 Objects.requireNonNull로 null을 막는다. 그래야 아래의 “생성자가 끝나면 객체는 완전하다”가 성립한다. of는 3장의 createNew와 달리 특별한 의도를 담은 이름이 아니다. “받은 값들로 그대로 만든다”는 뜻으로 자바에서 흔히 쓰는 이름이고, DTO는 값을 담아 옮기는 것 말고 하는 일이 없으니 이 이름이면 충분하다.

record를 쓰지 않는 이유는 DTO 공통 필드를 상속으로 관리해야 할 때 record가 클래스를 상속할 수 없기 때문이다. 예를 들어 목록을 조회하는 요청 DTO 여러 개가 페이지 번호(page)와 한 페이지 크기(size)를 똑같이 가진다면, 그 두 필드를 부모 클래스 하나에 두고 각 요청 DTO가 상속받게 한다. record로는 이 부모를 둘 수 없다. 이 규칙과 생성 경로를 하나로 고정하는 방법은 DTO 생성 표준에서 자세히 다룬다.

중요한 것은 setter가 없고 필드가 final이라는 점이다. 생성자가 끝나면 객체는 완전하고, 이후 상태가 바뀌지 않는다.


정리

  • null은 내부에서 허용하지 않는다
  • null 가능성은 orNull로 명시한다
  • 생성자는 완전한 상태를 보장해야 한다
  • 정적 팩토리로 생성만 허용한다
  • setter 대신 의도 기반 변경 메서드를 사용한다 (단순 조회는 getXxx())
  • 불변 객체는 변경 대신 새 객체를 만든다
  • 엔티티는 의도 기반 변경 메서드로 통제한다
  • DTO는 불변 클래스 + @Getter로 만든다 (setter만 금지)

이 철학을 지키면 코드가 명확해지고, 상태 관리가 쉬워진다. 이제 마지막으로 일반 객체(불변 VO, Value Object)와 엔티티 객체를 전체 예시로 정리한다. 두 예시는 같은 「회원」을 소재로 삼아, 불변으로 만들 수 있는 객체와 JPA 때문에 불변으로 만들 수 없는 객체가 각각 어떤 모양이 되는지 나란히 보여 주려는 것이다. 한 프로젝트에서 둘을 어떻게 함께 쓰고 서로 어떻게 바꾸는지는 이 글에서 다루지 않는다.


최종 예시: 일반 객체(불변) vs 엔티티(변경 통제)

1) 일반 객체 (불변 VO)

import lombok.AccessLevel;
import lombok.Builder;

import java.time.LocalDateTime;
import java.util.Objects;
import java.util.Optional;
import java.util.UUID;

public final class UserProfile {
    private final String userId;
    private final String email;
    private final String displayName;
    private final LocalDateTime createdAt;
    private final String profileImageUrlOrNull;
    private final Status status;

    @Builder(access = AccessLevel.PRIVATE)
    private UserProfile(
            String userId,
            String email,
            String displayName,
            LocalDateTime createdAt,
            String profileImageUrlOrNull,
            Status status
    ) {
        this.userId = Objects.requireNonNull(userId, "userId");
        this.email = Objects.requireNonNull(email, "email");
        this.displayName = Objects.requireNonNull(displayName, "displayName");
        this.createdAt = Objects.requireNonNull(createdAt, "createdAt");
        this.status = Objects.requireNonNull(status, "status");
        this.profileImageUrlOrNull = profileImageUrlOrNull; // null 허용
        validateEmail(email);
        validateDisplayName(displayName);
    }

    // 정적 팩토리
    public static UserProfile createNew(String email, String displayName) {
        return UserProfile.builder()
                .userId("USER_" + UUID.randomUUID())
                .email(email)
                .displayName(displayName)
                .createdAt(LocalDateTime.now())
                .profileImageUrlOrNull(null)
                .status(Status.ACTIVE)
                .build();
    }

    // 조회
    public String getUserId() { return userId; }
    public String getEmail() { return email; }
    public String getDisplayName() { return displayName; }
    public LocalDateTime getCreatedAt() { return createdAt; }
    public Optional<String> getProfileImageUrl() {
        return Optional.ofNullable(profileImageUrlOrNull);
    }
    public Status getStatus() { return status; }

    // 변경은 새 객체로
    public UserProfile updateProfileImage(String imageUrl) {
        validateImageUrl(imageUrl);
        return UserProfile.builder()
                .userId(this.userId)
                .email(this.email)
                .displayName(this.displayName)
                .createdAt(this.createdAt)
                .profileImageUrlOrNull(imageUrl)
                .status(this.status)
                .build();
    }

    public UserProfile deactivate() {
        if (this.status == Status.DEACTIVATED) {
            throw new IllegalStateException("이미 비활성화 상태입니다.");
        }
        return UserProfile.builder()
                .userId(this.userId)
                .email(this.email)
                .displayName(this.displayName)
                .createdAt(this.createdAt)
                .profileImageUrlOrNull(this.profileImageUrlOrNull)
                .status(Status.DEACTIVATED)
                .build();
    }

    private static void validateEmail(String email) {
        if (!email.matches("^[A-Za-z0-9+_.-]+@(.+)$")) {
            throw new IllegalArgumentException("유효하지 않은 이메일: " + email);
        }
    }

    private static void validateDisplayName(String name) {
        if (name.isBlank() || name.length() > 50) {
            throw new IllegalArgumentException("표시명은 1~50자");
        }
    }

    private static void validateImageUrl(String url) {
        if (url == null || url.isBlank()) {
            throw new IllegalArgumentException("이미지 URL은 공백일 수 없음");
        }
    }

    public enum Status {
        ACTIVE, DEACTIVATED
    }
}

한 줄씩 따라가기 — 이 중 새로 나온 줄만 짚는다. 생성자 · createNew · getter · updateProfileImage의 빌더 부분은 앞 섹션에서 이미 설명했다.

  • 생성자 끝의 validateEmail(email) · validateDisplayName(displayName) — null이 아닌 것만으로는 부족한 검사를 생성자에서 한 번 더 한다. validateEmail은 이메일 모양인지, validateDisplayName은 표시명이 공백만 있지 않고 50자를 넘지 않는지 본다. 맞지 않으면 IllegalArgumentException을 던져 객체가 아예 만들어지지 않는다.
  • email.matches("^[A-Za-z0-9+_.-]+@(.+)$") — matches는 문자열 전체가 괄호 안의 정규식 패턴에 맞는지 true/false로 돌려준다. 패턴을 끊어 읽으면 ^는 문자열의 시작, [A-Za-z0-9+_.-]+는 영문자 · 숫자 · + _ . - 중 하나 이상, @는 골뱅이 문자 그대로, (.+)는 아무 문자나 하나 이상, $는 문자열의 끝이다. 즉 “골뱅이 앞에 허용된 문자가 하나 이상 있고, 뒤에 무언가가 있는가”만 보는 느슨한 검사다.
  • validateImageUrl의 url == null — 이 글은 “내부 로직에서는 null 체크를 하지 않는다”고 했는데 여기서는 한다. updateProfileImage(String imageUrl)의 imageUrl은 객체 안에서 생긴 값이 아니라 밖에서 새로 들어오는 값이라, 생성자와 같은 입구 자리다. 게다가 생성자는 profileImageUrlOrNull에 null을 허용하므로, 여기서 막지 않으면 “이미지 변경”을 불렀는데 이미지가 지워지는 일이 생긴다. 그래서 입구에서 한 번 거른다.
  • deactivate() — 회원을 비활성 상태로 바꾼 새 객체를 돌려준다. 이미 DEACTIVATED라면 IllegalStateException을 던진다. 위의 검사들이 던지는 IllegalArgumentException은 “넘겨받은 인자가 잘못됐다”는 뜻이고, IllegalStateException은 “지금 객체의 상태에서는 이 메서드를 부를 수 없다”는 뜻이다(두 클래스의 자바 공식 문서 설명). deactivate()는 인자가 없고, 문제는 객체가 이미 비활성이라는 상태라서 후자를 쓴다.
  • public Optional<String> getProfileImageUrl() { ... } — String이 아니라 Optional을 반환한다. profileImageUrlOrNull 필드가 이름대로 null일 수 있다는 사실을, 호출하는 쪽이 타입만 보고도 알 수 있게 하려는 것이다.
  • return Optional.ofNullable(profileImageUrlOrNull); — profileImageUrlOrNull 값이 있으면 그 값을 담은 Optional을, null이면 빈 Optional을 돌려준다. 이 메서드를 부른 쪽은 .orElse(...)나 .isPresent()로 값이 있는지부터 확인하게 되어, null 체크를 깜빡할 일이 없다.

2) 엔티티 객체 (변경 통제)

import jakarta.persistence.*;

import java.time.LocalDateTime;
import java.util.Objects;
import java.util.UUID;

@Entity
@Table(name = "users")
public class UserEntity {
    @Id
    private String id;

    @Column(nullable = false)
    private String email;

    @Column(nullable = false)
    private String displayName;

    @Column(nullable = false)
    private LocalDateTime createdAt;

    @Column
    private String profileImageUrlOrNull;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false)
    private Status status;

    protected UserEntity() {}

    // 생성은 정적 팩토리
    public static UserEntity create(String email, String displayName) {
        UserEntity e = new UserEntity();
        e.id = "USER_" + UUID.randomUUID();
        e.email = requireNonNull(email, "email");
        e.displayName = requireNonNull(displayName, "displayName");
        e.createdAt = LocalDateTime.now();
        e.profileImageUrlOrNull = null;
        e.status = Status.ACTIVE;
        validateEmail(email);
        validateDisplayName(displayName);
        return e;
    }

    // 의도 기반 변경만 허용
    public void changeEmail(String newEmail) {
        validateEmail(newEmail);
        this.email = newEmail;
    }

    public void updateProfileImage(String imageUrl) {
        validateImageUrl(imageUrl);
        this.profileImageUrlOrNull = imageUrl;
    }

    public void removeProfileImage() {
        this.profileImageUrlOrNull = null;
    }

    public void deactivate() {
        if (this.status == Status.DEACTIVATED) {
            throw new IllegalStateException("이미 비활성화 상태입니다.");
        }
        this.status = Status.DEACTIVATED;
    }

    private static String requireNonNull(String value, String name) {
        return Objects.requireNonNull(value, name + " cannot be null");
    }

    private static void validateEmail(String email) {
        if (!email.matches("^[A-Za-z0-9+_.-]+@(.+)$")) {
            throw new IllegalArgumentException("유효하지 않은 이메일: " + email);
        }
    }

    private static void validateDisplayName(String name) {
        if (name.isBlank() || name.length() > 50) {
            throw new IllegalArgumentException("표시명은 1~50자");
        }
    }

    private static void validateImageUrl(String url) {
        if (url == null || url.isBlank()) {
            throw new IllegalArgumentException("이미지 URL은 공백일 수 없음");
        }
    }

    public enum Status {
        ACTIVE, DEACTIVATED
    }
}

한 줄씩 따라가기 — 새로 나온 줄만 짚는다. @Entity·@Id·@Column(nullable = false)·빈 생성자(protected UserEntity() {})는 앞의 엔티티 예시에서 이미 설명했다. import jakarta.persistence.*;는 @Entity · @Id · @Column 같은 JPA 표시들이 들어 있는 패키지를 통째로 가져오는 줄이다. JPA의 지금 정식 이름이 Jakarta Persistence이고(예전 이름 Java Persistence API를 줄인 것이 JPA다), 2020년 3.0 버전부터 패키지 이름도 javax.persistence에서 jakarta.persistence로 바뀌었다. 아래의 “Jakarta Persistence 공식 문서”도 곧 JPA 공식 문서다. 옵션 없이 쓴 @Column(profileImageUrlOrNull 위)은 JPA 기본값을 그대로 쓴다는 뜻이다 — 컬럼 이름은 필드 이름과 같고, null을 허용한다(Jakarta Persistence 공식 문서의 Column 기본값). 이 필드는 이름대로 null일 수 있으니 nullable = false를 주지 않았다. create() 안의 requireNonNull(email, "email")은 Objects.requireNonNull이 아니라 클래스 아래쪽에 직접 만든 private 메서드다. 하는 일은 같고, 예외 메시지 뒤에 " cannot be null"을 붙여 “email cannot be null”처럼 더 읽기 쉽게 만드는 것만 다르다. validateEmail · validateDisplayName · validateImageUrl · IllegalStateException은 바로 위 불변 VO 예시에서 설명한 것과 같다. @Table(name = "users")는 이 클래스가 짝지어지는 테이블 이름이 users라는 뜻이다 — 클래스 이름(UserEntity)과 테이블 이름이 다를 때 정확한 이름을 알려주는 표시다. @Enumerated(EnumType.STRING)은 status 필드 같은 자바 enum(Status.ACTIVE, Status.DEACTIVATED)을 DB 컬럼에 저장할 때, 순서를 뜻하는 숫자(0, 1 같은 값)가 아니라 이름 그대로(“ACTIVE”, “DEACTIVATED”) 문자열로 저장하라는 표시다. 숫자로 저장하면 나중에 enum 값 순서를 바꾸거나 중간에 새 값을 끼워 넣었을 때 이미 저장된 숫자가 가리키는 값이 조용히 달라지는데, 문자열로 저장하면 그 문제가 생기지 않는다. changeEmail·updateProfileImage·removeProfileImage·deactivate는 이 글 앞부분에서 말한 “의도 기반 메서드”를 실제로 구현한 것이다 — setter 대신 “무엇을 하려는지”가 이름에 드러나는 메서드로만 필드를 바꾸게 한다.


자신만의 철학을 만들어가는 중입니다.
최상단으로 이동했습니다!
확대 이미지

댓글남기기