jOOQ UPDATE와 DELET

데이터 수정과 삭제는 신중해야 하는 작업이다. jOOQ는 동적 필드 업데이트부터 WHERE 절 없는 위험한 쿼리를 방지하는 안전장치까지 제공해준다

UPDATE 3가지 방식

DAO를 통한 전체 필드 업데이트

JPA의 save()처럼 POJO 전체를 업데이트 – 변경 여부와 관계없이 모든 컬럼이 SET 절에 포함된다

Repository 구현

@Repository
public class ActorRepository {
    private final ActorDao actorDao;

    public void update(Actor actor) {
        actorDao.update(actor);
    }
}

테스트

@Test
@Transactional
@DisplayName("POJO를 사용한 update")
void 업데이트_with_pojo() {
    // given
    Actor newActor = new Actor();
    newActor.setFirstName("Tom");
    newActor.setLastName("Cruise");
    
    Actor actor = actorRepository.saveWithReturning(newActor);

    // when
    actor.setFirstName("Suri");
    actorRepository.update(actor);

    // then
    Actor updatedActor = actorRepository.findByActorId(actor.getActorId());
    assertThat(updatedActor.getFirstName()).isEqualTo("Suri");
}

생성된 SQL

UPDATE `actor` 
SET `actor`.`first_name` = 'Suri', 
    `actor`.`last_name` = 'Cruise', 
    `actor`.`last_update` = {ts '2025-12-30 15:19:35.0'} 
WHERE `actor`.`actor_id` = 208

문제점

-- 변경하지 않은 필드도 모두 UPDATE
SET `first_name` = 'Suri',
    `last_name` = 'Cruise',     -- 변경 안 했는데 포함됨
    `last_update` = {...}       -- 변경 안 했는데 포함됨

JPA의 더티 체킹과 달리, actorDao.update(actor)는 모든 필드를 UPDATE한다. 변경되지 않은 필드도 SET 절에 포함되므로 불필요한 업데이트가 발생한다

DTO 활용 동적 필드 업데이트 – 권장

변경된 필드만 선택적으로 업데이트

DTO 정의

@Getter
@Builder
public class ActorUpdateRequest {
    private String firstName;
    private String lastName;
}

Repository 구현

public int updateWithDto(Long actorId, ActorUpdateRequest request) {
    // null 또는 빈 문자열이면 해당 필드 제외
    var firstName = StringUtils.hasText(request.getFirstName()) 
        ? val(request.getFirstName())           // 값이 있으면 업데이트
        : noField(ACTOR.FIRST_NAME);            // 없으면 제외

    var lastName = StringUtils.hasText(request.getLastName()) 
        ? val(request.getLastName()) 
        : noField(ACTOR.LAST_NAME);

    return dslContext
            .update(ACTOR)
            .set(ACTOR.FIRST_NAME, firstName)
            .set(ACTOR.LAST_NAME, lastName)
            .where(ACTOR.ACTOR_ID.eq(actorId))
            .execute();
}

코드 분석

val()과 noField()

val(값)                    // Field로 감싸진 값
noField(ACTOR.COLUMN)     // 필드 제외 (업데이트하지 않음)
  • val(): 값을 jOOQ의 Field 타입으로 변환
  • noField(): 해당 필드를 UPDATE 문에서 제외

동적 필드 제어

// firstName만 있는 경우
var request = ActorUpdateRequest.builder()
    .firstName("Suri")
    .build();

updateWithDto(actorId, request);
// SQL: UPDATE actor SET first_name = 'Suri' WHERE ...

lastName은 null이므로 noField()가 반환되어 UPDATE 절에서 제외

테스트

@Test
@Transactional
@DisplayName("일부 필드만 update - DTO 활용")
void 업데이트_일부_필드만() {
    // given
    Actor newActor = new Actor();
    newActor.setFirstName("Tom");
    newActor.setLastName("Cruise");

    Long actorId = actorRepository.saveWithReturningPkOnly(newActor);
    
    var request = ActorUpdateRequest.builder()
            .firstName("Suri")  // firstName만 변경
            .build();

    // when
    actorRepository.updateWithDto(actorId, request);

    // then
    Actor updatedActor = actorRepository.findByActorId(actorId);
    assertThat(updatedActor.getFirstName()).isEqualTo("Suri");
    assertThat(updatedActor.getLastName()).isEqualTo("Cruise");  // 유지됨
}

생성된 SQL

