Skip to content

MyBatis Dynamic SQL

iFlex 는 SQL 문을 손으로 쓰지 않는 것을 지향합니다. 대신 MyBatis Dynamic SQL자바 코드에서 SQL 을 조립합니다.

이 문서는 그 라이브러리를 이 프로젝트에서 어떻게 쓰는지 다룹니다. 엔터티를 처음 만드는 순서는 엔터티 만들기 를 먼저 보세요.

핵심 한 문장

컬럼명을 문자열로 쓰지 마세요. {X}Dsl 로 참조하면 컬럼이 바뀔 때 컴파일 에러로 잡힙니다.

이 문서의 예제 테이블

설명에는 nv_article(게시글) 테이블을 씁니다. art_title, art_content, art_read, art_bd_code, art_deleted 같은 컬럼이 있어 검색·집계 예제를 보이기 좋습니다.

문서에 실린 SQL 은 전부 실제로 렌더해서 확인한 결과입니다. 읽기 쉽도록 바인딩 자리만 ? 로 줄였습니다(실제로는 #{parameters.p1,jdbcType=...} 형태).

왜 문자열 SQL 을 쓰지 않나

java
// ❌ XML 에 이렇게 쓰면 — 컬럼명이 바뀌어도 아무도 모릅니다. 런타임에 터집니다.
"SELECT * FROM nv_article WHERE art_title LIKE #{title} ORDER BY ${sortBy}"

// ✅ Dsl — art_title 을 리네임하면 이 줄이 컴파일 에러가 납니다
select(a.allColumns())
 .from(a, a.getAlias())
.where(a.title, isLike("%공지%"))
.orderBy(a.desc(a.createdDt))

얻는 것이 셋입니다.

문자열 SQLDynamic SQL
컬럼 리네임배포 후 그 화면을 열어야 발견컴파일 에러
타입 실수art_read > '많음' 이 그대로 나감컴파일 에러
조건 조립if 로 문자열 이어붙이기메서드 체인

${} 문자열 치환은 전면 금지입니다

${sortBy} 처럼 값이 SQL 에 그대로 박히는 문법은 인젝션 경로가 됩니다. DollarTokenRatchetTest 가 mapper XML 에 이런 토큰이 들어오는 것을 자동으로 막습니다.

정렬도 문자열이 아니라 컬럼 참조로 넘기세요 — 뒤의 정렬 참고.

{X}Dsl — 라이브러리가 요구하는 테이블 정의

Dynamic SQL 은 a.title 같은 컬럼 객체를 받아 SQL 을 만듭니다. 그래서 컬럼 객체를 선언해 둘 자리가 필요한데, 그것이 {X}Dsl 입니다.

java
public class ArticleDsl extends NvSqlTable {

    public static final String DEFAULT_ALIAS = "a";

    //region ===== table columns (nv_article) =====
    public final SqlColumn<Long>    id        = column("art_id",         JDBCType.BIGINT);
    public final SqlColumn<String>  boardCode = column("art_bd_code",    JDBCType.VARCHAR);
    public final SqlColumn<String>  title     = column("art_title",      JDBCType.VARCHAR);
    public final SqlColumn<String>  content   = column("art_content",    JDBCType.LONGVARCHAR);
    public final SqlColumn<Integer> read      = column("art_read",       JDBCType.INTEGER);
    public final SqlColumn<Boolean> deleted   = column("art_deleted",    JDBCType.BOOLEAN);
    public final SqlColumn<Instant> createdDt = column("art_created_dt", JDBCType.TIMESTAMP);
    //endregion

    public static ArticleDsl defaultAlias()   { return new ArticleDsl(DEFAULT_ALIAS); }
    public static ArticleDsl as(String alias) { return new ArticleDsl(alias); }
}
요소의미
SqlColumn<Integer> read컬럼 참조 + 타입where(a.read, isEqualTo("많음")) 은 컴파일 에러
DEFAULT_ALIASSQL 별칭. 검색 스키마의 경로("a.title")와 일치해야 합니다
as("별칭")같은 테이블을 두 번 조인할 때
java
ArticleDsl a  = ArticleDsl.defaultAlias();   // 별칭 "a"
ArticleDsl a2 = ArticleDsl.as("a2");         // self join 용

a.title                 // 컬럼 참조
a.allColumns()          // 전체 컬럼
a.getAlias()            // "a"
a.asc(a.title)          // 정렬 (타입 안전)
a.desc(a.createdDt)

컬럼을 추가·변경할 때는 3개 파일을 함께 고칩니다

  1. {X}.java — 엔터티 필드
  2. {X}Dsl.javaSqlColumn 선언 (컬럼맵은 NvSqlTable 이 자동 수집)
  3. mybatis/sql/.../{X}Repository.xml — resultMap

resultMap 을 빠뜨리면 저장은 되는데 조회 결과에만 안 담깁니다. 자세한 이유는 엔터티 만들기 참고.

조건 작성

기본 조건

java
import static org.mybatis.dynamic.sql.SqlBuilder.*;

select(a.allColumns())
 .from(a, a.getAlias())
.where(a.deleted, isEqualTo(false))
  .and(a.title, isLike("%공지%"))
  .and(a.createdDt, isGreaterThan(from))
.orderBy(a.desc(a.createdDt))
.build().render(RenderingStrategies.MYBATIS3);

자주 쓰는 조건:

조건SQL
isEqualTo(v) / isNotEqualTo(v)= v / <> v
isGreaterThan(v) / isLessThan(v)> v / < v
isGreaterThanOrEqualTo(v) / isLessThanOrEqualTo(v)>= v / <= v
isIn(list) / isNotIn(list)IN / NOT IN
isLike("%v%") / isNotLike(...)LIKE / NOT LIKE
isNull() / isNotNull()IS NULL / IS NOT NULL
isTrue() / isFalse()불리언
isBetween(a).and(b)BETWEEN

isIn 에 빈 컬렉션을 넘기면 SQL 이 깨집니다

IN () 은 문법 오류입니다. repository 안에서 early-return 으로 막으세요.

java
default List<Article> listByCodes(Collection<String> codes) {
    if (CollectionUtils.isEmpty(codes)) {
        return List.of();                    // ← 빈 컬렉션 방어
    }
    ...
}

중첩 괄호 — AND (A OR B)

가장 자주 필요하면서 가장 헷갈리는 부분입니다. "삭제되지 않았고, 제목 또는 내용에 키워드가 있는" 검색을 만들어 봅시다.

sql
WHERE art_deleted = false
  AND (art_title LIKE '%The%' OR art_content LIKE '%The%')

괄호가 없으면 완전히 다른 쿼리가 됩니다. SQL 은 ANDOR 보다 강하므로, 괄호를 빼면 (deleted=false AND title LIKE ...) OR (content LIKE ...) 로 해석되어 삭제된 글까지 걸려 나옵니다.

방법이 둘 있고, 렌더 결과는 완전히 같습니다.

방법 A — and(...) 의 세 번째 인자로 or(...) 넘기기

java
//@formatter:off
select(a.id, a.title)
 .from(a, a.getAlias())
.where(a.deleted, isEqualTo(false))
  .and(a.title, isLike(kw), or(a.content, isLike(kw)))
.build().render(RenderingStrategies.MYBATIS3);
//@formatter:on

방법 B — group(...) 으로 명시적으로 묶기

java
//@formatter:off
select(a.id, a.title)
 .from(a, a.getAlias())
.where(a.deleted, isEqualTo(false))
  .and(group(a.title, isLike(kw), or(a.content, isLike(kw))))
.build().render(RenderingStrategies.MYBATIS3);
//@formatter:on

두 방법 모두 다음을 만듭니다.

sql
select a.art_id, a.art_title from iflexdb.nv_article a
 where a.art_deleted = ?
   and (a.art_title like ? or a.art_content like ?)

어느 쪽을 쓸까

where/and/or 는 마지막에 가변 인자로 추가 조건을 받고, 그것들을 한 괄호로 묶어 렌더합니다. 즉 방법 A 의 괄호는 "덤" 이 아니라 규칙입니다.

  • 조건이 2~3개면 방법 A — 짧습니다
  • 중첩이 생기거나 조건을 변수로 뽑으면 방법 Bgroup(...) 이라는 이름이 의도를 드러냅니다

읽는 사람이 괄호를 의심하지 않게 하는 쪽을 고르세요.

3중 이상 중첩

or(...) 안에 다시 group(...) 을 넣으면 됩니다.

java
//@formatter:off
select(a.id)
 .from(a, a.getAlias())
.where(a.deleted, isEqualTo(false))
  .and(group(a.enabled, isEqualTo(true),
             or(group(a.title, isLike(kw), or(a.content, isLike(kw))))))
.build().render(RenderingStrategies.MYBATIS3);
//@formatter:on
sql
where a.art_deleted = ?
  and (a.art_enabled = ? or (a.art_title like ? or a.art_content like ?))

조건 개수가 실행 중에 정해질 때

검색 대상 필드를 사용자가 고르는 화면이라면 조건 개수가 고정이 아닙니다. List<AndOrCriteriaGroup> 을 만들어 넘기면 됩니다.

java
List<AndOrCriteriaGroup> ors = new ArrayList<>();
if (searchContent)  ors.add(or(a.content,   isLike(kw)));
if (searchCategory) ors.add(or(a.boardCode, isEqualTo(category)));

//@formatter:off
select(a.id)
 .from(a, a.getAlias())
.where(a.deleted, isEqualTo(false))
  .and(group(a.title, isLike(kw), ors))     // ← List 를 그대로 전달
.build().render(RenderingStrategies.MYBATIS3);
//@formatter:on
sql
where a.art_deleted = ?
  and (a.art_title like ? or a.art_content like ? or a.art_bd_code = ?)

화면 검색창은 대부분 직접 만들 필요가 없습니다

위는 리포지토리에서 직접 쿼리를 짤 때 이야기입니다.

CMS 목록 화면의 검색 조건은 프론트가 조건 트리({ao, conds})로 보내고, NvQueryBinder 가 알아서 괄호를 씌웁니다. 중첩 조건도 트리에 그대로 담기므로 서버 코드를 고칠 필요가 없습니다.

특히 결합자가 섞이면(ANDOR 혼재) 바인더가 왼쪽부터 접으며 괄호를 강제합니다 — 평평하게 두면 DB 의 우선순위 때문에 필터가 넓어지기 때문입니다.

[a, b(or), c(and)]  →  ((a OR b) AND c)

자세한 내용은 검색 조건 참고.

NOTEXISTS

java
.where(not(a.enabled, isEqualTo(true)))              // NOT (...)
.where(exists(select(a2.id).from(a2, a2.getAlias())
.where(a2.boardCode, isEqualTo(a.boardCode))))
sql
where exists (select a2.art_id from iflexdb.nv_article a2
               where a2.art_bd_code = a.art_bd_code)

집계 — count · group by · having

건수만 세기

java
countFrom(a, a.getAlias())
.where(a.deleted, isEqualTo(false))
.build().render(RenderingStrategies.MYBATIS3);
sql
select count(*) from iflexdb.nv_article a where a.art_deleted = ?
java
countDistinctColumn(a.boardCode).from(a, a.getAlias())   // 중복 제거 건수
sql
select count(distinct a.art_bd_code) from iflexdb.nv_article a

실행은 selectOneLong 으로 받습니다 — resultMap 이 필요 없습니다.

java
default long countArticle() {
    ArticleDsl a = ArticleDsl.defaultAlias();
    return selectOneLong(countFrom(a, a.getAlias())
        .where(a.deleted, isEqualTo(false))
        .build().render(RenderingStrategies.MYBATIS3));
}

그룹별 집계

됩니다. groupBy / having 과 집계 함수를 모두 지원합니다.

java
//@formatter:off
select(a.boardCode,
       count().as("cnt"),
       max(a.read).as("max_read"),
       avg(a.read).as("avg_read"))
     .from(a, a.getAlias())
    .where(a.deleted, isEqualTo(false))
    .groupBy(a.boardCode)
    .having(count(), isGreaterThan(10L))
    .orderBy(sortColumn("cnt").descending())
    .build().render(RenderingStrategies.MYBATIS3);
//@formatter:on
sql
select a.art_bd_code, count(*) as cnt, max(a.art_read) as max_read, avg(a.art_read) as avg_read
  from iflexdb.nv_article a
 where a.art_deleted = ?
 group by a.art_bd_code
having count(*) > ?
 order by cnt DESC

사용 가능한 집계·함수:

함수SQL
count() / count(col)count(*) / count(col)
countDistinctColumn(col)count(distinct col)
sum(col) / avg(col)sum / avg
min(col) / max(col)min / max
concat(col, ...) / concatenate(...)문자열 결합
substring(col, start, len)부분 문자열
add(col, ...) / multiply(col, ...)산술

집계 결과는 엔터티에 안 담깁니다

count(*) as cnt 같은 컬럼은 Article 엔터티에 자리가 없습니다. @ResultMap("resultMap") 으로는 받을 수 없다는 뜻입니다.

받는 방법은 바로 다음 절 — resultMap 없이 받기 입니다.

resultMap 없이 결과 받기

집계나 임시 통계처럼 엔터티에도 응답 DTO 에도 자리가 없는 결과를 위해, XML 선언 없이 결과를 받는 방법이 있습니다.

NvRepositoryDynamicCommonSelectMapper 를 상속하므로, 모든 리포지토리가 이 메서드들을 이미 가지고 있습니다.

단일 값

java
long   total = selectOneLong(provider);      // count 등
String name  = selectOneString(provider);
Integer n    = selectOneInteger(provider);
List<Long> ids = selectManyLongs(provider);  // ID 목록
메서드반환
selectOneLong / selectOptionalLong / selectManyLongsLong
selectOneInteger / selectOptionalInteger / selectManyIntegersInteger
selectOneString / selectOptionalString / selectManyStringsString
selectOneDouble / selectOneBigDecimal (+ Optional/Many)실수

콜백으로 원하는 객체에 담기

행 하나를 Map<String, Object> 으로 받아, 콜백 안에서 원하는 객체로 바꾸는 방식입니다. resultMap 을 선언하지 않아도 됩니다.

java
/** 게시판별 글 수 통계 — 전용 resultMap 없이 record 로 받는다 */
default List<ArticleStat> statByBoard() {
    ArticleDsl a = ArticleDsl.defaultAlias();

    //@formatter:off
    SelectStatementProvider provider =
        select(a.boardCode.as("board_code"),
               SqlBuilder.count().as("cnt"),        // ← 한정 호출에 주의 (아래 설명)
               max(a.createdDt).as("last_dt"))
             .from(a, a.getAlias())
            .where(a.deleted, isEqualTo(false))
            .groupBy(a.boardCode)
            .build().render(RenderingStrategies.MYBATIS3);
    //@formatter:on

    return selectMany(provider, row -> new ArticleStat(
        (String)  row.get("board_code"),
        ((Number) row.get("cnt")).longValue(),
        (Instant) row.get("last_dt")
    ));
}

record ArticleStat(String boardCode, long count, Instant lastDt) {}

렌더되는 SQL:

sql
select a.art_bd_code as board_code, count(*) as cnt, max(a.art_created_dt) as last_dt
  from iflexdb.nv_article a
 where a.art_deleted = ?
 group by a.art_bd_code

리포지토리 안에서는 count() 를 그냥 못 씁니다

NvRepositoryDynamic 이 이미 count(QueryBuilder) / count(NvQuery) 를 가지고 있어서, 리포지토리 인터페이스 안에서는 상속된 countimport static SqlBuilder.count 를 가립니다.

java
count().as("cnt")              // ❌ error: no suitable method found for count(no arguments)
SqlBuilder.count().as("cnt")   // ✅

static import 를 해 두었는데도 나는 에러라 원인을 찾기 어렵습니다. 리포지토리 default 메서드 안에서 집계를 쓸 땐 SqlBuilder. 를 붙이세요. (테스트나 서비스 클래스처럼 NvRepositoryDynamic 을 상속하지 않는 곳에서는 그냥 count() 로 됩니다.)

메서드설명
selectMany(provider, row -> ...)각 행(Map)을 원하는 타입으로
selectOne(provider, row -> ...)단건
selectManyMappedRows(provider)변환 없이 List<Map<String,Object>> 그대로
selectOneMappedRow(provider)단건 Map

Map 의 키는 반드시 .as("...") 로 고정하세요

키는 DB 드라이버가 돌려준 컬럼 라벨 그대로입니다. 이 프로젝트는 mapUnderscoreToCamelCase 를 켜지 않았으므로 자동 변환도 없습니다.

별칭을 주지 않으면 DBMS 마다 대소문자가 달라집니다 — 특히 Oracle 은 대문자로 돌려줍니다.

java
select(a.boardCode, count())                      // ❌ 키가 art_bd_code? ART_BD_CODE? count(*)?
select(a.boardCode.as("board_code"), count().as("cnt"))  // ✅ 키가 확정된다

as() 로 이름을 고정하면 4개 DBMS 에서 같은 키로 읽을 수 있습니다.

언제 콜백이고 언제 resultMap 인가

상황방법
목록·상세 등 화면이 반복해서 쓰는 응답{X}Dto + resultMap
집계·통계·일회성 조회콜백
단일 스칼라(건수, ID 목록)selectOneLong / selectManyLongs

콜백은 컴파일 타임 타입 검사가 없습니다(row.get("cnt")Object). 화면 계약이 되는 응답에는 쓰지 마세요 — 오타가 런타임 ClassCastException 이 됩니다.

서브쿼리 · UNION

서브쿼리

java
select(a.id, a.title)
 .from(a, a.getAlias())
.where(a.boardCode, isIn(
select(a2.boardCode)
 .from(a2, a2.getAlias())
.where(a2.enabled, isEqualTo(true))))
sql
select a.art_id, a.art_title from iflexdb.nv_article a
 where a.art_bd_code in (select a2.art_bd_code from iflexdb.nv_article a2
                          where a2.art_enabled = ?)

isEqualTo(select ...), isNotIn(select ...) 등도 같은 방식입니다.

UNION

java
select(a.id, a.title)
 .from(a, a.getAlias()).where(a.enabled, isEqualTo(true))
.union()
select(a2.id, a2.title)
 .from(a2, a2.getAlias()).where(a2.read, isGreaterThan(100))

unionAll() 은 중복을 제거하지 않습니다.

정렬과 limit offset

java
selectDistinct(a.boardCode)
 .from(a, a.getAlias())
.where(a.deleted, isEqualTo(false))
.orderBy(a.boardCode)
.limit(20).offset(40)
sql
select distinct a.art_bd_code from iflexdb.nv_article a
 where a.art_deleted = ? order by art_bd_code limit ? offset ?

정렬 키를 문자열로 만들지 마세요

java
new SortBy("createdDt", ASC)     // ❌ 화이트리스트에서 못 찾으면 조용히 드롭
a.asc(a.createdDt)               // ✅ 리네임하면 컴파일 에러

문자열 sortKey 는 QueryBuilder.resolveSortColumn 이 못 찾을 때 예외도 로그도 없이 폴백 정렬로 넘어갑니다. 컬럼을 리네임해도 빌드는 통과하고 정렬만 조용히 바뀝니다.

집계 별칭으로 정렬할 때만 sortColumn("cnt") 를 씁니다(그 별칭은 테이블 컬럼이 아니므로).

목록 화면의 페이징은 직접 limit/offset 을 쓰지 않습니다 — InPagination 이 처리하므로 목록과 페이징 을 보세요.

수정 · 삭제

java
// 상태만 바꾸기 — 다른 컬럼은 건드리지 않습니다
default int updateReadCount(Long id, int read) {
    ArticleDsl a = ArticleDsl.defaultAlias();
    //@formatter:off
    return _update(update(a)
              .set(a.read).equalTo(read)
              .set(a.modifiedDt).equalTo(Instant.now())
            .where(a.id, isEqualTo(id))
            .build().render(RenderingStrategies.MYBATIS3));
    //@formatter:on
}

// soft-delete
default int markAsDelete(Long id) {
    ArticleDsl a = ArticleDsl.defaultAlias();
    //@formatter:off
    return _update(update(a)
              .set(a.deleted).equalTo(true)
            .where(a.id, isEqualTo(id))
            .build().render(RenderingStrategies.MYBATIS3));
    //@formatter:on
}

전체 행 CRUD 는 직접 쓰지 않아도 됩니다 — NvRepositoryDynamic 이 제공합니다.

메서드설명
get(id)단건 조회
list(builder, sortBy...) / page(...)목록
count(builder)건수
insert(t)등록 (키 자동 생성)
insertSelective(t)등록 — null 컬럼 제외 (DB 기본값 보존)
update(t)전체 수정
updateSelective(t)null 아닌 필드만 수정
delete(id)물리 삭제

insert vs insertSelective

insert 는 모든 컬럼을 씁니다 — null 필드는 DB 에도 null 이 들어갑니다. insertSelective 는 null 필드를 아예 빼므로 DB 의 DEFAULT 값이 적용됩니다.

단, insertSelective 는 IDENTITY 방식에서 생성 키 회수를 보장하지 않습니다. 채번된 id 가 필요하면 insert 를 쓰세요.

키 생성 전략

iFlex 는 4개 DBMS 를 지원하는데 같은 엔터티라도 DDL 이 다릅니다 — 대부분 auto-increment 이지만 Oracle 은 시퀀스를 씁니다.

java
@TableInfo(dsl = ArticleDsl.class)                                  // AUTO (기본)
@TableInfo(dsl = BannerDsl.class, sequenceName = "seq_banner")      // 시퀀스 이름 지정
@TableInfo(dsl = TermDsl.class, keyStrategy = KeyStrategy.ASSIGNED) // PK 직접 지정
전략동작
AUTO (기본)Oracle → SEQUENCE, 그 외 → IDENTITY자동 해석
IDENTITYauto-increment. getGeneratedKeys() 로 키 회수, id 는 insert 에서 제외
SEQUENCEinsert 전에 SELECT <seq>.NEXTVAL 로 id 확보 후 id 포함 insert
ASSIGNED호출자가 id 를 직접 지정 (코드성 PK 등)

시퀀스 이름을 지정하지 않으면 seq_<테이블명>_id 관례를 따릅니다.

왜 이렇게까지 하나

DBMS 별로 리포지토리를 따로 만들거나 if (isOracle()) 분기를 코드에 뿌리지 않기 위해서입니다. 개발자는 insert(article) 만 호출하고, 어느 DB 에서 도는지는 신경 쓰지 않습니다.

코드 포맷 규칙

SQL 을 코드로 쓰면 읽기 어려워지기 쉽습니다. 프로젝트는 SQL 키워드 우측정렬 규칙을 씁니다.

java
//@formatter:off
return select(a.allColumns())
         .from(a, a.getAlias())
        .where(a.deleted, isEqualTo(false))
          .and(a.title, isLike(keyword))
        .orderBy(a.desc(a.createdDt))
        .build().render(RenderingStrategies.MYBATIS3);
//@formatter:on

//@formatter:off 를 지우지 마세요

IDE 자동 포맷이 이 정렬을 망가뜨립니다. .editorconfig 에 IntelliJ 마커가 활성화돼 있으니 DSL 블록은 항상 //@formatter:off//@formatter:on 으로 감싸세요.

그래서 XML 에는 무엇이 남나

대상위치
SELECT / INSERT / UPDATE / DELETE 문Java Dsl (필요하면 XML 에 직접 쓰기도 함)
resultMap (컬럼 → 객체 매핑)XML
xml
<resultMap id="articleResponseResult" type="articleResponse">
  <id     property="id"      column="art_id"/>
  <result property="title"   column="art_title"/>
  <result property="config"  column="art_config" typeHandler="...VarcharJsonTypeHandler"/>
  <association property="createdUser" columnPrefix="ins_user_" resultMap="accountRefResult"/>
</resultMap>

왜 결과 매핑만 XML 인가

Dynamic SQL 은 SQL 문을 만드는 라이브러리이고, 결과 매핑 기능이 아예 없습니다. 결과를 객체로 바꾸는 것은 MyBatis 본체의 일이며, 선언 수단은 XML 아니면 @Results 애노테이션입니다.

그중 XML 을 쓰는 이유는 매핑을 공유하기 위해서입니다. 두 방향으로 공유됩니다.

① 실행 경로가 둘인데 매핑은 하나 — SQL 은 Dsl 로 짤 때도 있고 XML 에 직접 쓸 때도 있습니다. 어느 쪽이든 같은 매핑을 가리킵니다.

adminResult          ← XML 의 <select id="getAdmin"> 이 사용
resultMap            ← 제네릭 CRUD 의 @ResultMap("resultMap") 이 사용
adminDtoResult  ← provider + fetcher 가 사용
    ↓ 셋 다 extends
AccountRepository.simpleAccountResult

② 모듈을 넘어 재사용<association>extends 로 다른 매퍼의 매핑을 끌어씁니다. 등록자/수정자 매핑(AccountRepository.accountRefResult)은 현재 21곳에서 <association> 으로 참조됩니다. 애노테이션으로는 columnPrefix·extends·<collection> 을 표현할 수 없고 매퍼 간 공유도 안 됩니다.

typeHandler 는 양쪽에 다 씁니다

typeHandler 가 XML 전용이라 XML 을 쓰는 게 아닙니다. 방향이 달라서 둘 다 필요합니다.

위치방향
{X}DslSqlColumn쓰기 — insert/update 파라미터 바인딩
XML resultMap읽기 — 조회 결과 파싱
java
public final SqlColumn<JsonMap> config =
    column("bd_config", JDBCType.LONGVARCHAR, "com.unvus.iflex.core.config.mybatis.type.VarcharJsonTypeHandler");

제네릭 CRUD 는 insert/update 파라미터를 XML 이 아니라 Dsl 정의에서 렌더링합니다. 그래서 Dsl 에 빠뜨리면 읽기는 되는데 저장에서 Type ...JsonMap not supported type 로 실패합니다 (DslJsonMapTypeHandlerTest 가 전 Dsl 을 전수 검사).

엔터티당 resultMap 은 2종(resultMap = 엔터티, {X}DtoResult = 응답 DTO)만 유지합니다. 집계처럼 매핑할 곳이 없는 결과는 XML 을 만들지 말고 콜백으로 받으세요.

관련 문서

문서내용
엔터티 만들기새 테이블 추가 순서 (Dsl 이 왜 필요한지)
목록과 페이징provider 로 목록 SQL 작성하기
검색 조건조건 트리를 WHERE 로 변환
MyBatis Dynamic SQL (GitHub)라이브러리 원본
MyBatis Dynamic SQL 공식 문서전체 레퍼런스