데이터 삽입은 모든 애플리케이션의 기본이다. jOOQ는 JPA처럼 편리한 DAO 방식부터 벌크 INSERT까지 다양한 INSERT 패턴을 제공한다
jOOQ INSERT의 5가지 방식
- 자동 생성 DAO 활용: JPA처럼 간편한 저장
- ActiveRecord 패턴: 레코드 객체가 직접 저장
- PK만 반환: INSERT 후 ID만 필요한 경우
- 전체 ROW 반환: INSERT 후 모든 컬럼 필요한 경우
- Bulk Insert: 대량 데이터 한 번에 삽입
자동 생성 DAO를 통한 INSERT
JPA의 save() 메서드처럼 사용할 수 있는 가장 간편한 방식
Repository 구현
@Repository
public class ActorRepository {
private final ActorDao actorDao;
public ActorRepository(Configuration configuration) {
this.actorDao = new ActorDao(configuration);
}
public Actor saveByDao(Actor actor) {
actorDao.insert(actor);
return actor; // PK가 actor 객체에 자동 설정됨
}
}
핵심 특징
Actor actor = new Actor();
actor.setFirstName("John");
actor.setLastName("Doe");
actor.setLastUpdate(LocalDateTime.now());
actorRepository.saveByDao(actor);
// INSERT 후 PK가 자동으로 설정됨
assertThat(actor.getActorId()).isNotNull();
생성된 SQL
INSERT INTO `actor` (`first_name`, `last_name`, `last_update`)
VALUES ('John', 'Doe', {ts '2025-12-28 23:30:00.608476'})
중요한 특징
- jOOQ 3.19부터 지원되는 유일한 자동 PK 설정 방식
jOOQ에서 actorDao.insert(actor) 방식만이 INSERT후 생성된 PK를 POJO에 자동으로 설정한다. 다른 모든 방식은 명시적으로 PK를 조회하거나 반환받어야 한다
테스트
@Test
@DisplayName("자동생성된 DAO를 통한 insert")
@Transactional
void insert_dao() {
// given
Actor actor = new Actor();
actor.setFirstName("John");
actor.setLastName("Doe");
actor.setLastUpdate(LocalDateTime.now());
// when
actorRepository.saveByDao(actor);
// then
assertThat(actor.getActorId()).isNotNull(); // PK 자동 설정
}
ActiveRecord를 통한 INSERT
레코드 객체가 직접 데이터베이스 작업을 수행하는 ActiveRecord 패턴
Repository 구현
@Repository
public class ActorRepository {
private final DSLContext dslContext;
private final JActor ACTOR = JActor.ACTOR;
public ActorRecord saveByRecord(Actor actor) {
// 1. POJO → Record 변환
ActorRecord actorRecord = dslContext.newRecord(ACTOR, actor);
// 2. Record가 직접 INSERT 수행
actorRecord.insert();
return actorRecord;
}
}
코드 분석
newRecord()로 변환
ActorRecord actorRecord = dslContext.newRecord(ACTOR, actor);
newRecord()는 POJO를 ActiveRecord로 변환한다. 변환된 레코드는 자체적으로 CRUD 메서드를 가진다
insert() vs store()
actorRecord.insert(); // 항상 INSERT 수행 actorRecord.store(); // INSERT 또는 UPDATE (상황에 따라)
- insert(): 무조건 새로운 레코드 삽입
- store(): PK 존재 여부에 따라 INSERT 또는 UPDATE 자동으로 결정
중요한 차이점
Actor actor = new Actor();
actor.setFirstName("John");
actor.setLastName("Doe");
actor.setLastUpdate(LocalDateTime.now());
ActorRecord actorRecord = actorRepository.saveByRecord(actor);
// 원본 POJO는 PK가 설정되지 않음
assertThat(actor.getActorId()).isNull();
// 반환된 Record에만 PK가 설정됨
assertThat(actorRecord.getActorId()).isNotNull();
생성된 SQL
INSERT INTO `actor` (`first_name`, `last_name`, `last_update`)
VALUES ('John', 'Doe', {ts '2025-12-28 23:37:03.957247'})
LastUpdate 자동 설정
Actor actor = new Actor();
actor.setFirstName("John");
actor.setLastName("Doe");
// actor.setLastUpdate() 생략 가능
ActorRecord actorRecord = actorRepository.saveByRecord(actor);
last_update 컬럼이 데이터베이스에서 DEFAULT CURRENT_TIMESTAMP로 정의되어 있다면, 명시적으로 설정하지 않아도 자동으로 현재 시간이 입력된다.
주의: 이 경우 반환된 actorRecord에는 lastUpdate 값이 채워지지 않는다
테스트
@Test
@DisplayName("ActiveRecord를 통한 insert")
@Transactional
void insert_by_record() {
// given
Actor actor = new Actor();
actor.setFirstName("John");
actor.setLastName("Doe");
actor.setLastUpdate(LocalDateTime.now());
// when
ActorRecord actorRecord = actorRepository.saveByRecord(actor);
// then
assertThat(actorRecord.getActorId()).isNotNull(); // Record에 PK 설정
assertThat(actor.getActorId()).isNull(); // POJO는 변경 없음
}
SQL 실행 후 PK만 반환
INSERT 후 생성된 PK만 필요한 경우 사용
Repository 구현
public Long saveWithReturningPkOnly(Actor actor) {
return dslContext
.insertInto(
ACTOR,
ACTOR.FIRST_NAME,
ACTOR.LAST_NAME
)
.values(
actor.getFirstName(),
actor.getLastName()
)
.returningResult(ACTOR.ACTOR_ID) // PK만 반환
.fetchOneInto(Long.class);
}
코드 분석
insertInto()와 values()
.insertInto(
ACTOR, // 테이블
ACTOR.FIRST_NAME, // 컬럼 1
ACTOR.LAST_NAME // 컬럼 2
)
.values(
"John", // 값 1
"Doe" // 값 2
)
명시적으로 컬럼과 값을 매핑한다. 순서가 중요하며, 개수가 일치해야 한다
returningResult()
.returningResult(ACTOR.ACTOR_ID) // 특정 컬럼만 반환 .fetchOneInto(Long.class) // Long 타입으로 변환
returningResult()는 INSERT 후 지정한 컬럼의 값만 반환한다
생성된 SQL
INSERT INTO `actor` (`first_name`, `last_name`)
VALUES ('John', 'Doe')
MySQL은 RETURNING 구문을 직접 지원하지 않으므로, jOOQ가 내부적으로 LAST_INSERT_ID()를 사용하여 PK를 가져온다
테스트
@Test
@DisplayName("SQL 실행 후 PK만 반환")
@Transactional
void insert_with_returning_pk() {
// given
Actor actor = new Actor();
actor.setFirstName("John");
actor.setLastName("Doe");
// when
Long pk = actorRepository.saveWithReturningPkOnly(actor);
// then
assertThat(pk).isNotNull();
}
SQL 실행 후 전체 ROW 반환
INSERT 후 모든 컬럼 값이 필요한 경우 사용
Repository 구현
public Actor saveWithReturning(Actor actor) {
return dslContext
.insertInto(
ACTOR,
ACTOR.FIRST_NAME,
ACTOR.LAST_NAME
)
.values(
actor.getFirstName(),
actor.getLastName()
)
.returning(ACTOR.fields()) // 모든 컬럼 반환
.fetchOneInto(Actor.class);
}
코드 분석
returning() vs returningResult()
.returningResult(ACTOR.ACTOR_ID) // 특정 컬럼만 .returning(ACTOR.fields()) // 모든 컬럼
- returningResult(): 지정한 컬럼만 반환
- returning(): 전체 또는 여러 컬럼 반환
생성된 SQL (2개의 쿼리)
-- 1. INSERT
INSERT INTO `actor` (`first_name`, `last_name`)
VALUES ('John', 'Doe')
-- 2. SELECT (자동 생성된 PK로 조회)
SELECT `actor`.`actor_id`,
`actor`.`first_name`,
`actor`.`last_name`,
`actor`.`last_update`
FROM `actor`
WHERE `actor`.`actor_id` IN (205)
중요: returning(ACTOR.fields())는 실제로 2개의 쿼리를 실행
- INSERT 문 실행
- 생성된 PK로 다시 SELECT
이는 MySQL이 PostgreSQL의 RETURNING 절을 지원하지 않기 때문이다. jOOQ가 자동으로 INSERT 후 SELECT를 수행한다
성능 고려사항
// PK로 조회하므로 성능 영향은 미미 WHERE `actor`.`actor_id` IN (205) // Primary Key 조건
PK 기반 조회는 매우 빠르므로 대부분의 경우 성능 문제가 없다. 오히려 별도로 조회 코드를 작성하는 것보다 간편하다
테스트
@Test
@DisplayName("SQL 실행 후 해당 ROW 전체 반환")
@Transactional
void insert_with_returning() {
// given
Actor actor = new Actor();
actor.setFirstName("John");
actor.setLastName("Doe");
// when
Actor newActor = actorRepository.saveWithReturning(actor);
// then
assertThat(newActor).hasNoNullFieldsOrProperties(); // 모든 필드 채워짐
assertThat(newActor.getActorId()).isNotNull();
assertThat(newActor.getLastUpdate()).isNotNull(); // DB 기본값 포함
}
Bulk Insert (대량 삽입)
여러 레코드를 한 번의 쿼리로 삽입하는 가장 효율적인 방식
Repository 구현
public void bulkInsertWithRows(List<Actor> actorList) {
// 1. Actor 리스트 → Row 리스트 변환
var rows = actorList.stream()
.map(actor -> DSL.row(
actor.getFirstName(),
actor.getLastName()
))
.toList();
// 2. 한 번에 삽입
dslContext
.insertInto(
ACTOR,
ACTOR.FIRST_NAME,
ACTOR.LAST_NAME
)
.valuesOfRows(rows) // ⭐ 여러 Row 한 번에
.execute();
}
코드 분석
DSL.row()로 Row 생성
DSL.row(값1, 값2, ...) // Row 객체 생성
DSL.row()는 여러 값을 하나의 Row로 묶는다. 타입 안전성이 보장된다
Row 타입의 제네릭
DSL.row("John", "Doe") // Row2<String, String>
DSL.row("A", "B", 1) // Row3<String, String, Integer>
// ... Row22까지 지원
// Row22 이상은 RowN 타입 사용
jOOQ는 Row1부터 Row22까지 타입별로 최적화되어 있다. 23개 이상의 컬럼은 RowN 타입을 사용한다
var 타입 추론
// 타입이 복잡함
List<Row2<String, String>> rows = actorList.stream()...
// var로 간결하게
var rows = actorList.stream()
.map(actor -> DSL.row(
actor.getFirstName(),
actor.getLastName()
))
.toList();
Java 11+의 var 키워드로 복잡한 제네릭 타입을 생략할 수 있다
valuesOfRows()
.valuesOfRows(rows) // List<Row> 한 번에 삽입
여러 Row를 한 번의 INSERT 문으로 실행한다
생성된 SQL
INSERT INTO `actor` (`first_name`, `last_name`)
VALUES ('John', 'Doe'), ('John 2', 'Doe 2')
단일 INSERT 문에 여러 VALUES가 포함된다
성능 비교
// 나쁜 방식: N번의 INSERT
for (Actor actor : actors) {
actorRepository.save(actor); // INSERT 쿼리 N번
}
// 좋은 방식: 1번의 Bulk INSERT
actorRepository.bulkInsertWithRows(actors); // INSERT 쿼리 1번
1,000개의 레코드를 삽입할 때
- 개별 INSERT: 1,000번 쿼리
- Bulk INSERT: 1번의 쿼리
테스트
@Test
@DisplayName("bulk insert 예제")
@Transactional
void bulk_insert() {
// given
Actor actor1 = new Actor();
actor1.setFirstName("John");
actor1.setLastName("Doe");
Actor actor2 = new Actor();
actor2.setFirstName("John 2");
actor2.setLastName("Doe 2");
List<Actor> actorList = List.of(actor1, actor2);
// when
actorRepository.bulkInsertWithRows(actorList);
// then (트랜잭션 롤백되므로 별도 검증 없음)
}
선택 가이드
// 1. 가장 간편하게 사용하고 싶다면
actorDao.insert(actor); // ✅ PK 자동 설정
// 2. ActiveRecord 패턴을 선호한다면
ActorRecord record = dslContext.newRecord(ACTOR, actor);
record.insert();
// 3. PK만 필요하다면
Long pk = dslContext.insertInto(ACTOR, ...)
.values(...)
.returningResult(ACTOR.ACTOR_ID)
.fetchOneInto(Long.class);
// 4. DB 기본값이 설정된 컬럼까지 필요하다면
Actor newActor = dslContext.insertInto(ACTOR, ...)
.values(...)
.returning(ACTOR.fields())
.fetchOneInto(Actor.class);
// 5. 대량 삽입이라면
dslContext.insertInto(ACTOR, ...)
.valuesOfRows(rows)
.execute();
추가 패턴 – INSERT…SELECT
SELECT 결과가 바로 INSERT 하는 패턴도 가능하다
dslContext
.insertInto(
TARGET_TABLE,
TARGET_TABLE.COLUMN1,
TARGET_TABLE.COLUMN2
)
.select(
dslContext.select(
SOURCE_TABLE.COLUMN_A,
SOURCE_TABLE.COLUMN_B
)
.from(SOURCE_TABLE)
.where(SOURCE_TABLE.STATUS.eq("ACTIVE"))
)
.execute();
생성된 SQL
INSERT INTO `target_table` (`column1`, `column2`) SELECT `source_table`.`column_a`, `source_table`.`column_b` FROM `source_table` WHERE `source_table`.`status` = 'ACTIVE'
이 패턴은 데이터 마이그레이션이나 통계 테이블 생성 시 유용하다
실전 팁
Transactional 필수
@Transactional
public void saveActors(List<Actor> actors) {
actors.forEach(actorDao::insert);
}
INSERT 작업은 반드시 트랜잭션 안에서 수행해야 한다
Batch Insert 크기 조절
public void bulkInsertInBatches(List<Actor> actors, int batchSize) {
Lists.partition(actors, batchSize).forEach(batch -> {
var rows = batch.stream()
.map(actor -> DSL.row(
actor.getFirstName(),
actor.getLastName()
))
.toList();
dslContext.insertInto(ACTOR, ...)
.valuesOfRows(rows)
.execute();
});
}
너무 큰 Bulk INSERT는 오히려 성능 저하를 일으킬 수 있다. 일반적으로 500 ~ 1,000개씩 나누는 것이 좋다
ON DUPLICATE KEY UPDATE
MySQL의 UPSERT 패턴도 지원한다
dslContext
.insertInto(ACTOR, ACTOR.ACTOR_ID, ACTOR.FIRST_NAME, ACTOR.LAST_NAME)
.values(1L, "John", "Doe")
.onDuplicateKeyUpdate()
.set(ACTOR.FIRST_NAME, "John")
.set(ACTOR.LAST_NAME, "Doe")
.execute();
생성된 SQL
INSERT INTO `actor` (`actor_id`, `first_name`, `last_name`)
VALUES (1, 'John', 'Doe')
ON DUPLICATE KEY UPDATE
`first_name` = 'John',
`last_name` = 'Doe'
DEFAULT 값 명시
dslContext
.insertInto(ACTOR, ACTOR.FIRST_NAME, ACTOR.LAST_NAME, ACTOR.LAST_UPDATE)
.values("John", "Doe", DSL.defaultValue(ACTOR.LAST_UPDATE))
.execute();
DB의 기본값을 명시적으로 사용할 수 있다
JOOQ의 INSERT 기능
- 다양한 방식: DAO, ActiveRecord, SQL, Bulk 등 상황에 맞게 선택
- 타입 안전성: 컴파일 타임에 컬럼/값 불일치 검증
- 성능 최적화: Bulk Insert로 대량 삽입 최적화
- 유연성: INSERT…SELECT, UPSERT 등 고급 패턴 지원