9. 값 타입

2025. 6. 19. 15:02Spring/JPA

1. 값 타입
1) 엔티티 타입 vs 값 타입
2) 값 타입의 종류

2. 복합 값 타입 (임베디드 타입) 
1) 예시 구조
2) 테이블에 매핑
3) 장점

3. 타입 별  공유 개념과 주의사항  
1) 엔티티 타입의 공유
2) 기본값 타입의 공유 
3) 복합값 타입의 공유: 주의 필요!  

4. 타입 별 비교 방식 
1) 자바에서의 객체 비교 방식 3가지
2) 엔티티 타입의 비교 방식
3) 기본값 타입의 비교 방식 
4) 복합값 타입 비교
5) 비교 방식 정리 

5. 값 타입 컬렉션
1) 사용 방식 & 예제 
2) 동작 방식
3) 제약사항
4) 실무 대안


1. 값 타입

1) 엔티티 타입 vs 값 타입

구분 엔티티 타입 값 타입
정의 방식 @Entity로 정의되는 클래스 엔티티가 아닌, 순수한 값만을 표현하는 타입
식별 방법  ID와 같은 식별자  내부 값 자체로 판단 (식별자 없음)
변경 추적 O ID 기준으로 동일 객체로 추적 가능 X 값이 바뀌면 다른 값으로 간주됨
변경 예시 이름 바뀌어도 ID가 같으면 동일 엔티티 100 → 200으로 바뀌면 완전히 다른 값으로 인식
생명 주기 독립적으로 존재 항상 엔티티에 종속됨
사용 위치  서로 다른 객체 간의 관계
예: 회원 - 팀
엔티티 내부 필드로 포함되는 정보
예: 이름(String), 나이(int), 금액(BigDecimal) 등
공유 여부 공유 가능, 의도된 공유  공유하면 위험 (객체 참조가 아닌 값을 복사해서 사용해야 안전)
공유 시 하나의 값 변경으로 다른 곳도 같이 바뀔 위험 있음
  • 진짜 값 타입일 때만 값 타입 사용
  • 변경 추적 필요하면 → 무조건 엔티티로 분리해야 안전

2) 값 타입의 종류

분류 설명 예시
1. 기본값 타입 자바가 제공하는 기본 자료형이나 래퍼 클래스, 문자열 등
JPA에서 특별한 설정 없이 바로 사용 가능
자바 기본형: int, double, boolean 등
래퍼 클래스: Integer, Long, Boolean 등
문자열: String
2. 복합값 타입
(임베디드 타입)
개발자가 직접 정의한 새로운 값 타입 
주로 여러 기본값 타입을 묶어서 복합값 타입으로 만든다. 
Address, Period 등
3. 값 타입 컬렉션 값 타입을 컬렉션으로 다루는 경우 
@ElementCollection + @CollectionTable 조합으로 사용
List<String> hobbies,
List<Address> addresses 등

 

2. 복합 값 타입 (임베디드 타입) 

1) 예시 구조

//BEFORE: 모든 필드가 Member 안에 있는 구조 
@Entity
public class Member {
    @Id
    private Long id;
    private String name;

    // 주소 필드들
    private String city;
    private String street;
    private String zipcode;
}

//AFTER: 복합 값 타입으로 분리
@Entity
public class Member {
    @Id
    private Long id;
    private String name;

    @Embedded
    private Address homeAddress;
}

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

    protected Address() {} // JPA용 기본 생성자
    public Address(String city, String street, String zipcode) {
        this.city = city;
        this.street = street;
        this.zipcode = zipcode;
    }
}
  • 사용 어노테이션 
    • @Embeddable: 임베디드 타입 클래스에 선언
    • @Embedded: 사용하는 곳(엔티티)에 선언
  • JPA는 내부적으로 리플렉션을 통해 객체를 생성하므로,
    @Embeddable 클래스에는 파라미터 없는 protected 기본 생성자가 반드시 있어야 한다.

