[JPA] ORM, 영속성 컨텍스트

RID·2024년 6월 20일
0

JPA 이해하기

목록 보기
1/4

배경


Spring 어플리케이션을 개발하면서 JPA의 동작 방식에 대해 이해가 되지 않는 부분이 많았다. @Transactional은 언제 걸어야 하는지, 코드의 흐름은 이상이 없는데 원하는 update가 이루어지지 않는 이유, Repository의 함수들 중 왜 특정 함수들은 JPQL을 사용해서 직접 작성해야만 성능을 높일 수 있는지 등 이유를 모르면서 방법만 적용해서 사용했던 경험이 많았다.

이번 기회에 JPA 관련 내용에 대해서 차근 차근 공부해보고자 한다. 이번 포스팅을 시작으로 아래와 같은 순서로 글을 작성해보려고 한다.

1. ORM과 영속성 컨텍스트
2. 연관관계 매핑
3. 영속성 전이
4. JPA N+1 쿼리 문제

1. ORM(Object-Relational Mapping)


어플리케이션을 개발하기 위해서는 필수적으로 데이터베이스를 연결하게 된다. 서비스를 운영하는데 있어 필요한 데이터를 저장해야 하고, 저장된 데이터를 토대로 기능하게 동작을 분리하는 경우도 있다.

서버를 구축하는데 가장 많이 나오는 CRUD 역시 결국 데이터를 저장하거나 저장된 데이터에 대한 동작들이다. 이렇게 다루는 데이터는 서버의 메모리 공간이 아니라 비휘발성 저장 공간에 저장 되어야 한다.

다시 말해서, 데이터를 생성, 수정한 서버가 잠시 종료되더라도 해당 데이터의 내용은 온전히 살아서 보존되어야 한다는 것이다. 이러한 것을 영속성이라고 하고 우리는 데이터의 영속성을 위해 비 휘발성 저장 공간인 데이터베이스를 함께 사용하게 되는 것이다.

하지만, DB에서 데이터를 가져오거나 삽입, 수정, 삭제하는 경우 각각의 DB에 맞는 SQL문을 통해 소통해야 한다. 그렇기 때문에 처리해야할 데이터가 많아진다는 뜻은 결국 어플리케이션 코드에 더 많은 SQL이 등장한다는 것이다.

그렇다면 이것이 왜 문제가 될까?

  1. SQL프로그래밍 언어가 아니다.

    • 어떤 프레임워크를 쓰는지, 어떤 프로그래밍 언어를 쓰는지에 따라 정해지는 것이 아니고 어떤 관계형 데이터베이스를 쓰는지에 따라 달라진다는 것이다. 그렇다면 당연히 문자열 형태로 SQL 쿼리를 작성할 수 밖에 없고, 컴파일 시점에 에러를 발견할 수 없다.
  2. RDB의 변경 사항이 어플리케이션 코드 내부 SQL 쿼리에 전파된다.

    • 데이터베이스의 스키마 형태나, 참조 등의 내용이 변경되는 경우 관련된 테이블에 해당하는 SQL 쿼리문을 모두 살펴보아야 한다. 물론, JPA를 사용하더라도 DB의 변경 사항이 생기면 해당 Entity를 수정해야 하겠지만, 캡슐화가 잘 되어 있어 변경의 전파가 크지 않다.
  3. 어플리케이션과 RDB는 각각 추구하는 방향이 다르다!

    • 데이터베이스는 말 그대로 데이터를 잘 저장하는 것이 주 목적이다. 데이터를 잘 저장하기 위한 기능, 빠르게 조회하기 위한 indexing, 입력된 쿼리를 빠르게 processing 하는것, 예기치 못한 상황에 대비한 logging과 recovery logic. 결국 client가 요청한 SQL 쿼리를 정확하고 빠르게 처리하는 것이 책임이다.

    • 반면, 우리가 개발하는 어플리케이션의 경우 객체들이 잘 협력할 수 있는 것에 초점을 둔다. 그렇기 때문에 객체지향 프로그래밍에서 등장하는 상속의 개념이 RDB에는 없고, RDB에서 중요한 Data consistency 등이 어플리케이션 코드에서는 쉽게 깨진다.

    • 이로 인해서 RDB 데이터를 객체로 Mapping하는 코드, 객체를 RDB 데이터로 Mapping하는 코드들을 추가하여 이 차이를 해결하려고 했지만, 어플리케이션이 커질 수록 이러한 코드는 유지보수를 힘들게 만든다.

