아인슈타임, 투표 현황 실시간 업데이트

2025. 11. 29. 20:34성장 로그

 

아인슈타임

시간은 상대적이다! 쉽고 공평하며 빠르게 약속을 확정해 드립니다!

estime.today

 

알고 가면 좋을 용어 정리

더보기

은 일정 조율을 위해 만든 하나의 공간을 뜻합니다.

 

슬롯은 30분 단위로 쪼개진 시간표의 한 칸(1x1)을 의미합니다.

 

투표는 시간표에서 내가 가능한 슬롯을 하나 이상 선택하는 행위를 말합니다.

 

시간표는 한 방에 속한 모든 사람의 투표를 모아 격자 형태로 보여 주는 화면을 뜻합니다. 본문에서는 투표 현황, 투표 통계, 모든 투표와 거의 같은 의미로 사용합니다. 많이 선택된 시간일수록 색이 더 진하게 보이도록 표현하여, 어느 시간이 선호도가 높은지 한눈에 볼 수 있게 하는 히트맵 방식을 사용합니다.

 

 

위 용어들은 엄밀하게는 조금씩 다르지만, 이 글에서는 이해를 돕기 위해 위와 같이 묶어서 거의 같은 의미로 이해해도 무방합니다.

 

알고 가면 좋을 아인슈타임 UI

더보기
방(약속, 일정 조율) 생성

 

내가 가능한 시간 투표

 

전체 시간표 (투표 현황)

 

추천 시간

추천 시간 슬롯(시간표에서 1x1)은 shimmer 애니메이션으로 강조하고,

 

이외의 시간

나머지 슬롯은 색 강도를 이용해서 상대적인 선호도를 표현한다.

 

 


다른 사람의 투표가 새로고침 없이 즉시 반영되었으면 좋겠다

사용자가 시간표 화면을 보고 있을 때 다른 사람이 투표하면, 그 결과가 별도의 새로고침 없이 바로 반영되기를 기대한다는 의미다. 투표 현황이 서비스의 핵심 도메인인 만큼, 시간표가 변경되면 화면도 실시간으로 동기화될 필요가 있다는 점이 유저 테스트 피드백과 팀 논의를 통해 분명해졌다. 

 

이를 위해 서버는 투표 발생 이벤트를 감지해 클라이언트에 알려 줄 수 있어야 했다. 이 요구사항을 처음 봤을 때 가장 먼저 떠오른 선택지는 SSE(Server-Sent Events)였다. 이전에 사용해 본 경험이 있었고, 서버에서 클라이언트로의 단방향 이벤트 전송 모델이 요구와 잘 맞아 보였기 때문이다. 다만 바로 기술을 확정하기보다는, SSE를 포함한 여러 방식을 비교해 보면서 선택의 근거를 명확히 하고자 했다.

 

출처: https://bytebytego.com/guides/shortlong-polling-sse-websocket/

 

우선 클라이언트가 서버에 주기적으로 변화를 물어보는 방식은 연결을 얼마나 오래 유지하느냐에 따라 Short polling과 Long polling으로 나눌 수 있다.

 

Short polling은 구현이 가장 단순하지만, 이벤트가 없어도 일정 주기로 요청과 응답이 반복된다는 문제가 있다. 트래픽과 리소스 사용 관점에서 비효율적이다.

 

Long polling은 이런 낭비를 줄이기 위해, 서버가 새로운 이벤트가 생길 때까지 응답을 지연시키는 방식이다. 이벤트가 없을 때 불필요한 응답은 줄어들지만, 이벤트가 한 번 전달될 때마다 HTTP 요청–응답 사이클이 매번 새로 열리고 닫힌다는 한계는 여전히 남는다. 요청이 들어올 때마다 헤더 파싱, 인증, 필터·인터셉터 체인 통과 등 공통 오버헤드가 반복된다는 의미다.

 

출처: https://bytebytego.com/guides/shortlong-polling-sse-websocket/

 

