무한히 반복되는 일정을 어떻게 저장할까? — 요일 비트마스크와 최적화

SpringJPAMySQLQueryDSLDatabase

들어가며

토덕 앱의 주요 화면들

토덕(to.duck)은 일정·루틴 관리와 가벼운 커뮤니티 기능을 함께 제공하는 모바일 앱입니다 (백엔드 저장소). 그 중 루틴은 사용자가 "매주 월·수·금 아침 산책"처럼 반복되는 일정을 등록해두면, 삭제하기 전까지 지정한 요일마다 자동으로 노출되는 기능입니다.

기능 설명만 보면 단순하지만, 데이터 모델을 설계하는 단계에서 한 가지 질문이 떠올랐습니다.

루틴은 삭제될 때까지 무한히 반복된다. 그렇다면 각 날짜의 완료 여부(체크 기록)는 어디에, 얼마나 저장해야 하는가?

"매주 월·수·금" 루틴 하나를 만든 사용자가 5년 동안 토덕을 쓴다면, 그 루틴의 날짜별 칸은 약 780개입니다. 사용자가 수백 명이고 루틴이 수천 개라면, 미래의 모든 날짜에 대한 칸을 미리 만들어두는 것은 끝이 없는 데이터가 됩니다.

이 글에서는 "무한 반복"이라는 요구사항을 유한한 저장 비용으로 풀어낸 과정과, 그 안에서 내린 선택들을 살펴봅니다.

문제를 다시 정의하기

토덕 앱의 루틴 관련 화면: 루틴 목록, 생성/수정, 상세보기, 삭제 모달

핵심 제약을 먼저 분리했습니다.

  1. 반복 규칙은 유한하다. "월·수·금"이라는 규칙 자체는 루틴당 한 줄이면 충분합니다.
  2. 반복 인스턴스(날짜별 칸)는 무한하다. 루틴은 생성일 이후 어느 시점에든 활성 상태로 존재해야 하므로, 미래로 갈수록 칸이 무한히 늘어납니다.
  3. 임의 시점의 칸이 사후에 수정될 수 있다. 사용자는 오늘만이 아니라 과거나 미래의 임의 날짜에 대해서도 완료/미완료를 토글할 수 있어야 합니다. 즉 모든 날짜가 잠재적인 "변경 대상"입니다.
  4. 하지만 실제로 손대는 인스턴스는 극히 일부다. 대부분의 날짜 칸은 "아무 일도 일어나지 않은" 상태로 머뭅니다. 사용자가 완료 체크를 하거나 특정 날짜의 루틴을 지운 경우에만 "기록할 거리"가 생깁니다.

여기서 방향이 잡혔습니다. 모든 칸을 저장하지 말고, 규칙만 저장한 뒤 조회 시점에 그 날짜의 칸을 계산하자. 그리고 사용자가 실제로 상태를 바꾼 칸(완료/삭제)만 예외적으로 레코드에 남기자.

캘린더 표준 패턴

글을 정리하면서 알게 된 것인데, 비슷한 형태의 접근은 캘린더 업계에서 오래전부터 사용해 온 표준 패턴이었습니다. iCalendar(RFC 5545)의 반복 일정 모델이 거의 같은 구조로, 반복 규칙(RRULE)을 저장하고, 조회 시점에 인스턴스를 펼치며(expand), 사용자가 개별적으로 수정·삭제한 날짜만 예외(EXDATE / RECURRENCE-ID)로 따로 보관합니다. Google·Apple·Outlook 캘린더가 모두 이 방식을 따릅니다. 도메인 제약을 따라가다 같은 결론에 도달한 셈입니다.

대안 비교

데이터 모델 선택은 두 단계의 결정으로 나뉩니다.

1단계: 인스턴스 처리 전략

전략설명결론
Eager materialization모든 미래 날짜의 루틴 인스턴스를 미리 행으로 생성탈락 (무한 반복이라 행이 무한 증가)
지연 합성규칙만 저장하고 조회 시점에 인스턴스를 계산채택

엄밀히 말하면 가까운 미래만 미리 생성하고 먼 미래는 지연 합성으로 두는 하이브리드 방식도 존재합니다. 다만 이는 모델을 정한 뒤의 사후 최적화 영역이므로 이 단계에서는 다루지 않습니다. 이하 모든 비교는 순수 지연 합성을 전제로 진행합니다.