ORM의 등장


위의 문제를 조금 가볍게 정리하면 객체지향 프로그래밍과 RDB의 패러다임 차이라고 볼 수 있는데, 결국 중간에서 이 차이를 메꿔주는 무언가가 등장하리라 예상된다. 그리고 이것을 우리는 현재 ORM이라고 부른다!

출처: https://www.altexsoft.com/blog/object-relational-mapping-tools

ORM은 어플리케이션 개발자 입장에서 RDB 데이터를 객체로 사용할 수 있게 하고, 객체지향 언어를 통해서 RDB와 소통할 수 있게 하는 것이다.

JPA(Java Persistence API) 는 자바 어플리케이션에서 사용할 수 있는 ORM 표준이며, interface 형태로 제공된다.

우리는 이 interface의 구현체 중 성숙도가 높다고 얘기하는 Hibernate를 사용하는 것이고, 이것이 우리가 어렵지 않게 DB와 소통할 수 있게 도와주는 것이다.

ORM의 등장 덕분에 어플리케이션 개발이 SQL 중심이 아닌 객체 중심으로 변환되고, 이에 따라 로직 개발에 집중할 수 있으니 자연스레 생산성이 올라가게 된다.

JAVA 어플리케이션에서 DB와 소통하는 방법

위의 그림을 보면 비슷하게 생긴 여러 용어가 등장한다(아무래도 다 J로 시작해서 그런 것 같다...)

아무튼, JDBC, JPA, Spring Data JPA, hibernate 등 각각이 어떤 책임을 가지고 협력하여 DB와 소통할 수 있는지 간단하게 알아보자.

1. JDBC(Java Database Connectivity) API

우리가 소통해야 할 RDB는 생각보다 종류가 많다. MySQL, PostgreSQL 등등 다양하게 존재하고, 당연히 각각의 SQL 쿼리문에는 차이가 존재한다.

JDBC는 이런 다양한 데이터베이스에 접근하여 SQL을 사용할 수 있도록 interface 형태로 존재한다. 그리고, 사용하는 RDB의 종류에 따라 JDBC의 구현체인 Driver를 통해 해당 DB와 직접 소통하게 된다.

Spring 어플리케이션을 만들면서 DB를 연결할 때 driver를 설정하게 될 텐데 이 과정이 해당 데이터베이스를 사용하기 위한 구현체를 설정하기 위한 과정이다.

2. JPA(Java Persistence API)

위에서 얘기했듯이 이는 자바 어플리케이션에서 사용할 수 있는 ORM 기술 표준이다. JDBC API를 내부적으로 활용하면서 개발자로 하여금 객체지향적으로 데이터를 다룰 수 있게 도와준다. JPA는 이런 ORM의 표준 interface이고, hibernate는 이 interface의 구현체 중 하나이다.

당연히 다른 구현체를 사용해도 무방하지만, 레퍼런스도 많고 안정적이라고 알려진 hibernate를 굳이 사용하지 않을 필요도 없을 것 같긴하다. (사실 다른 것을 안써봐서 얼마나 더 성숙한지, 레퍼런스가 얼마나 더 많은지 느껴보지는 못했다)


3. Spring Data JPA

사실 이전까지는 JPASpring Data JPA가 같은 것인 줄 알았다(사람마다 부르는 용어의 차이인 줄..). JPA는 ORM 기술 표준이다. 해당 표준을 바탕으로 구현된 hibernate를 통해 데이터베이스와 소통하지만, 이 과정에는 JPA의 Entity Manager 인터페이스를 사용해 영속성 컨텍스트로 데이터를 관리하게 된다.

