실시간 채팅을 구현해보자 (2) - Back_end

이원석·2025년 2월 20일
0

사이드 프로젝트

목록 보기
2/10
post-thumbnail

오타/잘못된 내용에 대한 지적은 언제나 환영입니다!


안녕하시렵니까.
이전 포스팅에서는 실시간 채팅 서비스의 개요에 대해 작성했습니다. 이번 포스팅은 back_end 서버에서 어떻게 서비스를 구현했는지 코드, 플로우 차트(시퀀스 다이어그램)과 함께 자세하게 작성해보겠습니다.

기본적으로 코드의 양이 많기 때문에, 핵심적인 내용만 기술하겠습니다. 추가로 궁금하신 점은 Back_end Code 를 참고해주세요!



-1: 아키텍처
-2: 기본 설정 코드
-3: 구현 코드



🏗️ 1. 아키텍처

아키텍처의 구성은 다음과 같습니다. 현재 로컬 환경에서의 작동만 테스트 했으며 추후 배포단계를 거친다면 아키텍처 항목은 수정 예정입니다.


✏️ Flow

Client(Browser) / Request → Spring Security → Server → DB → Client(Browser) / Response





💻 2. 구현 코드

📌 Dependecies

	// STOMP, ws
	implementation 'org.springframework.boot:spring-boot-starter-websocket'
	implementation 'org.webjars:sockjs-client:1.5.1'
	implementation 'org.webjars:stomp-websocket:2.3.4'

	// jpa
	implementation 'org.springframework.boot:spring-boot-starter-data-jpa' 
	testImplementation 'org.springframework.boot:spring-boot-starter-test' 

	// security
	implementation 'org.springframework.boot:spring-boot-starter-security'
	implementation 'org.springframework.security:spring-security-core'

	// jwt
	implementation 'io.jsonwebtoken:jjwt-api:0.12.6'
	runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6'
	runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6'

	// DB
	implementation 'com.google.oauth-client:google-oauth-client-jetty:1.23.0'
	runtimeOnly 'com.mysql:mysql-connector-j'
	implementation 'mysql:mysql-connector-java:8.0.33'

	// lombok
	compileOnly 'org.projectlombok:lombok'
	annotationProcessor 'org.projectlombok:lombok'

	// Spring Boot
	implementation 'org.springframework.boot:spring-boot-starter-web'
	testImplementation 'org.springframework.boot:spring-boot-starter-test'
	testRuntimeOnly 'org.junit.platform:junit-platform-launcher'

	// Redis
	implementation 'org.springframework.boot:spring-boot-starter-data-redis'

	// Mongo
	implementation 'org.springframework.boot:spring-boot-starter-data-mongodb'



📌 Config

Config 클래스는 Spring 애플리케이션이 시작될 때 필요한 설정을 정의하고, Spring 컨테이너가 관리할 Bean을 등록하는 역할을 합니다.


1. WebSocketConfig.java

@Configuration
@RequiredArgsConstructor
@EnableWebSocketMessageBroker
@Slf4j
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

    private final JwtProvider jwtProvider;

	// WebSocket 클라이언트가 연결할 수 있는 엔드포인트를 등록
    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        // 주소: ws://localhost:8080/chat
        registry.addEndpoint("/connect") // 클라이언트가 WebSocket 연결 시 사용할 URL
                .setAllowedOriginPatterns("*")
                .withSockJS() // 웹 소켓을 지원하지 않는 브라우저는 sockJS를 사용
        ;
    }


	// 클라이언트와 서버 간의 메시지 전달을 위한 메시지 브로커를 설정
    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {
    	// 클라이언트가 구독할 수 있는 경로(목적지)를 설정
        // MessageBroker가 해당 "/sub", "/queue" api를 구독하고 있는 클라이언트에게 메시지를 전달
        registry.enableSimpleBroker("/sub", "/queue")
                .setTaskScheduler(taskScheduler())
                // heart.beat 설정 25000ms -> 25s (모니터링)
                .setHeartbeatValue(new long[] {25000, 25000});

        // 클라이언트로 부터 메시지를 받을 api의 prefix를 설정한다. "/pub/~~"
        registry.setApplicationDestinationPrefixes("/pub");
    }


    // 클라이언트가 서버에 메시지를 전송할 때 사용할 인바운드 채널을 설정
    // (WebSocket 메시지가 클라이언트에서 서버로 전송될 때 사용되는 채널)
    @Override
    public void configureClientInboundChannel(ChannelRegistration registration) {
    	// JWT 토큰을 검증하기 위한 인터셉터를 추가 (JWT의 유효성 검사)
        registration.interceptors(new JwtChannelInterceptor(jwtProvider));
    }

    public TaskScheduler taskScheduler() {
        ThreadPoolTaskScheduler taskScheduler = new ThreadPoolTaskScheduler();
        taskScheduler.initialize();
        return taskScheduler;
    }
}

