공뷰/Spring

[Spring, MySQL] 야구보구 현장톡의 모든 것 - 1편 (Polling, Cursor Pagination, 인덱스 튜닝)

nourzoo 2025. 11. 18. 20:20

 

 

이 글은 야구보구의 현장톡 기능을 설계하고 구현한 과정을 기록한 것이다.

현장톡 기능을 어떤 방식으로 만들었는지부터, 인덱스 튜닝을 통해 성능을 개선한 과정까지 정리해두었다.

 

참고로 현장톡에는 ‘좋아요’ 기능도 포함되어 있는데, 이 부분은 내용이 길어져 다음 글에서 따로 다루려고 한다.

 

완성된 야구보구의 현장톡

아래는 완성된 현장톡 화면이다.

실제 서비스에서는 좋아요 광클, 실시간 메세지, 신고 기능까지 포함된다.

 

왜 polling인가?

현장톡 기능을 맡고 처음 고민했던 건 어떤 방식으로 채팅을 구현할지였다.

일반적으로 채팅은 세 가지 방식이 많다

  • polling
  • WebSocket
  • sse

우리 팀도 처음엔 야구 경기 중엔 사람이 몰리니까 WebSocket이 맞겠다고 생각했다.

하지만 현실적인 판단 기준은 다음과 같았다 👇🏽

 

1) 우리가 이미 익숙한 기술

팀 전체가 Spring MVC + HTTP 구조에 익숙했기 때문에,

빠르게 구현해서 사용자에게 보여주는 것이 더 중요한 상황이었다.

 

2) SSE를 제외한 이유

SSE는 장점도 많지만, 다음 문제를 고려했다

1 동접자 수가 증가하면 Tomcat 스레드를 계속 붙잡아두는 구조

2 경기 시간대에 트래픽이 몰리면 스레드 풀 고갈 가능성 😱

따라서 리스크가 크다고 판단했다.

 

3) 서비스 트래픽 패턴

경기 시간대에만 트래픽 폭증하고 경기 없는 날엔 거의 접속이 없다.

또한 카카오톡처럼 ms 단위 실시간이 중요한 서비스 아니기 때문에 1~2초 정도 늦게 보여도 UX적으로 문제되지 않는다.

 

4) Polling의 결정적 장점

기존 HTTP 인프라를 그대로 활용 가능했기 때문에 구현 속도가 빠를 것이라 생각했다.

WebSocket처럼 24시간 연결 유지를 위한 서버 리소스가 불필요하다.

 

👉🏽 최종적으로 polling 선택

엔티티 설계(Google Play 정책 고려까지)

처음엔 “채팅방 엔티티를 따로 만들까?” 고민했지만, 결론은 필요 없음이었다.

  • 야구보구에서는 경기마다 새로운 채팅방이 생긴다.
  • 하루 최대 5개 경기뿐이다. 즉, 하루에 5개의 채팅방 만들어짐.
  • 과거 경기 채팅은 다시 열람되지 않는다.(단, db에서 삭제는 하지 않는다.)
  • 구분은 game_id로 충분하다.

그래서 Talk 엔티티 하나만 만들고 game_id, member_id를 FK로 두었다.

 

또한 신고/삭제 기능을 고려해 soft delete 방식을 선택했다.

 

Google Play 정책

Google Play 정책에서 채팅 신고 기능은 필수라는 정보를 얻었다. 이 기능이 없으면, 비대면 심사에서 탈락한다고 한다.

 

우리는 다음 기준을 세웠다.

  • 내가 신고한 채팅 → 나에게만 숨김 처리
  • 내가 삭제한 채팅 → 나에게만 삭제됨
  • 서로 다른 10명 이상이 같은 톡을 신고하면 → 그 톡 작성자는 해당 채팅방에 더 이상 글을 쓸 수 없음

이를 위해 TalkReport 엔티티를 만들었다. talk_id, reporter_id를 FK로 가진다.

 

Pagination전략: Cursor 기반을 선택한 이유

