개발을 하다 보면 JPA와 Hibernate를 사용하는 환경에서 LazyInitializationException을 마주치게 될 때가 있습니다. 특히 연관 데이터를 지연 로딩(Lazy Loading)으로 설정했을 때 트랜잭션 종료 이후 데이터를 로드하려 하면 이 문제가 발생합니다. 이번 글에서는 LazyInitializationException이 무엇인지, 왜 발생하는지, 그리고 이를 해결하기 위해 필요한 트랜잭션, 영속성 컨텍스트, 준영속성에 대해 설명합니다.

 

 

 

1. LazyInitializationException이란?

LazyInitializationException은 JPA에서 지연 로딩(Lazy Loading)으로 설정된 연관 데이터를 트랜잭션 종료 이후 로드하려고 할 때 발생하는 예외입니다. Lazy Loading은 실제로 데이터가 필요할 때만 쿼리를 실행하여 데이터를 가져오는 방식이지만, 트랜잭션이 종료된 후에는 영속성 컨텍스트가 닫혀 데이터베이스에 접근할 수 없으므로 예외가 발생합니다.

 

 

2. 예제 코드

Circle 엔티티와 CircleSchedule 엔티티를 사용한 예제를 통해 설명합니다.

@Entity
class Circle(
    @Id
    @GeneratedValue
    val id: UUID = UUID.randomUUID(),

    val title: String,

    @OneToMany(mappedBy = "circle", fetch = FetchType.LAZY, cascade = [CascadeType.ALL])
    val circleSchedules: MutableList<CircleSchedule> = mutableListOf()
)


CircleSchedule 엔티티

@Entity
class CircleSchedule(
    @Id
    @GeneratedValue
    val id: UUID = UUID.randomUUID(),

    @ManyToOne(fetch = FetchType.LAZY)
    val circle: Circle,

    val dayOfWeek: String,
    val startTime: String,
    val endTime: String
)

 

 

3. 문제 상황: LazyInitializationException 발생

아래 코드에서 circle.circleSchedules는 Lazy Loading으로 설정되어 있습니다. 트랜잭션이 종료된 후 circleSchedules에 접근하려 하면 LazyInitializationException이 발생합니다.

 

문제 코드

fun getCircleSchedules(circleId: UUID): List<CircleSchedule> {
    val circle = circleRepository.findById(circleId)
        .orElseThrow { NotExistException("Circle not found") }
    return circle.circleSchedules // LazyInitializationException 발생 가능
}

 

발생 이유

  1. circle.circleSchedules는 FetchType.LAZY로 설정되어 있어, 기본적으로 데이터베이스 쿼리를 실행하지 않습니다.
  2. 트랜잭션이 종료되면 영속성 컨텍스트도 종료되므로, 지연 로딩된 데이터를 가져올 수 없습니다.

 

4. LazyInitializationException 해결 방법

1) @Transactional 사용

트랜잭션 내에서 지연 로딩 데이터에 접근하도록 @Transactional을 적용합니다.

@Transactional(readOnly = true)
fun getCircleSchedules(circleId: UUID): List<CircleSchedule> {
    val circle = circleRepository.findById(circleId)
        .orElseThrow { NotExistException("Circle not found") }
    return circle.circleSchedules // Lazy Loading 가능
}

 

2) Open-Session-In-View 설정

Spring Boot에서 spring.jpa.open-in-view 설정을 활성화하면 트랜잭션이 종료된 이후에도 Lazy Loading이 가능합니다. 하지만 이 방법은 권장되지 않습니다.

 

application.yml 설정:

spring:
  jpa:
    open-in-view: true

 

 

5. 영속성 컨텍스트와 트랜잭션

영속성 컨텍스트(Persistence Context)

영속성 컨텍스트는 JPA 엔티티를 관리하며 데이터베이스와의 상호작용을 처리하는 캐시 역할을 합니다.

  • 트랜잭션이 시작되면 영속성 컨텍스트가 활성화됩니다.
  • 트랜잭션이 종료되면 영속성 컨텍스트도 종료됩니다.
  • Lazy Loading은 영속성 컨텍스트 내에서만 작동합니다.

트랜잭션 종료 이후의 Lazy Loading

트랜잭션이 종료되면 영속성 컨텍스트가 종료되고, Lazy Loading 데이터는 로드할 수 없습니다. 이를 방지하려면 트랜잭션 내부에서 데이터를 로드해야 합니다.

 

 

6. 결론

  • LazyInitializationException은 트랜잭션 종료 이후 Lazy Loading 데이터를 로드하려 할 때 발생합니다.
  • @Transactional 등을 활용하여 LazyInitializationException을 방지할 수 있습니다.
  • 영속성 컨텍스트와 트랜잭션의 생명주기를 이해하면 Lazy Loading 문제를 더 잘 해결할 수 있습니다.

