카테고리 없음

[TIL] @Embedded

bluealice 2025. 7. 15. 19:51

 1. VO 와 엔티티 

데이터의 형태는 어디에서 이용되는지에 따라 달라진다. Redis 에서는 빠른 접근을 위해 사용 목적에 맞게 sorted set, List, Hash Table 등의 모델에 저장되고, RDB 에서는 영속성과 무결성의 목적에 맞게 테이블의 튜플 형태로 저장된다. application 에서는 비즈니스 로직을 수행하는 과정에서 '의미'가 중요해진다. 그래서 다른 시스템에서는 볼 수 없는 도메인이나 애그리거트라는 개념도 나온다. 


VO 는 Value Object 로, 돈이나 주소, 전화번호, 일련번호 등등 '값이 같으면 같은 것' 이라는 의미를 갖는 객체이다. 

Member 라는 객체는 이름이나 나이가 같아도 다른 사람이다. 보이는 값이 같아도 각자 다른 주체로서 구분된다. 반면, VO 의 경우 String 처럼 "안녕하세요" 와 "안녕하세요" 는 그 값이 같다.  따라서 객체 2개를 생성하여도 같은 것이다. 

예를 들어, Money과 Person 이라는 클래스가 각각 존재한다고 해보자.

// Money
Money m1 = new Money(1000, "KRW");
Money m2 = new Money(1000, "KRW");

if (m1.equals(m2)) // true
	System.out.println("True"); // 실제로 True 가 출력됨
}

// Person
Person p1 = new Person(1L, "Alice");
Person p2 = new Person(2L, "Alice");

if (p1.equals(p2)){
System.out.println("True") // True 가 출력되지 않음
}



다음과 같이, Person 은 같은 "Alice" 라는 이름을 가져도 다른 객체이지만, Money은 값으로 1000 를 가지면 같은 객체로 인식된다. 중요한 차이는 Id 를 갖지 않는다는 것이다. VO 객체와 같이 Id 를 가지지 않는다면, 해당 객체의 value 를 변경하면 아예 다른 객체가 된다.  


예를 들어, Post 의 경우 다른 사람이 각각 생성했어도 제목과 내용이 같으면 1개로 취급되어야 할까? 반대로 전화번호에 각각 id 를 부여해 값을 변경하도록 허용한다면, 어쩌다 같은 번호를 가지게 되어도 다른 상대로 연결될 수 있는걸까? 
Id 를 가진다는 것은 하나의 생명주기를 갖는 것이며, 구분되어야 한다는 의미이다. 따라서, Post 는 엔티티이고, 전화번호는 value object 으로 관리되어야 한다.

이러한 이유로 VO 에는 불변 객체로 설계해야 하며, setter나 id 를 만들지 않고 생성자를 통해 값을 생성한다. Object 클래스의 equals나 hashCode 메서드들도 따로 override 하여 '값'을 비교해주는 함수로 따로 작성해주는 것이 권장된다. 


2. @Embedded 와 @Embeddable 의 사용

엔티티를 작성하다 보면 Address 나 Password 와 같은 필드를 작성할 때가 있다. 이것들은 엔티티가 아닌 VO로서 값 그 자체를 나타낸다. Address 값이 같으면 같은 객체로 인식되기 때문이다.

zipcode, city, addressDetail 등의 필드를 하나하나 따로 작성할 수도 있겠지만, 어플리케이션 로직 상에서는 모두 '주소'를 이루는 값이기 때문에 Address 라는 하나의 객체로 묶어 처리하면 코드 복잡성도 줄고, 가독성도 높일 수 있을 것이다.
Application 내에서 이런 VO를 엔티티에 매핑하여 저장하고, 가져올 때는 VO로 가져오도록 할 수 있는 어노테이션이 있는데, @Embedded 와 @Embedable 이다.

사용법은 다음과 같다. Address 라는 VO 를 Person 이라는 Entity 에 포함시키고 싶을 때 다음과 같이 작성할 수 있다.

// Address
@Embeddable
@NoArgsConstructor(access= AccessLevel.PROTECTED)
public class Address{

    private String zipcode;
    private String city;
    private String addressDetail;

