luminous_dev 2025. 4. 2. 18:55

VO

: 도메인에서 한 개 또는 그 이상의 속성들을 묶어서 특정 값을 나타내는 객체 

: 도메인 객체의 일종

: 보통 기본 키로 식별값을 갖는 Entity와 구별해서 사용

 

 

 VO는 어떤 조건들에 의해 엔티티와 구별될까?

1. equals & hash code 메서드를 재정의해야 한다

 

타입도 같고, 내부의 속성값도 같은 두 객체가 있으면 당연히 같은 객체로 취급하고 싶을 것임 

근데 실제로 값이 같은 두 객체를 생성하고 동일성 비교를 해보면 둘은 서로 다른 객체로 구별됨 

 

 

public class Point {
  private int x;
  private int y;
  
  public Point(int x, int y) {
    this.x = x;
    this.y = y;
  }
}

@Test
void equals() {
  Point point = new Point(2, 3);
  Point point2 = new Point(2, 3);
  
  // point != point2
  assertThat(point == point2).isFalse();  // 동일성 비교
}

 

분명 같은 위치를 가리키고 있는데 다른 위치라고 판단

이 문제를 해결하기 위해서 우선은 동일성 비교와 동등성 비교의 차이를 알아야함

 

 

 

동일성(==) 비교 : 객체가 참조하고 있는 주솟값을 확인

point와 point2가 참조하고 있는 이 메모리 주솟값은 서로 다르고, 임의로 같게 만들 수 없음 

 

따라서 객체가 포함하고 있는 속성값들을 기준으로 객체를 비교하는 동등성 비교를 통해 객체를 비교해야 함

동등성 비교는 equals 메서드를 재정의함으로써 가능

어떤 속성값들을 기준으로 동등성 비교를 할 것인지는 다음과 같이 직접 equal 메서드의 재정의를 통해 정해야함 

 

// equals & hashcode 재정의
...
    @Override
    public boolean equals(final Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        final Point point = (Point) o;
        return x == point.x &&
                y == point.y;
    }

    @Override
    public int hashCode() {
        return Objects.hash(x, y);
    }
...

 

hashCode는 객체를 식별할 하나의 정숫값을 가리키고 재정의하지 않으면 메모리 주솟값을 사용해서 해시값을 만듦 

 

해시 코드를 재정의해주면 특정 값을 기준으로 같은 해시 코드를 얻을 수 있고,

이는 해시값을 사용하는 컬렉션 등에서 객체를 비교하는 용도로 사용

 

 

equals와 hashCode를 재정의하면, VO를 사용할 때 속성값이 같은 객체는 같은 객체임을 보장하면서

VO를 사용할 수 있음 

 

2. 수정자(setter)가 없는 불변 객체여야 한다

Entity

따로 식별 값을 갖고 있기 때문에 내부 속성값들이 변경된다 하더라도 같은 객체로 계속 인식하고 추적 가능

 

VO : 

값이 바뀌면 다른 값이 되어 추적이 불가하고, 복사될 때는 의도치 않은 객체들이 함께 변경되는 문제를 유발

그래서 VO는 반드시 값을 변경할 수 없는 불변 객체로 만들어야함 

 

// Order.java
public class Order {
  private String restaurant;
  private String food;
  private int quantity;
  
  // Getter, Setter ...
  // equals & hashcode
}

// Main.java
public static void main(String[] args) {

  Order 첫번째주문 = new Order();
  첫번째주문.setRestaurant("황제떡볶이");
  첫번째주문.setFood("매운떡볶이");
  첫번째주문.setQuantity(2);
  // 첫번째주문 = {restaurant='황제떡볶이', food='매운떡볶이', quantity=2}

  Order 두번째주문 = 첫번째주문;
  // 두번째주문 = {restaurant='황제떡볶이', food='매운떡볶이', quantity=2}

  두번째주문.setFood("안매운떡볶이");  //** 주문 변경
  두번째주문.setQuantity(3);       //** 주문 변경
  // 첫번째주문 = {restaurant='황제떡볶이', food='안매운떡볶이', quantity=3}
  // 두번째주문 = {restaurant='황제떡볶이', food='안매운떡볶이', quantity=3}
  
}

 

Order은 VO

첫 번째 주문 값을 두번째 주문에 그대로 복사한 곳 

두 번째 주문은 첫 번째 주문 값을 복사한 것이 아닌

참조하고 있는 메모리 주소를 복사했기 때문에 주문 내용이 바뀌면 메모리 안에 저장된 실제 값이 변경

같은 메모리를 참조하고 있는 첫 번째 주문의 내용도 변경된 값을 가리키게 되는 것 (치명적인 오류)

 