2단계: 반복 규칙 표현 방식

지연 합성으로 정한 뒤에는 "요일 반복 규칙을 어떻게 저장할지"가 남습니다. 세 가지를 검토했습니다.

방식저장조회한계
① 연관 테이블(routine_day)루틴당 요일 N행조인 후 필터활성 요일만큼 행 증식(최대 7배), 정규화 오버헤드
② 문자열/배열 컬럼루틴당 1컬럼("MON,WED,FRI")FIND_IN_SET / JSON_CONTAINS인덱스 미지원, 매 쿼리 문자열 파싱 비용
③ 요일 비트마스크루틴당 1컬럼(7비트)단일 쿼리 비트 AND표현력이 "매주 특정 요일"로 제한, 비트 AND는 sargable 아님

②는 FIND_IN_SET('MON', days)JSON_CONTAINS 같은 함수로 DB 레벨에서 다룰 수는 있지만, 이들 함수는 인덱스를 타지 못하고 매 행에서 문자열 파싱이 들어갑니다. 7개 고정 요일을 다루기에는 비용 대비 이득이 거의 없어 일찍 탈락시켰습니다.

①은 정규화 관점에서 정석이고 확장에 유연합니다. (user_id, day_of_week) 같은 복합 인덱스로 잘 튜닝하면 조인 자체의 비용도 작습니다. 다만 표현해야 할 경우의 수가 요일 7개로 고정된 작은 집합인데, 별도 테이블·행 증식·조인이라는 정규화 비용을 모두 치르는 것이 균형에 맞지 않았습니다.

토덕의 반복 규칙은 "매주 특정 요일에 반복" 한 가지뿐입니다(격주·매월 N번째 같은 복잡한 규칙은 요구사항에 없었습니다). 표현할 경우의 수가 작고 고정되어 있다면, ③의 단일 컬럼 + 비트 연산이 정규화 오버헤드와 조인을 모두 회피하면서 가장 간단합니다. 비트 AND가 인덱스를 못 탄다는 한계는 이미 user_id로 선스코핑되는 접근 패턴에서 비용이 사실상 없어 받아들일 수 있는 트레이드오프였습니다.

비트필드는 새로 만든 표현이 아니다

표현할 경우의 수가 고정된 작은 집합을 비트로 인코딩하는 방식은 시스템 소프트웨어 곳곳에서 이미 오래 쓰여 온 관용구입니다. UNIX cron은 분·시·일·월·요일을 비트셋으로 표현하고, UNIX 파일 권한 모드(0755)는 사용자/그룹/기타의 읽기·쓰기·실행을 비트로 인코딩합니다. Linux capabilities, Discord 권한 시스템도 같은 형태입니다. ③을 골랐다는 것은 새로운 발명이 아니라, 검증된 관용구를 우리 도메인에 맞게 가져온 것에 가깝습니다.

구현 1 — 요일을 7비트로

요일 비트마스킹 처리
요일 비트마스킹 처리

월요일부터 일요일까지를 각 비트에 매핑합니다. java.time.DayOfWeek가 월요일=1 … 일요일=7이므로, 1 << (value - 1)로 비트 위치를 정합니다.

비트 연산 로직이 도메인 곳곳에 흩어지면 읽기 어려워지므로, 불변 VO 하나에 전부 캡슐화했습니다.

public class DaysOfWeekBitmask {
    private static final byte MIN_VALID_BITMASK = 0x01; // 0000001
    private static final byte MAX_VALID_BITMASK = 0x7F; // 1111111

    private final byte bitmask;

    private DaysOfWeekBitmask(byte bitmask) {
        if (bitmask < MIN_VALID_BITMASK) {
            throw new IllegalArgumentException("Invalid bitmask...");
        }
        this.bitmask = bitmask;
    }

    public static DaysOfWeekBitmask createByDayOfWeek(List<DayOfWeek> daysOfWeek) {
        byte bitmask = (byte) daysOfWeek.stream()
            .mapToInt(DaysOfWeekBitmask::getDayBitmask)
            .reduce(0, (a, b) -> a | b);
        return new DaysOfWeekBitmask(bitmask);
    }

