728x90

Spring Boot로 API를 개발하다 보면, 모든 컨트롤러의 응답을 일관된 JSON 구조로 반환해야 할 때가 있다.

예를 들어, 다음과 같은 응답 형식을 유지하고 싶다고 가정해 보면,

 

{
  "result": {
    "resultCode": "Success",
    "resultMsg": "조회 성공"
  },
  "payload": {
    "data": [
      { "boardNo": "101", "boardTitle": "공지사항 1" },
      { "boardNo": "102", "boardTitle": "공지사항 2" }
    ],
    "totalCount": 2
  }
}

 

 

하지만 일반적인 컨트롤러는 보통 Map <String, Object>나 DTO 객체를 반환하므로, 위처럼 자동으로 감싸지 지는 않는다.

일반적인 컨트롤러에서는 이렇게 데이터가 출력된다.

{
  "data": [
    { "boardNo" "101", "boardTitle": "공지사항 1" },
    { "boardNo": "102", "boardTitle": "공지사항 2" }
  ],
  "totalCount": 2
}



이를 해결하기 위해 ResponseBodyAdvice를 활용하면 모든 컨트롤러의 응답을 일관된 구조로 감싸줄 수 있다.

 


1. ResponseBodyAdvice란?

Spring은 컨트롤러의 응답 데이터를 **HttpMessageConverter**를 사용하여 JSON, XML 등의 형식으로 변환한다.
이 과정에서 ResponseBodyAdvice 인터페이스를 구현하면, 변환 직전에 데이터를 가공할 수 있다.

즉, 모든 컨트롤러의 응답을 자동으로 감싸는 기능을 구현할 수 있다!!

 

 

 


2. ResponseBodyAdvice 동작 방식

 

💡 ResponseBodyAdvice의 동작 흐름

  1. 사용자가 API를 호출하면 컨트롤러가 데이터를 반환
  2. Spring이 HttpMessageConverter를 통해 JSON으로 변환하기 직전 ResponseBodyAdvice가 개입
  3. 응답 데이터를 우리가 원하는 JSON 형식으로 감싸도록 변환
  4. 최종적으로 클라이언트에게 변환된 JSON 응답이 전달

 

 


3. ResponseBodyAdvice 구현

import org.springframework.core.MethodParameter;
import org.springframework.http.MediaType;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice;

import javax.servlet.http.HttpServletRequest;

@ControllerAdvice
public class AppResponseAdvice implements ResponseBodyAdvice<Object> {

    @Override
    public boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType) {
        return true;
    }

    @Override
    public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType, 
                                  Class<? extends HttpMessageConverter<?>> selectedConverterType, 
                                  org.springframework.http.server.ServerHttpRequest request, 
                                  org.springframework.http.server.ServerHttpResponse response) {
        
        if ("handleException".equals(returnType.getMethod().getName()) ||
            "userHandleException".equals(returnType.getMethod().getName())) {
            return body;
        }

        // 응답 데이터가 Map이 아닌 경우, 기본적으로 오류 응답 구조로 감싸기
        if (!(body instanceof Map)) {
            return createResponse("Error", "에러 메세지", null);
        }

        return createResponse("Success", "조회 성공", body);
    }

    /**
     * 응답 데이터를 감싸는 공통 메서드
     */
    private Map<String, Object> createResponse(String code, String message, Object data) {
        Map<String, Object> response = new HashMap<>();
        Map<String, String> result = new HashMap<>();

        result.put("resultCode", code);
        result.put("resultMsg", message);
        response.put("result", result);
        response.put("payload", data);

        return response;
    }
}

 

 

💡 주요 기능

  • supports() : 모든 컨트롤러 응답에 적용되도록 true 반환
  • beforeBodyWrite() : 응답 데이터를 감싸서 변환하는 로직 구현

 

 


4. ResponseBodyAdvice 적용 결과

✔ Controller에서 Map 반환

 

@RestController
@RequestMapping("/api/board")
public class BoardController {

    @GetMapping("/list")
    public Map<String, Object> getBoardList() {
        Map<String, Object> result = new HashMap<>();
        List<Map<String, Object>> boardList = new ArrayList<>();

        Map<String, Object> board1 = new HashMap<>();
        board1.put("boardNo", "101");
        board1.put("boardTitle", "공지사항 1");

        Map<String, Object> board2 = new HashMap<>();
        board2.put("boardNo", "102");
        board2.put("boardTitle", "공지사항 2");

        boardList.add(board1);
        boardList.add(board2);

        result.put("data", boardList);
        result.put("totalCount", boardList.size());

        return result;
    }
}

 

하면 맨 위에 나와있는 것처럼 데이터가 가공되어서 출력된다.

 

 

 


5. ResponseBodyAdvice를 활용하면 좋은 점

✅ 모든 API 응답 형식을 일괄적으로 적용할 수 있음


✅ 컨트롤러에서 불필요한 응답 가공 코드를 제거하여 유지보수성 향상


✅ 예외 응답과 정상 응답을 구분하여 처리 가능

 

🎯 즉, API 응답 형식이 일관되게 유지되면서도 컨트롤러의 코드가 간결해짐

728x90
728x90

1. 다중 서버 세션 관리를 위한 핵심 컴포넌트

SessionRegistry

  • Spring Security에서 현재 활성화된 세션을 추적하고 관리하는 인터페이스
  • 동시 로그인 세션 관리 및 강제 로그아웃을 위해 사용
  • SessionRegistryImpl 또는 SpringSessionBackedSessionRegistry 구현체 활용 가능

FindByIndexNameSessionRepository

  • Spring Session의 세션 저장소 역할을 하는 인터페이스
  • Redis, JDBC, MongoDB 등 다양한 저장소 추상화 지원
  • sessionId 외에도 username과 같은 특정 속성으로 세션 검색 가능
  • Spring Security와 연동하여 로그인 사용자의 세션 처리

SpringSessionBackedSessionRegistry

  • Spring Session 기반의 SessionRegistry 구현체
  • FindByIndexNameSessionRepository를 활용한 세션 관리
  • 단일/다중 서버 환경 모두 지원
  • Redis와 같은 공유 저장소를 통한 일관된 세션 관리 가능

 

 

 


2. SessionRegistryImpl vs SpringSessionBackedSessionRegistry 비교

기존 코드에서는 SessionRegistryImpl을 사용해서 세션을 관리했으나, 다중서버에서 Redis로 세션을 공통으로 관리하기 위해서 SpringSessionBackedSessionRegistry로 변경함

 

  SessionRegistryImpl SpringSessionBackedSessionRegistry 
