카테고리 없음

[MyBatis] SpringBoot에서 MyBatis와 Enum 클래스를 매핑하기

연유뿌린빙수 2025. 11. 20. 17:56

최근 MyBatis를 기반으로 ORM을 사용하는 계기가 생겨서 작업하게 됐는데, 삽질을 많이 해서 트러블 슈팅에 대하여 기록해보도록 하겠다!


MyBatis란?

SQL 기반의 Persistence Framework다. Entity에 대하여 직접 DB의 컬럼과 매핑하는 JPA와 다르게, MyBatis에서는 Java 클래스가 DB의 정보를 담는 DTO 역할을 하며, SQL을 직접 작성하여 매핑한다.

( 직접 구현하는 쿼리문이 있기 때문에 N+1에 대하여 자동으로막아준다는 장점이 존재하지만
개인적으로는 JPA가 좋다...)


사용한 스택은 다음과 같다.

  • MyBatis
  • DB Flyway : DB 스키마를 작성하고 구성해야 하므로 미리 작성하기로 하였다. 개인적으로는 MyBatis에서 매우 편리하게 사용


생긴오류

나의 경우 Java의 Enum 클래스를 사용하고, DB에서는 VARCHAR로 관리를 하고 있었다.
그런데 이에 대하여 컬럼 매핑을 실패한다고 자꾸 오류가 떴다.


해결 방법

MyBatis에서는 커스텀 타입 변환을 위해 TypeHandler를 생성해야 한다.


우선 Enum 클래스로 회원인 Member의 권한에 대한 MemberRole과 MemberStatus라는 객체를 갖고 있다고 해보자.


다양한 Enum 클래스를 재사용 가능하게 만들기 위하여
Enum에 대한 공통 인터페이스를 생성하였다.

  • CodeEnum.java
public interface CodeEnum {
    String getCode();
}

그리고 이를 구현한 각각의 Enum 클래스를 생성했다.

  • MemberStatus
@Slf4j
public enum MemberStatus implements CodeEnum {
    ACTIVE("ACTIVE"),
    INACTIVE("INACTIVE")
    ;

    private final String code;
    MemberStatus(String code) {
        this.code = code;
    }

    @Override
    public String getCode() {
        return code;
    }
    /*
    DB의 VARCHAR -> Enum 클래스로 변환
     */
    public static MemberStatus fromCode(String code) {

        for (MemberStatus memberStatus : MemberStatus.values()) {
            if (memberStatus.getCode().equals(code)) {
                return memberStatus;
            }
        }
        log.warn("Invalid member status code: {}", code);
        throw GeneralException.of(ErrorCode.ENUM_MAPPED_FAILED);
    }
}
  • MemberRole
@Slf4j
public enum MemberRole implements CodeEnum {
    ROLE_ANONYMOUS("ROLE_ANONYMOUS"),
    ROLE_USER("ROLE_USER"),
    ROLE_ADMIN("ROLE_ADMIN"),
    ;

    private final String code;

    MemberRole(String code) {
        this.code = code;
    }

    @Override
    public String getCode() {
        return code;
    }

    public static MemberRole fromCode(String code) {
        for (MemberRole role : MemberRole.values()) {
            if (role.getCode().equals(code)) {
                return role;
            }
        }
        log.warn("Invalid member role code: {}", code);
        throw GeneralException.of(ErrorCode.ENUM_MAPPED_FAILED);
    }
}

이렇게 작성하였다.
이 때 DB VARCHAR를 enum을 기반으로 작성하고 그 역도 성립해야하기 때문에 fromCode라는 메서드도 구현하였다.

 

이제 MyBatis에서 Enum을 읽고 쓸 때 자동으로 변환할 수 있도록 TypeHandler를 구현하였다.


Java의 Enum ↔ DB의 VARCHAR 양방향 변환을 담당한다.

 

 

  • CodeEnumTypeHandelr
