엔터티 만들기
이 문서는 게시판(Board) 테이블 하나를 처음부터 끝까지 만들어 봅니다. 새 테이블을 추가할 때 무엇을 얼마나 써야 하는지, 그리고 왜 그것만 쓰면 되는지를 순서대로 따라가는 것이 목표입니다.
다른 문서들이 기능별 레퍼런스라면, 이 문서는 처음 한 번 읽는 순서입니다.
이 문서를 읽고 나면
- 새 테이블에 필요한 파일이 몇 개이고 각각 무슨 일을 하는지 알게 됩니다
- 선언 몇 개만으로 CRUD·검색·페이징이 도는 이유를 알게 됩니다
BoardRepository.xml에resultMap하나면 되는 이유를 알게 됩니다- 그 다음 실무에서 무엇이 더 필요해지는지, 왜 그런지 알게 됩니다
- 이 중 어디까지를 NeoSQL 이 대신 만들어 주는지 알게 됩니다
만들 테이블
제목과 내용이 있고, 삭제해도 흔적이 남는 단순한 게시판입니다.
create table nv_board
(
id bigint auto_increment comment '아이디' primary key,
title varchar(200) not null comment '제목',
content text null comment '내용',
is_deleted tinyint(1) not null comment '삭제 여부',
created_by bigint not null comment '등록자',
created_dt datetime(6) not null comment '등록일시',
modified_by bigint not null comment '수정자',
modified_dt datetime(6) not null comment '수정일시'
) comment '게시판';컬럼은 성격에 따라 셋으로 나뉩니다. 이 구분이 뒤에서 계속 등장합니다.
| 구분 | 컬럼 | 특징 |
|---|---|---|
| 키 | id | PK. DB 가 채번 |
| 업무 데이터 | title content | 사용자가 입력 |
| 삭제 표시 | is_deleted | 물리 삭제 대신 표시만 |
| 감사(audit) | created_by created_dt modified_by modified_dt | 프레임워크가 자동으로 채움 |
전체 그림
작업은 두 막으로 나뉩니다. 1막만 끝내도 CRUD·목록·검색이 전부 동작합니다. 2막은 그 응답을 외부 API 로 내보낼 때 필요한 것들입니다.
| 소스 | 1막 — 여기까지가 기본 | 2막 — 실무용 확장 |
|---|---|---|
Board.java | 엔터티 | — |
BoardDsl.java | 컬럼 지도 | — |
BoardRepository.java | 선언 | provider + fetcher 추가 |
BoardRepository.xml | resultMap 하나 | 응답 resultMap 추가 |
BoardService.java | 위임 + 목록 | 목록 메서드 교체 |
BoardSearchSchema.java | N/A | 검색 화이트리스트 |
BoardDto.java | N/A | 응답 DTO |
1막이 끝나면 이만큼이 됩니다
파일 5개, 직접 쓴 SQL 0줄, 직접 쓴 로직은 서비스 메서드 2개입니다.
- 등록 · 조회 · 수정 · 삭제 (등록자/수정일시 자동)
- 검색 조건 + 정렬 + 페이징 + 총건수 + 순번
- 등록자 이름(계정 조인)까지 포함된 목록
대신 1막은 아무것도 막지 않습니다
검색 조건은 테이블의 모든 컬럼에 열려 있습니다. 프론트가 보내는 대로 조건이 됩니다.
내부 도구나 프로토타입이면 충분하지만, 외부에 나가는 API 라면 닫아야 할 것(민감 컬럼)과 강제해야 할 것(삭제 제외, 소유자 제한)이 생깁니다. 2막이 그 이야기입니다.
1막. 목록·검색까지 동작시키기
1.1 Board.java — 엔터티
테이블 한 행을 담는 평범한 자바 객체입니다.
package com.unvus.iflex.core.modules.board.entity;
import com.unvus.iflex.core.platform.domain.audit.AbstractAuditingEntity;
import com.unvus.iflex.core.platform.pagination.Countable;
import com.unvus.iflex.core.platform.spring.validate.Utf8ByteLength;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import org.apache.ibatis.type.Alias;
import java.io.Serializable;
import java.time.Instant;
import static io.swagger.v3.oas.annotations.media.Schema.AccessMode.READ_ONLY;
@Data
@Alias("board")
@Schema(description = "게시판")
public class Board implements Serializable, AbstractAuditingEntity, Countable {
private static final long serialVersionUID = 1L;
//region ============================================== table columns (nv_board)
@Schema(description = "아이디", accessMode = READ_ONLY)
protected Long id;
@Schema(description = "제목")
@NotBlank(message = "\"제목\" 항목은 필수 입력값입니다.")
@Utf8ByteLength(max = 200, message = "\"제목\" 항목은 최대 200 byte 까지 입력 가능합니다.")
protected String title;
@Schema(description = "내용")
protected String content;
@Schema(description = "삭제 여부")
@NotNull
protected Boolean deleted;
@Schema(description = "등록자")
protected Long createdBy;
@Schema(description = "등록일시", example = "2025-12-31T19:22:35Z")
protected Instant createdDt;
@Schema(description = "수정자")
protected Long modifiedBy;
@Schema(description = "수정일시", example = "2025-12-31T19:22:35Z")
protected Instant modifiedDt;
//endregion
@Schema(description = "순번", accessMode = READ_ONLY)
private long positionIdx;
}붙어 있는 것들이 각각 무슨 일을 하는지 봅시다. 이 중 상당수가 "선언만 하면 프레임워크가 알아서" 에 해당합니다.
@Alias("board")
MyBatis 에 이 클래스의 짧은 별명을 등록합니다. 이게 있으면 XML 에서
<resultMap id="resultMap" type="board">처럼 쓸 수 있습니다. 없으면 매번 type="com.unvus.iflex.core.modules.board.entity.Board" 라고 전체 경로를 적어야 합니다.
implements AbstractAuditingEntity
"이 엔터티는 등록자/등록일시/수정자/수정일시를 가진다" 는 표시입니다. 인터페이스 자체는 getter/setter 선언뿐이지만, 프레임워크가 이 타입을 보고 두 가지를 자동으로 해 줍니다.
이 인터페이스 하나로 자동화되는 것
① 저장할 때 감사 필드 자동 채움 — insert() / update() 가 실행되기 직전, 값이 비어 있으면 현재 로그인 사용자와 현재 시각을 넣어 줍니다.
// NvRepositoryDynamic.applyInsertAudit() 발췌
if (auditingImmutableEntity.getCreatedBy() == null) {
auditingImmutableEntity.setCreatedBy(userId);
}
if (auditingImmutableEntity.getCreatedDt() == null) {
auditingImmutableEntity.setCreatedDt(now);
}즉 서비스 코드에서 board.setCreatedDt(Instant.now()) 를 쓸 일이 없습니다.
② 목록 조회 시 계정 테이블 자동 조인 — 목록에 "등록자 이름" 을 보여주려면 nv_account 를 조인해야 하는데, 이 인터페이스를 구현했으면 프레임워크가 LEFT JOIN 을 알아서 붙입니다 (2막에서 자세히).
등록만 있고 수정이 없는 테이블이라면
로그 테이블처럼 한 번 쌓이면 고치지 않는 경우엔 AbstractAuditingImmutableEntity 를 씁니다. created* 만 요구하며, AbstractAuditingEntity 는 여기에 modified* 를 더한 것입니다.
AbstractAuditingImmutableEntity createdBy, createdDt
↑ extends
AbstractAuditingEntity + modifiedBy, modifiedDtimplements Countable
목록에서 "이 행이 전체 몇 번째인가" 를 담을 자리(positionIdx)를 제공합니다.
게시판 목록에서 "총 137건 중 이 행은 137번" 같은 번호를 매길 때 씁니다. 페이징 결과를 만든 뒤 프레임워크가 알아서 채워 줍니다.
// NvRepositoryDynamic.pageWithRequestInternal() 발췌
if (firstItem instanceof Countable) {
for (Iterator<?> it = result.iterator(); it.hasNext(); idx--) {
((Countable) it.next()).setPositionIdx(base + idx);
}
}번호 컬럼이 필요 없으면 구현하지 않아도 됩니다. instanceof 검사에서 걸러지므로 그냥 넘어갑니다.
@NotBlank / @Utf8ByteLength
입력값 검증입니다. Controller 에서 @Valid 로 받으면 자동으로 걸러집니다.
@Utf8ByteLength 는 이 프로젝트가 추가한 것으로, 글자 수가 아니라 UTF-8 바이트 수를 셉니다. varchar(200) 은 바이트 기준이라 한글은 3바이트씩 먹습니다. 글자 수로 검사하면 "66글자인데 DB 에서 잘림" 같은 사고가 납니다.
@Schema
Swagger(OpenAPI) 문서에 나갈 설명입니다. accessMode = READ_ONLY 는 "응답에는 나오지만 요청으로는 못 받는다" 는 뜻으로, id 나 positionIdx 처럼 서버가 정하는 값에 붙입니다.
1.2 BoardDsl.java — 컬럼 지도
자바 필드 이름과 DB 컬럼 이름을 이어 주는 표입니다. 그리고 그 이상의 역할이 있습니다.
먼저 알아야 할 전제 — 이 프로젝트는 SQL 을 문자열로 쓰지 않습니다
iFlex 는 SQL 문을 손으로 쓰는 대신 MyBatis Dynamic SQL 로 조립하는 것을 지향합니다.
// ❌ 문자열 SQL — 컬럼을 리네임해도 아무도 모릅니다. 런타임에 터집니다
"SELECT * FROM nv_board WHERE bd_title LIKE #{title}"
// ✅ Dynamic SQL — 리네임하면 이 줄이 컴파일 에러가 납니다
select(b.allColumns())
.from(b, b.getAlias())
.where(b.title, isLike("%공지%"))체인은 SQL 키워드가 같은 열에서 끝나도록 들여씁니다. 그래야 자바 코드인데도 SQL 로 읽힙니다.
select ...
from ...
where ...BoardDsl 은 이 라이브러리가 요구하는 "테이블 정의" 입니다. Dynamic SQL 은 b.title 같은 컬럼 객체를 받아 SQL 을 만들기 때문에, 그 객체들을 어딘가에 선언해 두어야 합니다. 그 자리가 {X}Dsl 입니다.
즉 이 파일은 프로젝트가 임의로 만든 규칙이 아니라, 문자열 SQL 을 버리기 위해 치르는 비용입니다. 조인·집계·중첩 괄호 등 실제 쿼리 작성법은 MyBatis Dynamic SQL 문서에서 다룹니다.
package com.unvus.iflex.core.modules.board.entity.support;
import com.unvus.iflex.core.platform.query.NvSqlTable;
import org.apache.commons.collections4.bidimap.DualHashBidiMap;
import org.mybatis.dynamic.sql.SqlColumn;
import java.sql.JDBCType;
import java.time.Instant;
import java.util.Optional;
public class BoardDsl extends NvSqlTable {
public static final String DEFAULT_ALIAS = "b";
//region ============================================== table columns (nv_board)
public final SqlColumn<Long> id = column("id", JDBCType.BIGINT);
public final SqlColumn<String> title = column("title", JDBCType.VARCHAR);
public final SqlColumn<String> content = column("content", JDBCType.LONGVARCHAR);
public final SqlColumn<Boolean> deleted = column("is_deleted", JDBCType.BOOLEAN);
public final SqlColumn<Long> createdBy = column("created_by", JDBCType.BIGINT);
public final SqlColumn<Instant> createdDt = column("created_dt", JDBCType.TIMESTAMP);
public final SqlColumn<Long> modifiedBy = column("modified_by", JDBCType.BIGINT);
public final SqlColumn<Instant> modifiedDt = column("modified_dt", JDBCType.TIMESTAMP);
//endregion
public BoardDsl() {
this(DEFAULT_ALIAS);
}
public BoardDsl(String alias) {
super(() -> Optional.ofNullable("iflexdb"), "nv_board");
this.alias = alias;
}
public static BoardDsl as(String alias) { return new BoardDsl(alias); }
public static BoardDsl defaultAlias() { return new BoardDsl(DEFAULT_ALIAS); }
}왜 이 파일이 따로 있나
Board.java 에도 필드가 있는데 왜 같은 목록을 또 쓸까요? 역할이 다르기 때문입니다.
Board.java | BoardDsl.java | |
|---|---|---|
| 담는 것 | 값 (제목이 "공지사항") | 컬럼 자체 (title 이라는 컬럼) |
| 쓰는 곳 | 서비스 로직, JSON 응답 | SQL 조립 |
| 비유 | 서류 한 장 | 서류 양식의 빈칸 위치표 |
SqlColumn<String> title 은 제목 값이 아니라 "title 컬럼"이라는 좌표입니다. 이게 있으면 SQL 을 문자열이 아니라 자바 코드로 조립할 수 있습니다.
BoardDsl b = BoardDsl.defaultAlias();
//@formatter:off
select(b.allColumns())
.from(b, b.getAlias())
.where(b.deleted, isEqualTo(false))
.orderBy(b.desc(b.createdDt))
//@formatter:on이게 왜 좋은가 — 오타가 컴파일 에러가 됩니다
.where("delted = 0") // ❌ 런타임에 SQL 문법 오류
.where(b.delted, isEqualTo(false)) // ✅ 컴파일 에러 (그런 필드 없음)컬럼 이름을 바꾸면 BoardDsl 한 곳만 고치면 되고, 미처 못 고친 곳은 빌드가 잡아 줍니다. 문자열로 SQL 을 쓰면 배포한 뒤 그 화면을 열어 봐야 압니다.
타입도 지켜집니다. SqlColumn<Boolean> deleted 에 isEqualTo("아니오") 를 넣으면 컴파일이 실패합니다.
컬럼을 선언하면 SQL 이 따라옵니다
이 파일에서 직접 쓰는 건 컬럼 선언 한 줄씩이 전부입니다. 그런데 이걸로
public final SqlColumn<String> title = column("title", JDBCType.VARCHAR);INSERT · UPDATE · DELETE · SELECT 문이 전부 자동으로 만들어집니다.
boardService.add(board); // INSERT INTO nv_board (id, title, …) VALUES (…)
boardService.modify(board); // UPDATE nv_board SET title = ?, … WHERE id = ?
boardService.get(id); // SELECT b.* FROM nv_board b WHERE b.id = ?
boardService.remove(id); // DELETE FROM nv_board WHERE id = ?SQL 을 한 줄도 쓰지 않았는데 네 문장이 다 나옵니다. 컬럼을 하나 추가하면 그 컬럼이 INSERT 와 UPDATE 에 자동으로 끼어들고, 이름을 바꾸면 SQL 도 따라 바뀝니다.
어떻게 되는 건가요
NvSqlTable 이 이 클래스의 SqlColumn 필드를 모아 "필드명 → 컬럼" 사전을 만들고, 제네릭 CRUD 가 그걸 순회해 문장을 조립합니다.
// NvRepositoryDynamic.insert() 발췌
InsertDSL<T> insertDSL = SqlBuilder.insert(t).into(sqlTable);
sqlTable.getFieldMap().forEach((k, v) -> {
insertDSL.map(v).toProperty(k); // v=컬럼, k=자바 프로퍼티명
});사전은 선언된 필드에서 자동으로 만들어집니다. 계산 컬럼을 넣거나 특정 컬럼을 빼야 하는 드문 경우에만 getFieldMap() 을 재정의합니다.
컬럼을 추가·변경할 땐 세 파일을 같이 고칩니다
Board.java— 필드BoardDsl.java—SqlColumn선언BoardRepository.xml—resultMap항목
resultMap 만은 손으로 맞춰야 합니다. 빠뜨리면 저장은 되는데 조회 결과에만 안 담깁니다.
PK 는 어떻게 알아보나
NvSqlTable.getIdKey() 가 기본값으로 "id" 를 돌려줍니다. 즉 id 라는 이름의 컬럼이 PK 입니다. 위 WHERE id = ? 가 여기서 나옵니다.
PK 필드 이름이 id 가 아니라면 재정의합니다. 실제 TermDsl 이 그런 경우입니다(PK 가 code).
@Override
public String getIdKey() {
return "code";
}PK 가 두 개 이상이라면
getIdKeys() 로 전부 선언합니다.
// TermContentDsl — PK 가 (tc_code, tc_revision)
@Override
public List<String> getIdKeys() {
return List.of("code", "revision");
}update 는 두 키를 모두 조건에 걸어 정상 동작하지만, get(id) / remove(id) 는 쓸 수 없습니다 — 값 하나로는 행을 지목할 수 없기 때문입니다. 부르면 예외로 막습니다.
IllegalStateException: 복합 PK 테이블에는 단일키 get 을(를) 쓸 수 없습니다
— TermContentDsl [code, revision]. 전체 키로 조건을 거는 전용 메서드를 만들 것TermContentRepository.getByCodeRevision 처럼 전용 메서드를 만들어 쓰세요.
1.3 BoardRepository.java — 선언만
여기서부터가 재미있습니다. 메서드를 하나도 안 만듭니다.
package com.unvus.iflex.core.modules.board.repository;
import com.unvus.iflex.core.config.mybatis.support.DefaultMapper;
import com.unvus.iflex.core.modules.board.entity.Board;
import com.unvus.iflex.core.modules.board.entity.support.BoardDsl;
import com.unvus.iflex.core.platform.support.TableInfo;
import com.unvus.iflex.core.platform.support.generic.NvRepositoryDynamic;
import org.springframework.stereotype.Repository;
@Repository
@DefaultMapper
@TableInfo(dsl = BoardDsl.class)
public interface BoardRepository extends NvRepositoryDynamic<Board, Long> {
}이 빈 인터페이스가 아래 메서드를 전부 갖습니다.
| 메서드 | 하는 일 |
|---|---|
get(id) | PK 로 한 건 조회 |
list(builder, sortBy...) | 조건 목록 (페이징 없음) |
page(builder, page, sortBy...) | 조건 목록 (페이징) |
pageWithRequest(builder, ...) | 요청 페이징 정보로 목록 + 총건수 + 순번 |
count(builder) | 총건수 |
insert(entity) | 등록 (감사 필드 자동, PK 회수) |
insertSelective(entity) | 등록 (null 인 컬럼은 제외 → DB 기본값 유지) |
update(entity) | 전체 수정 |
updateSelective(entity) | null 이 아닌 컬럼만 수정 |
delete(id) | 물리 삭제 |
NvRepositoryDynamic 의 default 메서드로 전부 구현되어 있어서, 상속만 하면 따라옵니다.
세 애노테이션
@Repository — 스프링 빈으로 등록합니다.
@DefaultMapper — MyBatis 매퍼로 스캔 대상임을 표시합니다.
@TableInfo(dsl = BoardDsl.class) — 가장 중요합니다. 제네릭 메서드가 "어느 테이블을 다루는지" 알아내는 유일한 통로입니다.
NvRepositoryDynamic 은 인터페이스라서 필드를 가질 수 없습니다. 그래서 실행 시점에 이 애노테이션을 읽어 BoardDsl 인스턴스를 만들고, 거기서 테이블명·컬럼맵·PK 를 얻습니다.
// NvRepositoryDynamic 내부
private NvSqlTable getNvSqlTable() {
return getNvSqlTable(getTableInfo().dsl()); // @TableInfo 에서 Dsl 클래스를 읽어 new 한다
}PK 채번 방식은 지정하지 않아도 됩니다
@TableInfo 의 keyStrategy 는 기본값이 AUTO 이고, 실행 중인 DB 종류를 보고 알아서 고릅니다.
| DB | 해석 | 동작 |
|---|---|---|
| MySQL / MariaDB | IDENTITY | auto_increment, 생성된 키를 회수 |
| SQL Server | IDENTITY | identity |
| PostgreSQL | IDENTITY | generated as identity |
| Oracle | SEQUENCE | seq_nv_board_id 에서 미리 채번 후 INSERT |
같은 코드가 4개 DBMS 에서 그대로 동작하는 이유입니다. 시퀀스 이름은 seq_<테이블명>_id 관례를 따르며, 다르면 sequenceName 으로 지정합니다.
PK 를 직접 지정하는 테이블(코드값이 PK 등)은 keyStrategy = KeyStrategy.ASSIGNED 를 씁니다.
1.4 BoardRepository.xml — resultMap 하나
여기가 이 문서에서 가장 놀라운 부분입니다.
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.unvus.iflex.core.modules.board.repository.BoardRepository">
<resultMap id="resultMap" type="board">
<id property="id" column="id" jdbcType="BIGINT"/>
<result property="title" column="title" jdbcType="VARCHAR"/>
<result property="content" column="content" jdbcType="LONGVARCHAR"/>
<result property="deleted" column="is_deleted" jdbcType="BOOLEAN"/>
<result property="createdBy" column="created_by" jdbcType="BIGINT"/>
<result property="createdDt" column="created_dt" jdbcType="TIMESTAMP"/>
<result property="modifiedBy" column="modified_by" jdbcType="BIGINT"/>
<result property="modifiedDt" column="modified_dt" jdbcType="TIMESTAMP"/>
</resultMap>
</mapper>끝입니다. <select> 도, <insert> 도, <update> 도, <delete> 도 없습니다.
왜 이거면 되나
앞서 본 10개 메서드가 실제로 어떻게 실행되는지 따라가 봅시다. NvRepositoryDynamic 의 맨 아래에 이런 메서드들이 있습니다.
@ResultMap("resultMap") // ← XML 참조
@SelectProvider(type = SqlProviderAdapter.class, method = "select")
T _selectOne(SelectStatementProvider selectStatement);
@ResultMap("resultMap") // ← XML 참조
@SelectProvider(type = SqlProviderAdapter.class, method = "select")
List<T> _selectMany(SelectStatementProvider selectStatement);
@InsertProvider(type = SqlProviderAdapter.class, method = "insert") // ← XML 참조 없음
int _insert(InsertStatementProvider<T> insertStatement);
@UpdateProvider(type = SqlProviderAdapter.class, method = "update") // ← XML 참조 없음
int _update(UpdateStatementProvider updateStatement);
@DeleteProvider(type = SqlProviderAdapter.class, method = "delete") // ← XML 참조 없음
int _delete(DeleteStatementProvider deleteStatement);읽어야 할 지점은 @ResultMap 이 붙은 메서드가 조회 둘뿐이라는 것입니다.
- SQL 문장은 전부
SqlProviderAdapter가 자바에서 만듭니다 → XML 불필요 - 결과를 객체로 바꾸는 규칙만 XML 이 필요합니다 → 그게
resultMap - INSERT / UPDATE / DELETE 는 돌려받을 객체가 없습니다 → 아예 필요 없음
정리하면 이렇습니다.
insert / update / delete 자바가 SQL 생성 → XML 0줄
get / list / page / count 자바가 SQL 생성 → 결과 매핑용 resultMap 1개만직접 확인해 본 결과
위 XML(=resultMap 하나)만 파싱하고 리포지토리 인터페이스를 붙였을 때 실제로 바인딩되는 문장입니다.
_selectOne, _selectMany,
_insert, _insertWithGeneratedKey, _update, _delete,
_selectSequenceNextvalXML 에 <select> / <insert> 문을 한 개도 쓰지 않았는데 7개가 전부 준비됩니다.
id 는 반드시 "resultMap" 이어야 합니다
@ResultMap("resultMap") 이 문자열로 하드코딩되어 있습니다. 이름을 boardResult 같은 걸로 바꾸면 애플리케이션이 뜰 때 이렇게 실패합니다.
IncompleteElementException:
Could not find result map '....BoardRepository.resultMap'
referenced from '....BoardRepository._selectOne'namespace 도 리포지토리 인터페이스의 전체 경로와 정확히 같아야 합니다. 오타가 나면 "매퍼는 등록됐는데 메서드를 부르면 문장이 없다" 는 형태로 나타납니다.
namespace 가 정확하면 매퍼 등록이 자동입니다
MyBatis 는 XML 을 읽고 나서 namespace 문자열을 클래스 이름으로 해석해 보고, 실제로 그런 인터페이스가 있으면 매퍼로 자동 등록합니다.
그래서 BoardRepository 를 어디에 등록하는 코드가 따로 없는데도 동작합니다. namespace 를 정확히 쓰는 것이 단순한 관례가 아니라 연결 그 자체인 이유입니다.
1.5 BoardService.java — 위임 + 목록
package com.unvus.iflex.core.modules.board.service;
import com.unvus.iflex.core.modules.board.entity.Board;
import com.unvus.iflex.core.modules.board.entity.support.BoardDsl;
import com.unvus.iflex.core.modules.board.repository.BoardRepository;
import com.unvus.iflex.core.platform.query.tree.NvQuery;
import com.unvus.iflex.core.platform.support.generic.NvServiceDynamicSupport;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class BoardService extends NvServiceDynamicSupport<Board, Long, BoardRepository> {
public BoardService(BoardRepository repository) {
super(repository);
}
/** 검색 조건 + 페이징 목록 — 총건수·순번까지 채워진다 */
public List<Board> listBoard(NvQuery q) {
BoardDsl b = BoardDsl.defaultAlias();
return repository.pageWithRequest(repository.searchQuery(q, b));
}
/** 검색 조건에 맞는 총건수 */
public long countBoard(NvQuery q) {
return repository.count(q);
}
}CRUD 는 생성자만으로 상속되고, 목록·검색은 위 두 메서드가 전부입니다.
searchQuery 에 Dsl 을 반드시 넘기세요
searchQuery(NvQuery query, NvSqlTable... tables) 는 가변 인자라 테이블을 빼먹어도 컴파일됩니다.
repository.searchQuery(q) // ❌ 컴파일 통과 — 그런데 조건이 전부 사라진다
repository.searchQuery(q, b) // ✅빠뜨리면 경로를 해석할 테이블이 없어 모든 조건이 조용히 드롭되고, 필터 없는 전체 목록이 나갑니다. 에러도 로그도 없습니다.
리포지토리와 마찬가지로 아래 메서드는 상속으로 얻습니다.
| 서비스 메서드 | 위임 대상 |
|---|---|
get(id) | repository.get(id) |
list(params, sortBy...) | repository.list(...) |
page(params, page, sortBy...) | repository.page(...) |
pageWithRequest(params) | repository.pageWithRequest(...) |
count(params) | repository.count(...) |
add(entity) | repository.insert(...) |
modify(entity) | repository.update(...) |
modifySelective(entity) | repository.updateSelective(...) |
remove(id) | repository.delete(id) |
제네릭 3개 타입이 하는 일
NvServiceDynamicSupport<Board, Long, BoardRepository>| 자리 | 값 | 의미 |
|---|---|---|
| 1번째 | Board | 다루는 엔터티 |
| 2번째 | Long | PK 타입 |
| 3번째 | BoardRepository | 리포지토리 |
세 번째에 NvRepositoryDynamic 같은 상위 인터페이스가 아니라 구체 타입을 주는 게 포인트입니다. 이 덕분에 서비스에서 repository.listBoardByCode(...) 처럼 여러분이 직접 추가한 메서드에도 캐스팅 없이 접근할 수 있습니다.
다른 클래스를 상속해야 한다면
자바는 단일 상속이라 NvServiceDynamicSupport 를 못 쓰는 경우가 있습니다. 그럴 땐 NvServiceDynamic 인터페이스를 직접 구현하고 defaultRepository() 만 만들어 주면 됩니다.
@Service
public class BoardService extends SomeOtherBase implements NvServiceDynamic<Board, Long> {
private final BoardRepository repository;
public BoardService(BoardRepository repository) { this.repository = repository; }
@Override
public NvRepository<Board, Long> defaultRepository() { return repository; }
}1.6 1막 결과
파일 6개 — 그중 다섯은 선언이고, 직접 쓴 로직은 서비스의 두 메서드뿐입니다. 이걸로 다음이 전부 동작합니다.
CRUD
// 등록 — createdBy / createdDt / modifiedBy / modifiedDt 자동, id 는 채번 후 회수
Board board = new Board();
board.setTitle("공지사항");
board.setContent("반갑습니다");
board.setDeleted(false);
boardService.add(board);
Long newId = board.getId(); // ← 채번된 PK 가 들어와 있다
// 조회
Board found = boardService.get(newId);
// 수정 — modifiedBy / modifiedDt 자동 갱신
found.setTitle("공지사항 (수정)");
boardService.modify(found);
// 일부만 수정 — null 인 필드는 건드리지 않는다
Board patch = new Board();
patch.setId(newId);
patch.setTitle("제목만 변경");
boardService.modifySelective(patch);
// 삭제 (물리 삭제 — 아래 주의사항 참고)
boardService.remove(newId);목록과 검색
프론트가 보내는 조건 트리를 그대로 받아 넘기면 됩니다.
// GET /api/board?q={"conds":[{"field":"b.title","op":"lf","val":"공지"}]}
List<Board> list = boardService.listBoard(q);
long total = boardService.countBoard(q);만들어지는 SQL 입니다.
select b.* from iflexdb.nv_board b
where b.title like ? -- %공지%
order by b.id desc
limit ? offset ?여기까지가 1막입니다. 검색 조건 해석, 화이트리스트 검증, 정렬, 페이징, 총건수, 순번(positionIdx)이 전부 포함돼 있습니다. SQL 은 한 줄도 쓰지 않았습니다.
등록자 이름도 이미 나옵니다
Board 가 AbstractAuditingEntity 를 구현했으므로 목록 조회 시 nv_account 가 자동으로 조인되고, XML 의 resultMap 이 createdUser 로 매핑합니다.
즉 1막 상태에서도 "등록자: 홍길동" 을 보여줄 수 있습니다.
is_deleted 는 프레임워크가 알아서 해 주지 않습니다
컬럼을 is_deleted 로 만들어 두었다고 remove(id) 가 소프트 삭제로 바뀌지는 않습니다. remove(id) 는 DELETE FROM nv_board WHERE id = ? 를 실행해 행을 지웁니다.
소프트 삭제는 직접 만들어야 하며, 방법은 아래에서 다룹니다.
2막. 실무용으로 확장하기
1막으로 CRUD·목록·검색이 다 동작합니다. 그런데 응답이 List<Board> — 엔터티 그 자체입니다.
내부 도구나 프로토타입이라면 여기서 멈춰도 됩니다. 하지만 외부에 나가는 API 라면 엔터티를 그대로 내보내는 것이 곧 문제가 됩니다.
2막에서 만드는 둘은 "화면 단위" 입니다
1막의 파일들은 테이블 하나에 하나씩입니다 — Board, BoardDsl 은 nv_board 와 1:1 입니다.
2막의 둘은 다릅니다.
| 파일 | 무엇에 매인가 |
|---|---|
{X}SearchSchema | 그 화면에서 검색해도 되는 것 |
{X}Dto | 그 화면이 받을 결과 |
같은 테이블이라도 화면이 다르면 열 것과 내보낼 것이 다릅니다. 실제로 nv_account 한 테이블에 이렇게 셋이 붙어 있습니다.
| 화면 | 검색 스키마 | 응답 DTO |
|---|---|---|
| 관리자 목록 | AdminSearchSchema (5개 경로) | AdminDto |
| 회원 목록 | MemberSearchSchema (26개 경로) | MemberDto |
| 계정 공통 | — | AccountDto |
관리자 화면에 회원용 26개 경로를 열어 줄 이유가 없습니다. 화면이 늘면 이 둘도 늘어납니다.
이름은 용도에 맞게 붙이면 됩니다
{X}Dto 는 기본형일 뿐 고정 규칙이 아닙니다. 한 화면에서 목록과 상세의 모양이 달라지면 나눕니다.
BoardDto 목록·상세 공용 (대부분 이걸로 충분)
BoardListDto 목록용 — 본문 같은 큰 컬럼 제외
BoardDetailDto 상세용 — 자식 목록 포함
BoardExcelDto 엑셀용 — 다운로드 컬럼만검색 스키마도 마찬가지입니다 — 공개 API 를 더 좁게 열고 싶으면 BoardPublicSearchSchema 를 따로 두고 provider 에서 명시적으로 넘깁니다.
나누는 기준은 "같이 바뀌는가" 입니다. 목록과 상세가 항상 함께 바뀐다면 한 파일로 두세요. 따로 바뀌기 시작하면 그때 나눕니다.
2.1 1막의 응답으로는 부족한 이유
목록 화면이 요구하는 것과 엔터티가 가진 것이 어긋납니다.
문제 1 — 화면에 필요한 게 엔터티에 없다
목록에는 보통 "등록자: 홍길동" 처럼 사람 이름이 나옵니다. 그런데 Board 가 가진 건 createdBy = 42 라는 숫자뿐입니다. 이름은 nv_account 에 있습니다.
엔터티에 Account createdUser 필드를 넣어 해결할 수도 있지만, 그러면 테이블 한 행을 담는 그릇이 아니게 됩니다. insert 는 이 필드로 뭘 해야 할까요? 아무것도 안 합니다. 저장에는 없고 조회에만 있는 필드가 섞이기 시작합니다.
문제 2 — 나가면 안 되는 게 나간다
엔터티에는 내부용 컬럼이 섞여 있습니다. deleted 는 서버가 판단할 값이지 클라이언트가 볼 값이 아닙니다. 회원 테이블이라면 비밀번호 해시나 주민번호가 여기 해당합니다.
엔터티를 그대로 반환하면 필드를 추가할 때마다 "이거 나가도 되나" 를 매번 점검해야 합니다. 언젠가는 빠뜨립니다.
문제 3 — 응답 형태가 DB 사정에 끌려다닌다
컬럼 이름을 바꾸거나 타입을 조정하면 프론트엔드가 깨집니다. DB 리팩터링이 API 변경이 되어 버립니다.
그래서 역할을 둘로 나눕니다
Board (엔터티) | BoardDto (응답 DTO) | |
|---|---|---|
| 대표하는 것 | 테이블 한 행 | API 응답 한 건 |
| 바뀌는 이유 | 스키마가 바뀔 때 | 화면 요구가 바뀔 때 |
| 등록자 | Long createdBy | AccountRef createdUser (id + 이름) |
deleted | 있음 | 없음 |
| 순번 | — | positionIdx |
두 클래스가 서로 다른 이유로 바뀌기 때문에 나눕니다. 한 클래스가 두 이유로 바뀌면 한쪽을 고칠 때 다른 쪽이 깨집니다.
MapStruct 같은 걸로 변환하면 되지 않나요
보통은 그렇게 합니다. 조회한 엔터티를 DTO 로 옮기는 매퍼를 두는 방식이죠.
// 목록에서는 이렇게 하지 않습니다
List<Board> boards = repository.page(...);
List<BoardDto> result = boardMapper.toResponseList(boards);목록에서 이걸 피하는 이유는 두 가지입니다.
- 한 번 더 도는 루프 — 100건이면 100번, 1000건이면 1000번. 조회는 이미 끝났는데 변환만 O(n) 이 더 붙습니다.
- N+1 을 부릅니다 — 더 큰 문제입니다. 변환 시점에는 엔터티만 손에 있어서,
createdUser를 채우려면 계정을 다시 조회하게 됩니다. DB 에서 이미 조인해 온 정보를 버리고 다시 묻는 셈입니다.
이 프로젝트는 MyBatis 가 조회 결과를 곧바로 BoardDto 로 매핑합니다. 변환 루프가 아예 없고 쿼리도 한 번입니다. 그 장치가 이어서 만들 resultMap + fetcher 조합입니다.
그래도 MapStruct 를 쓰는 곳이 있습니다
상세 화면의 자식 목록입니다. 배너 하나에 딸린 배너 아이템처럼, 이미 메모리에 있는 소수의 객체를 옮기는 경우입니다.
// BannerService — 상세의 자식 변환
var resp = bannerResponseMapper.toItemResponse(item);목록과 달리 건수가 적고 추가 조회가 없어 위 두 문제가 생기지 않습니다. 현재 Banner 와 MsgTargetGroup 두 곳이 이 패턴을 씁니다.
기준은 하나입니다 — 목록은 직접 매핑, 상세의 자식은 매퍼.
2.2 BoardSearchSchema.java — 검색 화이트리스트
1막에서 검색이 이미 동작했습니다. 그런데 그때는 테이블의 모든 컬럼이 열려 있었습니다 — 프론트가 보내는 대로 조건이 됐습니다.
이제 그걸 닫습니다. "이 목록에서 검색해도 되는 것"의 명단을 서버가 갖는 것이 이 파일입니다.
명단에는 항목마다 셋을 적습니다.
| 적는 것 | 예 | 쓰이는 곳 |
|---|---|---|
| 경로 | "b.title" | 프론트가 보낸 field 와 대조 — 없으면 그 조건은 버려집니다 |
| 값 타입 | String.class | 넘어온 값을 이 타입으로 변환. 실패하면 버려집니다 |
| 허용 연산자 | (생략 시 타입에서 유도) | like 를 허용할지, eq 만 받을지 |
조건 트리를 WHERE 로 바꾸는 NvQueryBinder 가 조건 하나하나를 이 명단과 대조합니다. 명단에 없으면 SQL 에 넣지 않습니다.
그래서 이 파일을 쓸 때 하는 일은 컬럼을 옮겨 적는 게 아니라 무엇을 열지 고르는 것입니다. 아래 예제에서 deleted 가 빠진 것도 그래서입니다.
package com.unvus.iflex.core.modules.board.dto;
import com.unvus.iflex.core.platform.query.frag.Oper;
import com.unvus.iflex.core.platform.query.tree.NvSearchSchema;
import java.time.Instant;
import java.util.Set;
public enum BoardSearchSchema implements NvSearchSchema {
ID ("b.id" , Long.class),
TITLE ("b.title" , String.class),
CONTENT ("b.content" , String.class),
CREATED_BY ("b.createdBy" , Long.class),
CREATED_DT ("b.createdDt" , Instant.class),
MODIFIED_BY ("b.modifiedBy", Long.class),
MODIFIED_DT ("b.modifiedDt", Instant.class),
;
private final String path;
private final Class<?> valueType;
private final Set<Oper> ops;
BoardSearchSchema(String path, Class<?> valueType, Oper... ops) {
this.path = path;
this.valueType = valueType;
this.ops = NvSearchSchema.opsOrDefault(valueType, ops);
}
@Override public String path() { return path; }
@Override public Class<?> valueType() { return valueType; }
@Override public boolean allows(Oper op) { return ops.contains(op); }
}왜 이렇게까지 하나
검색 조건은 클라이언트가 마음대로 바꿉니다
검색 조건은 브라우저가 보내는 JSON 입니다. 개발자 도구를 열면 누구나 고칠 수 있습니다.
화면에는 제목 검색창만 있어도, 요청 본문은 얼마든지 바꿔 보낼 수 있습니다.
{ "ao": "and", "conds": [ { "field": "b.deleted", "op": "eq", "val": false } ] }b.deleted 가 스키마에 없으므로 이 조건은 버려집니다. 만약 열려 있었다면 삭제된 글을 들여다볼 수 있게 됩니다. 회원 테이블이라면 비밀번호 해시를 한 글자씩 맞춰 보는 블라인드 추측 공격이 가능해집니다.
{ "field": "acnt.loginPwd", "op": "la", "val": "$2a$10$K" }그래서 "검색에 쓸 컬럼" 은 열거하는 것이지 컬럼 목록을 복사해 오는 게 아닙니다.deleted 를 뺀 것은 실수가 아니라 의도입니다.
연산자는 안 적어도 됩니다
BoardSearchSchema(path, valueType, ops...) 의 ops 는 가변 인자이고, 비워 두면 값 타입에서 자동으로 유도됩니다.
| 값 타입 | 자동 허용 연산자 |
|---|---|
String | EQ NOT IN NOT_IN IS IS_NOT LIKE LIKE_FULL LIKE_AFTER LIKE_BEFORE |
Boolean | EQ NOT IS IS_NOT |
Instant / LocalDate | DATE_FROM DATE_TO EQ GT LT GTE LTE |
숫자 (Number 하위) | EQ NOT IN NOT_IN IS IS_NOT GT LT GTE LTE |
왜 타입에서 유도하나
문자열에 > 를 쓰거나 숫자에 LIKE 를 쓸 일은 거의 없습니다. 타입이 정해지면 쓸 연산자도 대체로 정해집니다.
전부 손으로 적으면 필드마다 10개씩, 컬럼 20개면 200개를 씁니다. 양이 많아지면 읽히지 않고, 읽히지 않으면 아무도 검토하지 않습니다. 화이트리스트인데 검토가 안 되면 의미가 없습니다.
기본값과 다르게 하고 싶을 때만 뒤에 적습니다. 그러면 예외만 눈에 띕니다.
// 숫자지만 범위 검색만 허용하고 IN 은 막고 싶다
RESULT_CNT ("bh.resultCnt", Integer.class, EQ, NOT, GT, LT, GTE, LTE),기본 규칙이 없는 타입을 쓰면 예외가 나므로, 조용히 넘어가지 않습니다.
리포지토리에 연결
@TableInfo 에 한 줄 추가합니다.
@Repository
@DefaultMapper
@TableInfo(dsl = BoardDsl.class, searchSchema = BoardSearchSchema.class)
public interface BoardRepository extends NvRepositoryDynamic<Board, Long> {
}왜 여기에 선언하나
엔터티 하나에 검색 스키마는 하나입니다. 그런데 예전에는 조회 메서드마다 넘겨야 했습니다.
// 예전 — 메서드가 늘어날수록 같은 걸 반복
return defaultListProvider(q, BoardSearchSchema.values(), audit, sortModel);
return count(q, BoardSearchSchema.values());반복이 문제인 이유는 한 군데만 빠뜨려도 그곳만 화이트리스트가 사라지는데, 그게 조용하다는 점입니다. @TableInfo 로 옮기면 선언 지점이 하나가 되고, 빠뜨리면 예외로 즉시 드러납니다.
// 지금 — 스키마 인자가 사라졌다
return defaultListProvider(q, audit, sortModel);
return count(q);스키마를 넘기는 두 가지 방법
조회 메서드는 스키마를 인자로 받는 형태와 생략하는 형태가 짝으로 있습니다.
| 스키마 명시형 | 스키마 생략형 | |
|---|---|---|
| provider | defaultListProvider(q, schema, audit, sortModel) | defaultListProvider(q, audit, sortModel) |
| 카운트 | count(q, schema) | count(q) |
| QueryBuilder | QueryBuilder.of(q, schema, table) | searchQuery(q, table) |
생략형은 @TableInfo(searchSchema = ...) 를 읽어 스키마를 채웁니다. 따라서 조합은 셋뿐이고, 결과가 각각 다릅니다.
① @TableInfo 에 선언 + 생략형 호출 → 권장
@TableInfo(dsl = BoardDsl.class, searchSchema = BoardSearchSchema.class)
...
return defaultListProvider(q, audit, sortModel); // 스키마 자동② @TableInfo 미선언 → 전 컬럼이 열린다 (1막의 기본값)
@TableInfo(dsl = BoardDsl.class) // searchSchema 없음
...
return defaultListProvider(q, audit, sortModel); // Dsl 의 모든 컬럼이 검색 대상1막에서 검색이 바로 됐던 이유가 이것입니다. 스키마가 없으면 프레임워크가 Dsl 의 fieldMap 전체를 검색 가능 경로로 만듭니다.
③ 그 조회만 다른 스키마 → 명시형 호출
// 공개 API 는 더 좁은 화이트리스트만 허용
return defaultListProvider(q, BoardPublicSearchSchema.values(), audit, sortModel);@TableInfo 에 선언해 두었더라도 명시형이 우선합니다.
미선언은 "검증 없음"입니다
②는 편의를 위한 기본값이지, 안전한 상태가 아닙니다. 테이블의 모든 컬럼이 열립니다.
비밀번호 해시 컬럼이 있는 테이블을 스키마 없이 두면 이게 그대로 실행됩니다.
{"conds":[{"field":"acnt.loginPwd","op":"la","val":"$2a$10$K"}]}where acnt.acnt_login_pwd like '$2a$10$K%'la(뒤에 무엇이 오든)로 한 글자씩 좁히면 해시를 통째로 알아낼 수 있습니다.
판단 기준은 하나입니다 — 이 목록이 외부로 나가는가.
| 상황 | 스키마 |
|---|---|
| 내부 도구·프로토타입, 민감 컬럼 없음 | 생략 가능 |
| 외부 노출 API 또는 민감 컬럼 보유 | 반드시 선언 |
프레임워크는 막지 않습니다. 길을 열어 두고, 닫는 책임은 개발자에게 있습니다.
값 타입별 허용 연산자는 자동 스키마에도 적용됩니다
자동 스키마도 값 타입에서 연산자를 유도합니다. 즉 숫자 컬럼에 LIKE 는 안 됩니다.
JsonMap 이나 enum 처럼 기본 규칙이 없는 타입은 동등 비교(eq/not/in/is)만 허용합니다.
2.3 BoardDto.java
package com.unvus.iflex.core.modules.board.dto;
import com.unvus.iflex.core.modules.user.dto.AccountRef;
import com.unvus.iflex.core.platform.pagination.Countable;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.time.Instant;
@Data
@Schema(description = "게시판 응답")
public class BoardDto implements Countable {
@Schema(description = "아이디")
private Long id;
@Schema(description = "제목")
private String title;
@Schema(description = "내용")
private String content;
@Schema(description = "등록자 아이디")
private Long createdBy;
@Schema(description = "등록 일시")
private Instant createdDt;
@Schema(description = "수정자 아이디")
private Long modifiedBy;
@Schema(description = "수정 일시")
private Instant modifiedDt;
@Schema(description = "순번")
private long positionIdx;
@Schema(description = "등록자")
private AccountRef createdUser;
@Schema(description = "수정자")
private AccountRef modifiedUser;
}엔터티와의 차이가 곧 이 클래스의 존재 이유입니다.
deleted가 없습니다 — 내부 상태라 내보내지 않습니다createdUser/modifiedUser가 있습니다 — 타입은Account엔터티가 아니라AccountRef입니다
AccountRef 를 쓰는 이유
Account 엔터티를 그대로 담으면 게시글 목록 응답에 계정의 모든 필드가 따라 들어옵니다. 비밀번호 관련 필드, 로그인 이력, 2FA 설정까지요.
AccountRef 는 "누구인지 가리키기만 하는" 최소 요약(id, 로그인ID, 이름 정도)입니다. 목록에 이름 하나 보여주려고 계정 전체를 실어 보낼 이유가 없습니다.
2.4 provider 와 fetcher
이제 리포지토리에 조회 SQL 을 만드는 쪽(provider) 과 결과를 DTO 로 받는 쪽(fetcher) 을 붙입니다.
@Repository
@DefaultMapper
@TableInfo(dsl = BoardDsl.class, searchSchema = BoardSearchSchema.class)
public interface BoardRepository extends NvRepositoryDynamic<Board, Long> {
/** 목록/페이징용 SELECT 문을 만든다 — 컬럼 + 등록자·수정자 조인 + WHERE + ORDER BY */
default SelectStatementProvider listBoardProvider(NvQuery q, SortModel sortModel) {
return defaultListProvider(q, defaultAudit(), sortModel);
}
/** 상세용 SELECT 문 — 목록과 같은 프로젝션에 WHERE 만 id 로 좁힌다 */
default SelectStatementProvider getBoardProvider(Long id) {
return defaultGetProvider(id);
}
/** 위 SELECT 문을 실행해 BoardDto 로 직접 매핑한다 */
@ResultMap("boardDtoResult")
@SelectProvider(type = SqlProviderAdapter.class, method = "select")
List<BoardDto> _boardDtoMapper(SelectStatementProvider selectStatement);
}이름이 list/get 인 이유
두 메서드 모두 SelectStatementProvider(SQL 문)를 반환합니다. BoardDto 를 반환하지 않습니다 — 결과 타입은 아래 _boardDtoMapper 의 @ResultMap 이 정합니다.
그래서 이름에 DTO 를 넣지 않고, 무엇을 조회하는가(목록이냐 단건이냐)로 짓습니다. 서비스 메서드 접두어(list / get / page)와 같은 어휘입니다.
이 두 메서드는 사실 없어도 됩니다
눈치채셨을 수도 있는데, 위 두 메서드는 기본형을 그대로 호출하기만 합니다. 그래서 서비스가 곧바로 기본형을 불러도 똑같이 동작합니다.
// 이렇게 해도 됩니다
public BoardDto getBoardDto(Long id) {
return repository._boardDtoMapper(repository.defaultGetProvider(id))
.stream().findFirst().orElse(null);
}그래도 만들어 두기를 권합니다. 이유는 하나입니다 — 나중에 조건이 붙을 자리이기 때문입니다.
확장 포인트로서의 값
지금은 한 줄이지만, 이 쿼리에 무언가 추가될 때 고칠 곳이 모듈당 한 곳으로 고정됩니다.
| 나중에 필요해지는 것 | 예 |
|---|---|
| 서버 강제조건 | deleted = false, type = 'a' (회원/관리자 분리) |
| 조인 | 첨부 개수, 카테고리명 보강 |
| 모듈 기본 정렬 | 버전 목록은 version DESC |
실제로 이 프로젝트의 목록 provider 19개 중 11개가 이미 이런 로직을 담고 있습니다. 처음엔 전부 한 줄이었습니다.
반대로 서비스가 기본형을 직접 부르고 있으면, 조건 하나를 넣기 위해 호출 지점을 전부 찾아 고쳐야 합니다. listBoardProvider 는 BoardService 한 곳에서만 4번(페이징·비페이징·엑셀 등) 호출됩니다. 하나를 빠뜨리면 에러 없이 그 경로만 조건이 빠집니다.
목록에 조건을 걸었으면 상세에도 겁니다
가장 흔한 누락입니다. 목록에서 deleted = false 로 감춘 행이 GET /api/board/123 으로는 그대로 조회됩니다. 목록과 상세는 서로 다른 provider 라 자동으로 맞춰지지 않습니다.
// 목록 — 삭제된 글 제외
default SelectStatementProvider listPageProvider(NvQuery q, SortModel sortModel) {
PageDsl p = PageDsl.defaultAlias();
QueryBuilder qb = searchQuery(q, dsl -> { dsl.and(p.deleted, isEqualTo(false)); return dsl; }, p);
return defaultListProvider(qb, p, defaultAudit(), sortModel);
}
// 상세 — 같은 조건을 건다
default SelectStatementProvider getPageProvider(String code) {
PageDsl p = PageDsl.defaultAlias();
return defaultGetProvider(code, p, defaultAudit(), and(p.deleted, isEqualTo(false)));
}삭제된 행도 봐야 하는 업무(복원·감사 화면)라면 조건을 걸지 않거나 전용 provider 를 따로 둡니다.
조건에 쓴 Dsl 과 넘기는 Dsl 은 같은 인스턴스여야 합니다
PageDsl p = PageDsl.defaultAlias();
defaultGetProvider(code, p, defaultAudit(), and(p.deleted, isEqualTo(false)));
// ↑ ↑ 같은 p다른 인스턴스를 넘기면 별칭이 해석되지 않아 이렇게 렌더됩니다.
from iflexdb.nv_page p ... where iflexdb.nv_page.page_deleted = ?
-- ^^^^^^^^^^^^^^ 별칭 p 가 아님 → 실행 시 unknown column컴파일로는 안 잡히고 실행해야 드러납니다.
상세도 audit 조인을 합니다 — 안 하면 조용히 깨집니다
목록과 상세는 같은 응답 resultMap(boardDtoResult)을 공유합니다. 그 resultMap 은 ins_user_* / upt_user_* 컬럼을 createdUser / modifiedUser 로 매핑합니다.
상세 provider 가 그 컬럼을 select 하지 않으면 에러 없이 createdUser 만 null 이 되고, 화면은 등록자 이름 대신 숫자 ID 를 보여줍니다. 실제로 이 프로젝트에서 발생했던 버그입니다.
defaultGetProvider(id) 는 목록과 같은 프로젝션을 만들어 이 실수를 구조적으로 막습니다.
audit 조인을 넣을지 빼는 기준
판단 기준은 엔터티가 아니라 응답 DTO 입니다.
응답 DTO 에 createdUser/modifiedUser 가 | 쓸 것 |
|---|---|
| 있다 | defaultGetProvider(id) |
| 없다 | defaultGetProvider(id, null) |
없는데 기본형을 쓰면 아무 데도 매핑되지 않을 컬럼을 조인해 옵니다. 동작은 하지만 낭비입니다. 반대로 있는데 null 을 넘기면 등록자가 null 이 됩니다.
그럼 audit 컬럼이 아예 없는 테이블은요
신경 쓰지 않아도 됩니다. 조인 여부는 최종적으로 엔터티가 AbstractAuditingEntity 계열을 구현했는지로 결정되기 때문입니다.
if (audit != null && audit.getInsertJoinKey() != null && isImmutableAuditing()) { …조인… }로그 테이블처럼 등록자 컬럼이 없는 엔터티는 인터페이스를 구현하지 않으므로, audit 을 넘겨도 조인이 붙지 않습니다.
-- ActionLog (audit 인터페이스 미구현) — audit 을 넘겨도 조인 없음
select al.* from iflexdb.nv_action_log al where al.al_id = ?즉 "실수로 켜도 안전" 합니다. 그래도 의도를 드러내려면 응답 DTO 기준(위 표)을 따라 null 을 명시하는 편이 읽기 좋습니다 — 나중에 그 엔터티에 audit 컬럼이 추가돼도 불필요한 조인이 생기지 않습니다.
DatabaseSupport 가 등록되지 않은 환경
defaultGetProvider(id) 는 defaultAudit() 으로 audit 설정을 읽습니다. 이 값은 MyBatisConfig 에서 SqlSessionFactory 를 만들 때 등록되므로 정상 구동에서는 항상 존재합니다.
다만 매퍼 애노테이션이 등록되지 않은 환경(신규 채널 모듈에서 등록을 빠뜨린 경우, 단위 테스트 등) 에서는 null 일 수 있어, audit 없이 조회하도록 폴백합니다. 조회 자체가 실패하는 것보다 낫습니다.
provider 와 fetcher 로 나눈 이유
listBoardProvider 는 SQL 문장을 만들 뿐 실행하지 않습니다. 실행은 _boardDtoMapper 가 합니다.
이렇게 나누면 같은 SQL 을 목록·페이징·엑셀이 공유합니다. 페이징은 여기에 LIMIT 과 총건수 쿼리를 얹고, 엑셀은 페이징 없이 전부 돌립니다. SQL 조립 로직은 한 벌만 유지됩니다.
defaultListProvider 가 대신 해 주는 것
한 줄짜리 provider 안에서 네 가지가 일어납니다.
return defaultListProvider(q, defaultAudit(), sortModel);- 컬럼 선택 —
BoardDsl의 전체 컬럼 - 감사 조인 — 엔터티가
AbstractAuditingEntity이므로nv_account를 두 번LEFT JOIN하고 컬럼에ins_user_/upt_user_접두어를 붙임 - WHERE —
NvQuery조건 트리를BoardSearchSchema로 검증해 조립 - ORDER BY — 요청 정렬을 화이트리스트로 검증, 없으면 PK 역순 폴백
만들어지는 SQL 은 대략 이렇습니다.
SELECT b.id, b.title, ...,
ins_user.acnt_id AS ins_user_acnt_id, ins_user.acnt_name AS ins_user_acnt_name, ...,
upt_user.acnt_id AS upt_user_acnt_id, ...
FROM nv_board b
LEFT JOIN nv_account ins_user ON b.created_by = ins_user.acnt_id
LEFT JOIN nv_account upt_user ON b.modified_by = upt_user.acnt_id
WHERE b.title LIKE ?
ORDER BY b.id DESC접두어가 붙는 이유
등록자와 수정자 모두 nv_account 를 봅니다. 같은 테이블을 두 번 조인하면 acnt_name 이 두 개가 되어 구분이 안 됩니다. 그래서 ins_user_acnt_name / upt_user_acnt_name 으로 별칭을 붙이고, resultMap 이 이 접두어로 어느 쪽인지 구분합니다.
응답 resultMap 추가
_boardDtoMapper 가 참조하는 boardDtoResult 를 XML 에 만듭니다.
<resultMap id="boardDtoResult"
type="com.unvus.iflex.core.modules.board.dto.BoardDto"
extends="resultMap">
<association property="createdUser"
resultMap="com.unvus.iflex.core.modules.user.repository.AccountRepository.accountRefResult"
columnPrefix="ins_user_"/>
<association property="modifiedUser"
resultMap="com.unvus.iflex.core.modules.user.repository.AccountRepository.accountRefResult"
columnPrefix="upt_user_"/>
</resultMap>타입이 다른데 extends 가 되나요
됩니다. MyBatis 의 extends 는 타입이 아니라 프로퍼티 이름 기준으로 동작합니다. resultMap(type=Board)의 title → title 매핑을 BoardDto 가 그대로 물려받습니다.
BoardDto 에 없는 프로퍼티(deleted)는 매핑에서 무시됩니다.
덕분에 컬럼 매핑을 한 번만 쓰면 됩니다. 이 동작은 ResultMapCrossTypeExtendsTest 가 고정하고 있습니다.
생성된 XML 에 simple{X}Result 가 따로 있는 이유
NeoSQL 이 만든 XML 을 보면 컬럼 매핑이 simpleBoardResult 에 있고, resultMap 과 boardDtoResult 가 둘 다 그걸 extends 합니다.
simpleBoardResult 컬럼 매핑만 (연관 없음)
├── resultMap + createdUser : Account (엔터티용)
└── boardDtoResult + createdUser : AccountRef (응답용)같은 createdUser 인데 타입이 다르기 때문입니다. resultMap 을 직접 extends 하면 Account 연관까지 딸려와 덮어쓰기가 됩니다.
지금처럼 resultMap 에 연관이 없다면 바로 extends 해도 됩니다. 엔터티 쪽에 연관을 추가하는 순간 컬럼 전용 베이스를 분리하세요.
2.5 서비스 교체
@Service
public class BoardService extends NvServiceDynamicSupport<Board, Long, BoardRepository> {
public BoardService(BoardRepository repository) {
super(repository);
}
/** 페이징 목록 — LIMIT · 총건수 · 순번은 withRequestDsl 안에서 처리된다 */
public List<BoardDto> pageBoardDto(NvQuery q) {
return InPagination.withRequestDsl(
(p) -> repository.listBoardProvider(q, Pagination.sortModel.get()),
repository::_boardDtoMapper);
}
/** 페이징 없는 목록 — 요청 정렬만 적용 */
public List<BoardDto> listBoardDto(NvQuery q) {
Pagination.primary();
try {
return repository._boardDtoMapper(
repository.listBoardProvider(q, Pagination.sortModel.get()));
} finally {
Pagination.reset();
}
}
/** 상세 */
public BoardDto getBoardDto(Long id) {
return repository._boardDtoMapper(repository.getBoardProvider(id))
.stream().findFirst().orElse(null);
}
}InPagination.withRequestDsl 이 provider 와 fetcher 를 받아 페이징 처리를 감쌉니다. 현재 페이지에 맞는 LIMIT, 총건수 조회, positionIdx 부여가 이 안에서 일어납니다.
총건수 쿼리는 필요할 때만 나갑니다
가져온 행 수가 페이지 크기보다 적으면 그 자체가 마지막 페이지라는 뜻이므로, 총건수를 계산으로 구하고 COUNT(*) 쿼리를 건너뜁니다. 페이지가 하나뿐인 목록에서 쿼리가 절반으로 줍니다.
2.6 흐름 되짚기
목록 요청 하나가 어떻게 흐르는지 한눈에 봅시다.
브라우저
│ GET /api/board?q={"ao":"and","conds":[{"field":"b.title","op":"lf","val":"공지"}]}
▼
BoardResource
│ NvQuery 로 파싱
▼
BoardService.pageBoardDto(q)
│
├─ InPagination.withRequestDsl(provider, fetcher)
│ │
│ ├─ provider ─ listBoardProvider(q, sortModel)
│ │ └ defaultListProvider
│ │ ├ BoardDsl 로 컬럼 목록
│ │ ├ BoardSearchSchema 로 조건 검증 (b.title 은 허용)
│ │ ├ nv_account 2회 조인 (등록자·수정자)
│ │ └ 정렬 검증 + LIMIT
│ │
│ └─ fetcher ─ _boardDtoMapper(sql)
│ └ @ResultMap("boardDtoResult")
│ └ 조회 결과를 BoardDto 로 (변환 루프 없음)
▼
List<BoardDto> ─ JSON각 파일이 딱 한 가지씩만 책임집니다.
| 파일 | 책임 |
|---|---|
BoardDsl | 컬럼이 어디 있는가 |
BoardSearchSchema | 무엇으로 검색해도 되는가 |
listBoardProvider / getBoardProvider | 어떤 SQL 을 만들 것인가 (목록 / 단건) |
boardDtoResult | 결과를 어떤 모양으로 받을 것인가 |
BoardService | 언제 무엇을 부를 것인가 |
소프트 삭제 직접 만들기
앞서 예고한 대로, is_deleted 컬럼이 있어도 프레임워크는 아무것도 하지 않습니다. remove(id) 는 행을 실제로 지웁니다.
소프트 삭제는 두 가지를 직접 해야 합니다.
① 삭제를 UPDATE 로 바꾸기
리포지토리에 전용 메서드를 만듭니다.
/** soft-delete — is_deleted = 1 */
default int markAsDelete(Long id) {
BoardDsl b = BoardDsl.defaultAlias();
//@formatter:off
return _update(SqlBuilder.update(b)
.set(b.deleted).equalTo(true)
.where(b.id, SqlBuilder.isEqualTo(id))
.build().render(RenderingStrategies.MYBATIS3));
//@formatter:on
}서비스는 remove 대신 이걸 부릅니다.
@Transactional
public int removeBoard(Long id) {
return repository.markAsDelete(id);
}② 조회에서 삭제된 행 제외하기
더 중요한 쪽입니다. 지우기만 하고 목록에서 안 빼면 삭제된 글이 계속 보입니다.
여기서 절대 하면 안 되는 것: deleted 를 BoardSearchSchema 에 넣고 프론트에서 조건을 보내는 것. 클라이언트가 보내는 조건은 클라이언트가 뒤집을 수 있습니다. deleted=false 를 deleted=true 로 바꿔 보내면 삭제된 글이 조회됩니다.
서버가 소유해야 합니다. provider 에서 조건을 직접 겁니다.
default SelectStatementProvider listBoardProvider(NvQuery q, SortModel sortModel) {
BoardDsl b = BoardDsl.defaultAlias();
// dslHelper 자리 — 클라이언트가 뒤집을 수 없는 서버 강제조건
QueryBuilder qb = searchQuery(q, (dsl) -> {
dsl.and(b.deleted, isEqualTo(false));
return dsl;
}, b);
return defaultListProvider(qb, b, defaultAudit(), sortModel);
}두 장치가 함께 동작합니다
BoardSearchSchema에deleted를 선언하지 않는다 → 클라이언트가 보내도 버려짐searchQuery의 dslHelper 에서deleted = false를 건다 → 항상 적용됨
전자만 있으면 삭제된 글이 다 보이고, 후자만 있으면 클라이언트가 덮어쓸 수 있습니다. "클라이언트가 뒤집으면 안 되는 조건" 은 스키마에서 빼고 dslHelper 로 건다 — 이게 원칙입니다.
같은 원칙이 회원 목록의 type = MEMBER, 약관 목록의 deleted = false 에도 그대로 적용되어 있습니다.
강제조건은 클라이언트 조건과 어떻게 결합되나
클라이언트 조건은 통째로 괄호에 묶여 강제조건과 AND 됩니다.
where p.page_deleted = ? -- 강제조건
and ( p.page_code = ? or p.page_title = ? ) -- 클라이언트가 보낸 것 전부이렇게 하지 않으면 클라이언트가 최상위 조건에 ao: "or" 를 실어 강제조건 밖으로 빠져나갈 수 있습니다.
{"conds":[{"ao":"or","field":"p.code","op":"isnot","val":null}]}where p.page_deleted = false OR p.page_code is not null -- ❌ 항상 참 → 삭제분까지 전부선언된 컬럼만 써도 성립하므로 화이트리스트로는 막을 수 없습니다. 그래서 바인더가 묶습니다 (NvQueryBinder, ForcedConditionIsolationTest 가 고정).
NeoSQL 로 자동 생성하기
지금까지 만든 파일을 정리하면 이렇습니다.
| 파일 | 성격 |
|---|---|
Board.java | 컬럼 목록에서 기계적으로 나옴 |
BoardDsl.java | 컬럼 목록에서 기계적으로 나옴 |
BoardRepository.java | 정해진 틀 |
BoardRepository.xml | 컬럼 목록에서 기계적으로 나옴 |
BoardService.java | 정해진 틀 |
BoardDto.java | 컬럼 목록 + 감사 필드 치환 |
BoardSearchSchema.java | 컬럼 목록에서 기계적으로 나옴 |
전부 테이블 정의에서 유도됩니다. 그래서 손으로 쓰지 않아도 됩니다.
NeoSQL 은 테이블 스키마를 읽어 1막과 2막의 파일을 모두 생성합니다. 지금까지 한 작업은 "생성된 코드를 읽을 수 있게 되는 것" 이 목적이었지, 매번 이렇게 타이핑하라는 뜻이 아닙니다.
그래도 이 문서를 읽어야 하는 이유
생성기는 테이블에서 유도되는 것만 만듭니다. 업무 규칙은 못 만듭니다.
생성된 코드에 손을 대야 하는 지점은 대체로 이 셋입니다.
BoardSearchSchema— 생성기는 전체 컬럼을 넣습니다. 내보내면 안 되는 컬럼을 빼는 건 사람의 판단입니다- provider 의 dslHelper —
deleted = false같은 서버 강제조건 BoardDto— 화면이 실제로 필요로 하는 필드만 남기기
생성된 코드가 어떻게 도는지 모르면 이 셋을 손댈 수 없습니다.
생성 후 다시 생성할 때
생성기는 마커(codegenie-needle-*) 사이 영역만 다시 씁니다. 직접 추가한 메서드는 마커 바깥, 즉 custom 영역에 두세요. 마커 안에 쓰면 재생성 때 사라집니다.
요약 체크리스트
새 테이블을 추가할 때 확인할 것들입니다.
1막 — CRUD
- [ ] 엔터티에
@Alias("...")를 붙였는가 (XML 의type과 일치) - [ ] 감사 컬럼이 있으면
AbstractAuditingEntity를 구현했는가 - [ ] 목록에 순번이 필요하면
Countable을 구현했는가 - [ ] PK 필드명이
id가 아니면getIdKey()를 재정의했는가 - [ ] PK 가 둘 이상이면
getIdKeys()로 전부 선언했는가 (단일키get/remove는 막힘) - [ ] 리포지토리에
@Repository@DefaultMapper@TableInfo(dsl = ...)이 있는가 - [ ] XML 의 namespace 가 리포지토리 전체 경로와 같은가
- [ ] XML 의 resultMap id 가 정확히
resultMap인가
2막 — 목록
- [ ]
@TableInfo에searchSchema를 선언했는가 - [ ] 검색 스키마에서 내보내면 안 되는 컬럼을 뺐는가
- [ ] 서버 강제조건을 dslHelper 로 걸었는가 (스키마가 아니라)
- [ ] 응답 DTO 에 내부 전용 필드가 섞이지 않았는가
- [ ] 응답 resultMap 이 계정 연관에
AccountRef를 쓰는가 (Account아님) - [ ] provider 이름이
list{X}Provider/get{X}Provider인가 (DTO 이름을 넣지 않았는가) - [ ] 상세 provider 가
defaultGetProvider(id)인가 — 목록과 프로젝션이 어긋나면 등록자가 조용히 null 이 된다
소프트 삭제를 쓴다면
- [ ]
remove(id)대신markAsDelete계열을 부르는가 - [ ] 목록 provider 에
deleted = false강제조건이 걸려 있는가
더 읽을 것
| 문서 | 다루는 내용 |
|---|---|
| 목록과 페이징 | InPagination, 정렬, 엑셀 다운로드 |
| 검색 조건 | 조건 트리 형식, 연산자, 프론트 연동 |
| MyBatis Dynamic SQL | 조인·서브쿼리 등 직접 SQL 조립 |
| 권한 설정 | 메뉴·API 권한 |