DTO 생성자 표준은 DTO를 「생성자로만 만든다」고 정했다. 그래서 빈 객체를 먼저 만들게 하는 @NoArgsConstructor(기본 생성자를 만들어 주는 Lombok 표시)와, 만든 뒤에 값을 바꾸게 하는 @Setter(각 필드의 setter를 만들어 주는 Lombok 표시)를 금지 목록에 올렸다. 이 규칙에는 예외가 두 곳뿐이다 — JPA가 만드는 Entity와, MyBatis가 만드는 MapperResult다. 이 글은 두 번째 예외를 다룬다.

MapperResult는 MyBatis가 DB에서 조회한 결과를 담는 자리에 쓰는 DTO 클래스다. 내 코드가 new로 만드는 객체가 아니라 MyBatis가 만들어 값을 채우는 객체라서, MyBatis가 어떤 방식으로 객체를 만드느냐가 곧 이 클래스에 무엇을 허용해야 하느냐가 된다. 표준은 그 예외의 근거를 이렇게 적었다(이 글을 쓸 때의 원문을 옮긴 것이다).

MyBatis는 기본적으로 기본 생성자 + setter/필드 주입으로 결과를 매핑한다.
생성자 매핑을 쓰려면 resultMap 의 <constructor> 나 별도 설정이 필요한데,
이 표준은 그 설정을 도입하지 않고 예외로 둔다.

이 문장을 풀면 이렇다. 매핑은 DB 조회 결과의 각 컬럼 값을 객체의 필드에 옮겨 담는 일이다. MyBatis는 보통 기본 생성자로 빈 객체를 먼저 만든 뒤 setter를 불러 값을 채우거나, setter 없이 리플렉션으로 필드에 값을 바로 꽂아 넣는다 — 이 글에서 필드 주입은 뒤쪽 방식, 즉 setter를 거치지 않고 필드에 값을 바로 집어넣는 것을 가리킨다(스프링이 빈을 엮어 주는 의존성 주입과는 다른 말이다). 생성자로 바로 매핑하게 하려면 resultMap이라는 MyBatis 설정 안에 <constructor>라는 XML 태그(꺾쇠 괄호로 감싼 표시)를 써서 “이 생성자로 만들어라”고 따로 알려줘야 한다는 것이 표준의 설명이었다. 원문의 「별도 설정」이 무엇을 가리키는지는 표준이 적지 않았다.

그래서 표준의 논리는 이렇게 이어진다. MyBatis는 기본 생성자와 setter가 있어야 MapperResult를 채울 수 있다 → 생성자로 채우게 하는 설정은 들이지 않기로 했다 → 그러니 MapperResult에만 금지 목록의 @Setter·@NoArgsConstructor를 허용한다. 첫 고리가 무너지면 예외의 근거가 없어진다. 설정 없이는 정말 안 되는지 쟀다.


주장 — 기본 생성자와 setter가 없으면 매핑하지 못한다

MyBatis는 기본 생성자와 setter가 없으면 조회 결과를 매핑하지 못한다.

근거 종류는 1(기술 사실)이다.


반증 조건 — 둘 중 하나라도 채워지면 반증이다

(가) · (나) · 대조군은 바로 아래 「설계」에서 정의하는 결과 타입 세 가지다. 짧게 말하면 (가)는 기본 생성자도 setter도 없는 타입, (나)는 setter만 없는 타입, 대조군은 표준이 허용한 기본 생성자 + setter 타입이다. 대조군은 「이건 당연히 채워져야 한다」는 기준선이다. 그래서 대조군마저 안 채워지면 MyBatis의 한계가 아니라 내 실험 장치(DB 연결 · 쿼리 · 설정)가 잘못된 것이고, 그때는 (가) · (나)의 결과도 믿을 수 없다.

