save() 안 했는데 왜 UPDATE 쿼리가 나간 거지?

JPA를 쓰다 보면 이런 의문이 계속 생깁니다. em.persist()를 호출하면 DB에 바로 INSERT가 나갈까? 엔티티 필드만 바꿨는데 왜 UPDATE가 실행되지? 영속성 컨텍스트가 뭔지, 1차 캐시는 어떻게 동작하는지, N+1 문제는 왜 발생하는지 — 내부 원리를 알아야 이런 의문이 풀립니다.

JPA란

JPA(Java Persistence API) 는 자바 진영의 ORM 표준 명세이고, 실제 구현체는 Hibernate 가 사실상 표준이에요. 객체와 관계형 DB 테이블을 매핑하여 SQL 없이 데이터를 다룰 수 있게 해줍니다.

명세라는 게 핵심인데, JPA 자체는 인터페이스 모음이지 구현체가 아닙니다. 복잡한 쿼리는 결국 직접 써야 하지만, 기본적인 CRUD에서 반복되는 boilerplate를 줄여주는 건 확실해요.

PLAINTEXT
JPA (명세, 인터페이스)
 └── Hibernate (구현체)
      └── Spring Data JPA (추상화 레이어)

Spring Data JPA는 JPA를 한 번 더 감싼 건데, JpaRepository 인터페이스만 상속하면 기본 CRUD 메서드를 자동으로 제공해주고, 메서드 이름만으로 쿼리를 생성해줍니다. 편하긴 한데 내부에서 뭐가 돌아가는지 모르면 성능 이슈를 만나고도 원인을 못 찾아요.


영속성 컨텍스트 (Persistence Context)

JPA의 핵심 중 핵심입니다. JPA가 어떻게 동작하는지 이해하려면 여기서 시작해야 해요.

영속성 컨텍스트는 엔티티를 영구 저장하는 환경 이라고 정의되지만, 실제로는 엔티티를 관리하는 논리적인 영역이라고 이해하는 게 맞습니다. DB에 바로 저장하는 게 아니라, 중간에 이 영속성 컨텍스트가 끼어서 엔티티의 상태를 추적하고 관리해요.

EntityManager와의 관계

EntityManager가 영속성 컨텍스트에 접근하는 창구입니다. EntityManager를 통해 엔티티를 저장하거나 조회하면, 그 엔티티는 영속성 컨텍스트에 의해 관리되기 시작해요.

JAVA
@PersistenceContext
private EntityManager em;

// em.persist(member) → 영속성 컨텍스트에 member를 넣는다
// em.find(Member.class, 1L) → 영속성 컨텍스트에서 먼저 찾고, 없으면 DB 조회

Spring에서는 보통 EntityManager를 직접 다루기보다 JpaRepository를 쓰지만, 내부적으로는 전부 EntityManager가 동작하고 있습니다. 트랜잭션 범위 안에서는 같은 영속성 컨텍스트를 공유한다는 점도 기억해야 해요.


엔티티 생명주기

엔티티는 4가지 상태를 가집니다. 이 생명주기를 이해하면 JPA의 동작이 예측 가능해져요.

PLAINTEXT
비영속 (new) ──persist()──▶ 영속 (managed)

                          detach()/clear()


                          준영속 (detached)

                            merge()


                          영속 (managed)

영속 (managed) ──remove()──▶ 삭제 (removed)

비영속 (new/transient)

엔티티 객체를 생성만 하고 영속성 컨텍스트와 아무 관련이 없는 상태입니다.

JAVA
Member member = new Member();
member.setName("홍길동");
// 그냥 자바 객체일 뿐, JPA가 관리하지 않는다

영속 (managed)

영속성 컨텍스트에 의해 관리되는 상태입니다. persist()를 호출하거나, find()로 조회하면 이 상태가 돼요.

JAVA
em.persist(member); // 비영속 → 영속
Member found = em.find(Member.class, 1L); // DB에서 조회 → 영속

영속 상태라고 해서 바로 INSERT 쿼리가 나가는 건 아닙니다. 트랜잭션 커밋 시점에 쿼리가 모아서 나가요. 이게 뒤에서 설명할 쓰기 지연입니다.

준영속 (detached)

영속성 컨텍스트가 관리하던 엔티티가 분리된 상태입니다. 더 이상 변경 감지도 안 되고, 1차 캐시에서도 빠져요.