'JPA' 카테고리의 다른 글

Kotlin, JPA 환경에서 Entity 설계에 대한 고민  (0) 2024.09.08
자바 ORM 표준 JPA 프로그래밍 기본편 정리 1탄  (0) 2022.08.07
@Enumerated  (0) 2022.07.25

이번에 새로운 사이드 프로젝트를 진행하면서 Kotlin, JPA, Spring Boot를 활용하게 되었다. 관련 예제 코드를 찾아보니, Kotlin과 JPA로 작성된 Entity 클래스는 보통 아래와 같은 형태로 많이 작성된다.

 

@Entity
data class Article(
    var slug: String = "",
    var title: String = "",
    var description: String = "",
    var body: String = "",
    @ManyToMany
    val tagList: MutableList<Tag> = mutableListOf(),
    var createdAt: OffsetDateTime = OffsetDateTime.now(),
    var updatedAt: OffsetDateTime = OffsetDateTime.now(),
    @ManyToMany
    var favorited: MutableList<User> = mutableListOf(),
    @ManyToOne
    var author: User = User(),
    @Id @GeneratedValue(strategy = GenerationType.AUTO)
    var id: Long = 0
) {
    fun favoritesCount() = favorited.size
}

Kotlin의 data class에서는 생성자에 var와 val 키워드를 사용하면, 생성자 파라미터와 클래스 변수를 동시에 선언할 수 있다. 이를 통해 작성해야 할 코드 양을 줄일 수 있는 장점이 있다. 그러나 이 방식은 엔티티의 값을 외부에서 쉽게 변경할 수 있는 가능성을 열어두기 때문에, 좋은 설계라고 볼 수 없다. 그렇다면, 어떻게 엔티티 클래스를 작성하는 것이 더 나은 방식일까?

 

var와 val의 차이점

Kotlin에서 var는 변수의 값을 변경할 수 있게 만들고, val은 초기화된 후 값을 변경할 수 없도록 만든다. 따라서, 변경되지 않아야 할 필드에는 val을 사용하고, 외부에서 변경이 가능한 필드는 var를 사용하게 된다.

변경이 불필요한 필드는 val을 사용하여 불변성을 유지하는 것이 좋지만, JPA Entity에서는 모든 필드를 val로 선언할 수는 없다. 예를 들어, 엔티티의 식별자(id)는 데이터베이스에서 자동 생성되므로, 코드에서 변경할 수 없게 막을 수 없다. 또한, JPA는 기본 생성자와 함께 동작하는 특성이 있으므로, data class보다는 일반 클래스를 사용하는 것이 더 적합할 때가 많다.

개선된 설계1: 불변성을 고려한 엔티티 작성

아래는 JPA와 Kotlin에서 엔티티 설계를 개선한 예시다. var를 최소화하고, setter는 외부에서 호출할 수 없도록 protected로 제한하여 불필요한 변경을 방지한다.

@Entity
class Article(
    slug: String = "",
    title: String = "",
    description: String = "",
    body: String = "",
    @ManyToMany
    val tagList: MutableList<Tag> = mutableListOf(),
    createdAt: OffsetDateTime = OffsetDateTime.now(),
    updatedAt: OffsetDateTime = OffsetDateTime.now(),
    @ManyToOne
    val author: User = User()
) {
    var slug: String = slug
        protected set
    var title: String = title
        protected set
    var description: String = description
        protected set
    var createdAt: OffsetDateTime = createdAt
        protected set
    var updatedAt: OffsetDateTime = updatedAt
        protected set

    @ManyToMany
    var favorited: MutableList<User> = mutableListOf()
        protected set

    @Id @GeneratedValue(strategy = GenerationType.AUTO)
    var id: Long = 0
        protected set

    fun favoritesCount() = favorited.size
}

이 방식은 외부에서 함부로 필드 값을 변경하지 못하게 막으면서, 엔티티의 불변성을 유지할 수 있다. slug, title, description 등은 외부에서 접근은 가능하지만, 값을 변경할 수는 없다. 대신, 비즈니스 로직 내에서 값이 필요할 때만 변경할 수 있다. 하지만 val을 사용하는 변수만 클래스의 생성자에 두는 것은 코드의 일관성이 떨어지게 만들 수 있다 생각한다.

 

개선된 설계2: 모든 필드를 클래스 내부에서 선언하기

아래는 val과 var 모두를 생성자가 아닌 클래스 내부에 선언하여 관리하는 방식의 예시다. 이 방식은 코드의 구조를 더 명확하게 하고, 엔티티의 불변성을 강력하게 유지할 수 있다.