Server-Sent Events(SSE)는 클라이언트가 한 번 구독 요청을 보내고 나면, 서버가 text/event-stream 형태의 HTTP 응답을 열어 둔 채 그 위에 여러 개의 이벤트를 연속해서 흘려보내는 방식이다. 한 번 열린 연결 위에서 여러 이벤트를 전달할 수 있기 때문에, 위에서 언급한 polling 계열의 문제를 상당 부분 줄일 수 있다. 이 글에서는 뒤에서 SSE를 조금 더 깊게 다룰 것이므로 여기서는 개념만 정리하고 넘어간다.

 

출처: https://bytebytego.com/guides/shortlong-polling-sse-websocket/

 

WebSocket(WS)은 상대적으로 빠르게 후보에서 제외했다. 우리가 풀고자 한 문제는 '서버에서 클라이언트로 단방향으로 이벤트를 전송하는 것'이었기 때문에, 양방향 통신, 매우 낮은 지연, 별도 프로토콜과 같은 WebSocket의 장점을 활용할 상황이 아니었다. 기존의 HTTP 기반 REST API를 그대로 유지한 채, 특정 이벤트(투표 발생)만 단방향으로 전달하면 충분했다. WebSocket 채널을 추가로 도입하고 운영 복잡도를 감수할 만큼의 요구사항은 아니라고 판단했다.

 

위 비교를 바탕으로, 기본 통신은 기존 HTTP 기반 REST API로 유지하고, 투표 이벤트는 SSE로 전달하는 조합을 선택했다.

 


 

 

다음으로, SSE 이벤트에 어떤 데이터를 담을지를 결정해야 했다. 처음에는 SSE 이벤트 안에 어떤 참여자가 어떤 슬롯에 투표했는지를 그대로 담고, 클라이언트에서 통계를 다시 계산하는 방식을 고려했다. 예를 들자면 아래와 같다.

...
{
  "name": "강산",
  "dateTimeSlot": [
    "2026-06-08T11:30",
    "2026-06-08T12:00",
    "2026-06-08T12:30",
    "2026-06-08T13:00",
    "2026-06-08T13:30"
  ]
}
...

 

 

다만 투표 통계는 다소 복잡한 집계 연산이 필요했다. 동일한 연산을 클라이언트에 중복 구현하면, 서버·클라이언트 간 로직이 어긋날 가능성이 커지고, 통계 정책이 바뀔 때마다 양쪽을 같이 수정해야 한다는 점도 부담이었다. 그래서 통계 계산 책임은 서버에 두고, 클라이언트는 서버가 계산한 결과를 그대로 렌더링하는 쪽으로 역할을 나누었다. 대략 아래와 같은 형태의 응답을 SSE로 전달하는 구조다.

...
{
  "dateTimeSlot": "2025-12-07T08:00",
  "names": ["엠제이", "링크", "율무", "띠용", "에드"]
},
{
  "dateTimeSlot": "2025-12-07T08:30",
  "names": ["엠제이", "링크", "율무", "띠용", "에드"]
},
...

 

이 접근은 통계 일관성 측면에서는 타당했지만, 구현은 점점 한 메서드에 몰리기 시작했다.

@Transactional
public VotesOutput updateParticipantVotes(final VotesUpdateInput input) {

    // (기존) 단일 참여자의 투표 업데이트
    
    // (추가) 투표 현황 조회 및 통계 계산

    // (추가) 커밋 이후 투표 통계를 SSE로 전송하기 위한 콜백 등록
    TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
        @Override
        public void afterCommit() {
            // (추가) 계산된 투표 통계를 SSE로 전송
        }
    });

    // (기존) 업데이트된 단일 참여자의 투표 결과 반환
    return VotesOutput.from(input.name(), updatedVotes);
}
(추가)로 시작하는 주석들은 SSE 브로드캐스팅이 도입되며 새로 작성한 코드다.

 

