이번에 프로젝트 설계부터 개발까지 모두 진행하게 되었다. 설계할 때, 중요한 요소 중 하나인 코드컨벤션을 작성하고 있는데, 4개의 프로젝트를 진행하면서 가장 규격을 세우고 싶었던 게 바로 MyBatis에서 Entity 매핑할 때 적합한 어노테이션과 생성자를 정의하는 것이였다. 왜냐면 많은 사람들이 너무나도 다르게 Entity를 선언하고 있었다. 누구는 @Data를 쓰고 누구는 @AllArgsConstructor를 쓰고 이러다 보니 유지보수할 때, 어떤 방식을 따라가야 할지? 어떤 게 적합한지? 의문이 들었다.
그래서 오늘은 MyBatis에 활용하기 위한 Entity를 선언할 때, 어떤 방식이 적합할지? 한번 알아보자!
MyBatis는 Entity 매핑을 어떻게 하는가?
어떤 방식이 적합한지 파악하기 앞서, MyBatis에서는 어떻게 조회 결과를 Entity에 매핑하는지 알아보자.
디버깅모드를 통해 Select 동작 이후의 흐름을 따라가보았다. 그러던 중, 객체를 매핑하는 함수를 찾게 되었다.
경로는 DefaultResultSetHandler.class의 createResultObject method이다.
private Object createResultObject(ResultSetWrapper rsw, ResultMap resultMap, List<Class<?>> constructorArgTypes, List<Object> constructorArgs, String columnPrefix) throws SQLException {
Class<?> resultType = resultMap.getType();
MetaClass metaType = MetaClass.forClass(resultType, this.reflectorFactory);
List<ResultMapping> constructorMappings = resultMap.getConstructorResultMappings();
if (this.hasTypeHandlerForResultObject(rsw, resultType)) {
return this.createPrimitiveResultObject(rsw, resultMap, columnPrefix);
} else if (!constructorMappings.isEmpty()) {
return this.createParameterizedResultObject(rsw, resultType, constructorMappings, constructorArgTypes, constructorArgs, columnPrefix);
} else if (!resultType.isInterface() && !metaType.hasDefaultConstructor()) {
if (this.shouldApplyAutomaticMappings(resultMap, false)) {
return this.createByConstructorSignature(rsw, resultMap, columnPrefix, resultType, constructorArgTypes, constructorArgs);
} else {
throw new ExecutorException("Do not know how to create an instance of " + resultType);
}
} else {
return this.objectFactory.create(resultType);
}
}
위 함수의 조건문을 하나하나씩 파헤쳐보면서 어떻게 조회 결과와 객체를 매핑하는지 살펴보자!
👉 Step 1. if (this.hasTypeHandlerForResultObject(rsw, resultType))
private boolean hasTypeHandlerForResultObject(ResultSetWrapper rsw, Class<?> resultType) {
return rsw.getColumnNames().size() == 1
? this.typeHandlerRegistry.hasTypeHandler(resultType, rsw.getJdbcType((String)rsw.getColumnNames().get(0)))
: this.typeHandlerRegistry.hasTypeHandler(resultType);
}
boolean 함수를 파악해보면 조회 결과 컬럼이 하나인 경우, result type을 확인하고 변환할 수 있다면 true를 반환한다. 이에 해당하는 result type은 Primitive Types(int, long, boolean, ...), Wrapper Classes(String, Long, Boolean, ...)이 될 것이다.
(* 생성자에 따른 객체 매핑과는 관계없으므로 알고만 넘어가면 될 것 같다.)
👉 Step 2. else if (!constructorMappings.isEmpty())
<resultMap id="productMap" type="com.example.demo.model.Product">
<constructor>
<arg column="product_id" javaType="long"/>
<arg column="product_name" javaType="String"/>
</constructor>
</resultMap>
<select id="selectProductById" resultMap="productMap">
SELECT quantity
, product_name
FROM products
WHERE product_id = #{productId}
</select>
@Select("SELECT product_id, product_name FROM products WHERE product_id = #{productId}")
@ConstructorArgs({
@Arg(column = "product_id", javaType = long.class),
@Arg(column = "product_name", javaType = String.class)
})
Product selectProductById(long productId);
해당 조건문은 xml을 작성할 때, resultType이 아니라 resultMap을 작성하는 분들에게는 와닿는 내용일 것이다. resultMap의 생성자를 따로 지정한 경우, 해당 조건문을 통해 선언한 생성자 기반으로 조회 결과와 객체를 매핑해 준다.
(* 필자는 resultMap을 사용할 생각이 없기에 상세 내용은 스킵하겠다.)
👉 Step 3. else if (!resultType.isInterface() && !metaType.hasDefaultConstructor())
@AllArgsConstructor(access = AccessLevel.PRIVATE)
public class Product {
private Long productId;
private String productName;
private String productCode;
private LocalDateTime createDate;
private LocalDateTime updateDate;
private Integer productStatus;
private Long quantity;
}
말 그대로, resultType이 인터페이스가 아니고 기본 생성자가 없는 경우 만족하게 된다. 위 예제와 같은 Entity가 있다면, 해당 조건문을 통해 선언한 생성자 기반으로 조회 결과와 객체를 매핑해 준다.
(* 선언된 생성자가 하나도 없으면 ExecutorException)
👉 Step 4. else
@NoArgsConstructor(access = AccessLevel.PRIVATE) // 명시적으로 선언
public class Product {
private Long productId;
private String productName;
private String productCode;
private LocalDateTime createDate;
private LocalDateTime updateDate;
private Integer productStatus;
private Long quantity;
}
위 3가지 조건에 부합하지 않는다면 return this.objectFactory.create(resultType) 함수를 통해 기본 생성자 + setter/ field injection 기반으로 조회 결과와 객체를 매핑해 준다.
🔍 Flow Chart
Step 1 ~ Step 4까지 이해를 돕기 위해 Flow Chart를 그려보았다.