설명 기본적인 SessionRegistry 구현체 Spring Session과 연동된 SessionRegistry
저장 방식 서버 메모리(In-Memory)에 세션 저장 Redis, JDBC, MongoDB 등 외부 저장소를 활용
단일 서버 환경 ✅ 사용 가능 ✅ 사용 가능
다중 서버 환경 사용 불가 (서버마다 세션 정보가 다름) 사용 가능 (세션이 공유 저장소에 저장됨)
장점 - 간단한 설정으로 사용 가능
- 별도 저장소 필요 없음
- Redis 같은 공유 저장소를 활용하여 서버 간 세션 공유 가능
단점 - 단일 서버에서만 정상 동작
- 서버 재시작 시 세션 정보 유실
- 서버 확장 시 세션 정보 공유 안 됨
- 추가적인 Redis/JDBC 설정 필요

단일 서버 환경 → SessionRegistryImpl 사용 가능
다중 서버 환경 → SpringSessionBackedSessionRegistry 사용 필수

 

 

 


3. 세션 클러스터링(Session Clustering)

1. Sticky Session

https://www.imperva.com/learn/availability/sticky-session-persistence-and-cookies/

 

Sticky session은 세션을 사용해 트래픽을 분산하는 기능으로 특정 세션의 요청을 최초 처리한 서버로만 전송하는 것을 의미

 

예를 들어 회원 1이 서버 A에서 세션을 생성한다면, 이후 회원 1이 보내는 모든 요청은 서버 A로만 전송하게 됩니다.

 

이러한 Sticky session은 아래와 같은 단점이 존재

  • 동일한 세션은 동일한 서버로만 전송 => 특정 서버에 요청이 몰려 과부하가 발생 할 수 있음
  • 특정 서버에 장애가 발생할 시 => 해당 서버에 연결되어 있는 세션들이 모두 소실될 수 있음

위와 같은 단점을 해결하기 위해 Session Clustering 방식이 존재


2. Session Clustering (세션 클러스터링)

https://smjeon.dev/web/sticky-session/

 

Session Clustering 은 여러 WAS가 세션을 공유하여 동일하게 세션을 관리하는 방식을 의미

 

세션 클러스터링 방식은 Sticky Session에서 발생했던 1. 특정 서버에만 트래픽이 많이 발생하거나, 2. 장애 발생 시 세션 소실의 문제를 해결할 수 있음

 

하지만 세션 클러스터링은 세션 데이터가 생성될 때마다 모든 서버에 세션 정보를 추가해야 하기 때문에 서버의 메모리에 상당히 부담이 된다는 치명적인 단점이 존재


3. Redis Session Clustering

Redis를 이용한 세션 클러스터링 방식은 세션을 관리할 저장소를 Redis로 따로 두어 사용하는 방식입니다.

 

RDB가 아닌 Redis에서 세션을 관리하면 좋은 점

  • 세션은 데이터 모델이 관계형 데이터베이스처럼 복잡하지 않다
  • 세션은 보통 만료시간이 존재하기에 영속성이 필요하지 않다
  • 세션은 사용자가 로그인 후 요청을 할 때마다 확인이 필요하기 때문에 그만큼 I/O가 많이 발생하고, RDB를 사용하는 경우에는 디스크 I/O로 인해 성능 이슈가 발생할 수 있다

 

- 세션 클러스터 내용 출처 https://zzang9ha.tistory.com/442

 

Spring 분산 환경에서 세션 관리하기 (Redis Session Clustering)

🔗 Spring 분산 환경에서 세션 관리하기(세션 클러스터링) 안녕하세요, 이번 포스팅에서는 여러 서버 환경에서 세션을 관리하는 방법에 대해 살펴보겠습니다. (코드는 깃허브에서 확인하실 수 있

zzang9ha.tistory.com

 

 

 


4. 다중 서버에서 로그인 테스트 시 설정이 동일해야 하는 이유

  • Sticky Session이 아니면, 사용자의 요청이 여러 서버로 분산
  • 따라서, 모든 서버가 동일한 세션 저장소(Redis 등)를 공유해야 세션 정보가 유지
  • 설정이 다르면 특정 서버에서 로그인했어도, 다른 서버에서는 해당 세션을 찾을 수 없어 로그아웃된 상태가 될 수 있음

 

 


5. Dependencies

1. spring-boot-starter-data-redis

  • Spring Boot에서 Redis를 사용하기 위한 의존성
  • Redis 클라이언트인 Lettuce 또는 Jedis와의 연결을 설정하고 관리하는 기본적인 기능을 제공
  • Redis 데이터 처리와 관련된 기본적인 기능을 제공하며, 이를 통해 애플리케이션에서 Redis를 캐시 저장소나 메시지 큐, 다른 용도로 사용할 수 있음
  • 이 라이브러리를 사용하면 Redis를 다양한 용도로 연결하고 활용할 수 있음

기능

  • Redis에 대한 연결 설정 및 관리
  • Redis에 데이터를 저장하고 조회하는 기능 (기본적인 Redis 키-값 저장소)
  • 캐시와 같은 일반적인 Redis 사용을 위한 설정
=> 이 의존성만 사용하면 Redis와의 연결은 관리하지만 세션 관리 기능을 제공하지 않음

2. spring-session-data-redis

  • Spring Session을 Redis와 통합하여 세션 관리를 할 수 있도록 돕는 라이브러리
  • Spring Session은 애플리케이션의 세션 관리를 외부 저장소(Redis 등)로 분리하여 관리하는 기능을 제공
  • 이 라이브러리는 Redis를 세션 저장소로 사용하게 해 주며, 세션 데이터가 클러스터링 된 환경에서도 유지될 수 있도록 지원

기능

  • Redis를 세션 저장소로 설정
  • Spring Security와 연동하여 로그인 상태를 Redis에 저장하고 공유
  • 세션 클러스터링을 통해 다중 서버 환경에서 세션을 유지
  • Redis에서 세션 만료 및 관리

=> 이 의존성만 사용하면 세션 관리와 관련된 기능만 제공

 

 

 

 

 


SpringSecurty + Redis 사용시의 흐름

1. 익명 사용자 세션 생성

  • 사용자가 로그인 페이지에 처음 접근할 때, 익명 사용자로서 세션이 자동으로 생성
  • 이때 SecurityContext에는 인증되지 않은 상태로 익명 사용자 정보가 저장

2. 로그인 시도 (인증 과정)

  • 사용자가 로그인 폼에 아이디와 비밀번호를 입력하고 로그인 요청을 보냄
  • Spring Security는 Authentication 필터AuthenticationManager를 사용해 인증을 진행
  • 인증이 성공하면, SecurityContext에 인증된 사용자 정보(예: username, roles, authorities 등)가 저장
  • 로그인된 세션이 현재의 HTTP 세션에 바인딩

3. 세션 관리 (SpringSessionBackedSessionRegistry)

  • SpringSessionBackedSessionRegistry는 세션의 유효성 검사, 세션 만료, maximumSessions 등의 정책을 적용하여 세션 관리를 수행
  • 이 과정에서, FindByIndexNameSessionRepository와 결합하여 세션 데이터를 Redis와 같은 외부 저장소에 저장 => 이때 Redis에는 사용자 세션의 정보 (예: 세션 ID, 인증된 사용자 정보 등)가 저장

4. 로그아웃

  • 사용자가 로그아웃을 요청하면, SecurityContext에서 현재 사용자의 인증 정보를 clear합니다. 즉, 인증 정보가 삭제되고, 이로써 인증되지 않은 상태로 돌아감
  • 세션도 무효화되며, Redis에 저장된 해당 세션 정보도 삭제 => 이때 세션 ID가 Redis에서 제거

5. 로그인 페이지로 리디렉션

  • 로그아웃 후, 사용자는 자동으로 로그인 페이지로 리디렉션

정리 :

  • 세션 생성 (익명 사용자) → 로그인 → 인증 → SecurityContext에 인증 정보 저장 → SpringSessionBackedSessionRegistry 세션 관리 및 Redis 저장 → 로그아웃 → Redis에서 세션 삭제 → 로그인 페이지로 리디렉션
728x90

'DBMS > REDIS' 카테고리의 다른 글

[Redis]Redis를 활용한 Spring Security 세션 관리 동작 원리  (0) 2025.02.10
728x90

 

 1. Redis에서 세션이 저장되는 방식

Spring Session과 Redis를 사용하면 세션 정보를 Redis에 저장하여 분산 환경에서도 세션을 공유할 수 있음
기본적으로 세션 데이터는 아래와 같은 4가지 키-값 형태로 Redis에 저장됨

🔹 로그인 후 Redis에 저장되는 데이터

예를 들어, U0001097이라는 유저가 로그인하면 Redis에 다음과 같은 데이터가 생성됨

Key(Redis Key)

spring:session:expirations:<timestamp> 특정 시간에 만료되는 세션 ID 리스트
spring:session:index:org.springframework.session.FindByIndexNameSessionRepository.PRINCIPAL_NAME_INDEX_NAME:세션ID 사용자 ID(U0001097)와 관련된 세션 ID 리스트
spring:session:sessions:expires:<세션 ID> 특정 세션 ID의 만료 시간
spring:session:sessions:<세션 ID> 실제 세션 데이터 (유저 정보, 속성 등 포함)

 


 2. Redis 세션 저장 구조

🔹 Redis에 저장된 데이터

cf)