    public static byte getDayBitmask(DayOfWeek day) {
        return (byte) (1 << (day.getValue() - 1));
    }

    public boolean includesDay(DayOfWeek day) {
        return (bitmask & getDayBitmask(day)) != 0;
    }

    public Set<DayOfWeek> getDaysOfWeek() {
        return Arrays.stream(DayOfWeek.values())
            .filter(this::includesDay)
            .collect(Collectors.toCollection(() -> EnumSet.noneOf(DayOfWeek.class)));
    }
}

createByDayOfWeek는 루틴 생성·수정 시 요청으로 들어온 요일 리스트를 OR로 합쳐 하나의 바이트로 만들고, includesDay는 AND로 특정 요일 포함 여부를 판별합니다. 외부에서는 비트 연산을 전혀 알 필요 없이 List<DayOfWeek> ↔ VO 로만 대화합니다.

만약 "평일에만 반복되는 루틴인가" 같은 조합 판별이 필요하다면, 같은 비트 표현 위에서 두 줄로 표현됩니다.

byte WEEKDAYS = MONDAY | TUESDAY | WEDNESDAY | THURSDAY | FRIDAY;
boolean isWeekdayRoutine = (bitmask & WEEKDAYS) != 0;

요일 집합의 합집합·교집합·포함 관계가 모두 비트 OR/AND 한 번에 처리됩니다.

DB에는 이 VO를 byte 한 컬럼으로 저장합니다. JPA AttributeConverter로 변환을 자동화하면 엔티티 코드에는 비트 표현이 직접 노출되지 않습니다.

@Converter
public class DaysOfWeekBitmaskConverter implements AttributeConverter<DaysOfWeekBitmask, Byte> {
    @Override
    public Byte convertToDatabaseColumn(DaysOfWeekBitmask attribute) {
        return attribute.getValue();
    }

    @Override
    public DaysOfWeekBitmask convertToEntityAttribute(Byte dbData) {
        return DaysOfWeekBitmask.from(dbData);
    }
}
@Entity
public class Routine {
    // ...
    @Convert(converter = DaysOfWeekBitmaskConverter.class)
    @Column(name = "days_of_week", nullable = false)
    private DaysOfWeekBitmask daysOfWeekBitmask;
    // ...
}

엔티티는 DaysOfWeekBitmask 타입만 노출하고, 영속화 계층에서 Byte로의 변환은 컨버터가 책임집니다. 도메인 객체, 비트 연산 로직, 저장 표현이 각자의 책임으로 분리됩니다.

DaysOfWeekBitmask는 단일 날짜 매칭(includesDay)뿐 아니라 기간 내 매칭 날짜 스트림(streamMatchingDatesInRange)도 제공합니다. 예를 들어 "월·수·금" 루틴에서 6월 한 달의 매칭 날짜를 뽑으면 [6/2, 6/4, 6/6, 6/9, 6/11, ...] 같은 시퀀스가 나옵니다. 같은 표현 위에서 양방향 활용이 가능하고, 호출부는 비트 시프트나 마스킹을 직접 다루지 않습니다.

같은 VO는 일정(Schedule) 도메인에서도 그대로 재사용됩니다. 토덕에는 루틴 외에 일정도 "매주 특정 요일 반복" 형태를 가지는데, VO를 공용 모듈(im.toduck.global.helper.DaysOfWeekBitmask)에 두었기 때문에 일정 엔티티는 자기 도메인의 Converter만 붙여 같은 표현을 그대로 씁니다.

구현 2 — 단일 쿼리로 "그 날짜의 활성 루틴" 조회

조회 시나리오는 두 갈래입니다.

  • 사용자 화면 조회: 사용자가 토덕을 열어 "오늘 내 루틴", "이번 주 월요일에 해야 할 일" 같은 화면을 봅니다. 한 사용자가 특정 요일에 대해 조회하는 형태입니다.
  • 일간 알림 배치: 매일 새벽 정해진 시각에 그날 알림이 필요한 루틴을 전 사용자에서 식별해 알림 대상으로 등록합니다. 전 사용자가 같은 특정 요일에 대해 일괄 식별되는 형태입니다.