채팅은 무한스크롤 구조에 가장 적합하기 때문에 offset보다 cursor 기반 페이지네이션이 적합하다.

스프링에서 페이지네이션을 구현하는 방법은 두 가지가 있다. offset-based pagination, cursor-based pagination이다.

  1. Offset-based Pagination
    쇼핑몰에서 자주 볼 수 있는 전통적인 페이징 방식이다. 페이지 번호 기준으로 데이터를 가져오며, 사용자가 1페이지, 2페이지처럼 페이지를 클릭하면 해당 페이지에 해당하는 데이터를 조회한다. 쿼리가 상대적으로 단순하다는 장점이 있다.

    다만 한 가지 단점이 있는데, 예를 들어 2페이지를 조회할 때는 1페이지+2페이지 데이터를 모두 읽고, 그중에서 2페이지에 해당하는 데이터만 사용하는 경우가 많다. 페이지가 뒤로 갈수록 “앞 페이지까지의 데이터”를 계속 같이 읽어야 하므로, 전체적으로 읽어야 하는 데이터 양이 점점 많아지고 그만큼 성능이 떨어진다.

  2. Cursor-based Pagination
    Cursor 기반 페이지네이션은 무한 스크롤 방식에 적합한 방식이다. 우리가 보통 채팅방에서 위로 스크롤하면 예전 대화가 자연스럽게 이어서 불러와지는 방식이 여기에 해당한다.

    이 방식을 사용하려면 클라이언트가 커서 값(마지막으로 받은 created_at 또는 id)을 어딘가에 기억하고 있다가, 다음 요청 시 이 값을 함께 보내야 한다. 그러면 DB는 그 커서 값을 기준으로 인덱스를 바로 타고 들어가 “여기 이후 데이터부터 가져와라”는 식으로 동작하게 되고, 덕분에 페이지가 뒤로 가더라도 일정한 성능을 유지할 수 있다.

    나도 처음엔 이게 실제로 언제 호출되는 건지 잘 이해되지 않아서 안드로이드 기준 구현 방식을 찾아봤다. 보통은 RecyclerView나 ListView에 스크롤 리스너를 달고, 스크롤 위치를 계속 체크하다가 “마지막 아이템에서 N개 이내로 가까워졌을 때” 다음 페이지 API를 호출한다. 예를 들어 리스트에 아이템이 100개 있다면, 90번째 아이템이 화면에 보이는 시점에 다음 데이터를 요청해서 자연스럽게 이어 붙이는 식으로 동작한다.

 

Slice vs Page

Spring data JPA에서는 Pagination을 위해 두 가지 객체를 제공한다. Page, Slice이다.

public interface MemberRepository extends JpaRepository<Member, Long> {
    Slice<Member> findSliceBy(final Pageable pageable);
		Page<Member> findPageBy(final Pageable pageable);
}

 

Slice

Slice는 Streamable을 상속받는 인터페이스로 Pagination 관련된 여러 메서드를 갖고 있다.

Slice는 Page보다 가벼운 형태의 페이징 방식이다. 조회 쿼리만 실행하고, 전체 개수를 세는 쿼리를 따로 수행하지 않는다. 대신 현재 페이지의 데이터가 꽉 찼는지 여부, 다음 페이지가 있는지 여부 등을 기반으로 페이징 동작을 결정한다.

데이터 양이 많을수록 count 쿼리를 생략하는 Slice 방식이 성능상 훨씬 유리하다. 특히 무한스크롤이나 커서 기반 페이징처럼 전체 개수가 굳이 필요 없는 경우는 slice가 적합하다.

 

아래는 참고용 slice 인터페이스