JAVA
em.detach(member);  // 특정 엔티티만 분리
em.clear();         // 영속성 컨텍스트 전체 초기화
em.close();         // 영속성 컨텍스트 종료

준영속 상태의 엔티티를 다시 영속 상태로 만들려면 merge()를 씁니다. 다만 merge는 새로운 영속 엔티티를 반환하는 거라서, 원래 객체가 영속 상태로 바뀌는 게 아니에요. 이 차이를 놓치면 버그가 생깁니다.

JAVA
Member mergedMember = em.merge(detachedMember);
// mergedMember는 영속 상태, detachedMember는 여전히 준영속

삭제 (removed)

엔티티를 영속성 컨텍스트와 DB에서 삭제 요청한 상태입니다. 트랜잭션 커밋 시점에 DELETE 쿼리가 나가요.

JAVA
em.remove(member);

영속성 컨텍스트의 이점

영속성 컨텍스트가 중간에 끼어 있으면서 주는 이점들이 있습니다. 하나씩 살펴볼게요.

1차 캐시

영속성 컨텍스트 내부에 Map 형태의 캐시 가 있습니다. 키는 엔티티의 @Id 값이고, 값은 엔티티 인스턴스예요.

JAVA
Member member = new Member();
member.setId(1L);
member.setName("홍길동");

em.persist(member); // 1차 캐시에 저장

Member findMember = em.find(Member.class, 1L);
// DB에 쿼리를 날리지 않고 1차 캐시에서 바로 가져온다

1차 캐시는 트랜잭션 범위 안에서만 유효합니다. 트랜잭션이 끝나면 영속성 컨텍스트도 날아가니까, 애플리케이션 전체에서 공유하는 캐시와는 달라요. 그래서 성능 이점이 극적이지는 않은데, 같은 트랜잭션 안에서 같은 엔티티를 반복 조회할 때는 확실히 DB 접근을 줄여줍니다.

동일성 보장 (Identity)

같은 트랜잭션 안에서 같은 엔티티를 여러 번 조회하면 항상 같은 인스턴스 를 반환합니다.

JAVA
Member a = em.find(Member.class, 1L);
Member b = em.find(Member.class, 1L);

System.out.println(a == b); // true — 같은 참조

이건 1차 캐시 때문에 가능한 거예요. 첫 번째 조회에서 1차 캐시에 올라가고, 두 번째 조회에서는 1차 캐시에서 같은 객체를 돌려주니까요. Java 컬렉션에서 같은 객체를 꺼내는 것과 비슷한 느낌입니다.

쓰기 지연 (Write-behind)

persist()를 호출해도 INSERT SQL이 바로 나가지 않습니다. 영속성 컨텍스트 내부의 쓰기 지연 SQL 저장소 에 쿼리를 모아뒀다가, flush()가 호출되는 시점에 한 번에 DB로 보내요.

JAVA
EntityTransaction tx = em.getTransaction();
tx.begin();

em.persist(memberA); // INSERT SQL을 쓰기 지연 저장소에 넣는다
em.persist(memberB); // 마찬가지

// 아직 DB에는 아무것도 안 갔다

tx.commit(); // 이 시점에 flush() → INSERT 2개가 나간다

쓰기 지연의 장점은 배치 처리가 가능 하다는 거예요. hibernate.jdbc.batch_size를 설정하면 여러 INSERT를 묶어서 한 번에 보낼 수 있습니다.

YAML
spring:
  jpa:
    properties:
      hibernate:
        jdbc:
          batch_size: 50

변경 감지 (Dirty Checking)

JPA에서 가장 마법처럼 느껴지는 기능입니다. 영속 상태의 엔티티를 수정하면, 별도의 update() 메서드를 호출하지 않아도 트랜잭션 커밋 시점에 알아서 UPDATE 쿼리가 나가요.

JAVA
@Transactional
public void updateMemberName(Long id, String newName) {
    Member member = em.find(Member.class, id); // 영속 상태
    member.setName(newName); // 값만 바꾸면 끝
    // em.update(member) 같은 건 없다!
}

동작 원리는 이렇습니다.

  1. 엔티티를 영속성 컨텍스트에 넣을 때 스냅샷 을 찍어둡니다
  2. flush() 시점에 현재 엔티티와 스냅샷을 비교합니다
  3. 변경된 필드가 있으면 UPDATE SQL을 쓰기 지연 저장소에 넣고 DB에 보냅니다