spring:session:expirations:1739162460000 → [ "32cf118e-9c76-4550-b7ee-c5d6abc3c520" ]
spring:session:index:org.springframework.session.FindByIndexNameSessionRepository.PRINCIPAL_NAME_INDEX_NAME:SESSIONID → { "32cf118e-9c76-4550-b7ee-c5d6abc3c520" }
spring:session:sessions:expires:32cf118e-9c76-4550-b7ee-c5d6abc3c520 → 1739162460
spring:session:sessions:32cf118e-9c76-4550-b7ee-c5d6abc3c520 → { "sessionAttr:SpringContext": {...}, "creationTime": 1739162460000, ... }

 


 3. 로그인 및 세션 저장 과정

1️⃣ 사용자가 로그인한 경우

  1. Spring Security가 사용자 인증(Authentication)을 수행함
  2. 세션이 생성되고 Redis에 저장됨
  3. Redis에 spring:session:sessions:<세션 ID>와 관련된 여러 키가 저장됨

2️⃣ 사용자가 로그아웃한 경우

🚀 로그아웃 후 Redis에서 삭제될 데이터

spring:session:sessions:<세션 ID> ✅ 삭제됨 현재 세션에 대한 데이터
spring:session:sessions:expires:<세션 ID> ✅ 삭제됨 현재 세션의 만료 정보
spring:session:index:org.springframework.session.FindByIndexNameSessionRepository.PRINCIPAL_NAME_INDEX_NAME:세션ID ❌ 남아 있음 이게 남아 있어서 동시 접속 허용됨
spring:session:expirations:<timestamp> ❌ 남아 있음 다른 세션이 있을 경우 남아 있음

 4. 동시 로그인(멀티 디바이스 로그인) 관리

✅ 동시 로그인을 허용하려면

  • Redis의 사용자 ID 인덱스(spring:session:index:org.springframework.session.FindByIndexNameSessionRepository.PRINCIPAL_NAME_INDEX_NAME:세션ID)를 삭제하지 않아야 함
  • 즉, 하나의 세션만 로그아웃하고 다른 세션을 유지해야 함

❌ 동시 로그인을 차단하려면

  • 로그아웃 시, 사용자 ID에 연관된 모든 세션을 삭제해야 함
  • sessionRepository.findByPrincipalName(userId)를 사용해 해당 사용자의 모든 세션 ID를 삭제하면 됨.
 

 


5. 정리: 로그아웃 시 남겨야 할 정보와 삭제해야 할 정보

🔹 로그아웃 시 삭제할 정보 (현재 세션만 삭제)

  • spring:session:sessions:<세션 ID> (세션 데이터)
  • spring:session:sessions:expires:<세션 ID> (만료 정보)

🔹 로그아웃 시 남겨야 할 정보 (다른 기기의 세션 유지)

  • spring:session:index:org.springframework.session.FindByIndexNameSessionRepository.PRINCIPAL_NAME_INDEX_NAME:<사용자 ID> (사용자의 다른 세션을 유지해야 함)
  • spring:session:expirations:<timestamp> (다른 세션이 있으면 유지)

🚀 결론

Redis는 Spring Security 세션을 관리하기 위해 4가지 주요 키를 사용함
로그아웃 시 현재 세션만 삭제하면 동시 접속이 유지됨
동시 로그인을 차단하려면 사용자 ID에 연관된 모든 세션을 삭제하면 됨
세션 무효화 (session.invalidate();) 없이 Redis에서 세션을 삭제하지 않으면 불완전한 로그아웃이 될 수 있음

🔥 즉, "동시 로그인 허용"과 "완전한 로그아웃"을 구분해서 설계해야 한다! 

728x90
728x90

Redis란?

