← ALL ENTRIES

값 객체(Value Object)


참조 객체의 속성을 표현하는 단순·불변 객체인 값 객체의 개념과, 이를 활용해 클래스의 복잡성을 낮추고 코드를 명확하게 만드는 리팩토링 방법.

값 객체란

📝 NOTE 정의 값 객체(Value Object) 는 색상·숫자·금액처럼 참조 객체의 속성을 표현하기 위해 사용하는 단순한 객체다. 값이 동일하면 동일한 객체로 간주한다.

  • 참조 객체 vs 값 객체
    • 참조 객체: 식별성(객체 식별자) 으로 동일성을 판단
    • 값 객체: 속성(값) 으로 동등성을 판단 → 값이 같으면 같은 객체
  • 대표 예시: 금액(Money)

값 객체의 3가지 구현 규칙

  1. 불변 객체로 구현 — 인스턴스 변수를 final로 선언, 생성자에서만 초기화
  2. 상태 변경 시 새 객체 반환 — 직접 수정하지 않고 새 상태의 객체를 생성해 반환
  3. equals 오버라이딩 — 모든 속성을 비교해 동등성 판단
class Money {
    private final long amount;   // final → 생성 후 변경 불가

    Money(long amount) { this.amount = amount; }

    // plus는 amount를 변경하지 않고 새 Money를 생성해 반환
    Money plus(Money other) {
        return new Money(this.amount + other.amount);
    }

    @Override
    public boolean equals(Object o) { /* 모든 속성 비교 */ }
}

값 객체가 참조 객체보다 단순한 이유

같은 연산을 가변 객체불변 객체로 각각 구현해 비교한다.

1) 상태 추적 문제

  • 가변 객체: plusamount직접 수정 → 연산할 때마다 상태 변경이 내부에 누적
    • amount1, amount2의 최종 값을 알려면 첫 줄부터 마지막 줄까지 전부 따라가야
  • 불변 객체: 상태 변경 불가 → 생성 시점 값(예: 10,000원, 20,000원)이 그대로 유지
    • 중간 연산은 무시하고 생성 시점 상태만 알면 됨 → 추적 비용 감소

2) 별칭(Aliasing) 문제

📌 IMPORTANT 두 별칭(Client1, Client2)이 동일한 Money 객체를 참조하는 상황:

  • 가변 객체: Client1이 값을 변경하면 Client2도 같은 변경을 봄 → 예상 못 한 부수 효과 → 버그
  • 불변 객체: 변경 시 새 객체를 생성하므로 Client1만 새 객체를 참조, Client2로 변경이 전파되지 않음 → 안전

💡 TIP 정리

구분참조 객체값 객체
판단 기준동일성(식별자 비교)동등성(속성 비교)
가변성가변 객체불변 객체
별칭 문제발생함방지됨

→ 값 객체를 참조 객체와 섞어 쓰면 참조 객체의 복잡성을 완화할 수 있다.

값 객체의 2가지 용도

강사는 값 객체를 두 목적으로 사용한다:

  1. 암시적 개념을 명시적으로 표현
  2. 복잡한 로직·중복 코드를 이동시키는 장소

용도 1: 암시적 개념의 명시적 표현

  • Stage.totalPrice, Game.priceLong 타입이지만 실제로는 “금액” 개념
  • 개발자가 코드를 볼 때마다 머릿속에서 Long → 금액으로 변환해야 함

📌 IMPORTANT 코드의 어떤 개념을 머릿속에서 다른 개념으로 해석해야 한다면, 그 개념을 코드로 명확하게 표현하는 게 낫다.

  • 해결: Money 값 객체를 만들고 amount를 그 안에 선언, plus/minus/times 메서드 추가
  • LongMoney, 연산자 → Money의 메서드로 변경 → 금액 개념이 코드에 명시적으로 드러남

용도 2: 중복 제거 (DRY)

  • Sales·Game에 금액을 올림 처리하는 로직이 중복 → 규칙이 바뀌면 여러 곳을 동시에 수정해야 함
  • 이 로직을 값 객체 Money로 옮겨 중복 제거

📝 NOTE DRY 원칙 모든 지식 조각은 시스템 안에서 하나의 모호하지 않고 의미 있는 표현을 가져야 한다. (중복 개념·중복 코드가 존재해서는 안 된다)

📌 IMPORTANT 중복 코드 = 요구사항이 변경될 때 함께 수정되는 코드. 모양이 같아도 함께 수정되지 않으면 중복 코드가 아니다.

값 객체와 합성 관계

📝 NOTE 합성(Composition) 관계 포함되는 객체의 생명 주기가 포함하는 객체에 종속된다 = 함께 생성되고 함께 삭제된다.

  • 예: Sales가 제거될 때 포함된 Money도 함께 제거됨
  • UML에서 속이 칠해진 마름모를 가진 연결선으로 표현
  • 값 객체는 독립 클래스로 두기보다 다른 객체의 속성으로 표현하는 것을 선호 → 생명 주기 종속을 직관적으로 드러냄