WebSocket 기능을 설정하기 위한 WebSocketConfig 클래스입니다. WebSocketMessageBrokerConfigurer 인터페이스를 구현, WebSocket의 엔드포인트 및 메시지 브로커를 구성했습니다. CONNECT 시점의 JWT 유효성 검사를 위한 Interceptor 설정이 있습니다.

자세한 내용은 주석을 참고해주세요.



2. WebSecurityConfig.java

@Configuration
@RequiredArgsConstructor
@EnableWebSecurity
public class WebSecurityConfig {

    private final JwtAuthenticationProvider jwtAuthenticationProvider;
    private final CorsConfig corsConfig;

    @Bean
    public JwtAuthenticationFilter jwtAuthenticationFilter(
            AuthenticationConfiguration authConfig,
            JwtProvider jwtProvider,
            ObjectMapper objectMapper) throws Exception {

        return new JwtAuthenticationFilter(authConfig.getAuthenticationManager(), jwtProvider, objectMapper);
    }


    // 요청에 대한 사용자 권한을 위한 구성 메서드
    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http,
                                                   JwtAuthenticationFilter jwtAuthenticationFilter) throws Exception {
        return
                http
                        .httpBasic(AbstractHttpConfigurer::disable) // HTTP Basic 인증 비활성화
                        .csrf(AbstractHttpConfigurer::disable) // REST API 는 기본적으로 상태를 유지하지 않으며, CSRF 방어가 필요하지 않기 때문에 비활성화
                        .cors(cors -> cors.configurationSource(corsConfig.corsConfigurationSource())) // CORS 허용
                        // 세션 관리 설정
                        // Stateless 로 설정하여 서버가 세션 상태를 유지하지 않음 (JWT 인증에 적합)
                        .sessionManagement(configurer ->
                                configurer.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                        )
                        .authorizeHttpRequests(auth -> auth
                                .requestMatchers("/", "/connect/**").permitAll()
                                .requestMatchers("/init", "/chatroom/**", "/message/**").authenticated()
                        )
                        // JWT 인증처리
                        .authenticationProvider(jwtAuthenticationProvider)
                        
                        // 커스텀 인증 필터 추가
                        // `UsernamePasswordAuthenticationFilter` 전에 `JwtAuthenticationFilter` 실행
                        .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)

                        // 특정 경로에만 이 보안 설정 적용

                        // SecurityFilterChain 빌드
                        .build();
    }
}

SpringSecurity 기능을 설정하기 위한 WebSecurityConfig 클래스입니다.

SpringBoot의 버전을 3.3.7로 업그레이드하며 기존의 WebSecurityConfigurerAdapter를 상속하는 방식이 deprecated 되었으며, FilterChain을 Bean으로 등록하는 방식이 권장되어 새롭게 구성했습니다.

Spring 공식 문서