고성능 Key-Value 구조의 저장소로, 비정형 데이터를 저장 및 관리하기 위한 오픈 소스 기반의 NoSQL DB

 

In-Memory 데이터 구조를 사용해 웹 서버의 부담을 줄이고 고속으로 데이터를 제공하는 데 탁월하다

 


Redis의 주요 특징

1. In-Memory 데이터베이스

Redis는 데이터를 RAM에 저장하여 디스크 기반 데이터베이스보다 매우 빠른 속도를 자랑함

RAM을 사용함으로써 디스크 I/O가 발생하지 않아 데이터 처리 속도가 대폭 향상됨

  • 장점: 빠른 데이터 저장 및 조회
  • 단점: RAM 용량 초과 시 데이터 유실 가능 (휘발성)
    → 휘발성 문제 해결: 백업 기능(AOF, RDB)을 제공하여 데이터 유실 위험을 방지

 


2. Redis의 데이터 타입

Redis는 다양한 데이터 타입을 지원하며, 이를 활용하면 복잡한 데이터 작업을 간소화할 수 있다

  • String: 일반적인 Key-Value 형태
  • Hash: Key-Value 형태를 테이블 형식으로 저장
  • List: 순서가 있는 값들의 목록
  • Set: 중복 없는 값들의 집합
  • Sorted Set: 정렬 가능한 집합

eg) Sorted-Set을 사용하면 데이터를 정렬하는 로직을 단순화하고 처리 속도를 높일 수 있음

출처 : https://velog.io/@wnguswn7/Redis%EB%9E%80-%EB%AC%B4%EC%97%87%EC%9D%BC%EA%B9%8C-Redis%EC%9D%98-%ED%8A%B9%EC%A7%95%EA%B3%BC-%EC%82%AC%EC%9A%A9-%EC%8B%9C-%EC%A3%BC%EC%9D%98%EC%A0%90


3.  Redis의 백업과 복구

Redis는 데이터를 디스크에 저장할 수 있는 두 가지 주요 방식을 제공

 

AOF(Append Only File)

개념

AOF는 Redis의 모든 Write/Update 연산을 파일에 순차적으로 기록하는 방식
Redis 서버가 중단되더라도, AOF 로그를 재실행하여 데이터 상태를 복구할 수 있다

동작 방식

  1. Redis에 Write/Update 연산 발생
  2. 해당 연산을 AOF 파일에 순서대로 기록
  3. Redis 서버 재시작 시 AOF 파일의 명령어를 순차적으로 실행하여 복구

장점

  • 데이터 복구 가능성이 매우 높음 (모든 연산을 기록)
  • Write/Update 중심의 데이터베이스에서 적합
  • 사람이 읽을 수 있는 명령어 형태로 저장되어 디버깅이 용이

단점

  • 파일 크기가 커질 수 있음 → 주기적인 파일 압축(Rewrite) 필요
  • 디스크 I/O 부담이 큼 (많은 Write 작업 발생 시)

추가 옵션

  • fsync 옵션: 데이터 기록 주기를 조정해 성능과 안정성을 조율
    • always: 매 Write마다 디스크에 기록 (가장 안전, 성능 낮음)
    • everysec: 1초마다 기록 (기본값, 안정성과 성능 균형)
    • no: Redis가 디스크 기록을 관리하지 않음 (가장 빠름, 안전성 낮음)

 

RDB(Snapshotting)

개념

RDB는 Redis 데이터를 특정 시점(Snapshot)에 전체적으로 저장하는 방식
정해진 간격으로 메모리에 있는 모든 데이터를 디스크에 저장하여 백업본을 생성한다

동작 방식

  1. 주기적 또는 특정 조건에서 Redis가 전체 데이터를 Snapshot 형태로 디스크에 저장
  2. Snapshot 파일(dump.rdb) 생성
  3. Redis 서버 재시작 시 Snapshot 파일을 로드하여 복구

장점

  • AOF보다 디스크 사용량이 적음 (스냅샷 방식)
  • 빠른 복구 시간 → 대량의 데이터를 효율적으로 복구 가능
  • 디스크 I/O 부하가 적음 → 고성능 애플리케이션에 적합

단점

  • 스냅샷 사이의 데이터는 유실될 가능성 있음
    • 예: 매 5분마다 Snapshot을 저장하는 경우, 마지막 5분간의 데이터는 복구 불가
  • Write 중심 환경에서는 적합하지 않을 수 있음

사용 방법

  • Redis 설정 파일에서 Snapshot 주기 설정 가능
    예: save 60 10000 → 1분 동안 10,000개 이상의 키가 변경될 경우 Snapshot 생성

4. 고가용성과 확장성

Redis는 Sentinel 및 Cluster를 통해 고가용성과 확장성을 제공한다

  • Sentinel: 자동 장애 복구, 모니터링, 알림 등 제공
  • Cluster: 자동 파티셔닝을 통해 데이터를 여러 Redis 인스턴스에 분산

● 파티셔닝(Partitioning): 다수의 Redis 인스턴스에 데이터를 분산 저장하여 확장성을 높이고, 각 인스턴스는 자신에게 할당된 데이터만 관리


5. 싱글 스레드 구조

Redis는 기본적으로 싱글 스레드로 동작하며, 하나의 명령만 순차적으로 처리한다

  • 장점: Race Condition(경쟁 상태) 문제를 방지하여 데이터 일관성 보장
  • 단점: 복잡하거나 처리 시간이 긴 요청이 있으면 Redis 서버의 응답이 느려질 수 있음

 


Redis 사용시 주의사항

 

메모리 관리

Redis는 데이터를 RAM에 저장하는 In-Memory 데이터베이스이기 때문에, 사용 가능한 메모리 용량을 초과할 경우 데이터 유실이나 성능 저하가 발생할 수 있다

해결 방법

  • 메모리 정책 설정: Redis는 메모리 초과 시 데이터를 삭제하는 정책을 지원한다.
  • 설정 가능한 maxmemory-policy 값:
    • noeviction: 메모리 초과 시 새 데이터를 추가하지 않음 (기본값)
    • allkeys-lru: 가장 오래 사용되지 않은 키부터 삭제
    • volatile-lru: 만료 설정이 있는 키 중 가장 오래 사용되지 않은 키부터 삭제
    • allkeys-random: 임의의 키 삭제
    • volatile-random: 만료 설정이 있는 키 중 임의의 키 삭제
    • volatile-ttl: 만료가 가장 가까운 키 삭제
    적합한 정책을 설정해 메모리 부족 시 데이터 유실을 최소화

 

  • 메모리 사용량 모니터링:
    • 주기적으로 Redis 메모리 사용량을 확인 (INFO memory 명령 사용)
    • maxmemory 설정으로 Redis가 사용할 메모리 한도를 제한

 

  • 데이터 크기 관리:
    • TTL(Time-To-Live)을 설정하여 필요 없는 데이터가 Redis에 오래 남아 있지 않도록 관리
    • Redis에 대량의 데이터를 저장하기보다는, 캐싱 목적으로 사용

