[UMC] @DynamicInsert와 @DynamicUpdate

Backend·2025-11-19·by 1000hyehyang

예를 들어 이런 상황을 떠올려보자.

Member 엔티티에 여러 개의 컬럼이 있는데... email, nickname, age, status, createdAt, updatedAt
서비스 코드에서 아래처럼 딱 한 줄만 바꿨다!

Member member = memberRepository.findById(id).orElseThrow();
member.setNickname("new-name");

분명 건드린 건 nickname 하나뿐인데, 실행해보면서 쿼리 로그를 열어보면 이런 모습이 찍힌다.

update member 
   set email = ?, nickname = ?, age = ?, status = ?, created_at = ?, updated_at = ?, ...
 where id = ?

코드에서는 setNickname()만 호출했는데, Hibernate가 생성한 UPDATE 문을 보면 마치 “모든 컬럼 값을 한 번에 다시 덮어쓰겠다”는 듯이 모든 컬럼이 SET 절에 포함되어 있다.

바로 이 동작이 Hibernate의 기본 전략이다. 그런데 여기서 “굳이 모든 컬럼을 다 업데이트해야 하나?”라는 의문이 생긴다.

그래도 "아 그냥 원래 이렇게 전체 업데이트 되는 거구나"하고 넘길 수 있다. 본인도 딱히 이상한 점을 못 느꼈다.

위 의문점을 해결하기 위해 검색을 좀 하다 보면 @DynamicInsert, @DynamicUpdate라는 녀석들이 보인다.

이번 글에서는 그 의문에서 출발해서, 아래 세 가지를 정리해본다.

  1. 기본 Hibernate 쿼리가 어떻게 만들어지고, @DynamicInsert, @DynamicUpdate를 켰을 때 뭐가 달라지는지
  2. 기본 전략 vs Dynamic 전략의 장단점
  3. “언제” 저 어노테이션을 쓰는 게 맞는지

 

1. @DynamicInsert / @DynamicUpdate는 기본 쿼리를 어떻게 바꾸는가

먼저 Hibernate가 아무 설정도 없을 때 INSERT/UPDATE SQL을 어떻게 만드는지부터 보자.

1-1. 기본 전략: 엔티티당 정적 SQL 한 방

Hibernate는 애플리케이션이 실행될 때 엔티티를 스캔하고, 각 엔티티에 대해 INSERT/UPDATE SQL을 하나씩 만들어 캐시해 둔다. 이때 매핑된 모든 컬럼을 항상 포함하는 정적 SQL을 만든다.

예를 들어 아래 같은 엔티티가 있다고 하자.

@Entity
public class Member {

    @Id @GeneratedValue
    private Long id;

    private String email;
    private String nickname;
    private Integer age;
    private String status;
}

Hibernate가 준비해 두는 SQL은 대략 이런 형태다.

-- INSERT (기본 전략)
insert into member (email, nickname, age, status, id)
values (?, ?, ?, ?, ?)

-- UPDATE (기본 전략)
update member
set email = ?, nickname = ?, age = ?, status = ?
where id = ?

여기서 핵심은 두 가지다.

첫째, INSERT/UPDATE 문에 항상 모든 컬럼이 들어간다. 둘째, 이 SQL은 한 번 만들어지면 엔티티 생명주기 내내 재사용된다. Hibernate 입장에서는 SQL을 매번 만들 필요가 없고, JDBC 레벨에서도 PreparedStatement 캐시를 그대로 활용할 수 있다.

그래서 닉네임만 바꿔도 쿼리 로그에서 “모든 컬럼 UPDATE”를 보게 되는 것이다.

 

1-2. @DynamicInsert: null이 아닌 컬럼만 INSERT

@DynamicInsert는 Hibernate 고유 어노테이션이다. Javadoc 정의를 그대로 보면 다음과 같다.

해당 엔티티의 SQL insert 문을 동적으로 생성하고, null이 아닌 컬럼만 포함한다.

