[O+T] 영상 이어보기 처리량 개선하기

0. 개요

유튜브나 넷플릭스, 혹은 다른 OTT 서비스에서 영상을 시청하면 아래와 같이 어디까지 봤는지 그 지점을 표시해줍니다.

이렇게 시청 전에 재생 지점을 보여주는 것뿐만 아니라, 이전에 시청했던 영상을 다시 볼 때 마지막으로 봤던 지점부터 이어 볼 수 있는 기능을 제공합니다.


특히, 사용자가 영상을 시청하는 동안 일정 주기로 현재 재생 위치를 갱신해야 하기 때문에 같은 사용자가 같은 영상에 대해 짧은 간격으로 반복 요청을 보내는 구조가 됩니다. 또한, OTT 서비스에서 사용자는 영상 시청에 대부분의 시간을 사용하므로 자연스레 트래픽이 가장 몰리는 지점이 됩니다.

 

이 요청은 같은 사용자와 같은 영상에 대해 짧은 간격으로 반복 호출된다는 특징이 있습니다. 따라서 일반적인 조회와 다르게 반복적인 write 요청을 어떻게 더 효율적으로 처리할 것인지의 관점에서 접근했습니다.

 

이어보기 갱신 작업이 트래픽을 더 잘 견디도록 개선한 과정을 정리해보겠습니다.

 

 

1. 테스트 결과

많은 트래픽이 예상되는 지점이므로 K6 부하 테스트를 진행해봤습니다.

각 VU가 이어보기 지점을 5초마다 갱신하는 /playlist API를 호출합니다.

VU2000부터 평균 응답 시간이 매우 높아지는 것을 볼 수 있고, VU3000이상은 에러율이 너무 높아서 기존 상태에서 사실상 무의미했습니다. 처리량 역시 기대에 미치지 못했습니다.

  • 요청 스레드 대기 증가
  • DB Connection 부족
  • GC

등이 주요 병목 원인으로 예상되었고, Grafana를 살펴봤습니다.

우선 VU2000~부터는 지표 수집 자체가 잘 되지 않았고, 로그에서 request fail이 많이 발생한 것을 확인했습니다. 이는 요청 스레드의 대기가 증가해서 요청 자체가 실패한 것으로 파악되었습니다.

 

서버 스펙이 작아서 발생하는 것이기도 하지만, 병목 지점이 있기 때문에 요청-응답의 순환이 원활하지 않은 것이 근본적인 문제였습니다. DB Connection 부족이 발견되었고, 쿼리 자체의 속도 혹은 쿼리 개수로 문제를 좁혔습니다.

 

타임아웃은 없었지만 pending 상태가 70개를 넘어서는 것을 통해 쿼리 자체의 속도보다는 쿼리 개수가 너무 많아서 발생하는 병목으로 확인했고, 실제 로직에서 사용하는 쿼리 또한 간단했기에 쏟아지는 트래픽에서 쿼리 수를 줄이는 방향으로 잡았습니다.

 

컴퓨터 자원의 부담을 최소화한 상태에서 응답 시간을 줄이고 처리량을 향상시키는 것을 목적으로 진행했습니다.

 

 

2. Read: 이어보기 캐시 도입

2-1. 이어보기 지점 갱신 로직

먼저, 이어보기 기능을 담당하는 Playback 갱신 API의 로직을 살펴봤습니다.

Select문으로 mediaId에 대한 예외 처리를 먼저 하고 contentsId를 가져온 후 Playback에 대한 Upsert를 진행합니다.

 

우선 쿼리를 단순화하고자 매번 Upsert가 아닌 플레이어 화면 진입 후 첫 재생 시 Insert, 영상 재생 중 update API로 분리했습니다.

 

Playback 갱신 요청은 사용자가 영상을 시청하는 동안 일정 주기로 반복해서 호출됩니다. 한 번 호출되고 끝나는 요청이 아니라 같은 사용자와 같은 영상에 대해 짧은 간격으로 계속 들어오는 write 요청입니다. 이런 요청 경로에서 기존 구조는 성공 요청마다 항상 두 번의 데이터베이스 접근이 필요했습니다.

 

한 번의 요청만 보면 큰 차이가 없어 보일 수 있지만, 이 요청은 반복 호출되기 때문에 요청당 데이터베이스 접근이 한 번 더 필요하다는 것이 부담된다고 생각했습니다.