    // 엔티티와 달리 value로 동등성 비교
    @Override
    public boolean equals(Object o){
        if(this == o) return true;
        if (!(o instanceof Address)) return false;
        Address address = (Address) o;
        return Objects.equals(city, address.city) &&
        Objects.equals(zipcode, address.zipcode) &&
        Objects.equals(addressDetail, address.addressDetail);
    }

    @Override
    public int hashCode(){
        return Objects.hash(city, zipcode, addressDetail);
    }
}

// Person
@Entity
@NoArgsConstructor(access= AccessLevel.PROTECTED)
public class Person{
    @Id
    @GeneratedValue
    private Long id;

    @Embedded
    private Address address;

    ...
}



VO 객체에 @Embeddable 을 작성하면, 스캔되어, @Embedded 가 붙은 객체를 생성 또는 로드할 때 스캔된 객체 리스트에서 'Address' 를 찾아 매핑해주는 방식이다.


1) 왜 Password를 VO로 써야 하는가?
Password의 경우 단순히 문자열로 보일 수 있지만, VO로 작성할 경우 타입 안정성과 가독성을 높일 수 있다. 

// String 으로 표현
private String password;
// VO 적용
private Password password;

 

VO 로 작성했을 때 클래스명을 통해 해당 필드의 역할을 한눈에 알 수 있어 훨씬 가독성이 있고, Password의 경우 어플리케이션에 따라 regex validation을 적용하여 객체를 생성할 때마다 유효한지 검사할 수도 있다. 

그러나 모든 객체를 전부 VO 로 따로 만들 필요는 없고, VO로서의 역할을 하며 그 자체로 의미 있는 로직을 가져야 하는 등의 충분한 사유가 있을 때 VO를 적용할 수 있고, 과도하게 적용하면 오버 테크놀로지가 되어 오히려 유지보수에 좋지 않다. 

 


3. JPA와 @AttributesOverrides

embedded 된 객체는 persistence context 안에서 어떻게 저장되어 있을까?
정답은 flatten 되어 저장되게 된다. 

만약 엔티티가 다음과 같다면,

@Embeddable
public class Address{
    private String zipcode;
    private String city;
}

@Entity
public class Member{
    private Long id;

    private String name;

    @Embedded
    private Address homeAdrs;

}



JPA 컨텍스트에서는

// 실제 자료형은 아니다
member@1 {
    id,
    name,
    homeAdrs.city,
    homeAdrs.zipcode
}


이런 식으로 flatten 되어 저장된다고 한다.  DB에도 id, name, city, zipcode 이렇게 flatten 되어 저장된다.

1) update
JPA 에서 update 는 entity 단위로 저장되고, 변경 감지는 필드 단위의 스냅샷으로 진행되기 때문에, 필드 homeAdrs 자체의 참조가 유지되어 내부 필드의 값을 바꿔도 더티 체킹이 생각대로 동작하지 않는다.
VO 개념 자체도, 값을 변경하거나 어떠한 생명주기를 가질 수 있는 것이 아니고 '값' 그 자체이다. 
변경하고자 한다면 객체 자체를 변경하는 것이 개념적으로도, JPA 기능적으로도 옳은 방법이다.

2) persist
JAVA 에서 객체를 생성하는 방법에는 Reflection 과 new 2가지가 있다. Reflection 은 해당 클래스파일에 접근해서 객체에 대한 정보를 임의로 파악하는 기술이고, new 는 생성자를 사용해서 메모리를 할당받는 방법이다. 
Reflection 은 class path 문자열만 알면 객체를 만들 수 있다는 점 때문에, 어플리케이션 개발자가 직접 사용하기보다는 다양한 프레임워크 내에서 객체를 생성할 때 자주 사용된다. 반면에 new의 경우, 'new Member', 'new Post' 처럼 코드가 직접 작성되어 있어야 한다. 
JPA 에서는 어떤 클래스를 가진 객체를 생성해야겠다는 것만 알 뿐, 객체를 생성하기 위해 생성자에 어떠한 필드를 넣어야 할지는 모른다. 따라서 'new Member' 와 같은 코드가 직접적으로 쓰여있지 않아도 reflection 을 사용해서 여러 객체를 상황에 맞게 생성한다. 
보통 reflection 은 이런 식으로 클래스 이름만 알고 있는 객체를 생성하는데