원래는 한 참여자의 투표만 갱신하면 되던 updateParticipantVotes 흐름 안에, 투표 현황 조회 → 통계 계산 → SSE에 실을 데이터 준비 → afterCommit 콜백 등록까지 모두 얹히면서, 한 요청이 떠안는 책임이 눈에 띄게 무거워졌다. 그럼에도 불구하고 통계는 서버에서 계산한다는 방향성 자체는 유효하다고 판단해, 투표 업데이트와 통계 브로드캐스트를 도메인 이벤트 발행·구독 구조로 분리하는 것을 후속 리팩터링 과제로 넘겼다.


투표는 동시에 일어날 수 있다

서버에서 투표 현황을 계산해서 내려주는 방식에는 또 다른 문제가 있었다. 여러 사용자의 투표가 거의 동시에 발생하는 경우, 서버 입장에서는 연속된 여러 이벤트가 짧은 시간 안에 발생하게 되는데, 이때 어떤 이벤트의 결과가 사용자 화면에 먼저 반영되는지가 중요해진다.

 

예를 들어 A, B, C 세 사용자가 거의 동시에 투표를 완료했고, 완료 시점 기준으로는 A, B, C 순서였다고 하자. 그렇다면 이상적으로는 다음과 같은 상태 변화를 기대하게 된다.

  • A의 투표 이후: 기존 상태 + A의 투표
  • B의 투표 이후: (기존 상태 + A의 투표) + B의 투표
  • C의 투표 이후: ((기존 상태 + A의 투표) + B의 투표) + C의 투표

즉, [기존 + A] → [(기존 + A) + B] → [((기존 + A) + B) + C] 순서로 시간이 흐르면서 투표 현황이 단조롭게 누적되기를 기대하게 된다. 현실적으로 이 순서가 항상 보장되지 않더라도, 최소한 최종 상태만큼은 언제나 [((기존 + A) + B) + C]와 일치해야 한다.

 

하지만 실제 시스템에서는 이 기대가 깨질 수 있다. 이 문제를 코드를 통해 살펴보자.

@Transactional
public VotesOutput updateParticipantVotes(final VotesUpdateInput input) {

    // (기존) 단일 참여자의 투표 업데이트
    
    // (추가) 투표 현황 조회 및 통계 계산

    // (추가) 커밋 이후 투표 통계를 SSE로 전송하기 위한 콜백 등록
    TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
        @Override
        public void afterCommit() {
            // (추가) 계산된 투표 통계를 SSE로 전송
        }
    });

    // (기존) 업데이트된 단일 참여자의 투표 결과 반환
    return VotesOutput.from(input.name(), updatedVotes);
}
(추가)로 시작하는 주석들은 SSE 브로드캐스팅이 도입되며 새로 작성한 코드다.

 

위 코드는 트랜잭션 안에서 투표를 업데이트한 뒤, 커밋이 성공하면 afterCommit 콜백에서 SSE 이벤트를 전송하는 방식이다. 문제는 DB 커밋 순서와 afterCommit 콜백 실행(이벤트 전송) 순서가 항상 같지 않다는 점이다. 예를 들어서 스레드 스케줄링에 따라 콜백 실행이 잠시 지연되면, A → B → C 순서로 커밋된 트랜잭션의 SSE 이벤트가 뒤섞여 전송될 수 있다. 이때 SSE 이벤트에 커밋 시점의 투표 현황을 실어 보낸다면 잠깐 동안은 잘못된 순서의 투표 현황을 보게 되거나, 최종적으로도 기대와 다른 상태를 보게 될 위험이 있다.

 

이를 해결하기 위해 시퀀스 번호를 두고, 더 큰 번호의 이벤트만 반영한다는 방식도 고려할 수 있다. 하지만 이는 기본적으로 stateless하게 운영하던 서버에 글로벌 상태를 도입하게 되고, 추후 이 상태를 여러 인스턴스 간에 일관성 있게 관리해야하니 문제가 불필요하게 복잡해진다. 다른 방법으로 커밋 시점을 타임스탬프로 두고 비교하는 방식도 생각해 볼 수 있다. 서버에서 별도의 상태를 들지 않아도 된다는 점에서 시퀀스 번호를 대신 할 수 있는 선택지였다.

 