주의할 점 — 기본적으로 Hibernate는 모든 컬럼을 포함한 UPDATE 를 생성합니다. 변경된 필드만 업데이트하고 싶으면 @DynamicUpdate를 붙여야 하는데, 컬럼이 적은 테이블에서는 오히려 쿼리 캐싱 측면에서 전체 컬럼 업데이트가 유리할 수 있어요.

JAVA
@Entity
@DynamicUpdate // 변경된 컬럼만 UPDATE
public class Member {
    // ...
}

flush vs commit

이 둘의 차이를 정확히 모르는 사람이 많습니다.

구분flushcommit
역할영속성 컨텍스트의 변경 내용을 DB에 동기화트랜잭션을 ** 확정**
1차 캐시유지된다트랜잭션 종료 후 영속성 컨텍스트가 정리됨
롤백 가능 여부가능 (트랜잭션이 아직 안 끝났으니까)불가능

flush()는 쓰기 지연 저장소에 있던 SQL을 DB로 보내는 행위일 뿐, 트랜잭션을 끝내는 게 아닙니다. commit() 안에서 flush()가 자동으로 호출되고, 그다음에 실제 DB 트랜잭션이 커밋돼요.

flush가 발생하는 타이밍은 세 가지입니다.

  1. em.flush() 직접 호출 — 테스트할 때 주로 씀
  2. ** 트랜잭션 커밋 시** — 자동으로 flush
  3. JPQL 쿼리 실행 전 — JPQL은 DB에 직접 쿼리를 날리니까, 그 전에 쓰기 지연 저장소를 비워야 정합성이 맞다
JAVA
em.persist(memberA);

// JPQL 실행 → 자동 flush 발생
List<Member> members = em.createQuery("select m from Member m", Member.class)
    .getResultList();
// memberA도 결과에 포함된다

즉시 로딩 vs 지연 로딩

연관 관계가 걸린 엔티티를 ** 언제** 로딩할 것인가의 문제입니다.

즉시 로딩 (EAGER)

연관된 엔티티를 ** 조회 시점에 바로** 같이 가져옵니다.

JAVA
@Entity
public class Member {
    @ManyToOne(fetch = FetchType.EAGER) // 기본값이 EAGER
    @JoinColumn(name = "team_id")
    private Team team;
}

em.find(Member.class, 1L)을 호출하면 Member와 Team을 JOIN해서 한 방에 가져옵니다. 편해 보이지만, 실무에서 EAGER는 거의 쓰면 안 돼요. 이유는 뒤에서 설명할 N+1 문제 때문입니다.

지연 로딩 (LAZY)

연관된 엔티티를 ** 실제로 사용하는 시점에** 가져옵니다. 처음에는 프록시 객체를 넣어두고, 프록시의 메서드를 호출하는 순간 DB 쿼리가 나가요.

JAVA
@Entity
public class Member {
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "team_id")
    private Team team;
}
JAVA
Member member = em.find(Member.class, 1L);
// SELECT * FROM member WHERE id = 1 (Team은 안 가져옴)

System.out.println(member.getTeam().getClass());
// class com.example.Team$HibernateProxy$xxxx — 프록시 객체!

member.getTeam().getName();
// 이 시점에 SELECT * FROM team WHERE id = ? 쿼리 발생

프록시 객체

지연 로딩의 핵심은 프록시입니다. Hibernate는 Team을 상속받은 프록시 클래스를 바이트코드로 동적 생성해요. 프록시 객체는 실제 Team의 참조를 가지고 있지 않다가, 처음 메서드가 호출되면 영속성 컨텍스트에 초기화를 요청하고, 그때 DB 쿼리가 나갑니다.

주의사항이 몇 가지 있어요.

  • 프록시는 원본을 상속받은 거라서 == 비교가 안 됩니다. instanceof를 써야 해요
  • 영속성 컨텍스트 밖에서(트랜잭션이 끝난 후) 프록시를 초기화하면 LazyInitializationException이 터집니다
  • em.getReference()로도 프록시를 가져올 수 있습니다 — DB 쿼리 없이 프록시만 반환
