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

2025. 12. 9. 06:51성장 로그

들어가며

 

아인슈타임

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

estime.today

 

 

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

아인슈타임시간은 상대적이다! 쉽고 공평하며 빠르게 약속을 확정해 드립니다!estime.today 알고 가면 좋을 용어 정리더보기방은 일정 조율을 위해 만든 하나의 공간을 뜻합니다. 슬롯은 30분 단위

mak-ing.tistory.com

이전 글에서 투표 현황 실시간 업데이트를 구현한 뒤, 캐싱 도입 전에 투표 데이터 모델 최적화를 먼저 진행했다고 언급했다. 이 글은 그 과정을 정리한 내용이다.

 

아인슈타임에서는 전체 API 호출의 70% 이상이 투표 관련이다. 한 사용자가 일정 조율 방 하나에서 수백 개의 시간대에 투표할 수 있기 때문에, 투표 도메인은 다른 어떤 도메인보다 훨씬 자주, 훨씬 많이 사용된다.

 

예를 들어, 한 방에서 투표 가능한 시간대가 300개라고 해보자. 참여자 한 명이 그중 약 1/3인 100개 슬롯에 투표하고, 이런 참여자가 10명만 모여도 다음과 같은 규모가 된다.

  • : 1개
    • 참여자: 10명
      • 투표: 약 1,000개

물론 데이터 개수가 많다는 것만으로 성능 문제가 생기진 않는다. 레코드 크기나 그 위에서 돌아가는 로직도 함께 봐야 한다. 다만 투표가 차지하는 비율이 압도적이다 보니, 이 도메인의 모델링을 다시 들여다볼 명분은 충분했다.

 

여기에 이전 글에서 언급한 zero-payload 패턴 SSE 문제까지 발생하면서, 투표 도메인 최적화에 대한 확신이 생겼다.

이 도메인의 최적화가 서비스 전체 성능에 직결될 수 있다는 점을 염두에 두고 아래 글을 읽으면 좋을 것 같다!

Vote (투표)

아인슈타임에서 투표는 일정 조율 참여자가 특정 시간 슬롯에 가능 여부를 표시하는 행위다.

기존의 투표는 다음과 같이 모델링했다.

// ...
@Entity
public class Vote {

    @EmbeddedId
    private VoteId id;
    
    // ...
}
// ...
@Embeddable
public class VoteId implements Serializable {

    private Long participantId;
    private DateTimeSlot dateTimeSlot;
    
    // ...
}
participantId는 방에 관계없이 Auto-Increment로 증가한다.

 

투표(Vote)는 별도의 대체 키(surrogate key) 없이 전체 필드로 구성된 복합 PK로 식별한다.

평소 선호하는 대체 키 대신 이 구조를 선택했는지는 글의 본론과는 다소 거리가 있어 접은 글로 남기겠다.
더보기
더보기

말단 도메인

  • 투표는 방 → 참여자 → 투표 구조에서 말단 도메인이다.
  • 다른 테이블이 vote를 FK로 참조하지 않는다.

공간 절약

  • 한 참여자가 수백 개씩 투표를 생성하고, 한 방에서 수천 개의 투표가 쌓일 수 있다.
  • 실제로 쓰이지 않는 ID 컬럼을 추가하면 모든 레코드에 불필요한 8바이트(또는 그 이상)를 낭비하게 된다.

도메인 규칙

  • 한 참여자는 동일 슬롯에 두 번 이상 투표할 수 없다.
  • participantId + dateTimeSlot 조합을 PK로 두면 이 규칙이 기본 키 제약으로 자연스럽게 표현된다.
  • 별도 UNIQUE 인덱스 없이도 중복 투표를 방지할 수 있다.

개별 투표 ID를 들고 다닐 일이 없어서

  • 투표는 개별 ID로 조회하기보다 참여자 기준으로 통째로 다루는 대상이다.
  • surrogate key를 둬도 활용도가 거의 없다.