VO는 중간에 그 값이 변하지 않도록 만들어야 함 

즉, 값을 변경할 수 있는 수정자(setter)가 없어야 함

생성자를 통해서 객체가 생성될 때, 값이 한 번만 할당되고 이후로는 변경되지 않도록

 

Setter이 아닌 생성자를 통해 Order 객체를 불변으로 만들기!!

public class Order {
  private String restaurant;
  private String food;
  private int quantity;
  
  public Order(String restaurant, String food, int quantity) {
    this.restaurant = restaurant;
    this.food = food;
    this.quantity = quantity;
  }
  
  // only getter..
}

public static void main(String[] args) {

  Order 첫번째주문 = new Order("황제떡볶이", "매운떡볶이", 2);
  // 첫번째주문 = {restaurant='황제떡볶이', food='매운떡볶이', quantity=2}

  Order 두번째주문 = new Order("황제떡볶이", "매운떡볶이", 2)
  // 두번째주문 = {restaurant='황제떡볶이', food='매운떡볶이', quantity=2}

  두번째주문 = new Order("황제떡볶이", "안매운떡볶이", 3)  //** 주문 변경
  // 첫번째주문 = {restaurant='황제떡볶이', food='매운떡볶이', quantity=2}
  // 두번째주문 = {restaurant='황제떡볶이', food='안매운떡볶이', quantity=3}
  
}

 

이제는 setter가 없으므로 생성자를 통해 객체를 새로 생성하고 재할당

 

VO를 사용하면 얻을 수 있는 이점

1. 객체가 생성될 때 해당 객체안에 제약사항을 추가할 수 있음 

2. 생성될 인스턴스가 정해져 있는 경우에는

미리 인스턴스를 생성해놓고 캐싱하여 성능을 높이는 방법도 고려해볼 수 있음

3. Entity의 원시 값들을 VO로 포장하면 Entity가 지나치게 거대해지는 것을 막을 수 있음 

( 테이블 관점이 아닌 객체 지향적인 관점으로 프로그래밍 가능) 

 

 

컬렉션도 VO의 역할을 한다면, 일급 컬렉션과 같은 불변객체로 만들어서 사용 가능

 

일급 컬렉션?

"컬렉션을 감싸는 하나의 클래스를 만들어, 컬렉션을 관리하는 객체"

 

: 컬렉션을 래핑하여 단 하나의 멤버 변수만을 가지는 객체

 

: Collection을 Wrapping하면서, Wrapping한 Collection 외 다른 멤버 변수가 없는 상태

 

예시)

 

💡 [문제점] 일반적인 List 사용

Order

public class Order {
    private List<Item> items;

    public Order(List<Item> items) {
        this.items = items;
    }

    public List<Item> getItems() {
        return items;
    }
}

 

단점 

1. Order 클래스에서 List<Item>을 그대로 사용하므로, 외부에서 items.add()를 통해 직접 조작 가능 (불변성 깨짐)

2. List<Item> 관련 비즈니스 로직이 Order 클래스 곳곳에 흩어질 가능성 높음

 

💡 [해결책] 일급 컬렉션 적용

Items

public class Items {
    private final List<Item> items;

    public Items(List<Item> items) {
        this.items = new ArrayList<>(items); // 방어적 복사
    }

    public List<Item> getItems() {
        return Collections.unmodifiableList(items); // 불변 리스트 반환
    }

    public int totalPrice() { // 비즈니스 로직 캡슐화
        return items.stream()
                .mapToInt(Item::getPrice)
                .sum();
    }

    public void addItem(Item item) {
        items.add(item);
    }
}

 

Order

public class Order {
    private final Items items;

    public Order(Items items) {
        this.items = items;
    }

    public int getTotalPrice() {
        return items.totalPrice();
    }
}

 

 

  • Order 클래스가 Items 객체만 관리하므로 책임이 명확해짐
  • List<Item>을 직접 노출하지 않고, Collections.unmodifiableList()로 불변성 유지
  • totalPrice()와 같은 비즈니스 로직이 Items 내부에 위치하여 코드의 응집도가 올라감

 

 

 

예시 2) 

public class Person {
    private String name;
    private List<Car> cars;
    // ...
}

public class Car {
    private String name;
    private String oil;
    // ...
}

 

위 코드에서 List<Car>과 같은 컬렉션 부분을 다음과 같이 바꾸는 것

public class Person {
    private String name;
    private Cars cars;
    // ...
}

// List<Car> cars를 Wrapping
// 일급 컬렉션
public class Cars {
    // 멤버변수가 하나 밖에 없다!!
    private List<Car> cars;
    // ...
}

public class Car {
    private String name;
    private String oil;
    // ...
}

 

 