JAVA
// 프록시 비교 주의
Team teamProxy = member.getTeam();
Team teamReal = em.find(Team.class, 1L);

System.out.println(teamProxy == teamReal);           // false일 수 있음
System.out.println(teamProxy instanceof Team);        // true
System.out.println(teamProxy.getClass() == Team.class); // false

실무에서는 모든 연관 관계를 LAZY로 설정 하고, 필요한 경우에만 fetch join이나 @EntityGraph로 한 번에 가져오는 게 정석입니다.


N+1 문제

JPA를 쓰면 거의 반드시 마주치게 되는 문제입니다. 왜 발생하고, 어떻게 해결할 수 있을까요?

발생 원인

N+1이란, 1번의 쿼리로 N개의 결과를 가져왔는데, 각 결과의 연관 엔티티를 조회하기 위해 추가로 N번의 쿼리 가 나가는 현상입니다.

JAVA
@Entity
public class Team {
    @OneToMany(mappedBy = "team", fetch = FetchType.EAGER)
    private List<Member> members;
}
JAVA
// JPQL로 모든 팀 조회
List<Team> teams = em.createQuery("select t from Team t", Team.class)
    .getResultList();

// 실제 나가는 쿼리:
// 1) SELECT * FROM team                  ← 1번 (팀 10개 조회)
// 2) SELECT * FROM member WHERE team_id = 1  ← +1
// 3) SELECT * FROM member WHERE team_id = 2  ← +1
// ...
// 11) SELECT * FROM member WHERE team_id = 10 ← +1
// 총 11번 쿼리 = 1 + N(10)

EAGER로 설정해도 JPQL은 SQL로 번역될 때 연관 관계를 무시하고 우선 엔티티만 가져옵니다. 가져온 뒤에 EAGER니까 연관 엔티티를 즉시 로딩하려고 추가 쿼리를 날려요. LAZY여도 마찬가지 — 루프 안에서 연관 엔티티에 접근하는 순간 N번의 쿼리가 나갑니다.

JAVA
// LAZY여도 이렇게 쓰면 N+1 발생
for (Team team : teams) {
    System.out.println(team.getMembers().size()); // 각 팀마다 쿼리 발생
}

결국 EAGER냐 LAZY냐는 N+1이 터지는 타이밍의 차이 일 뿐, 근본적인 해결책은 아닙니다.

해결법 1: Fetch Join

JPQL에서 JOIN FETCH를 쓰면 연관된 엔티티를 한 방 쿼리 로 가져옵니다.

JAVA
List<Team> teams = em.createQuery(
    "select t from Team t join fetch t.members", Team.class)
    .getResultList();

// SELECT t.*, m.* FROM team t
// INNER JOIN member m ON t.id = m.team_id
// → 쿼리 1번으로 끝

다만 fetch join에는 제약이 있어요.

  • 페이징이 안 됩니다 — 컬렉션 fetch join과 페이징을 같이 쓰면 Hibernate가 메모리에서 페이징을 해요. 데이터가 많으면 OOM 위험
  • ** 둘 이상의 컬렉션을 fetch join할 수 없습니다** — 카테시안 곱이 발생해서 MultipleBagFetchException이 터져요
  • ** 별칭을 줄 수 없습니다** — join fetch t.members m WHERE m.age > 10 이런 식으로 필터링하면 안 됩니다 (데이터 정합성 깨짐)

해결법 2: @EntityGraph

어노테이션 기반으로 fetch join과 비슷한 효과를 냅니다. Spring Data JPA와 잘 어울려요.

JAVA
public interface TeamRepository extends JpaRepository<Team, Long> {

    @EntityGraph(attributePaths = {"members"})
    @Query("select t from Team t")
    List<Team> findAllWithMembers();
}

내부적으로 LEFT OUTER JOIN을 사용합니다. fetch join처럼 쿼리 한 번에 연관 엔티티를 가져오지만, JPQL을 직접 쓰지 않아도 되는 게 장점이에요.

해결법 3: @BatchSize

한 번에 연관 엔티티를 모아서 IN 쿼리로 가져오는 방식입니다. N+1을 완전히 없애지는 못하지만, N번의 쿼리를 N / batch_size번으로 줄여줘요.

