9. 값 타입
2025. 6. 19. 15:02ㆍSpring/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>), 한 줄 메모들