Entity Manager를 직접 사용하여 코드를 작성할 수 있겠지만, JPA를 한 단계 더 추상화하여 Repository라는 인터페이스를 제공하는 것이 Spring Data JPA이다. Spring 어플리케이션을 작성할 때 사용했던 repository 인터페이스가 곧 Spring Data JPA에서 제공하는 것이다.

우리는 이 repository 인터페이스 덕분에 JPA를 훨씬 더 쉽게 이용할 수 있다. save(), remove() 등의 함수부터, 규칙을 준수하며 interface에 메소드를 입력하기만 하면 자동으로 구현체를 만들어준다!

길게 여러 얘기를 한 것 같지만 결국 이 모든 것들이 잘 협력하면서 객체지향적으로 데이터베이스와 소통할 수 있는 환경이 만들어졌다는 것이다! 이제 내부적으로 어떻게 동작하는지 살펴보자.

2. JPA Persistence Context


어플리케이션을 개발할 때 데이터베이스를 연결해서 사용하는 가장 큰 이유 중 하나는 데이터에 영속성을 부여하기 위함이다. 어플리케이션 내부 변수를 통해 관리되는 데이터는 메모리 공간에 존재하게 된다. 즉, 어플리케이션이 종료되면 사라지기 때문에 이 데이터를 DB에 저장하면서 영속성을 부여해야 한다.

JPA는 Entity를 통해 DB의 데이터를 객체지향적으로 다룰 수 있게 도와준다. 하지만 Entity는 실제 메모리에 존재하는 하나의 객체일 뿐이고, 이 데이터에 영속성을 부여하기 위한 과정은 따로 필요하다. JPA는 EntityManager 를 통해 각 Entity에 영속성을 부여해야 하는지 등을 결정하거나 영속 상태의 Entity를 적절하게 관리할 수 있게 해준다.

이렇게 Entity들을 관리하여 영속성을 부여하는 환경을 JPA에서 제공하며 이를 영속성 컨텍스트라고 한다.

Spring Data JPA에서 제공하는 repository를 활용하면 사실 내부 동작에 대해서 구체적으로 몰라도 사용이 가능하지만, 예상치 못한 결과나 오류에 대비하기 위해서 동작을 이해할 필요가 있다.

Entity Life Cycle


Spring 어플리케이션에서 사용되는 Entity는 아래와 같은 생명주기를 가진다. 사실 모든 Entity가 동일한 상태 변화를 거쳐서 주기를 가진다기 보다는, 각 상태가 의미하는 것과 상태 사이의 변화가 어떤 상황에서 발생하는지에 집중하는 게 좋을 것 같다.

출처: https://ultrakain.gitbooks.io/jpa/content/chapter3/chapter3.3.html

  • New: Entity 객체가 처음 생성된 경우 이 상태를 가진다. 이 상태는 사실 영속성 컨텍스트의 관리를 받지 않는 상태이며, 비영속 상태라고도 한다.

  • Managed: 영속성 컨텍스트에 저장되어 관리를 받고 있는 상태이며, 영속 상태라고도 한다.

  • Detached: Managed 상태의 Entity가 영속성 컨텍스트로부터 분리된 상태, 준영속 상태라고 부른다.

  • Removed: 말 그대로 삭제된 상태를 말한다.

위에서 말했듯이, 이렇게 상태만 보면 각각 어떤 상황을 의미하는지 짐작하기 어렵다. 아래 EntityManager의 함수들의 동작과 함께 데이터에 영속성을 부여하는 과정이 어떻게 일어나는 지 살펴보자.

Entity Manager


EntityManager 함수의 동작을 이해하기 위해서는 영속성 컨텍스트의 내부 구조에 대해서 조금 알 필요가 있다.

출처: https://ultrakain.gitbooks.io/jpa/content/chapter3/chapter3.4.html

위의 사진을 보면 쓰기지연 SQL 저장소, 1차 캐시, Dirty Checking 등의 개념이 등장한다. 천천히 살펴보자.