즉, 정적 INSERT 하나를 캐시해서 쓰는 대신, 각 엔티티 인스턴스의 필드 값을 보고 “non-null 컬럼만 모아서 INSERT 문을 만들겠다”는 뜻이다.

코드에 붙이면 이렇게 생긴다.

@Entity
@DynamicInsert
public class Member {

    @Id @GeneratedValue
    private Long id;

    private String email;
    private String nickname;
    private Integer age;
    private String status;
}

그리고 다음처럼 일부만 채워서 저장한다고 하자.

Member m = new Member();
m.setEmail("test@example.com");
m.setNickname("tester");
// age, status는 건드리지 않음 (null)
memberRepository.save(m);

기본 전략이라면 다음과 같은 INSERT가 나간다.

insert into member (email, nickname, age, status, id)
values (?, ?, ?, ?, ?)
-- age, status 자리에 null 들어감

@DynamicInsert를 켰다면 Hibernate는 이 엔티티 인스턴스를 보고 “값이 들어간 컬럼만” 골라서 INSERT 문을 만든다.

insert into member (email, nickname, id)
values (?, ?, ?)

이게 중요한 이유는 두 가지다.

하나는 “컬럼이 많고 대부분 nullable인 레거시 테이블에서 불필요한 null INSERT를 줄인다”는 점이고, 다른 하나는 “DB에 미리 설정해 둔 DEFAULT 값이 실제로 쓰일 수 있다”는 점이다. 컬럼을 아예 INSERT에서 빼 버렸기 때문에 DB의 DEFAULT ... 설정이 그대로 적용된다.

 

1-3. @DynamicUpdate: 변경된 컬럼만 UPDATE

@DynamicUpdate도 마찬가지로 Hibernate 고유 어노테이션이다. 정의는 이렇다.

해당 엔티티의 SQL update 문을 동적으로 생성하고, 실제로 변경된 컬럼만 포함한다.

똑같은 Member 엔티티에 붙인다고 해보자.

@Entity
@DynamicUpdate
public class Member {

    @Id @GeneratedValue
    private Long id;

    private String email;
    private String nickname;
    private Integer age;
    private String status;
}

이미 저장된 멤버를 하나 읽어온 다음, 닉네임만 변경했다고 하자.

Member m = memberRepository.findById(id).orElseThrow();
m.setNickname("new-name");

기본 전략에서는 다음과 같은 UPDATE가 생성된다.

update member
set email = ?, nickname = ?, age = ?, status = ?
where id = ?

@DynamicUpdate를 켜면 Hibernate가 dirty checking 결과를 보고, “어떤 필드가 실제로 변경됐는지”를 기준으로 UPDATE 문을 만든다.

update member
set nickname = ?
where id = ?

컬럼이 많은 테이블에서, 실제로는 자주 바뀌지 않는 필드를 잔뜩 들고 있을 때 이런 동작이 의미를 가진다. 특정 컬럼에 인덱스, 트리거, 계산 컬럼이 얽혀 있는 경우에는 건드리지 않는 것만으로도 DB 부하를 줄일 수 있다.

 

2. 기본 전략 vs Dynamic 전략

이제 “무슨 일이 벌어지는지”는 이해했다. 그런데 '굳이?'라는 생각이 들기도 한다. 그래서 이걸 켰을 때 뭐가 좋아지고, 뭐가 나빠지는지를 비교해보자.

 

2-1. 기본 전략의 장점과 한계

Hibernate가 기본으로 선택한 전략은 “엔티티당 정적 SQL을 하나 준비해 두고 계속 재사용하는 방식”이다.

