jOOQ INSERT

데이터 삽입은 모든 애플리케이션의 기본이다. 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 등 고급 패턴 지원

출처 – 실전 jOOQ! Type Safe SQL with Java