현재 드러난 요구와 가까운 미래까지를 기준으로 봐도 합리적이어서

  • 현재는 보조 인덱스도 없고, participantId를 조건으로 하는 쿼리만 존재한다.
    클러스터링 인덱스 (participant_id, date_time_slot)으로 충분히 해결된다.
  • 지금까지 드러난 사용 패턴과 예상 가능한 가까운 변화 범위를 기준으로 보면, 처음부터 대체 키와 추가 인덱스를 깔아 두기보다는 필요해지는 시점에 ID와 인덱스를 도입하는 편이 더 단순하고 비용도 적다고 판단했다.

VoteId는 아래 두 필드로 구성된다

  • participantId: 참여자 식별자
  • dateTimeSlot: 시간 슬롯을 나타내는 값 객체

이 구조에서 최적화 대상은 사실상 DateTimeSlot 뿐이었다.


DateTimeSlot(슬롯)의 문제점

// ...
public class DateTimeSlot implements Comparable<DateTimeSlot> {

    public static final Duration UNIT = Duration.ofMinutes(30);
    private final LocalDateTime startAt;
    
    // ...
    
    private static void validateStartAt(final LocalDateTime startAt) {
        if (startAt.getMinute() != 0 && startAt.getMinute() != UNIT.toMinutes()) {
            throw new SlotNotDivideException(DomainTerm.TIME_SLOT, startAt);
        }

        if (startAt.getSecond() != 0 || startAt.getNano() != 0) {
            throw new InvalidTimeDetailException(DomainTerm.TIME_SLOT, startAt);
        }
    }

    // ...
}
MySQL에서는 DATETIME(6)으로 저장한다.

 

DateTimeSlot은 LocalDateTime 필드 하나만 가지고, 이 시각부터 30분 동안을 하나의 슬롯으로 본다. 검증 로직을 보면 0분/30분만 유효하고, 초·나노초는 항상 0이어야 한다.

: 2025-12-25 10:00:00.000000

 

도메인 정책상 30분 단위만 허용하면서도, LocalDateTime과 DATETIME(6)은 초·나노초까지 표현할 수 있는 넓은 범위를 그대로 들고 있었다. 실제로는 사용하지 않는 정보에 불필요한 표현 범위를 할당하고 있던 셈이다.

: 2025-12-25 12:34:56.789012

 

여기를 개선할 수 있겠다는 판단이 들었다.


날짜와 시간을 다른 타입으로 분리

가장 먼저 떠올린 방법이다.

  • 날짜: LocalDate
  • 시간: 0시 00분, 0시 30분, 1시, 1시 30분, ... , 23시 30분, 24시 00분을 ENUM으로 정의 (48개)

이렇게 하면 시간 슬롯은 ENUM과 HashMap 등을 이용해 객체를 재사용할 수 있고, DB에도 0~47 범위의 슬롯 인덱스로만 저장하면 되니 저장 공간을 줄이는 효과도 기대할 수 있었다.

 

하지만 날짜는 여전히 LocalDate로 들고 있어야 하고, 기존에는 LocalDateTime 하나로 표현하던 값을 이제는 두 타입으로 나눠야 한다. 시간 표현은 조금 가벼워지지만, 슬롯 전체를 표현하는 모델 자체가 더 단순해졌다고 보기는 어렵다.


유레카!

날짜 표현을 고민하다가 관점을 바꾸게 만든 아주 당연한 사실 하나를 깨달았다.

아인슈타임에서 일정 조율 방을 만들 때 일정 조율 기간은 과거를 포함할 수 없다.

 

예를 들어 2025년 12월 30일에 2025년 12월 25~28일 일정을 조율할 수는 없다.

즉, 과거 시간에 투표할 일이 없다.

 

그렇다면 굳이 과거까지 포함한 전체 시간축을 표현할 필요가 없고, 미래의 어느 시점인가만 표현하면 된다.

 

마침 그 즈음에 time-sortable ID 시스템을 공부하면서, 시간을 절대 시각 대신 (기준 시점 + 오프셋)으로 표현하는 패턴을 경험한 상태였다. 이 방식을 활용하기로 했다.

  • 기준일(EPOCH): 최종 데모일
  • 날짜: 기준일로부터의 일 단위 오프셋
  • 시간: 하루를 30분 단위로 나눈 슬롯 인덱스

이렇게 바꾸면 DateTimeSlot은 더 이상 LocalDateTime일 필요가 없다.