장점부터 보면...

  • 엔티티당 INSERT/UPDATE SQL을 한 번만 생성하면 된다. 매번 SQL을 조합할 필요가 없으니 Hibernate 쪽에서의 오버헤드가 적다.
  • 동일한 SQL 텍스트가 반복되기 때문에 JDBC 드라이버나 DB 쪽 PreparedStatement 캐시를 효율적으로 쓸 수 있다.
  • 항상 모든 컬럼을 쓰기 때문에, DB 기본값/트리거 같은 것을 복잡하게 섞지 않는 한 “엔티티 스냅샷과 DB 상태가 엇갈리는” 일이 상대적으로 적다.

단점은 굉장히 직관적이다.

  • 컬럼이 30~40개 되는 테이블에서 한 컬럼만 바꿔도 모든 컬럼이 UPDATE된다.
  • 변경되지 않은 컬럼에 대해서도 트리거, 인덱스, 계산 컬럼 등이 돌 수 있다. 특히 특정 컬럼에 변경이 있을 때만 작업을 수행하는 구조라면, “실제로는 안 바뀐 값인데, Hibernate가 한 번 더 SET 해버려서” 비용이 손해일 수 있다.

즉, 기본 전략은 “Hibernate 입장에서 최적화된 모드”라고 보면 된다. 엔티티 상태를 유지하고, dirty checking을 하고, 캐시를 활용하는 쪽에 플러스. 대신 DB 입장에서는 조금 더 많은 일을 하게 된다.

 

2-2. @DynamicInsert / @DynamicUpdate의 장점

먼저 @DynamicInsert의 장점부터 보자.

DynamicInsert는 다음 상황에서 의미가 있다.

첫째, 컬럼이 많고 대부분 nullable인 레거시 테이블에서 “실제로 채워지는 컬럼만 INSERT”하고 싶을 때이다. 이 경우 null인 컬럼을 전부 INSERT에 포함시키는 것은 네트워크/DB 양쪽 모두에서 그냥 낭비다.

둘째, “자바에서는 null로 두고, DB 기본값에 맡기고 싶은 컬럼”이 많을 때다. 예를 들어 created_at, created_by, status 같은 컬럼에 DEFAULT가 걸려 있다면, DynamicInsert로 그 컬럼들을 INSERT에서 빼버림으로써 DB 기본값 로직을 깨끗하게 살릴 수 있다.

@DynamicUpdate는 업데이트 시점에 “실제로 바뀐 컬럼만 UPDATE”한다는 장점이 있다. 지금까지 본 예처럼, nickname 하나만 바꿨는데 전체 컬럼이 다 SET 되는 일을 막아준다.

이 동작이 특히 유의미해지는 경우는 다음과 같다.

  • 어떤 컬럼에 인덱스/트리거/계산 컬럼이 걸려 있어서 “진짜로 변경된 경우에만 건드리고 싶은” 경우
  • LOB나 큰 텍스트 컬럼이 있어서, 바뀌지도 않는 데이터를 매번 다시 쓰고 싶지 않을 때
  • 여러 쓰레드가 같은 로우의 서로 다른 컬럼을 바꾸는 동시성 시나리오에서 “불필요한 덮어쓰기” 가능성을 조금이라도 줄이고 싶을 때

 

2-3. @DynamicInsert / @DynamicUpdate의 단점과 주의점

그러면 왜 Hibernate가 이걸 기본값으로 켜 두지 않았을까?

첫 번째는 Hibernate 쪽 오버헤드다.

정적 전략에서는 INSERT/UPDATE SQL을 엔티티당 하나씩 만들어 캐시한다. Dynamic을 켜면, 각 엔티티 인스턴스 상태를 보고 매번 SQL을 다시 만들어야 한다.

DynamicUpdate를 쓰면 어떤 컬럼이 변경됐는지 체크해서 그때그때 SQL을 생성해야 하기 때문에, 쿼리 캐시를 못 쓰고 Hibernate 쪽에서 오버헤드가 생긴다.

두 번째는 DB 기본값과 엔티티 상태가 엇갈릴 수 있다는 점이다.

