Post

분산 락 TTL 최적화 - 1시간에서 30초로, 자동 갱신으로 안정성 확보

SSE 스트리밍 시 분산 락 TTL을 1시간에서 30초로 단축하고, 자동 갱신 메커니즘을 추가하여 클라이언트 새로고침 시 빠른 복구를 가능하게 한 경험

분산 락 TTL 최적화 - 1시간에서 30초로, 자동 갱신으로 안정성 확보

문제의 발견

AI 채팅 서비스에서 답답한 버그 리포트가 들어왔다.

“채팅 중에 새로고침했더니 ‘에이전트 사용중’이라고 나와요. 1시간 동안 못 써요.”

로그를 확인해보니 분산 락의 TTL이 1시간으로 설정되어 있었다. 클라이언트가 새로고침해도 서버에서는 알 수 없어서 락이 1시간 동안 유지되고 있었다.

왜 1시간이었는가?

flowchart TD
    A[stream_timeout_seconds: 3600] --> B[분산 락 TTL: 1시간]
    A --> C[SSE 타임아웃: 1시간]

    D[문제점] --> E[클라이언트 새로고침]
    E --> F[서버: 연결 해제 감지 불가]
    F --> G[락 1시간 유지]
    G --> H[같은 세션 사용 불가]

초기 설계의 의도:

  • RAG Flow 응답이 최대 1시간까지 걸릴 수 있음
  • 스트리밍 타임아웃과 락 TTL을 동일하게 설정

문제점:

  • RAG Flow SSE 연결은 서버→클라이언트 단방향
  • 클라이언트가 연결을 끊어도 서버에서 감지 불가
  • 락이 1시간 동안 유지되어 같은 세션 재사용 불가

해결책: TTL 단축 + 자동 갱신

핵심 아이디어

1
2
기존: TTL 1시간 (고정)
개선: TTL 30초 + 스트리밍 중 10초마다 갱신

정상적인 스트리밍 중에는 락이 계속 갱신되고, 클라이언트가 사라지면 30초 후 자동 해제된다.

설정 변경

1
2
3
4
5
6
# config.yaml
connection:
  sse:
    stream_timeout_seconds: 3600           # RAG Flow 응답 대기 최대 시간 (1시간)
    distributed_lock_ttl_seconds: 30       # 분산 락 TTL (30초)
    lock_extend_interval_seconds: 10       # 분산 락 갱신 주기 (10초)

시퀀스 다이어그램

sequenceDiagram
    participant Client as 클라이언트
    participant API as API Gateway
    participant Redis as Redis
    participant RAG as RAG Flow

    Client->>API: 질문 요청
    API->>Redis: 분산 락 획득 (TTL: 30초)
    API->>RAG: SSE 스트리밍 시작

    loop 스트리밍 중 (10초마다)
        API->>Redis: ExtendStreamLock (TTL 갱신)
        Redis-->>API: OK
    end

    alt 정상 종료
        RAG-->>API: 스트리밍 완료
        API->>Redis: 분산 락 해제
        API-->>Client: 응답 완료
    else 클라이언트 새로고침
        Note over Client: 연결 끊김
        Note over API: ExtendStreamLock 호출 중단
        Note over Redis: 30초 후 TTL 만료로 자동 해제
    end

구현

락 갱신 메서드

1
2
3
4
5
6
7
func (s *chatService) ExtendStreamLock(ctx context.Context, sessionID string) error {
    lockKey := fmt.Sprintf("stream_lock:%s", sessionID)
    ttl := time.Duration(s.config.Connection.SSE.DistributedLockTTL) * time.Second

    // EXPIRE 명령으로 TTL 갱신
    return s.redis.Expire(ctx, lockKey, ttl).Err()
}

자동 갱신 고루틴

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
func (s *sseService) startLockExtender(ctx context.Context, sessionID string, stopChan chan struct{}) {
    interval := time.Duration(s.config.Connection.SSE.LockExtendInterval) * time.Second
    ticker := time.NewTicker(interval)
    defer ticker.Stop()

    for {
        select {
        case <-stopChan:
            // 스트리밍 종료
            return
        case <-ctx.Done():
            // 컨텍스트 취소
            return
        case <-ticker.C:
            // 10초마다 TTL 갱신
            if err := s.lockExtender(ctx, sessionID); err != nil {
                s.logger.Warnf("failed to extend lock: %v", err)
            }
        }
    }
}

SSE 스트리밍에 통합

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
func (s *sseService) ProcessSSEStream(ctx context.Context, sessionID string, ...) error {
    // 락 갱신 고루틴 시작
    stopExtender := make(chan struct{})
    go s.startLockExtender(ctx, sessionID, stopExtender)
    defer close(stopExtender)  // 스트리밍 종료 시 갱신 중단

    // SSE 스트리밍 처리
    for {
        select {
        case line := <-lineChan:
            // 메시지 처리
        case err := <-errChan:
            if errors.Is(err, io.EOF) {
                return nil  // 정상 종료
            }
            return err
        case <-ctx.Done():
            return ctx.Err()
        }
    }
}