public interface Slice<T> extends Streamable<T> {
    int getNumber(); // 현재 페이지
    int getSize(); // 페이지 크기
    int getNumberOfelements(); // 현재 페이지에 나올 데이터 수
    List<T> getContent(); // 조회된 데이터
    boolean hasContent(); // 조회된 데이터 존재 여부
    Sort getSort(); // 정렬 정보
    boolean isFirst(); // 현재 페이지가 첫 번째 페이지인지 여부
    boolean isLast(); // 현재 페이지가 마지막 페이지인지 여부
    boolean hasNext(); // 다음 페이지 여부
    boolean hasPrevious(); // 이전 페이지 여부
    Pageable getPageable(); // 페이지 요청 정보
    Pageable nextPageable(); // 다음 페이지 객체
    Pageable previousPageable(); // 이전 페이지 객체
    <U> Slice<U> map(Function<? super T, ? extends U> convert); // 변환기
}

Page

Page는 Slice를 상속하는 구조라 Slice가 가진 기능을 모두 사용할 수 있다. 다만 Page는 조회 결과를 가져온 뒤에 전체 데이터 개수(count)를 조회하는 쿼리가 한 번 더 실행된다는 점이 가장 큰 차이점이다.

이 추가 쿼리 덕분에 전체 데이터 개수, 전체 페이지 수 등 페이징 UI를 그릴 때 필요한 정보를 모두 얻을 수 있다.

하지만 count 조회 비용이 생각보다 크기 때문에, 데이터가 많은 경우에는 성능 부담이 생긴다.

 

 

난 위의 두 방식을 비교하고 처음엔 Page로 구현을 시작했다.

 

팀원 밍트가 Slice 기반 무한스크롤에 대한 힌트를 줘서 찾아보았다.

결국 전체 개수(count)가 필요하지 않은 채팅 무한스크롤에서는 Slice가 불필요한 쿼리를 줄여 훨씬 효율적이라는 판단을 내렸다.

다만 Slice를 쓸 때 가장 유의해야 하는 점은, cursor로 사용할 컬럼에 인덱스가 있어야 한다는 것이다.

그래서 Talk의 cursor id로 PK인 talk_id를 사용했다.

 

Slice 기반 Service 코드

    public TalkCursorResultResponse findTalksExcludingReported(
            final long gameId,
            final Long cursorId,
            final int limit,
            final long memberId
    ) {
        Pageable pageable = PageRequest.of(0, limit);
        Slice<TalkResponse> talkResponses = getTalkResponses(gameId, cursorId, memberId, pageable);

        Long nextCursorId = getNextCursorIdOrNull(talkResponses.hasNext(), talkResponses);
        List<TalkResponse> hiddenReportedTalks = hideReportedTalks(talkResponses.getContent(), memberId);

        CursorResultParam<TalkResponse> cursorResultParam = new CursorResultParam<>(hiddenReportedTalks, nextCursorId,
                talkResponses.hasNext());

        return new TalkCursorResultResponse(cursorResultParam);
    }

 

이 코드는 내가 신고한 talk을 제외한 모든 talk을 조회하는 서비스로직이다.

Pageable 객체를 통해 slice한 talk 리스트를 가져오게 된다.

원래 Page 객체를 사용해 응답을 받았을 때는 nextCursor의 유무를 판단하고 계산하는 과정이 수동으로 필요했지만, Slice 방식으로 구현하면서 hasNext()로 다음 커서 여부를 자동으로 알 수가 있어 Page때처럼 직접 계산할 필요가 없어졌다. 👍🏽

 

Talk 쿼리 성능 개선 기준

모바일 앱의 속도는 사용자 경험을 결정하는 아주 중요한 요소다. 여러 글로벌 기업의 리서치 자료를 찾아봤다.

  1. Jakob Nielsen – “Response Times: 3 Important Limits”
    https://www.nngroup.com/articles/response-times-3-important-limits/
    • 0.1초 이내: 사용자가 즉각적 반응으로 인식 → UI 조작이 자연스럽게 느껴지는 한계
    • 1초 이내: 사용자 흐름(flow)의 끊김 없이 작업 가능 → 지연 인식 시작하지만 집중은 유지됨
    • 10초 이상: 주의 집중이 끊어지고 대체 행동 가능성 높음
  2. Google – “Speed Matters” 등 사례
    https://research.google/blog/speed-matters/
    • 검색결과 페이지에서 100~400 ms 지연만으로도 사용자 검색량이 약 0.2%-0.6% 감소했다는 실험 결과 존재
    • 페이지 로딩이 3초를 초과하면 모바일 사용자 이탈률이 급격히 증가하는 것으로 나타남
  3. 웹.dev – Why Speed Matters
    https://web.dev/learn/performance/why-speed-matters?hl=ko
    • 빠르게 로딩되고 사용자 입력에 즉각 반응하는 사이트가 그렇지 않은 사이트보다 사용자 유지·참여도가 높다라는 설명