2) 테이블에 매핑

  • 임베디드 타입이 필드 단위로 분해되어 한 테이블에 컬럼으로 들어감 
    • 임베디드 값 자체가 null이면 매핑된 컬럼들도 모두 null로 저장됨
    • 임베디드 타입의 필드명은 기본적으로 테이블 컬럼명에 그대로 반영됨 
  • 같은 임베디드 타입을 두 번 이상 사용할 경우 컬럼명이 중복될 수 있으므로,
    @AttributeOverrides와 @AttributeOverride를 통해 컬럼명을 명시적으로 재정의해야 한다.
@Embedded
@AttributeOverrides({
    @AttributeOverride(name = "city", column = @Column(name = "home_city")),
    @AttributeOverride(name = "street", column = @Column(name = "home_street")),
    @AttributeOverride(name = "zipcode", column = @Column(name = "home_zipcode"))
})
private Address homeAddress;

@Embedded
@AttributeOverrides({
    @AttributeOverride(name = "city", column = @Column(name = "work_city")),
    @AttributeOverride(name = "street", column = @Column(name = "work_street")),
    @AttributeOverride(name = "zipcode", column = @Column(name = "work_zipcode"))
})
private Address workAddress;

3) 장점

  • 재사용성 높음: Address, Period 같은 클래스는 여러 엔티티에서 재사용 가능
  • 응집도 높은 설계 가능: 관련 필드를 하나의 객체로 묶어, 코드 가독성과 관리성 향상
  • 객체지향 설계: 테이블은 그대로지만, 객체는 더 풍부하게 모델링 가능

 

3. 타입 별  공유 개념과 주의사항  

1) 엔티티 타입의 공유

  • 엔티티 타입은 의도적으로 객체를 공유하여 사용도록 설계한다. 
//엔티티 타입 객체 공유 
//하나의 팀(Team 엔티티)을 여러 회원이 공유(참조)하는 건 자연스러움. 의도된 공유
Team team = em.find(Team.class, teamId);
memberA.setTeam(team);
memberB.setTeam(team);

2) 기본값 타입의 공유 

타입 공유해도 되는 이유  
자바 기본형
(int, double 등)
자바 언어 자체가 항상 값을 복사하기 때문에 공유 개념 자체가 없음
객체 기본형 
(Integer, String 등)
객체지만 불변객체로 제공된다.
값 변경할 경우 새로운 객체가 생성됨  따라서 내부 값은 절대 바뀌지 않음
//자바의 기본형: 항상 값 복사
int a = 10;
int b = a; // 값 '10' 복사
b = 20;    // a에는 영향 없음

//래퍼 클래스, 문자열 타입: 객체지만 불변 객체이므로 내부 값 변경 안됨 
String s1 = "hello";
String s2 = s1;      // "hello" 참조 공유
s2 = "bye";          
// 단, 위와 같이 값이 바뀌는 경우 "hello"를 참조하던 것을 끊고
// 새로운 String 객체를 생성하여 참조하게 됨. 
// 따라서 s1은 여전히 "hello"를 가리키고 있다.

3) 복합값 타입의 공유: 주의 필요!  

  • 복합값 타입은 객체 참조를 공유할 경우 하나의 값 변경으로 다른 곳도 같이 바뀔 위험 있어 주의해야한다. 
//값 타입 객체 Address
Address address = new Address("Seoul", "Gangnam", "12345");

Member memberA = new Member("현지");
memberA.setAddress(address);

Member memberB = new Member("지피티");
memberB.setAddress(address); // 같은 address 객체의 참조 값을 공유!

// memberA 와 memberB 는 같은 Address 객체를 공유하고 있으나
// memberB에서 값을 바꾸면 의도치 않게 memberA도 값까지 바뀌는 문제가 생긴다. 
memberB.getAddress().setCity("Busan");
  • 따라서 복합값 타입을 공유하고 싶은 경우 
    • 객체 참조만 복사하는 얕은 복사는 객체 자체를 공유하는 상태가 되어 위험하므로 
    • 새로운 인스턴스를 생성하여 필드 값만 복사하는 깊은 복사 방식을 사용하거나 
    • 애초에 불변 객체로 설계하는 것이 안전하다. 
  • 해결 1: 새로운 인스터스 생성하여 값만 복사
