먼저 JPA를 사용할때의 장/단점, 특징, 용어 등을 알아야 한다
JPA란?
JPA: 데이터베이스와 객체지향 프로그래밍 간의 매핑을 제공하는 ORM(Object-Relational Mapping) 기술
특징
데이터베이스를 객체로 매핑하여 SQL을 직접 작성하지 않아도 데이터베이스 작업이 가능
영속성 컨텍스트를 통해 엔티티의 상태를 관리
트랜잭션 범위 내에서 1차 캐시를 통해 데이터베이스 부하를 줄이고 엔티티의 동일성을 보장.
이 글에서는 JPA의 1차 캐시(First Level Cache)를 중심으로 다룬다
캐시, 캐싱이란?
- 캐시(Cache): 데이터나 값을 미리 복사해 놓는 임시 장소
- 캐싱(Caching): 캐시된 영역에서 데이터를 저장하거나 조회하는 접근 방식
1차 캐시란?
1차 캐시(First Level Cache): 영속성 컨텍스트(Persistence Context) 내부에 엔티티를 보관하는 저장소
트랜잭션 범위 내에서 영속 상태의 엔티티를 EntityManager가 관리. 동일한 영속성 컨텍스트에서 관리되는 Entity에 한정
그럼, 2차 캐시도 있나요?
2차 캐시(Second Level Cache): Application 단위의 캐시를 말하며, JPA에서는 Shared Cache라고 명명
Ehcache: JVM 메모리 로컬 캐시
Redis: 분산/외부 캐시
이 글에서는 1차 캐시를 주로 다루므로 2차캐시 얘긴 여기까지..!
(1차 캐시)사용 목적
1. 데이터베이스 부하 감소
동일한 영속성 컨텍스트 내에서 동일한 ID로 조회된 엔티티는 데이터베이스 쿼리를 실행하지 않고, 1차 캐시에 저장된 엔티티를 반환
=> 데이터베이스 부하를 줄이고 조회 성능 향상
2. 엔티티 동일성 보장
동일한 트랜잭션 내에서는 동일한 엔티티 인스턴스를 반환
=> EntityManager.find()를 여러 번 호출하더라도 같은 ID의 엔티티는 같은 객체로 취급
3. 변경 감지(Dirty Checking)
트랜잭션 종료 시점에, JPA는 1차 캐시에 저장된 엔티티의 "스냅샷"(최초 상태)과 현재 상태를 비교 후 변경 사항이 감지된 경우에만 update 쿼리를 실행하여 데이터베이스에 반영
관련메서드
- save(): persist() or merge()로 ActionQueue의 insertions에 대기열에 추가시키며, 영속성 컨텍스트에 저장(1차 캐시로 만듦)
- find(), findOne(), findById(), findByIdOrNull(): 영속성 컨텍스트에서 1차 캐시로 먼저 조회하고, 없을 시 실제 DB쿼리 실행
- flush(): 1차 캐시에 있는 내용을 트랜잭션 로그에 반영(이 과정에서 ActionQueue가 비워짐) but 1차캐시를 비우지는 않음
- clear(): 영속성 컨텍스트를 비움(1차 캐시 삭제)
ActionQueue란?
- Hibernate 내부에서 INSERT, UPDATE, DELETE와 같은 작업을 저장하는 대기열
- flush() 호출 시 이 대기열에 있는 작업이 순차적으로 실행
필수 개발 지식
먼저, 당연한 말이지만 Server Application과 DBMS는 다른 프로세스로 동작한다
다른 프로세스라는 말은 두 프로세스가 서로 데이터를 주고 받기 위해서 Network I/O를 사용해야 한다는 뜻이다
Network I/O에는 데이터 전송 비용(요금)뿐만 아니라, 연결을 설정하고 유지하는 데 드는 CPU 자원 및 시간 비용도 발생한다
이러한 비용을 줄이는 것은 기업 입장에서 서비스 성능을 높이고, 운영 비용을 줄이는 데 매우 중요한 작업인 것이다
또한 서버와 DB는 TCP로 Connection을 맺는다. 이 과정에서 3-way handshake가 일어나는 등... 수많은 과정이 일어난다
이 비용은 값싸지 않기 때문에 Connection Pool이란 걸로 이 과정을 미리 진행해놓고 가져다 쓸 수 있는 HikariCP라는 구현체를 스프링에서 만들어 놨다
이와 비슷하게 Tomcat과 같은 서블릿컨테이너에도 Thread Pool이 존재한다
의외로 이러한 기본적인 개념을 놓치고 있는 개발자분들도 있으신 것 같았다
=> Spring Application(:8080)과 MySQL in Docker(:3306)이 다른 PID를 갖고 떠있는 상태
1차 캐시 장점
그렇기때문에 JPA의 1차 캐시는 AWS, GCP, Azure 등에서 각각 다른 외부 프로세스로 띄워놓고(AWS의 경우 EC2 + RDS) 사용할 경우 어느정도의 비용 감소를 경험할 수 있는 유용한 기능인 셈이다!
이 비용감소라는게 그렇게 크지는 않다. 왜냐하면 1차캐시는 트랜잭션 단위(엔티티매니저 단위)로 생성되고 사라지기때문이다
하지만 엄청 긴 로직 안에서 조회쿼리가 계속 발생해야 한다면 이점이 있는 것이다
그리고 비용 감소는 1차or2차 캐싱 전략을 사용했기 때문에 Network I/O를 줄일 수 있다
1차 캐시 단점
빛이 있다면 어둠이 항상 있는 법. 모든 선택에는 장/단점이 존재한다고 생각한다
1. 캐시를 사용한다는 점은 저장소를 2개로 관리한다는 점이다. 즉 Sync, 동기화 과정이 필요하다는 뜻이다
2. JPA는 Transaction이 Commit or Rollback되는 시점에 flush() 메서드를 자동으로 호출하여 1차캐시의 변경과정은 트랜잭션 로그에 반영하고 Database에서는 트랜잭션 로그를 읽어서 저장할지 날릴지 정하게 된다
그리고 이 flush()과정에는 ActionQueue에 들어간 작업이 처리되는데 개발자의 의도와 다르게 동작할 수도 있다는 것이다(하지만 Hibernate가 왜 그렇게 만들었는지 이유를 알려고 노력하면 이해할 수 있다)
3. Dirty Checking이 일어나면 개발자가 수정한 필드만 업데이트 되는게 아니라 id를 제외한 모든 필드가 업데이트된다
4. 부하가 많은 작업을 JPA를 사용할 경우 EntityManager.clear()를 통해 영속성 캐시를 주기적으로 비워줘야 Heap 메모리에 부하가 줄어들어서 OOM이 발생하지 않게 된다
5. 목적을 항상 기억해야한다. 1차 캐시는 "조회"에 강점을 가진 기능인데, Spring Batch같이 ETL, ELT가 주된 목적인 애플리케이션에서는 굳이 1차캐시를 사용해서 개발자의 의도와 다른 동작, 메모리 사용을 할 필요가 없는 것이다. 그래서 보통 Batch에서는 Jdbc Template을 사용한다
단점들에 대해서 관련된 다음 포스팅에서 직접 알아볼 예정이지만.. 이번 포스팅에서는 1차 캐시의 장점 + 단점/주의점(4번 과정)을 눈으로 확인해보겠다
Tests Setup
특징
- Spring 컨테이너가 다 뜨고 난 후(DataSource, TransactionManager, EntityManager, JPA 관련 빈들 AOP 후처리 등)에 User와 Order 테스트 데이터를 3개정도 넣어줬다. save()와 saveAll()에는 @Transactional이 붙어있어 굳이 추가하진 않았다
특징
- @SpringbootTest대신에 @DataJpaTest를 사용했다
- DataJpaTest와 합이 좋고, 테스트에서 사용할만한 유용한 유틸성 메서드들을 제공해주는 TestEntityManager를 사용했다
- Embedded DB를 사용하기 싫어서 실제 DB를 사용하고자 @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) 옵션을 변경했다
- @AfterEach에서는 로깅 라이브러리보다는 간단하게 테스트쿼리와 구분되게 print 출력을 담당하고 마지막으로 테이블을 비우는 작업을 담당했다
1차 캐시 장점 1
특징
- JUnit5에서 사용가능한 TestInfo 인스턴스 객체에서 DisplayName을 받아서 출력했다
- 데이터 초기화 컴포넌트에서 준비했던 알고 있는 1L아이디로 2번 조회해봤다
결과
분명히 조회 메서드를 2번 사용했는데 콘솔 이후에 select쿼리가 1번만 발생했다
1차 캐시 장점 2
이번에는 다른 ID로 조회해봤다
애플리케이션 메서드로 보면 3번의 조회를 요청했는데 동일한 ID로의 2번, 다른 ID로 1번 해서 총 2번의 쿼리만 발생했다(업계용어로 쿼리가 나갔다 라고도 표현한다ㅋ)
1차 캐시 장점 3
이번에는 기존에 저장된 엔티티가 아니라 새로 저장한 엔티티로 검증해봤다
결과
당연히도 save는 영속화하는 메서드이기때문에 조회를 2번했음에도 쿼리가 발생하지 않았다
만약에 이런 경우는 희박하지만, 내부적으로 엔티티에 변경이 있어서 확실한 조회를 해야할 경우에는 어떻게 해야 할까?
이런 경우, 주입받은 EntityManager의 clear()메서드를 사용해서 1차캐시를 날려버리면 된다
그럼 clear()를 할 때 주의사항은 없을까?
있다. 1차 캐시를 날려버리는것이기때문에 한 트랜잭션 내부에서 사용할 경우에 1차 캐시의 변경점을 트랜잭션 로그에 반영하고 날려야 한다(동기화) < 이 부분이 Hibernate/JPA를 잘 이해하고 써야한다는 점이다
flush()를 해준 이후, clear()를 해줘야 한다는 점이다
1차 캐시 단점(주의사항)
이번에는 대용량작업을 할시에 어떤 문제점이 있고, 이것을 해결하기 위해 어떻게 해야 하는지 알아보자
1차 캐시 단점
코드
실행
분석
참고로 테스트코드는 IntelliJ가 아닌 Gradle로 실행했다
그렇기때문에 VisualVM에서 GradleWorker로 출력이 되는 모습을 볼 수 있다
우측의 Heap메모리 사용량을 보면 20초에는 Used가 90,000,000B였고 50초에는 132,000,000B인 것을 볼 수 있다
이대로 가다간 애플리케이션이 Out Of Memory가 발생할 것이다
이 메모리는 1차 캐시의 메모리가 증가하는 모습이다
GC가 중간중간 비우고 있지만, 그래프가 상승하는 모습을 볼 수 있다
그럼 어떻게 해야할까?
1차 캐시 단점 개선
위에서 말한대로 clear()를 해야한다. 테스트코드가 아닌 실제 애플리케이션이라면 flush()를 통해 데이터베이스 트랜잭션과 sync를 맞추고, clear()로 1차 캐시를 비워줘야 한다(실제처럼 했다)
분석
우측의 Heap메모리 사용량을 보면 20초에는 Used가 49,000,000B였고 50초에는 47,000,000B인 것을 볼 수 있다
그래프도 우상향만 했던 기존 그래프와 비교하면 비교적 평온해보인다
기존의코드와 비교하면 50초 기준으로 (개선 전)132,000,000B -> (개선 후)47,000,000B로 줄어들었다
JPA는 기본적으로 Batch에 약하다. Batch를 위해서는 Native Query또는 JdbcTemplate등을 사용하는 것을 고려하거나, 이런식으로 JPA의 동작과정을 알고 써야한다
이렇게 캐시를 사용하고, 메모리를 사용하는 것의 장단점 및 주의사항에 대해서 알아봤다
- 출처
'Backend > JPA' 카테고리의 다른 글
Hibernate(JPA) 탐구 - 1편(feat. FlushEvent와 Action Queue) (0) | 2024.11.25 |
---|---|
JPA Query 로그 출력(feat. 물고기를 주지말고, 물고기 잡는 법좀 알려줘라..) (31) | 2024.11.15 |
댓글