@Entity
class Article(
    slug: String = "",
    title: String = "",
    description: String = "",
    body: String = "",
    tagList: MutableList<Tag> = mutableListOf(),
    createdAt: OffsetDateTime = OffsetDateTime.now(),
    updatedAt: OffsetDateTime = OffsetDateTime.now(),
    author: User = User()
) {
    var slug: String = slug
        protected set
    var title: String = title
        protected set
    var description: String = description
        protected set
    var body: String = body
        protected set

    @ManyToMany
    val tagList: MutableList<Tag> = tagList
        protected set

    var createdAt: OffsetDateTime = createdAt
        protected set
    var updatedAt: OffsetDateTime = updatedAt
        protected set

    @ManyToMany
    var favorited: MutableList<User> = mutableListOf()
        protected set

    @ManyToOne
    var author: User = author
        protected set

    @Id @GeneratedValue(strategy = GenerationType.AUTO)
    var id: Long = 0
        protected set

    fun favoritesCount() = favorited.size
}

위와 같이 모든 필드를 클래스 내부에서 선언하면, 외부에서 접근할 수 있는 경로를 명확히 제한할 수 있다. 불필요한 수정이 불가능해지며, 엔티티의 일관성이 더욱 강화된다.

 

 

JPA 특징

  • JPA는 특정 데이터베이스에 종속되지 않는다.
  • 객체지향적인 프로그래밍을 할 수 있도록 도와준다.

Entity Manager Factory

  • 이름 그대로 Entity Manager를 생성하는 객체
  • 싱글턴 방식으로 사용
  • Entity Manager는 쓰레드간에 공유하면 안된다.
  • JPA의 모든 데이터 변경은 트랜잭션 안에서 실행해야한다.

영속성 컨텍스트(Persistence Context)

  • 엔티티를 영구 저장하는 환경
  • EntityManager.persist(entity)
  • 논리적인 개념
  • 엔티티 매니저를 통해서 영속성 컨텍스트에 접근할 수 있다.
  • 스프링 프레임워크 같은 컨테이너 환경에서는 엔티티 매니저와 영속성 컨텍스트가 N:1 관계를 가진다.

영속성 컨텍스트의 이점

  • 1차 캐시
    • persist가 진행되면 바로 DB로 저장되지 않고 영속성 컨텍스트 내부의 1차 캐시에 저장된다. 그리고 해당되는 sql은 영속성 컨텍스트 내부의 쓰기 지연 SQL에 저장된다.
    • find를 사용해서 조회하면 또한 1차캐시에 저장된다.
    • find 사용시 이미 1차 캐시에 저장되있으면 해당 캐시가 사용된다.
  • 동일성(identity) 보장
  • 트랜잭션을 지원하는 쓰기 지연(transactional write-behind)
  • 변경 감지(Dirty Checking)
  • 지연 로딩(Lazy Loading)

Entity Manager를 사용한 업데이트 방법

  • 업데이트 하기전 find를 통해서 해당 entity를 조회하게 된다. 그러면 영속성 상태가 되며 1차 캐시에 해당 엔티티가 올라가게 된다. 이 때 트랜잭션이 걸려있고 값을 변경하게 되면 트랜잭션이 끝나는 시점에 1차 캐시의 처음값이 담긴 스냅샷과 현재의 값을 비교하여 변경되면 update SQL을 쓰기 지연 SQL 저장소에 저장하게 되고 flush할때 DB로 update 쿼리가 실행되게 된다.

flush란?

  • 영속성 컨텍스트의 변경내용을 데이터베이스에 반영하는 작업이다.
  • 플러시가 발생되면 아래 동작이 실행된다.
    • 변경 감지
    • 수정된 엔티티 쓰기 지연 SQL 저장소에 등록
    • 쓰기 지연 SQL 저장소의 쿼리를 데이터베이스에 전송
    • (등록, 수정, 삭제 쿼리)

영속성 컨텍스트가 flush 되는 시점

  • entityManager.flush()가 실행될 때
  • 트랜잭션이 커밋 될 때
  • JPQL 쿼리 실행될 때 (모드를 설정하여 트랜잭션 커밋 시점에 실행되도록 변경할 수 있다.)

엔티티의 생명주기

  • 비영속 (new/transient)
    • 영속성 컨텍스트와 전혀 관계가 없는 새로운 상태
    • 단순히 객체를 생성한 상태
  • 영속 (managed)
    • 영속성 컨텍스트에 관리되는 상태
    • Entity Manager를 통해 persist를 수행한 상태
  • 준영속(detached)
    • 영속성 컨텍스트에 저장되었다가 분리된 상태
    • Entity manager의 detach, clear, close이 실행되면 영속 → 준영속이 된다.
  • 삭제 (removed) - 삭제된 상태