(날짜 오프셋 + 슬롯 인덱스)라는 두 개의 작은 정수만으로 하나의 투표 슬롯을 표현할 수 있게 된다.


비트로 표현

다음 단계는 이 둘을 하나의 정수 값으로 묶는 것이었다. 비트를 활용해 날짜와 시간을 한 번에 인코딩하기로 했다.

 

먼저 시간부터 보자.

  • 30분 단위 → 하루는 24 × 2 = 48개 슬롯
  • 48개를 표현하려면 최소 6비트

날짜는 기준일로부터의 일 단위 오프셋이고, 몇 년을 커버할 것인지에 따라 필요한 비트 수가 달라진다.

  • 10비트 → 2¹⁰ = 1,024일 ≒ 2.8년

여기까지 계산하면 한 가지 후보가 자연스럽게 나온다.

날짜 10비트 + 시간 6비트 = 총 16비트(2바이트)

 

딱 맞게 떨어지는 설계처럼 보였지만, 실제 서비스 운영을 고려하면 두 가지 문제가 있었다.


 

16비트(2바이트) 설계의 한계

문제 1: 날짜 표현 범위

 

10비트로 날짜를 표현하면 약 2.8년까지만 표현 가능하다.
서비스가 3년 이상 운영되면, 아래와 같은 점을 고려해야 한다.

  • EPOCH를 뒤로 미뤄서 기존/신규 데이터를 다른 기준일로 해석
  • 데이터 마이그레이션

장기 운영 시 이런 잠재적인 유지보수 포인트는 분명 스트레스 요인이다.

 

 

문제 2: 시간 단위 확장

 

현재는 30분 단위만 쓰지만, 이런 요구가 생길 수 있다.

  • 특정 방은 15분 단위로 투표하고 싶다.
  • 특정 시간대만 10분 단위로 투표하고 싶다.

그러면 슬롯 개수는 바로 늘어난다.

단위 슬롯 수  필요 비트
30분 48개 6비트
15분 96개 7비트
10분 144개 8비트

 

시간에 6비트만 배정한 설계는 가까운 미래만 고려해도 한계에 부딪힐 수 있는 구조였다.


24비트(3바이트) 설계

조금 더 여유 있는 구조로 방향을 틀었다.

구분 비트 수 표현 범위
날짜 12비트 4,096일 ≒ 11.2년
시간 8비트 256 (10분 단위까지 커버)
확장용 4비트 Flag/Version/Option용

 

날짜 12비트는 최대 4,096을 표현할 수 있고, 일 단위로 사용하면 약 11.2년이다.

10년을 넘어서는 기간은 사실상 반영구적이라고 판단했다.

 

시간 8비트는 최대 256을 표현할 수 있어 10분 단위(144 슬롯)까지 충분히 커버한다.

10분보다 더 작은 단위는 사실상 투표 자체가 어렵기에, 시간 표현 때문에 설계를 바꿀 일은 없을 것이다.

 

날짜 12비트 + 시간 8비트 = 20비트면 논리적으로 충분하지만, 타입은 바이트 단위로 움직인다. 20비트를 담기 위해서 3바이트를 쓸 거라면 남는 4비트는 Flag/Version/Option 등을 위한 확장용 비트로 두기로 했다.

 

예를 들어 아래와 같이 조금 더 먼 미래를 바라본 것이다.

이때 기존 인코딩 스킴을 깨지 않고 상위 4비트만 추가로 해석하면 된다.

  • 수정 불가능한 투표
  • 삭제됐으나 기록은 남는 투표
  • 인코딩이 달라져 버전을 관리해야 하는 경우
  • 외부 API(구글 캘린더 등)로부터 얻은 일정 정보

Java와 MySQL에서 사용하는 타입은 아래와 같다.

환경 타입 설명
Java int (4바이트) 3바이트짜리 정수 타입이 없어서 int 사용.
비트 연산도 int 하나로 처리하는 게 가장 단순
 MySQL MEDIUMINT UNSIGNED (3바이트) 설계한 24비트 인코딩 범위와 정확히 대응

CompactDateTimeSlot (압축 슬롯)