두 시나리오 모두 핵심 연산은 "이 날짜의 요일에 해당하는 루틴인가" 한 가지로 동일합니다. 이 매칭이 DB 레벨에서 그대로 처리된다는 점도 비트마스크를 고른 이유 중 하나였습니다. MySQL은 모든 정수 타입에 대해 비트 연산자(&, |, ^, << 등)를 1차 시민으로 지원하므로, BIT(7) 같은 특수 타입을 도입하지 않아도 TINYINT 컬럼에 표준 SQL 비트 AND를 그대로 적용할 수 있습니다. Hibernate의 function('bitand', ...) 호출도 MySQL dialect에서 네이티브 & 연산자로 변환되어 함수 호출 오버헤드 없이 실행됩니다.

또 하나의 책임 분리가 들어가 있습니다. 애플리케이션 코드는 DaysOfWeekBitmask VO 위에서 includesDay(MON) 같은 도메인 표현으로 비트를 다루고, 영속화·조회는 DB가 자기 언어(네이티브 비트 AND)로 같은 byte 값을 직접 처리합니다. 두 층이 같은 byte 표현을 각자의 언어로 다루는 셈입니다. VO는 도메인 가독성을, DB는 실행 효율을 가져갑니다.

이제 "특정 날짜에 반복되어야 하는 루틴"을 찾는 일은, 그 날짜의 요일 비트가 켜진 루틴을 찾는 일과 같습니다. MySQL의 비트 AND를 QueryDSL 템플릿으로 표현했습니다.

private BooleanExpression routineMatchesDate(final LocalDate date) {
    byte dayBitmask = DaysOfWeekBitmask.getDayBitmask(date.getDayOfWeek());

    return Expressions.numberTemplate(
        Byte.class,
        "function('bitand', {0}, CAST({1} as byte))",
        qRoutine.daysOfWeekBitmask, dayBitmask
    ).gt((byte) 0);
}

이 조건 하나만으로 끝나는 것은 아니고, 실제 쿼리는 다음 다섯 조건의 조합입니다.

public List<Routine> findUnrecordedRoutinesByDateMatchingDayOfWeek(
    final User user,
    final LocalDate date,
    final List<RoutineRecord> routineRecords
) {
    return queryFactory
        .selectFrom(qRoutine)
        .where(
            qRoutine.user.eq(user),              // ① 사용자 스코핑
            scheduleModifiedOnOrBeforeDate(date), // ② 규칙 효력일 컷오프
            routineNotRecorded(routineRecords),   // ③ 이미 기록된 루틴 제외
            routineMatchesDate(date),             // ④ 요일 비트 매칭
            routineNotDeleted()                   // ⑤ 삭제되지 않은 루틴만
        )
        .fetch();
}

①에서 인덱스로 한 사용자의 루틴만 좁히고, ②~⑤를 잔여 조건으로 적용합니다. 별도 연관 테이블이 없고 조인도 없습니다.

비트 조건만으로는 풀스캔이 되는데?

bitand(col, x) > 0 같은 비트 조건은 sargable하지 않아 단독으로는 인덱스를 활용할 수 없고, 회피할 실용적 방법도 거의 없습니다(요일별 functional index를 7개 만들거나, 토요일 켜진 64개 값을 IN 절로 나열하는 우회가 가능하지만, selectivity가 낮아 옵티마이저가 풀스캔으로 폴백하거나 비트마스크 본래의 컴팩트함이 무너집니다). 다만 이 쿼리는 항상 user_id로 먼저 스코핑되므로, 인덱스로 한 사용자의 루틴(보통 수십 개)만 추린 뒤 비트 필터를 잔여 조건으로 적용합니다. 실제 접근 패턴에서는 비용이 거의 없습니다. 사용자 스코핑이 없는 전역 쿼리(예: 알림 배치)에서는 이 점이 그대로 풀스캔으로 이어진다는 점은 기억해 둘 필요가 있습니다.

범위 조회로의 확장

같은 비트 AND 패턴은 "기간 내 활성 루틴" 조회로도 자연스럽게 확장됩니다. DaysOfWeekBitmask.createFromDateRange로 기간 내 요일들을 OR로 합쳐 범위 비트마스크를 만들면, 단일 요일 비트가 범위 요일 비트로 바뀔 뿐 같은 비트 AND 한 줄로 처리됩니다.