JPA 어노테이션

  • @Entity: JPA가 관리할 객체
  • @Id: 데이터베이스 PK와 매핑
  • @Table: 은 엔티티와 매핑할 테이블 지정 ex) @Table(name = “hello”)
  • @Column 컬럼 매핑
  • @Temporal 날짜 타입 매핑
    • 요즘은 LocalDate, LocalDateTime을 사용하기에 사용 x
  • @Enumerated enum 타입 매핑
    • 결과적으로 value = EnumType.STRING으로 사용해야 된다.
  • @Lob BLOB, CLOB 매핑
    • @Lob에는 지정할 수 있는 속성이 없다.
    • 매핑하는 필드 타입이 문자면 CLOB 매핑, 나머지는 BLOB 매핑
    • CLOB: String, char[], java.sql.CLOB
    • BLOB: byte[], java.sql.BLOB
  • @Transient 특정 필드를 컬럼에 매핑하지 않음(자바에서만 사용되는 변수)
    • 필드 매핑 x
    • 데이터베이스에 저장 x, 조회 x
    • 주로 메모리상에서만 임시로 어떤 값을 보관하고 싶을 때 사용

데이터베이스 스키마 자동 생성

  • DDL을 애플리케이션 실행 시점에 자동으로 생성한다.
  • 테이블 중심 → 객체 중심으로 변경
  • 데이터베이스 방언을 활용해서 데이터베이스에 맞는 적절한 DDL을 생성한다.
  • 이렇게 생성된 DDL은 개발 장비에서만 사용한다.
  • 생성된 DDL은 운영서버에서는 사용하지 않거나, 적절히 다듬은 후 사용된다.

hibernate.hbm2ddl.auto

  • DDL 생성 기능은 DDL을 자동 생성할 때만 사용되고 JPA의 실행로직에는 영향을 주지 않는다.

@SequenceGenerator

  • 아이디 값 위나 클래스 위에 사용
  • 주의: allocationSize 기본값은 50이다.
  • name : 식별자 생성기 이름
  • sequenceName 데이터베이스에 등록되어 있는 시퀸스 이름
  • initialValue DDL 생성 시에만 사용됨, 시퀸스 DDL을 생성할 때 처음 1 시작하는 수를 지정한다.
  • allocationSize 시퀸스 한 번 호출에 증가하는 수 (성능 최적화에 사용된다. 데이터베이스 시퀸스 값이 하나씩 증가하도록 설정되길 원하면 이 값을 반드시 1로 설정해야한다.)
    • 만약 위의 값을 1로 설정하면 데이터를 생성하고 db에 저장하고 조회하는 것을 하나의 데이터마다 수행하여 성능은 떨어질 수 있다.
  • catalog, schema: 데이터베이스 catalog, schema 이름

이번에 Entity를 만들면서 Enum class를 사용하였다. QNA라는 클래스인데, 질문의 답변과 질문을 저장하는 테이블이다. 해당 질문에 답변이 되었는지 구별하기 위해서 isAnswered라는 변수를 만들었다. 해당 변수는 Enum 타입이다. 별생각 없이 Enum class를 변수로 두고 Entity를 생성하였다. 테이블의 enumtype에 들어간 값을 보니 순번이 들어가 있었다. 순번이 들어가 있으면 해당 순서에 있는 문자열을 확인해야 무슨 의미인지 확인할 수 있고 순서가 변경되었을 때 테이블에는 변경된 순서를 반영할 수 없는 문제가 있다. 이를 해결하기 위해서 @Enumerated 어노테이션이 필요하다.


@Enumerated 어노테이션의 타입은 2가지가 있다. ORDINAL, STRING이다. ORDINAL은 한국말로 순서를 나타내는 숫자 서수라고한다. ORDINAL을 사용할 경우 그냥 어노테이션을 사용하지 않아도 같은 효과를 볼 수 있기 때문에 해당 어노테이션을 사용했다면 목적은 STRING 타입을 사용하여 서수 대신에 해당 타입의 이름이 들어가게 하는 것이다. STRING 타입으로 설정하면 순번이 테이블에 기록되는 것이 아니라 ENUMTYPE의 이름이 저장되게 된다. 참고로 순서가 저장되게 되면 해당 칼럼은 INTEGER 타입이 된다.

 

 

EnumType 예시

public enum EnumType {
    /** Persist enumerated type property or field as an integer. */
    ORDINAL,

    /** Persist enumerated type property or field as a string. */
    STRING
}

 

 

ENTITY에서 사용 예시 by kotlin

    @Enumerated(EnumType.STRING)
    var isAnswered = AnswerType.NOT_ANSWERED
        protected set

 

 

+ Recent posts