[MyBatis] Enum 타입 매핑하기 (TypeHandler)

dondonee·2024년 4월 23일
0
post-thumbnail
post-custom-banner

Enum 타입 매핑하기

마이바티스는 PreparedStatement에 파라미터를 설정하고 ResultSet에서 값을 가져올 때 TypeHandler를 사용한다. Java와 DBMS의 데이터 타입을 호환 가능하게 하는 것이다.

ENUM 타입 컬럼을 다룰 때는 기본적으로 EnumTypeHandler가 사용되지만, 이 핸들러는 상수 값을 그대로 DB에 저장하기 때문에 그대로 사용하기에는 불편함이 있다. DB에 저장하는 값을 상수와 다르게 하고 싶다면 TypeHandler를 상속한 커스텀 타입 핸들러를 만들면 된다.


다음은 스프링 부트 게시판 만들기에서 Enum 타입을 적용한 과정이다.



1) DB 세팅

ERD

게시판 커뮤니티 프로젝트에서 회원 정보(member_profile)의 학년(Grade), 권한(authority) 컬럼은 ENUM 데이터 타입을 적용하기로 했다. 학년이나 권한의 경우 옵션이 잘 변하지 않고 몇 개 없기 때문에 별도 테이블로 분리하기에는 과하다고 생각했다.


DDL (MySQL)

DROP TABLE member_profile IF EXISTS;
CREATE TABLE member_profile
(
    profile_id  BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
    user_no     BIGINT NOT NULL,
    nickname    VARCHAR(12) NOT NULL,
    image_url   VARCHAR(100) NULL DEFAULT NULL,
    transferred TINYINT NULL DEFAULT NULL,
    grade       ENUM('0', '1', '2', '3', '4', 'graduate'),
    authority   ENUM('admin', 'mentor', 'user'),
    region_id   INT NULL DEFAULT NULL,
    date_joined   DATETIME NOT NULL,
    date_updated DATETIME NULL DEFAULT NULL
);

MySQL을 사용하는 경우 위와 같이 데이터 타입을 지정하는 곳에 ENUM()을 쓰고 괄호 안에 열거형 값을 쓰면 된다. ( 🔗 Datails : MySQL 8.0 Reference Manual - 13.3.5 The ENUM Type )

  • 학년은 재학생일 경우 1 ~ 4, 졸업생일 경우 graduate으로 저장한다.
  • 권한은 관리자, 멘토, 사용자 3가지가 있으며 각각 A, M, U로 저장한다. ('멘토'는 방송대에 있는 시스템이다. 특정 게시판에 대한 권한을 주기 위해 도입했다.)

DDL (H2)

    grade VARCHAR(10) NULL DEFAULT NULL,
    CHECK (grade IN ('1', '2', '3', '4', 'graduate')),
    authority VARCHAR(10) NULL DEFAULT NULL,
    CHECK (authority IN ('A', 'M', 'U'))

H2 데이터베이스는 ENUM 타입을 지원하지 않는다. 따라서 위와 같이 임시로 VARCHAR(10) 타입을 지정해준 뒤 열거형 제약을 위해 CHECK를 사용했다.



2) Enum 클래스 만들기

CodeEnum 인터페이스

public interface CodeEnum {

    String getCode();
    String getDescription();
}

Enum 클래스를 일관적으로 구현하기 위해 CodeEnum 인터페이스를 만들고 두 개의 Getter를 정의했다.

인터페이스에서 정의한 메서드는 반드시 오버라이드 해야하기 때문에 CodeEnum 인터페이스를 구현하는 Enum 클래스는 code, description 필드를 갖는다. code 필드는 DB에 저장할 값을, description 필드는 나중에 뷰에서 출력할 값을 가지도록 할 것이다.

참고로 위와 같이 인터페이스로 Getter를 지정하지 않아도 code 필드는 반드시 @Getter를 갖고 있어야 한다. 마이바티스가 쿼리에 값을 매핑할 때 DB에 저장할 값(code)을 객체에서 꺼내오기 위해 Getter를 사용하기 때문이다.


Grade, Authority 클래스

ENUM 컬럼에 매핑할 Grade, Authority Enum 클래스를 만들어 주고 CodeEnum 인터페이스를 구현하도록 한다.

@Getter
public enum Grade implements CodeEnum {

    GRADE_1("1", "1학년"),
    GRADE_2("2", "2학년"),
    GRADE_3("3", "3학년"),
    GRADE_4("4", "4학년"),
    GRADUATE("graduate", "졸업생");

    private String code;
    private String description;

    Grade(String code, String description) {
        this.code = code;
        this.description = description;
    }
}
@Getter
public enum Authority implements CodeEnum {

    ADMIN("A", "관리자"),
    MENTOR("M", "멘토"),
    USER("U", "사용자");

