TIL - 20251201

juni·2025년 12월 1일

TIL

목록 보기
194/316

1201 Spring Boot JPA 심화: 지연 로딩, N+1 문제, 동시성 제어


✅ 1. 프록시(Proxy)와 지연 로딩(Lazy Loading)

  • JPA는 연관된 엔티티를 조회할 때, 실제 엔티티 객체 대신 프록시(Proxy) 객체를 사용하여 성능을 최적화합니다.

  • 프록시 객체:

    • 실제 엔티티 클래스를 상속받아 만들어진 가짜(껍데기) 객체입니다.
    • 내부에는 실제 엔티티의 ID 값만 가지고 있으며, 나머지 필드는 비어있습니다.
    • 프록시 객체의 메서드(e.g., getName())가 최초로 호출되는 시점에, JPA는 비로소 데이터베이스에 SELECT 쿼리를 보내 실제 데이터를 조회하고 프록시 객체를 초기화합니다.
  • 지연 로딩 (Lazy Loading):

    • 개념: 연관된 엔티티를 처음부터 함께 조회하지 않고, 프록시 객체로 채워두었다가, 실제로 해당 엔티티를 사용하는 시점에 가서야 데이터베이스에서 조회하는 전략입니다.
    • 설정: @ManyToOne, @OneToMany 등의 연관관계 어노테이션에서 fetch 속성을 FetchType.LAZY로 설정합니다. (Spring Boot에서는 @ManyToOne은 LAZY가, @OneToMany는 LAZY가 기본값)
    • 장점: 당장 필요하지 않은 연관 엔티티까지 모두 조회하는 것을 방지하여, 불필요한 SQL 실행을 줄이고 초기 로딩 성능을 크게 향상시킵니다.
  • 즉시 로딩 (Eager Loading):

    • 개념: 주 엔티티를 조회할 때, 연관된 엔티티까지 처음부터 JOIN 쿼리를 통해 함께 조회하는 전략입니다. (fetch = FetchType.EAGER)
    • 단점: 연관된 엔티티가 항상 필요한 경우가 아니라면, 불필요한 JOIN으로 인해 성능이 저하될 수 있습니다. 특히, JPQL 사용 시 N+1 문제를 유발하는 주범이므로 사용을 극도로 지양해야 합니다.

✅ 2. N+1 문제와 해결 방안

  • N+1 문제: JPA를 사용할 때 발생하는 가장 대표적인 성능 문제. 연관관계가 설정된 엔티티를 조회할 때, 한 번의 쿼리로 N개의 데이터를 가져왔는데, 이 N개의 데이터 각각에 대해 연관된 데이터를 조회하기 위해 N번의 추가 쿼리가 발생하는 현상입니다. (총 1 + N번의 쿼리 실행)

  • 발생 시나리오:

    1. postRepository.findAll()을 호출하여 모든 게시글(Post)을 조회합니다. (쿼리 1번)
    2. 각 게시글의 작성자(User) 정보를 화면에 표시하기 위해, post.getUser().getName()을 호출합니다.
    3. PostUser가 지연 로딩(LAZY)으로 설정되어 있다면, 이 시점에 각 Post마다 User를 조회하기 위한 SELECT 쿼리가 N번 추가로 실행됩니다.

➕ N+1 문제 해결 방안

  1. 페치 조인 (Fetch Join):

    • 개념: JPQL 쿼리를 작성할 때 JOIN FETCH 키워드를 사용하여, 조회하는 주 엔티티와 연관된 엔티티를 처음부터 함께 조회하도록 명시하는 방법입니다.
    • 가장 효과적이고 일반적인 해결책입니다. JPA는 JOIN FETCH를 보고, 외부 조인(OUTER JOIN)을 사용하여 단 한 번의 SQL 쿼리로 모든 연관 데이터를 가져옵니다.
    • @Query 어노테이션과 함께 사용됩니다.
      @Query("SELECT p FROM Post p JOIN FETCH p.user")
      List<Post> findAllWithUser();
  2. @EntityGraph:

    • JPQL 쿼리를 직접 작성하지 않고, 어떤 연관 엔티티를 함께 조회할지를 어노테이션으로 지정하는 방법입니다.
    • attributePaths 속성에 함께 조회할 연관 필드의 이름을 지정하면, JPA가 페치 조인과 유사한 쿼리를 생성해줍니다.
      @EntityGraph(attributePaths = {"user"})
      List<Post> findAll();
  3. 배치 사이즈 (Batch Size):

    • @BatchSize 어노테이션이나 default_batch_fetch_size 설정을 통해, 지연 로딩 시 한 번에 조회할 엔티티의 개수를 지정합니다.
    • 예를 들어, 배치 사이즈가 100이라면, N개의 추가 쿼리가 발생하는 대신 WHERE id IN (?, ?, ...) 과 같이 100개씩 묶어서 조회하는 쿼리가 실행되어 전체 쿼리 수를 크게 줄여줍니다.