JAVA
@Entity
public class Team {
    @BatchSize(size = 100)
    @OneToMany(mappedBy = "team")
    private List<Member> members;
}
SQL
-- @BatchSize(size = 100) 적용 시
-- 팀 100개의 멤버를 한 번에 조회
SELECT * FROM member WHERE team_id IN (1, 2, 3, ..., 100)

글로벌로 설정할 수도 있어요.

YAML
spring:
  jpa:
    properties:
      hibernate:
        default_batch_fetch_size: 100

실무에서는 글로벌 batch_size를 깔아놓고, 성능이 중요한 곳만 fetch join을 쓰는 전략이 많이 쓰입니다.

해결법 비교

방법장점단점
Fetch Join쿼리 1번으로 해결페이징 불가, 다중 컬렉션 불가
@EntityGraph어노테이션 기반, 간편LEFT JOIN이라 중복 발생 가능
@BatchSize페이징 가능, 적용 범위 넓음완전히 1번은 아님

JPQL vs QueryDSL vs Native Query

JPQL (Java Persistence Query Language)

엔티티 객체를 대상으로 하는 쿼리 언어입니다. SQL과 비슷하지만 테이블이 아니라 ** 엔티티 **를 대상으로 쿼리해요.

JAVA
List<Member> members = em.createQuery(
    "select m from Member m where m.age > :age", Member.class)
    .setParameter("age", 20)
    .getResultList();

장점은 DB에 독립적이라는 것. 단점은 문자열이라서 컴파일 시점에 오류를 잡을 수 없다는 거예요. 오타 하나 때문에 런타임에 터지는 경험은... 누구나 한 번쯤 해봤을 겁니다.

QueryDSL

JPQL을 자바 코드로 작성할 수 있게 해주는 프레임워크입니다. ** 타입 세이프 **한 게 가장 큰 장점이에요.

JAVA
QMember member = QMember.member;

List<Member> members = queryFactory
    .selectFrom(member)
    .where(
        member.age.gt(20),
        member.team.name.eq("개발팀")
    )
    .orderBy(member.name.asc())
    .fetch();

동적 쿼리를 만들 때 진가를 발휘합니다. JPQL로 동적 쿼리를 만들려면 문자열을 이어붙여야 하는데 정말 지저분해져요. QueryDSL은 BooleanBuilderBooleanExpression을 사용해서 깔끔하게 조합할 수 있습니다.

JAVA
public List<Member> searchMembers(String name, Integer age) {
    BooleanBuilder builder = new BooleanBuilder();

    if (name != null) {
        builder.and(member.name.contains(name));
    }
    if (age != null) {
        builder.and(member.age.goe(age));
    }

    return queryFactory
        .selectFrom(member)
        .where(builder)
        .fetch();
}

Native Query

SQL을 직접 작성합니다. JPA가 지원하지 않는 DB 고유 기능을 써야 할 때 사용해요.

JAVA
List<Member> members = em.createNativeQuery(
    "SELECT * FROM member WHERE age > ?", Member.class)
    .setParameter(1, 20)
    .getResultList();

DB에 종속적이기 때문에 가능하면 JPQL이나 QueryDSL로 해결하고, 정 안 될 때만 쓰는 게 좋습니다.


@Transactional과 JPA의 관계

JPA의 모든 데이터 변경은 트랜잭션 안에서 이루어져야 합니다. Spring에서는 @Transactional이 이걸 담당해요.

JAVA
@Service
@RequiredArgsConstructor
public class MemberService {

    private final MemberRepository memberRepository;

    @Transactional
    public void updateName(Long id, String name) {
        Member member = memberRepository.findById(id)
            .orElseThrow();
        member.setName(name);
        // save() 안 불러도 된다 — 변경 감지가 알아서 UPDATE
    }

    @Transactional(readOnly = true)
    public Member findMember(Long id) {
        return memberRepository.findById(id)
            .orElseThrow();
    }
}

@Transactional이 하는 일을 정리하면 이렇습니다.

  1. 메서드 시작 시 트랜잭션 시작 + 영속성 컨텍스트 생성
  2. 메서드 정상 종료 시 flush → commit → 영속성 컨텍스트 종료
  3. 예외 발생 시 rollback → 영속성 컨텍스트 종료

readOnly = true를 붙이면 Hibernate가 변경 감지를 위한 스냅샷을 생성하지 않기 때문에 메모리 절약이 됩니다. 조회 전용 메서드에는 무조건 붙이는 게 좋아요. 일부 DB에서는 읽기 전용 트랜잭션에 대해 최적화를 해주기도 합니다.


