1. (어제 듣다 남은) 영한님의 JPA 활용2편의 [섹션 4 : API 개발 고급 - 컬렉션 조회 최적화] 강의를 들었다.
📍 v3.1 엔티티를 DTO로 변환 - 페이징과 한계 돌파
- 컬렉션을 페치 조인하면 페이징이 불가능하다.
- 컬렉션을 페치 조인하면 일대다 조인이 발생하므로 데이터가 예측할 수 없이 증가한다.
- 일대다에서 일(1)을 기준으로 페이징을 하는 것이 목적이다. 그런데 데이터는 다(N)를 기준으로 row 가 생성된다.
- Order를 기준으로 페이징 하고 싶은데, 다(N)인 OrderItem을 조인하면 OrderItem이 기준이 되어 버린다.
- 이 경우 하이버네이트는 경고 로그를 남기고 모든 DB 데이터를 읽어서 메모리에서 페이징을 시도한다. 최악의 경우 장애로 이어질 수 있다.
- 그러면 페이징 + 컬렉션 엔티티를 함께 조회하려면 어떻게 해야할까?
- 대부분의 페이징+컬렉션엔티티 조회 문제는 이 방법으로 해결할 수 있다!
1) 모든 ToOne관계를 fetch join
ToOne 관계는 row수를 증가시키지 않으므로 페이징 쿼리에 영향을 주지 않는다.
(예제의 경우 member, delivery에 대해 fetch join)
2) 컬렉션은 지연 로딩으로 조회
3) 지연 로딩 성능 최적화를 위해 batchSize 적용
- batchSize 옵션을 사용하면 컬렉션이나 프록시 객체를 한꺼번에 설정한 size 만큼
IN 쿼리로 조회(스프링 부트 3.1부터는 하이버네이터 6.2를 사용하는데 where in 대신에 array_contains 사용) - batchSize = in쿼리의 개수
- batchsize 1이고 총데이터2개면 2번 날라감
- batchsize 10이고 총데이터100이면 10번 날라감
- 글로벌 설정 : hibernate.default_fetch_size
- 개별 최적화 : @BatchSize
- 컬렉션일 때는 필드에 바로 @BatchSize
- ToOne관계일 때는 대상 엔티티에 @BatchSize


