데이터 수정과 삭제는 신중해야 하는 작업이다. 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 | 명시적, 효율적 | 코드 길이 |
| Record | Changed 필드만 | (조회 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절 누락 자동 감지