그렇지만 서버가 커밋 시점이나 버전을 이벤트에 실어 보내면, 클라이언트는 이를 기준으로 이벤트를 비교하고 더 오래된 이벤트를 버리거나 무시하는 조건 로직을 추가해야 한다. 코드 레벨에서는 if 문 몇 줄이겠지만, 서버의 동시성·순서 보장 이슈를 클라이언트까지 공유된 지식으로 끌어올려야 하는 구조가 썩 마음에 들지 않았다.

 

그래서 단순하지만 확실한 해결책을 선택했다. SSE 이벤트에는 어떤 변경이 일어났는지를 싣지 않고 투표 현황이 변경되었다는 사실만 알리도록 바꿨다. 즉, SSE는 "투표 현황이 변경되었다" 라는 신호(zero-payload 패턴)만 책임지고, 투표 현황은 클라이언트가 기존의 조회 API를 호출해 가져오도록 했다.

 

@Transactional
public VotesOutput updateParticipantVotes(final VotesUpdateInput input) {

    // (기존) 단일 참여자의 투표 업데이트
    
    // (추가) 커밋 이후 투표 업데이트를 SSE로 알리기 위한 콜백 등록
    TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
        @Override
        public void afterCommit() {
            // (추가) 투표 업데이트 알림, zero-payload(notification-only) 패턴
        }
    });

    // (기존) 업데이트된 단일 참여자의 투표 결과 반환
    return VotesOutput.from(input.name(), updatedVotes);
}

 

이렇게 하면 이벤트가 ABC, BAC, BCA, CBA 어떤 순서로 오더라도 상관없다. 클라이언트는 변경 알림을 받는 시점에 항상 조회 API를 호출하고, 그 시점의 DB 기준으로 계산된 최신 통계를 내려받기 때문에, 조회 결과는 언제나 올바른 상태를 보장할 수 있다.

 

요약하면, 이 구조에서의 역할 분리는 다음과 같다.

  • SSE: 투표 현황이 변경되었다는 사실을 알리는 용도
  • 조회 API: 올바른 최신 투표 현황 조회를 책임지는 용도

이 과정에서 updateParticipantVotes 메서드 안에서 통계 계산 로직이 완전히 빠져나가면서, 앞서 언급했던 투표 업데이트와 통계 계산·브로드캐스트가 한 메서드에 뒤섞여 있던 코드 레벨 결합도 문제도 이벤트 구조를 도입하지 않고 자연스럽게 해소할 수 있었다.


 

단순함의 비용

zero-payload 패턴을 활용하는 SSE의 문제는 최종 데모데이 발표에서 드러났다. 한 방에 50명 이상이 접속한 상태에서, 모두가 짧은 시간 안에 투표를 반복해서 등록·수정했다. 이때 서버는 투표 현황이 변경되었다는 알림(SSE 이벤트)만 브로드캐스트하고, 알림을 받은 클라이언트가 각자 통계 조회 API를 호출하도록 되어 있었다. 그래서 투표 1건이 곧바로 50개 이상의 조회 요청으로 fan-out되었고, 사람 수만큼 이 패턴이 반복되면서 방 하나에서만 수천 번의 DB 조회 및 통계 계산이 수행되었다. 참가자 수가 늘어날수록 방 전체의 조회·연산 비용이 사실상 제곱에 가깝게 증가하는 구조였다. 다행히 서버 장애나 눈에 띄는 에러는 발생하지 않았지만, 모니터링에서 P95 지연 시간과 CPU 사용률이 눈에 띄게 튀는 것을 확인할 수 있었다. (EC2: t4g.small, RDS: t4g.micro)

 