아래 셋은 판정 이름이다. refuted는 주장이 반증됨, supported는 주장이 뒷받침됨, inconclusive는 어느 쪽으로도 판단할 수 없음이다.

  • refuted — (가)나 (나) 중 하나라도 예외 없이 값이 채워진다
  • supported — (가)와 (나) 둘 다 실패하고 대조군만 채워진다
  • inconclusive — 대조군도 실패한다(설정이 틀린 것이다)

설계 — 같은 조회에 결과 타입만 바꿔 끼운다

(대조군) 기본 생성자 + setter          표준이 예외로 허용한 모양
(가)     final 필드 + 전 인자 생성자만   기본 생성자도 setter도 없다
(나)     기본 생성자 + private 필드      setter 가 없다

전 인자 생성자는 필드를 하나도 빠뜨리지 않고 전부 매개변수로 받는 생성자를 말한다. 세 타입 모두 필드는 id(Long) · name(String) · amount(Integer) 셋이고, 세 필드를 읽는 getter(getId() · getName() · getAmount())도 셋 다 가졌다. 다른 점은 객체를 만들고 값을 넣는 길뿐이다 — 대조군은 인자 없는 생성자와 setter 셋, (나)는 인자 없는 생성자만 있고 setter가 없다. (가)는 이렇게 생겼다.

public class CtorOnlyItem {
    private final Long id;
    private final String name;
    private final Integer amount;

    public CtorOnlyItem(Long id, String name, Integer amount) {
        this.id = id;
        this.name = name;
        this.amount = amount;
    }
    // getId() · getName() · getAmount() 만 있다
}

한 줄씩 보면 — 세 필드가 모두 final이라 생성자에서 한 번 넣은 뒤로는 바꿀 수 없다. 생성자는 세 값을 다 받는 것 하나뿐이라, new CtorOnlyItem()처럼 빈 객체를 만들 길이 없다. setter도 없고 값을 읽는 getter만 있다. 표준이 다른 DTO에 요구하는 모양 그대로다.

결과 타입은 매퍼 인터페이스의 메서드 반환 타입으로 정했다.

@Mapper
public interface ItemMapper {
    String QUERY = "select id, name, amount from item where id = #{id}";

    @Select(QUERY) ControlItem  control(@Param("id") Long id);   // 대조군
    @Select(QUERY) CtorOnlyItem ctorOnly(@Param("id") Long id);  // (가)
    @Select(QUERY) NoSetterItem noSetter(@Param("id") Long id);  // (나)
}

한 줄씩 보면 — @Mapper는 MyBatis에게 이 인터페이스의 구현을 대신 만들라고 알리는 표시다. QUERY는 인터페이스 안에 둔 문자열 상수로, item 테이블에서 id가 같은 행 하나의 세 컬럼을 가져오는 SQL이고, #{id}는 메서드에 넘긴 id 값이 들어갈 자리다. @Select(QUERY)는 「이 메서드를 부르면 이 SQL을 실행하라」는 MyBatis 표시다. SQL을 매퍼 XML 파일에 따로 적는 대신 메서드 위에 바로 적은 것이다. @Param("id")는 매개변수에 id라는 이름을 붙여서, SQL의 #{id}가 바로 이 매개변수를 가리키게 한다. 세 메서드는 같은 QUERY를 쓰고 반환 타입만 다르다. MyBatis는 이 반환 타입을 보고 어떤 클래스로 결과를 채울지 정한다. resultMap도 <constructor>도 어디에도 없다 — 표준이 「설정을 도입하지 않는다」고 한 그 상태다.

컬럼 이름은 필드 이름과 같게 뒀다. 이름까지 서로 다르게 두면, 실패했을 때 그 원인이 생성자 · setter 때문인지 이름이 안 맞아서인지 갈리지 않는다. 실험에서 바꾸는 조건은 결과 타입 하나로 묶었다. 테이블에는 행 하나(id=1, name='alpha', amount=700)만 넣었다.

