제목 끝의 「지지」는 판정이다. 증명 글은 실험 결과가 주장을 받쳐 주면 지지(supported), 주장을 뒤집으면 반증(refuted), 어느 쪽인지 가를 수 없으면 판정 불가(inconclusive)라고 적는다. 이 글은 지지다.
제목의 「매핑」은 SQL 결과 행 하나를 자바 객체 하나로 옮겨 담는 일이다. MyBatis가 그 일을 객체의 생성자를 불러서 하면 「생성자 매핑」이다. 어느 컬럼을 생성자의 몇 번째 인자에 넣을지는 resultMap의 <constructor>에 손으로 적어 줄 수 있는데, 그걸 적지 않아도 MyBatis가 알아서 짝을 지어 넣으면 「자동」이다. 이 실험은 <constructor>를 쓰지 않았다.
DTO 생성자 표준은 DTO에 @Setter·@NoArgsConstructor를 쓰지 못하게 하는 금지 목록을 두면서, MyBatis가 SQL 결과를 담아 돌려주는 타입인 MapperResult에만 예외로 둘 다 허용해 왔다. 근거는 「MyBatis는 기본 생성자와 setter로 매핑하고, 생성자 매핑을 쓰려면 별도 설정이 필요하다」였다. 기본 생성자로 빈 객체를 먼저 만들고 setter로 필드를 하나씩 채운다는 뜻이라, 그 근거대로라면 MapperResult에는 기본 생성자와 setter가 둘 다 있어야 했다.
선행 실험이 그 근거를 흔들었다. final 필드에 전 인자 생성자만 둔 타입도 MyBatis 설정을 하나도 바꾸지 않은 채 값이 채워졌다. 여기서 MyBatis 설정은 Spring Boot 앱의 설정 파일 application.properties에 mybatis.configuration.으로 시작하는 줄로 적는 값들이다. setter가 없어도 된다면 예외를 둘 이유가 사라진다. 그래서 이슈 #165 「MyBatis는 생성자만으로도 매핑된다」가 열렸다.
그런데 그 실험은 컬럼 순서와 생성자 파라미터 순서가 같았다. 생성자가 이름으로 받았는지 순서로 받았는지가 갈리지 않았다. 이슈도 「어느 조건에서 깨지는가」는 재지 않았다고 적었다. 이 실험은 순서만 바꿔 그 둘을 가른다.
주장 — 생성자 인자는 컬럼 순서로 들어가 같은 타입 두 컬럼이 조용히 뒤바뀐다
기본 설정에서 기본 생성자 없이 resultType 으로 매핑하면 생성자 인자는 컬럼 순서로 들어가,
같은 타입 두 컬럼의 SELECT 순서가 생성자 파라미터 순서와 다르면 예외 없이 뒤바뀐 값을 받는다.
한 줄씩 따라가기. 「기본 설정」은 MyBatis 설정값을 하나도 바꾸지 않은 상태다. 「기본 생성자 없이」는 결과를 담을 클래스에 인자 없는 생성자가 없다는 뜻이다. 「SELECT 순서」는 SELECT 문에 컬럼 이름을 적은 순서다 — select id, name, code면 id가 첫째, name이 둘째, code가 셋째다.
resultType은 SQL 결과를 담을 자바 타입이다. MyBatis는 SQL과 그 결과 타입을 별도 파일인 매퍼 XML에 적을 수도 있고, 자바 매퍼 인터페이스의 메서드 위에 바로 적을 수도 있다. 이 실험은 뒤쪽을 써서, 결과 타입을 메서드의 반환 타입으로 적었다. 실험 코드 ItemMapper의 한 메서드가 이렇게 생겼다.
@Select("select id, code, name from item where id = #{id}")
CtorItem ctorSwapped(@Param("id") Long id);
한 줄씩 따라가기. @Select는 이 메서드를 부르면 괄호 안의 SQL을 돌리라는 어노테이션이다. SQL은 item 테이블에서(from item) id가 주어진 값인 행만 골라(where id = ...) id·code·name 세 컬럼을 이 순서로 꺼낸다. @Param("id")는 메서드 인자에 id라는 이름을 붙이고, SQL의 #{id} 자리에는 그 이름의 인자 값이 들어간다. 반환 타입 CtorItem이 곧 resultType이다 — MyBatis는 SQL 결과 한 행을 CtorItem 객체로 만들어 돌려준다. CtorItem에 기본 생성자가 없으면 MyBatis는 있는 생성자를 찾아 컬럼 값을 그 인자로 넣으려 한다.
근거 종류는 1(기술 사실)이다. 출발점은 MyBatis 문서의 argNameBasedConstructorAutoMapping 설명과, MyBatis 3.5.17의 내부 소스 코드를 읽은 추론이었다. 이 설정은 MyBatis 3.5.10 버전부터 생겼고 기본값은 false다. 켜면 생성자 인자를 자리 순서가 아니라 파라미터 이름과 컬럼 이름을 맞춰서 채운다. 꺼져 있으면 순서로 채운다는 것이 문서의 설명이고, 이 실험이 확인하려는 것이 그것이다.
추론에는 한 가지가 더 붙어 있었다. 소스를 읽어 보니 MyBatis는 생성자로 객체를 만든 뒤에 한 단계를 더 거친다 — 컬럼 이름과 같은 이름의 필드를 찾아 값을 다시 넣는 단계다. 이 글에서는 이것을 「필드 자동 매핑」이라고 부른다. 앞의 argNameBasedConstructorAutoMapping은 생성자에 인자를 넣을 때 이름을 쓸지 정하는 설정이고, 필드 자동 매핑은 생성자가 끝난 다음에 따로 도는 단계라 그 설정이 꺼져 있어도 돈다(이것도 소스를 읽은 추론이다). 그래서 이 두 번째 단계가 final 필드까지 덮어써서 최종 값은 맞아 보일 수 있다고 봤다. 평소 코드에서 final 필드는 한 번 정하면 못 바꾸지만, 리플렉션은 setAccessible(true)로 접근 제한을 풀어 일반 class의 final 필드 값도 바꿔 쓸 수 있다. 그래서 생성자가 받은 인자와, 매핑이 다 끝난 뒤 getter로 읽은 최종 값을 따로 쟀다.
반증 조건 — (가)의 생성자 인자만으로 가른다
돌리기 전에 claim.md에 적었다. 아래에 나오는 말은 다음 섹션 「설계」에서 자세히 정의한다. 미리 짧게 적으면 이렇다.
- 대조군 쿼리 — 컬럼을 생성자 파라미터와 같은 순서(
id, name, code)로 SELECT한다. 결과를 이미 아는 대조군이다 - 실험 쿼리 —
name과code의 자리만 바꿔(id, code, name) SELECT한다 - (가) —
final필드와 생성자 하나만 있는 평범한 class. (나)(다)(라) 는 record · 설정을 바꾼 경우 · 기본 생성자를 둔 경우다
판정 기준은 셋이다.
- refuted — (가) 실험 쿼리에서 생성자가 이름대로 맞는 인자를 받는다. 또는 예외를 낸다(주장은 「예외 없이」다)
- supported — (가) 실험 쿼리에서 예외 없이 생성자가 뒤바뀐 인자를 받는다
- inconclusive — (가) 대조군 쿼리가 실패하거나 생성자 인자가 기대값이 아니다
최종 getter 값과 (나)(다)(라)는 기록만 하고 판정에 쓰지 않는다. 주장이 「생성자 인자」에 대한 것이라서다.
설계 — 같은 행을 SELECT 순서 둘 × 결과 타입 넷으로 읽는다
테이블의 행은 (id=1, name='alpha', code='C-7') 하나다. name과 code를 둘 다 varchar로 둔 것이 핵심이다. 예를 들어 숫자 컬럼이 문자열 파라미터 자리로 가면 MyBatis가 값을 그 타입으로 바꿔 넣는 과정에서 무언가 어긋날 수 있고, 그러면 「조용히 뒤바뀐다」를 볼 수 없다. 두 컬럼이 같은 문자열 타입이면 그런 갈림 자체가 없어서, 순서만으로 값이 바뀌는지를 볼 수 있다. 타입이 다를 때 실제로 예외가 나는지, 다른 무엇이 되는지는 재지 않았다.
대조군 select id, name, code 컬럼 순서 = 생성자 파라미터 순서
실험 select id, code, name name · code 자리만 바꿨다
(가) 일반 class · final 필드 · 생성자 (Long id, String name, String code) 하나만
(나) record (Long id, String name, String code)
(다) (가)와 같은 클래스 · arg-name-based-constructor-auto-mapping=true
(라) private 기본 생성자 · final 아닌 private 필드 · setter 없음
한 줄씩 따라가기. 대조군은 SELECT에 적은 컬럼 순서(id, name, code)가 생성자 파라미터 순서와 같고, 실험은 name과 code의 자리만 맞바꿨다(id, code, name). (가)는 지금까지 써 온 평범한 class다 — 필드는 final이고 값은 생성자로만 받는다. (나)는 같은 값을 record로 받는 경우다.
(다)는 (가)와 같은 class를 쓰되, 앞서 설명한 argNameBasedConstructorAutoMapping을 true로 켠 경우다. 표의 arg-name-based-constructor-auto-mapping은 같은 설정을 application.properties 파일에 적을 때의 이름이다. 실제로는 mybatis.configuration.arg-name-based-constructor-auto-mapping=true 한 줄을 적었다. 이름 기준 매핑에는 하나가 더 필요하다. 컴파일하면 보통 name·code 같은 원래 파라미터 이름이 arg0·arg1처럼 자리 번호로 바뀌어 사라지는데, -parameters 옵션을 주면 그 이름이 그대로 컴파일 결과에 남는다. 이름 기준 매핑은 그 남은 이름과 컬럼 이름을 맞춰 보는 방식이라, 이 옵션이 없으면 애초에 이름을 맞춰 볼 수가 없다. 이 실험의 빌드는 Gradle이고, 빌드 파일에 넣은 Spring Boot의 Gradle 플러그인(Gradle에 Spring Boot용 빌드 기능을 더해 주는 부품)이 자바 컴파일에 -parameters를 자동으로 붙인다. 그래서 빌드 파일에 따로 적지 않았다.
(라)는 생성자로 값을 넣지 않는다. private 기본 생성자로 빈 객체를 만든 뒤 MyBatis가 컬럼 이름과 같은 이름의 필드를 찾아 직접 값을 꽂아 넣게 한 경우다. 생성자가 private이어도 MyBatis는 리플렉션으로 부를 수 있다.
생성자가 받은 인자는 생성자 안에서 기록해 두었다. 기록기는 Recorder라는 클래스다. static 필드에 문자열 하나와 호출 횟수를 두고, 생성자가 첫 줄에서 받은 인자를 그 필드에 적는다.
public CtorItem(Long id, String name, String code) {
Recorder.ctor(id, name, code);
this.id = id;
this.name = name;
this.code = code;
}
한 줄씩 따라가기. Recorder.ctor(...)는 받은 세 값을 "1,C-7,alpha"처럼 쉼표로 이어 static 필드에 적고 호출 횟수를 1 늘린다. 나머지 세 줄은 받은 값을 final 필드에 넣는다. 매핑이 끝나면 Recorder에 적힌 값(생성자가 받은 인자)과 getName() 같은 getter로 읽은 값(최종 값)을 따로 꺼내 비교했다.
(다)는 앱을 한 번 더 띄워서 쟀다. 이 설정은 SqlSessionFactory(MyBatis가 DB와 주고받을 때 쓰는 설정과 연결 정보를 묶어 둔 객체) 하나에 걸리고, 그 객체는 앱 전체가 하나를 같이 쓴다. 그래서 한 번 실행하는 동안 설정을 켰다 껐다 할 수 없고, 꺼진 채로 한 번, 켠 채로 한 번 프로그램을 따로 실행했다. 실행마다 두 가지를 같이 찍었다. 설정이 실제로 켜졌는지는 factory.getConfiguration().isArgNameBasedConstructorAutoMapping()으로 봤다 — SqlSessionFactory에서 설정 객체를 꺼내 그 설정 값을 묻는 것이고, 켜졌으면 true다. 파라미터 이름이 컴파일 결과에 남았는지는 CtorItem.class.getConstructor(...).getParameters()로 생성자의 파라미터 목록을 리플렉션으로 얻은 뒤, 그 첫 파라미터의 isNamePresent()로 봤다. 이름이 남았으면 true다.
환경은 선행 실험과 같다 — Spring Boot 3.5.16 · mybatis-spring-boot-starter 3.0.4 · 실제로 올라온 MyBatis 3.5.17. 스타터는 MyBatis 본체를 함께 끌어오는 묶음이라 번호가 따로 있다. 「실제로 올라온」은 프로그램이 돌 때 실제로 쓰인 MyBatis 본체의 버전을 찍어 확인했다는 뜻이다. DB는 로컬 PostgreSQL에 실험 전용 데이터베이스를 만들고 지웠다.
결과 — 생성자는 순서대로 받았고, 그 뒤가 경우마다 달랐다
실험 쿼리(select id, code, name)만 옮긴다. 아래 표의 값은 모두 id,name,code 순서로 찍었다 — 생성자가 받은 인자는 첫째·둘째·셋째 파라미터 순서, getter 값은 getId()·getName()·getCode() 순서다. 그래서 1,alpha,C-7이 맞는 값이고 1,C-7,alpha는 name과 code가 뒤바뀐 값이다. 대조군 쿼리는 네 경우 모두 1,alpha,C-7로 맞았다.
생성자가 받은 인자 최종 getter 값
(가) class · final 1,C-7,alpha 1,alpha,C-7
(나) record 1,C-7,alpha 1,C-7,alpha
(다) 이름 기준 설정 true 1,alpha,C-7 1,alpha,C-7
(라) private 기본 생성자 (기본 생성자 호출) 1,alpha,C-7
한 줄씩 따라가기. (가)는 생성자가 뒤바뀐 값을 받았는데 최종 getter 값은 맞다. (나)는 생성자도 getter도 뒤바뀐 채다. (다)는 둘 다 맞다. (라)는 인자 없는 기본 생성자가 불렸으므로 생성자가 받은 인자 칸에 적을 값이 없고, 최종 값은 맞다.
어느 경우도 예외가 나지 않았고 생성자는 매번 한 번만 불렸다. (다)에서 설정 값은 true였고, 생성자 파라미터 이름은 컴파일 결과에 id,name,code로 남아 있었다. 원본은 블로그 페이지가 아니라 이 블로그 저장소 안의 파일 proofs/mybatis-constructor-column-order/result.md에 있다. 실험 폴더는 사이트에 싣지 않으므로 GitHub 저장소에서 그 경로를 연다.
판정은 supported다. (가)의 생성자는 예외 없이 name에 C-7을, code에 alpha를 받았다.
예상과 달랐던 점 — 없었다, 그래서 선행 실험이 왜 통과했는지가 설명된다
결과는 소스를 읽은 추론과 한 칸도 어긋나지 않았다. 대신 이 결과가 선행 실험의 「채워짐」을 다시 읽게 만든다.
(가)의 최종 getter 값은 맞았다. 생성자는 틀린 값을 받았는데 뒤에서 누군가 이름대로 고쳐 놓았다. 선행 실험은 실제로 getter로 읽은 최종 값만 봤다. 그러니 순서를 바꿨어도 「채워짐」으로 찍혔을 것이다. 선행 실험의 「채워진다」가 틀린 것은 아니지만, 그 결과만으로는 생성자가 맞는 값을 받았다고 말할 수 없다. getter로 확인하는 테스트는 이 뒤바뀜을 못 잡는다. 생성자 안에서 값을 검증하거나, 받은 값으로 다른 값을 계산해 필드에 넣는 코드(예: name으로 표시용 이름을 만들어 두는 코드)는 틀린 값을 본다.
record는 덮어쓸 수 없어서 뒤바뀐 값이 그대로 남았다. 자바의 리플렉션 문서는 일반 class의 final 필드와 달리 record의 final 필드는 setAccessible(true)를 해도 값을 바꿀 수 없는 필드로 적는다. 여기서도 예외는 없었다.
고쳐 놓은 주체가 생성자 다음에 도는 필드 자동 매핑의 리플렉션이라는 것은 재지 않았다. 결과와 맞는 추론일 뿐이다. record에서 덮어쓰기를 시도하다 조용히 넘어갔는지도 재지 않았다.
이 결과가 표준에 남기는 것 — 예외 조항을 그냥 지우면 안 된다는 쪽을 지지한다
여기서 「예외 조항」은 도입에서 말한, DTO 생성자 표준이 MapperResult에만 @Setter·@NoArgsConstructor를 허용해 온 조항이다. 이슈 #165가 따질 안은 셋이다. 이 실험은 결정하지 않고, 각 안이 어느 경우에 해당하는지만 대 준다.
안 A — 예외 조항을 삭제한다(MapperResult도 생성자만). (가)·(나)에 해당한다. 기본 설정 그대로면 SELECT 순서가 생성자 순서와 어긋나는 순간 조용히 뒤바뀐다. class는 getter가 가려 주고 record는 그대로 틀린다. 이 결과는 안 A를 단독으로는 지지하지 않는다.
안 B — 기본 생성자만 허용하고 setter는 금지한다. (라)에 해당한다. private 기본 생성자와 setter 없는 필드로 순서와 무관하게 이름대로 채워졌다. 설정에 기대지 않는다. 대신 필드가 final이 아니다. 그러면 객체를 만든 뒤에도 필드 값이 바뀔 수 있어서, 「생성자가 끝나면 상태가 완성된다」는 DTO 생성자 표준의 약속을 이 타입에서만 포기하게 된다. 그리고 금지 목록(DTO에 쓰지 않는 어노테이션 목록)에서 MapperResult만 빠져나가던 구멍이 @Setter 하나만큼 좁아질 뿐, @NoArgsConstructor가 여전히 허용되므로 닫히지는 않는다.
안 C — 이름 기준 설정을 강제한다. (다)에 해당한다. 설정 한 줄로 class와 record 모두 순서와 무관하게 맞았다. MapperResult도 생성자만 쓰면 되므로 금지 목록의 예외가 없어져 구멍도 닫힌다. 대신 설정이 빠진 프로젝트에서는 경고 없이 안 A의 뒤바뀜으로 돌아간다. -parameters 없이 켰을 때 어떻게 되는지는 재지 않았다.
정리하면 결과가 지지하는 것은 안 B와 안 C이고, 안 A는 안 C와 묶일 때만 안전하다. 어느 쪽인지는 #165에서 정한다.
정리
- 기본 설정에서 생성자 자동 매핑은 컬럼 순서로 넣는다. 같은 타입 두 컬럼이 예외 없이 뒤바뀌었다
- class는 최종 getter 값이 맞아 보여 뒤바뀜이 가려진다. record는 뒤바뀐 채 남는다
arg-name-based-constructor-auto-mapping=true에서는 순서와 무관하게 맞았다. private 기본 생성자 + setter 없음도 맞았다- 예외 조항을 그냥 지우는 안 A는 이 결과로 지지되지 않는다
- 재지 않은 것 — getter를 고쳐 놓은 작동 원리,
-parameters없이 이름 기준 설정을 켰을 때, 타입이 다른 컬럼끼리 뒤바뀔 때, 생성자가 여럿일 때, 다른 MyBatis 버전
자신만의 철학을 만들어가는 중입니다.
댓글남기기