가장 먼저 떠오른 해법은 캐싱이었다. 같은 방의 시간표를 여러 사용자가 동시에 보고 있다면, 동일한 투표 현황과 통계 결과를 매번 새로 조회·계산할 필요는 없다. 하지만 실제 사용 패턴을 보면 데모데이처럼 많은 인원이 한 방에 몰리는 상황은 드물고, 보통은 한 방에 동시에 SSE로 접속해 있는 사용자가 2~3명 수준에 그쳤다. 투표가 프라이빗한 행위라는 점까지 감안하면, 각 투표 방마다 수십 KB 규모의 통계 응답을 캐시에 쌓아 두는 것이 과연 맞는 선택인지 고민이 될 수밖에 없었다. 방 수가 늘어날수록 메모리 사용량과 TTL 전략까지 함께 신경 써야 했기 때문에, 단순히 캐시 한 줄로 덮어두기에는 찜찜한 부분이 남았다.


결국 캐싱 자체가 정답에 가까운 선택이라고 보았지만, 당장 장애를 막기 위한 긴급 처치가 필요한 상황은 아니었다. 무엇보다 서비스 전체 트래픽에서 투표·통계 도메인이 차지하는 비중이 컸기 때문에, 캐시부터 적용하기보다 데이터 표현 방식과 조회·집계 비용을 줄이는 쪽이 먼저 고민해야 할 지점이라고 판단했다. 그래서 캐시 도입은 뒤로 조금 미루고, 먼저 투표 데이터 모델을 최적화하는 작업부터 진행했다.

 

 

아인슈타임, 투표 데이터 모델 최적화

들어가며 아인슈타임시간은 상대적이다! 쉽고 공평하며 빠르게 약속을 확정해 드립니다!estime.today 아인슈타임, 투표 현황 실시간 업데이트아인슈타임시간은 상대적이다! 쉽고 공평하며 빠르게

mak-ing.tistory.com

 

이후 캐싱을 도입했다. 사용자 수와 트래픽을 고려하면 아직 스케일 아웃을 본격적으로 고민할 단계는 아니었기 때문에, Redis를 별도로 붙이기보다는 단일 인스턴스 환경에서 효율적인 Caffeine 로컬 캐시를 사용하는 쪽이 더 적절하다고 판단했다.

 

또한 투표 현황 조회는 단순 조회가 아니라 통계 집계, 정렬 등 후처리 로직이 함께 들어가기 때문에, 리포지토리에서 조회한 결과가 아니라 서비스 레이어에서 반환하는 최종 통계 응답 객체를 캐싱 대상으로 삼았다.

 

@Cacheable(value = CacheNames.VOTE_STATISTIC, key = "#input.session()", sync = true)
@Transactional(readOnly = true)
public DateTimeSlotStatisticOutput calculateVoteStatistic(final RoomSessionInput input) {
    // 투표 현황 조회 및 통계 계산
}

 

calculateVoteStatistic에는 @Cacheable(sync = true)를 적용해, 동일 세션(방 외부 식별자)에 대한 동시 요청이 몰리더라도 실제 DB 조회와 통계 계산은 한 번만 수행되도록 했다. 동시에 여러 스레드가 같은 키로 메서드를 호출해도, 첫 번째 스레드가 캐시를 채우는 동안 나머지는 그 결과를 함께 가져가도록 하는 방식이다.

 

@CacheEvict(value = CacheNames.VOTE_STATISTIC, key = "#input.session()")
@Transactional
public VotesOutput updateParticipantVotes(final VotesUpdateInput input) {
    // 투표 업데이트
}

 

캐시 무효화는 투표 변경 시점에 처리한다. updateParticipantVotes에서 투표가 추가·수정되면, 해당 세션 키에 대한 캐시를 @CacheEvict로 즉시 삭제하고, 이후 첫 조회에서 다시 통계가 계산·캐싱되도록 했다. 이 방식은 TTL 기반 자연 만료 + 변경 시점 즉시 삭제를 함께 사용해, 정합성과 성능을 동시에 챙기는 형태다.

 

캐시가 의도한 대로 동작하는지 확인하기 위해, 동일 세션 반복 조회, 투표 변경 후 캐시 무효화, 그리고 sync = true 환경에서의 동시성까지 테스트로 검증했다. 

 