Address address = new Address("Seoul", "Gangnam", "12345");
memberA.setAddress(address);
memberB.setAddress(new Address(address.getCity(), address.getStreet(), address.getZipcode()));
  • 해결 2: 불변 객체로 설계
    • private final 필드: 불변성 유지, 생성 후 값 변경 불가                       
    • Setter 없음: 외부에서 값 변경 불가능하게 막음                          
    • 생성자에서만 초기화: 객체 생성 시점에만 값 설정                             
    • 기본 생성자 protected: JPA가 리플렉션으로 사용할 수 있도록 허용하되 외부에서는 생성 못 하게 제한 
    • equals, hashCode 재정의: 값 기반 비교를 위한 기본 설계 방식                        
@Embeddable
public class Address {
    //1. private final 필드: 불변성 유지, 생성 후 값 변경 불가
    //2. Setter 없음: 외부에서 값 변경 불가능하게 막음
    @Column(nullable = false)
    private final String city;

    @Column(nullable = false)
    private final String street;

    @Column(nullable = false)
    private final String zipcode;

    //3. 기본 생성자 protected
    // JPA가 리플렉션으로 사용할 수 있도록 허용하되 외부에서는 생성 못 하게 제한
    protected Address() {
         this.city = null;
        this.street = null;
        this.zipcode = null;
    }

    //4. 생성자에서만 초기화: 객체 생성 시점에만 값 설정
    public Address(String city, String street, String zipcode) {
        this.city = city;
        this.street = street;
        this.zipcode = zipcode;
    }
    //5. equals(), hashCode()도 재정의: 값 기반 비교를 위한 기본 설계 방식 
    //6. getter() : 코드는 생략 
}

 

4. 타입 별 비교 방식 

1) 자바에서의 객체 비교 방식 3가지

비교 방식 의미 용도 및 특징 개발자 재정의 가능 
== 연산자  참조값(주소값) 비교 → 객체 동일성  같은 객체를 참조하고 있을 때만 true ❌ (JVM이 관리)
equals() 객체의 내용 비교 → 동등성  논리적으로 같은 객체인지 비교 ✅ (보통 재정의함)
hashCode() 객체 내용을 기반으로 만든 정수값 (해시값) 비교  HashSet, HashMap 등에서
객체의 내용을 빠르게 비교할 때 사용
✅ (equals()와 세트)
  • 자바 공식 원칙: equals()가 true라면 hashCode()도 같아야 한다
    • HashSet, HashMap, Hashtable 등은 내부적으로 “hashCode() → equals()” 순서로 객체 비교 
    • 따라서 논리적 동등성을 올바르게 비교하려면 반드시 hashCode()와 equals()를 함께 재정의해야 한다. 
  • 롬복의 @EqualsAndHashCode 
    • 모든 필드를 기준으로 비교하는 equals()와 hashCode()를 자동 생성하는데 이러면 JPA와 충돌 가능성이 높다
    • ID가 없는 상태(=비영속 상태)에서 잘못된 비교가 발생할 수 있고 지연 로딩 필드(연관관계 엔티티) 까지 비교해버릴 수도 있음
    • 따라서 @EqualsAndHashCode(of = "id")로 비교 기준 명확히 지정하는 것이 좋음

2) 엔티티 타입의 비교 방식

Member memberA = new Member(1L, "현지", 29);
Member memberB = new Member(1L, "현지", 29); // 필드값 완전 동일
비교 방식 설명
== 참조값 비교 → false (인스턴스 다름)
equals() 실무에선 보통 ID 기반으로 재정의함 → true
hashCode() equals() 기준과 동일하게 재정의 필요
@Override
public boolean equals(Object o) {
    if (this == o) return true;
    if (!(o instanceof Member)) return false;
    return id != null && id.equals(((Member) o).id);
}

@Override
public int hashCode() {
    return Objects.hash(id);
}

//ID가 없는 상태(비영속 상태)에서는 비교 결과가 예측과 다를 수 있으므로 주의!

3) 기본값 타입의 비교 방식 