일반적인 웹 소켓은 "ws://localhost:8080/connect" 을 통해 Connect를 시도하지만, SockJS는 브라우저가 직접 WebSocket을 지원하지 않을 경우를 대비해서 동작 방식이 다릅니다.

  1. 초기 SockJS(WebSocket) 연결시에는 HTTP 통신 "http(s)://~~ + /connect" 이 사용됩니다. (이후에는 ws:// 으로 동작)
    이 때, 보안 정책상 커스텀 헤더가 제한되기 때문에 SecurityFilter를 타지 않도록 permitAll 처리를 해줍니다.

  2. 초기 채팅방 접속시 토큰 발급 및 검증 과정을 거치는 "/init", 채팅 목록 로딩 "/message", 채팅방 생성 "/chatroom" api는 SecurityFilter를 타도록 authenticated 처리를 해줍니다.



Security Cycle은 다음과 같은 flow를 거칩니다.

자세한 내용은 주석과 Github 를 참고해주세요.



🔍 주요 기능 및 코드 설명

-1: WebSocket 신원 인증(JWT) 검사 시점
-2: WebSocket 전역 예외처리
-3: Entry(접속자) 실시간 체킹
-4: 채팅 목록 페이지네이션(무한 스크롤)



1. WebSocket 신원 인증(JWT) 검사 시점

〰 FlowChart


해당 프로젝트는 로그인 기능이 없는 채팅방이며, 최소한의 사용자 식별을 위해 JWT의 Payload에 사용자 식별 정보를 포함하여 토큰을 발급합니다.

이전 포스팅에서 STOMP는 헤더를 사용할 수 있는 장점이 있다고 언급했는데, 그렇다면 JWT는 언제 발급하는 것이 가장 적절할까요?


JWT 발급 시점과 관련하여 두 가지 고려사항이 있었습니다.

1️⃣ WS CONNECT 요청은 SecurityFilter를 거치지 않는다.

HTTP 요청과 달리 WebSocket 통신은 Spring Security의 필터 체인을 통과하지 않습니다.
따라서 CONNECT 시점에 인증 및 유효성 처리를 위해서는 별도의 CustomChannelInterceptor를 사용해 인증을 처리해야 합니다.

@Slf4j
@Component
@RequiredArgsConstructor
@Order(Ordered.HIGHEST_PRECEDENCE + 99)
/*
    WebSocket Connect 시에 인증처리
 */
public class JwtChannelInterceptor implements ChannelInterceptor {
    private final JwtProvider jwtProvider;
    public @Value("${server.chat-room-id}") String roomId;

    @Override
    public Message<?> preSend(Message<?> message, MessageChannel channel) {
        StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message);

        // CONNECT 요청의 경우
        if (StompCommand.CONNECT.equals(accessor.getCommand())) {
            // 헤더에서 JWT 토큰 추출
            String ipAddress = accessor.getFirstNativeHeader("X-Client-IP");
            String token = accessor.getFirstNativeHeader("Authorization");

            if (token != null && token.startsWith("Bearer ")) {
                token = token.substring(7); // "Bearer "를 제거하여 실제 토큰만 추출

                try {
                    jwtProvider.validateToken(token, ipAddress);
                } catch (ExpiredJwtException e) {
                    throw e;
                } catch (Exception e) {
                    throw new BadCredentialsException("Invalid JWT token", e);
                }

            } else {
                log.warn("Authorization header is missing or invalid");
                throw new IllegalArgumentException("Authorization header is missing or invalid");
            }
        }

        return message;
    }
}




2️⃣ 최초 접속자는 CONNECT 요청을 보내기 전에 JWT가 없음.

최초의 CONNECT 시점에서는 JWT 없이 요청이 들어오므로, 인증이 불가능한 상태가 됩니다.

그렇다면? CONNECT하는 시점에 JWT를 발급하는게 맞을까요? 다음 두 가지를 생각해볼 수 있습니다.


🛠️ CONNECT 시점에 JWT 발급

CONNECT → JwtChannelInterceptor → JWT 발급 → RECONNECT

이 경우 클라이언트는 총 2번의 CONNECT를 시도하게 됩니다. 불필요한 재연결(RECONNECT) 과정이 추가되는데, 사실 큰 상관은 없다고 생각했지만 더 나은 방법이 없을까 고민해봤습니다.


🛠️ CONNECT 이전에 JWT 발급

init() → JWT 발급 → CONNECT → JwtChannelInterceptor

최초 클라이언트의 접속시 JWT 유효성 검사용 api를 호출하는 방법입니다. 이 경우 HTTP 통신을 거치게 되지만 CONNECT는 1번 시도하게 됩니다.


정리해보면 다음과 같습니다.

방법장점단점
CONNECT 시점에 JWT 발급- WebSocket만으로 인증을 처리할 수 있음
- 별도의 HTTP 요청이 필요 없음
- 최초 CONNECT 시 JWT가 없기 때문에 RECONNECT 필요
- 불필요한 WebSocket 재연결이 발생
CONNECT 이전에 JWT 발급 (init() 활용)- CONNECT 요청이 한 번만 발생하여 불필요한 재연결이 없음
- 클라이언트가 미리 JWT를 발급받아 사용 가능
- 최초 연결 속도가 더 빠름
최초 접속 시 추가적인 HTTP 요청(JWT 발급 API 호출)이 필요