public class CodeEnumTypeHandler<E extends Enum<E> & CodeEnum>
        extends BaseTypeHandler<E> {

    private final Class<E> type;

    /*
    Type에 따른 생성자
     */
    public CodeEnumTypeHandler(Class<E> type) {
        if (type == null){
            LogUtil.error("type이 비어있습니다.");
            throw GeneralException.of(ErrorCode.INTERNAL_SERVER_ERROR);
        }
        this.type = type;
    }

    /*
    Enum -> VARCHAR 변환(Enum.name 기반)
     */
    @Override
    public void setNonNullParameter(PreparedStatement ps, int i,
                                    E parameter, JdbcType jdbcType) throws SQLException {
        ps.setString(i, parameter.getCode());
    }

    /*
    DB의 VARCHAR -> Enum으로 변환
     */
    @Override
    public E getNullableResult(ResultSet rs, String columnName)
            throws SQLException {
        String code = rs.getString(columnName);
        return code == null ? null : fromCode(code);
    }

    @Override
    public E getNullableResult(ResultSet rs, int columnIndex)
            throws SQLException {
        String code = rs.getString(columnIndex);
        return code == null ? null : fromCode(code);
    }

    @Override
    public E getNullableResult(CallableStatement cs, int columnIndex)
            throws SQLException {
        String code = cs.getString(columnIndex);
        return code == null ? null : fromCode(code);
    }

    /*
    각각의 Enum 클래스에 대한 fromCode 메서드 호출
     */
    private E fromCode(String code) {
        try {
            Method method = type.getMethod("fromCode", String.class);
            LogUtil.info("Received code from DB: " + code);
            LogUtil.info("Enum type: " + type.getName());

            for (E enumConstant : type.getEnumConstants()) {
                LogUtil.info("Enum: " + enumConstant.name() + ", Code: " + enumConstant.getCode());
            }

            return (E) method.invoke(null, code);
        } catch (InvocationTargetException e) {
            LogUtil.error("InvocationTargetException occurred", e.getCause());
            throw GeneralException.of(ErrorCode.INTERNAL_SERVER_ERROR);
        } catch (Exception e) {
            LogUtil.error("Exception occurred", e);
            throw GeneralException.of(ErrorCode.INTERNAL_SERVER_ERROR);
        }
    }
}
  • setNonNullParameter
@Override
public void setNonNullParameter(PreparedStatement ps, int i,
                                E parameter, JdbcType jdbcType) throws SQLException {
    ps.setString(i, parameter.getCode());
}

이는 Java의 Enum 을 DB VARCHAR로 변경해준다. Enum의 name에 대하여 변환해준다.

 

 

  • getNullableResult
    select 결과에서 컬럼이름으로 값을 가져오거나, 컬럼 인덱스로 조회하거나, CallableStatement를 통해 조회하는 등 ENUM으로 매핑할 수 있도록 지정하고,
    만약에 지정하지 못하면 null을 반환하도록 설정한다.

이제 이 공통 Enum 핸들러를 기반으로 각 Enum 타입별 핸들러를 작성한다.

 

 

  • MemberStatusTypeHandler
@MappedTypes(MemberStatus.class)
public class MemberStatusTypeHandler extends CodeEnumTypeHandler<MemberStatus> {
    public MemberStatusTypeHandler() {
        super(MemberStatus.class);
    }
}
  • MemberRoleTypeHandler
@MappedTypes(MemberRole.class)
public class MemberRoleTypeHandler extends CodeEnumTypeHandler<MemberRole> {
    public MemberRoleTypeHandler() {
        super(MemberRole.class);
    }
}

확실한 Mapping을 위하여 Mapped Annotation으로 지정을 해주었다.
이렇게 개별에 대한 타입 핸들러를 지정함으로써 매핑에 대하여 실수하는 일이 없도록 작성하였다.

 

 


  • member-mapper.xml
        <result property="memberStatus" column="member_status"
                javaType="com.yachaerang.backend.api.common.MemberStatus"
                typeHandler="com.yachaerang.backend.global.util.MemberStatusTypeHandler"/>

        <result property="role" column="member_role"
                javaType="com.yachaerang.backend.api.common.Role"
                typeHandler="com.yachaerang.backend.global.util.RoleTypeHandler"/>

이렇게 설정하면 MyBatis가 자동으로 Enum과 VARCHAR 간의 변환을 처리하게 된다.

 
 
 


주의해야하는 점 - @Builder 함께 사용 시 생성자 매핑 이슈

이러고도 에러를 겪었었다.

MyBatis를 사용하면서 Lombok의 @Builder 어노테이션과 함께 사용했는데 예상치 못한 에러가 발생했다.