멀티 탭 문제

추가로 발견된 문제

“탭1에서 세션A, 탭2에서 세션B를 열면 탭1이 이상해져요.”

ActiveSessionManager사용자당 하나의 활성 세션만 인식하고 있었다.

1
2
3
4
5
6
7
8
// 문제 코드: 사용자당 하나의 세션만
func (m *ActiveSessionManager) IsActiveSession(userID, sessionID string) bool {
    activeSession, exists := m.activeSessions[userID]
    if !exists {
        return true
    }
    return activeSession == sessionID  // 마지막 세션만 활성
}

해결: 연결별 활성 세션 관리

1
2
3
4
5
6
7
8
// 수정 후: 연결(탭)별 활성 세션
func (m *ActiveSessionManager) IsActiveSession(connectionID, sessionID string) bool {
    activeSession, exists := m.activeSessions[connectionID]
    if !exists {
        return true  // 하위 호환성
    }
    return activeSession == sessionID
}

각 WebSocket 연결(탭)마다 독립적인 활성 세션을 관리한다.

TTL 값 선택 근거

왜 30초인가?

TTL장점단점
5초빠른 복구네트워크 지연 시 락 만료 위험
30초안전 마진 + 빠른 복구-
60초충분한 안전 마진복구 대기 시간 길어짐
1시간-복구 불가 수준

왜 10초 갱신인가?

1
2
3
TTL: 30초
갱신 주기: 10초
안전 마진: 30 - 10 = 20초
  • 네트워크 지연이 최대 20초까지 발생해도 락 만료 없음
  • Redis 호출 빈도가 너무 높지 않음 (10초당 1회)

권장 설정

환경TTL갱신 주기비고
일반30초10초기본 권장값
불안정 네트워크60초20초지연 대비

고려했지만 채택하지 않은 방안

1. 클라이언트 연결 상태 감지 (ctx.Done())

1
2
3
4
5
// 시도했지만 효과 없음
select {
case <-ctx.Done():
    // 클라이언트 연결 해제 감지?
}

문제: RAG Flow SSE 연결은 서버→클라이언트 단방향이라 클라이언트 상태를 감지할 수 없다.

2. 분산 락 TTL 0초 (락 없음)

문제:

  • 동일 세션에 동시 요청 시 중복 스트리밍 발생
  • 메시지 순서 보장 불가

3. Heartbeat 기반 감지

문제:

  • 클라이언트 구현 필요
  • SSE 단방향 특성상 클라이언트→서버 통신 불가

결과

성능 영향

항목영향
Redis 호출 증가스트리밍 중 10초마다 EXPIRE 1회 (경미)
메모리 사용고루틴 1개 추가 (무시 가능)
CPU 사용Ticker 1개 추가 (무시 가능)

사용자 경험 개선

지표BeforeAfter
새로고침 후 대기 시간최대 1시간최대 30초
멀티 탭 지원불가가능

로그 변화

수정 전:

1
2
3
[15:23:45] Client refreshed
[15:23:45] Lock still held: 59m remaining
[15:23:45] Error: Agent is busy

수정 후:

1
2
3
4
[15:23:45] Client refreshed
[15:23:45] Lock extend stopped
[15:24:15] Lock expired (30s TTL)
[15:24:16] New request accepted

배운 점

1. TTL은 “최악의 상황”을 기준으로 설정하지 마라

1
2
3
4
5
6
// 안티패턴: 최악의 경우(1시간 스트리밍)에 맞춤
lockTTL = streamTimeout  // 1시간

// 올바른 패턴: 짧은 TTL + 자동 갱신
lockTTL = 30s
extendInterval = 10s

2. 자동 갱신 패턴

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 패턴: 짧은 TTL + 주기적 갱신
func keepAlive(ctx context.Context, resource string) {
    ticker := time.NewTicker(extendInterval)
    defer ticker.Stop()

    for {
        select {
        case <-ctx.Done():
            return  // 컨텍스트 취소 시 갱신 중단 → TTL 만료
        case <-ticker.C:
            extend(resource)  // TTL 갱신
        }
    }
}

3. 단방향 연결의 한계 인식

SSE는 서버→클라이언트 단방향이다. 클라이언트 상태를 감지하려면:

  • 양방향 통신 (WebSocket) 사용
  • 또는 짧은 TTL + 자동 갱신 (이 방식)

결론

분산 락 TTL 최적화의 핵심:

  1. 짧은 TTL: 1시간 → 30초로 단축
  2. 자동 갱신: 10초마다 TTL 연장
  3. 우아한 종료: 정상 종료 시 즉시 해제, 비정상 종료 시 TTL 만료

락은 “보유하는 동안만” 유효해야 한다. 소유자가 사라지면 빠르게 해제되어야 한다.

This post is licensed under CC BY 4.0 by the author.