데이터 복구

Redis는 기본적으로 In-Memory 데이터베이스이므로, 시스템 장애나 전원 차단 시 데이터가 유실될 수 있다

해결 방법

  • 백업 설정:
    • AOF(Append Only File)와 RDB(Snapshotting)를 적절히 설정하여 데이터 복구를 대비
    • 주기적으로 백업 파일을 외부 저장소에 보관
  • 복구 테스트:
    • 장애 상황을 가정한 복구 프로세스를 사전에 테스트하여 복구 시간을 단축

명령어의 시간 복잡도

Redis 명령어의 시간 복잡도를 고려하지 않고 대량의 데이터를 처리하는 명령을 실행하면 서버 성능 저하나 장애를 유발할 수 있다

해결 방법

  • 시간 복잡도 확인:
    • 데이터 크기에 따라 처리 시간이 증가하는 명령어는 주의 (O(N) 이상의 명령어)
      • 예: KEYS, SORT, LRANGE(큰 범위) 등
    • 대체 명령어 사용 추천:
      • KEYS 대신 SCAN(Cursor 기반 점진적 조회)
      • 긴 LRANGE 대신 데이터 크기 제한 및 페이징 사용
  • 배치 처리:
    • 대량 데이터를 처리할 경우 파이프라이닝(Pipelining)을 사용해 성능 최적화

싱글 스레드 특성

Redis는 싱글 스레드로 동작하므로, 하나의 명령이 오래 걸리면 다른 명령의 처리가 지연될 수 있다

해결 방법

  • 처리 시간이 긴 작업 최소화:
    • 긴 명령어를 실행할 때는 데이터를 나누어 처리하거나 백그라운드로 실행
  • 클러스터링:
    • Redis Cluster를 구성해 작업을 분산 처리

보안

Redis는 기본적으로 인증과 네트워크 보안을 제공하지 않으므로, 외부 공격에 취약할 수 있다

해결 방법

  • 인증 설정:
    • Redis 설정 파일에서 비밀번호(requirepass)를 설정
  • 네트워크 보안:
    • Redis를 외부 네트워크에 노출하지 않고, 방화벽이나 VPN을 통해 접근 제어
    • 필요 시 TLS를 활성화하여 암호화된 통신 사용
  • IP 제한:
    • 설정 파일에서 bind 옵션으로 특정 IP에서만 Redis에 접근하도록 설정

메모리 단편화

크고 작은 데이터를 할당하고 해제하는 과정에서 메모리 단편화가 발생해 성능이 저하될 수 있다

해결 방법

  • Redis 메모리 관리 도구(mem_fragmentation_ratio)로 단편화 수준을 주기적으로 점검
  • Redis 설정에서 activedefrag 옵션을 활성화해 단편화를 자동으로 해소

클러스터 및 복제 구성 시 주의점

Redis Cluster와 복제를 잘못 구성하면 데이터 불일치, 성능 저하, 장애 복구 실패가 발생할 수 있습니다.

해결 방법

  • Redis Cluster:
    • 키 분배 방식(Hash Slot)을 이해하고, 동일한 키 패턴을 사용해 데이터 분산
    • 클러스터 환경에서 마스터-슬레이브 구조를 활용해 읽기 작업을 분산
  • Redis Sentinel:
    • Sentinel을 구성하여 마스터 장애 시 자동 복구 및 알림 설정

로그 및 모니터링

Redis 서버에서 발생하는 문제를 조기에 감지하지 못하면 심각한 장애로 이어질 수 있다

해결 방법

  • 모니터링 도구 사용:
    • Redis의 상태를 실시간으로 확인할 수 있는 도구 사용:
      • Grafana, Prometheus 등의 모니터링 툴과 통합
    • 주요 모니터링 지표:
      • 메모리 사용량(used_memory, maxmemory)
      • 명령 실행 속도 및 처리량(instantaneous_ops_per_sec)
      • 연결 상태(connected_clients)
  • 로그 관리:
    • Redis 로그 레벨을 notice로 설정하여 주요 이벤트 기록
    • 장애 발생 시 로그를 분석하여 원인 파악
728x90
728x90

WebSocket은 실시간 양방향 통신이 필요한 애플리케이션에 필수적인 기능이지만, 네트워크의 불안정 등으로 인해 연결이 예기치 않게 끊어질 수 있다.

 

이전 프로젝트를 진행할 때 단순한 채팅 기능만 구현했을 뿐, 이러한 에러들은 미쳐 생각하지 못했다.


 

기능 구현 스택

 

Java, Spring, WebSocket(STOMP), SockJS


해결방안

1. STOMP와 Heartbeat 설정을 통한 연결 상태 확인

WebSocket에서는 STOMP 프로토콜을 통해 주기적으로 연결 상태를 확인하는 heartbeat 기능을 제공한다. 클라이언트와 서버 간 heartbeat 설정을 통해 실시간으로 연결 상태를 모니터링할 수 있다.

 

클라이언트에서 Heartbeat 설정

function connect() {
    let socket = new SockJS(contextPath + '/chat-websocket');
    stompClient = Stomp.over(socket);

    // 10초마다 heartbeat 설정
    stompClient.heartbeat.outgoing = 10000; // 클라이언트 -> 서버로 heartbeat 전송
    stompClient.heartbeat.incoming = 10000; // 서버 -> 클라이언트로 heartbeat 수신

    stompClient.connect({}, function(frame) {
        console.log('Connected: ' + frame);
        stompClient.send("/app/chat.addUser", {}, JSON.stringify({sender: username, type: 'JOIN'}));
    });
}

 

서버에서 Heartbeat 설정

@Override
public void configureMessageBroker(MessageBrokerRegistry config) {
    config.enableSimpleBroker("/topic", "/queue")
          .setHeartbeatValue(new long[]{10000, 10000}); // 10초마다 heartbeat
    config.setApplicationDestinationPrefixes("/app");
}

 

클라이언트와 서버 간에 주기적으로 heartbeat가 오가지 않으면 연결이 비정상적으로 종료된 것으로 간주하고 재연결 로직을 활성화할 수 있다

 


2. WebSocket의 자동 재연결 구현