DynamicInsert를 켜고, 엔티티 필드를 null로 둔 채 저장하면 DB에는 DEFAULT 값이 들어간다. 그런데 동일 트랜잭션 안에서 영속성 컨텍스트에 남아 있는 엔티티 인스턴스는 여전히 null을 들고 있을 수 있다. 쿼리를 다시 날리거나 refresh를 하지 않으면, “코드에서 보는 값과 DB 값이 다르다”는 미묘한 상태가 발생한다.

세 번째는 JPA 이식성이다.

두 어노테이션 모두 org.hibernate.annotations 패키지에 있는 Hibernate 전용 기능이다. JPA 표준이 아니고, EclipseLink 같은 다른 구현체로 갈아타면 그대로는 동작하지 않는다.

네 번째는 낙관적 락과의 상호작용이다.

@Version 컬럼을 사용하는 경우에도 DynamicUpdate가 “version만 따로 올리는 UPDATE”를 여전히 날리기 때문에, 개발자가 기대하는 것(“변경이 없으면 UPDATE도 없고 version도 안 바뀌겠지?”)과 실제 동작이 어긋날 수 있다.

요약하면, Dynamic 계열은 “DB를 조금 덜 괴롭히는 대신 Hibernate 쪽이 더 수고하는 구조”다. 결국 양쪽 사이의 트레이드오프를 어디에 두느냐 문제다.

 

3. 언제 @DynamicInsert / @DynamicUpdate를 적용하면 좋을까

“전체 UPDATE 되는 게 그렇게까지 나쁜가?” “그렇다고 모든 엔티티에 Dynamic을 다 켤 필요가 있을까?”

대부분의 경우 기본 전략 그대로 두는 게 정석이고, 아래 같은 케이스에서만 선택적으로 @DynamicInsert / @DynamicUpdate를 붙이는 쪽이 현실적인 타협안이다.

 

3-1. @DynamicInsert를 고려하면 좋은 상황

하나는 컬럼이 많은 테이블인데, 실제로는 일부만 채워서 INSERT하는 경우가 대부분일 때다. 이 경우 null 필드를 전부 INSERT에 포함시키는 건 쓸모 없는 트래픽이다.

다른 하나는 DB 기본값을 적극적으로 쓰는 설계다.

예를 들어,

  • status 컬럼: DEFAULT 'ACTIVE'
  • created_at: DEFAULT CURRENT_TIMESTAMP
  • created_by: DB 트리거에서 자동 채움

이런 구조라면, 애플리케이션에서 굳이 값을 지정하지 않고 null로 둔 채 저장하되, INSERT 문에서는 아예 컬럼 자체를 빼버려 DB가 알아서 채우게 만드는 게 깔끔하다.

반대로, 엔티티 생성 시점에 자바 코드에서 모든 필드를 명시적으로 채워넣는 스타일이라면, DynamicInsert의 이점은 거의 없다.

3-2. @DynamicUpdate를 고려하면 좋은 상황

DynamicUpdate는 “컬럼 수가 많고, UPDATE가 자주 발생하고, 그 중 일부 컬럼에 무거운 부하가 걸려 있는” 테이블에서만 가치가 있다.

예를 들어,

  • 하나의 로우에 수십 개 컬럼이 있고, 대부분은 변경이 드물다.
  • 어떤 컬럼은 LOB나 큰 텍스트라서, 바뀌지도 않았는데 매번 다시 쓰고 싶지 않다.
  • 특정 컬럼 변경 시에만 트리거나 인덱스, 계산 컬럼이 크게 돈다.

이럴 때 DynamicUpdate를 켜면, 실제로 바뀐 컬럼에 대해서만 SET을 날리므로 DB 쪽 부하를 줄일 수 있다.

그 외의 평범한 엔티티(컬럼 수 적고, UPDATE 빈도 낮고, 트리거도 없고)는 굳이 DynamicUpdate까지 써 가면서 Hibernate 쪽 오버헤드를 늘릴 이유가 거의 없다.

댓글 로딩 중...