// ...
public class CompactDateTimeSlot implements Comparable<CompactDateTimeSlot> {

    private static final LocalDate EPOCH = LocalDate.of(2025, 10, 24);

    private final int encoded;

    public static CompactDateTimeSlot from(final int encoded) {
        if (encoded < 0 || encoded > 0xFFFFF) {
            throw new CompactDateTimeSlotOutOfRangeException(DomainTerm.DATE_TIME_SLOT, encoded);
        }
        return new CompactDateTimeSlot(encoded);
    }

    public static CompactDateTimeSlot from(final LocalDateTime startAt) {
        validateNull(startAt);
        validateStartAt(startAt);

        final long dayOffset = ChronoUnit.DAYS.between(EPOCH, startAt);
        final int timeSlotIndex = (startAt.getHour() * 60 + startAt.getMinute()) / (int) DateTimeSlot.UNIT.toMinutes();
        return new CompactDateTimeSlot((int) ((dayOffset << 8) | timeSlotIndex));
    }

    // ...
}

기존 DateTimeSlot은 LocalDateTime 객체를 그대로 들고 있었다.

 

 

LocalDateTime의 내부 구조

 

LocalDateTime은 구현상 LocalDate와 LocalTime으로 나뉘어 있고, 각 클래스는 다음과 같이 필드를 가진다.

(JDK 21 corretto 기준)

LocalDate LocalTime
int year byte hour
short month byte minute
short day byte second
  int nano
더보기
더보기

한 가지 흥미로운 점!

 

month와 day는 값의 범위가 각각 1~12, 1~31이라서, 이론적으로는 byte로도 충분히 표현할 수 있다.

그런데 실제 JDK 구현을 보면 short 타입으로 선언되어 있다.

 

조금 찾아보니, 자바에서 객체 하나가 차지하는 메모리는 필드 타입 크기를 단순히 더해서 정해지지 않는다는 것을 알게 됐다.

  • 객체마다 공통으로 붙는 헤더 오버헤드가 있고,
  • 필드 배치를 할 때 정렬(alignment)을 맞추기 위해 패딩이 들어갈 수도 있어서,

shortbyte로 바꾼다고 해서 각 인스턴스의 실제 메모리 사용량이 항상 줄어든다고 보장할 수는 없다.

 

LocalDate가 왜 그 시점에 short를 선택했는지에 대한 공식적인 설명은 확인하기 어려웠다. 그래서 우리는 현재 JDK 내부 구현이 이렇게 되어 있다는 사실 정도만 조심스럽게 받아들이면 될 것 같다.

 

https://bugs.openjdk.org/browse/JDK-8371816?attachmentOrder=desc&utm_source=chatgpt.com

 

Loading...

Summary Change the type of primitives fields in java.time implementation classes to enable a more compact representation. The serialized form of both instances and the classes themselves remain compatible. Problem Some java.time classes use larger primitiv

bugs.openjdk.org

JDK 26에서는 변경될지도?!

즉, 원래는 DateTimeSlot이 이 전체 구조를 객체 그래프 그대로 품고 있었던 셈인데, CompactDateTimeSlot은 날짜·시간 정보를 비트 인코딩을 통해 int 하나로 표현한다.


 

첫 번째 정적 팩토리 메서드 from(int encoded)는 이미 인코딩 된 정수값을 받는다.
미래 확장을 위해 상위 4비트를 사용할 것이라고 했지만, 아직 사용할 곳이 없어서 검증에서는 20비트(5자리 16진수, 0xFFFFF)를 넘지 않도록 제한해 두었다.

 

두 번째 정적 팩토리 메서드 from(LocalDateTime startAt)는 LocalDateTime을 받아서 인코딩한다.
이 메서드가 실제 인코딩 과정을 담당한다.

  1. LocalDateTime을 날짜 오프셋(dayOffset)과 시간 슬롯 인덱스(timeSlotIndex)로 분해
  2. 날짜 오프셋을 8비트 왼쪽으로 시프트 (dayOffset << 8)
  3. 시간 슬롯 인덱스와 OR 연산 ( | timeSlotIndex)
  4. 간단한 비트 연산만으로 하나의 int로 압축