위의 내용을 합쳐보면, 사용자가 "빠르다"라고 느끼는 기준이 꽤 명확하게 나뉜다.

0.1초 이하의 응답 속도는 사용자에게 쾌적한 경험을 제공한다. 반면 200ms(0.2초) 정도만 넘어가도 기다린다라는 심리적 체감이 시작되고, 1초가 넘어가면 확실히 느려졌다는 인식이 생긴다.

 

따라서 야구보구의 현장톡 쿼리 개선 기준을 200ms로 잡았다.

Talk에 인덱스 걸기

현장톡의 무한 스크롤 기능을 Slice 기반으로 구현하면서, 가장 중요하게 생각한 부분이 바로 인덱스 최적화였다.

Slice 방식은 인덱스를 제대로 타야 의미가 있는데, 초기에는 인덱스 필요성을 잘 몰라서 아무 설정 없이 개발을 진행하고 있었다. 이후 페이지네이션에 대해 공부를 더 하면서 인덱스가 반드시 필요하다는 걸 깨닫고 성능 차이를 직접 확인해보기로 했다.

 

테스트는 dev 서버에서 진행했다.

기본 데이터는 게임 1개당 10만 개의 Talk, 총 10개의 게임에만 삽입해서 총 채팅은 100만(1,000,000)개의 Talk이다.

 

MySQL EXPLAIN ANALYZE는 쿼리를 딱 한 번 실행한 결과만 보여주기 때문에 딱 한 번 실행했을 때 걸린 시간이다.

 

테스트 결과 요약

  • 톡 생성
    • avg 30ms
  • 톡 조회
    • avg 1.258s
  • 최신 톡 조회
    • avg 4.5s
  • 톡 삭제
    • avg 0.09ms
  • 톡 신고
    • avg 0.2ms

이 중에서 가장 시간이 많이든 쿼리는 우리가 정한 한도였던 200ms를 넘는 톡 조회최신 톡 조회였다.

둘 다 비슷한 형태인데 왜 차이가 날까?

 

들었던 의문

톡 조회, 최신 톡 조회는 뭐가 그리 다르길래 이렇게 성능 차이가 나는가?

두 쿼리가 겉보기엔 비슷하지만

 

1. 톡 조회의 EXPLAIN ANALYZE를 보면

-> Limit: 10 row(s)  (cost=1.01e+9 rows=10) (actual time=1258..1258 rows=10 loops=1)
    -> Filter: ((t1_0.game_id = 1) 
               and (t1_0.deleted_at is null) 
               and (t1_0.talk_id < 1000000))  
       (cost=1.01e+9 rows=4977) 
       (actual time=1258..1258 rows=10 loops=1)
        -> Index range scan on t1_0 using PRIMARY 
           over (talk_id < 1000000) (reverse)  
           (cost=1.01e+9 rows=497708) 
           (actual time=0.0858..1193 rows=900009 loops=1)

 

대략 1.258초 정도 걸린다.

talk_id < 1000000 조건으로 pk 인덱스를 역순으로 스캔한다.

그 범위에서 약 90만 row를 읽으면서 그 중에서만 game_id = 1 AND deleted_at is null 만족하는 row 골라서 10개 찾는 형태이다.

거의 테이블 전체(약 90만개)를 뒤져야 하는 것이다.

 