이 패턴은 일간 알림 배치에서 핵심적으로 쓰입니다. 매일 새벽 정해진 시각에 그날 알림 대상이 되는 루틴을 전 사용자에서 한 쿼리에 식별합니다.

public List<Routine> findActiveRoutinesWithReminderForDates(LocalDate startDate, LocalDate endDate) {
    return queryFactory
        .selectFrom(qRoutine)
        .where(
            routineNotDeleted(),
            hasReminderEnabled(),
            routineMatchesDateRange(startDate, endDate)  // 범위 비트 AND
        )
        .fetch();
}

다만 이 전역 조회에서는 user_id 선스코핑이 없어 비트 필터가 풀스캔 위에서 동작합니다.

JPQL/QueryDSL에서 MySQL 비트 함수 호출

JPQL은 표준 함수 집합만 정의하고 있어서 MySQL의 & 비트 연산자를 직접 사용할 수 없습니다. Expressions.numberTemplatefunction('bitand', ...) 형태로 감싸면 Hibernate가 dialect에 등록된 함수로 변환합니다. 같은 표현이 MySQL에서는 BIT_AND 또는 &, 다른 DB에서는 각자의 비트 함수로 매핑되므로, 도메인 코드는 SQL 방언에서 분리된 채로 유지됩니다.

구현 3 — 조회 시점의 지연 합성

마지막 단계는 "그 날짜에 보여줄 루틴 목록"을 만드는 일입니다. 두 종류의 데이터를 따로 조회해 합칩니다.

날짜별 루틴 조회의 두 갈래 조회와 Usecase 병합 흐름
날짜별 루틴 조회: 기록된 루틴 + 미기록 활성 루틴을 따로 조회한 뒤 Usecase에서 병합
  • 이미 기록된 루틴: 사용자가 완료 체크를 했거나 특정 날짜에서 삭제한 루틴 → 실제 RoutineRecord 레코드로 존재
  • 미기록 활성 루틴: 그 날짜의 요일에 해당하지만 아직 아무 기록도 없는 루틴 → 위 비트 쿼리로 계산해서 "미완료" 상태로 합성
@Transactional(readOnly = true)
public MyRoutineRecordReadListResponse readMyRoutineRecordList(Long userId, LocalDate date) {
    User user = userService.getUserById(userId)
        .orElseThrow(() -> CommonException.from(ExceptionCode.NOT_FOUND_USER));

    List<RoutineRecord> routineRecords = routineRecordService.getRecordsIncludingDeleted(user, date);

    List<Routine> routines = routineService.getUnrecordedRoutinesForDate(user, date, routineRecords);
    List<RoutineRecord> activeRoutineRecords = routineRecords.stream()
        .filter(record -> !record.isInDeletedState())
        .toList();

    return RoutineMapper.toMyRoutineRecordReadListResponse(
        DailyRoutineData.of(date, routines, activeRoutineRecords)
    );
}

미기록 루틴을 계산할 때는 이미 기록된 루틴을 NOT IN으로 제외합니다. 활성 루틴 쿼리는 "그 요일에 반복되는 루틴"을 모두 가져오기 때문에, 이미 기록이 있는 루틴은 기록된 루틴 쪽에서 처리되어야 두 갈래 결과가 중복되지 않습니다. 즉 한 루틴은 둘 중 어느 한 갈래에만 등장해야 합니다.

또 기록된 루틴 조회에는 삭제 상태의 기록까지 함께 포함됩니다. 이는 NOT IN 필터링용으로 필요하기 때문입니다(삭제된 인스턴스가 있는 루틴은 미기록 활성 루틴 쪽에서 다시 합성되면 안 됨). 다만 응답을 만들 때는 삭제 상태 기록을 걸러냅니다. 사용자에게는 "삭제된 인스턴스"가 노출될 이유가 없으니까요.

결과적으로 사용자는 "그 날짜에 해야 할 루틴 + 완료 여부"를 정확히 받지만, DB에는 실제로 상태가 바뀐 칸만 남습니다.

요청 처리의 동시성 이슈