영속성 컨텍스트는 일반적으로 @Transactional의 라이프사이클과 함께 동작하게 된다.

트랜잭션이 시작되면 JpaTransactionManagerEntityManagerFactory로부터 EntityManager를 가져오게 되고, 트랜잭션이 종료될 때 EntityManager를 close하게 된다.

1차 캐시

영속성 컨텍스트는 일반적으로 관리되고 있는 영속상태의 Entity를 1차 캐시라는 저장소에 보관한다.

1차 캐시는 EntityId를 key로 가지며 저장되고, 해당 Entity, 그리고 1차캐시에 저장될 시점의 Entity 스냅샷을 저장하게 된다.

  • 이후 동일한 Id를 가진 데이터의 조회가 일어날 때 DB에 접근하기 이전에 1차 캐시를 먼저 확인하게 되어 불필요한 조회 쿼리를 방지한다.

  • 내부 캐시에 저장함으로서 동일 Id를 가진 Entity에 객체들의 동일성을 보장해준다.

  • 또한, 아래에서 설명할 변경 감지 기능에 중요한 역할을 한다.

쓰기지연 SQL 저장소

  • 영속성 컨텍스트는 트랜잭션이 커밋되기 이전까지 필요한 SQL 쿼리를 쓰기지연 SQL저장소에 쌓아둔다.
    - 이렇게 매번 쿼리를 날리지 않고 모아서 날리게 되면 쿼리를 최적화 해서 날릴 수 있다는 장점이 있다!(나중에 N+1 문제에서 언급할 batch_size 옵션등을 통해 요청 쿼리 개수를 크게 줄일 수 있다)

  • 쓰기지연 SQL 저장소에 있는 쿼리는 flush() 호출을 통해 실제 데이터베이스에 쿼리가 날아가게 되는데, 해당 내용은 아래에서 더 살펴보도록 하자.

Dirty Checking(변경 감지)

Entity수정하는 상황이 생길텐데 EntityManager에는 update()라는 메소드가 존재하지 않는다.

이는 변경감지 기능 때문인데, 위에 1차 캐시를 얘기하면서 Entity와 함께 해당 Entity의 스냅샷을 같이 저장한다고 했었다. 영속성 컨텍스트는 이 스냅샷과 Entity를 비교하여 변경을 감지한다. Entity에 수정이 일어나는 과정을 살펴보자.

  • 먼저 트랜잭션이 종료되는 시점에서 EntityManagerflush()를 호출하게 된다.
    (트랜잭션이 종료되지 않더라도 flush()가 호출되는 시점이라고 생각해도 된다!)
  • 쓰기지연 SQL 저장소에 있는 쿼리를 DB로 보내기 이전에 1차캐시에 존재하는 값들 중 Entity와 스냅샷에 차이가 있는 Entity를 찾아 update 쿼리를 생성해 쓰기지연 SQL 저장소로 보내게 된다.

  • 이후 쓰기지연 SQL 저장소의 쿼리를 DB로 보내고, DB 트랜잭션을 commit하게 된다.

이러한 변경감지 기능을 통해 데이터를 수정하는 과정에서 우리는 repository의 save() 메소드를 굳이 호출할 필요가 없었던 것이다.

당연한 소리겠지만 Dirty Checking은 영속 상태의 Entity, 영속성 컨텍스트의 관리를 받고 있는 Entity에만 적용된다.

Flush에 대하여


위에서 쓰기지연 SQL 저장소의 쿼리문은 flush()가 호출되는 시점에 DB로 날려진다고 했다. flush()는 아래와 같은 세 가지 상황에서 호출될 수 있다.

  1. EntityManagerflush() 함수를 직접 호출하는 경우 혹은 Spring Data Repository의 saveAndFlush() 메소드를 호출하는 경우이다.

  2. Transcation이 종료되어 commit 될 때 자동으로 호출된다.

  3. JPQL 쿼리를 이용해서 요청할 때 자동으로 호출된다.