UPDATE `actor` 
SET `actor`.`first_name` = 'Suri'  -- firstName만 업데이트
WHERE `actor`.`actor_id` = 209

장점

  • 변경된 필드만 업데이트
  • 불필요한 업데이트 방지
  • DB 부하 감소
  • 명확한 의도 표현

ActiveRecord 활용 동적 업데이트

Record 객체를 사용하여 변경된 필드만 업데이트

Repository 구현

public int updateWithRecord(Long actorId, ActorUpdateRequest request) {
    // 1. 현재 레코드 조회
    ActorRecord record = dslContext.fetchOne(ACTOR, ACTOR.ACTOR_ID.eq(actorId));

    // 2. 변경된 필드만 설정
    if (StringUtils.hasText(request.getFirstName())) {
        record.setFirstName(request.getFirstName());
    }

    if (StringUtils.hasText(request.getLastName())) {
        record.setLastName(request.getLastName());
    }

    // 3. 변경된 필드만 업데이트
    return dslContext
            .update(ACTOR)
            .set(record)  // Record의 changed 필드만 반영
            .where(ACTOR.ACTOR_ID.eq(actorId))
            .execute();
}

Record의 Changed 추적

ActorRecord record = dslContext.fetchOne(ACTOR, ...);

// 필드 변경 시 자동으로 "changed" 상태 기록
record.setFirstName("Suri");  // firstName: changed
// record.setLastName() 호출 안 함  // lastName: not changed

dslContext.update(ACTOR).set(record)...
// SQL: UPDATE actor SET first_name = 'Suri' WHERE ...
// lastName은 changed가 아니므로 제외됨

Record는 ‘changed’ 상태를 추적해 변경된 필드만 UPDATE에 포함한다.
.set(record)를 사용하면 변경된 필드만 UPDATE 절에 포함된다

대안 – record.update()

// 방법 1: DSLContext 사용
dslContext.update(ACTOR)
    .set(record)
    .where(ACTOR.ACTOR_ID.eq(actorId))
    .execute();

// 방법 2: Record의 update() 메서드 사용
record.setActorId(actorId);  // PK 필수
record.update();              // 자동으로 WHERE actor_id = ? 생성

record.update()는 PK를 기반으로 자동으로 WHERE 절을 생성

테스트

@Test
@Transactional
@DisplayName("일부 필드만 update - record 활용")
void 업데이트_일부_필드만_with_record() {
    // given
    Actor newActor = new Actor();
    newActor.setFirstName("Tom");
    newActor.setLastName("Cruise");

    Long actorId = actorRepository.saveWithReturningPkOnly(newActor);
    
    var request = ActorUpdateRequest.builder()
            .firstName("Suri")
            .build();

    // when
    actorRepository.updateWithRecord(actorId, request);

    // then
    Actor updatedActor = actorRepository.findByActorId(actorId);
    assertThat(updatedActor.getFirstName()).isEqualTo("Suri");
}

생성된 SQL

UPDATE `actor` 
SET `actor`.`first_name` = 'Suri'  -- 변경된 필드만
WHERE `actor`.`actor_id` = 210

UPDATE 방식 비교

방식업데이트 필드쿼리 수장점단점
DAO모든 필드1간편함불필요한 업데이트
DTO + val / noField선택적1명시적, 효율적코드 길이
RecordChanged 필드만(조회 1 + 수정 1) 총 2자동 추적조회 쿼리 추가

선택 가이드

// 1. 전체 업데이트 (간편하지만 비효율)
actorDao.update(actor);

// 2. 선택적 업데이트 (권장)
var firstName = hasText(req.getFirstName()) 
    ? val(req.getFirstName()) 
    : noField(ACTOR.FIRST_NAME);
dslContext.update(ACTOR)
    .set(ACTOR.FIRST_NAME, firstName)
    .where(...)
    .execute();

// 3. Record 자동 추적 (조회가 필요한 경우)
ActorRecord record = dslContext.fetchOne(ACTOR, ...);
record.setFirstName("new");  // changed 자동 추적
record.update();
  • 권장: 대부분의 경우 방식 2(DTO + val / noField)가 가장 명시적이고 효율적

DELETE – 2가지 방식

DSLContext를 통한 DELETE

public int delete(Long actorId) {
    return dslContext
            .deleteFrom(ACTOR)
            .where(ACTOR.ACTOR_ID.eq(actorId))
            .execute();
}

테스트