타입 비교 방식 설명
int, double 등 기본형 == 값 자체 비교. 가장 단순하고 안전
equals() 객체가 아니기 때문에 비교 메서드 존재하지 않음
Integer, Long 등 래퍼 클래스 ==  참조값 비교, 권장X
equals() 값 비교, 안전하고 권장O
String 등 불변 객체 ==  참조값 비교, 상황에 따라 다르게 작동할 수 있어 권장X
.equals() 내부적으로 equals() 잘 구현돼 있어 신뢰 가능O
  • Integer, Long 등 래퍼 클래스의 == 비교 
    • 자바는 -128 ~ 127 범위의 Integer 객체는 캐싱해서 재사용한다.
    • 따라서 -128~127 범위의 Integer 객체 == 비교는 true (같은 객체 캐싱됨)
    • 나머지는 false (매번 새 객체 생성됨)
  • String 등 불변 객체의 == 비교 
상황 == 결과 이유
리터럴 "hello" vs "hello" true 문자열 풀(String pool)에서 같은 객체 참조
new String("hello") vs "hello" false new는 항상 새로운 객체 생성함
new String("hello") vs new String("hello") false 서로 다른 객체 생성됨

4) 복합값 타입 비교

비교 방식 설명
== 참조값 비교 → false (인스턴스 다르면 당연히 false)
equals() 모든 필드 값을 기준으로 재정의 권장 
hashCode() equals()와 같은 필드 기준으로 생성해야 일관성 유지

5) 비교 방식 정리 

타입  권장 비교 방법 
기본값 타입 - 기본형 == 사용
기본값 타입 - 객체형  .equals() 사용 권장 (재정의 필요 없음)
복합값 타입 모든 필드 값 기준으로 비교하도록 equals() 재정의
엔티티 타입 ID 기준으로 비교하도록 equals() 재정의

 

5. 값 타입 컬렉션

1) 사용 방식 & 예제 

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

    protected Address() {}
    public Address(String city, String street, String zipcode) {
        this.city = city;
        this.street = street;
        this.zipcode = zipcode;
    }
}

@Entity
public class Member {
    @Id
    @GeneratedValue
    private Long id;
    private String name;

    @ElementCollection
    @CollectionTable(
        name = "member_address",
        joinColumns = @JoinColumn(name = "member_id")
    )
    private List<Address> homeAddresses = new ArrayList<>();
}
  • @ElementCollection은 JPA에게 “얘는 값 타입이지만 여러 개야!” 라고 알려줌
  • @CollectionTable은 별도 테이블에 저장하라고 지시
  • 테이블 매핑  
member 테이블               | member_address 테이블
------------------------  | -----------------------------------
id | name                 | member_id | city | street | zipcode
------------------------  | -----------------------------------
1  | HJ                   |     1     | 서울   | 강남대로 | 12345
                          |     1     | 부산   | 해운대로 | 67890
                          |     1     | 제주   | 한라산로 | 11111

2) 동작 방식

  • 값 조회: 지연 로딩 (lazy loading) 기본 적용됨 
  • 값 저장: insert 여러 개 나감
  • 값 수정: 기존 데이터 전부 삭제 → 새로 insert (식별자가 없어 변경 추적 불가하기 때문)
  • 값 삭제: 연관된 값들이 부모 엔티티 삭제되면 자동으로 삭제됨 
    @ElementCollection 자체에 CascadeType.ALL + orphanRemoval 같은 효과가 기본 내장돼 있어서 

3) 제약사항

  • 식별자 없어서 변경 추적 어려움 (항상 전체 삭제 후 재삽입)
  • 모든 컬럼이 PK이기 때문에 null 허용 X, 중복 저장 X
  • 성능 이슈 가능성: 양이 많아지면 insert/delete 반복 때문에 부하 발생 가능

4) 실무 대안

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

    private String city;
    private String street;
    private String zipcode;

    @ManyToOne(fetch = LAZY)
    private Member member;
}
  • 위와 같은 제약사항이 있으므로 실무에서는 값 타입 컬렉션 대신 일대다 엔티티 관계로 설계한다. 
  • 이러면 Address도 식별자를 가지므로 수정/추적 가능해지고
  • 변경 이력을 관리하거나, 주소를 독립적으로 처리할 수 있음
  • 따라서 정말 간단한 정보만 보관하여 조회 하고, 수정은 거의 없는 경우에만 값 타입 컬렉션 사용 권장 
    예: 취미 목록(List<String>), 태그(List<String>), 한 줄 메모들