전•후 비교 테스트 

24비트 정수 인코딩 적용 전·후의 성능을 비교하는 간단한 벤치마크를 진행했다.


테스트 조건

  • 한 방에서 선택 가능한 시간 슬롯: 336개 (7일 × 48슬롯)
  • 참여자: 10명
  • 한 참여자가 투표하는 슬롯: 200개
  • 총 투표: 2,000건 (10명 × 200개)

실행 절차

  1. 336개 슬롯 중 각 참여자가 200개 슬롯을 균일 난수로 선택 (슬롯 조합 선택은 측정 시간에서 제외)
  2. 객체 생성 테스트: 2,000건 투표 객체 생성을 10,000회 반복하여 누적 시간을 측정
  3. 정렬/통계 테스트: 2,000건 투표를 일급 컬렉션으로 묶은 뒤, 주로 사용되는 정렬 및 통계 계산 메서드를 10,000회 반복하여 누적 시간을 측정
  4. 동일 실험을 5회 반복하고, 각 항목의 중앙값(median)을 최종 비교 지표로 사용
    (단일 측정치에 우연한 노이즈가 끼어드는 것을 완화)

전·후 비교 결과

구분 개선율 비고
객체 생성 시간(ms) 1069.91 1140.22 6% 저하 인코딩 비용 발생
통계 계산 시간(ms) 1852.88 1054.28 1.76x 향상  
투표 정렬 시간(ms) 2382.83 1389.87 1.71x 향상  
DB 저장 공간(byte) 8 [DATETIME(6)] 3 [MEDIUMINT] 62.5% 절감 1건 기준

 

요약하면,

  • 개별 객체 생성 시에는 인코딩/디코딩 비용만큼의 오버헤드가 생겼고
  • 컬렉션 수준의 연산(정렬, 통계 계산)과 저장소 측면에서는 그보다 큰 이득을 얻었다.

인코딩 비용

여러 케이스로 테스트를 시도해 보니 객체 생성 시간이 6%에서 심지어 30% 이상까지 저하되기도 했다.

요청값을 LocalDateTime으로 파싱하고 다시 int로 인코딩하는 과정 때문이다.

 

요청값을 바로 int로 인코딩하는 방법을 고민하다가, 발상을 전환했다.

그냥 요청에서부터 인코딩 된 정수값을 활용하면 되지 않을까?

 

프론트엔드 팀과 논의한 결과, 렌더링을 위해 전처리하는 곳에서 비트 연산 정도는 문제 없이 처리할 수 있다고 했다. 고맙다...

그래서 서버와 클라이언트 모두 인코딩 된 값으로만 통신하기로 결정했다

// Before
{ 
    // ...
    "dateTimeSlot": "2025-12-07T08:00" 
    // ...
}

// After
{
    // ...
    "dateTimeSlot": 12345
    // ...
}

 

이렇게 하면 인코딩 비용이 전혀 발생하지 않고, 네트워크 측면에서도 유의미한 개선이 있다.

 

 

추가: 응답 압축 적용

 

이때 JSON 응답을 살펴보다가 압축이 적용되지 않은 것을 발견하고, nginx에 zstd, br, gzip 순서로 응답 인코딩을 설정했다.

압축률 설정은 ChatGPT 딥리서치가 20분간 찾아준 것을 토대로 조금만 수정해서 적용했다.

너무 많은 케이스가 있어서 하나씩 테스트해 볼 엄두는 안 났다 ㅎㅎ;

# 공통: 인코딩별 캐시 분리
add_header Vary Accept-Encoding;

# gzip
gzip on;
gzip_comp_level 6;
gzip_min_length 512;
gzip_types application/json;

# brotli
brotli on;
brotli_comp_level 4;
brotli_min_length 512;
brotli_types application/json;

# zstd
zstd on;
zstd_comp_level 3;
zstd_min_length 512;
zstd_types application/json;

 

압축 결과

인코딩 평균 응답 시간 평균 크기
identity (없음) 0.000490s 33,987 bytes
gzip 0.000703s 1,021 bytes
brotli 0.000624s 1,021 bytes
zstd 0.000497s 1,299 bytes

 