@Test
@Transactional
@DisplayName("delete 예제")
void delete_예제() {
    // given
    Actor newActor = new Actor();
    newActor.setFirstName("Tom");
    newActor.setLastName("Cruise");

    Long actorId = actorRepository.saveWithReturningPkOnly(newActor);

    // when
    int result = actorRepository.delete(actorId);

    // then
    assertThat(result).isEqualTo(1);  // 1개 행 삭제
}

생성된 SQL

DELETE FROM `actor` 
WHERE `actor`.`actor_id` = 213

ActiveRecord를 통한 DELETE

public int deleteWithRecord(Long actorId) {
    ActorRecord record = dslContext.fetchOne(ACTOR, ACTOR.ACTOR_ID.eq(actorId));
    return record.delete();
}

코드 분석

// Record 조회 후 delete() 호출
ActorRecord record = dslContext.fetchOne(ACTOR, ACTOR.ACTOR_ID.eq(actorId));
record.delete();  // 자동으로 WHERE actor_id = ? 생성

Record의 delete() 메서드는 PK를 기반으로 자동으로 WHERE 절을 생성한다

테스트

@Test
@Transactional
@DisplayName("delete 예제 - with active record")
void delete_with_active_record_예제() {
    // given
    Actor newActor = new Actor();
    newActor.setFirstName("Tom");
    newActor.setLastName("Cruise");

    Long actorId = actorRepository.saveWithReturningPkOnly(newActor);

    // when
    int result = actorRepository.deleteWithRecord(actorId);

    // then
    assertThat(result).isEqualTo(1);
}

생성된 SQL

DELETE FROM `actor` 
WHERE `actor`.`actor_id` = 212

안전장치 – WHERE절 없는 UPDATE/DELETE 방지

실수로 WHERE절 없이 UPDATE나 DELETE를 실행하면 전체 데이터가 영향을 받는다. jOOQ는 이를 방지하는 설정을 제공한다

Configuration 설정

@Configuration
public class JooqConfig {
    @Bean
    public DefaultConfigurationCustomizer jooqDefaultConfigurationCustomizer() {
        return c -> c.settings()
                .withExecuteDeleteWithoutWhere(ExecuteWithoutWhere.THROW)  // DELETE 방지
                .withExecuteUpdateWithoutWhere(ExecuteWithoutWhere.THROW)  // UPDATE 방지
                .withRenderSchema(false);
    }
}

옵션 종류

ExecuteWithoutWhere.LOG_DEBUG    // DEBUG 로그만 출력 (기본값)
ExecuteWithoutWhere.LOG_INFO     // INFO 로그 출력
ExecuteWithoutWhere.LOG_WARN     // WARN 로그 출력
ExecuteWithoutWhere.THROW        // 예외 발생 (권장)
ExecuteWithoutWhere.IGNORE       // 무시
  • 권장: 애플리케이션 레벨에서는 THROW로 설정하여 실수를 원천 차단

동적 확인

잘못된 코드 (WHERE절 없음)

public int updateWithDto(Long actorId, ActorUpdateRequest request) {
    var firstName = StringUtils.hasText(request.getFirstName()) 
        ? val(request.getFirstName()) 
        : noField(ACTOR.FIRST_NAME);
    
    var lastName = StringUtils.hasText(request.getLastName()) 
        ? val(request.getLastName()) 
        : noField(ACTOR.LAST_NAME);

    return dslContext
            .update(ACTOR)
            .set(ACTOR.FIRST_NAME, firstName)
            .set(ACTOR.LAST_NAME, lastName)
            // .where(...) WHERE 절 누락
            .execute();
}

실행 결과

org.jooq.exception.DataAccessException: 
A statement is executed without WHERE clause

SQL: UPDATE `actor` SET `first_name` = ?

예외 발생으로 실수 방지

왜 필요한가?

// 실수로 WHERE 절 누락
UPDATE actor SET first_name = 'John';  
-- 모든 배우의 이름이 'John'으로 변경!

DELETE FROM actor;  
-- 모든 배우 데이터 삭제!

애플리케이션 레벨에서 전체 UPDATE/DELETE는 거의 사용되지 않는다. 이런 작업은 데이터베이스 콘솔에서 신중하게 수행해야 한다

설정 효과
  • 개발 단계에서 실수 조기 발견
  • 프로덕션 환경에서 대량 데이터 손실 방지
  • 코드 리뷰에서 WHERE절 누락 자동 감지

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