왜 사용할까?

다음과 같은 코드가 있음 

// GSConvenienceStore.class
public class GSConvenienceStore {
    // 편의점에는 여러 개의 아이스크림을 팔고 있을 것이다.
    private List<IceCream> iceCreams;
    
    public GSConvenienceStore(List<IceCream> iceCreams) {
        this.iceCreams = iceCreams;
    }
    ...
}

// IceCream.class
public class IceCream {
    private String name;
    ...
}

 

편의점은 아이스크림의 종류를 10가지 이상 팔지 못할 때,

List<IceCream> iceCreams의 size가 10이 넘으면 안되는 검증이 필요할 것

 

// GSConvenienceStore.class
public class GSConvenienceStore {
    private List<IceCream> iceCreams;
    
    public GSConvenienceStore(List<IceCream> iceCreams) {
        validateSize(iceCreams)
        this.iceCreams = iceCreams;
    }
    
    private void validateSize(List<IceCream> iceCreams) {
    	if (iceCreams.size() >= 10) {
            new throw IllegalArgumentException("아이스크림은 10개 이상의 종류를 팔지 않습니다.")
        }
    }
    // ...
}

 

위 코드의 문제점

 

1.   만약 아이스크림뿐만 아니라 과자, 라면 등 여러 가지가 있다고 가정할 때

모든 검증을 GSConvenienceStore class에서 할 것인가?

 

2. 만약 CUConvenienceStore class에서도 동일한 것을 판다면 

GSConvenienceStore class에서 했던 검증을 또 사용할 것인가?

 

잘못된 코드

// GSConvenienceStore.class
public class GSConvenienceStore {
    private List<IceCream> iceCreams;
    private List<Snack> snacks;
    private List<Noodle> Noobles;
    
    public GSConvenienceStore(List<IceCream> iceCreams ...) {
        validate아이스크림(아이스크림);
        validate과자(과자);
        validate라면(라면);
        // ...
    }
    // ...
}

// CUConvenienceStore.class
public class CUConvenienceStore {
    private List<IceCream> iceCreams;
    private List<Snack> snacks;
    private List<Noodle> Noobles;
    
    public CUConvenienceStore(List<IceCream> iceCreams ...) {
        validate아이스크림(아이스크림);
        validate과자(과자);
        validate라면(라면);
        // ...
    }
    // ...
}

 

 

 

3. List<IceCream> iceCreams의 원소 중에서 하나를 find하는 메서드가 필요할 때

GSConvenienceStore class CUConvenienceStore class 같은 메서드(find)를 두번 구현할 것인가?

// GSConvenienceStore.class
public class GSConvenienceStore {
    private List<IceCream> iceCreams;
    // ...
    public IceCream find(String name) {
        return iceCreams.stream()
            .filter(iceCream::isSameName)
            .findFirst()
            .orElseThrow(RuntimeException::new)
    }
    // ...
}

// CUConvenienceStore.class
public class CUConvenienceStore {
    private List<IceCream> iceCreams;
    // ...
    public IceCream find(String name) {
        return iceCreams.stream()
            .filter(iceCream::isSameName)
            .findFirst()
            .orElseThrow(RuntimeException::new)
    }
    // ...
}

 

편의점 class의 역할이 무거워 지고, 중복코드가 많아짐

 

위 코드를 일급 컬렉션으로 만든다면?

1. 아이스크림 객체를 따로 생성

// IceCream.class
public class IceCreams {
    private List<IceCream> iceCreams;
    
    public IceCreams(List<IceCream> iceCreams) {
        validateSize(iceCreams)
        this.iceCreams = iceCreams
    }
    
    private void validateSize(List<IceCream> iceCreams) {
    	if (iceCreams.size() >= 10) {
            new throw IllegalArgumentException("아이스크림은 10개 이상의 종류를 팔지않습니다.")
        }
    }
    
    public IceCream find(String name) {
        return iceCreams.stream()
            .filter(iceCream::isSameName)
            .findFirst()
            .orElseThrow(RuntimeException::new)
    }
    // ...
}

 

2. 편의점 class는 다음과 같이 변경됨 

// GSConvenienceStore.class
public class GSConvenienceStore {
    private IceCreams iceCreams;
    
    public GSConvenienceStore(IceCreams iceCreams) {
        this.iceCreams = iceCreams;
    }
    
    public IceCream find(String name) {
        return iceCreams.find(name);
    }
    // ...
}

// CUConvenienceStore.class
public class CUConvenienceStore {
    private IceCreams iceCreams;
    
    public CUConvenienceStore(IceCreams iceCreams) {
        this.iceCreams = iceCreams;
    }
    