테스트 알아보기

더보기

캐시가 의도한 대로 동작하는지 확인하기 위해,

  1. 같은 세션으로 여러 번 조회했을 때 Repository 호출이 1회만 일어나는지  
  2. 투표 수정 이후에는 캐시가 무효화되어 다시 Repository를 호출하는지  

를 먼저 테스트로 검증했다.

// ... 생략 ...
class VoteStatisticCacheTest {

    // ... 생략 ...

    @BeforeEach
    void setUp() {
        // ... 생략 ...
    }

    @Transactional
    @DisplayName("같은 세션으로 여러 번 조회 시 캐시 히트되어 Repository 조회가 1회만 실행된다")
    @Test
    void cacheHit_sameSession() {
        // given
        final RoomSessionInput input = RoomSessionInput.from(room.getSession());

        // when
        roomApplicationService.calculateVoteStatistic(input);
        roomApplicationService.calculateVoteStatistic(input);
        roomApplicationService.calculateVoteStatistic(input);

        // then
        verify(voteRepository, times(1)).findAllByParticipantIds(anyList());
    }

    @Transactional
    @DisplayName("투표 수정 시 캐시가 무효화되어 다음 조회에서 Repository를 다시 호출한다")
    @Test
    void cacheEvict_afterVoteUpdate() {
        // given
        final RoomSessionInput sessionInput = RoomSessionInput.from(room.getSession());
        final VotesUpdateInput updateInput = new VotesUpdateInput(
                room.getSession(),
                participant.getName(),
                List.of(slot)
        );

        // when
        roomApplicationService.calculateVoteStatistic(sessionInput);  // 1회 - DB 조회
        roomApplicationService.updateParticipantVotes(updateInput);   // 캐시 evict
        roomApplicationService.calculateVoteStatistic(sessionInput);  // 2회 - 캐시 무효화로 다시 DB 조회

        // then
        verify(voteRepository, times(2)).findAllByParticipantIds(anyList());
    }
}

 

 

여기에 더해, 동일 키에 대해 여러 스레드가 동시에 접근하는 경우에도 캐시가 의도대로 동작하는지 확인하기 위해, @Cacheable(sync = true) 옵션의 효과를 검증하는 동시성 테스트를 추가했다.

@DisplayName("동시에 여러 요청이 들어와도 sync=true 덕분에 Repository 조회가 1회만 실행된다")
@Test
void concurrentRequests_withSyncTrue_shouldQueryOnlyOnce() throws InterruptedException {
    // given
    final int concurrentRequests = 20;
    final RoomSessionInput input = RoomSessionInput.from(room.getSession());
    final ExecutorService executor = Executors.newFixedThreadPool(concurrentRequests);
    final CountDownLatch readyLatch = new CountDownLatch(concurrentRequests);
    final CountDownLatch startLatch = new CountDownLatch(1);
    final CountDownLatch doneLatch = new CountDownLatch(concurrentRequests);
    final AtomicInteger successCount = new AtomicInteger(0);

    // when
    for (int i = 0; i < concurrentRequests; i++) {
        executor.submit(() -> {
            try {
                readyLatch.countDown();
                startLatch.await();

                roomApplicationService.calculateVoteStatistic(input);
                successCount.incrementAndGet();
            } catch (final Exception e) {
                e.printStackTrace();
            } finally {
                doneLatch.countDown();
            }
        });
    }

    readyLatch.await();
    startLatch.countDown();
    doneLatch.await();
    executor.shutdown();

    // then
    assertThat(successCount.get()).isEqualTo(concurrentRequests);
    verify(voteRepository, times(1)).findAllByParticipantIds(anyList());
}

 

이 테스트는 다음 두 가지를 동시에 확인한다.

  1. 동시 요청 20개가 모두 정상적으로 응답을 받았는지
  2. 그 과정에서 Repository 조회는 딱 한 번만 실행되었는지