사실 두 과정 모두 큰 차이가 없어보이지만, 다음 두 가지를 고려하여 후자의 방식(CONNECT 이전에 JWT 발급)을 선택했습니다.

  1. WebSocket 연결을 시도하는 과정에서 JWT를 발급하면 인증 흐름이 복잡해지고, WebSocket 자체의 역할과 맞지 않음.
  2. JWT 발급을 위한 API가 별도로 존재하면, 인증과 WebSocket 연결의 역할이 명확하게 분리됨.
  3. WebSocket 핸드셰이크 과정에서도 굳이 HTTP 요청을 발생시킬 필요가 있나?





2. WebSocket 전역 예외처리

WebSocket은 비연결성(Stateless) HTTP와 다르게 지속적인 양방향 통신을 유지하기 때문에, 통신시에 예외가 발생하더라도 별도의 응답(Response)을 반환할 수 없습니다.

대신, 특정 사용자의 구독 채널을 통해 예외 메시지를 전송해야 합니다.

@RestControllerAdvice
@Slf4j
@RequiredArgsConstructor
public class WebSocketExceptionHandler {
 
 	@MessageExceptionHandler(SocketException.class)
    @SendToUser(destinations = "/queue/errors", broadcast = false)  // 해당 Session 의 User 에게만 예외 메시지 전송
    protected ApiResponse<?> handleSocketException(SocketException e) {
        return ApiResponse
                .error(
                        e.getMessage(),
                        WsErrorType.SOCKET_ERROR,
                        e.getCause()
                );
    }

    @MessageExceptionHandler(BadCredentialsException.class)
    @SendToUser(destinations = "/queue/errors", broadcast = false)  // 해당 Session 의 User 에게만 예외 메시지 전송
    protected ApiResponse<?> handleInvalidToken(BadCredentialsException e) {
        log.error("BadCredentialsException");
        return ApiResponse
                .error(
                        e.getMessage(),
                        HttpErrorType.INVALID_TOKEN,
                        e.getCause()
                );
    }

    @MessageExceptionHandler(ExpiredJwtException.class)
    @SendToUser(destinations = "/queue/errors", broadcast = false)  // 해당 Session 의 User 에게만 예외 메시지 전송
    protected ApiResponse<?> handleExpiredToken(ExpiredJwtException e) {
        log.error("ExpiredJwtException");
        return ApiResponse
                .error(
                        e.getMessage(),
                        HttpErrorType.EXPIRED_TOKEN,
                        e.getCause()
                );
    }
    
    ...
}

🛠️ @SendToUser 동작 방식

@SendToUser(destinations = "/queue/errors", broadcast = false)
이 어노테이션은 현재 세션(해당 예외를 발생시킨 유저)에게만 메시지를 전송하도록 설정합니다.
broadcast = false는 메시지를 모든 사용자에게 브로드캐스트하지 않도록 설정합니다. true인 경우, 해당 유저의 모든 활성 세션(PC, 모바일, 데스크톱 등)에 에러 메시지가 전달되게 합니다.

@SendToUser를 사용하게 되면 destination의 prefix로 "/user"가 붙게됩니다. 클라이언트 측에서 구독 시 이 부분을 신경써야 합니다.


🛠️ @SendTo는 뭐야?

@SendTo는 지정한 목적지를 구독한 모든 구독자들에게 이벤트를 broadcast 하게됩니다.
따라서! 예외를 발생시킨 개별 사용자에게만 이벤트를 전달하려면 @SendToUser를 사용해야겠죠?





3. Entry(접속자) 실시간 체킹

〰 FlowChart

위와 같이 실시간 채팅방에서는 현재 접속 중인 사용자를 실시간으로 파악할 수 있으면 좋겠죠?

그렇다면, 어떻게 구현할 수 있을까요? 처음에는 다음과 같은 구현 방식을 생각했습니다.



1️⃣ 초기 구현 방식

🛠️ Interceptor와 스케줄러 사용

CONNECT → JwtChannelInterceptor → ServiceLayer → WebSocketBroadCatser(Scheduler) → broadcast

Inerceptor에서 CONNECT를 감지하면, 메모리에 (HashMap 형태) 현재 참여중인 사용자를 증가시켰습니다.

이후, SpringScheduler를 통해 5초 간격으로 현재 접속 중인 사용자를 broadcasting 하도록 했습니다.

👇

