no image
[GreenCherry/React, Spring boot, PWA, FCM] FCM을 활용한 백그라운드에서 알림 구현
그린체리 프로젝트를 바탕으로 알림서비스에 대한 구조를 간단하게 설명하자면, 프론트 단에서 처음 로그인을 할 경우, 1. 알림 허용에 대한 권한에 대해 묻는다. 2. 허용을 눌렀을 경우 각 아이디당 플랫폼에 해당한 유니크한 토큰을 발급하여 회원정보와 함께 DB에 저장한다. 프론트 단에서 알림관련 API 호출할 경우, 1. 백엔드 단에서 알림에 관련된 API가 호출되면서 해당 내용에 대한 알림을 회원에게 보낸다. 각 프론트, 백엔드에서 FCM이 무슨 역할을 하는지 이해하는 게 중요하다 프론트에서 FCM은 알림 허용 권한, 토큰 발급의 역할을 하는 대신, 백엔드에서 FCM은 알림을 프론트로 보내는 역할을 한다 실질적인 FCM은 백엔드에서 사용된다고 볼 수 있다 해당 프로젝트에선 백엔드 서버인 Java에서 알림..
2023.05.25
TIL
no image
[GreenCherry] 로직 수정을 통한 로직 효율성 높이기
모바일에서 주문목록을 조회하는 로직을 정리해보자면 (1) memberId에 해당한 order 데이터 조회한다. (2) orderId로 store 데이터 조회한다. (3) orderId로 review 데이터 조회한다. (4) review 작성여부를 통해 리뷰 작성 가능 기간인지, 리뷰 작성 가능 여부를 체크한다. (5) DTO 형식으로 변경 후 반환한다. 수정 전 로직 서비스와 서비스간의 통신인 Feign이 order의 개수만큼 이루어지기때문에 order의 개수가 많을 경우 많은 api 호출이 이루어져 서비스의 속도가 느려지는 것을 확인할 수 있었다. 이 문제사항을 해결하기 위해 order를 list로 묶어서 한 번에 통신할 수 있도록 로직을 수정하였다. 다른 부분에서도 사용되는 메서드, 로직이 있어 수정..
2023.05.14
no image
[GreenCherry/동시성이슈] DB Lock중에서도 Pessimistic Lock을 활용한 동시성 이슈 해결
요약 동시성 문제를 해결하기 위해 1️⃣ synchronized 2️⃣ DB Lock 3️⃣ 메시지 큐, Redis를 이용한 글로벌 락 를 고려해보았을 때, 현재 프로젝트에선 Pessimistic Lock을 사용하여 공유되는 데이터 DB 데이터 row에 직접 Exclusive Lock을 거는 방안이 적합하다고 생각하여 Pessimistic Lock을 적용하였다. 동시성 여러 작업이 겹치는 기간에 실행될 수 있음을 의미한다. 동시에 실행하는 것이 아니라 CPU가 작업마다 시간을 분할해 적절하게 context switching을 해서 동시에 실행되는 것처럼 보이게 한다. 동시성의 핵심 목표 : 유휴 시간을 최소화하는 것이다. 유휴 시간은 컴퓨터가 작동 가능한데도 작업을 하지 않는 시간으로 아무것도 안하고 놀..
2023.05.06
no image
[JPA] OSIV와 성능 최적화
Open Session In View : 하이버네이트 Open EntityManager In View : JPA (관례상 OSIV라 한다) OSIV ON spring.jpa.open-in-view : true 기본값 이 기본값을 뿌리면서 애플리케이션 시작 시점에 warn로그를 남기는 것은 이유가 있다. OSIV 전략은 트랜잭션 시작처럼 최초 데이터베이스 커넥션 시작 시점부터 API 응답이 끝날 때까지 영속성 컨텍스트와 데이터베이스 커넥션을 유지한다. 그래서 지금까지 View Template이나 API 컨트롤러에서 지연 로딩이 가능했던 것이다. 지연 로딩은 영속성 컨텍스트가 살아있어야 가능하고, 영속성 컨텍스트는 기본적으로 데이터베이스 커넥션을 유지한다. 이것 자체가 큰 장점이다. 그런데 이 전략은 너무 ..
2023.03.22
TIL
[JPA] API 개발 고급 정리
정리 엔티티 조회 엔티티 조회해서 그대로 반환 : V1 엔티티 조회 후 DTO로 변환 : V2 페치 조인으로 쿼리 수 최적화 : V3 컬렉션 페이징과 한계 돌파 : V3.1 컬렉션은 페치 조인시 페이징이 불가능 ToOne관계는 페치 조인으로 쿼리 수 최적화 컬렉션은 페치 조인 대신에 지연 로딩을 유지하고, hibernate.default_batch_fetch_size, @BatchSize로 최적화 DTO 직접 조회 JPA에서 DTO를 직접 조회 : V4 컬렉션 조회 최적화 - 일대다 관계인 컬렉션은 IN 절을 활용해서 메모리에 미리 조회해서 최적화 : V5 플랫 데이터 최적화 - JOIN 결과를 그대로 조회 후 애플리케이션에서 원하는 모양으로 직접 변화 : V6 권장 순서 엔티티 조회 방식으로 우선 접근..
2023.03.21
TIL
[JPA] 페이징과 한계돌파
컬렉션을 페치 조인하면 페이징이 불가능하다. 컬렉션을 페치조인하면 일대다 조인이 발생하므로 데이터가 예측할 수 없이 증가한다. 일대다에서 일(1)을 기준으로 페이징을 하는 것이 목적이다. 그렇데 데이터는 다(N)를 기준으로 row가 생성된다. Order를 기준으로 페이징하고 싶은데 다(N)인 OrderItem을 조인하면 OrderItem이 기준이 되어버린다. 더 자세한 내용은 자바 ORM 표준 JPA 프로그래밍 - 페치 조인 한계 참고 이 경우 하이버네이트는 경고 로그를 남기고 모든 DB 데이터를 읽어서 메모리에서 페이징을 시도한다. 최악의 경우 장애로 이어질 수 있다. 한계 돌파 그러면 페이징과 컬렉션 엔티티를 함께 조회하려면 어떻게 해야할까 ? 지금부터 코드도 단순하고, 성능 최적화도 보장하는 매우 ..
2023.03.20
TIL
[JPA] N+1 문제
JPA를 사용하다 보면 의도하지 않았지만 여러 번의 select문이 여러 개가 나가는 현상을 본 적이 있을 것이다. 이러한 현상을 N+1문제라고 부른다. N+1 문제 연관 관계가 설정된 엔티티를 조회할 경우에 조회된 데이터 개수(n)만큼 연관관계의 조회 쿼리가 추가로 발생하여 데이터를 읽어오는 현상 지연로딩에서는 N+1문제가 발생하지 않는 것처럼 보이지만, 막상 객체를 탐색하려고 하면 N+1문제가 발생되어 즉시로딩과 N+1문제가 발생되는 시점만 다를 뿐이다. N+1 발생 원인 JPA가 JPQL을 분석해서 SQL을 생성할 때는 글로벌 Fetch 전략을 참고하지 않고, 오직 JPQL 자체만을 사용한다 즉, 아래와 같은 순서로 동작한다. 해결 방안 Fetch Join, EntityGraph 어노테이션, Bat..
2023.03.19
TIL
no image
[JPA] respository에 DTO 사용 ?
Respository public List findAllWithMemberDelivery() { return em.createQuery( "select o from Order o"+ " join fetch o.member m" + " join fetch o.delivery d ",Order.class ).getResultList(); } Query Respository (DTO 사용한 버전) public List findOrderDtos() { return em.createQuery( "select new jpabook.jpashop.repository.OrderSimpleQueryDto(o.id, m.name,o.orderDate,o.status, d.address) from Order o"+ " jo..
2023.03.18
TIL

그린체리 프로젝트를 바탕으로 알림서비스에 대한 구조를 간단하게 설명하자면,

 

프론트 단에서 처음 로그인을 할 경우,

1. 알림 허용에 대한 권한에 대해 묻는다.

2. 허용을 눌렀을 경우 각 아이디당 플랫폼에 해당한 유니크한 토큰을 발급하여 회원정보와 함께 DB에 저장한다.

 

프론트 단에서 알림관련 API 호출할 경우,

1. 백엔드 단에서 알림에 관련된 API가 호출되면서 해당 내용에 대한 알림을 회원에게 보낸다.

 

 

각 프론트, 백엔드에서 FCM이 무슨 역할을 하는지 이해하는 게 중요하다

프론트에서 FCM은 알림 허용 권한, 토큰 발급의 역할을 하는 대신, 

백엔드에서 FCM은 알림을 프론트로 보내는 역할을 한다

실질적인 FCM은 백엔드에서 사용된다고 볼 수 있다

 

해당 프로젝트에선 백엔드 서버인 Java에서 알림을 구현했지만, Node.js,Python 등에서도 구현이 가능하다고 한다.

 

 

 

프로젝트에서 FCM을 사용한 이유는 간단하다

1. 플랫폼 종속성 없이 push 메시지 전송 가능

2. 사용자 기기의 배터리 및 네트워크 리소스 절약

3. 백그라운드를 통한 푸시 알림 기능

 

 

1. 플랫폼 종속성 없이 push 메시지 전송 가능

iOS,Android, Web 각 플랫폼에서 push 메시지를 보내려면 각 플랫폼별로 개발해야 하는 불편함이 있는데,

FCM을 이용하면 플랫폼에 종속되지 않고 push 메시지를 전송할 수 있다

 

 

2. 사용자 기기의 배터리 및 네트워크 리소스 절약

서버를 경유해서 실시간 push 메시지를 받으려면 사용자는 항상 서버에 접속해야하는데,

이는 사용자 기기의 배터리 및 네트워크 리소스를 크게 낭비한다

 

클라우드 메시징 서비스를 사용하면

사용자는 낮은 배터리와 네트워크의 사용만으로도 메시지를 실시간으로 송수신 처리를 할 수 있다

이와 같은 이유로 대부분의 어플리케이션 서비스들은 클라우드 메시징 서버를 경유해서,

실시간으로 유저들에게 메시지를 전송해주고 있다고 한다

 

3. 백그라운드를 통한 푸시 알림 기능

PWA의 Service Worker를 활용하여 웹 페이지와는 별개로 백그라운드 동기화나 푸시 알림 기능을 구현하기 위해 선택했다

 

 

구현 단계 정리

[Frontend] React에서 PWA 구현 단계

  1. PWA Manifest 설정하기 PWA Manifest를 설정하기 위해서는 public/manifest.json 파일을 생성합니다. 이 파일에서는 앱의 이름, 아이콘, 시작 URL 등을 정의할 수 있다
  2. Service Worker 구현하기 Service Worker는 public/service-worker.js 파일에서 구현할 수 있습니다. 이 파일에서는 Service Worker의 라이프사이클 이벤트를 처리하고, 오프라인 캐싱 등의 기능을 구현할 수 있다
  3. Push Notification을 위한 FCM 설정하기 Firebase Cloud Messaging(FCM)을 이용하여 Push Notification을 구현할 수 있다 이를 위해서는 Firebase 프로젝트를 생성하고, FCM을 설정해야 합니다. React에서는 firebase 모듈을 이용하여 FCM SDK를 사용할 수 있다
  4. 알림 목록을 가져올 API 구현하기 알림 목록을 가져오기 위한 API를 구현해야 한다. 이를 위해서는 Spring Boot Java 백엔드에서 알림을 저장하고, 필요한 API를 구현한다.
  5. React 프론트엔드에서 API 호출하기 React에서는 axios 또는 fetch 등의 라이브러리를 이용하여 API를 호출할 수 있다. 필요한 API를 호출하여 알림 목록을 가져와야 한다.
  6. 알림을 표시하는 컴포넌트 구현하기 알림 목록을 가져온 후에는 알림을 표시하는 컴포넌트를 구현해야 한다. 이를 위해서는 React에서 Notification API를 이용할 수 있다.
  7. PWA를 등록하기 PWA를 등록하기 위해서는 index.js 파일에서 serviceWorkerRegistration.register()를 호출해야 한다.

PWA를 사용하기 위해서는 Service Worker를 등록해야 합니다. 이를 위해 아래와 같이 코드를 추가할 수 있습니다.

import { initializeApp } from 'firebase/app';
import { getMessaging, getToken, onMessage } from 'firebase/messaging';

const firebaseConfig = {
  // Firebase 설정 정보
};

// Firebase 초기화
const app = initializeApp(firebaseConfig);
const messaging = getMessaging(app);

// Service Worker 등록
if ('serviceWorker' in navigator) {
  window.addEventListener('load', async () => {
    try {
      const registration = await navigator.serviceWorker.register('/firebase-messaging-sw.js');
      messaging.useServiceWorker(registration);
      console.log('ServiceWorker registration successful with scope: ', registration.scope);
    } catch (err) {
      console.log('ServiceWorker registration failed: ', err);
    }
  });
}

// 알림 권한 요청 및 토큰 발급
async function requestPermission() {
  console.log('권한 요청 중...');

  const permission = await Notification.requestPermission();
  if (permission === 'denied') {
    console.log('알림 권한 허용 안됨');
    return;
  }

  console.log('알림 권한이 허용됨');

  const token = await getToken(messaging, {
    vapidKey: 'BBuoQiK6Hci6-fWBqgcIAn-a8Nzc7kF1XVpkCKfHINcvckb-u3sz8eSrsbtns2WjrXZ9bxs7j0DCsNtkNIiqjHc',
  });

  if (token) console.log('token: ', token);
  else console.log('Can not get Token');

  // 알림 수신 처리
  onMessage(messaging, (payload) => {
    console.log('메시지가 도착했습니다.', payload);
    // ...
  });
}

requestPermission(); 

위 코드에서 /firebase-messaging-sw.js는 Service Worker 파일이 위치한 경로를 의미한다. 이 경로는 실제 프로젝트에서 사용하는 경로에 맞게 변경해주어야 한다. 또한, vapidKey는 Web Push에서 사용되는 Public Key 값으로 변경해야 한다.

 

[Backend] Spring Boot Java 에서 알림을 구현 단계 

  1. FCM 설정하기 Firebase Console에서 프로젝트를 생성하고, FCM을 설정한다.
  2. Firebase Admin SDK 연동하기 Firebase Admin SDK를 이용하여 FCM을 사용할 수 있다. 이를 위해서는 Firebase Console에서 서비스 계정을 생성하고, 해당 계정의 인증 정보를 이용하여 Firebase Admin SDK를 초기화한다.
  3. FCM API 구현하기 Spring Boot Java 백엔드에서는 FCM API를 구현하여 Push Notification을 보낸다. 이를 위해서는 Firebase Admin SDK의 Messaging 클래스를 이용한다.
  4. 알림 목록 API 구현하기 알림 목록을 조회하고 관리하기 위해서는 백엔드에서 알림 관련 데이터를 저장하고, 필요한 API를 구현해야 한다. 예를 들어, 알림 목록을 조회하는 API를 구현하면, 프론트엔드에서 해당 API를 호출하여 알림 목록을 가져올 수 있다. 이때 백엔드에서는 데이터베이스를 이용하여 알림 정보를 저장하고, RESTful API를 구현하여 프론트엔드와 통신합니다. 또한, 알림을 클릭하면 해당 알림으로 이동할 수 있는 링크를 제공하기 위한 API도 구현할 수 있다. 이렇게 구현된 API를 이용하여, 사용자가 알림을 클릭하면 백엔드에서 해당 알림에 대한 처리를 수행하고, 필요한 화면으로 이동시키는 것이 가능한다.

 

구현 단계 자세히 보기

 

1. FCM 설정하기

 

파이어베이스 프로젝트 생성

  1. Firebase에 로그인하고 프로젝트를 연다
  2. 개요 페이지에서 앱 추가를 클릭한다
  3. 웹 앱에 Firebase 추가를 선택한다
  4. 스니펫을 복사하여 애플리케이션 HTML에 추가한다

💡 SDK 설정 및 구성 (Firebase SDK snippet) 을 잘 확인해야 한다

프로젝트 설정에서 일반탭에서 생성한 앱에 대한 정보를 확인이 가능하며, FCM은 클라우드 메시징 탭에서 확인 가능하다.

 

 

비밀 키 파일 생성

생성한 프로젝트 페이지 > 설정 > 서비스 계정 항목

 

프로젝트로 생성된 비밀키를 이동

원래 깃허브와 같이 공개된 장소에 이 파일을 같이 올리는 것은 안전하지 않다고 한다

이번 프로젝트에서는 json 파일로 생성된 비밀 키를 resources 디렉토리에 저장했다 

resources/firebase/greencherry-notice.json

{
  "type": "service_account",
  "project_id": "greencherry-notice",
  "private_key_id": "",
  "private_key": "",
  "client_email": "",
  "client_id": "",
  "auth_uri": "https://accounts.google.com/o/oauth2/auth",
  "token_uri": "https://oauth2.googleapis.com/token",
  "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
  "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/firebase-adminsdk-qjueq%40greencherry-notice.iam.gserviceaccount.com"
}

 

 

2. Firebase Admin SDK 연동하기

 

Dependency 설정

 

build.gradle

implementation 'com.google.firebase:firebase-admin:9.1.1'

 

application.yml

firebase:
  path: firebase/greencherry-notice.json

 

FCM 초기화

어플리케이션이 시작될 때 Firebase 프로젝트에 앱을 등록해줘야 한다.

(두 번 등록 되면 에러가 나므로 시작할 때마다 초기화 해준다)

 

commons/NoticeInitializer.java

@Slf4j
@Component
public class NoticeInitializer {

    @Value("${firebase.path}")
    private String path;

    @PostConstruct
    public void init(){
        ClassPathResource resource = new ClassPathResource(path);

        try(InputStream is = resource.getInputStream()) {
            FirebaseOptions options = FirebaseOptions.builder()
                    .setCredentials(GoogleCredentials.fromStream(is))
                    .build();

            if(FirebaseApp.getApps().isEmpty()){
                FirebaseApp.initializeApp(options);
                log.info("Firebase application init");
            }
        } catch (IOException e) {
            e.printStackTrace();
            log.error(e.getMessage());
        }
    }
}

 

 

 

3. FCM API 구현하기 (메세지 보내기)

메세지를 보낼 때 필요한 요소는 2가지이다

1. 누구한테 보낼지

 토큰과 토픽 방식으로 보낼 수 있는데, 토큰을 사용했다

2. 어떤 정보를 보낼지

 

아래와 같이 Message를 보내는 별도의 서비스를 만들었다

NotificationRequest에는 받을 상대의 토큰값, 푸시될 알림 메세지와 제목을 가지고 있고

사용하진 않지만, 이미지도 지정해서 넣어줄 수 있다

 

service/NoticeService

public void sendNotice(List<String> tokens){
        for (String token : tokens) {
            Message message = Message.builder()
                    .setToken(token)
                    .putData("title", "제목")
                    .putData("body", "내용")
                    .setWebpushConfig(WebpushConfig.builder().putHeader("ttl", "1000")
                            .setNotification(new WebpushNotification("title", "body"))
                            .build())
                    .build();

            FirebaseMessaging.getInstance().sendAsync(message);
        }
    }

 

 

 

더 자세한 코드를 확인하실 분은  아래 링크 참고 부탁드립니다

https://github.com/tandamzi/GreenCherry

 

 

 

참고

 

Web Push | React + FCM 구현하기 (feat. pwa, service worker)

웹에서도 네이티브 앱처럼 푸시 알람을 받을 수 있습니다. 웹의 사용성을 네이티브 앱처럼 개선하기 위해서 나온 기술인 PWA(Progressive Web Application)을 활용하면 가능합니다.PWA는 Progressive Web Applica

velog.io

 

FCM, Spring Boot를 사용하여 웹 푸시 기능 구현하기

Firebase firebase Firebase는 웹과 모바일 개발에 필요한 기능을 제공하는 BaaS(BackEnd as a Service) 이다. 백엔드 서버의 인프라들을 제공해주고 많은 기능들을 지원한다. 머신러닝 사용자 인증 파일 저장

velog.io

 

 

📮개인 공부를 위한 공간으로 틀린 부분이 있을 수도 있습니다.📮

문제점 및 수정 사항이 있을 시, 언제든지 댓글로 피드백 주시기 바랍니다.

'TIL' 카테고리의 다른 글

Redis ?  (0) 2023.05.26
FCM(Firebase Cloud Messaging)  (0) 2023.05.26
[JPA] OSIV와 성능 최적화  (0) 2023.03.22
[JPA] API 개발 고급 정리  (1) 2023.03.21
[JPA] 페이징과 한계돌파  (0) 2023.03.20

모바일에서 주문목록을 조회하는 로직정리해보자면

(1) memberId에 해당한 order 데이터 조회한다. 

(2) orderId로 store 데이터 조회한다.

(3) orderId로 review 데이터 조회한다.

(4) review 작성여부를 통해 리뷰 작성 가능 기간인지, 리뷰 작성 가능 여부를 체크한다.

(5) DTO 형식으로 변경 후 반환한다.

 

수정 전 로직

서비스와 서비스간의 통신인 Feign이 order의 개수만큼 이루어지기때문에 order의 개수가 많을 경우 많은 api 호출이 이루어져 서비스의 속도가 느려지는 것을 확인할 수 있었다.

이 문제사항을 해결하기 위해 order를 list로 묶어서 한 번에 통신할 수 있도록 로직을 수정하였다. 다른 부분에서도 사용되는 메서드, 로직이 있어 수정하는데 고려하는 상황이 많았다.

 

order-service의 OrderService

public Page<OrderMobileListResponseDto> mobileOrderList(Long memberId, Pageable pageable){
        log.info("[OrderService] mobileOrderList ");

        Page<Order> pageByMemberId = orderRepository.findPageByMemberId(memberId, pageable);

        // TODO : 리뷰량이 많은 경우 속도 느림
        // TODO : order시간 기준 최신순 정렬
        Page<OrderMobileListResponseDto> pages = pageByMemberId.map(order -> {
            StoreInfoForOrderDto storeInfoDto = storeServiceClient.storeInfoForOrder(order.getStoreId()).getData();
            Boolean review = reviewServiceClient.existReviewByOrder(order.getId()).getData();

            String writedCheck = writedCheck(order.getCreateDate(), LocalDateTime.now(), review);
            return OrderMobileListResponseDto.create(order, storeInfoDto, writedCheck);
        });

        return pages;

    }

 

 

수정 후 로직

HashMap과 HashSet를 활용하여 순서에 맞는 order 정보를 가져왔으며, 원하는 데이터를 뽑아 DTO로 만들어 반환해주었다.

 

order-service의 OrderService

/** 모바일 용 */
    public Page<OrderMobileListResponseDto> mobileOrderList(Long memberId, Pageable pageable){
        log.info("[OrderService] mobileOrderList ");

        Page<Order> orders = orderRepository.findPageByMemberId(memberId, pageable);

        List<Long> storeIds = new ArrayList<>();
        List<Long> orderIds = new ArrayList<>();

        orders.forEach(order -> {
            storeIds.add(order.getStoreId());
            orderIds.add(order.getId());
        });

        List<StoreInfoForOrderDto> storeInfoForOrderDtos = storeServiceClient.storeInfoForOrder(storeIds).getData();
        List<Long> orderIdsByReview = reviewServiceClient.existReviewByOrder(orderIds).getData();

        HashMap<Long,StoreInfoForOrderDto> storeInfoMap = new HashMap<>();
        storeInfoForOrderDtos.forEach(storeInfo-> storeInfoMap.put(storeInfo.getStoreId(),storeInfo));

        HashSet<Long> set = new HashSet<>(orderIdsByReview);
        Page<OrderMobileListResponseDto> pages = orders.map(order -> {
            StoreInfoForOrderDto storeInfoForOrderDto = storeInfoMap.get(order.getStoreId());
            String writedCheck = writedCheck(order.getId(), order.getCreateDate(), LocalDateTime.now(), set);
            return OrderMobileListResponseDto.create(order, storeInfoForOrderDto, writedCheck);
        });

        return pages;

    }

 

 

 

TIL :

서비스단 로직을 개발하면서 CRUD를 구성할 때도, 자료구조를 활용하지 않아서 그런지, 알고리즘외에 자료구조를 활용할 생각을 못했던 것 같다. 이번 로직수정을 통해 자료구조에 대한 개념을 다시 정리할 필요성을 느꼈으며, 자료구조에 대해 활용하는 방안에 대해서도 생각할 수 있는 경험이 되었다.

 

 

 

feign client 적용할 때 참고하기 좋은 블로그 :

 

우아한 feign 적용기 | 우아한형제들 기술블로그

{{item.name}} 안녕하세요. 저는 비즈인프라개발팀에서 개발하고 있는 고정섭입니다. 이 글에서는 배달의민족 광고시스템 백엔드에서 feign 을 적용하면서 겪었던 것들에 대해서 공유 하고자 합니다

techblog.woowahan.com

 

 

 

 

📮개인 공부를 위한 공간으로 틀린 부분이 있을 수도 있습니다.📮

문제점 및 수정 사항이 있을 시, 언제든지 댓글로 피드백 주시기 바랍니다.

요약

동시성 문제를 해결하기 위해 1️⃣ synchronized 2️⃣ DB Lock 3️⃣ 메시지 큐, Redis를 이용한 글로벌 락 를 고려해보았을 때, 현재 프로젝트에선 Pessimistic Lock을 사용하여 공유되는 데이터 DB 데이터 row에 직접 Exclusive Lock을 거는 방안이 적합하다고 생각하여 Pessimistic Lock을 적용하였다.

 

 

동시성

여러 작업이 겹치는 기간에 실행될 수 있음을 의미한다.

동시에 실행하는 것이 아니라 CPU가 작업마다 시간을 분할해 적절하게 context switching을 해서 동시에 실행되는 것처럼 보이게 한다.

동시성의 핵심 목표 : 유휴 시간을 최소화하는 것이다.

유휴 시간은 컴퓨터가 작동 가능한데도 작업을 하지 않는 시간으로 아무것도 안하고 놀고 있는 시간이라고 생각하면 된다.

현재 프로세스 또는 스레드가 I/O 작업, DB 트랜잭션 등등 외부 프로그램 실행을 기다리는 동안에 다른 프로세스 또는 스레드가 CPU 할당을 받는다. 그림으로 표현하면 아래와 같다.

 

이 여러개의 task들은 하나 이상의 코어에서 실행된다. 같은 시간에 같은 자원에 접근하는 상황이 생길 수 있는데 해당 자원에 write 권한으로 접근하는 경우 '데이터의 무결성 유지!'를 꼭 염두해둬야 한다.

 

 

고려사항

사용자의 주문하기와 관련된 서비스이다 보니 “동시성” 문제 또한 고려하지 않을 수 없다.

동시성 문제가 발생할 수 있는 부분은 사용자가 주문하기 위해 가게 정보와 체리박스 수량을 조회하는 부분과 체리박스 수량을 감소하는 부분이다.

주문하기가 끝나기전 체리박스 수량이 감소되지 않은 시점에서 체리박스 주문하기를 하여 주문이 되는 문제가 발생하면 안된다

또한 체리박스 수량이 마지막 1개 남아 있는 시점에서 동시에 두 명의 유저가 요청을 하게 되었을 때 둘 모두 성공하는 것이 아니라 한 명만이 성공해야 한다

이러한 동시성 문제를 해결하기 위해 1️⃣ synchronized 2️⃣ DB Lock 3️⃣ 메시지 큐, Redis를 이용한 글로벌 락 를 고려할 수 있다.

일반적으로 synchronized를 붙이는 코드는 @Transactional 로 감싸진 메소드이다.

즉 AOP를 통해 해당 메소드 앞 뒤로 트랜잭션 시작과 트랜잭션 커밋 또는 롤백을 수행해준다

 

 

 

우선 트랜잭션 시작은 동시에 요청이 온 두 트랜잭션 모두가 시작할 수 있다. 트랜잭션 프록시를 호출하게 되면 트랜잭션 프록시는 데이터 소스를 찾아서 사용하게 되면서 이때 커넥션 풀에서 커넥션을 획득하게 된다. 따라서 동시에 온 두 요청 모두가 각각의 커넥션을 소유할 수 있게 된다. 이렇게 커넥션을 맺은 이후에 실제 Target Method에 대해서는 synchronized 를 걸었기에 순차적으로 진행된다.

 

하지만 커밋하는 시점, 즉 하나의 스레드가 Target Method를 처리하고 나오는 순간 아직 첫 번째 트랜잭션 커밋이 되지 않은 시점에서 다른 트랜잭션에서 해당 메소드에 진입이 가능하게 된다. 앞선 트랜잭션에서는 커밋이 완료되지 않았고, 두 번째 실행된 트랜잭션에서 쿠폰을 발급받게되면 둘 모두 성공하게 된다.

즉, 처음 트랜잭션이 아직 커밋되지 않은 시점에 두 번째 트랜잭션이 DB로부터 데이터를 조회할 수 있게 되고 해당 데이터를 통해서 검증을 수행하게 되면 여전히 동시성 문제가 존재한다.

 

이러한 방식은 근본적으로 하나의 서버를 고려하였을 때 생각할 수 있는 방법이며, 여러 대의 서바가 존재한다고 하면 진입점이 여러 개가 되므로 결국 동일한 문제가 발생한다.

 

예를 들어, 각각의 서버에서 수량을 조회할 수 있고, A,B 서버 모두 수량 =1 을 조회해온다면 둘 다 감소 시키는 문제가 발생할 수 있다.

 

이를 해결하기 위한 방법으로 메시지 큐, Redis를 이용한 글로벌 락과 같은 방법도 있겠지만,

실무라 가정하고, 실무라면 현재 사용하는 리소스이외의 Redis를 사용할 경우에는 운영비, 구축비용등 비용적인 측면이 발생하기 때문에 사용하고 있던 MySQL을 활용해서 Optimistic Lock, Pessimistic Lock 2가지 방법을 고려했다.

 

 

 

결론

결론적으로 Pessimistic Lock을 사용하여 공유되는 데이터 DB 데이터 row에 직접 Exclusive Lock을 거는 방안을 선택했다. 따라서 여러 요청이 왔을 때 한 번에 하나의 요청만이 해당 row에 접근할 수 있게 되고 순차적으로 처리되는 효과를 얻을 수 있다. 이러한 비관적 락은 DB row에 Exclusive Lock을 걸기에 속도가 많이 저하되는 문제가 있지만, Optimistic Lock을 사용하지 않은 이유는 다음과 같다.

 

 

Optimistic Lock은 “선착순 1명”과 같이 여러 요청 중 하나의 요청만을 성공시켜야 할 때 적절한 방법이라고 생각한다 Optimistic Lock은 실패 시에 개발자가 직접 재시도를 해주어야 하는데 특정 예외가 발생했을 때 해당 메소드를 재시도하는 Spring에서 제공해주는 @Retryable 어노테이션을 함께 사용하는 것을 고려해 볼 수 있다. 하지만 이 경우 몇 번 재시도를 하는 것이 적절한지 명확한 답을 생각하는 게 어렵다 판단하였다.

 

 

Optimistic Lock 은 일반적으로 처리 요청을 받은 순간부터 처리가 종료될 때까지 레코드를 잠그는 Pessimistic Lock보다 성능이 좋다.

데이터 성향에따라, Pessimistic Lock이 좋은 경우도 있는데 이런 경우이다.

  • 재고가 1개인 상품이 있다.
  • 100만 사용자가 동시적으로 주문을 요청한다.

Pessimistic Lock의 경우 1명의 사용자 말고는 대기를 하다가 미리 트랜잭션 충돌 여부를 파악하게 된다. 즉, 재고가 없음을 미리 알고 복잡한 처리를 하지 않아도 된다.

Optimistic Lock 의 경우 동시 요청을 보낸 사용자가 처리를 순차적으로 하다가 Commit을 하는 시점에 비로소 재고가 없음을 파악하게 된다. 그리고 처리한 만큼 롤백도 해야하기 때문에, 자원 소모도 크게 발생하게 된다.

 

 

적용하기

Pessimistic Lock 전 수정전 코드

  1. feign을 통해 store-service에서 가게 정보, 체리박스 정보 반환받는다.
  2. order 데이터 생성
  3. feign 을 통해 체리박스 수량 감소 로직 실행 후 반환
    1. 반환값이 필요 없음에도 feign을 사용한 이유 성공, 실패 로직 없기 때문에 비동기로 구현할 경우, 체리박스 수량 감소가 실패할때 판단할 수 없기때문에 성공, 실패 로직을 따로 만들지 않고, 순차적으로 로직을 수행하기 위해 feign 방안 선택했다.

Order-service

OrderService

@Transactional
    public void registerOrder(RegisterOrderDto orderDto){
        log.info("orderDto.getStoreId() = {}", orderDto.getStoreId());

        SingleResult<StoreDetailforOrderResponseDto> result = storeServiceClient.storeDetailforOrder(orderDto.getStoreId());
        StoreDetailforOrderResponseDto storeDetail = result.getData();

        if(!storeDetail.isOpen()){
            throw new StoreNotOpenException();
        }

        if(orderDto.getOrderQuantity()>storeDetail.getCherryBox().getQuantity()){
            throw new CherryBoxQuantityInsufficientException();
        }

        int totalSalesAmount = orderDto.getOrderQuantity() * storeDetail.getCherryBox().getPricePerCherryBox();

        // TODO : 하나의 회원이 해당 가게에 대해 여러 번 주문할 경우 컬럼이 새로 생성된다.
        // TODO : 손님 a, 손님 b가 동시에 가게 c에 주문을 한다면 ? 동시성 이슈 !
        orderRepository.save(Order.builder()
                .memberId(orderDto.getMemberId())
                .storeId(orderDto.getStoreId())
                .state(State.ORDER_COMPLETE)
                .quantity(orderDto.getOrderQuantity())
                .totalSalesAmount(totalSalesAmount)
                .build());

        Result decreaseCherrybox = storeServiceClient.decreaseCherryBox(storeDetail.getStoreId(), orderDto.getOrderQuantity());

    }

 

 

Test code

동시성 테스트는 CountDownLatch를 이용했다.

📌 ExecutorService

  • 병렬 작업시 여러 개의 작업을 효율적으로 처리하기 위해 제공된 JAVA 라이브러리
  • 손쉽게 ThreadPool을 구성하고 Task를 실행하고 관리할 수 있는 역할
  • Executors를 사용하여 ExecutorService 객체를 생성하며, ThreadPool의 개수 및 종류를 지정할 수 있는 메소드를 제공한다.

📌 CountDownLatch

  • 어떤 스레드가 다른 스레드에서 작업이 완료될 때까지 기다릴 수 있도록 해주는 클래스
  • CountDownLatch 를 이용하여, 멀티스레드가 100번 작업이 모두 완료한 후, 테스트를 하도록 기다리게 한다.
  • 작동원리
    1. new CountDownLatch(100); 를 이용해 Latch할 갯수를 지정한다
    2. CountDown()을 호출하면 Latch의 숫자가 1개씩 감소한다.
    3. await()은 Latch의 숫자가 0이 될 때까지 기다리는 코드다.
@Test
    void registerOrder_동시요청() throws InterruptedException {
        int threadCount = 100;
        ExecutorService executorService = Executors.newFixedThreadPool(32);
        CountDownLatch latch = new CountDownLatch(threadCount);

        for(int i=0;i<threadCount;i++){
            executorService.submit(()->{
                try{
                    RegisterOrderDto build = RegisterOrderDto.builder()
                            .storeId(1L)
                            .memberId(3L)
                            .orderQuantity(1)
                            .build();
                    orderService.registerOrder(build);
                }finally {
                    latch.countDown();
                }
            });
        }
        latch.await();
        List<Order> byMemberId = orderRepository.findByMemberId(3L);

        assertEquals(100,byMemberId.size());
    }

이와 같은 경우 테스트 실패

CountDownLatch를 이용하여, 멀티스레드 작업이 100번의 주문증가 로직을 호출한 뒤에 주문량 100, 재고가 0이 되는지 확인했지만, 실제로는 의외의 값이 나오는 것을 확인할 수 있다.

그 이유는 레이스 컨디션(Race Condition) 이 일어나기 때문이다.

  • 🔥 레이스 컨디션이란, 2 이상의 스레드가 공유 데이터에 액세스 할 수 있고, 동시에 변경하려할 때 발생할 수 있는 문제

해결방안

수정후 코드

Store-service

StoreRepository

		/**[주문하기용] 가게 상세 조회 */
    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("SELECT s FROM Store s JOIN FETCH s.cherryBox WHERE s.id = :id")
    Optional<Store> findByIdLockWithCherryBox(Long id);

실패

이와 같은 경우 테스트 실패

CountDownLatch를 이용하여, 멀티스레드 작업이 100번의 주문증가 로직을 호출한 뒤에 주문량 100, 재고가 0이 되는지 확인했지만, 실제로는 의외의 값이 나온다.

트랜잭션 관리가 되지 않아 체리박스 감소 메소드에서 동시성 이슈 발생한다 판단하여 체리박스 감소 메소드에도 Pessimistic Lock을 걸어 동시성 이슈를 방지하였다.

Store-service

StoreRepository

		/**[주문하기용] 가게 상세 조회 */
    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("SELECT s FROM Store s JOIN FETCH s.cherryBox WHERE s.id = :id")
    Optional<Store> findByIdLockWithCherryBox(Long id);

    /**
     * [주문하기용] 체리박스 감소
     */
    @Lock(LockModeType.PESSIMISTIC_WRITE)
    Optional<Store> findById(Long storeId);

이와 같은 경우 테스트 실패

하지만 이 역시도 주문량 100, 재고가 0이 되는지 확인했지만, 실제로는 의외의 값이 나온다.

 

 

 

성공

현재 진행했던 로직에서 2번의 feign 통해 데이터를 주고 받는 부분에서 트랜잭션 관리가 되지 않아 의외의 값이 나온다고 판단하여, 1번의 feign만으로 데이터를 주고 받는 로직을 수정하였다.

수정 로직

  1. order-service에서 받은 ResigerOrderDto를 feign을 통해 store-service에 보낸다 [Pessimistic Lock 적용]
    1. store-service에서 체리박스 수정과 같은 로직을 수행한 후, order 컬럼을 만들 때 필요한 데이터 StoreDetailforOrderResponseDto 를 order-service에 준다.
  2. order-service에서 order 데이터 생성

Order-service

orderService

@Transactional
    public void registerOrder(RegisterOrderDto orderDto){
        log.info("[OrderService] registerOrder");
        log.info("orderDto.getStoreId() = {}", orderDto.getStoreId());

        SingleResult<StoreDetailforOrderResponseDto> result = storeServiceClient.storeDetailforOrder(orderDto);
        StoreDetailforOrderResponseDto storeDetail = result.getData();

        // TODO : 하나의 회원이 해당 가게에 대해 여러 번 주문할 경우 컬럼이 새로 생성된다.
        orderRepository.save(Order.builder()
                .memberId(orderDto.getMemberId())
                .storeId(orderDto.getStoreId())
                .state(State.ORDER_COMPLETE)
                .quantity(orderDto.getOrderQuantity())
                .totalSalesAmount(storeDetail.getTotalSalesAmount())
                .build());


    }

 

 

store-service

storeService

/**[주문하기용] 가게 상세 조회 */
    @Transactional
    public StoreDetailforOrderResponseDto storeDetailforOrder(RegisterOrderDto orderDto){
        log.info("[StoreService] storeDetailforOrder");
        Store store = storeRepository.findByIdLockWithCherryBox(orderDto.getStoreId()).orElseThrow(StoreNotFoundException::new);

        if(!store.isOpen()){
            throw new StoreNotOpenException();
        }

        if(orderDto.getOrderQuantity()> store.getCherryBox().getQuantity()){
            throw new CherryBoxQuantityInsufficientException();
        }

        int totalSalesAmount = orderDto.getOrderQuantity() * store.getCherryBox().getPricePerCherryBox();

        cherryBoxService.decreaseCherryBox(store.getId(), orderDto.getOrderQuantity());

        return StoreDetailforOrderResponseDto.create(store,totalSalesAmount);

    }

 

storeRepository

		/**[주문하기용] 가게 상세 조회 */
    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("SELECT s FROM Store s JOIN FETCH s.cherryBox WHERE s.id = :id")
    Optional<Store> findByIdLockWithCherryBox(Long id);

 

 

 

 

 

그외 고민 사항

에러 처리

에러처리에 대해서도 많은 고민을 했다.

수정 전 로직에서 체리박스 수량 부족, 가게 오픈 여부에 대한 Exception이 한 메소드안에서 발생하는데, 둘다 404로 Exception을 터뜨리니, order-service에서 에러처리 로직에서 어떻게 구현해야할지 고민이 많았다. 실제로 인프런 강의에서도 exception이 한 번만 터지는 로직만 예시로 봐서 고민 끝에 유사한 Exception코드를 찾아 사용하였다.

store-service

ExceptionAdvice

@ExceptionHandler(CherryBoxQuantityInsufficientException.class)
    @ResponseStatus(HttpStatus.NOT_ACCEPTABLE)
    public Result CherryBoxQuantityInsufficientException() {
        return responseService.getFailureResult(-201,"해당 가게에 대한 체리박스 수량이 부족합니다.");
    }

@ExceptionHandler(StoreNotOpenException.class)
    @ResponseStatus(HttpStatus.NOT_FOUND)
    public Result StoreNotOpenException() {
        return responseService.getFailureResult(-300,"해당 가게 오픈 시간이 아닙니다.");
    }

 

order-servicce

FeignErrorDecoder

@Slf4j
public class FeignErrorDecoder implements ErrorDecoder {
    @Override
    public Exception decode(String methodKey, Response response) {
        switch (response.status()){
            case 404:
                if(methodKey.contains("storeDetailforOrder")){
                    return new StoreNotOpenException();
                }
                break;
            case 406:
                if(methodKey.contains("storeDetailforOrder")){
                    return new CherryBoxQuantityInsufficientException();
                }
            default:
                return new Exception(response.reason());
        }
        return null;
    }
}

 

 

동시성 이슈를 해결하면서 로직 설계의 중요성을 깨닫게 되었다. 

수정 전 코드를 보면 알 수 있다싶이 feignclient를 통해 데이터를 가져온 부분이 2번이나 사용이 되는데, DB Lock을 거는데에도 어떻게 걸어야할지에 대한 고민을 하게끔하는 로직이였다면,수정 후 코드에선 feignclient를 통해 데이터를 가져오는 부분을 1개로 줄이면서 DB Lock도 보다 쉽게 걸 수 있는 것을 확인할 수 있다.

 

📮개인 공부를 위한 공간으로 틀린 부분이 있을 수도 있습니다.📮

문제점 및 수정 사항이 있을 시, 언제든지 댓글로 피드백 주시기 바랍니다.

[JPA] OSIV와 성능 최적화

개발의 숩
|2023. 3. 22. 00:18

Open Session In View : 하이버네이트

Open EntityManager In View : JPA

(관례상 OSIV라 한다)

 

OSIV ON

  • spring.jpa.open-in-view : true 기본값

 

이 기본값을 뿌리면서 애플리케이션 시작 시점에 warn로그를 남기는 것은 이유가 있다.

OSIV 전략은 트랜잭션 시작처럼 최초 데이터베이스 커넥션 시작 시점부터 API 응답이 끝날 때까지 영속성 컨텍스트와 데이터베이스 커넥션을 유지한다. 그래서 지금까지 View Template이나 API 컨트롤러에서 지연 로딩이 가능했던 것이다.

지연 로딩은 영속성 컨텍스트가 살아있어야 가능하고, 영속성 컨텍스트는 기본적으로 데이터베이스 커넥션을 유지한다. 이것 자체가 큰 장점이다.

그런데 이 전략은 너무 오랜시간동안 데이터베이스 커넥션 리소스를 사용하기때문에, 실시간 트래픽이 중요한 애플리케이션에서는 커넥션이 모자랄 수 있다. 이것은 결국 장애로 이어진다.

예를 들어서 컨트롤러에서 외부 API를 호출하면 외부 API 대기 시간만큼 커넥션 리소스를 반환하지 못하고, 유지해야 한다.

 

 

 

 

OSIV OFF

  • spring.jpa.open-in-view : false OSIV 종료

 

OSIV를 끄면 트랜잭션을 종료할 때 영속성 컨텍스트를 닫고, 데이터베이스 커넥션도 반환한다. 따라서 커넥션 리소스를 낭비하지 않는다.

OSIV를 끄면 모든 지연로딩을 트랜잭션 안에서 처리해야 한다. 따라서 지금까지 작성항 많은 지연 롣딩 코드를 트랜잭션 안으로 넣어야 하는 단점이 있다. 그리고 view template에서 지연로딩이 동작하지 않는다. 결론적으로 트랜잭션이 끝나기 전에 지연 로딩을 강제로 호출해 두어야 한다.

 

 

 

커멘트와 쿼리 분리

실무에서 OSIV를 끈 상태로 복잡성을 관리하는 좋은 방법이 있다.

바로 Command와 Query를 분리하는 것이다. 보통 비즈니스 로직은 특정 엔티티 몇개를 등록하거나 수정하는 것이므로 성능이 크게 문제가 되지 않는다. 그런데 복잡한 화면을 출력하기 위한 쿼리는 화면에 맞추어 성능을 최적화 하는 것이 중요하다. 하지만 그 복잡성에 비해 핵심 비즈니스에 큰 영향을 주는 것은 아니다.그래서 크고 복잡한 애플리케이션을 개발한다면, 이 둘의 관심사를 명확하게 분리하는 선택은 유지보수 관점에서 충분히 의미있다

 

단순하게 설명해서 다음처럼 분리하는 것이다

  • OrderService
    • OrderService : 핵심 비즈니스 로직
    • OrderQueryService : 화면이나 API에 맞춘 서비스( 주로 읽기 전용 트랜잭션 사용)

보통 서비스 계층에서 트랜잭션을 유지한다. 두 서비스 모두 트랜잭션을 유지하면서 지연 로딩을 사용할 수 있다.

 

참고 : OSIV에 관해 더 깊이 알고 싶으면 자바 ORM 표준 JPA 프로그래밍 13장 웹 애플리케이션과 영속성 관리를 참고하자

 

 

 

OSIV ,,? 아직 잘 모르겠다 어지럽다

 

[JPA] API 개발 고급 정리

개발의 숩
|2023. 3. 21. 11:52

정리

엔티티 조회

  • 엔티티 조회해서 그대로 반환 : V1
  • 엔티티 조회 후 DTO로 변환 : V2
  • 페치 조인으로 쿼리 수 최적화 : V3
  • 컬렉션 페이징과 한계 돌파 : V3.1
    • 컬렉션은 페치 조인시 페이징이 불가능
    • ToOne관계는 페치 조인으로 쿼리 수 최적화
    • 컬렉션은 페치 조인 대신에 지연 로딩을 유지하고, hibernate.default_batch_fetch_size, @BatchSize로 최적화
  • DTO 직접 조회
    • JPA에서 DTO를 직접 조회 : V4
    • 컬렉션 조회 최적화 - 일대다 관계인 컬렉션은 IN 절을 활용해서 메모리에 미리 조회해서 최적화 : V5
    • 플랫 데이터 최적화 - JOIN 결과를 그대로 조회 후 애플리케이션에서 원하는 모양으로 직접 변화 : V6

 

 

권장 순서

  1. 엔티티 조회 방식으로 우선 접근
    1. 페치조인으로 쿼리 수를 최적화
    2. 컬렉션 최적화
      1. 페이징 필요 hibernate.default_batch_fetch_size 로 최적화
      2. 페이징 필요 X → fetch join 사용
  2. 엔티티 조회 방식으로 해결이 안되면 DTO 조회 방식 사용
  3. DTO 조회 방식으로 해결이 안되면 NativeSQL or 스프링 JdbcTemplate

 

참고 : 엔티티 조회 방식은 페치 조인이나, hibernate.default_batch_fetch_size , @BatchSize r같이 코드를 거의 수정하지 않고, 옵션만 약간 변경해서, 다양한 성능 최적화를 시도할 수 있다. 반면에 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와 비교해서 성능 차이도 미비하다.

 

보통은 페치 조인 , batch size 설정을 하면 해결이 된다 

[JPA] 페이징과 한계돌파

개발의 숩
|2023. 3. 20. 13:26

컬렉션을 페치 조인하면 페이징이 불가능하다.

  • 컬렉션을 페치조인하면 일대다 조인이 발생하므로 데이터가 예측할 수 없이 증가한다.
  • 일대다에서 일(1)을 기준으로 페이징을 하는 것이 목적이다. 그렇데 데이터는 다(N)를 기준으로 row가 생성된다.
  • Order를 기준으로 페이징하고 싶은데 다(N)인 OrderItem을 조인하면 OrderItem이 기준이 되어버린다.
  • 더 자세한 내용은 자바 ORM 표준 JPA 프로그래밍 - 페치 조인 한계 참고

이 경우 하이버네이트는 경고 로그를 남기고 모든 DB 데이터를 읽어서 메모리에서 페이징을 시도한다. 최악의 경우 장애로 이어질 수 있다.

 

 

한계 돌파

그러면 페이징과 컬렉션 엔티티를 함께 조회하려면 어떻게 해야할까 ?

지금부터 코드도 단순하고, 성능 최적화도 보장하는 매우 강력한 방법을 소개하겠다.

대부분의 페이징 + 컬렉션 엔티티 조회 문제는 이 방법으로 해결할 수 있다.

 

  • 먼저 ToOne 관계를 모두 페치조인한다. ToOne관계는 row 수를 증가시키지 않으므로 페이징 쿼리에 영향을 주지 않는다.
  • 컬렉션은 지연 로딩으로 조회한다.
  • 지연 로딩 성능 최적화를 위해 hibernate.default_batch_fetch_size @BatchSize 를 적용한다.
    • **hibernate.default_batch_fetch_size : 글로벌 설정**
    • **@BatchSize : 개별 최적화 - 디테일하게 적용하고 싶을 때**
    • 이 옵션을 사용하면 컬렉션이나, 프록시 객체를 한꺼번에 설정한 size만큼 IN 쿼리로 조회한다.
    jpa:
        hibernate:
          ddl-auto: create
        properties:
          hibernate:
            show_sql: true
            format_sql: true
            default_batch_fetch_size: 1000 #최적화 옵션
    
    • 개별로 설정하려면 @BatchSize를 적용하면 된다 (컬렉션은 컬렉션 필드에, 엔티티는 엔티티 클래스에 적용)
    장점
    • 쿼리 호출 수 가 1+N → 1+1로 최적화된다
    • 조인보다 DB 데이터 전송량이 최적화된다.이 방법은 각각 조회하므로 전송해야할 중복 데이터가 없다
    • Order와 OrderItem을 조인하면 Order가 OrderItem만큼 중복해서 조회된다.
    • 페치 조인 방식과 비교해서 쿼리 호출 수가 약간 증가하지만, DB 데이터 전송량이 감소한다.
    • 컬렉션 페치 조인은 페이징이 불가능하지만 이 방법은 페이징이 가능하다
    결론
    • ToOne 관계는 페치 조인해도 페이징에 영향을 주지 않는다. 따라서 ToOne관계는 페치조인으로 쿼리 수를 줄이고 해결하고, 나머지는 hibernate.default_batch_fetch_size 로 최적화하자
    참고: default_batch_fetch_size 의 크기는 적당한 사이즈를 골라야 하는데, 100 ~ 1000 사이를 선택하는 것을 권장한다. 이 전략은 SQL IN절 사용하는데, 데이터베이스에 따라 IN절 파라미터를 1000으로 제한하기도 한다. 1000으로 잡으면 한번에 1000개를 DB에서 애플리케이션에 불러오므로 DB에 순간 부하가 증가할 수 있다. 하지만 애플리케이션은 100이든 1000이든 결국 전체 데이터를 로딩해야 하므로 메모리 사용량이 같다. 1000으로 설정하는 것이 성능상 가장 좋지만, 결국 DB든 애플리케이션이든 순간 부하를 어디까지 견딜 수 있는지로 결정하면 된다.
    • 한번에 조회
    • 데이터가 최종 갯수만큼 나오는데 중복인 된 상태에서 조회가 된다
    • DB에서 애플리케이션으로 전부 전송하게 되어, 전송량 자체가 많아지게 된다
    • 용량이 많은 이슈
    public List<Order> findAllByMemberDelivery(int offeset, int limit) {
            return em.createQuery(
                            "select o from Order o"+
                                    " join fetch o.member m "+
                                    " join fetch o.delivery d ",Order.class)
                    .setFirstResult(offeset)
                    .setMaxResults(limit)
                    .getResultList();
        }
    
    • 데이터 전송량이 쿼리가 최적화되어서 전송되기때문에 용량이슈가 발생하지 않는다
    • 데이터는 중복없이 조회가 가능하다
    • 정확하게 필요한 데이터를 조회하게 된다.
    public List<Order> findAllByMemberDelivery(int offeset, int limit) {
            return em.createQuery(
                            "select o from Order o",Order.class)
                    .setFirstResult(offeset)
                    .setMaxResults(limit)
                    .getResultList();
        }
    
    • batch size 전략때문에 성능 최적화가 된 상태에서 데이터를 가져올 수 있다.
    • 아무래도 네트워크를 많이 타서 가져오게 되므로 fetch join을 미리 잡아놓고 가져오는게 효율적이다

 

 

'TIL' 카테고리의 다른 글

[JPA] OSIV와 성능 최적화  (0) 2023.03.22
[JPA] API 개발 고급 정리  (1) 2023.03.21
[JPA] N+1 문제  (0) 2023.03.19
[JPA] respository에 DTO 사용 ?  (0) 2023.03.18
[JPA] DTO 설계 이유  (0) 2023.03.18

[JPA] N+1 문제

개발의 숩
|2023. 3. 19. 21:07

JPA를 사용하다 보면 의도하지 않았지만 여러 번의 select문이 여러 개가 나가는 현상을 본 적이 있을 것이다.

이러한 현상을 N+1문제라고 부른다.

 

 

N+1 문제

연관 관계가 설정된 엔티티를 조회할 경우에 조회된 데이터 개수(n)만큼 연관관계의 조회 쿼리가 추가로 발생하여 데이터를 읽어오는 현상

지연로딩에서는 N+1문제가 발생하지 않는 것처럼 보이지만, 막상 객체를 탐색하려고 하면 N+1문제가 발생되어 즉시로딩과 N+1문제가 발생되는 시점만 다를 뿐이다.

 

 

N+1 발생 원인

JPA가 JPQL을 분석해서 SQL을 생성할 때는 글로벌 Fetch 전략을 참고하지 않고, 오직 JPQL 자체만을 사용한다

즉, 아래와 같은 순서로 동작한다.

 

 

해결 방안

Fetch Join, EntityGraph 어노테이션, Batch Size등의 방법이 있다.

 

 

1. Fetch join

JPQL을 사용하여 DB에서 데이터를 가져올 때 처음부터 연관된 데이터까지 같이 가져오게 하는 방법이다. SQL Join문을 생각하면 될 것 같다.

별도의 메소드를 만들어줘야 하며 @Query 어노테이션을 사용해서 “join fetch 엔티티.연관관계_엔티티” 구문을 만들어 주면 된다.

SQL 로그를 보면 별도의 지정을 안하면 JPQL에서 join fetch 구문은 SQL문의 inner join구문으로 변경되어 실행된다.

 

 

Fetch Join의 단점

Fetch join도 유용해보이지만, 단점은 있다.

  • 연관관계 설정해놓은 FetchType을 사용할 수 없다
    • 데이터 호출 시점에 모든 연관 관계의 데이터를 가져오기때문에 FetchType을 Lazy로 해놓는 것이 무의미하다
  • 페이징 쿼리를 사용할 수 없다.
    • 하나의 쿼리문으로 가져오다보니 페이징 단위로 데이터를 가져오는 것이 불가능하다

 

 

 

2. EntityGraph 어노테이션

@EntityGraph 라는 어노테이션을 사용해서 fetch 조인을 하는 것인데, 사용하는 순간 관계가 조금만 복잡해도 수습하기가 어렵다고 한다 이런 게 있는 구나 하고 알기만 하고 사용하지 말자

Fetch join과 는 다르게 join문이 outer join으로 실행된다.

 

 

 

3. Batch Size

정확히는 N+1 문제를 안 일어나게 하는 방법이 아니라 N+1문제가 발생하더라도 select * from user where team_id =? 이 아닌 SQL의 IN절을 사용하는 방식으로 N+1문제가 발생하게 하는 방법이다. 이렇게 하면 100번 일어날 N+1 문제를 1번만 더 조회하는 방식으로 성능 최적화를 할 수 있다.

application.yml

jpa:
    hibernate:
      ddl-auto: create
    properties:
      hibernate:
        show_sql: true
        format_sql: true
        **default_batch_fetch_size: 1000 #최적화 옵션**

 

 

4. QueryBuilder

Query를 실행하도록 지원해주는 다양한 플러그인이 있다

대표적으로 Mybatis, QueryDSL, JOOQ, JDBC Template 등이 있을 것이다

이를 사용하면 로직에 최적화된 쿼리를 구현할 수 있다.

// QueryDSL로 구현한 예제
return from(owner).leftJoin(owner.cats, cat)
                   .fetchJoin()

 

 

Fetch Join과 EntityGraph 주의할 점

Fetch join과 EntityGraph는 JPQL을 사용하여 Join문을 호출한다는 공통점이 있다.

또한, 공통적으로 카테시안 곱(Cartesian Product)이 발생하여 중복데이터가 존재할 수 있다. 그러므로 중복된 데이터가 컬레션에 존재하지 않도록 주의해야 한다

그렇다면 어떻게 중복된 데이터를 제거할 수 있을까

  • 컬렉션을 Set을 사용하게 되면 중복을 허용하지 않는 자료구조이기때문에 중복된 데이터를 제거할 수 있다.
  • JPQL을 사용하기 때문에 distinct을 사용하여 중복된 데이터를 조회하지 않을 수 있다.

 

 

정리

N+1 은 연관관계를 맺는 엔티티를 사용한다면 한 번쯤 부딪힐 수 있는 문제

상황에 따라 어떻게 해결할지 충분히 고민끝에 골라서 성능 최적화를 시켜야 하는 부분이다

마냥 N+1을 해결하기 위해 fetch join을 한다고 해서 추후 페이징을 못하는 경우도 있기때문이다.

 

'TIL' 카테고리의 다른 글

[JPA] API 개발 고급 정리  (1) 2023.03.21
[JPA] 페이징과 한계돌파  (0) 2023.03.20
[JPA] respository에 DTO 사용 ?  (0) 2023.03.18
[JPA] DTO 설계 이유  (0) 2023.03.18
[JPA] Entitiy 대신 ResponseDto,RequestDto 사용, 그 이유  (0) 2023.03.17

[JPA] respository에 DTO 사용 ?

개발의 숩
|2023. 3. 18. 23:31

Respository

public List<Order> findAllWithMemberDelivery() {
        return em.createQuery(
                "select o from Order o"+
                        " join fetch o.member m" +
                        " join fetch o.delivery d ",Order.class
        ).getResultList();
    }

 

Query

 

 

 

Respository (DTO 사용한 버전)

public List<OrderSimpleQueryDto> findOrderDtos() {
        return em.createQuery(
                "select new jpabook.jpashop.repository.OrderSimpleQueryDto(o.id, m.name,o.orderDate,o.status, d.address) from Order o"+
                " join o.member m"+
                " join o.delivery d", OrderSimpleQueryDto.class)
                .getResultList();
    }

 

Query

  • 일반적인 SQL을 사용할 때 처럼 원하는 값을 선택해서 조회
  • new 명령어를 사용해서 JPQL의 결과를 DTO로 즉시 변환
  • SELECT 절에서 원하는 데이터를 직접 선택하므로 DB 애플리케이션 네트웍 용량 최적화(생각보다 미비)
  • 리포지토리 재사용성 떨어짐, API 스펙에 맞춘 코드가 리포지토리에 들어가는 단점

 

 

💡 PostMan 결과는 똑같지만 쿼리가 다른 걸 알 수 있다

 

 

 

정리

엔티티를 DTO로 변환하거나, DTO로 바로 조회하는 두가지 방법은 각각 장단점이 있다.

둘중 상황에 따라서 더 나은 방법을 선택하면 된다.

엔티티로 조회하면 리포지토리 재사용성도 좋고, 개발도 단순해진다.

 

 

쿼리 방식 선택 권장 순서

  1. 우선 엔티티DTO변환하는 방법을 선택한다.
  2. 필요하면 페치 조인으로 성능을 최적화 한다. 대부분의 성능 이슈가 해결된다.
  3. 그래도 안되면 DTO직접 조회하는 방법을 사용한다.
  4. 최후의 방법은 JPA가 제공하는 네이티브 SQL이나 스프링 JDBC Template을 사용해서 SQL을 직접 사용한다.

'TIL' 카테고리의 다른 글

[JPA] API 개발 고급 정리  (1) 2023.03.21
[JPA] 페이징과 한계돌파  (0) 2023.03.20
[JPA] N+1 문제  (0) 2023.03.19
[JPA] DTO 설계 이유  (0) 2023.03.18
[JPA] Entitiy 대신 ResponseDto,RequestDto 사용, 그 이유  (0) 2023.03.17