특히, 대부분인 성공 요청도 체크를 위해 항상 먼저 조회를 수행해야 했고, 아래과 같은 아쉬움이 있었습니다.

  • 성공 경로에서도 DB round trip이 2번 발생한다.
  • 반복적인 write 요청 경로에서 select 비용이 누적된다.

 

2-2. 단일 쿼리 통합을 선택하지 않은 이유

처음에는 기존 SELECT + UPSERT 흐름을 하나의 SQL로 합치는 방향을 생각했습니다. INSERT ... SELECT ... ON DUPLICATE KEY UPDATE 형태로 mediaId -> contentsId 변환, 재생 가능 여부 확인, playback upsert를 하나의 경로로 묶는 방식입니다.

INSERT INTO playback (member_id, contents_id, position_sec, created_date, modified_date, status)
SELECT :memberId, c.id, :positionSec, NOW(), NOW(), 'ACTIVE'
FROM contents c JOIN media m ON m.id = c.media_id
WHERE m.id = :mediaId AND c.status = 'ACTIVE' ...
ON DUPLICATE KEY UPDATE position_sec = :positionSec, ...

이 방식은 동기 write의 경우 장점이 있습니다.

  • 성공 경로의 DB 왕복을 줄일 수 있다.
  • 조회와 저장을 하나의 경로로 묶을 수 있다.

하지만 이번 작업은 반복적으로 들어오는 write 자체를 버퍼링하고, 이후 bulk로 처리하는 구조가 필요했습니다.

int affectedRows = playbackRepository.updatePlaybackByMediaId(memberId, mediaId, positionSec);
if (affectedRows > 0)
	return;