DB는 로컬 PostgreSQL 17이고 실험 전용 데이터베이스를 만들고 지웠다. PostgreSQL 프로그램 하나 안에는 테이블을 담는 데이터베이스를 여러 개 따로 만들 수 있고, 이 실험은 그중 하나를 새로 만들어 썼다. 이 PC에서 다른 프로젝트가 쓰는 PostgreSQL에 붙었기 때문에, 그 안에 실험 테이블을 남기지 않으려고 따로 만들었다. MyBatis 3.5.17(mybatis-spring-boot-starter 3.0.4 — 스타터), Spring Boot 3.5.16이다. 실험 프로그램은 Spring Boot 앱으로 만들었다. ItemMapper는 구현 클래스가 없는 인터페이스인데, 스타터가 앱을 띄울 때 그 구현 객체를 만들어 빈으로 넣어 두고, 프로그램은 그 빈을 꺼내 세 메서드를 부른다.


결과 — 셋 다 채워졌다

PROBE boot=3.5.16 mybatis=3.5.17 mybatis_spring=3.0.4
      control=ok:filled:1,alpha,700
      ctor_only=ok:filled:1,alpha,700
      no_setter=ok:filled:1,alpha,700

이 출력은 실험 프로그램이 세 메서드를 차례로 부른 뒤 한 줄로 찍은 것이다. PROBE는 결과 줄을 콘솔에 함께 찍히는 로그 속에서 찾기 쉽게 붙인 머리말일 뿐 뜻은 없다. 원본은 한 줄이고, 여기서는 읽기 좋게 줄을 나누고 이 글에 필요 없는 필드(자바 버전 · DB 이름 등)를 뺐다. 원본은 proofs/mybatis-mapping-without-setter/result.md에 있다.

프로그램은 돌려받은 객체의 getter로 id · name · amount를 읽어 각각 null인지 센다. 셋 다 값이 있으면 filled, 하나도 없으면 empty, 섞였으면 partial이라고 적고, 메서드 호출에서 예외가 나면 ok 대신 fail:과 예외 클래스 이름을 적는다.

한 줄씩 보면, boot=3.5.16·mybatis=3.5.17·mybatis_spring=3.0.4는 각각 Spring Boot·MyBatis·mybatis-spring-boot-starter의 버전이다. control=·ctor_only=·no_setter=는 각각 (대조군)·(가)·(나) 타입을 돌린 결과다. ok:filled:1,alpha,700에서 ok는 예외 없이 끝났다는 표시이고, filled: 뒤에 콤마로 이어진 1,alpha,700은 getter로 읽은 id · name · amount 값이고, 테이블에 넣어 둔 행과 같다 — 셋 다 비지 않고 값이 들어갔다는 뜻이다. 세 줄이 전부 같은 값으로 나온 것이 “셋 다 채워졌다”는 결론의 근거다.

판정은 refuted다. final 필드에 전 인자 생성자만 둔 타입도, setter가 없는 타입도 예외 없이 모든 필드가 찼다.


예상과 달랐던 점 — 표준 글의 서술과 반증 조건이 가리키는 곳이 다르다

표준 글의 원문은 「기본 생성자 + setter/필드 주입」이다. 그런데 「주장」 섹션의 문장을 줄이면서 나는 「필드 주입」을 빼고 「setter가 없으면 못 한다」로 적었고, 반증 조건도 그 줄인 문장에 맞춰 (나)를 넣었다. 그래서 반증 조건이 표준 원문보다 넓다. 필드 주입까지 적혀 있으므로 (나)가 성공한 것은 글과 어긋나지 않는다. 뒤집힌 것은 (가)다 — 기본 생성자가 아예 없는 타입이 매핑됐다.

그래서 이 결과가 흔드는 것은 정확히 이 지점이다.

표준 글이 말한 것 실측
setter나 필드 주입으로 채운다 (나)에서 맞다
생성자 매핑은 <constructor> 설정이 있어야 한다 틀렸다. (가)가 설정 없이 됐다
그래서 MapperResult에 기본 생성자와 setter를 허용한다 근거가 사라졌다