- 장점
- 쿼리호출수가1+N+N -> 1(order)+1(orderitems)+1(item)로 최적화된다.
- 조인보다 DB 데이터 전송량이 최적화된다.
- Order와 OrderItem을 조인하면 Order가 OrderItem 만큼 중복해서 조회된다.
- 이 방법은 각각 조회하므로 전송해야할 중복 데이터가 없다.
- 페치 조인 방식과 비교해서 쿼리 호출 수가 약간 증가하지만, DB 데이터 전송량이 감소한다.
- 컬렉션 페치 조인은 페이징이 불가능 하지만 이 방법은 페이징이 가능하다.
- 비교
- v3 fetch join는 한방쿼리긴함. 근데 중복 데이터(뻥튀기)를 애플리케이션으로 다 전송 -> 쿼리는 한방인데 데이터 전송량이 많음
- v3.1는 한방쿼리는 아니지만. 데이터 중복 없음. 필요한 데이터 딱딱 찍어서 가져옴.
- => 상황에서 맞게 선택 (member, deliver를 fetch join으로 가져올지(쿼리1회), batchsize로 가져올지(쿼리1+1+1회))
- 결론
- ToOne 관계는 페치 조인해도 페이징에 영향을 주지 않는다. 따라서 ToOne 관계는 페치조인으로 쿼리 수를 줄이고 해결하고, 나머지는 hibernate.default_batch_fetch_size 로 최적화 하자.
size 어느정도로? 시간 vs 부하
default_batch_fetch_size 의 크기는 적당한 사이즈를 골라야 하는데, 100~1000 사이를 선택 하는 것을 권장한다. 이 전략은 SQL IN 절을 사용하는데, 데이터베이스에 따라 IN 절 파라미터를 1000으로 제한하기도 하기 때문
1000으로 잡으면 한번에 1000개를 DB에서 애플리케이션에 불러오므로 DB에 순간 부하가 증가할 수 있다.
하지만 애플리케이션은 100이든 1000이든 결국 전체 데이터를 로딩해야 하므로 메모리 사용량이 같다.
1000으로 설정하는 것이 성능상 가장 좋지만, 결국 DB든 애플리케이션이든 순간 부 하를 어디까지 견딜 수 있는지로 결정하면 된다.
스프링 부트 3.1부터는 하이버네이터 6.2를 사용하는데 where in 대신에 array_contains 사용하는 이유
이 둘은 같다.
select ... where array_contains([1,2,3],item.item_id)
select ... item.item_id where in(1,2,3)
where in 대신에 array_contains로 변경하는 이유는 성능 최적화 때문이다.
SQL을 실행할 때 DB는 SQL구문을 이해하기 위해 SQL을 파싱하고 분석하는 등 여러가 지 복잡한 일을 처리해야 한다.
그래서 성능을 최적화하기 위해 이미 실행된 SQL 구문은 파싱된 결과를 내부에 캐싱하고 있다 (SQL 실행 결과가 아니고 SQL 구문 자체 캐싱)
where in 쿼리는 데이터 개수에 따라 SQL 구문이 바뀐다.
where item.item_id in(?)
where item.item_id in(?,?)
where item.item_id in(?,?,?,?)
array_contains는 왼쪽 배열에 바인딩하기 때문에 데이터 개수가 달라져도 바뀌지 않는다.
select ... where array_contains(?,item.item_id)
📍 v4. JPA에서 DTO 직접 조회
- 루트 1회 + 컬렉션 N회 = 1+N
- ToOne(N:1, 1:1) 관계들을 먼저 조회하고, ToMany(1:N) 관계는 각각 별도로 처리한다.
- 이런 방식을 선택한 이유는 다음과 같다.
- ToOne 관계는 조인해도 데이터 row 수가 증가하지 않는다.
- ToMany(1:N) 관계는 조인하면 row 수가 증가한다.
- row 수가 증가하지 않는 ToOne 관계는 조인으로 최적화 하기 쉬우므로 한번에 조회하고, ToMany 관계는 최적화하기 어려우므로 findOrderItems() 같은 별도의 메서드로 조회한다.
📍 v5. JPA에서 DTO 직접 조회 - 컬렉션 조회 최적화
- 루트 1회 + 컬렉션 1회 = 2
-
ToOne 관계들을 먼저 조회하고, 여기서 얻은 식별자 orderId로 ToMany 관계인 OrderItem 을 한꺼번에 조회
- where in을 이용해서 컬렉션 조회 한방에
-
MAP을 사용해서 매칭 성능 향상(O(1))
- 비교
- dto로 직접 조회 : 많은 코드 직접 작성. 데이터 전송량은 적음.
- fetch join : 많은거가 자동화됨. 데이터 전송량은 많음.
📍 v6. JPA에서 DTO 직접 조회 - 플랫 데이터 최적화
- 쿼리한방
- 단점
- 쿼리는 한번이지만 조인으로 인해 DB에서 애플리케이션에 전달하는 데이터에 중복 데이터가 추가되므로 상황에 따라 V5 보다 더 느릴 수 도 있다.
- 애플리케이션에서 추가 작업이 크다. (애플리케이션이 다시 묶고 정리하고 해줘야함)
- 페이징 불가능
<<<<<< 여기부터 다시 읽어
📍 정리
- 엔티티 조회
- 엔티티를 조회해서 그대로 반환: V1
- 엔티티 조회 후 DTO로 변환: V2
- 페치 조인으로 쿼리 수 최적화: V3
- 컬렉션 페이징과 한계 돌파: V3.1
- 컬렉션은 페치 조인시 페이징이 불가능
- ToOne 관계는 페치 조인으로 쿼리 수 최적화
- 컬렉션은 페치 조인 대신에 지연 로딩을 유지하고, hibernate.default_batch_fetch_size , @BatchSize 로 최적화
- DTO 직접 조회
- JPA에서 DTO를 직접 조회: V4
- 컬렉션 조회 최적화 - 일대다 관계인 컬렉션은 IN 절을 활용해서 메모리에 미리 조회해서 최적화: V5
- 플랫 데이터 최적화 - JOIN 결과를 그대로 조회 후 애플리케이션에서 원하는 모양으로 직접 변환: V6
📍 권장 순서
1. 엔티티조회방식으로 우선접근
1) 페치조인으로 쿼리 수를 최적화
2) 컬렉션 최적화
- 페이징 필요 hibernate.default_batch_fetch_size , @BatchSize 로 최적화
- 페이징 필요X 페치 조인 사용
2. 엔티티조회방식으로 해결이 안되면 DTO조회방식 사용
3. DTO 조회 방식으로 해결이 안되면 NativeSQL or 스프링 JdbcTemplate
참고: 엔티티 조회 방식은 페치 조인이나, hibernate.default_batch_fetch_size , @BatchSize 같이 코드를 거의 수정하지 않고, 옵션만 약간 변경해서, 다양한 성능 최적화를 시도할 수 있다. 반면에 DTO를 직 접 조회하는 방식은 성능을 최적화 하거나 성능 최적화 방식을 변경할 때 많은 코드를 변경해야 한다.
참고: 개발자는 성능 최적화와 코드 복잡도 사이에서 줄타기를 해야 한다. 항상 그런 것은 아니지만, 보통 성 능 최적화는 단순한 코드를 복잡한 코드로 몰고간다. > 엔티티 조회 방식은 JPA가 많은 부분을 최적화 해주기 때문에, 단순한 코드를 유지하면서, 성능을 최적화 할 수 있다. > 반면에 DTO 조회 방식은 SQL을 직접 다루는 것과 유사하기 때문에, 둘 사이에 줄타기를 해야 한다.
📍 DTO 조회 방식의 선택지
- DTO로 조회하는 방법도 각각 장단이 있다. V4, V5, V6에서 단순하게 쿼리가 1번 실행된다고 V6이 항상 좋은 방법인 것은 아니다.
- V4는코드가단순하다. 특정주문한건만조회하면이방식을사용해도성능이잘나온다.예를들어서조회 한 Order 데이터가 1건이면 OrderItem을 찾기 위한 쿼리도 1번만 실행하면 된다.
- V5는 코드가 복잡하다. 여러 주문을 한꺼번에 조회하는 경우에는 V4 대신에 이것을 최적화한 V5 방식을 사 용해야 한다. 예를 들어서 조회한 Order 데이터가 1000건인데, V4 방식을 그대로 사용하면, 쿼리가 총 1 + 1000번 실행된다. 여기서 1은 Order 를 조회한 쿼리고, 1000은 조회된 Order의 row 수다. V5 방식 으로 최적화 하면 쿼리가 총 1 + 1번만 실행된다. 상황에 따라 다르겠지만 운영 환경에서 100배 이상의 성 능 차이가 날 수 있다.
- V6는 완전히 다른 접근방식이다. 쿼리 한번으로 최적화 되어서 상당히 좋아보이지만, Order를 기준으로 페 이징이 불가능하다. 실무에서는 이정도 데이터면 수백이나, 수천건 단위로 페이징 처리가 꼭 필요하므로, 이 경우 선택하기 어려운 방법이다. 그리고 데이터가 많으면 중복 전송이 증가해서 V5와 비교해서 성능 차이도 미비하다.
2. 영한님의 JPA 활용2편의 [섹션 5 : API 개발 고급 - 실무 필수 최적화] 강의를 들었다.
📍 OSIV ON
- spring.jpa.open-in-view : true (기본값)
-
OSIV 전략은 최초 데이터베이스 커넥션 시작 시점부터 API 응답이 끝날 때 까지 영속성 컨텍스트와 데이터베이스 커넥션을 유지한다.
지연 로딩은 영속성 컨텍스트가 살아있어야 가능하고, 영속성 컨텍스트는 기본적으로 데이터베이스 커넥션을 유지한다. OSIV 덕분에 controller등에서 지연로딩이 가능하다.
- 동작원리 (https://willseungh0.tistory.com/74)
- 1) 클라이언트의 요청이 들어오면 영속성 컨텍스트 생성. BUT 트랜잭션 시작 X
- 2) 서비스 계층에서 트랜잭션을 시작하면 앞에서 생성해 둔 영속성 컨텍스트에 트랜잭션 시작
- 3) 비즈니스 로직 실행하고 서비스 계층 끝나면 트랜잭션 커밋하면서 영속성 컨텍스트 플러시 (트랜잭션만 종료하고 영속성 컨텍스트는 살려둔다)
- 4) 클라이언트의 요청이 끝날 때 영속성 컨텍스트 종료
- 영속성 컨텍스트는 트랜잭션 범위 안에서 엔티티를 조회하고 수정할 수 있다.
- 영속성 컨텍스트는 트랜잭션 범위 밖에서 엔티티를 조회만 할 수 있다. (트랜잭션 없이 읽기)
- 단점
- 너무 오랜시간동안 데이터베이스 커넥션 리소스를 사용 -> 실시간 트래픽이 중요한 애플리케이션에서는 커넥션이 모자랄 수 있다 -> 이것은 결국 장애로 이어진다
- ex. 컨트롤러에서 외부 API를 호출하면 외부 API 대기 시간 만큼 커넥션 리소스를 반환하지 못하고, 유지해야 한다.
📍 OSIV OFF
- spring.jpa.open-in-view: false (OSIV 종료)
-
트랜잭션을 종료할 때 영속성 컨텍스트를 닫고, 데이터베이스 커넥션도 반환한다 -> 따라서 커넥션 리소스를 낭비하지 않는다.
- false하면 해당 메소드 끝나는 순간에 트랜잭션 커밋(또는 롤백)하고 영속성 컨텍스트 날려버리고 커넥션도 반환
- 단점
-
모든 지연로딩을 트랜잭션 안에서 처리해야 한다. 따라서 지연 로딩 코드를 트랜잭션 안으로 넣어야 한다.
-
쥬니의 말~~
(OSIV = false 상태)
메소드 나올 때 transaction commit & persistence context close
(OSIV = true 상태)
메소드 나올 때 transaction commit
controller에서는 pc가 물고 있는 con사용해서 select 기타등등 가능
요청이 완전 끝났을 때 persistence context close
3. 예전에 발생한 트랜잭션과 영속성 컨텍스트 관련 에러를 제대로 이해했다.
이 글 2번 강의를 듣다가 트랜잭션의 범위와 영속성 컨텍스트의 범위, 즉 생명주기가 다를 수 있다는 사실을 깨달았다.
그리고 lazy loading은 트랜잭션이 아니라 영속성 컨텍스트가 살아있어야 가능하다.
그런데 문득..!! 옛날 우리팀 테스트 코드에서 발생했던 예외가 생각났다.
상황
- service에 대한 테스트
- 최초) 테스트에 @Transactional이 없었다 -> LazyLoading 실패 어쩌고 예외 발생
- 수정) 테스트에 @Transactional 붙임 -> 해결
당시에는 '지연로딩은 트랜잭션 범위 안에서만 기능하는구나~'하고 넘겼었다.
근데 '지연로딩은 트랜잭션이 아니라 영속성 컨텍스트가 살아있어야 가능하다'
그럼 저 상황은 뭐였을까???
글렌과 대화를 했더니 궁금증이 해결됐다.
- 테스트 메소드에 @Transactional을 붙인다
- 메소드 시작과 동시에 트랜잭션 시작
- -> 트랜잭션 시작과 동시에 영속성 컨텍스트 생성
- -> @Transactional이 붙은 service 로직 입성
- -> 기본전파옵션(REQUIRED)라 외부 트랜잭션(테스트 메소드의 트랜잭션)에 참여
- -> service 로직 데굴데굴
- -> service 로직 나와도 외부 트랜잭션 유지(영속성 컨텍스트 살아있음)
- -> 영속성 컨텍스트로 레이지로딩
- 테스트 메소드에 @Transactional 안 붙인다
- 테스트 데굴데굴
- -> @Transactional이 붙은 service 로직 입성
- -> 외부 트랜잭션 없기 때문에 새로운 트랜잭션 생성
- -> 영속성 컨텍스트 생성
- -> service 로직 데굴데굴
- -> service 로직 나오면 트랜잭션 끝. 영속성 컨텍스트도 없어짐
- -> 영속성 컨텍스트 없어서 레이지로딩 불가
- -> 에러
4. 자바 ORM 표준 JPA 프로그래밍 책의 [13.1 트랜잭션 범위의 영속성 컨텍스트]를 읽었다.
위 에러에 대해 분석하다가 영속성 컨텍스트와 트랜잭션 범위에 대해 제대로 모르는 것 같아서 책을 봤다.
1. 스프링 컨테이너는 트랜잭션 범위의 영속성 컨테이너 전략을 기본으로 사용한다.
= 트랜잭션 범위와 영속성 컨텍스트의 생존 범위가 같다
- 트랜잭션을 시작할 때 영속성 컨텍스트 생성
- 트랜잭션이 끝날 때 영속성 컨텍스트 종료
- 1) 트랜잭션 커밋
- 2) 먼저 영속성 컨텍스트를 플러시해서 변경 내용 데이터베이스 반영
- 3) 데이터베이스 트랜잭션 커밋
- (만약 예외가 발생하면 트랜잭션 롤백하고 종료. 플러시 호출X)
2. 트랜잭션이 같으면 같은 영속성 컨텍스트를 사용한다.
- 트랜잭션 범위의 영속성 컨텍스트 전략은 다양한 위치에서 엔티티 매니저를 주입받아 사용해도 트랜잭션이 같으면 항상 같은 영속성 컨텍스트를 사용한다.
- 따라서 밑 예제에서도 엔티티 매니저를 사용하는 repo1, repo2 모두 같은 트랜잭션 범위에 있기 때문에, 엔티티 매니저는 달라도 같은 영속성 컨텍스트를 사용한다.
@Service
class TestService {
@Autowired Repository1 repo1;
@Autowired Repository2 repo2;
@Transactional
public void logic() {
repo1.hello();
repo2.findMember();
}
}
@Repository
class Repository1 {
@PersistenceContext
EntityManger em;
public void hello() {
em.XXX();
}
}
@Repository
class Repository2 {
@PersistenceContext
EntityManger em;
public void findMember() {
em.XXX();
}
}
3. 트랜잭션이 다르면 다른 영속성 컨텍스트를 사용한다
- 같은 엔티티 매니저를 사용해도 트랜잭션에 따라 접근하는 영속성 컨텍스트 다름
- 스프링 컨테이너는 스레드마다 각각 다른 트랜잭션 할당
- 따라서 같은 엔티티 매니저를 호출해도 접근하는 영속성 컨텍스트가 다르므로 멀티스레드 환경에 안전
5. [펀잇] 외부 이용자가 S3버킷에 접근해 object를 다운받거나 업로드하게 하려면
우리팀은 프론트가 직접 S3에 이미지를 업로드하기로 했다!
(프론트에서 백으로 이미지를 보내고 백에서 s3로 업로드하면, 이미지 전송자체로 서버부하가 커져서)
흑흑 사실 s3&cloudfront 붙이는거 재밌어 보여서 내(백엔드)가 하고 싶었는데.. 프론트쪽에서 하는게 맞는 것 같다 ㅠㅡㅠ
언젠가 기회가 되다면 꼭 직접 해보고 싶다!
여튼 관련 정보를 좀 찾아봤다~
1) 모든 파일을 public으로 만들기
- 장 : 별도 관리 필요없다
- 단 : 아무나 파일 다운 가능 (보안 문제)
2) IAM 자격증명 공유 (access key 공유)
- 장점 : 지정한 사람만 공유 가능
- 단점 : 자격증명 유출/변경 시 공유자 모두에게 다시 부여 필요해야함. 관리 자체가 빡셈.
3) IAM 사용자 부여 (Role 부여)
- 장점 : 지정한 사람만 공유 가능
- 단점 : IAM 사용자 숫자 제한 (5000개), 부여하는 과정과 유지보수의 어려움
4) pre-signed url
미리 서명된 권한이란 임시 권한으로서 말 그대로 S3 접근을 위한 임시 url로, 일정 시간이 지나면 만료
- 흐음 근데 우리팀은 현재 프론트, 백 둘 다 같은 ec2에 있는데,
이 ec2에 S3접근권한 가진 Role을 부여하면 프론트에서 바로 S3에 꽂을 수 있는거 아닐까?! - 근데 그러면 만약 나중에 프론트의 정적 페이지를 S3로 배포하면, Role을 가진 ec2에서 벗어나는 거니까 또다시 접근 불가인가?
그러면 그냥 accessKey를 공유하는게 나을까?! - 우리는 어차피 이미지를 업로드하는 이용자가 프론트엔드 하나뿐인데 굳이 presigned URL을 쓸 필요가 있을까?
6. 알고리즘 문제를 1개 풀었다.