지연 합성을 선택한 순간 따라오는 트레이드오프가 하나 있습니다. 같은 (routine, date)에 대해 두 요청이 거의 동시에 들어오면(일명 "따닥" 이슈), 두 트랜잭션이 동시에 "기록 없음"을 보고 둘 다 새 레코드를 만들 수 있다는 점입니다.

실제로 운영 중에 "루틴이 복제되어 보인다"는 사용자 CS가 접수됐습니다. 추적해 보니 iOS 클라이언트의 구현 실수와 네트워크 지연이 겹쳐 같은 완료 처리 요청이 거의 동시에 두 번 도착했고, 같은 (routine, date)에 대해 RoutineRecord가 두 건 생성된 결과였습니다.

토덕에서는 분산락으로 이 구간을 순차 처리합니다.

@Transactional
public void updateRoutineCompletion(Long userId, Long routineId, RoutinePutCompletionRequest request) {
    // ... user, routine 조회 ...

    String lockKey = "routine:" + routineId + ":date:" + date;
    distributedLock.executeWithLock(lockKey, () -> {
        if (routineRecordService.updateIfPresent(routine, date, isCompleted)) {
            return;  // 이미 기록이 있으면 업데이트
        }
        if (!routineService.canCreateRecordForDate(routine, date)) {
            throw CommonException.from(ExceptionCode.ROUTINE_INVALID_DATE);
        }
        routineRecordService.create(routine, date, isCompleted);  // 없으면 생성
    });
}

락 키를 routine:{routineId}:date:{date} 단위로 잡았습니다. 락 단위가 (사용자, 루틴, 날짜) 조합으로 좁혀져 있어, 같은 사용자의 다른 루틴이나 다른 날짜 요청은 서로를 블로킹하지 않습니다. 락 안에서는 "있으면 업데이트, 없으면 생성"이라는 단순한 흐름만 수행합니다.

복합 유니크 제약, MySQL Upsert, 낙관적 락 등을 검토했지만, 기존 로직 변경 최소화와 향후 분산 환경 확장성을 고려해 Redis 기반 분산락을 선택했습니다.

한계와 확장 방향

현재 토덕 규모에는 적절하지만, 한 가지 한계는 짚어둘 필요가 있습니다.

읽기 확장성: 사용자 스코핑이 없는 전역 조회는 비트 필터가 풀스캔 위에서 돕니다. 루틴이 수백만으로 커지면 알림 배치처럼 user 스코핑 없이 도는 쿼리에서 병목이 발생합니다.

확장 방향으로 자주 거론되는 방식은 하이브리드 materialization입니다. 가까운 날짜 구간만 실제 행으로 미리 생성해 인덱스 가능한 조회로 처리하고, 먼 미래는 규칙으로 계산하는 방식입니다(Asana가 약 2주치 미래 인스턴스를 자동 생성하는 것으로 알려져 있고, Google Calendar 같은 도구도 유사한 하이브리드 접근을 사용합니다).

토덕은 이 확장에 유리한 조건을 이미 갖추고 있습니다. 매일 새벽 도는 알림 배치 스케줄러(Spring @Scheduled 기반)가 운영 중이고, RoutineRecord 자체가 인스턴스의 상태를 담는 행 구조라 materialize된 인스턴스 행 역할까지 그대로 수행할 수 있습니다.

마치며

"무한히 반복되는 일정"이라는 요구사항은, 결국 무엇을 저장하고 무엇을 계산할지에 대한 문제였습니다. 규칙은 저장하고 인스턴스는 계산하며, 사용자가 실제로 상태를 바꾼 예외만 레코드로 남긴다. 이 경계를 잘 긋는 것이 핵심이었고, 그 경계는 많은 캘린더에서 오래전부터 정립되어 온 패턴이기도 했습니다.

비트마스크는 그 위에 얹은 도메인 특화 최적화입니다. "표현할 경우의 수가 요일 7개로 고정"이라는 제약을 활용해 연관 테이블을 단일 byte 컬럼으로 축약한 형태입니다. 비트 표현 하나로 도메인을 간결하게 풀어낸 부분이 이번 작업에서 가장 기억에 남습니다.

RFC 5545 — iCalendar
Internet Calendaring and Scheduling Core Object Specification (iCalendar)
〜〜〜