이렇게 동기 호출과 동시성 상황을 모두 테스트로 묶어두면, 이후 캐시 설정을 변경하거나 다른 캐시 구현체로 교체하더라도 캐시 동작과 DB 조회 횟수 상한이 깨지지 않았는지 테스트로 빠르게 확인할 수 있다.

 

캐시 설정은 다음과 같이 구성했다.

@Configuration
@EnableCaching
public class CacheConfig {

    @Bean
    public CacheManager cacheManager() {
        final CaffeineCacheManager cacheManager = new CaffeineCacheManager(
                CacheNames.VOTE_STATISTIC,
                CacheNames.COMPACT_VOTE_STATISTIC
        );
        cacheManager.setCaffeine(Caffeine.newBuilder()
                .expireAfterWrite(500, TimeUnit.MILLISECONDS)
                .maximumSize(1000));
        return cacheManager;
    }
}

 

위 설정은 Caffeine 캐시의 TTL(expireAfterWrite)과 maximumSize만 정의한다.

실제 무효화는 앞서 살펴본 것처럼 서비스 레이어의 @CacheEvict와 함께 동작한다.

 

TTL: 500ms

  • 투표가 발생한 직후 짧은 시간 동안 몰리는 조회를 한 번의 조회·집계로 흡수하고, 그 이후에는 비교적 빠르게 최신 상태로 자연 만료되도록 하기 위한 값이다. 
  • TTL을 얼마나 길게 가져갈지는 팀 내에서도 의견이 갈리는 지점이라, 일단은 보수적으로 500ms로 설정해 두었다. 향후 실제 트래픽 패턴과 모니터링 지표를 보면서 더 길게 가져가거나, 방 특성에 따라 분리하는 등 조정 여지는 열어둔 상태다.

maximumSize: 1000

  • 동시에 활성화될 수 있는 방(세션) 수를 고려했을 때, 여유는 있으면서도 과도하게 메모리를 사용하지 않는 수준으로 설정했다.

 

이렇게 구성함으로써, zero-payload 패턴으로 인해 투표 1건이 다수의 조회로 fan-out되는 구조는 유지하되, 중복되는 DB 조회·통계 계산을 한 번으로 모을 수 있다.

 


마무리

돌아보면, 이 글에서 정리한 결정들은 정답을 찾았다기보다는 그때그때 상황에서 더 나은 쪽을 고른 것에 가깝다. 당시의 선택이 곧 완결은 아니었고, 하나를 고를 때마다 그 선택이 드러내는 다음 문제가 또 보였다. 이번 작업에서는 그렇게 한 번에 끝내기보다는, 지금 할 수 있는 수준에서 최선을 고르고, 거기서 드러난 문제를 다시 정리하는 식으로 조금씩 형태를 바꿔 가는 쪽에 더 가깝게 움직였다.

 

최근에는 실제 서비스 운영 중에 당장 드러난 문제보다는, 조금 더 먼 지점을 가정하는 질문들을 꺼내 보고 있다.

  • 트래픽이 지금보다 훨씬 커진다면 어떻게 될까?
  • 무중단 배포 중에 SSE 커넥션은 어떻게 끊김 없이 넘겨야 할까?
  • 스케일 아웃 환경에서 지금의 구조는 어떤 문제에 부딪힐까?
  • 갑작스러운 다수의 SSE 커넥션을 어떻게 버틸 수 있을까?

아직 답을 내리지 못한 질문들이 남아 있고, 그 질문들을 하나씩 뜯어보고 있는 중이다. 이 내용들도 곧 정리해볼 것 같다~

 

끝!

'성장 로그' 카테고리의 다른 글

아인슈타임, 투표 데이터 모델 최적화  (0) 2025.12.09
우아콘 2025, 다녀왔습니다~  (0) 2025.10.29
레벨 1 회고  (1) 2025.04.08
[장기 미션] 회고 : 객체  (1) 2025.04.05
[블랙잭 미션] 회고 : Ace, Hit, SRP  (1) 2025.03.17