Builder 패턴을 통해 필드 순서 없이 객체를 생성하려고 했다.

@Builder
public class Member {
    private Long id;
    private String name;
    private String email;
    private MemberStatus status;
}

이렇게만 작성하면 Builder 패턴을 쓸 수 있으니 편하겠다 싶었는데...

 

생긴 오류

MyBatis로 SELECT 쿼리를 실행하면 객체 매핑에서 자꾸 에러가 발생했다.

org.apache.ibatis.executor.ExecutorException: 
Error setting property 'name' of 'Member'. 
Could not set property 'name' with value 'test@email.com'

타입 불일치 에러가 뜨거나, 이상한 값이 엉뚱한 필드에 들어가는 현상이 발생했다.

 

원인

1. @Builder가 자동으로 생성하는 것

@Builder만 단독으로 사용하면 Lombok이 모든 필드를 파라미터로 받는 생성자를 자동으로 생성한다.

// Lombok이 자동 생성하는 생성자
public Member(Long id, String name, String email, MemberStatus status) {
    this.id = id;
    this.name = name;
    this.email = email;
    this.status = status;
}

2. MyBatis의 생성자 기반 매핑 방식

MyBatis는 DB 조회 결과를 객체로 매핑할 때 다음 우선순위로 동작한다:

  • 파라미터가 있는 생성자를 발견하면 → 생성자 자동 매핑 방식 사용
  • 기본 생성자가 있으면 → 기본 생성자 + setter 방식 사용

문제는 생성자 자동 매핑 방식에서는 SELECT 문에 작성된 컬럼의 순서가 중요하다는 것이다!

 

 

 

3. 컬럼 순서 매칭 문제

MyBatis는 SELECT 컬럼 순서와 생성자 파라미터 순서를 1:1로 매핑한다.
sql-- 이 순서대로 생성자에 값이 들어감

SELECT name, email, id, status FROM member;
// MyBatis가 이렇게 호출함
new Member(
    "홍길동",           // 1번째 컬럼(name) → 1번째 파라미터(id) ❌ 타입 불일치!
    "test@email.com",  // 2번째 컬럼(email) → 2번째 파라미터(name) ❌ 
    1L,                // 3번째 컬럼(id) → 3번째 파라미터(email) ❌
    ACTIVE             // 4번째 컬럼(status) → 4번째 파라미터(status) ✅
);

순서나 타입이 하나라도 안 맞으면 에러가 발생하거나 이상한 값이 들어간다!

 

해결 방법


방법 1: SELECT 컬럼 순서를 생성자 파라미터와 맞추기

-- 생성자 파라미터 순서: id, name, email, status
SELECT id, name, email, status FROM member;

하지만 이는 언제든 순서를 맞추기가 어렵기 때문에 유지보수에 좋지 못한 코드가 되며,
실수하기가 너무 쉽기 때문에 비 추천한다.

또한 다음과 같이 SELECT * 사용은 불가

 

방법 2: @NoArgsConstructor 추가하기 -> 기본 생성자를 기반으로 작업

@Builder
@NoArgsConstructor  // 기본 생성자 생성
@AllArgsConstructor // @Builder가 필요로 하는 전체 생성자
public class Member {
    private Long id;
    private String name;
    private String email;
    private MemberStatus status;
}

이렇게 기본생성자를 추가하면, MyBatis가 기본 생성자로 빈 객체 생성한다.

ResultMap 또는 자동 매핑으로 setter를 통해 필드 값을 채울 수 있다.

 

이를 기반으로 작업한다면 컬럼 순서에 구애받지 않고 컬럼 이름 기반으로 매핑할 수 있다.

SELECT 컬럼 순서를 신경 쓰지 않아도 되며, select * 사용이 가능하며, 유지보수가 편하다는 장점으로 기본생성자를 사용하는 것을 적극 추천한다!

 

 

 

 


결론적으로! MyBatis와 Lombok을 함께 사용할 때는 반드시 기본 생성자를 포함시켜야 한다!

@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Member {
    // 필드들...
}

이렇게 하면 MyBatis가 기본 생성자를 사용해 객체를 안전하게 매핑할 수 있다.

 

핵심: 개발자는 항상 기본 생성자를 만들어주자! ^^