@Component
RequiredArgsConstructor
EnableScheduling
Slf4j
public class WebSocketBroadCaster {
    private final SimpMessagingTemplate messagingTemplate;
    private final ChatService chatService;
    private @Value("${server.chat-room-id}") String roomId;

    // 5초마다 참여 인원 수를 브로드캐스트
    @Scheduled(fixedRate = 5000) 
    public void broadcastSessionCount() {
        int entryCount = chatService.getEntry(roomId);

        // 클라이언트에게 전송
        messagingTemplate.convertAndSend("/sub/channel/entry" + roomId, entryCount);
    }
}

흠.. 실시간성이 중요한 채팅 서비스에 어울리지 않다고 생각했지만 별 다른 아이디어가 떠오르지 않았습니다.

어떻게 개선할 수 있을까 고민하던 도중.. @EventListener를 사용하면 훨신 간단하고 효율적으로 기능을 개선할 수 있다는 것을 알게되었습니다.



✨ 개선 과정

🛠️ @EventListener 활용

CONNECT → StompListener → broadcast

@EventListener란 Spring Framework의 이벤트 기반 프로그래밍을 위한 어노테이션으로, 특정 이벤트가 발생했을 때 그에 대한 처리를 수행하는 메서드를 정의하는 데 사용됩니다.

CONNECT 이벤트가 발생하면 이를 감지하여 사용자가 접속했다고 판단하여, Session에 참여중인 접속자를 braodcast 하도록 구현했습니다.

👇

// After CONNECT 
@EventListener(SessionConnectedEvent.class)
public void handleSessionSubscribed(SessionConnectedEvent event) {
    int webSocketSessions = subProtocolWebSocketHandler.getStats().getWebSocketSessions();
    log.info("After CONNECT");
    messagingTemplate.convertAndSend("/sub/channel/entry", webSocketSessions);
}



❗ 문제 발생

그러나, 채팅방에 참여한 사용자는 접속자 수(Entry)를 전달받지 못하는 문제가 발생했습니다. (다른 참여자들은 정상적으로 이벤트 전달 됨)

로깅 작업을 통해 전체적인 Flow를 살펴본 결과 아마 다음의 이유로 문제가 발생했음을 추측했습니다. (아닐수도 있음..)

CONNECT →(찰나) [@EventListener - broadcast] → SUBSCRIBE(broadcast를 전달받지 못함)


JavaScript의 코드를 보면 CONNECT 요청 이후 SUBSCRIBE(구독) 작업을 진행합니다.

그러나, @EventListener가 CONNECT 요청을 감지하는 그 순간은 정말 찰나였습니다. 사용자가 Entry event를 구독하기 전에 서버는 broadcast를 통해 이벤트를 전달하고, 그 결과 채팅방에 참여하는 사용자는 갱신된 broadcast message를 놓치는 문제가 발생했습니다.



🛠️ Broadcast 시점 변경

// After Subscribe
@EventListener(SessionSubscribeEvent.class)
public void handleSessionSubscribed(SessionSubscribeEvent event) {
    int webSocketSessions = subProtocolWebSocketHandler.getStats().getWebSocketSessions();
    log.info("After Subscribe");
    messagingTemplate.convertAndSend("/sub/channel/entry", webSocketSessions);
}

CONNECT → SUBSCRIBE → [@EventListener - broadcast]

해당 문제는 CONNECTED 이벤트가 아닌, SUBSCRIBE 이벤트를 감지하도록 시점을 변경하여 해결할 수 있었습니다.





4. 채팅 목록 페이지네이션 (무한 스크롤)

모든 채팅 메시지를 로딩하면 어떻게 될까요? 당연히 안 됩니다! 😅

채팅 메시지가 10개, 100개, 1000개 ... 10만 개로 기하급수적으로 늘어나고, 채팅방도 10개, 100개 ... 많아진다면, N개의 채팅방에서 M개의 채팅을 로딩한다면 기본적으로 O(N * M)의 시간 복잡도를 가집니다.

이 시간 복잡도를 감소시킬 수 있는 방법은 무엇일까요?

바로 페이지네이션(무한 스크롤)을 활용하는 것입니다! 페이지네이션을 통해 한 번에 로드하는 메시지 수를 제한하면, 예를 들어 M개의 채팅을 모두 로딩하는 것이 아니라 10개로 고정시킨다면, 각 채팅방에서 로드되는 메시지 수를 상수 k(=10)로 설정할 수 있습니다. 이 경우 시간 복잡도는 O(N)으로 표현할 수 있습니다.