왜 됐는지는 이 실험이 재지 않았다. 후보는 둘이다. MyBatis가 설정 없이 스스로 생성자를 골라 컬럼 값을 넘겼을 수도 있고(생성자 자동 매핑), 생성자로 객체를 만든 뒤 리플렉션으로 필드에 값을 다시 덮어썼을 수도 있다. (가)에는 세 값을 받는 생성자 하나뿐이니 객체를 만들 때 그 생성자를 거쳤을 것이라고 짐작은 하지만, 이 실험은 getter로 읽은 최종 값만 봤으므로 최종 값이 생성자로 들어간 것인지 덮어쓴 것인지 가르지 못한다. 그래서 표의 「틀렸다」는 정확히는 「<constructor> 설정이 없으면 기본 생성자 없는 타입을 채우지 못한다」가 틀렸다는 뜻이고, 생성자가 실제로 불렸는지는 측정한 것이 아니라 추정이라 결과에 적지 않았다. 생성자에 실제로 무엇이 들어오는지는 뒤이은 컬럼 순서 실험이 따로 쟀다.


이 결과가 표준에 남기는 것 — 예외를 없앨 수 있는지가 쟁점이 된다

이 실험은 같은 날 같은 표준을 두고 돌린 다른 실험들(Jackson 생성자 감지 · Jackson setter 경로 · Lombok final 필드)과 성격이 다르다. 앞의 둘에 나오는 Jackson은 MyBatis가 아니라, 요청으로 들어온 JSON을 자바 객체로 바꾸는 쪽의 도구다. 그 실험들은 근거 문장을 흔들었지만, 이 실험은 예외 조항 자체가 필요 없어질 수 있다는 것을 보였다. MapperResult도 다른 DTO처럼 final 필드와 생성자만으로 둘 수 있다면, @Setter · @NoArgsConstructor 금지에 뚫어 둔 두 예외 가운데 MapperResult 쪽이 사라진다.

규칙은 바로 고치지 않는다. 실험 쪽이 틀렸을 수도 있어서다. 대신 두 가지를 한다. 하나는 표준 글의 해당 원칙 맨 위에 > 반증:으로 시작하는 한 줄을 달아, 그 원칙을 읽는 사람이 반증된 결과가 있다는 것을 바로 보게 하는 것이다. 다른 하나는 이 저장소의 GitHub 이슈에 kind: 모순 표시를 붙인 안건 이슈를 열어 예외를 없앨지, 없앤다면 어느 조건에서 깨지는지를 따지는 것이다. MyBatis 버전이 올라가며 동작이 달라질 여지도 그 자리에서 확인해야 한다.

그 뒤 이야기는 표준 글에 있다. 컬럼 순서를 바꿔 다시 재 보니 생성자가 값을 이름이 아니라 컬럼 순서대로 받아 뒤바꿔 넣었다. 반면 기본 생성자가 있으면 MyBatis는 생성자에 값을 넘기지 않고 이름으로 필드를 찾아 채워서, 순서를 바꿔도 값이 맞았다(private 생성자여도 그랬다). 그래서 예외를 없애지 않고 「private 기본 생성자만 두고 setter는 두지 않는다」로 좁혔다(DTO 생성자 표준 원칙 6).


정리

  • 세 타입 모두 설정 없이 채워졌다. final 필드 + 전 인자 생성자만 둔 타입 포함
  • 「<constructor> 설정이 있어야 생성자 매핑이 된다」가 틀렸다
  • MapperResult 예외 조항의 근거가 사라졌다. 없앨지는 모순 이슈에서 정한다
  • 재지 않은 것 — 매핑이 왜 성공했는지(생성자로 넣었는지 필드에 넣었는지), 다른 MyBatis 버전, 컬럼 이름이 다를 때

자신만의 철학을 만들어가는 중입니다.
최상단으로 이동했습니다!
확대 이미지

댓글남기기