주의할 점 — 이걸 모르면 터진다

merge()와 persist()를 혼동하면 버그가 생깁니다

merge()는 새로운 영속 엔티티를 ** 반환 **하는 것이지, 원래 객체가 영속 상태로 바뀌는 게 아닙니다. 반환값을 무시하고 원래 객체를 계속 사용하면 변경 감지가 동작하지 않아요.

EAGER 로딩은 N+1 문제의 타이밍만 다를 뿐 근본 해결이 아닙니다

EAGER든 LAZY든 JPQL에서의 N+1 문제는 동일하게 발생합니다. 차이는 쿼리가 나가는 시점뿐이에요. 모든 연관 관계를 LAZY로 설정하고, 필요할 때만 fetch join으로 가져오는 것이 정석입니다.

벌크 연산 후 영속성 컨텍스트를 초기화하지 않으면 오래된 데이터가 반환됩니다

벌크 연산은 영속성 컨텍스트를 무시하고 DB에 직접 쿼리를 날리므로, 1차 캐시에는 수정 전 데이터가 남아있어요. @Modifying(clearAutomatically = true) 또는 em.clear()로 반드시 초기화해야 합니다.


심화 주제

여기서부터는 깊이 있게 물어볼 때 나오는 주제들입니다.

OSIV (Open Session In View)

Spring Boot에서 기본값이 true입니다. OSIV가 켜져 있으면 영속성 컨텍스트가 HTTP 요청의 시작부터 응답이 끝날 때까지 유지돼요.

YAML
spring:
  jpa:
    open-in-view: true  # 기본값

OSIV가 켜져 있으면 View 레이어(Controller, Thymeleaf 등)에서도 지연 로딩이 가능합니다. LazyInitializationException을 안 만나니까 편하긴 한데, 문제는 DB 커넥션을 요청 끝까지 물고 있다는 거예요. 트래픽이 많은 서비스에서는 ** 커넥션 풀 고갈 **로 이어질 수 있습니다.

PLAINTEXT
OSIV ON:  [요청 시작] ====== 커넥션 유지 ====== [응답 끝]
OSIV OFF: [요청 시작] == 커넥션 == [서비스 끝] ... [응답 끝]

실무에서는 **OSIV를 끄고 **, 필요한 데이터를 서비스 레이어에서 미리 로딩하거나 fetch join으로 해결하는 방식을 많이 씁니다. API 서버라면 끄는 게 맞다고 봐요.

2차 캐시

1차 캐시는 영속성 컨텍스트(트랜잭션) 범위입니다. 2차 캐시는 ** 애플리케이션 범위 **의 공유 캐시예요.

PLAINTEXT
조회 요청 → 1차 캐시 확인 → (없으면) 2차 캐시 확인 → (없으면) DB 조회

Hibernate에서는 Ehcache, Hazelcast 같은 캐시 프레임워크를 2차 캐시로 쓸 수 있습니다.

JAVA
@Entity
@Cacheable
@Cache(usage = CacheConcurrencyStrategy.READ_WRITE)
public class Member {
    // ...
}

2차 캐시는 데이터 정합성 문제가 있을 수 있어서, 자주 변경되는 엔티티보다는 ** 잘 안 바뀌는 코드성 데이터 **(국가, 카테고리 등)에 쓰는 게 적합합니다.

낙관적 잠금 vs 비관적 잠금

동시성 제어와 관련된 주제입니다. DB의 Lock과도 연결돼요.

** 낙관적 잠금 (Optimistic Lock)**은 "충돌이 거의 안 일어날 것"이라고 가정하고, 커밋 시점에 버전을 비교해서 충돌을 감지합니다.

JAVA
@Entity
public class Member {
    @Version
    private Long version;
}
SQL
UPDATE member SET name = '변경', version = 2
WHERE id = 1 AND version = 1
-- version이 이미 바뀌었으면 0 rows updated → OptimisticLockException

** 비관적 잠금 (Pessimistic Lock)**은 "충돌이 무조건 일어난다"고 가정하고, 조회 시점에 DB Lock을 겁니다.

JAVA
Member member = em.find(Member.class, 1L,
    LockModeType.PESSIMISTIC_WRITE);