    private String code;
    private String description;

    Authority(String code, String description) {
        this.code = code;
        this.description = description;
    }
}


3) TypeHandler

마이바티스는 기본적으로 Enum 클래스의 상수값을 DB에 저장한다. 예를 들어 별도 설정을 하지 않으면 Authority.ADMIN은 DB에 "ADMIN" 그대로 저장된다. 이 때 마이바티스는 기본 핸들러인 EnumTypeHandler를 사용한다.

만약 Enum 값을 정수 인덱스로 저장하고 싶다면 마이바티스에서 제공하는 EnumOrdinalTypeHandler를 사용하도록 설정하면 된다. 인덱스는 상수가 선언된 순서대로 할당된다.

  • 하지만 상수 인덱스를 DB에 저장하는 방법은 좋지 않다. 애플리케이션 코드에서도 테이블에서도 직관성이 떨어져서 실수가 일어나기 쉽다. 또한 요즘은 하드웨어 성능이 좋아서 정수로 저장한다고 크게 효율이 좋지도 않다고 한다.

커스텀 TypeHandler 만들기

그런데 내가 작성한 Enum 클래스들은 상수 외에 code, description 값도 가지고 있기 때문에 마이바티스가 제공하는 EnumTypeHandlerEnumOrdinalTypeHandler는 사용할 수 없다.

커스텀 타입 핸들러를 만들어서 마이바티스가 Enum 객체를 DB에 저장할 때는 상수의 code 값을 저장하고, 반대로 DB에서 값을 가져올 때는 code 값을 통해 대응하는 상수를 찾아 Enum 객체를 반환할 수 있도록 해주어야 한다.

커스텀 타입 핸들러는 🔗 Spring Boot에서 myBatis의 TypeHandler와 Enum 관리하기를 참고했다.


public abstract class CodeEnumTypeHandler<E extends Enum<E>> implements TypeHandler<CodeEnum> {

    private Class<E> type;

    public CodeEnumTypeHandler(Class<E> type) {
        this.type = type;
    }


    @Override
    public void setParameter(PreparedStatement ps, int i, CodeEnum parameter, JdbcType jdbcType) throws SQLException {
        ps.setString(i, parameter.getCode());
    }

    @Override
    public CodeEnum getResult(ResultSet rs, String columnName) throws SQLException {
        String code = rs.getString(columnName);
        return getCodeEnum(code);
    }

    @Override
    public CodeEnum getResult(ResultSet rs, int columnIndex) throws SQLException {
        String code = rs.getString(columnIndex);
        return getCodeEnum(code);
    }

    @Override
    public CodeEnum getResult(CallableStatement cs, int columnIndex) throws SQLException {
        String code = cs.getString(columnIndex);
        return getCodeEnum(code);
    }

    private CodeEnum getCodeEnum(String code) {
        try {
            CodeEnum[] values = (CodeEnum[]) type.getEnumConstants();
            for (CodeEnum value : values) {
                if (value.getCode().equals(code)) {
                    return value;
                }
            }
            return null;
        } catch (Exception e) {
            throw new TypeException("Cannot convert " + code + " to " + type.getSimpleName());
        }
    }
}
  • private Class<E> type : 타입 핸들러가 다룰 Enum 클래스의 종류(Grade.class, Authority.class)를 저장한다. 마이바티스가 DB에서 값을 꺼낸 뒤 code에 상응하는 Enum 클래스(type)를 반환하기 위해서이다.
  • setParameter() : 마이바티스가 PreparedStatement에 Enum 값을 세팅할 때 호출된다. 파라미터로 전달받은 CodeEnum에서 code 값을 꺼내 setString으로 파라미터 바인딩을 해 준다.
  • getResult() : 컬럼값(code)을 꺼내와서 Enum으로 반환하기 위해 호출된다. getCodeEnum 메서드를 만들어서 문자열 code를 적절한 타입의 Enum으로 반환하는 작업을 해 준다.
  • getCodeEnum() : type(Enum 클래스)에서 모든 상수 값을 가지고 와서 code와 일치하는 상수가 있는지 찾는다. 존재하는 경우 찾은 Enum을 반환하고 없다면 null을 반환한다. 이 메서드에서 오류가 발생하는 경우 마이바티스 오류인 TypeException을 반환한다.


커스텀 TypeHandler 등록

@Getter
public enum Grade implements CodeEnum {

	//...

    @MappedTypes(Grade.class)
    public static class TypeHandler extends CodeEnumTypeHandler<Grade> {
        public TypeHandler() {
            super(Grade.class);
        }
    }
}