WebSocket 연결이 끊어졌을 때 자동으로 재연결할 수 있도록 클라이언트 코드에 onclose 이벤트 핸들러를 추가하여 재연결을 시도한다

 

function connect() {
    let socket = new SockJS(contextPath + '/chat-websocket');
    stompClient = Stomp.over(socket);

    stompClient.connect({}, function(frame) {
        console.log('Connected: ' + frame);
        stompClient.send("/app/chat.addUser", {}, JSON.stringify({sender: username, type: 'JOIN'}));
    }, function(error) {
        console.error("Connection error: ", error);
        setTimeout(connect, 5000); // 5초 후 자동 재연결 시도
    });

    socket.onclose = function() {
        console.log("Connection closed. Attempting to reconnect...");
        setTimeout(connect, 5000); // 5초 후 자동 재연결 시도
    };
}

 

연결이 끊어졌을 때 setTimeout을 사용하여 일정 시간 후 재연결을 시도함으로써 연결의 안정성을 높일 수 있다


3. 서버에서 @OnClose 이벤트 활용

서버 측에서 WebSocket 연결이 끊겼을 때 @OnClose 이벤트를 활용하여 리소스를 정리하고, 사용자의 상태를 업데이트할 수 있다

@OnClose
public void onClose(Session session, CloseReason reason) {
    System.out.println("Connection closed: " + session.getId() + ", reason: " + reason);
    // 리소스 정리 및 사용자 상태 관리
}

 

@OnClose 이벤트는 세션이 닫힐 때 실행되므로 연결이 종료되었을 때 필요한 정리 작업을 수행할 수 있다

 


4. Ping-Pong 프레임 활용 (WebSocket 표준 기능)

만약 STOMP나 SockJS를 사용하지 않을 경우, WebSocket 표준 기능인 Ping-Pong 프레임을 활용하여 서버와 클라이언트가 연결 상태를 주기적으로 확인할 수 있다

서버가 주기적으로 Ping 메시지를 보내고 클라이언트가 응답(Pong)함으로써 연결 상태를 유지한다

// 서버 측 Ping-Pong 핸들러 예시
public void sendPing(Session session) {
    try {
        session.getBasicRemote().sendPing(java.nio.ByteBuffer.wrap(new byte[0]));
    } catch (IOException e) {
        System.out.println("Ping failed, closing session.");
        try {
            session.close();
        } catch (IOException ex) {
            ex.printStackTrace();
        }
    }
}

 

728x90
728x90

JPA를 사용하면서 N+1 문제 왜 발생하는가?


 

1. N+1 문제가 무엇인가?

N+1 문제는 기본적으로 지연 로딩(Lazy Loading) 때문에 발생한다. JPA는 엔티티 간의 연관 관계를 설정하면, 연관된 엔티티를 실제로 필요할 때(지연 로딩) 가져오는 전략을 사용하는데, 이것이 잘못 사용될 경우 N+1 문제를 일으킬 수 있다.

 

2. 어떻게 발생하는가? (예시)

예를 들어, 부모(Parent)와 자식(Child)이라는 두 개의 엔티티가 있다. Parent 엔티티는 여러 Child 엔티티와 1 : N 관계를 가진다.

@Entity
public class Parent {
    @Id
    private Long id;

    @OneToMany(mappedBy = "parent", fetch = FetchType.LAZY)
    private List<Child> children;
}

@Entity
public class Child {
    @Id
    private Long id;

    @ManyToOne
    private Parent parent;
}

이 경우, Parent 엔티티는 여러 Child 엔티티를 가진다.

이제 데이터베이스에 Parent 엔티티가 10개 있다고 가정해보면, 이 때 Parent 엔티티의 리스트를 조회하면 지연 로딩 때문에 자식 엔티티는 바로 로딩되지 않는다.

List<Parent> parents = entityManager.createQuery("SELECT p FROM Parent p", Parent.class).getResultList();

 

  • 이 쿼리는 부모 엔티티 목록만 가져오는 한 개의 쿼리가 실행된다.
  • 하지만 이후 각 부모 엔티티에 연관된 자식 엔티티를 가져오기 위해, 각각의 부모에 대해 자식 엔티티를 조회하는 추가적인 쿼리가 발생한다.

SQL 쿼리 실행 순서를 살펴보면:

SELECT * FROM Parent; -- 1개의 쿼리로 10명의 부모를 조회

SELECT * FROM Child WHERE parent_id = 1; -- 첫 번째 부모의 자식 조회 (추가 쿼리)
SELECT * FROM Child WHERE parent_id = 2; -- 두 번째 부모의 자식 조회 (추가 쿼리)
...
SELECT * FROM Child WHERE parent_id = 10; -- 열 번째 부모의 자식 조회 (추가 쿼리)
// 총 11개의 쿼리가 실행

 

이처럼 처음에 부모를 가져오는 1개의 쿼리와 각 부모마다 자식을 가져오는 10개의 추가 쿼리가 발생하여 총 11개의 쿼리가 실행된다.

부모 엔티티가 더 많아질수록 쿼리 수는 기하급수적으로 늘어나므로 성능 문제가 발생하게되고

이것이 바로 N+1 문제이다.

 


N+1 문제 해결 방법

 

1. Fetch Join 사용

Fetch Join을 사용하면 여러 연관된 엔티티를 한 번의 쿼리로 모두 가져올 수 있다. 이를 통해 쿼리 횟수를 1로 줄일 수 있다.


아까 예시로 나타냈던 기존 쿼리는

List<Parent> parents = entityManager.createQuery("SELECT p FROM Parent p", Parent.class).getResultList();

 

부모 엔티티에 연관된 자식 엔티티를 가져오기 위해, 각각의 부모에 대해 자식 엔티티를 조회하는 추가적인 쿼리가 발생하게 되는데

 

Fetch Join을 이용한 쿼리는

List<Parent> parents = entityManager.createQuery(
    "SELECT p FROM Parent p JOIN FETCH p.children", Parent.class).getResultList();

 

=> 실행되는 쿼리

SELECT p.*, c.* FROM Parent p
JOIN Child c ON p.id = c.parent_id;

 

이렇게 한번의 쿼리만 실행되게 할 수 있다.

 

N+1 문제를 해결하는 방식 중 Fetch Join은 JPQL(JPA Query Language)로 작성하는 경우가 많다. JPQL은 엔티티 객체를 대상으로 하는 쿼리 언어로, SQL과 달리 데이터베이스 테이블이 아닌 JPA 엔티티를 대상으로 동작한다.

 

그렇다면 FetchJoin의 장단점은 어떤것이 있을까?

 