✅ 3. 동시성 제어 (Concurrency Control)

  • 문제점: 여러 사용자가 동시에 동일한 데이터를 수정하려고 할 때, 데이터의 일관성이 깨지는 문제가 발생할 수 있습니다. (e.g., 동시에 상품 재고를 감소시키는 경우)

  • 동시성 제어: 이러한 문제를 해결하여 데이터의 무결성을 보장하는 메커니즘입니다.

➕ 주요 동시성 제어 기법

  1. 비관적 락 (Pessimistic Lock):

    • 개념: "다른 트랜잭션이 이 데이터를 수정할 것이라고 비관적으로 가정"하고, 데이터에 접근하는 시점부터 실제 데이터베이스에 락(Lock)을 거는 방식입니다.
    • 동작: 트랜잭션 A가 데이터에 락을 걸면, 트랜잭션 B는 트랜잭션 A가 끝날 때까지 해당 데이터에 접근할 수 없어 대기(Blocking)하게 됩니다.
    • JPA 구현: @Lock(LockModeType.PESSIMISTIC_WRITE) 어노테이션을 사용합니다.
    • 장점: 데이터 충돌이 절대 발생하지 않아 데이터 정합성을 확실하게 보장합니다.
    • 단점: 락으로 인한 대기 시간이 길어져 전체적인 시스템 성능(처리량)이 저하될 수 있습니다.
    • 적용: 충돌이 빈번하게 발생하고, 데이터 정합성이 매우 중요한 경우에 사용. (e.g., 은행 계좌, 재고 관리)
  2. 낙관적 락 (Optimistic Lock):

    • 개념: "다른 트랜잭션이 이 데이터를 거의 수정하지 않을 것이라고 낙관적으로 가정"하고, 실제 데이터베이스에 락을 걸지 않는 대신, 버전(Version) 관리를 통해 정합성을 맞추는 방식입니다.
    • 동작:
      a. 엔티티에 @Version 어노테이션이 붙은 버전 관리용 필드(e.g., long version)를 추가합니다.
      b. 트랜잭션 A가 데이터를 조회할 때 버전(e.g., version=1)을 함께 가져옵니다.
      c. 트랜잭션 A가 데이터를 수정하고 커밋할 때, "내가 조회했던 버전(version=1)과 현재 DB의 버전이 동일한가?"를 확인합니다.
      d. 동일하면 버전을 1 증가시키고(version=2) 업데이트합니다. 만약 그 사이에 다른 트랜잭션 B가 먼저 수정하여 버전이 변경되었다면(version=2), 예외(OptimisticLockException)를 발생시켜 업데이트를 실패시킵니다.
    • JPA 구현: @Version 어노테이션을 필드에 추가하기만 하면 됩니다.
    • 장점: 실제 락을 사용하지 않아 대기가 발생하지 않으므로, 비관적 락보다 성능이 좋습니다.
    • 단점: 충돌이 발생하면 개발자가 직접 예외를 처리(재시도 등)해야 합니다.
    • 적용: 충돌이 거의 발생하지 않는 대부분의 조회/수정 작업에 적합합니다.

📌 요약

  • JPA의 지연 로딩(Lazy Loading)은 불필요한 쿼리를 줄여 성능을 최적화하는 핵심 전략이며, 즉시 로딩(Eager Loading)은 N+1 문제를 유발하므로 사용을 지양해야 합니다.
  • N+1 문제페치 조인(Fetch Join)이나 @EntityGraph를 사용하여, 단 한 번의 쿼리로 연관된 데이터를 함께 조회함으로써 해결하는 것이 가장 효과적입니다.
  • 동시성 제어는 여러 트랜잭션이 동일한 데이터를 동시에 수정할 때 발생하는 문제를 막기 위한 기술입니다.
  • 충돌이 잦고 정합성이 매우 중요하면 비관적 락을, 충돌이 적고 성능이 중요하면 낙관적 락(@Version)을 사용하는 것이 일반적인 전략입니다.

0개의 댓글