// Reflection 으로 객체 생성
Class<?> clazz = Class.forName("Person");
Object obj = clazz.getDeclaredConstructor().newInstance();


getDeclaredConstructor() 함수가 기본 생성자이며, newInstance() 를 통해 객체를 가져오게 된다. 만약 entity 가 10개, 20개 있는 상황에서 클래스 하나하나 다른 매개변수 개수와 종류를 알지는 못할 것이다. 이런 식으로 기본 생성자로 부른 다음 필드를 찾아 주입하는 방식으로 진행되고, Entity 객체가 새로 생성될 때도 이와 같은 과정을 따르기 때문에 기본생성자가 최소한 Public 혹은 Protected 접근자 중 하나로 생성되어 있어야 한다. private 일 경우 접근이 불가하다.
embedded 객체도 JPA 가 생성 후 Entity 객체 생성 시 주입한다. 따라서 여기서도 기본 생성자가 필요하다.

3) 한 엔티티에서 VO를 두 번 사용할 때

만약 Person 객체에 집주소와 회사 주소를 모두 적어야 한다고 가정해보자. 똑같이 Address 객체를 사용하지만, JPA 입장에서는 city, zipcode, addressDetail 이라는 이름이 같기 때문에 어떤 컬럼에 매핑해야할지 헷갈릴 수 있다.
이런 경우에, DB에 저장하고 매핑 시 사용할 필드명을 각각의 상황에 따라 다르게 적용하는 방법이 있는데 @AttributeOverrrides 를 사용하는 것이다.

@Entity
public class Person {
    @Id
    @GeneratedValue
    private Long id;

	// 집주소 : home_ 접두사로 필드명을 정한다.
    @Embedded
    @AttributeOverrides({
    @AttributeOverride(name = "city", column = @Column(name = "home_city")),
    @AttributeOverride(name = "zipcode", column = @Column(name = "home_zipcode")),
    @AttributeOverride(name = "addressDetail", column = @Column(name = "home_addressDetail"))
    })
    private Address homeAddress;

	// 직장 주소 : work_ 접두사로 필드명을 정한다.
    @Embedded
    @AttributeOverrides({
    @AttributeOverride(name = "city", column = @Column(name = "work_city")),
    @AttributeOverride(name = "zipcode", column = @Column(name = "work_zipcode")),
    @AttributeOverride(name = "addressDetail", column = @Column(name = "work_addressDetail"))
    })
    private Address workAddress;

}



다음과 같이 각 Address 의 어떤 필드를 실제 column 에서는 어떤 이름으로 매핑해줄지 지정해줄 수 있고,

member@1 {
    id,
    name,
    (homeAddress.)home_city,
    (homeAddress.)home_zipcode,
    (homeAddress.)home_addressDetail,
    (workAddress.)work_city,
    (workAddress.)work_zipcode,
    (workAddress.)work_addressDetail,
    ...
}



과 같이 db 와 jpa 에서 다음과 같이 구분되어 저장 및 관리된다.

4. 정리

정리하면 어플리케이션 내에서, 엔티티 객체에서 VO 객체를 사용하여 가독성과 타입 안정성을 높일 수 있어 더 명확한 의미로 로직을 처리할 수 있다. 
추가적인 코드 작성 없이 JPA 에서 @Embeddable, @Embedded 를 사용해 Application 에서 사용하는 VO 객체를 DB 필드와 자연스럽게 매핑하여 사용할 수 있다. 
JPA 에서는 Reflection 을 사용하기 때문에 반드시 기본생성자가 있어야 하며, 더티체킹으로 update 를 할 때에는 해당 embedded 객체로 인식하기 때문에 setter 대신 객체 자체를 바꾸는 것으로 사용한다.(VO의 개념)
2개 이상의 VO 객체를 한 Entity 에서 사용할 때는 필드 이름을 구분해서 db 에 저장하기 위해 @AttributeOverrides 로 어떤 필드가 어떤 컬럼명으로 매핑될 것인지 설정할 수 있다.