위 Flow Chart를 기반으로 @NoArgsConstructor + @AllArgsConstructor가 선언되어 있는 Entity가 있고 해당 객체를 resultType에 지정해 주었다면 어떻게 객체를 매핑할까?
가장 마지막 조건문에 부합하여 기본 생성자로 객체 생성 후, 결과를 매핑할 것이다. 즉, MyBatis는 기본생성자 사용을 지향한다는 결론에 도달할 수 있다.
생성자 선언 방식에 따른 객체 바인딩
어떤 원리로 SQL 조회결과를 객체에 바인딩하는지 알아냈으니, 이제는 테스트를 진행해보려고 한다.
테스트 대상은 @NoArgsConstructor, @RequiredArgsConstructor, @AllArgsConstructor로 Entity에 선언하여 테스트를 진행했다.

테스트를 진행하면서 젤 복잡하고 머리 아픈 건 @RequiredArgsConstructor이다. 해당 어노테이션은 final 키워드를 붙이면 자동으로 의존성 주입해서 생성자를 만들어주는 원리인데, 솔직히 Entity에 많이 사용하지는 않는다. 다만, 레거시 코드에서 @Data라는 어노테이션에 의해 알게 모르게 많이 사용하고 있을 뿐.... ^^
그리고 테스트를 하면서 @NoArgsConstructor의 경우, 객체를 매핑하면서 Exception Level까지 격상시키지 않기 때문에 좀 더 안정적이라고 느꼈다. 또한, Entity의 필드가 추가되거나 삭제되어도 시스템에 미치는 영향을 최소화할 수 있어 유연성 측면에서 더 낫다고 생각했다.
NoArgsConstructor만 사용했을 때, 문제점
기본생성자만 사용하는 경우, 아예 문제가 없는 것은 아니다.
@Getter
@NoArgsConstructor // 명시적으로 선언
public class Product {
private Long productId;
private String productName;
private String productCode;
private LocalDateTime createDate;
private LocalDateTime updateDate;
private Integer productStatus;
private Long quantity;
}
이렇게 되어있을 때, Product 객체를 만들어서 insert 하고 싶을 때는 어떻게 해야 할까?
✨ 해결책 1. JavaBeans Pattern
Product product = new Product();
product.setProductId(1L);
product.setProductName("콤퓨타");
product.setProductCode("COM-001");
product.setCreateDate(LocalDateTime.now());
product.setUpdateDate(LocalDateTime.now());
product.setProductStatus(1);
product.setQuantity(50L);
Entity에 set method(@Setter)를 선언하고 이를 활용하는 패턴이다. 그러나 이 방법은 사용하지 않을 계획이다. 필자는 Entity가 생성 이후에는 변경되지 않았으면 좋겠다. 즉, Entity는 불변했으면 좋겠다.
✨ 해결책 2. 필요한 생성자를 만들어주자.
@Getter
@NoArgsConstructor
public class Product {
private Long productId;
private String productName;
private String productCode;
private LocalDateTime createDate;
private LocalDateTime updateDate;
private Integer productStatus;
private Long quantity;
private Product(Long productId, String productName, String productCode, LocalDateTime createDate, LocalDateTime updateDate, Integer productStatus, Long quantity) {
this.productId = productId;
this.productName = productName;
this.productCode = productCode;
this.createDate = createDate;
this.updateDate = updateDate;
this.productStatus = productStatus;
this.quantity = quantity;
}
public static Product insertOf(Long productId, String productName, String productCode, LocalDateTime createDate, LocalDateTime updateDate, Integer productStatus, Long quantity){
return new Product(productId, productName, productCode, createDate, updateDate, productStatus, quantity);
}
}
Entity를 외부에서 인스턴스 생성하지 못하도록 private으로 설정하고 static method를 통해 insert에 필요한 객체를 만들도록 수정했다. 이렇게 되면 JavaBeans Pattern의 불변성 문제도 해결할 수 있다.
그런데, 만약 협업을 하는 개발자가 productName과 productCode를 수정하기 위해서 이를 위한 생성자를 만들어 달라고 한다.
@Getter
@NoArgsConstructor
public class Product {
private Long productId;
private String productName;
private String productCode;
private LocalDateTime createDate;
private LocalDateTime updateDate;
private Integer productStatus;
private Long quantity;
private Product(String productName, String productCode){
this.productName = productName;
this.productCode = productCode;
}
private Product(Long productId, String productName, String productCode, LocalDateTime createDate, LocalDateTime updateDate, Integer productStatus, Long quantity) {
this.productId = productId;
this.productName = productName;
this.productCode = productCode;
this.createDate = createDate;
this.updateDate = updateDate;
this.productStatus = productStatus;
this.quantity = quantity;
}
public static Product insertOf(Long productId, String productName, String productCode, LocalDateTime createDate, LocalDateTime updateDate, Integer productStatus, Long quantity){
return new Product(productId, productName, productCode, createDate, updateDate, productStatus, quantity);
}
public static Product updateOf(String productName, String productCode){
return new Product(productName, productCode);
}
}
스읍.. 이렇게 가다가 Entity class에 code number가 1000이 넘어갈 것 같다. 의도치 않게 점층적 생성자 패턴이 만들어지고 있다. 물론, 이 방식이 어떤 목적으로 생성자를 사용하는지 명확하다는 의견도 있었다. 그렇지만 필자가 생각하기에는 처음 보는 사람 입장에서는 다소 이해하기 어려울 수도 있을 것 같다.
✨ 해결책 3. AllArgsConstructor + Builder
@Getter
@NoArgsConstructor
public class Product {
private Long productId;
private String productName;
private String productCode;
private LocalDateTime createDate;
private LocalDateTime updateDate;
private Integer productStatus;
private Long quantity;
@Builder(builderClassName = "ProductOperationBuilder", builderMethodName = "operation")
private Product(Long productId, String productName, String productCode, LocalDateTime createDate, LocalDateTime updateDate, Integer productStatus, Long quantity) {
this.productId = productId;
this.productName = productName;
this.productCode = productCode;
this.createDate = createDate;
this.updateDate = updateDate;
this.productStatus = productStatus;
this.quantity = quantity;
}
public static Product insertOf(Long productId, String productName, String productCode, LocalDateTime createDate, LocalDateTime updateDate, Integer productStatus, Long quantity){
return Product.operation()
.productId(productId)
.productName(productName)
.productCode(productCode)
.createDate(createDate)
.updateDate(updateDate)
.productStatus(productStatus)
.quantity(quantity)
.build();
}
public static Product updateOf(String productName, String productCode){
return Product.operation()
.productName(productName)
.productCode(productCode)
.build();
}
}
해결책 1,2번을 해결할 수 있는 패턴이 무엇이 있을까 고민하다 AllArgsConstructor에 Builder를 사용하는 건 어떨까?라는 생각을 했다. 이 방법의 경우, 불변성 문제와 생성자 코드로인해 code number가 1000이 넘어가는 문제를 해결할 수 있다.
다만, updateOf와 같이 빌더를 사용한다면 productName과 productCode 두 필드를 제외하고 모두 null 처리된다는 점을 주의해야 한다.
추천하는 Entity 패턴
여기까지 오기 위해 엄청나게 많은 테스트와 공부를 했다..! 결국은 MyBatis에서 지향하는 기본생성자를 선언하되 insert, update에 사용될 수 있는 확장성을 고려해서 설계하면 된다.
@Getter
@Builder(builderClassName = "ProductOperationBuilder", builderMethodName = "operation")
@NoArgsConstructor(access = AccessLevel.PRIVATE)
@AllArgsConstructor(access = AccessLevel.PRIVATE)
public class Product {
private Long productId;
private String productName;
private String productCode;
private LocalDateTime createDate;
private LocalDateTime updateDate;
private Integer productStatus;
private Long quantity;
public static Product insertOf(Long productId, String productName, String productCode, LocalDateTime createDate, LocalDateTime updateDate, Integer productStatus, Long quantity){
return Product.operation()
.productId(productId)
.productName(productName)
.productCode(productCode)
.createDate(createDate)
.updateDate(updateDate)
.productStatus(productStatus)
.quantity(quantity)
.build();
}
public static Product updateOf(String productName, String productCode){
return Product.operation()
.productName(productName)
.productCode(productCode)
.build();
}
}
그리고 외부에서 Entity class는 instance를 직접적으로 생성할 수 없도록 AccessLevel을 private로 지정해주었다. (Only Builder를 통해서만 객체 생성 가능)
오늘의 결론
당연하게도 필자가 생각하는 방식이 정답이 아니다. 아니 패턴에 정답은 없다.
그래서 더 좋은 의견이 있다면 적극 수용할 예정이다!!
MyBatis를 좀 안다고 생각했는데, 막상 더 깊이 공부해 보니 배울게 엄청나게 많았다. 앞으로 꾸준히 학습해서 MyBatis 오픈소스에 기여해보고 싶다.
(* 공부 출처: https://github.com/mybatis/mybatis-3)
'SPRING' 카테고리의 다른 글
실무 예제로 배우는 Template Method Pattern (4) | 2025.02.19 |
---|---|
LIKE Wildcard 검색을 막아보자(MySQL, MyBatis) (2) | 2024.12.25 |
Spring AI를 사용해보자. (3) | 2024.11.17 |
[MyBatis] Loop vs Subquery vs Inner Join (3) | 2024.07.20 |
MyBatis에서 Helper 클래스 적용하기 (StringUtils, CollectionUtils...) (2) | 2024.06.23 |