[SpringBoot] JPA에서 Enum 값 다루기(AttributeConverter)
모델에 Enum값을 정의하고 싶을 때가 있다. 예를 들어 상태가 될 수도 있고, 타입일수도 있다.
예를 들어, Challenge라는 Entity가 있고, status 값은 ChallengeStatus라는 Enum 타입이다.
@Entity
@NoArgsConstructor
public class Challenge {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column
private String name;
@Column
private String goal;
@Column
private ChallengeStatus status;
public Challenge(String name, String goal) {
this.name = name;
this.goal = goal;
this.status = ChallengeStatus.READY;
}
public void open() {
this.status = ChallengeStatus.OPENED;
}
public void close() {
this.status = ChallengeStatus.CLOSED;
}
public Long getId() {
return id;
}
public String getName() {
return name;
}
public String getGoal() {
return goal;
}
public ChallengeStatus getStatus() {
return status;
}
}
public enum ChallengeStatus {
READY,
OPENED,
CLOSED
}
이렇게 설정하면 어떤 타입의 컬럼이 세팅될까? 테스트를 위해 ddl-auto: create-drop
으로 설정하고 애플리케이션을 실행시켜보았다.
create table Challenge (
status tinyint check (status between 0 and 2),
id bigint generated by default as identity,
goal varchar(255),
name varchar(255),
primary key (id)
)
ORDINARY 방식
DDL로 확인할 수 있듯이 기본적으로는 ENUM 값 순서대로 저장된다.
READY는 0, OPENED은 1, CLOSED은 2이다.
public enum ChallengeStatus {
READY, // 0
OPENED, // 1
CLOSED // 2
}
이렇게 하면 실제 테이블의 데이터를 확인했을때 0이 READY인지 한 번 더 체크해야하는 단점이 있다.
문자열 그대로 저장
그럼 ENUM값 그대로 저장하면 안되는걸까?
@Enumerated(EnumType.STRING)
어노테이션을 사용하면 문자열로 저장된다.
@Enumerated(EnumType.STRING)
private ChallengeStatus status;
create table Challenge (
id bigint generated by default as identity,
goal varchar(255),
name varchar(255),
status enum ('CLOSED','OPENED','READY'),
primary key (id)
)
이렇게 데이터가 저장된다면 조금 더 직관적으로 데이터를 확인할 수 있다.
@Enumerated(EnumType.ORDINAL)
의 문제점
만약 애플리케이션 코드에는 Enum을 쓰지만, DB에 저장할때는 다른 값을 저장하게 할 순 없을까?
예를 들어, 개발팀 컨벤션으로 active 상태는 1이상, deleted 상태는 0으로 개발을 해야하는 상황이 있다 가정해보자.
간단하게는 EnumType.ORDINAL
를 활용할 수 있다.
@Entity
@NoArgsConstructor
public class Schedule {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column
private String title;
@Column
private LocalDateTime startedAt;
@Column
private LocalDateTime endedAt;
@Enumerated(EnumType.ORDINAL)
private ScheduleStatus status;
}
public enum ScheduleStatus {
DELETED,
ACTIVE
}
이렇게 하면 DELETED는 0, ACTIVE는 1로 표현할 수 있을것이다.
하지만 새로운 상태가 추가된다면 어떨까? PAUSED, CLOSED 두 가지 상태를 추가하였다. 자연스레 PAUSED는 2, CLOSED는 3의 값을 갖게 될 것이다.
public enum ScheduleStatus {
DELETED,
ACTIVE,
PAUSED,
CLOSED
}
하지만 추후에 ScheduleStatus는 PAUSED, CLOSED 중간 상태가 포함될 수 있어 CLOSED 값을 넉넉하게 9로 설정하고 싶을 수도 있다.
EnumType.ORDINAL
를 사용하는 경우, 중간에 enum값의 순서를 변경할 수 없고, 바꾸려고 하면 잘못된 데이터를 저장할 가능성이 높다.
AttributeConverter
위의 예시처럼, JPA를 사용할때 엔티티 속성의 값과, 실제로 DB에 저장하고자 하는 값이 다를 때는 AttributeConverter 인터페이스를 사용하면 된다.
public class ScheduleStatusConverter implements AttributeConverter<ScheduleStatus, Integer> {
@Override
public Integer convertToDatabaseColumn(ScheduleStatus attribute) {
int dbData = switch (attribute) {
case DELETED -> 0;
case ACTIVE -> 1;
case PAUSED -> 5;
case CLOSED -> 9;
};
return dbData;
}
@Override
public ScheduleStatus convertToEntityAttribute(Integer dbData) {
ScheduleStatus status = switch (dbData) {
case 0 -> ScheduleStatus.DELETED;
case 1 -> ScheduleStatus.ACTIVE;
case 5 -> ScheduleStatus.PAUSED;
case 9 -> ScheduleStatus.CLOSED;
};
return status;
}
}
AttrivuteConverter 인터페이스는 두가지 메소드를 구현해야 한다.
- convertToDatabaseColumn: 엔티티 속성을 데이터베이스에 저장할 값의 타입으로 변환한다.
- convertToEntityAttribute: 데이터베이스에 저장된 값을 엔티티 속성 타입으로 변환한다.
이렇게 구현한 Converter는 직접 엔티티 속성값에 @Convert
어노테이션으로 선언해주면 된다.
@Convert(converter=ScheduleStatusConverter.class)
private ScheduleStatus status;
하지만 이 방법은 도메인 로직에서 구체적인 컨버터에 의존하는 것이므로, Converter에 @Converter
어노테이션을 붙여주고 autoApply
옵션을 사용해주는 것이 좋다.
그렇게 하면, 해당 타입을 사용하는 엔티티 속성들에는 자동으로 Converter를 적용해준다.
// @Convert(converter=ScheduleStatusConverter.class) 자동으로 적용됨
private ScheduleStatus status;
@Converter(autoApply = true)
public class ScheduleStatusConverter implements AttributeConverter<ScheduleStatus, Integer> {
@Override
public Integer convertToDatabaseColumn(ScheduleStatus attribute) {
...
}
@Override
public ScheduleStatus convertToEntityAttribute(Integer dbData) {
...
}
}
리팩토링 하기
위의 예시의 Converter는 개발자가 직접 DELETED는 0이고, ACTIVE는 1이고.. 이런식으로 코드를 작성해, 새로운 상태가 추가되었을때 실수할 확률이 높다.
그래서 보통 Enum에 value를 추가해서 직접 parse할 수 있도록 개발한다.
public enum ScheduleStatus {
DELETED(0),
ACTIVE(1),
PAUSED(5),
CLOSED(9);
private final int value;
ScheduleStatus(int value) {
this.value = value;
}
public int getValue() {
return value;
}
public static ScheduleStatus parse(int value) {
return Arrays.stream(values())
.filter(s -> s.value == value)
.findFirst()
.orElse(ScheduleStatus.ACTIVE);
}
}
@Converter(autoApply = true)
public class ScheduleStatusConverter implements AttributeConverter<ScheduleStatus, Integer> {
@Override
public Integer convertToDatabaseColumn(ScheduleStatus attribute) {
return attribute.getValue();
}
@Override
public ScheduleStatus convertToEntityAttribute(Integer dbData) {
return ScheduleStatus.parse(dbData);
}
}
AttributeConverter는 Enum이 아닌, 다른 타입을 사용할때도 활용할 수 있으므로 잘 사용해보자.