// SELECT ... FOR UPDATE 쿼리 발생
구분낙관적 잠금비관적 잠금
가정충돌이 드물다충돌이 잦다
방법@Version 비교SELECT FOR UPDATE
성능충돌 없으면 좋음락 대기 비용 발생
적합한 상황읽기 많은 서비스재고 차감 같은 동시 쓰기

Bulk 연산

변경 감지는 건건이 UPDATE를 날리니까, 수천 건을 한 번에 업데이트하려면 성능이 안 나옵니다. 이럴 때 벌크 연산을 씁니다.

JAVA
int count = em.createQuery(
    "update Member m set m.age = m.age + 1 where m.age >= :age")
    .setParameter("age", 20)
    .executeUpdate();

벌크 연산의 함정 — ** 영속성 컨텍스트를 무시하고 DB에 직접 쿼리를 날립니다 **. 그래서 벌크 연산 후에는 영속성 컨텍스트와 DB의 데이터가 달라질 수 있어요.

해결책은 두 가지입니다.

  1. ** 벌크 연산을 먼저** 수행하고, 그 이후에 엔티티를 조회
  2. 벌크 연산 후 em.clear()로 영속성 컨텍스트를 초기화
JAVA
@Modifying(clearAutomatically = true) // 벌크 연산 후 자동으로 clear
@Query("update Member m set m.age = m.age + 1 where m.age >= :age")
int bulkAgePlus(@Param("age") int age);

Spring Data JPA에서는 @Modifying(clearAutomatically = true)를 붙이면 벌크 연산 후 영속성 컨텍스트를 자동으로 비워줍니다.


파생 개념

트랜잭션 격리 수준

JPA의 트랜잭션 동작은 DB의 격리 수준에 영향을 받습니다. READ COMMITTED, REPEATABLE READ 같은 격리 수준에 따라 동일한 JPA 코드라도 동작이 달라질 수 있어요. 이 부분은 별도로 정리한 글이 있습니다.

Spring Data JPA

앞에서 잠깐 언급했지만, Spring Data JPA는 JPA를 한 단계 더 추상화한 레이어입니다. JpaRepository를 상속하면 기본 CRUD가 제공되고, 메서드 이름으로 쿼리를 자동 생성해요.

JAVA
public interface MemberRepository extends JpaRepository<Member, Long> {
    List<Member> findByNameAndAgeGreaterThan(String name, int age);
    // → SELECT * FROM member WHERE name = ? AND age > ?
}

편하지만, 복잡한 쿼리는 결국 JPQL이나 QueryDSL을 써야 합니다. 그리고 내부적으로 SimpleJpaRepository가 @Transactional을 이미 가지고 있어서, 서비스 레이어에서 @Transactional을 안 붙여도 동작하기는 해요. 하지만 비즈니스 로직의 트랜잭션 경계를 명확히 하기 위해 서비스 레이어에 직접 붙이는 게 맞습니다.

Connection Pool

JPA가 DB에 접근하려면 결국 JDBC 커넥션이 필요합니다. Spring Boot는 기본으로 HikariCP 를 커넥션 풀로 사용해요.

YAML
spring:
  datasource:
    hikari:
      maximum-pool-size: 10    # 최대 커넥션 수
      minimum-idle: 5           # 최소 유휴 커넥션
      connection-timeout: 30000 # 커넥션 획득 대기 시간 (ms)

OSIV를 키면 요청 하나가 커넥션을 끝까지 물고 있으니 풀이 빨리 소진됩니다. 커넥션 풀 크기와 OSIV 설정, 트랜잭션 범위는 서로 연관이 있으니 같이 생각해야 해요.


정리

개념핵심
영속성 컨텍스트엔티티를 관리하는 논리적 영역, EntityManager를 통해 접근
1차 캐시트랜잭션 범위 캐시, 동일성 보장
변경 감지스냅샷 비교로 자동 UPDATE
쓰기 지연SQL을 모았다가 flush 시점에 한 번에 전송
지연 로딩프록시 객체로 필요한 시점에 쿼리
N+1fetch join, @EntityGraph, @BatchSize로 해결
OSIVAPI 서버에서는 끄는 게 좋다
낙관적/비관적 잠금@Version vs SELECT FOR UPDATE
벌크 연산영속성 컨텍스트 무시하므로 clear 필수
댓글 로딩 중...