기본적으로 페이지네이션은 offset과 limit을 기반으로 작동합니다. 먼저 인간의 언어로 풀어봅시다. offset은 동적으로 limit은 정적으로 생각하면 됩니다.

"어디서부터(offset) 최대 몇 개(limit)를 가져오겠다."

"(offset =)0번째 부터 (limit =) 10개를 가져오겠다."
"(offset =)11번째 부터 (limit =) 10개를 가져오겠다."

생각보다 간단하죠?

프론트엔드는 스크롤 감지를 통해 offset이 10씩 증가하도록 구현했습니다.


❗ 문제 발생

하지만 offset 기반 페이지네이션은 다음과 같은 두 가지 문제가 있었습니다.

🚨 데이터의 변화가 있는 경우, 데이터 중복 현상

실시간으로 메시지가 추가되는 채팅 시스템에서 고정된 offset을 사용하면, 새로운 데이터가 추가될 때 페이징 일관성이 깨지는 문제(중복 데이터 로딩)가 발생했습니다.

흐름은 다음과 같습니다. 👇

0️⃣ [1~20] 20개의 메시지가 등록됨

1️⃣ 최초 로딩시점
(offset 0, limit 10)
- [20~10] 10개의 메시지를 생성 시간 내림차순으로 가져옴 (최신 등록순)

2️⃣ "아무무"가 채팅 10개 입력
- [21~30] 10개의 메시지가 추가로 등록됨

3️⃣ "알리스타"가 상단 스크롤
- 스크롤 감지 (offset += 10, limit 10)
- 추가 로딩시 [10~0]의 메시지를 가져와야 정상 처리
- offset 기준으로 보면,30개의 메시지 중 생성 시간 내림차순 기준으로 10번째 (, 20번 부터) 10개의 데이터를 가져옴.[20~10] 10개의 메시지를 다시 중복해서 가져오는 문제 발생.



🚨 RDB/NoSQL 에서 OFFSET 쿼리의 퍼포먼스 이슈

offset은 쿼리 실행 시 offset만큼 모든 행을 "읽으며" offset을 감소시킨 후 원하는 데이터 M개를 가져옵니다. 아래의 예시를 한 번 봅시다.

SELECT * FROM messages ORDER BY created_at DESC LIMIT 10 OFFSET 0;

위 쿼리는 정렬된 데이터 중 처음 10개만 가져오지만,

SELECT * FROM messages ORDER BY created_at DESC LIMIT 10 OFFSET 100000;

offset이 100,000인 경우 10만 개의 데이터를 "하나씩" 스킵하면서 센 후 10개를 가져오게 됩니다. 딱 봐도 엄청난 비효율성 아니겠습니까?



✨ 개선 과정

해당 문제는 커서 기반 페이지네이션을 통해 해결할 수 있었습니다.


🛠️ 커서 기반 페이지네이션 (Cursor-based Pagination)

오프셋 기반 페이지네이션은 우리가 원하는 데이터가 '몇 번째'에 있다는 데에 집중하고 있다면, 커서 기반 페이지네이션은 우리가 원하는 데이터가 '어떤 데이터의 다음'에 있다는 데에 집중합니다.

(offset=) n개의 row를 skip 한 다음 10개 주세요 가 아니라, 이 row(Cursor) 다음 것 부터 10개 주세요 를 요청하는 식입니다. @minsangk velog

우선, Cursor를 어떻게 구할까요?

  1. 최초 로딩 시점
    가장 최신 데이터(예: id 역순)에서 LIMIT 개의 메시지를 가져옵니다. 이때, 조회한 메시지 중 마지막 메시지의 id를 Cursor로 사용하여 클라이언트에 저장합니다.

  2. 이후 로딩 시점
    클라이언트는 저장된 Cursor를 기준으로, 즉, 커서보다 작은 id를 가진 메시지 중 LIMIT 개를 가져오게 됩니다. 이를 통해 중복 없이 이전 메시지들을 순차적으로 불러올 수 있습니다.


Back_end code

@RequiredArgsConstructor
@Repository
@Slf4j
public class MongoChatRepositoryImpl implements ChatRepository {
    private final MongoTemplate mongoTemplate;

    @Override
    public void save(Chat chat) {
        mongoTemplate.save(chat);
    }