if (!contentsRepository.existsPlayableByMediaId(mediaId)
	throw new BusinessException(ErrorCode.CONTENTS_NOT_FOUND);

 

이를 기준으로 보면 단일 쿼리 통합에는 한계가 있었습니다.

  • 잘못된 mediaId에 대해 즉시 실패 응답을 주기가 어려움
    • 버퍼에 적재한 뒤 바로 204를 반환하면 검증이 DB write 시점으로 밀리는 순간 잘못된 요청도 일단 성공처럼 보이게 됨
  • bulk upsert의 복잡함
    • 나중에 버퍼에 쌓인 데이터를 한 번에 반영할 때는 검증과 변환이 붙은 무거운 쿼리보다 필요한 데이터만 빠르게 반영하는 쿼리가 더 적합함
    • Upsert -> Update 변경 후에도 결국 조인이 필요함

따라서 검증 + 저장을 하나의 SQL로 묶는 방식보다 검증은 앞단에서 빠르게 처리하고, 저장은 뒤로 미루는 구조가 더 자연스럽다고 판단했습니다.

 

2-3. 캐시 도입

갱신 요청 외에도 해당 요청이 유효한지 확인하기 위해 MediaID를 검증하는 과정에서 지속적으로 DB Connection을 사용하여 리소스를 낭비하고 있었습니다. 이때 캐시를 도입하여 DB Connection 사용을 최소화하는 방법으로 개선했습니다. 영상 최초 진입 시에만 쿼리를 날리고, 그 다음 요청부터는 캐시에 접근합니다.

  • mediaId가 재생 가능한 대상인지 먼저 확인한다.
  • 이 검증 결과를 캐시에 저장한다.
  • 이후 요청에서는 DB 조회 없이 캐시로 검증한다.
  • 검증을 통과한 요청만 갱신 작업을 진행한다.

크게 아래 세 가지 이유로 캐시를 선택했습니다.

 

자주 변경되지 않고 조회가 빈번하다

미디어는 관리자 페이지에서만 수정이 가능하고, 변경이 매우 드문 특성을 가지고 있습니다. 반면, 이어보기 갱신은 영상을 시청하고 있는 모든 사용자로부터 5초마다 요청이 발생하여 같은 데이터를 반복적으로 조회하게 됩니다.

 

미디어 수정은 관리자 기능을 통해 제한적으로 발생하므로, 수정 시점에 해당 캐시를 갱신하거나 삭제하는 방식으로 데이터 정합성 관리가 비교적 단순하다고 판단했습니다.

 

잘못된 요청에 대해 즉시 실패 응답을 줄 수 있다

버퍼링 구조에서는 요청을 받은 뒤 바로 204를 반환하는 쪽이 자연스럽습니다.

 

이때 검증까지 뒤로 미뤄버리면 존재하지 않는 mediaId나 재생 불가능한 콘텐츠에 대해서도 일단 성공 응답을 주게 됩니다.

반면, 앞단에서 캐시 기반 검증을 수행하면 재생 가능한 대상인지 먼저 판별한 뒤에만 버퍼에 적재할 수 있습니다.

  • 유효한 요청 -> 204
  • 잘못된 요청 -> 404

를 바로 구분할 수 있습니다.

 

bulk update를 단순한 저장 쿼리로 유지할 수 있다

검증 단계에서 contentsId까지 확정해 두면, flush 시점에는 더 이상 mediaId -> contentsId 변환이나 상태 검증이 필요하지 않습니다.

 

버퍼를 비울 때의 SQL은 단순해집니다.

  • (memberId, contentsId, positionSec, ...)으로 Update
  • Update 내 Join 없음

무거운 검증 로직을 다시 요청 시점으로 끌어오고, bulk update는 최대한 저장 전용 쿼리로 단순화할 수 있습니다.

 

2-4. 로컬 vs 글로벌 캐시

로컬 캐시를 사용할지 글로벌 캐시를 사용할지 결정해야 했는데, 다중 서버 환경임에도 로컬 캐시가 적합하다고 생각했습니다. 

  • 속도
    • 글로벌 캐시는 매 요청마다 네트워크를 왕복해야 하지만, 로컬 캐시는 바로 응답 가능
  • 캐시 장애 시 DB 부하
    • 별도 캐시 서버에 장애가 발생하면 검증을 위한 Select 쿼리가 매 요청마다 DB에 발생
  • 추가 인프라 및 관리의 어려움
  • Media 데이터 변경에 민감하지 않음
    • 메타 데이터 변경이 빈번하지 않으며 Select 검증 로직에서 상태를 체크하지만, 이 값들이 변경되더라도 즉시 반영되어 playback 갱신을 막을 필요는 없음. 캐시 데이터의 일관성을 완벽히 유지하지 않고, TTL로 충분히 허용 가능한 수준.

 

2-5. 문제

정합성 문제

어떤 media가 PUBLIC에서 PRIVATE로 바뀌거나, COMPLETED 상태가 아닌 값으로 바뀌는 경우 캐시에 이전 상태가 남아 있으면 재생 불가능한 대상임에도 검증을 통과시킬 수 있습니다. 변경 후 이어보기 갱신은 큰 문제가 없기 때문에 짧은 TTL로 충분히 관리 가능하다고 보았습니다.

 

메모리 관리

미디어 데이터에 대해 각 서버별로 같은 미디어를 캐싱하게 되며, 그 수가 많아질 경우 메모리 관리가 필수적입니다. 

  • 적절한 TTL 설정
  • 캐싱 미디어 수 제한
  • 키-값 크기 최소화

Grafana에서 Heap을 보며 Promotion, GC가 얼마나 일어나는지 체크해야 합니다.

 

2-6. 결론

로컬 캐시로 Caffeine을 사용했고, 아래 그림처럼 검증에 캐시를 두어 Select를 최소화했습니다.

구분 기존 흐름 개선 후 흐름
정상 요청 SELECT -> UPDATE 캐시 체크 -> Update
실패 요청 조회 단계에서 예외 캐시 확인 후 예외

 

 

3. Write: 문제 인식

캐시로 Select를 줄였으나, 부하 테스트 결과에는 큰 변화가 없었습니다. 여전히 이어보기 갱신 요청마다 Update 쿼리를 수행했고, 이로 인한 DB 접근이 너무 잦았기 때문입니다.

 

캐시 후에도 눈에 띄는 개선이 없는 이유

여전히 Update 자체가 요청 수만큼 발생하기 때문에 요청 스레드가 매번 DB Update에 묶여, DB Connection이 금방 부족해졌습니다. 기본값인 이 설정을 늘리는 방법도 있지만, 처음부터 늘릴 것이 아니라고 생각해 다른 방식을 찾아보았습니다.

 

동기 Write 구조의 한계

  • DB 접근 대기로 요청 스레드의 대기가 늘어남
  • 커넥션 점유 및 대기 시간동안 요청에 대한 응답 시간이 지연
  • 고부하에서 톰캣 스레드 고갈요청 자체가 실패

위의 이유들로 인해 검증은 캐싱으로, 저장은 미루는 방식을 선택하여 사용자 요청에 대한 응답은 빨리 주는 구조로 변경하는 방안을 선택했습니다.

 

 

4. Write: 버퍼링

버퍼를 두어 요청에 대한 작업을 모아두고 일정량 혹은 일정 주기마다 한 번에 DB에 접근하는 방식으로 변경했습니다.

 

작업 특성

Playback의 특성을 간단히 정리해보자면 아래와 같습니다.

  • 어느 정도의 유실은 허용한다. (이어보기 요청 3~4회 정도의 사이클까지)
  • 즉시 반영되지 않아도 된다.
  • 가장 최근 발생한 요청이 반영되어야 한다.
  • Write 비율이 매우 높다.
  • 사용자 + 콘텐츠 조합으로 구분되며, 하나의 조합으로 지속적인 요청이 들어온다.

 

Step 1. 큐 도입

요청과 DB Write를 분리하기 위해 작업을 큐에 모아 한 번에 갱신합니다. 요청 스레드가 직접 DB에 접근하는 대신 큐에 작업만 넣고 즉시 응답하도록 바꿨습니다. 실제 DB 반영은 별도의 워커 스레드가 뒤에서 처리합니다.

 

로컬 큐 vs 글로벌 큐

캐시처럼 큐도 마찬가지로 로컬이냐 글로벌이냐 결정해야 했고, 큐 외의 다른 자료구조를 쓸 것인지 고민했습니다. 

후보 장점 단점 적합한 상황
Local Queue 네트워크 왕복, 인프라 추가 x 서버 다운 시 큐 작업 유실,
서버 간 순서 보장 필요
빠른 속도 필요, 손실 허용
Redis Write-Behind 다중 서버 공유 간편, 순서 보장 가능 네트워크 왕복 발생, 인프라 추가 다중 서버, 내구성 
In-Memory Map 중복 key 자동 제거 크기 한정 및 원자적 처리 어려움 중복이 많이 발생하는 경우

 

저는 로컬 큐를 선택했습니다. 다중 서버 환경에서 순서 문제가 가장 크게 다가왔지만 충분히 해결할 수 있었고, 글로벌 큐 다운 시 트래픽을 견디기 어려울 것이라 생각했습니다. 또한, Playback 특성 상 주기가 짧다면 중복이 많이 발생하지 않고, 자연스러운 FIFO를 사용하고자 큐를 선택했습니다.

 

LinkedBlockingQueue

큐는 LinkedBlockingQueue를 사용했습니다.

  • bounded: 트래픽이 매우 많아져 큐에 작업이 무한히 쌓일 수 있으므로 큐의 상한을 정했습니다.
  • put/take 락 분리: 큐에 작업을 넣는 쪽과 꺼내는 쪽의 락이 분리되어 있어 락 경합을 줄일 수 있습니다.
public class PlaybackCommandQueue {

    private final BlockingQueue<PlaybackCommand> queue;

    public PlaybackCommandQueue(int capacity) {
        this.queue = new LinkedBlockingQueue<>(capacity);
    }
    // offer(), drain(), size() ...
}

상한은 Bulk Size(1000)보다 큰 10000으로 잡았습니다. 순간적으로 몰리는 경우 Flush보다 더 많은 요청이 들어올 수 있기 때문입니다.

 

큐에 추가하는 함수는 put()이 아닌 offer()를 사용했습니다. 큐에 빈 공간이 없는 경우 put()은 대기합니다. 하지만, 스레드 대기로 인해 트래픽이 늘어날수록 병목이 거대해질 수 있었고, 이어보기는 모든 데이터가 반드시 포함되어야 하는 것은 아니므로 바로 false를 반환하는 offer()를 수행합니다.

 

Step 2. Bulk Update

큐에 모은 PlaybackCommand 객체를 작업 단위로, 하나씩 쿼리를 날리는 것이 아니라 일정 주기마다 Bulk Update를 수행했습니다.

API 요청은 큐에 적재 후 사용자에게 응답하고, 백그라운드 스레드로 큐를 확인합니다. 큐를 보는 스레드는 하나로 고정하여 스레드 낭비를 줄였습니다. Flush 트리거는 두 개로, 큐가 꽉 차거나 정해진 시간(5초 ~)마다 큐를 비우고, 쿼리를 날립니다.

public record PlaybackCommand(
    long memberId,
    long contentsId,
    int positionSec,
    Instant requestedAt
) {
    public PlaybackKey key() {
        return new PlaybackKey(memberId, contentsId);
    }
}

A유저가 M영상에 대한 이어보기를 보내는 것을 구분하고자 member, contents를 key로 잡았습니다.

 

데드락

Bulk Update로 인해 여러 대의 서버에서 Flush를 할 때,  Update 쿼리는 로우 락을 잡기 때문에 순서에 따라 데드락이 발생할 수 있었습니다.

서버 1에서 K1, K2, K3로, 서버 2에서 K2, K1, K3 순서로 Flush를 진행할 때, 위처럼 락이 엇갈릴 수 있습니다.

쿼리를 날리기 전키 기준으로 정렬해 데드락을 방지했습니다.

List<PlaybackCommand> sorted = commands.stream()
    .sorted(Comparator.comparing(PlaybackCommand::memberId)
                       .thenComparing(PlaybackCommand::contentsId))
    .toList();

 

Step 3. 중복 제거

큐에서 작업을 꺼내는 Flush 주기가 사용자 이어보기 지점 갱신 주기보다 길다면 한 번의 Flush 작업에 동일한 키를 가진 Command가 여러 개 존재할 수 있습니다.

이어보기는 '가장 최근 발생'한 것만 DB에 반영되면 되므로 한 번의 Flush에서 중복 키에 대한 비효율을 줄일 수 있었습니다.

그림처럼 큐에 있는 5개의 작업에서 중복을 제거한 후 3개만 Flush 한다면 수행해야 할 작업이 줄어듭니다.

 

하지만, 현재 유실 문제를 최소화하고자 클라이언트로부터 발생하는 이어보기 요청 주기와 Flush 주기를 5초로 같게 설정했기 때문에 하나의 큐에 동일한 키의 작업이 존재하지 않습니다.

 

이는 트래픽이 더 높아져, 클라이언트가 작업을 모아서 주거나, Flush 주기를 늘리게 되는 경우 더욱 최적화할 수 있는 방안입니다.

 

Step 4. 순서 보장

앞서 본 것처럼 이어보기는 '가장 최근 발생'한 것이 DB에 반영되어야 하는데, 여러 서버의 각 큐에서 동시다발적으로 Flush가 발생하면 요청이 발생한 순서와 DB에 반영되는 순서가 어긋날 수 있습니다.

13:00:01  사용자 A → 서버 2 큐: key=(42, 100), position=100, event_time=13:00:01
13:00:06  사용자 A → 서버 1 큐: key=(42, 100), position=200, event_time=13:00:06

13:00:08  서버 1 Flush → UPDATE playback SET position_sec=200
                         WHERE member_id=42 AND contents_id=100   → DB position=200 ✅
13:00:10  서버 2 Flush → UPDATE playback SET position_sec=100
                         WHERE member_id=42 AND contents_id=100   → DB position=100 ❌

사용자 A가 200초 지점까지 봤는데, DB에는 100초가 마지막으로 기록되는 문제가 발생할 수 있습니다.

 

5초 주기로 이어보기 지점 갱신을 하니까 괜찮을 수 있다고 생각했지만, 사용자가 직접 바를 눌러서 다른 지점으로 이동하여 시청하는 경우 불편함이 있을 것이라고 생각했습니다.

 

Event_Time 컬럼 추가

요청이 발생한 시각을 데이터베이스에 함께 저장하고, 더 오래된 요청은 무시하는 방식으로 해결했습니다.

playback 테이블에 event_time 컬럼을 추가하고, Update 조건으로 event_time을 확인하도록 구성했습니다.

UPDATE playback
SET position_sec = ?, modified_date = NOW(), event_time = ?
WHERE member_id = ? AND contents_id = ? AND ...
AND event_time < ?

사용자가 직접 영상 시청 지점을 이동할 수 있기 때문에 position으로 정렬하는 방식은 사용할 수 없었습니다.

 

 

5. 결과

개선 작업을 진행하고 다시 부하 테스트를 진행했습니다.

VU2000부터 문제가 보이던 것이 VU5000까지 에러 없이 처리하는 것을 확인했습니다. TPS 또한 VU / 5초 정도로 예상치만큼 요청을 처리하여 최대 761/s까지 나타나며 처리량이 상당이 높아졌습니다.

 

VU10000은 또다시 에러율이 높은데, 이는 요청 자체가 실패하는 경우가 많았습니다. 요청을 받아줄 톰캣 스레드가 부족하여 서버에 들어가지 못했습니다.

 

DB 커넥션

기본값으로 설정해 둔 10개를 넘어 커넥션을 얻기 위해 대기하던 작업 전과 다르게 안정된 커넥션 사용을 보여주고 있었고, DB 커넥션 부족 문제는 해결되었습니다.

 

버퍼

큐 사이즈와 실패 횟수를 메트릭으로 확인했습니다.

큐가 상한까지 차지 않았고, 버려진 요소가 존재하지 않았기에 버퍼의 병목이 크지 않았습니다.

 

Heap

주된 원인은 아니었지만, 힙이 작고 Servivor Space의 크기가 작아서 금방금방 가득 찼습니다. 이로 인해 트래픽이 높은 경우 요청 객체 생성이 많아지며 공간을 많이 차지하고, Major GC가 발생하기도 했습니다. 이는 작은 사이즈로 인한 조기 승격으로 Old 영역에 객체가 금방 많아져 발생한 것으로 파악되었습니다. 힙 사이즈를 키워 어느 정도 해결이 가능했습니다.

 

CPU

vcpu가 2개인 t3.micro 인스턴스로 부하 테스트를 진행하여 낮은 서버 스펙으로 인해 Load 패널에서 코어를 넘어서는 것을 확인할 수 있습니다. 

마찬가지로 그 시점에 CPU 사용률과 스레드 개수, runnable 스레드까지 CPU 할당을 대기하고 있는 것이 매우 많았습니다.

자원을 할당받지 못한 스레드로 인해 결국 요청이 실패하게 되어 VU10000 지점에서는 업그레이드가 필요했습니다. 

 

혹은 요청 주기를 늘리며 이어보기 지점을 클라이언트 단에서도 버퍼링하여 모아서 보내는 방식으로 처리량을 늘릴 수 있을 것 같습니다. 아래 7번 항목에서 이 경우 어떤 식으로 확장 가능한지 더 다루겠습니다.

 

VU10000 구간에서 발생한 실패는 DB나 큐의 병목이 아니라 톰캣 스레드와 CPU 자원 한계였습니다. DB 커넥션은 안정적으로 유지되었고, 큐도 상한에 닿지 않았으며 drop된 요소도 없었습니다. 결국 처음 마주한 "DB 접근이 잦아서 요청 스레드가 묶이는 문제"는 해소되었고, 그 다음 병목은 인스턴스 자체의 처리 한계로 옮겨갔습니다.

 

이 지점은 t3.micro 인스턴스 자체의 vCPU 2개로는 더 받을 여력이 없다는 신호이므로, 요청의 구조를 변경하거나 인프라 업그레이드 또는 수평 확장이 필요했습니다.

 

 

6. 성능 외 문제

정상 흐름 중심으로 진행된 성능 개선과 별도로 문제가 되는 상황들이 있습니다. 여러 문제가 있지만, 사실 playback은 약간의 유실이 허용되는 가벼운? 느낌의 데이터이기 때문에 이를 막고 얻는 이득보다 오히려 복잡도가 높아지는 경우가 많았습니다. 이득과 복잡도를 따져보며 할 수 있는 것은 하되, 너무 복잡하지 않도록 해결하고자 했습니다.

 

6-1. 큐가 가득 찬 경우

트래픽이 몰릴 때 Timeout 전, Flush 동안 큐에 요소가 BulkSize 이상으로 쌓일 수 있습니다. 큐의 bounded를 정해두었기 때문에 큐가 가득찼을 때 요청이 들어오면 어떻게 처리할지 정책을 정해야 했습니다.

 

대안 방식 장점 단점 판단
A. overflowMap 사용 - queue full 시 overflowMap에 key별 최신값 저장
- queue drain과 overflow를 합쳐 batch update
queue full 데이터도 flush 가능 - batch size 예측 어려움
- shutdown 처리 복잡
- unbounded map 관리 필요
보존이 꼭 필요하면 사용
B. 429 응답 queue full 시 클라이언트에 실패 반환 실패를 명시적으로 알림 클라이언트가 backoff 필요 이어보기에 불필요
C. put()으로 blocking queue에 공간이 날 때까지 요청 thread 대기 유실 감소 요청 thread가 병목이 됨 write-behind 목적과 충돌
D. drop + warn queue full 시 command를 버리고 로그/지표 기록 가장 단순, 메모리 안전, 장애 전파 적음 마지막 위치 일부 유실 가능 선택

 

이어보기는 5초 뒤 같은 사용자의 다음 update가 발생하므로 drop + logging을 선택했습니다.

boolean offered = playbackCommandQueue.offer(memberId, contentsId, positionSec);
if (!offered) {
    playbackMetrics.incrementQueueFullDrop();
    log.warn("Playback queue full, dropping command: memberId={}, contentsId={}",
        memberId, contentsId);
}

대신, 메트릭을 수집하여 큐 지표를 확인하고 적절한 상한 및 큐 개수를 산정할 수 있도록 했습니다.

 

 

6-2. Flush 실패

Bulk Update 중 DB 일시 장애, connection timeout 등으로 batchUpdate가 실패할 수 있습니다.

대안 방식 장점 단점 판단
A. drop + warn 실패 batch를 버리고 warn/metric 기록 - worker 지연 없음
- 정책 명확
실패 순간 batch 유실 선택
B. retry 짧게 대기 후 한 번 재시도 deadlock/일시 장애에 효과 가능 retry 동안 worker가 멈추고 queue가 쌓임 실패가 잦은 경우
C. retry buffer 실패 batch를 메모리에 보관 후 재시도 유실 감소 메모리, 순서, shutdown, 중복 처리 복잡 현재 과함
D. WAL flush 전 파일에 기록 후 성공 시 삭제 프로세스 종료에도 복구 가능 파일 I/O, 복구 로직, 중복 처리 필요 Playback에는 과설계

 

이 문제도 마찬가지로 Playback 특성상 처리하고자 한다면 복잡도가 높아졌습니다. DB 장애는 종종 발생하는 일이지만, 대부분의 사용자는 영상에 오랜 시간 머물고, 계속하여 5초마다 요청이 들어오기 때문에 원인을 기록하고 drop하는 방식을 선택했습니다.

 

공부하면서 느낀 건 RabbitMQ나 Kafka에서 처리해주는 것들과 비슷했습니다. 저는 로컬 큐로 이들을 대신하여 구현했기 때문에 직접 처리해야 했고, 처리 정책이나 방식의 결정에 대해서는 메시지 브로커를 사용했던 프로젝트와 결이 비슷했습니다.

 

 

6-3. Graceful Shutdown

서버가 정상 종료될 때, 메모리에 남아 있는 작업들에 대한 처리는 어떻게 할지 고민했습니다. 큐에 남은 command를 그냥 버리면 마지막 5~10초 분량의 모든 사용자 위치가 사라집니다. Graceful Shutdown을 통해 정상 종료 경로에서 남은 작업을 최대한 반영하고 종료하는 정책을 만들었습니다.

앞선 결정들처럼 굳이?하면서 버릴 수 있었지만, 이는 충분히 예측과 처리가 가능한 문제였기 때문입니다.

 

Spring의 graceful shutdown을 켜서, 진행 중인 요청이 끝날 시간을 설정했고,

server:
  shutdown: graceful
spring:
  lifecycle:
    timeout-per-shutdown-phase: 30s

 

워커가 @PreDestroy 시점에 직접 큐를 비웁니다

@PreDestroy
void shutdown() {
    running = false;          // 1. 새 flush loop 진입 차단
    waitForLeaderToStop();    // 2. 진행 중인 flush가 끝나길 대기
    drainRemainingQueue();    // 3. 남은 큐 flush
}

 

주의할 점은 서버를 끌 때 kill -15를 사용하면 안 된다는 점입니다. SIGKILL 시그널이 발생하여 즉시 강제 종료하므로 설정한 작업이 수행되지 않고 바로 종료될 수 있습니다.

 

kill -9를 사용하여 SIGTERM을 주거나 kill -2로 SIGINT를 통해 프로세스를 종료해야 Graceful Shutdown이 수행됩니다.

 

 

7. 나아가

5초 주기로 발생하는 Playback에 대해서는 현재까지의 개선 작업으로 충분하다고 생각하지만 더 높은 부하, 정책 변경 혹은 아예 다른 Log나 데이터 사용량같은 Write 트래픽이 쏟아지는 경우에는 현재 구조에서 더 발전시킬 수 있습니다. 이에 대해 생각해 본 내용들을 간단히 적어보겠습니다.

 

7-1. 클라이언트 버퍼링

클라이언트에서 요청을 모아서 주는 방식입니다.

현재는 5초 주기로 요청하는데, 이를 30초로 변경하고, 한 번에 6개씩 모아서 List로 요청합니다.

요청 횟수 자체가 현저히 줄어들지만, 그만큼 장애 시 유실 범위(5초 -> 30초)가 커진다는 단점이 있습니다. 텀을 두고 주기적으로 발생하는 Playback보다는 Write가 제한 없이 몰리는 구조에서 유용합니다.

 

백엔드보단 프론트에서 처리해줘야 하는 영역입니다.

 

7-2. Queue의 Lock이 병목일 수도

LinkedBlockingQueue를 쓰는 상황에서 위 방식을 사용하면 큐에 작업을 넣기 위한 락 경합이 심해질 수 있습니다. 요청 스레드 간 경합 + 하나의 스레드가 리스트를 순회하며 여러 요소를 하나씩 넣기 때문입니다. 

Producer - Consumer 구조를 통해 큐에 넣는 스레드를 싱글 스레드로 제한합니다. Lock이 병목이었다면 큐에 접근하는 스레드를 하나만 두어 락 경합을 없앨 수 있습니다.

 

저는 테스트 결과 큐에 병목이 발생하려면 현재보다 월등히 높은 부하가 필요했기에 Producer Thread를 도입하지 않았습니다.

 

7-3. Multi Queue + Worker Pool

이 모든 것을 종합하고 파이프라인을 확장해 큐를 여러 개 두어 큐에서 작업을 꺼내는 스레드DB Flush 작업을 처리하는 스레드분리하는 방식입니다.

큐를 여러 개 두고, 특정 기준(우리의 경우 member_contents 키가 될 수 있음) 혹은 랜덤으로 큐를 선택합니다. 해당 큐의 Producer Thread(Single Thread)가 큐에 작업을 넣고, Consumer Thread가 해당 작업을 Flush 전용 Worker Pool에 제출합니다. 이렇게 각 스레드들의 작업을 분리하여 락을 없애고, 스케일 아웃을 내부적으로 구성할 수 있습니다.

 

위 그림에선 공간이 부족해서 Worker Pool 하나에서 모든 큐의 작업을 처리하지만, 이 Pool도 격리하여 큐마다 전용 풀을 둘 수 있습니다.

 

 

8. 마무리

최고 TPS가 280/s에 VU2000을 버거워하던 것을 TPS 760/s까지 수용하고, VU5000을 견딜 수 있도록 개선했습니다. 평균 응답 시간도 예측한대로 거의 즉시 완료되는 모습을 확인하기도 했습니다. 고민한 지점이 많았고, 실제로 적용한 것과 그렇지 못 한 것도 많았습니다. 스레드 풀, 락, 싱글 스레드, 큐 등 이러한 구조들 중에서 현재 서비스의 병목 지점에 알맞은 해결책을 과하지 않게 내고자했고, 이 과정에서 공부가 더 많이 되었던 것 같습니다.

 

특히, 다중 서버와 순서가 중요함에도 로컬 큐를 도입하기로 결정한 점이 이전까지의 시도와 달랐습니다. '정말 필요한가'를 따져보며 관리의 복잡성을 줄이면서 문제가 발생하지 않도록 구성하고자 했고, 역시 우리가 해결하려는 문제가 무엇인지 정확히 인식하는 것이 중요했습니다.

 

이 작업을 거치며 크게 두 가지를 배웠습니다.

 

기본 지식과 원리를 공부하면서도 이것이 나에게 어떤 쓸모가 있을까 고민한 적이 있습니다. 이번에 큐 구현 및 이로 인해 발생하는 문제들의 해결법을 찾으며 이미 만들어진 메시지 큐들로부터 힌트를 얻을 수 있었습니다. 사용법에 더해 내부 원리를 이해하며 응용할 수 있도록 학습하는 것이 정말 필요하다고 생각했습니다.

 

그리고 의심되는 지점에 대한 가설을 세우고 지표로 확인하는 것이 중요했습니다. 이전까지는 예측한 범위 내에서 실제로 문제가 발생했고, 이번에도 처음에는 그렇게 접근했습니다. 하지만, 락으로 인한 큐 병목을 예상해서 확장된 버전으로 개선을 했었는데 효과가 없었습니다. 그제서야 테스트 및 지표를 통해 큐가 병목이 아님을 확인해서 과한 설계를 적용하지 않게끔 돌아올 수 있었습니다.

 

귀찮다고 예측으로 넘겨짚지 말고 기본 지식을 쌓는 것이 중요했습니다!