마이바티스는 PreparedStatement
에 파라미터를 설정하고 ResultSet
에서 값을 가져올 때 TypeHandler
를 사용한다. Java와 DBMS의 데이터 타입을 호환 가능하게 하는 것이다.
ENUM 타입 컬럼을 다룰 때는 기본적으로 EnumTypeHandler
가 사용되지만, 이 핸들러는 상수 값을 그대로 DB에 저장하기 때문에 그대로 사용하기에는 불편함이 있다. DB에 저장하는 값을 상수와 다르게 하고 싶다면 TypeHandler
를 상속한 커스텀 타입 핸들러를 만들면 된다.
다음은 스프링 부트 게시판 만들기에서 Enum 타입을 적용한 과정이다.
게시판 커뮤니티 프로젝트에서 회원 정보(member_profile
)의 학년(Grade
), 권한(authority
) 컬럼은 ENUM 데이터 타입을 적용하기로 했다. 학년이나 권한의 경우 옵션이 잘 변하지 않고 몇 개 없기 때문에 별도 테이블로 분리하기에는 과하다고 생각했다.
member_user
테이블은 사용자의 로그인 아이디(login_name
)만 가지고 있고, member_profile
은 사용자의 닉네임, 프로필 사진 경로, 권한 등 애플리케이션에서 자주 사용되는 정보를 모은 테이블이다. ( 🔗 ERD 참고 블로그 : 회원 가입 및 로그인을 위한 테이블 설계 ).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
으로 저장한다.A
, M
, U
로 저장한다. ('멘토'는 방송대에 있는 시스템이다. 특정 게시판에 대한 권한을 주기 위해 도입했다.) 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
를 사용했다.
public interface CodeEnum {
String getCode();
String getDescription();
}
Enum 클래스를 일관적으로 구현하기 위해 CodeEnum
인터페이스를 만들고 두 개의 Getter를 정의했다.
인터페이스에서 정의한 메서드는 반드시 오버라이드 해야하기 때문에 CodeEnum
인터페이스를 구현하는 Enum 클래스는 code
, description
필드를 갖는다. code
필드는 DB에 저장할 값을, description
필드는 나중에 뷰에서 출력할 값을 가지도록 할 것이다.
참고로 위와 같이 인터페이스로 Getter를 지정하지 않아도 code
필드는 반드시 @Getter
를 갖고 있어야 한다. 마이바티스가 쿼리에 값을 매핑할 때 DB에 저장할 값(code
)을 객체에서 꺼내오기 위해 Getter를 사용하기 때문이다.
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;
}
}
마이바티스는 기본적으로 Enum 클래스의 상수값을 DB에 저장한다. 예를 들어 별도 설정을 하지 않으면 Authority.ADMIN
은 DB에 "ADMIN" 그대로 저장된다. 이 때 마이바티스는 기본 핸들러인 EnumTypeHandler
를 사용한다.
만약 Enum 값을 정수 인덱스로 저장하고 싶다면 마이바티스에서 제공하는 EnumOrdinalTypeHandler
를 사용하도록 설정하면 된다. 인덱스는 상수가 선언된 순서대로 할당된다.
그런데 내가 작성한 Enum 클래스들은 상수 외에 code
, description
값도 가지고 있기 때문에 마이바티스가 제공하는 EnumTypeHandler
나 EnumOrdinalTypeHandler
는 사용할 수 없다.
커스텀 타입 핸들러를 만들어서 마이바티스가 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
을 반환한다.@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
)를 지정했다.
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>
을 설정해주면 된다.
@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에 등록한 뒤, Post
와 Member
가 잘 JOIN 되어 가져와지는 지 테스트해보았다. 잘 동작한다.
Member
객체만 단독으로 INSERT 한 뒤 SELECT 하는 테스트도 잘 동작했다.
Cause: org.h2.jdbc.JdbcSQLIntegrityConstraintViolationException: Check constraint violation: "CONSTRAINT_6C: ";
memberRepository.insert()
를 실행할 때 등록한 커스텀 타입 핸들러가 인식되지 않아서 ENUM 컬럼에 부적절한 값을 INSERT 하게 되어 위와 같은 오류가 발생했다.
MemberMapper.xml
<insert>
에서 parameterType = "Member"
속성을 제거하니 잘 동작한다. PostMapper.xml
<insert>
에서는 parameterType
속성이 있어도 잘 동작하니 의문이긴 하다.