JPA 아둥바둥 — ElementCollection 과 중첩된 Embedded, 그리고 AccessType 이슈

Dongkyun
9 min readJul 15, 2021

JPA 아둥바둥 시리즈는 10년동안 DB 만 해왔던 사람이 JPA 를 공부하고 적용해오면서 겪었던 이슈, 그리고 해결 방안을 정리해놓은 포스트들입니다.

모든 이슈는 핵심적인 부분만 코드로 구현해서 Github 에 올라가 있습니다. Issue 브랜치 로 이슈 상황을 재현할 수 있으며, Solution 브랜치 에서 해결 방안을 확인할 수 있습니다.

이 포스트는 src/main/java/com/example/jpa/issues/issue01 아래 있는 내용입니다.

배경

아래와 같이 엔티티가 모델링되어 있습니다.

엔티티 다이어그램

간단히 위의 클래스들을 설명해보자면 이렇습니다.

  • Payment 클래스는 결제 요청 자체에 관한 정보를 가지고 있습니다. 1건의 결제 요청은 여러 결제 수단을 포함할 수 있습니다. 간단히 말하자면, 복합 결제가 가능합니다.
  • PaymentDetail 클래스는 1건의 결제 요청에서 여러 결제 수단에 관한 정보를 가지고 있습니다. 그렇기 때문에 PaymentPaymentDetail 은 1:N 의 관계를 가집니다.
  • Payment 클래스와 PaymentDetail 클래스는 데이터 수명주기가 동일합니다. 항상 같이 생성되고 항상 같이 저장되고 수정됩니다. 그렇기 때문에 @OneToMany 와 같은 엔티티 관계를 맺는 것이 아니라 값객체로서 모델링되었고, @ElementCollection 을 통해 관계를 맺습니다.
  • TxResultPaymentDetail 에 있는 각 결제 수단을 처리하면서 생기는 결과 코드와 결과 메세지 정보를 가지고 있습니다.

결과적으로 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_coderesult_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 클래스의 테이블 컬럼을 만들 때 필드를 참고해서 만들게 되고, @Columnname 속성으로 컬럼명이 잘 생성됩니다.

@Embeddable
@Access(AccessType.FIELD)
@Getter
@Setter
public class TxResult {

@Column(name = "result_code")
private String code;

@Column(name = "result_message")
private String message;
}

--

--