약 30KB 크기의 JSON이 zstd로 압축하면 1KB 수준까지 줄었고, 응답 시간은 압축을 전혀 적용하지 않은 경우와 거의 차이가 없었다. 95% 이상 압축하면서 지연은 거의 추가되지 않는 셈이다.

 

최신 브라우저 대부분이 zstd를 지원하므로 호환성 이슈 없이 용량을 줄일 수 있다.

Safari 등 zstd 미지원 브라우저는 다음 우선순위인 br이나 gzip을 사용한다.

응답 크기가 30KB에서 1KB로 줄어들면, 초기 혼잡 윈도(IW10, 약 14KB) 안에 들어온다. TCP 연결이 이미 수립된 keep-alive 상태라면, slow start 구간에서도 추가 RTT 없이 한 번에 전송될 가능성이 높다. 네트워크 품질이 크게 나쁘지 않은 상황이라면, 압축만으로도 사실상 TCP 1-RTT를 기대할 수 있다.

도입하지 않기로 한 선택지들

24비트 인코딩을 마친 뒤에도 한 가지 고민이 남아 있었다.

참여자 1명이 수백 개의 row를 만드는 현재 구조, 더 줄일 수 없을까?

 

비정규화: row 수 자체를 줄여보자

가장 먼저 떠올린 건 비정규화였다. 현재는 참여자가 100개 슬롯에 투표하면 100개의 row가 생긴다.

participant_id date_time_slot
1 11296
1 11297
1 ...
1 (100번째)

 

비정규화하면 참여자당 row 1개로 줄어, 데이터 총량과 인덱스 트리가 작아질 수 있다.

participant_id votes
1 [11296, 11297, ..., 11395]

 

하지만 투표는 수정이 자주 일어나는 데이터다. 슬롯 하나만 바꿔도 필드 전체를 다시 써야 하고, 투표 1건 = row 1건 전제로 복합 PK와 쿼리가 짜여 있어 설계 변경 범위도 만만치 않았다.

 

그럼 문서형 NoSQL이 맞지 않을까?

비정규화의 한계를 느끼면서 자연스럽게 문서형 NoSQL을 떠올렸다. 그런데 곰곰이 생각해 보니, 투표만 NoSQL로 빼면 어중간해진다. RDB와의 트랜잭션 일관성·동기화 전략을 재설계해야 하고, 모니터링·백업·장애 대응 등 운영 비용도 늘어난다.


오히려 투표뿐 아니라 참여자, 방까지 하나의 JSON 문서로 표현하는 게 더 자연스러워 보였다. 하지만 그렇게 되면 DB 인프라 환경 자체가 바뀌어야 한다. 리팩토링 범위가 너무 커졌다.

 

결론: 보류
지금은 RDB 안에서 인코딩 방식만 바꾸는 수준으로도 성능과 저장 공간 측면에서 충분한 효과를 얻고 있다. NoSQL 전환은 추후 팀 과제로 검토해 볼 예정이다.


마무리

시간을 표현할 때 LocalDateTime은 가장 먼저 떠오르는 타입이다. 하지만 아인슈타임에서는 과거 시간에 투표할 일이 없고, 30분 단위 슬롯만 유효하다. 전체 시간축을 표현할 필요가 없었던 셈이다. 

 

당연하다고 생각한 부분을 부정하는 것에서 최적화가 시작됐다.

일반적으로 최적화라고 하면 N+1 해결, 인덱스·쿼리 개선, 캐싱, 스레드·커넥션 풀 튜닝처럼 서버 맥락에서 완결되는 작업들이 먼저 떠오른다. 이번에는 달랐다. 도메인 모델 설계부터 API 스펙 변경, 프론트엔드와의 인코딩 협업, 응답 압축까지. 아인슈타임만의 최적화였다.

 

끝!

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

아인슈타임, 투표 현황 실시간 업데이트  (0) 2025.11.29
우아콘 2025, 다녀왔습니다~  (0) 2025.10.29
레벨 1 회고  (1) 2025.04.08
[장기 미션] 회고 : 객체  (1) 2025.04.05
[블랙잭 미션] 회고 : Ace, Hit, SRP  (1) 2025.03.17