장점:

  • N+1 문제 해결: 다중 쿼리의 오버헤드를 없애고 성능을 최적화한다.
  • 한 번의 쿼리로 여러 엔티티 조회: 연관된 데이터를 한꺼번에 가져오므로 데이터베이스와의 네트워크 왕복 횟수를 줄일 수 있음.
  • 즉시 로딩 가능: 연관된 데이터를 즉시 사용할 수 있어 Lazy 로딩과 달리 성능 이슈가 적음.
  • 데이터 일관성: 트랜잭션이 종료된 이후에도 필요한 데이터를 모두 사용할 수 있어 데이터 일관성 문제를 방지.
  • 복잡한 관계의 단순화: 복잡한 연관 관계를 Fetch Join으로 쉽게 처리할 수 있음.

단점:

  • 데이터 양이 많을 경우 성능이 저하될 수 있다. 조인된 결과가 많아지면 메모리 사용량이 늘어나고, 애플리케이션 성능에 부정적인 영향을 미칠 수 있다.
  • N:N 관계나 매우 복잡한 쿼리에서는 Fetch Join이 오히려 문제를 야기할 수 있다. 예를 들면, 중복된 결과가 반환될 수 있으며, 이는 개발자가 추가적인 처리를 해야 할 수도 있다.
  • FetchType 설정 무시: Fetch Join을 사용하면 Lazy로 설정해도 즉시 로딩이 이루어지기 때문에, 연관 엔티티를 언제 로딩할지에 대한 세부적인 제어가 불가능하다.
  • 페이징 처리 불가: Fetch Join은 결과를 한 번에 로딩하기 때문에, 연관된 데이터를 페이징 단위로 가져오는 것이 불가능하다. 따라서 대용량 데이터를 처리할 때 페이징 쿼리와 함께 사용하기 어렵다.

2. @EntityGraph 사용

@EntityGraph는 특정 쿼리에서 명시적으로 어떤 연관 엔티티를 함께 로딩할지 정의하는 방법이다. Fetch Join과 달리 쿼리에서 명시적으로 작성하는 것이 아닌, 애노테이션으로 설정할 수 있다.

특정 엔티티의 조회 시, @EntityGraph 어노테이션을 사용하여 즉시 로딩할 연관 엔티티를 명시하면 내부적으로 JPA가 JPQL로 변환하여 처리한다.

 

예시:

@EntityGraph(attributePaths = {"children"})
List<Parent> findAll();

 

@EntityGraph의 장단점 :

 

장점:

  • 코드가 간결: JPQL 쿼리를 직접 작성할 필요 없이 어노테이션으로 필요한 연관 엔티티를 즉시 로딩할 수 있다.
  • OUTER JOIN 사용: Fetch Join과 달리 OUTER JOIN을 사용하므로, 연관된 엔티티가 없어도 주 엔티티는 조회된다.

단점:

  • 복잡한 연관 관계가 많을 경우 성능이 저하될 수 있다.
  • Fetch Join과 마찬가지로 중복 데이터가 발생할 가능성이 있으며, 이를 방지하기 위한 추가적인 조치가 필요하다.

Fetch Join@EntityGraph의 공통점과 차이점

 

 

공통점

  • 두 방법 모두 지연 로딩(Lazy Loading) 설정을 무시하고, 연관된 엔티티를 즉시 로딩(Eager Fetching) 한다.
  • JPQL을 사용하여 JOIN을 수행하는 방식이지만, @EntityGraph는 개발자가 직접 JPQL을 작성할 필요가 없고, Fetch Join은 JPQL에서 명시적으로 사용해야 한다.
  • N+1 문제 해결에 유용하며, 한 번의 쿼리로 여러 연관된 엔티티를 함께 조회할 수 있다.

차이점

조인 방식

  • Fetch Join: INNER JOIN을 사용하여 연관된 엔티티가 없으면 해당 엔티티는 결과에서 제외.
  • @EntityGraph: OUTER JOIN을 사용하여 연관된 엔티티가 없더라도 주 엔티티는 결과에 포함.

JPQL 작성 여부

  • Fetch Join: 개발자가 JPQL을 작성해야 하고, 직접적으로 JOIN FETCH를 명시해야 한다.
  • @EntityGraph: 개발자가 JPQL을 작성하지 않고도 애노테이션을 사용해 설정할 수 있으며, JPA가 내부적으로 JPQL로 변환하여 쿼리를 실행한다.

주의할 점

카테시안 곱(Cartesian Product)

  • 두 방식 모두 다대다 또는 일대다 관계에서 Cartesian Product가 발생할 수 있습니다. 연관된 엔티티 수만큼 중복된 데이터가 조회될 수 있습니다. 예를 들어, 부모-자식 관계에서 부모가 여러 자식을 가지고 있을 때 부모 엔티티가 중복되어 나타날 수 있습니다.

 

 

중복데이터를 제거할 수 있는 방법으로는 ?

 

1. Set 컬렉션 사용

  • Set중복을 허용하지 않는 자료구조이므로, Fetch Join이나 @EntityGraph로 인해 중복된 데이터가 조회되더라도 컬렉션이 Set으로 정의되어 있다면 중복된 엔티티가 자동으로 제거된다..
  • 예를 들어, 부모와 자식 관계에서 부모가 여러 자식을 가질 때, 조인 결과로 중복된 부모 엔티티가 반환될 수 있다.
    하지만 Set을 사용하면 중복된 부모 엔티티가 자동으로 제거된다
@OneToMany(fetch = FetchType.LAZY)
private Set<Child> children;  // Set 사용으로 중복된 자식 제거

 

주의 할 점은 Set은 데이터의 순서를 보장하지 않으므로, 순서가 중요한 경우에는 적합하지 않을 수 있다.

 

2. JPQL에서 DISTINCT 사용

  • JPQL의 DISTINCT 키워드를 사용하면 중복된 데이터가 조회되지 않도록 할 수 있다.
  • JPQL에서 DISTINCT는 SQL의 DISTINCT처럼 작동하여 결과 집합에서 중복된 행을 제거한다.
  • 하지만 DISTINCT는 엔티티 전체가 동일할 때만 중복을 제거하므로, 부모 엔티티와 자식 엔티티의 일부 데이터가 다르다면 여전히 중복된 부모 엔티티가 나타날 수 있다. 그럼에도 불구하고, 연관된 여러 엔티티의 중복 문제를 완화할 수 있는 방법이다.
String jpql = "SELECT DISTINCT p FROM Parent p JOIN FETCH p.children";
List<Parent> parents = entityManager.createQuery(jpql, Parent.class).getResultList();

 