직접 호출하는 경우나, 트랜잭션이 종료될 때 flush() 가 동작하는 이유는 명확하다고 생각한다. 당연히 실제 Entity로 관리되던 데이터에 영속성을 부여하기 위해서 DB에 SQL 쿼리가 날아가야 하기 때문이다. 그렇다면 왜 JPQL을 통한 요청에서 flush()가 호출되는지 고민해볼 필요가 있다.

이 상황을 이해하기 위한 알아두어야 할 전제가 있다.

JPQL 쿼리는 1차캐시를 보지 않고 바로 DB에 SQL 쿼리를 날린다.

아래와 같은 상황을 생각해보자.

  • 처음에 특정 데이터를 id = 1을 통해 조회한다. 이때, 1차 캐시를 먼저 확인하고 1의 id를 가지는 Entity가 없음을 확인하고 데이터베이스를 조회하여 해당 데이터로 Entity를 만들어 1차 캐시에 저장하게 된다.

  • 이후 수정이 일어났을 때는, 변경이 일어났지만 아직 데이터베이스에 SQL 쿼리가 날아가지 않아 실제 DB에는 변경이 반영되지 않고, 1차 캐시에 존재하는 Entity에만 변경사항이 존재한다.

  • 이때 동일한 id로 조회하지만 이번엔 JPQL로 요청한다. 이 경우 1차캐시를 확인하지 않고 DB에 조회를 하게 되고, 이때 조회하는 값은 1차캐시에 존재하는 값과 다를 것이다.

이런 문제 때문에 JPQL이 DB에 쿼리를 날리기 이전에 1차캐시와 DB 데이터 사이에 정합성을 맞춰주어야 한다. 그래서 flush()를 호출하게 되고 변경감지에 의해 생성된 update 쿼리를 포함하여 쓰기 지연 SQL저장소에 있는 SQL 쿼리를 DB에 날리게 된다.

이후 JPQL에 의해 SQL을 날리더라도 이제는 1차캐시와 DB의 데이터가 동일함을 보장받을 수 있다!

위의 상황에서 헷갈리면 안되는 것은 flush()를 통해 1차 캐시의 데이터와 DB의 데이터 사이 정합성을 보장하는 것이지, 1차 캐시를 포함한 영속성 컨텍스트를 비우는 것이 아니다!

merge()


영속성 컨텍스트에 의해 관리되는 Entitydetach(), clear(), close() 등의 함수 호출로 인해 준영속 상태, 즉 영속성 컨텍스트의 관리에서 벗어나게 된다.

  • detach() : 인자로 들어온 영속 상태의 Entity를 준영속 상태로 만든다.
  • clear(): 모든 영속 상태의 Entity를 준영속 상태로 만든다. 1차 캐시를 비우게 된다.
  • close(): 영속성 컨텍스트가 닫히게 된다. 마찬가지로 영속성 컨텍스트에 의해 관리되던 모든Entity가 준영속 상태가 된다.

이렇게 준영속 상태인 Entity의 경우 영속성 컨텍스트의 관리에서 벗어났기 때문에, 영속성 컨텍스트가 제공하는 그 어떤 기능도 기대할 수 없다.(Dirty Checking 등)

그렇다면 준영속 상태인 Entity를 다시 영속 상태로 만들기 위한 방법은 무엇일까?

바로 merge() 함수를 이용하면 된다! 하지만 이 merge() 함수의 동작 방식에서 우리가 조심해야 할 부분이 있다.


@Transactional
fun main() {
		val member = MemberEntity(name = "test")
		entityManager.persist(member)
	
		entityManager.detach(member)

		val mergedMember = entityManager.merge(member)
}

먼저 새로운 Entity를 생성하여 persist() 함수를 통해 이를 영속 상태로 변경한다. 그리고 detach() 함수를 통해 준영속 상태로 만들고, merge() 함수를 통해 다시 해당 Entity가 영속성 컨텍스트에 의해 관리되길 바란다.