값 객체를 식별하는 방법

💡 TIP 미리 고민하지 말 것 값 객체 타입은 미리 설계하지 않는다. 복잡한 참조 객체를 리팩토링하는 과정에서 발견한다.

  1. 리팩토링 중 숨겨진 간단한 개념 발견 → 개념을 표현하는 값 객체 추가
  2. 참조 객체들 사이 중복 코드 발견 → 중복을 이동시킬 값 객체 추가
  3. 함께 사용되는 변수를 찾기 — 다음 변수들은 값 객체 후보:
    • 함께 초기화 / 함께 변경 / 여러 메서드에서 함께 참조
    • 다른 메서드의 파라미터로 함께 전달 / 유효성을 함께 검증

실전 적용: Game 클래스 리팩토링

📌 IMPORTANT 값 객체를 추가할 때는 관련 변수뿐 아니라 그 변수를 이용하는 로직도 함께 옮기는 것이 핵심이다.

1) x, y → Position (위치)

  • roomAt, isBlocked의 인자로 x·y를 함께 전달, tryMove에서 x·y를 함께 변경 → 위치라는 숨은 개념
  • Position 클래스 추가: x·y를 final 선언, 생성자에서 초기화
  • 정적 팩토리 메서드 of 추가 → 작은 클래스는 생성자보다 가독성·사용성 개선
  • shift 메서드: 전달된 값을 더한 새 Position 인스턴스를 반환(불변)
  • Game의 x·y를 Position으로 대체, 증가 코드는 shift 호출로 변경
class Position {
    private final int x, y;
    private Position(int x, int y) { this.x = x; this.y = y; }
    static Position of(int x, int y) { return new Position(x, y); }
    Position shift(...) { return new Position(x + dx, y + dy); }
}

2) width, height → Size (크기)

  • width·height를 곱해 배열 크기를 계산하고, 포지션 포함 여부 확인에도 함께 사용 → 크기 개념
  • Size 클래스에 width·height를 final 선언, 정적 팩토리 메서드 추가
  • 로직 이동:
    • area() — width × height (배열 크기)
    • contains() — 좌표가 지도 안에 포함되는지
    • indexOf() — 좌표를 배열 인덱스로 변환 (중복 로직 통합)

💡 TIP 함께 사용되는 변수들을 값 객체로 추출하면 인스턴스 변수와 파라미터 개수가 줄고, 값 객체는 작아서 여러 곳(예: Room의 x·y)에서 재사용할 수 있다.

3) 이동 숫자 → Direction (방향)

  • 추상화된 move에 좌표 변경 값을 숫자로 전달 → 동서남북 방향을 직관적으로 표현하지 못함 (X는 오른쪽, Y는 아래쪽으로 증가한다는 사실을 기억해야 함)
  • 숨은 개념 = 방향(Direction)
  • Direction열거형(enum) 으로 구현: NORTH, SOUTH, EAST, WEST
  • Position.shift의 파라미터를 Direction으로 변경, 방향별 좌표 변경 로직을 shift 안에 캡슐화
  • 결과: move에 이해하기 어려운 숫자 대신 Direction을 전달 → 플레이어는 좌표 구조를 몰라도 이동 가능
  • 4개의 move 메서드가 이름·구현 간 의미 차이가 사라져 인라인화로 제거 → 더 작고 단순한 Game

마무리: 작은 리팩토링의 힘

📌 IMPORTANT 클래스 사이의 책임을 조정하기 전에, 먼저 개별 클래스 단위로 작은 리팩토링을 수행한다. (긴 메서드를 조합 메서드로 분리 / 모호한 개념을 값 객체로 추출) 이렇게 메서드·클래스 수준을 정리하면 책임 조정이 훨씬 수월해진다.

  • 작은 코드를 값 객체로 옮기는 건 사소해 보여도, 쌓이면 코드가 비교할 수 없을 정도로 개선된다.
  • 다음 주제 예고: 책임을 여러 클래스로 분리하는 단일 책임 원칙(SRP)

핵심 요약

  • 값 객체는 속성(값) 으로 동등성을 판단하는 불변 객체이며, 상태 변경 시 새 객체를 반환하고 equals로 모든 속성을 비교한다.
  • 불변이기 때문에 상태 추적이 필요 없고 별칭 문제(부수 효과)가 발생하지 않아 참조 객체의 복잡성을 낮춘다.
  • 값 객체는 ① 암시적 개념을 명시적으로 표현하고, ② 중복 로직을 한곳으로 모으는(DRY) 두 용도로 쓴다.
  • 값 객체는 미리 설계하지 않고, 함께 사용되는 변수를 리팩토링하며 발견한다 (변수 + 로직을 함께 이동).
  • 실전: x·y→Position, width·height→Size, 이동 숫자→Direction(enum)으로 추출해 Game을 작고 명확하게 만든다.