장점:

  • SQL에서 DISTINCT와 유사하게 동작하여 중복된 데이터를 제거할 수 있습니다.
  • Fetch Join을 사용할 때 발생하는 중복된 부모 데이터를 줄일 수 있습니다.

단점:

  • 연관된 엔티티가 동일하지 않으면 중복이 완전히 제거되지 않을 수 있습니다.
  • 결과적으로 성능에 약간의 영향을 줄 수 있습니다.

3. Batch Fetching

 

Batch Fetching은 Hibernate에서 제공하는 성능 최적화 방법으로, 연관된 엔티티들을 배치로 묶어서 조회하는 방식이다.

  • 개념: 연관된 엔티티들을 여러 번에 나누어 가져오되, 각 번의 조회에서 일정 개수씩 묶어 한꺼번에 로딩하는 방식 hibernate.default_batch_fetch_size 속성을 사용하여 설정할 수 있다.

1. application.properties에서 설정하는 방법

spring.jpa.properties.hibernate.default_batch_fetch_size=10

 

2. 엔티티를 사용해서 설정

@Entity
public class Parent {

    @Id
    private Long id;

    @OneToMany(fetch = FetchType.LAZY)
    @BatchSize(size = 10)  // Batch Fetching을 엔티티 수준에서 설정할 수도 있음
    private List<Child> children;
}

@Entity
public class Child {

    @Id
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    private Parent parent;
}

 

장점:

  • Lazy Loading 유지: 지연 로딩을 그대로 유지하면서 성능 최적화를 할 수 있다.
  • 대규모 데이터를 처리할 때 적절히 설정하면 성능 개선에 큰 도움이 된다.

단점:

  • Hibernate에 종속적인 기능으로, JPA 표준이 아니다.
  • 배치 크기를 잘못 설정하면 오히려 성능에 부정적인 영향을 미칠 수 있다.

FetchType.EAGER 사용

 

즉시 로딩(Eager Fetching)은 연관된 엔티티를 항상 즉시 로딩하는 방식이다. 연관된 모든 엔티티를 한 번에 조회할 수 있지만, 무조건적으로 사용하기에는 부담이 될 수 있다.

  • 개념: 연관된 엔티티를 즉시 로딩하도록 FetchType.EAGER로 설정한다. 이는 연관된 엔티티를 무조건 즉시 로딩하기 때문에 추가적인 N+1 문제를 방지할 수 있다.

    장점
    • 모든 연관된 데이터를 한 번에 로딩하므로 N+1 문제를 해결할 수 있다.
    단점:
    • 불필요한 데이터 로딩이 발생할 수 있다. 즉시 로딩은 항상 연관된 데이터를 가져오기 때문에 필요하지 않은 데이터를 로딩할 경우 성능 저하를 일으킬 수 있다.

QueryDSL, Mybatis등 QueryBuilder를 사용해서 n+1 문제를 해결 할 수도 있다

 

1. QueryDSL로 N+1 문제 해결

import com.querydsl.jpa.impl.JPAQueryFactory;
import org.springframework.stereotype.Repository;
import javax.persistence.EntityManager;
import java.util.List;

@Repository
public class ParentRepository {

    private final JPAQueryFactory queryFactory;

    // EntityManager 주입을 통해 JPAQueryFactory 초기화
    public ParentRepository(EntityManager entityManager) {
        this.queryFactory = new JPAQueryFactory(entityManager);
    }

    public List<Parent> findAllParentsWithChildren() {
        QParent parent = QParent.parent;  // Parent 엔티티의 Q 클래스
        QChild child = QChild.child;      // Child 엔티티의 Q 클래스

        // Fetch Join을 사용해 Parent와 Child를 한 번에 로딩하여 N+1 문제 해결
        return queryFactory
            .selectFrom(parent)
            .leftJoin(parent.children, child).fetchJoin()  // Parent와 Child 간의 Fetch Join
            .distinct()  // 중복된 Parent 제거
            .fetch();    // 결과 조회
    }
}

 

쿼리 설명:

  • leftJoin(parent.children, child).fetchJoin(): Parent 엔티티와 연관된 Child 엔티티를 Fetch Join으로 한 번의 쿼리로 가져온다
  • distinct(): 중복된 Parent 엔티티를 제거하여 카테시안 곱 문제를 방지한다.
  • fetch(): 결과를 쿼리하여 리스트로 반환한다.

QueryDSL의 장점:

  • 타입 세이프 쿼리: 컴파일 타임에 오류를 잡아낼 수 있다.
  • 동적 쿼리: 여러 조건을 동적으로 추가하여 유연한 쿼리를 작성할 수 있다.
  • Fetch Join으로 N+1 문제 해결: 연관 엔티티를 한 번의 쿼리로 가져오기 때문에 N+1 문제를 해결한다.

 

2. MyBatis로 N+1 문제 해결

MyBatis는 직접적으로 SQL 쿼리를 작성하므로, 연관된 엔티티를 JOIN을 통해 한 번의 쿼리로 가져올 수 있다.

ResultMap을 사용하여 Parent와 Child를 매핑할 수 있으며, 이를 통해 N+1 문제를 해결할 수 있다.

 

<resultMap id="ParentWithChildrenMap" type="Parent">
    <id column="parent_id" property="id" />
    <result column="parent_name" property="name" />
    
    <!-- children 필드를 Child 리스트로 매핑 -->
    <collection property="children" ofType="Child">
        <id column="child_id" property="id" />
        <result column="child_name" property="name" />
    </collection>
</resultMap>

<select id="findAllParentsWithChildren" resultMap="ParentWithChildrenMap">
    SELECT p.id AS parent_id, p.name AS parent_name,
           c.id AS child_id, c.name AS child_name
    FROM Parent p
    LEFT JOIN Child c ON p.id = c.parent_id
</select>
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Select;
import java.util.List;

@Mapper
public interface ParentMapper {

    @Select("SELECT * FROM Parent")
    List<Parent> findAllParentsWithChildren();
}
@Getter @Setter
public class Parent {
    private Long id;
    private String name;
    private List<Child> children;  // OneToMany 관계

}

@Getter @Setter
public class Child {
    private Long id;
    private String name;
    private Parent parent;  // ManyToOne 관계

}

MyBatis에서 N+1 문제 해결:

  • LEFT JOIN을 통해 Parent와 Child 데이터를 한 번의 쿼리로 가져오므로, N+1 문제를 해결할 수 있다.
  • ResultMap을 사용하여 SQL 결과를 Parent와 Child 엔티티에 매핑한다. 이를 통해 다대일 또는 일대다 관계를 쉽게 처리할 수 있다.
728x90

+ Recent posts