Enum 클래스 내부에 CodeEnumTypeHandler를 상속받은 타입 핸들러를 static으로 생성해준다(Enum 상수는 static final). @MappedTypes 애노테이션에 매핑할 Enum 클래스를 설정해주고, 내부 생성자 파라미터로 Enum 클래스(Grade.class)를 전달해준다.


@Getter
public enum Authority implements CodeEnum {

	//...
    
        @MappedTypes(Authority.class)
    public static class TypeHandler extends CodeEnumTypeHandler<Authority> {
        public TypeHandler() {
            super(Authority.class);
        }
    }
}

Authority에도 똑같이 타입 핸들러를 추가해준다.


애플리케이션 설정

mybatis:
  type-handlers-package: com.knou.board.domain

마이바티스가 커스텀 타입 핸들러를 찾을 수 있도록 애플리케이션 설정 파일(application.yml)에 타입 핸들러가 위치한 패키지를 지정해준다. 나는 도메인 객체 내부에 타입 핸들러를 생성했으므로 도메인 객체들이 위치한 패키지(domain)를 지정했다.



4) Mapper

Enum 타입을 매핑하기 위해서는 resultMap을 사용해야 한다. <result> 태그의 typehandler 속성을 지정해준다. 타입 핸들러의 전체 패키지를 지정해주어야 한다.


<mapper namespace="com.knou.board.repository.mybatis.PostMapper">

    <resultMap id="memberMap" type="Member">
        <result property="userNo" column="user_no"/>
        <result property="profileId" column="profile_id"/>
        <result property="nickname" column="nickname"/>
        <result property="imageUrl" column="image_url" />
        <result property="transferred" column="transferred" />
        <result property="grade" column="grade" typeHandler="com.knou.board.domain.member.Grade$TypeHandler" />
        <result property="authority" column="authority" typeHandler="com.knou.board.domain.member.Authority$TypeHandler" />
        <result property="regionId" column="region_id" />
        <result property="dateJoined" column="date_joined" />
        <result property="dateUpdated" column="date_updated" />
    </resultMap>
    <resultMap id="postMap" type="Post">
        <id property="id" column="post_id"/>
        <result property="categoryId" column="category_id"/>
        <result property="title" column="title"/>
        <result property="content" column="content"/>
        <result property="dateCreated" column="date_created"/>
        <result property="dateModified" column="date_modified"/>
        <result property="viewCount" column="view_count"/>
        <collection property="author" resultMap="memberMap"/>
    </resultMap>

    <insert id="insert" useGeneratedKeys="true" keyProperty = "id" parameterType="Post">
        INSERT INTO post (category_id, author_id, title, content, date_created, view_count)
            VALUES (#{categoryId}, #{author.userNo}, #{title}, #{content}, #{dateCreated}, #{viewCount})
    </insert>
    
    <select id="selectById" resultMap="postMap">
        SELECT * FROM post
            JOIN member_profile ON post.author_id = member_profile.user_no
        WHERE post.post_id = #{id}
    </select>
</mapper>

위 매퍼는 PostMapper.xml이다. MemberMapper.xml에서도 마찬가지로 <resultMap>을 설정해주면 된다.



5) 테스트

@Slf4j
@Transactional
@SpringBootTest
@Sql(scripts = "classpath:/testInit.sql")
public class MyBatisPostRepositoryTest {

    @Autowired
    PostRepository postRepository;
    @Autowired
    MemberRepository memberRepository;

    @Test
    void insert() {
        //given
        Member member = new Member(1L, "홍길동", false, Grade.GRADE_1, 1);
        memberRepository.insert(member);
        Post post = new Post(1L, member, "title", "content");

        //when
        Post insertPost = postRepository.insert(post);

        //then
        Post selectPost = postRepository.selectById(insertPost.getId());
        assertThat(selectPost).isEqualTo(insertPost);
    }
}

Member를 필드로 갖는 Post 객체를 생성하고 DB에 등록한 뒤, PostMember가 잘 JOIN 되어 가져와지는 지 테스트해보았다. 잘 동작한다.

Member 객체만 단독으로 INSERT 한 뒤 SELECT 하는 테스트도 잘 동작했다.


(debug) 타입 핸들러 인식 오류

Cause: org.h2.jdbc.JdbcSQLIntegrityConstraintViolationException: Check constraint violation: "CONSTRAINT_6C: ";

memberRepository.insert()를 실행할 때 등록한 커스텀 타입 핸들러가 인식되지 않아서 ENUM 컬럼에 부적절한 값을 INSERT 하게 되어 위와 같은 오류가 발생했다.

MemberMapper.xml <insert>에서 parameterType = "Member" 속성을 제거하니 잘 동작한다. PostMapper.xml <insert>에서는 parameterType 속성이 있어도 잘 동작하니 의문이긴 하다.



---

🔗 References

post-custom-banner

0개의 댓글