	// 최초 로딩 시점 
    // SELECT * FROM chat WHERE chat_room_id = 'roomId' ORDER BY _id DESC LIMIT 10
    @Override
    public List<Chat> findChatsWithPaginationByLimit(String roomId, int limit) {
        Query query = new Query(Criteria.where("chat_room_id").is(roomId))
                .with(Sort.by(Sort.Direction.DESC, "_id"))  // _id 기준 내림차순 정렬 추가
                .limit(limit);
        return mongoTemplate.find(query, Chat.class);
    }
    

	// 이후 스크롤 시점
    // SELECT * FROM chat 
	// WHERE chat_room_id = 'roomId' AND _id < lastMessageId 
	// ORDER BY _id DESC LIMIT 10;
    @Override
    public List<Chat> findChatsWithPaginationByLastMessageId(String roomId, int limit, int lastMessageId) {
        Query query = new Query(Criteria.where("chat_room_id").is(roomId)
                .and("_id").lt(lastMessageId))
                .with(Sort.by(Sort.Direction.DESC, "_id"))  // _id 기준 내림차순 정렬 추가
                .limit(limit);
        return mongoTemplate.find(query, Chat.class);
    }
}

MongoTemplate은 Spring Data MongoDB에서 MongoDB와 상호작용할 때 사용하는 핵심 클래스입니다. SQL에서 JdbcTemplate이랑 비슷한 개념이라고 생각하면 됩니다. 직접 MongoDB와 연결되어 쿼리를 실행 하게 됩니다.



Front_end Code

    async loadChat() {
        const chatContainer = $("#chatting");
        chatContainer.empty();  // 기존 채팅 목록 초기화
    
        // 첫 10개 메시지를 화면에 추가
        await this.loadMoreChat();  // offset = 0, limit = 10
    }

    async loadMoreChat() {

        const chatContainer = $("#chatting");

        console.log(`Loading chats from lastMessageId: ${this.lastMessageId}`);

        // 첫 로딩시에는 가장 최근 메시지들을 가져온다.
        // 근데, hasMoreData가 False가 되는 경우는 가장 마지막 메시지에 닿았을 때 더 이상 가져오지 못하는 것이다.
        // 따라서 이 경우 데이터가 더 추가되던 말던 이미 마지막 메시지까지 다 긁어왔기 때문에 더 이상 데이터를 가져올 필요가 없다.
        let messagesToDisplay = null;
        // 서버에서 10개씩 데이터 가져오기
        if (this.hasMoreData) {
            if (this.lastMessageId == null) {
                messagesToDisplay = await this.getChatListByLimit(10); // offset과 limit 전달
            } else {
                messagesToDisplay = await this.getChatListByLastMessageId(this.lastMessageId, 10);
            }
        }

        if (!messagesToDisplay || messagesToDisplay.length === 0) {
            this.hasMoreData = false;
            console.log("No more chats to load.");
            return; // 더 이상 데이터가 없으면 종료
        }

        for (let i = 0; i < messagesToDisplay.length; i++) {
            const chatMessage = messagesToDisplay[i];
            if (i == messagesToDisplay.length - 1) {
                this.lastMessageId = chatMessage.id;
            }

            const chatHtml = this.createChatHtml(
                chatMessage.randId + " " + chatMessage.nameTag,
                chatMessage.message,
                chatMessage.createdAt,
                chatMessage.ipAddress
            );
            chatContainer.prepend(chatHtml);  // 위쪽에 추가 (스크롤 위치를 위로 올리기 위해)
        }
        

        if (this.previousScrollPos != null) {
            this.scrollToPreviousPosition();
        } else {
            this.scrollChatToBottom();
        }
    }

"lastMessageId"가 Cursor 역활을 합니다. 만약, "lastMessageId"의 값이 초기화 되어있지 않다면 초기로딩 시점으로 간주하며, 값이 할당되어 있다면 Cursor를 기반으로 데이터를 페이지네이션 합니다.



다음으로는 Front-end 코드는 왜? 어떻게? 구성했는지 알아보겠습니다. 원래 이번 포스팅에 다 정리하려고 했는데 생각보다 글이 너무 길어지네요 😢





참고문헌
https://velog.io/@minsangk/%EC%BB%A4%EC%84%9C-%EA%B8%B0%EB%B0%98-%ED%8E%98%EC%9D%B4%EC%A7%80%EB%84%A4%EC%9D%B4%EC%85%98-Cursor-based-Pagination-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0

0개의 댓글