본문 바로가기
개발 - Coding/Java & Spring

[JPA] 컴파일시에는 발견할 수 없는 @Enumerated와 @Embeddable 오류

by dev_jinyeong 2023. 6. 1.

문제 상황

JPA 환경에서 Enum 타입에 실수로 @Embeddable 또는 @Embedded Annotation을 적용했을 때 생기는 오류에 대해서 알아보겠습니다.

이 문제는 @Enumerated가 적용된 Enum에 @Embeddable이나 @Embedded를 사용하기 때문에 발생합니다.

Java의 Enum과 JPA에서의 Enum

Java에는 Enum 타입이 있고 이를 이용해서 더 명시적이고 의미론적인 코드를 짤 수 있습니다.

예를 들어 다음과 같이 활용할 수 있습니다.

Enum을 적용하여 코드를 개선하는 예제

먼저 단순하게 String을 이용하여 멤버십 레벨을 다루는 코드입니다.

package com.example.EnumTest;

import lombok.*;

import javax.persistence.*;

@Entity
@Getter @Setter // 예제 단순화를 위해 Getter, Setter 설정
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Member {

    @Id @GeneratedValue
    private Long id;

    private String name;

    private int age;

    private String memberLevel;
}


// Member의 멤버십 레벨을 조정하는 비즈니스 코드 예시

member.setMemberLevel("bronze");

위의 코드의 문제점은 다음과 같습니다.

  1. memberLevel이 가질 수 있는 값을 코드만 보고 알 수 없습니다. -> 가독성 저하
  2. memberLevel이 String이기 때문에 아무 값이나 가질 수 있습니다. -> 안정성 저하
  3. memberLevel을 변경할 때마다 값을 하드코딩해야 합니다. -> 코드 작성 효율성 저하, 안정성 저하

위의 코드는 Enum을 이용해서 코드를 개선할 수 있습니다.

package com.example.EnumTest;

public enum MemberLevel {
    BRONZE, SILVER, GOLD
}
package com.example.EnumTest;

import lombok.*;

import javax.persistence.*;

@Entity
@Getter @Setter // 예제 단순화를 위해 Getter, Setter 설정
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Member {

    @Id @GeneratedValue
    private Long id;

    private String name;

    private int age;

    @Enumerated(EnumType.STRING)
    private MemberLevel level;
}

// member의 멤버십 레벨을 변경하는 비즈니스 로직 예시

member.setLevel(MemberLevel.BRONZE);

단 JPA에서 엔티티가 Enum 값을 가질 때 @Enumerated Annotation을 이용해야 합니다.

@Enumerated는 JPA에서 Enum 값을 핸들링할 수 있도록 명시하는 도구입니다.

@Enumerated와 @Embeddable 오류

주의할 점은 @Enumerated와 @Embeddable/@Embedded를 함께 쓰면 오류가 발생한다는 것입니다.

JPA를 잘 아는 경우에는 두 Annotation을 같이 쓰는 일이 없겠지만, 제 경우에는 Entity에 외부 클래스 값을 입력한다는 개념에서 혼동해서 같이 쓰게 되었고 오류를 발견하게 되었습니다.

예를 들어서 다음과 같이 코드를 작성해볼 수 있습니다.

package com.example.EnumTest;

import lombok.*;

import javax.persistence.*;

@Entity
@Getter @Setter // 예제 단순화를 위해 Getter, Setter 설정
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Member {

    @Id @GeneratedValue
    private Long id;

    private String name;

    private int age;

    @Enumerated(EnumType.STRING)
    @Embedded
    private MemberLevel level;
}
package com.example.EnumTest;

import javax.persistence.Embeddable;

@Embeddable
public enum MemberLevel {
    BRONZE, SILVER, GOLD
}

@Embeddable, @Embedded 어느 쪽을 사용해도 효과는 같습니다.

@Enumerated와 @Embeddable/@Embedded가 함께 사용될 경우 Enum 값이 누락됩니다.

누락되는 Enum값 확인

@Embeddable/@Embedded를 사용하지 않았을 경우 level을 제대로 출력하는 것을 확인할 수 있습니다.

그러나 @Enumerated와 @Embeddable/@Embedded를 같이 사용할 경우 열이 누락됩니다.

이 오류가 곤란한 이유는 컴파일 시에는 오류를 알 수 없기 때문입니다.

@Enumerated와 @Embeddable/@Embedded가 함께 쓰일 경우가 없습니다.

그러나 JPA에서는 이러한 상황을 오류를 검출하지 않고 값을 누락하고 진행합니다.

일반적으로 Production, Staging 환경에서는 Table을 모두 생성한 이후에 DB와 연결되기 때문에 컴파일시 무조건 에러가 발생합니다.

그러나 테스트 환경에서는 ddl-auto 옵션을 create나 create-drop으로 놓고 하는 경우가 많기 때문에 테스트 환경에서는 테이블 생성까지는 문제없이 컴파일 될 가능성이 있습니다.

원인 분석

이러한 오류는 @Enumerated와 @Embeddable/@Embedded가 사용되는 상황이 다르기 때문에 생깁니다.

@Enumerated는 JPA에서 기본값 타입을 핸들링하기 위한 기능이고 @Embeddable/@Embedded는 복합 값 타입을 핸들링하기 위한 기능입니다.

따라서 @Enumerated는 Enum type을, @Embeddable/@Embedded는 Class를 핸들링합니다.

JPA에게 어떤 값이 기본 값 타입이면서 복합 값 타입이라고 선언한 것입니다.

DB에서 Enum type은 숫자로 표현되거나 문자열로 표현됩니다. (EnumType.Ordinal, EnumType.String)

이러한 값은 기본값 타입이고 @Embeddable/@Embedded는 복합 값 타입입니다.

이 경우 JPA는 오류를 반환하는 대신 예측하지 못한 방식으로 작동합니다.

그것은 그 값을 아예 배제하는 것입니다.

결론

이러한 문제는 단순히 @Enumerated와 @Embeddable/@Embedded를 함께 사용하지 않는 것으로 해결됩니다.

애노테이션 프로세서를 이용해서 두 애노테이션이 조합될 수 없도록 설정할 수도 있으나 같이 사용하지 않는다는 단순한 해결책에 비해서 너무 비용이 커집니다.

해결책은 간단하지만 컴파일 단계에서 감지되지 않는다는 점, 테스트를 꼼꼼히 작성하지 않을 경우 예상치 못한 동작을 검증하지 못할 수 있다는 점에서 글을 남겨봅니다.