그렇다면 merge()가 어떻게 동작하는지 살펴보자.

  1. 먼저 인자로 넘어온 EntityId가 1차 캐시에 존재하는지 확인한다.
  2. 만약 존재하지 않으면 DB에 조회 쿼리를 날리고 해당 데이터를 1차 캐시에 추가한다.
  3. 1차캐시에 저장된 Entity에서 인자로 넘어온 Entity와의 변경 사항을 반영하여 반환한다.

이제 무엇을 조심해야하는지 느껴질 것이다. persist()는 인자로 넘겨준 객체를 동일하게 반환하는 반면 merge()에 의해 반환된 mergedMember 와 인자로 전달한 member 객체는 서로 다른 인스턴스라는 점이다!

그럼 merge()를 쓸때만 항상 반환된 객체를 사용하면 되는 거 아냐? 라고 생각할 수 있지만, 문제는 우리가 Spring Data JPA의 repository interface를 사용한다는 점이다.

Spring Data JPA repository save()


Spring 어플리케이션을 작성할 때 Service의 비즈니스 로직에서 특정 객체를 저장할 때 save() 함수를 호출한다. 함수의 역할이 이름에서부터 명확하고 구체적인 것을 굳이 알 필요는 없지만 위의 내용을 공부한 이상 우리는 한 가지 습관을 가져야만 한다.

먼저 save()의 동작 방식에 대해서 공부해보자. 결국 Spring Data JPA가 제공하는 repository 역시 JPA를 한 단계 더 추상화한 interface이기 때문에 내부적으로 EntityManager를 통해 데이터의 영속성을 관리한다.

SimpleDataJpaRepository.class

 @Transactional
    public <S extends T> S save(S entity) {
        Assert.notNull(entity, "Entity must not be null");
        if (this.entityInformation.isNew(entity)) {
            this.entityManager.persist(entity);
            return entity;
        } else {
            return this.entityManager.merge(entity);
        }
    }

먼저 인자로 넘어온 Entity에 대해서 isNew() 함수를 호출한다. 이 함수는 해당 EntityidPK 필드가 초기화 되어있는지를 반환하는 것이다.

true를 반환하는 경우 해당 Entity처음 영속 상태로 들어가는 Entity인 것이고, false를 반환하는 경우 이미 한 번 영속성을 부여 받았던 Entity라는 뜻이다.

이때, true인 경우 persist()를, false인 경우 merge()를 호출하게 된다. 우리는 구체적으로 save()의 내부 동작을 정확히 파악하고 사용하는 것이 아니라, save라는 이름에서 알 수 있듯이 DB에 온전하게 저장되기를 원하는 것이다. 그러므로 내가 전달한 Entitypersist()가 호출되는지, merge()가 호출되는지에 대해서 매번 신경쓰기 어렵다.

그렇기 때문에 save() 함수를 사용할 경우 가급적이면 반환된 Entity객체를 사용하는 습관을 가져야 한다. 모든 흐름을 이해하고 있다고 하더라도 merge()의 반환 객체에 대한 예외적인 버그 상황을 만들지 않기 위해서이다!

이전에 양방향 연관관계 매핑 상황에서 이런 merge(), persist()의 동작 차이 때문에 버그를 경험한 적이 있다. 참고해보면 좋을 것 같다.

Spring-Data-JPA에서-양방향-영속성-전파-문제

정리


Controller - Service -Repository의 3 layered-구조를 계속 사용하면서 가장 의문이 들었던 부분이 Repository 였다. 그만큼 추상화가 많이 되어 있기도 하고 구체적인 동작 방식이 많이 숨겨져있다. 간단한 어플리케이션의 경우 큰 문제가 없겠지만 저장해야할 데이터가 늘어나고, 데이터간에 관계가 많아질 수록 성능에 직접적으로 문제가 가는 부분이라서 이번 기회에 한 번 제대로 공부해보고자 하였다.

추상적으로 알고 있던 부분에 대해서 짚어볼 수 있는 시간이었고, 이전에 원인을 모른채 겪었던, 그리고 이유를 모른채 해결했던 부분들도 다시 돌아볼 수 있었던 것 같다!

0개의 댓글