    public IceCream find(String name) {
        return iceCreams.find(name);
    }
    // ...
}

// 만약 find메서드 중복되는 것이 신경쓰인다면 부모 클래스를 만들어 상속을 사용하세용:)

 

과자랑 라면이 생겨도 과자/라면의 일급 컬렉션이 해결해줄 것

 

 

이러한 구조는 컬렉션에 대한 조작을 통제하고 비즈니스 로직을 캡슐화하는 데 유용

 

1) 객체지향 프로그래밍에서 중요한 역할함 

→ 객체의 상태와 행위를 한 곳에서 관리할 수 있게 해주며, 불변성을 보장하여 부작용을 줄일 수 있기 때문

 

2) 코드의 명확성과 안전성, 유지보수성 향상

(코드의 재사용성을 높이고, 오류 가능성을 줄이는 데 도움)

컬렉션에 대한 조작을 제한하고, 관련 로직을 한 곳에서 관리, 테스트가 용이해지기 때문 

관련 로직이 한 곳에 집중되어 있어, 변경 사항이 발생할 경우 해당 부분만 수정하면 되기 때문

 

 

 

구현 예제)

컬렉션을 멤버 변수로 가지는 클래스를 정의>  해당 컬렉션에 대한 조작을 제어하는 메소드를 제공

이를 통해 컬렉션에 대한 직접적인 접근을 제한하고, 관련 로직을 캡슐화할 수 있기 때문

 

 

    public class GameResults {
        private final List<GameResult> results = new ArrayList<>();

        public void addResult(GameResult result) {
            results.add(result);
        }

        public List<GameResult> getResults() {
            return new ArrayList<>(results);
        }
    }

 

 

장점

1. 컬렉션에 대한 조작을 제한하여 코드 안정성을 높일 수 있음 

2. 비즈니스 로직을 캡슐화해서 코드의 가독성과 유지보수성을 향상시킬 수 있음

(관련 로직이 한 곳에 집중되어 코드 구조가 명확해짐) 

3. 불변성을 보장해서 부작용을 줄일 수 있음  

외부에서 컬렉션에 접근할 수 없어서 컬렉션의 상태를 변경할 수 없기 때문 

 

 

 

단점 

1. 컬렉션에 대한 간단한 조작이 필요한 경우에도 별도의 클래스를 정의해야함

-> 코드의 복잡성이 증가 (컬렉션마다 별도의 클래스를 정의해야하기 때문)

 

2. 성능 상 이슈 발생

-> 컬렉션의 상태를 변경할 때마다 새로운 객체를 생성해야해서

객체 생성 비용이 증가 

 

 

 

단점에도 불구하고 장점이 더 큼

코드의 안정성과 유지보수성을 높이는 데 큰 도움이 되기 때문에 


우아한 테크 블로그에서는 다음 정의를 다르게 정의함

일급 컬렉션은 불변성을 보장하지 않는다는 입장

= 일급 컬렉션이 주는 기능의 핵심은 불변이 아니다.

 

다른 블로그 글

1. setter을 구현하지 않으면 불변 컬렉션이 된다 (x)

→ setter을 안 사용해도 변화를 줄 수 있음 

 

따라서 

public class Lotto {
    private final List<LottoNumber> lotto;

    public Lotto(List<LottoNumber> lotto) {
        this.lotto = new ArrayList<>(lotto);
    }

    public List<LottoNumber> getLotto() {
        return Collections.unmodifiableList(lotto);
    }
}

 

1. new ArrayList<>(lotto);로 재할당

2. unmodifiableList()를 사용하면 불변이 됨

 

 

 

참고 문헌

VO

 

VO(Value Ojbect)란 무엇일까?

프로그래밍을 하다 보면 VO라는 이야기를 종종 듣게 된다. VO와 함께 언급되는 개념으로는 Entity, DTO등이 있다. 그리고 더 나아가서는 도메인 주도 설계까지도 함께 언급된다. 이 글에서는 우선 다

tecoble.techcourse.co.kr

 

일급 컬렉션

 

1급 컬렉션의 이해와 실무 적용 사례

1급 컬렉션의 개념, 장단점, 실무 적용 사례 및 구현 예제를 통해 코드의 품질을 높이는 방법을 알아봅니다.

f-lab.kr

 

 

 

 

일급 컬렉션을 사용하는 이유

일급 컬렉션이란? 본 글은 일급 컬렉션 (First Class Collection)의 소개와 써야할 이유를 참고 했다. 일급 컬렉션이란 단어는 소트웍스 앤솔로지의 객체지향 생활체조 규칙 8. 일급 콜렉션 사용에서 언

tecoble.techcourse.co.kr