2. 최신 톡 조회의 EXPLAIN ANALYZE를 보면

-> Limit: 11 row(s)  (actual time=4555..4555 rows=11 loops=1)
    -> Filter: game_id = 1 and deleted_at is null and talk_id > 2
       (actual time=4555..4555 rows=11 loops=1)
        -> Index range scan on t1_0 using PRIMARY
           over (2 < talk_id) (reverse)
           (actual time=1.97..4483 rows=990011 loops=1)

 

한 번 실행에 4.55초나 걸린다. 거의 990,011 row를 스캔하고 있다.

Index range scan on t1_0 using PRIMARY over (2 < talk_id) (reverse)(cost=46573 rows=502326) (actual time=1.97..4483 rows=990011 loops=1)

 

여기서 중요한 지점은, MySQL이 (game_id, deleted_at, talk_id) 같은 복합 인덱스를 사용하지 않고 PK(talk_id) 하나만으로 조회를 수행했다는 점이다. 그 결과 실제 실행 계획은 이렇게 동작했다.

 

talk_id > 2라는 조건이 들어가면, MySQL은 PK 인덱스를 기준으로 talk_id가 2보다 큰 모든 row를 역순으로 스캔한다. 그리고 각 row마다 game_id = 1인지, deleted_at IS NULL인지 체크하면서 조건에 맞는 11개를 찾을 때까지 계속 읽는다. 거의 전체 테이블을 훑는 셈이라 시간이 크게 늘어날 수밖에 없다.

 

내가 처음 실행했던 EXPLAIN ANALYZE는 기준 talk_id를 2로 잡아버렸기 때문에, talk_id가 2보다 큰 모든 톡 중에서 조건 맞는 최신 11개를 조회하는 상황이었다. 이 말은 곧 거의 100만 건에 가까운 row를 전부 읽어보라는 뜻이라서 4.5초까지 튄 것이다.

 

물론 운영 환경에서는 이 정도로 극단적인 조건이 거의 나오지 않는다. 사용자 클라이언트는 보통 마지막으로 읽은 talk_id를 기준으로 최신 톡을 불러오는데, 이 값이 대부분 999980 같은 최신 근처다.

그러면 조건이 talk_id > 999980처럼 매우 좁은 범위가 되고, 실제로 읽어야 하는 row 수도 훨씬 적기 때문에 조회 속도는 훨씬 빨라진다.

 

그럼에도 불구하고 조회 쿼리가 가장 병목이 될 가능성이 높다고 판단했다. 채팅 특성상 특정 경기(game_id)의 메시지만 필터링하는 조회가 대부분이고, 삭제되지 않은 메시지(deleted_at IS NULL) 조건까지 항상 붙기 때문이다. 채팅 데이터는 시간이 지날수록 빠르게 누적되기 때문에, 최신 메시지를 talk_id DESC로 정렬해 가져오는 패턴 또한 가장 자주 일어난다.

 

이런 조회 패턴을 종합하면, 인덱스는 (game_id, deleted_at, talk_id) 순서가 가장 효율적이다.

game_id로 먼저 범위를 좁히고, 그 안에서 deleted_at이 NULL인 값만 바로 골라낸 뒤, talk_id 순서대로 최신 메시지를 정렬 없이 바로 읽어올 수 있기 때문이다.

 

인덱스 걸었는데 사용을 안 함

 

인덱스는 잘 걸어두었으니 “이제 톡 조회 쿼리의 explain analyze 결과도 확 줄겠지?” 하고 다시 실행해봤다. 그런데 결과는 의외였다.

-> Limit: 10 row(s)  (actual time=653..653 rows=10)
    -> ...
        -> Index range scan using PRIMARY over (talk_id < 1000000) (reverse)
           (actual time=0.0879..590 rows=900009)

 

계속 PRIMARY(talk_id) 인덱스만 타고 있었고, 성능도 절반 정도 줄었을 뿐 기대했던 수준까지는 나오지 않았다. 왜 MySQL은 방금 만든 새로운 인덱스를 안 쓰는 걸까?라는 의문이 들었다.

 

 

