JPA 아둥바둥 — ElementCollection 과 중첩된 Embedded, 그리고 AccessType 이슈
JPA 아둥바둥 시리즈는 10년동안 DB 만 해왔던 사람이 JPA 를 공부하고 적용해오면서 겪었던 이슈, 그리고 해결 방안을 정리해놓은 포스트들입니다.
모든 이슈는 핵심적인 부분만 코드로 구현해서 Github 에 올라가 있습니다. Issue 브랜치 로 이슈 상황을 재현할 수 있으며, Solution 브랜치 에서 해결 방안을 확인할 수 있습니다.
이 포스트는 src/main/java/com/example/jpa/issues/issue01
아래 있는 내용입니다.
배경
아래와 같이 엔티티가 모델링되어 있습니다.
간단히 위의 클래스들을 설명해보자면 이렇습니다.
Payment
클래스는 결제 요청 자체에 관한 정보를 가지고 있습니다. 1건의 결제 요청은 여러 결제 수단을 포함할 수 있습니다. 간단히 말하자면, 복합 결제가 가능합니다.PaymentDetail
클래스는 1건의 결제 요청에서 여러 결제 수단에 관한 정보를 가지고 있습니다. 그렇기 때문에Payment
와PaymentDetail
은 1:N 의 관계를 가집니다.Payment
클래스와PaymentDetail
클래스는 데이터 수명주기가 동일합니다. 항상 같이 생성되고 항상 같이 저장되고 수정됩니다. 그렇기 때문에@OneToMany
와 같은 엔티티 관계를 맺는 것이 아니라 값객체로서 모델링되었고,@ElementCollection
을 통해 관계를 맺습니다.TxResult
는PaymentDetail
에 있는 각 결제 수단을 처리하면서 생기는 결과 코드와 결과 메세지 정보를 가지고 있습니다.
결과적으로 1개의 엔티티만 존재하며, 나머지는 모두 값객체입니다.
이슈
TxResult
클래스에서 @Column
을 통해 컬럼명을 지정했음에도 불구하고, 이 명령이 무시됩니다. 세 가지 클래스의 정의를 보면 아래와 같습니다.
@Entity
@Table(name = "payment", catalog = "issue01")
public class Payment {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "pay_id")
private Long id;
@Column(name = "requested_at")
private LocalDateTime requestedAt;
@ElementCollection
@CollectionTable(
name = "payment_details",
catalog = "issue01",
joinColumns = @JoinColumn(name = "pay_id"),
foreignKey = @ForeignKey(value = ConstraintMode.NO_CONSTRAINT)
)
@OrderColumn(name = "pay_order")
List<PaymentDetail> details = new ArrayList<>();
}@Embeddable
public class PaymentDetail {
@Column(name = "pay_method")
private String method;
@Column(name = "pay_amount")
private Integer amount;
@Embedded
private TxResult txResult;
}@Embeddable
@Getter
@Setter
public class TxResult {
@Column(name = "result_code")
private String code;
@Column(name = "result_message")
private String message;
}
TxResult
클래스의 필드들은 payment_details
라는 테이블의 컬럼으로 들어가게 됩니다. 이 상태로 DDL 을 자동 생성 및 실행하게 한 뒤 테이블 정의를 확인해보면, result_code
컬럼 대신 code
컬럼이, result_message
컬럼 대신 message
컬럼이 있습니다. 뭔가 이상합니다.
show create table payment_details;
CREATE TABLE `payment_details` (
`pay_id` bigint NOT NULL,
`pay_amount` int DEFAULT NULL,
`pay_method` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`code` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`message` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`pay_order` int NOT NULL,
PRIMARY KEY (`pay_id`,`pay_order`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
왜 @Column
에 정의된 name
속성을 무시하고 멤버변수명 그대로 컬럼이 만들어질까요?
분석
약간의 찍기와 노가다를 통해 이상한 점 하나를 발견할 수 있습니다. TxResult
클래스에서 @Getter
, @Setter
를 제거하고 실행하면 아래와 같은 에러가 발생합니다.
Caused by: org.hibernate.MappingException: Could not instantiate collection persister org.hibernate.persister.collection.BasicCollectionPersister
이 상황을 통해 유추해보면, JPA 가 테이블 컬럼 정보를 생성할 때 필드로 접근하는게 아니라 프로퍼티로 접근하는게 아닐까? 하는 의구심이 듭니다. 즉, TxResult
클래스에 @Access(AccessType.PROPERTY)
로서 데이터를 접근하는 것 같습니다.
우선 가설 검증을 위해 Getter, Setter 를 다른 이름으로 아래와 같이 만들어서 TxResult
클래스를 바꿔보았습니다.
@Embeddable
public class TxResult {
@Column(name = "result_code")
private String code;
@Column(name = "result_message")
private String message;
public String getResultCode() { return code; }
public void setResultCode(String code) { this.code = code; }
public String getResultMessage() { return message; }
public void setResultMessage(String message) { this.message = message; }
}
그리고 Spring Boot 를 실행해서 변경된 payment_details
테이블 정의를 보면 아래와 같습니다.
show create table payment_details;
CREATE TABLE `payment_details` (
`pay_id` bigint NOT NULL,
`pay_amount` int DEFAULT NULL,
`pay_method` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`result_test_code` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`result_test_message` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`pay_order` int NOT NULL,
PRIMARY KEY (`pay_id`,`pay_order`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
앗! 컬럼명이 새로 추가한 Getter, Setter 의 이름으로 만들어진 것을 알 수 있습니다. result_test_code
와 result_test_message
로 변경되었습니다. 가설이 맞는 것 같습니다.
AccessType
의 기본값은 @Id
의 위치를 기준으로 설정됩니다. 이 코드 예제와 같은 경우 Payment
클래스에서 @Id
를 필드에 정의했고, 그렇기 때문에 기본값은 AccessType.FIELD
입니다. PaymentDetail
클래스까지도 별도의 Getter 와 Setter 없이 잘 동작한 것을 보면 AccessType
의 기본값이 AccessType.FIELD
로 설정되었고, PaymentDetail
클래스까지는 잘 상속된 것 같습니다.
문제는 이게 왜 TxResult
클래스까지는 상속되지 않느냐는 것입니다. 그리고 이건.. 아무리 구글링하고 책을 뒤져봐도 잘 모르겠습니다.
혹시 누가 이유를 아는 사람이 있다면 알려주세요! 아니면 🪲?
@Embedded
가 중첩되면 원래 그런 것인가 해서 PaymentDetail
클래스를 @ElementCollection
으로 연결하지 않고 단순히 @Embedded
로 연결해보았습니다. 이 경우에는 TxResult
클래스에 Getter, Setter 없이 의도한 대로 정상 동작합니다.
즉, @ElementCollection
이 중간에 낀 경우, 그리고 그 후에 @Embedded
로 클래스가 중첩된 경우에만 기본 AccessType
이 프로퍼티로 변경되는 것으로 추측합니다.
해결 방안
해결 방안은 아래와 같이 TxResult
클래스에서 강제로 클래스의 기본 AccessType
을 필드로 변경해주면 됩니다. 이렇게 하면 TxResult
클래스의 테이블 컬럼을 만들 때 필드를 참고해서 만들게 되고, @Column
의 name
속성으로 컬럼명이 잘 생성됩니다.
@Embeddable
@Access(AccessType.FIELD)
@Getter
@Setter
public class TxResult {
@Column(name = "result_code")
private String code;
@Column(name = "result_message")
private String message;
}