우리의 쿼리 조건은 아래와 같다.

where
    t1_0.game_id = 1
    and t1_0.deleted_at is null
    and t1_0.talk_id < 1000000
order by
    t1_0.talk_id desc
limit 10;

 

이때 MySQL 입장에서 선택지는 두 가지다. 

 

1. PRIMARY(talk_id) 인덱스로 스캔

  • talk_id < 1000000 범위를 역순으로 훑으면서
  • 그 중에서 game_id = 1 AND deleted_at IS NULL 조건에 맞으면 선택
  • 이렇게 10개 찾으면 멈춤

2. (game_id, deleted_at, talk_id) 복합 인덱스로 스캔

  • game_id = 1 AND deleted_at IS NULL 구간만 타고 들어가서
  • 그 안에서 talk_id < 1000000 범위를 보며 10개 찾기

개발자 입장에선 2번이 훨씬 좋아 보이지만, mysql의 cost model이 현재 통계 기준으로는 1번이 더 싸다고 착각 중이다. 🤪

 

왜 MySQL은 PK 인덱스를 더 싸다고 믿을까?

착각하는 이유 1: talk_id < 1,000,000이 거의 전체라서

MySQL은 인덱스 고를 때 가능하면 정렬을 인덱스 자체로 해결하고 싶어한다.

PK(talk_id)는 desc 정렬과 맞아떨어지기 때문에 MySQL 입장에서는 어차피 거의 다 읽어야 하니까 그럼 정렬도 바로 되는 PK 인덱스 쓰는게 낫겠다고 판단함

 

착각하는 이유 2: (game_id, deleted_at)의 선택도가 낮게 보임

SHOW INDEX에서 Cardinality를 보면

  • PRIMARY (talk_id) → 989,317
  • idx_talks_game_deleted_talkid_desc
    • game_id → 9 (팀 수)
    • deleted_at → 9 (거의 다 NULL임)
    • talk_id → 999,306

즉, MySQL 입장에서는

  • game_id = 1대략 전체의 1/9 (1/9로 줄여도 10만개 남으니까 좁혀지는 정도가 약하다고 생각)
  • deleted_at IS NULL → 거의 다 NULL이면 추가 필터 효과 없다고 느낄 수 있음
  • talk_id < 1000000 → 거의 전체 범위

그래서 MySQL은 복합 인덱스 타도 어차피 많이 읽을텐데, 걍 PK 쓰면서 ORDER BY도 공짜로 해결하자는 심산.

 

나는 그래서 FORCE INDEX로 복합 인덱스를 강제로 태워서 비교해봤다.

FORCE INDEX (idx_talks_game_deleted_talkid_desc)

 

그 결과 실제 실행시간이 654ms → 1.1ms가 나왔다.

Index range scan using idx_talks_game_deleted_talkid_desc
over (game_id = 1 AND deleted_at = NULL AND talk_id < 1000000) (reverse)
(actual time=1.08..1.08 rows=10)

 

즉, 인덱스 타기 전엔 10개 찾기까지 90만 row를 훑어야 해서 0.6초가 걸렸다면,

복합 인덱스를 타면 game_id=1 AND deleted_at IS NULL 구간으로 바로 들어가서 talk_id 역순으로 10개 읽고 끝.

 

결론

MySQL은 cost 계산을 잘못해서 PRIMARY(talk_id)로 훑는 게 더 싸다고 판단했지만,

실제로는 복합 인덱스가 약 600배 빠른 결과를 냈다.

 

인덱스 설계의 영향이 이렇게 크다. 🤙🏽

실제로는 인덱스 하나가 서비스 전체 성능을 좌우할 수 있다는 점을 다시 한 번 경험한 사례였다.

다음 글에서는 현장톡의 좋아요 광클 기능을 어떻게 배치 기반으로 구현했는지 이어서 써보겠다.