작은 수정이 전체를 깨뜨리기 시작했을 때: 옐로우버스 DB 재설계 이야기
데이터베이스 재설계를 통해 옐로우버스 일정 시스템의 핵심 문제를 해결한 과정에 대해 공유합니다.
안녕하세요! 더스윙에서 백엔드 개발을 담당하고 있는 명수찬입니다.
옐로우버스의 학원 셔틀버스 운행 시스템은 수년간 기능을 확장하고 고객 규모가 커지면서 빠르게 성장해 온 서비스입니다.
실제로 서비스 초기였던 2020년에는 연간 약 2만 4천 회 수준이던 운행량이, 2023년에는 약 21만 회, 그리고 2025년에는 약 70만 회 수준까지 증가하며 짧은 기간 동안 30배 이상 성장을 기록했습니다.
그러나 서비스의 복잡도와 규모가 증가할수록, 초기 단계에서 구축된 데이터베이스 구조는 그 성장을 충분히 뒷받침하지 못하고 있었습니다.
특히 운행일정과 탑승일정처럼 파이프라인이 촘촘하게 얽힌 도메인에서 문제는 더욱 두드러졌습니다. 우리는 어느 순간부터 작은 수정 하나가 시스템 전체로 파급되는 나비 효과를 겪고 있었습니다.
이 구조 위에서 기능을 더하고 당장의 문제를 해결하는 것은 가능했지만, 지속 가능한 형태는 아니었습니다.
데이터 계층은 애플리케이션 코드보다 훨씬 변경 비용과 리스크가 크며, 잘못된 수정은 곧바로 운영 장애나 데이터 불일치로 이어질 수 있습니다.
그럼에도 불구하고, 옐로우버스의 일정 시스템은 더는 ‘땜질식 개선’으로 유지할 수 있는 수준이 아니었습니다.
결국 우리는 시스템을 근본부터 다시 설계하는 것만이 지속 가능한 선택이라고 판단했습니다.
이 글에서는 우리가 겪었던 구조적 문제와 이를 해결하기 위해 도입한 설계 전략, 그리고 그 결과를 공유하고자 합니다.
재설계를 불가피하게 만든 구조적 문제들
문제를 해결하기 위해선 먼저 현상을 정확히 진단해야 했습니다. 옐로우버스 일정 시스템은 크게 두 가지 축으로 구성됩니다.
- 버스가 언제, 어디를 운행하는가 — 운행일정
- 학생이 언제, 어디서 타고 내리는가 — 탑승일정
두 축은 서로 밀접하게 연결되어 있고, 실제 운영에서는 항상 함께 움직입니다. 문제는 이 두 영역이 시간이 지나면서 변경에 극도로 취약한 구조로 굳어졌다는 점이었습니다.
아래에서는 운행일정과 탑승일정 각각에서 어떤 문제가 있었는지, 그리고 두 도메인이 어떻게 서로의 변경을 증폭시키고 있었는지 순서대로 살펴보겠습니다.
1. 끝없이 파편화되는 운행일정

운행일정은 특정 기간 동안 반복적으로 운행되는 노선, 기사, 동승매니저 정보 등을 포함하는 데이터입니다.
운행일정은 통상 긴 기간으로 생성되지만, 운영 현실에서는 빈번한 수정이 발생합니다. 가령 "1년 치 일정 중 특정 한 달(anchor period)만 경로를 변경한다"는 단순한 요구사항이 들어오면, 기존 시스템은 이를 다음과 같이 운행일정을 물리적으로 분할했습니다.
- Anchor Period 이전의 운행일정
- Anchor Period 기간 동안의 운행일정
- Anchor Period 이후의 운행일정
하나였던 운행일정이 세 개의 별도 운행일정으로 분리되는 것입니다.

이 패턴이 반복되면 어떤 일이 벌어질까요?
운영 과정에서 하루 단위로라도 수정이 발생할 때마다 해당 기간이 물리적으로 세 개로 분리되며 새로운 운행일정이 추가됩니다. 결과적으로 운행일정은 시간이 지날수록 끝없이 잘게 쪼개지고, 원래의 흐름을 추적하기 어려운 조각난 데이터 집합으로 변하게 됩니다.
2. 기하급수적으로 증가하는 탑승일정 인스턴스

학생의 탑승일정은 두 가지 레이어로 구분할 수 있습니다.
- 반복되는 일정의 패턴
(화-목 / A 정류장 / 학생 1 / 3월~6월) - 패턴이 실현된 실제 인스턴스
(3월 2일 화요일 / A 정류장 / 학생 1)
패턴은 일정이 반복되는 구조를 정의하고, 인스턴스는 해당 패턴이 특정 날짜에 적용될 때 파생될 수 있는 실제 일정입니다.
기존의 구조는 탑승일정의 패턴이 생성되는 순간, 해당 기간 동안 발생 가능한 모든 날짜의 인스턴스를 즉시 물리적으로 생성(eager generation)하였습니다.
이 방식은 치명적인 비효율을 낳았습니다.
학생 10명이 1년 치 일정을 등록하면 약 2,600개의 인스턴스가 즉시 생성됩니다. 패턴을 수정해야 할 경우, 이미 생성된 수천 개의 인스턴스를 검색 → 삭제 → 재계산 → 재생성해야 했습니다.
이후 탑승일정의 패턴에 변경이 재발생할 때마다, 이미 생성되고 변경된 수천 개의 인스턴스를 또다시 모두 처리해야 했고, 이는 데이터베이스 I/O를 불필요하게 낭비할 뿐만 아니라, 수정 로직을 매우 무겁게 만들었습니다.
3. 운행·탑승일정 간 강한 결합이 만든 비용 폭증
운행일정과 탑승일정은 서로 독립적인 데이터가 아닙니다. 학생의 탑승일정은 특정 운행일정에 연결되어 있고, 운행일정의 기간이나 경로가 변경되면 그 변화는 그대로 탑승일정에도 전파됩니다.
앞서 언급한 대로 운행일정이 수정으로 인해 물리적으로 파편화되면, 이를 참조하던 탑승일정 패턴 역시 강제로 분리되어야만 했습니다. 이는 곧 미리 생성되어 있던 수천 개의 인스턴스를 모두 폐기, 변경, 생성해야 하는 대규모 트랜잭션으로 이어졌습니다.
이러한 구조적 결합이 실제 운영에서 어떤 비용으로 나타났는지를 살펴보기 위해, 2025년 한 해 동안 일정 생성과 변경으로 인해 실제로 영향을 받은 데이터 로우 수를 집계해 보았습니다.
여기서 말하는 ‘변경’은 변경 이벤트의 횟수가 아니라, 수정·삭제·재삽입으로 인해 실제로 영향을 받은 데이터 로우 수 기준입니다.
|
구분 |
최초 생성 로우 수 |
변경으로 영향을 받은 로우 수 |
|---|---|---|
|
운행일정 |
34만 |
120만 |
|
탑승일정 패턴 |
46만 |
260만 |
|
탑승일정 인스턴스 |
1,079만 |
3,600만 |
표에서 볼 수 있듯이, 운행일정과 탑승일정 패턴, 그리고 실제 실행 단위인 인스턴스로 내려갈수록 최초 생성된 데이터 대비 변경으로 영향을 받은 데이터의 규모는 기하급수적으로 증가했습니다.
결국 운행일정의 아주 사소한 수정 하나가 도메인 전체를 뒤흔드는 거대한 데이터 변환 작업이 되어버린 셈입니다. 더 큰 문제는 이러한 수정이 반복될수록 데이터의 파편화가 심화되어, 나중에는 원본 데이터의 맥락조차 추적하기 어려워진다는 점이었습니다.
현실의 지극히 자연스러운 행위가 시스템 전체의 리소스를 사용하는 구조적 병목이 되었음을 알게 된 시점에서 우리는, “어디를 먼저 고쳐야 할까?”가 아니라, “애초에 어떤 관점으로 다시 설계해야 할까?” 를 고민하기 시작했습니다.
재설계 시작하기 - 세 가지 전략
문제를 파고들며, 우리는 이 문제를 하나의 근본 원인으로 정의할 수 있었습니다.
시스템이 데이터를 너무 쉽게 변경하도록(mutable) 설계되어 있었고 그 변경이 통제 불가능한 연쇄 반응을 일으킨다는 점이었습니다.
기존 구조에서 운행일정은 긴 기간을 하나의 레코드로 표현하면서 언제든 UPDATE로 덮어씌워질 수 있었습니다. 탑승일정 또한 미래의 데이터를 미리 생성해 둔 탓에, 변경이 발생하면 대량의 UPDATE와 재생성 작업을 피할 수 없었습니다.
그래서 우리는 질문의 방향을 틀어보았습니다.
이 데이터를 불변(immutable)으로 관리할 수는 없을까?
한 번 기록된 값은 바꾸지 않고, 그 위에 새로운 상태를 쌓아 올리는(append-only) 형태로 만들 수는 없을까?
여기서 말하는 불변이란, 값이 영원히 고정된다는 의미가 아닙니다. 기존 레코드는 그대로 보존한 채, 변경 사항을 새로운 레코드로 추가(insert)하여 시간에 따른 상태 변화(history)를 명확히 기록하는 구조를 의미합니다.
결국 우리가 해결해야 했던 본질적인 모순은 이것이었습니다.
변경은 자연스러운 일인데, 왜 우리 시스템에서만 이렇게 비싸고, 추적하기 어려울까?
Strategy #1 - Append-only Model
UPDATE 대신 INSERT로 변경을 표현하기
우리 팀은 일정 도메인의 특성상, UPDATE 방식이 유발하는 비용과 위험이 과도하다고 판단했습니다.
1. UPDATE는 잠금(lock)과 경합(contention)을 유발합니다.
특히 운행일정처럼 “한 레코드가 넓은 기간을 대표하는 구조”에서는 해당 레코드가 병목 지점이 되어 동시성 제어가 어려워지고, 운영 중 잦은 변경이 발생할 경우 성능 저하와 경쟁 상황이 더욱 두드러집니다.
2. UPDATE는 정합성(consistency) 문제를 쉽게 만들 수 있습니다.
운행일정은 독립된 데이터가 아니라 API, 배치 작업, 운행을 이루는 각 참여자, 그리고 탑승일정 등 수많은 파이프라인과 거미줄처럼 얽혀 있습니다. 단 하나의 UPDATE가 도메인 전체에 도메인 전체에 연쇄적인 변경(cascade)을 요구하는 경우가 빈번했고, 이 과정에서 트랜잭션이 실패하거나 일부 데이터가 갱신될 경우 참조 무결성이 깨지는 위험이 존재했습니다.
3. UPDATE는 추적(tracebility)과 복구(rollback)를 어렵게 합니다.
한 번 UPDATE가 실행되면, 잘못된 수정이 뒤늦게 발견되더라도 정확히 "누가, 언제, 무엇을, 왜, 어떤 순서로 덮어썼는지" 추적하기가 매우 어렵습니다. 상태가 시시각각 변하는 일정 도메인에서 UPDATE는 문제의 재현 가능성(reproducibility)을 떨어뜨리고 문제 해결 시간을 불필요하게 늘립니다.

우리는 이러한 구조적 위험을 해소하기 위해, 일정의 변경을 수정(mutation)이 아닌 추가(append)로 표현하는 Append-only Architecture로 전환했습니다.
이 구조의 핵심 열쇠는 바로 override_weight 입니다.
변경된 구조는 다음과 같습니다.
- Logical Grouping
모든 운행일정은 동일한 논리적 식별자를 공유합니다 - Append
일정이 변경되면 해당 변경 구간에 대한 새로운 레코드를 INSERT 합니다 - Weighting
새 레코드는 이전 레코드보다 더 높은override_weight값을 부여받습니다. - Resolution
조회 시점에는 같은 식별자 그룹 내, 특정 날짜에 대해override_weight가 가장 높은 레코드 하나만을 유효한 일정으로 판별합니다.
즉, 동일한 운행일정이 시간이 지나며 여러 “버전”으로 누적되더라도, 각 날짜에 대해 최종적으로 어떤 일정이 유효한지는 override_weight를 기준으로 일관성 있게 결정할 수 있는 구조가 됩니다.

데이터베이스 내부를 들여다보면, 마치 여러 장의 레이어가 겹쳐 있는 것처럼 보일 수 있습니다. 하지만 이를 시간 축 위에서 평탄화 하면 이야기는 단순해집니다.
각 시간 구간마다 가장 가중치(weight)가 높은 레코드만이 위로 드러나고, 나머지는 가려지게 됩니다. 결과적으로 사용자는 복잡한 이력 속에서도 항상 "현재 유효한 최신 운행일정" 만을 바라보게 됩니다.
Strategy #2 - Decoupling
운행일정과 탑승일정 사이의 변경 전파 끊기
두 번째로 우리가 해결해야 했던 문제는 운행일정의 변경이 탑승일정으로 무조건 전파되는 강한 결합(strong coupling) 구조였습니다.
기존 구조에서는 탑승일정이 운행일정의 물리적 레코드를 직접 참조했습니다. 이 말은 곧, 운행일정이 조금이라도 변경되거나 분할될 때, 이를 참조하는 모든 탑승일정도 그 물리적 변화를 따라가야 함을 의미합니다.

우리는 이 문제를 해결하기 위해 탑승일정이 더 이상 변하는 물리적 레코드를 바라보지 않고, 변하지 않는 "논리적 식별자"를 바라보도록 의존성을 재설계했습니다.
이제 탑승일정은 운행일정의 구체적인 레코드 ID를 알지 못하며, 단지 논리적인 연결 고리만 보유합니다. 실제 두 데이터의 매칭이 필요한 시점에 시스템은 해당 논리적 식별자 그룹 내에서 override_weight가 가장 높은 운행일정을 동적으로 조회합니다.
이러한 구조 변경을 통해 우리는 두 가지 중요한 이점을 얻었습니다.
1. 도메인 간 독립성 확보(Loose Coupling)
탑승일정은 이제 운행일정의 내부 사정(레코드 분할, 버전 변경 등)에 전혀 영향을 받지 않습니다. 운행일정 내부에서 수백 번의 INSERT와 버전 변경이 일어나더라도, 탑승일정이 바라보는 논리적 식별자는 불변하기 때문입니다.
즉, 운행일정 쪽에서 어떤 내부 변화가 일어나더라도 탑승일정은 그 변화에 끌려가지 않는 느슨한 연결(loose coupling) 관계가 됩니다.
2. 변경의 국소화(Locality of Change) 확보
탑승일정 자체는 필요하다면 변경할 수 있습니다. 다만 중요한 것은, 운행일정의 변경 때문에 탑승일정이 연쇄적으로 변경되는 기존의 구조적 문제를 제거했다는 점입니다.
탑승일정이 어떤 운행일정과 매칭될지는 조회 시점(runtime)에 결정되므로, 데이터 저장 시점에는 서로의 상태를 신경 쓸 필요가 없어졌습니다.
- 운행일정 변경 → 탑승일정 영향 없음
- 탑승일정 변경 → 탑승일정 도메인 내부에서만 처리

이 구조는 객체지향 설계에서 자주 이야기하는
“구현이 아니라 인터페이스에 의존하라"
(DIP: Dependency Inversion Principle)
는 원칙과도 맥락을 같이 합니다.
탑승일정은 이제 구체적이고 불안정한 레코드가 아니라, 추상적이고 안정적인 논리적 식별자에 의존합니다.
나아가, 우리는 탑승일정 자체에도 앞서 언급한 Append-only 모델을 적용했습니다.
탑승일정을 변경해야 할 때도 기존 데이터를 UPDATE 하는 대신, override_weight를 높인 새 레코드를 INSERT 합니다.
결과적으로 두 도메인 모두 기존 데이터는 보존하고(append-only), 서로의 변경에는 영향을 받지 않는(decoupled) 견고한 구조를 갖추게 되었습니다.
Strategy #3 - Lazy Generation
탑승일정은 필요할 때만 생성
마지막으로 우리는 탑승일정의 생성 시점을 다시 생각했습니다.
기존 시스템은 패턴이 정의되는 순간, 해당 기간 전체에 대한 날짜별 인스턴스를 즉시 생성(eager generation) 하였습니다.
문제는 이 방식이 아직 발생하지 않은 상태(state)를 대량으로 미리 만들어놓고 관리한다는 점입니다.
탑승일정의 인스턴스는 “실제로 탑승이 발생하는 시점”에 비로소 의미를 가집니다, 하지만 Eager Generation 방식은 일어나지 않은 일'을 관리하느라 현재의 시스템 리소스를 낭비하고, 변경 유연성을 스스로 제약하는 구조였습니다.
재설계 이후, 탑승일정은 다음과 같은 방식으로 동작합니다.
- 탑승일정 생성 시에는 패턴만 DB에 기록합니다.
- 개별 '인스턴스'는 실제 조회가 필요하거나 탑승이 발생하는 시점에 지연 생성(lazy generation) 됩니다.
이 방식은 불필요한 상태를 만들지 않기 때문에 생성 이후 따라붙는 Mutation 비용이 사라졌고 패턴 변경이 전체 인스턴스 재생성으로 이어지지 않으며 실제로 발생한 이벤트만 관리하면 되므로 저장 용량과 데이터 정합성 측면에서도 훨씬 유리합니다.
결과적으로 탑승일정 도메인은 “필요할 때만 최소 상태만 유지하는 구조”로 재정렬되며, 너무 많은 상태를 미리 만들어두고 관리하느라 생기던 복잡성을 제거할 수 있었습니다.
재설계 이후 우리가 얻은 것들
재설계를 통해 옐로우버스의 일정 시스템은 다음과 같은 형태로 정리되었습니다.
- Append-only 구조로 확보한 데이터 안정성
변경사항은 항상 새로운 레코드 추가로 표현되며,override_weight를 통해 시간 축 위에서 어떤 일정이 유효한지 안정적으로 해석할 수 있습니다. 그 결과 운행일정은 더 이상 과거 데이터의 유실이나 복잡한 분할·병합 작업에 흔들리지 않는 불변(immutable)의 안정성을 갖게 되었습니다. - 논리적 연결을 통한 도메인 격리
탑승일정은 운행일정 레코드 자체에 의존하지 않고, 논리적 식별자 내에서 가장 우선순위가 높은 운행일정과 동적으로 매칭됩니다. 두 데이터의 매칭은 저장 시점이 아닌 조회 시점에 동적으로 이루어지므로, 한쪽의 변경이 다른 쪽으로 전파되는 연쇄 반응(cascade)이 차단되었습니다. - Lazy Generation을 통한 상태(state)의 최소화
불필요한 미래 데이터를 미리 생성하여 관리하지 않고, 실제 필요한 시점에 생성합니다. 이를 통해 대량의 사전 생성 데이터가 유발하던 관리 비용과 Mutation 비용을 제거했습니다.
운영 관점에서의 실질적 효과
이 구조적 변화는 대규모 스케일에서 특히 강한 효과를 보입니다.
이 차이가 실제 운영 환경에서는 어느 정도의 차이를 만들어낼까요?
아래는 동일한 조건에서 기존 구조와 재설계 이후의 성능과 비용을 비교한 표입니다.
구분 | 기존 방식 (Mutable + Eager Generation) | 재설계 이후 (Immutable + Lazy Generation) |
|---|---|---|
초기 생성 데이터량 | 1,000명 × 5일 × 104주 = 약 520,000개 인스턴스 생성 | 패턴 5개 × 학생 수 → 5,000개 수준 |
운행일정 변경 시 영향 | 기존 인스턴스 52만 개 전체 삭제 → 재계산 → 재생성 | 변경된 일정에 대해 INSERT 몇 개만 수행, 탑승일정은 영향 없음 |
탑승일정 인스턴스 생성 시점 | 패턴 생성 시 전체 즉시 생성(Eager) | 실제 탑승이 발생하거나 조회될 때 지연 생성(Lazy) |
수정/변경 비용 | 대량 레코드 재생성으로 인해 고비용 | 부분적 INSERT만 발생, 비용 최소화 |
레이턴시 | 변경 한 번에 수 초~수십 초 | 대부분의 변경이 수 ms 단위 |
도메인 결합도 | 운행일정 변경이 탑승일정까지 연쇄 전파 | 두 도메인이 완전히 분리, 변경의 국소화 |
데이터 무결성 | 대량 변이(mutation)로 인해 오류 가능성 증가 | append-only 기반으로 변경 이력 안전하게 보존 |
정리하자면
재설계된 일정시스템은 도메인의 자연스러운 성질을 반영하면서도, 제어 가능한 구조를 만들어냈습니다.
- Immutability 상태 변화를 최소화하여 이력을 보존하고 정합성을 지킵니다.
- Decoupling 도메인 간의 연결 고리를 느슨하게 하여 변경의 전파를 막습니다.
- Lazy Generation 데이터를 미리 쌓아두지 않고, 필요한 순간에 생성합니다.
그 결과, 시스템은 더 예측 가능하게 동작하며, 대규모 데이터에서도 일정 변경을 안전하고 일관되게 처리할 수 있는 기반을 갖추게 되었습니다.
마치며
이번 재설계 과정을 거치며 가장 크게 배운 점은,
데이터베이스 설계는 "나중에 고치자" 아니라, 처음부터 최대한 올바르게 설계하는 것이 결국 가장 큰 비용 절감이 된다는 사실이었습니다.
아이러니하게도 우리는 이 가장 중요한 작업을 언제나 도메인을 가장 모르는 시점에 시작합니다.
처음에는 운영 시나리오도 충분히 알 수 없고, 어떤 변경이 잦을지, 어디가 확장될지, 어디가 병목이 될지조차 예측하기 어렵습니다.
그래서 초기에 만든 스키마는 필연적으로 시행착오를 품게 되고, 그 선택은 이후 오랫동안 시스템의 형태와 한계를 결정짓곤 합니다.
이번 일정 시스템 재설계는, 우리가 그동안 쌓은 시행착오를 한 번 분해하고 다시 조립하는 과정이었습니다.
이제는 일정 변경이 일어날 때 팀이 더 이상
“이걸 바꾸면 또 어디가 깨질까?”
걱정하지 않아도 되는 시스템적 신뢰를 얻게 되었습니다.
앞으로도 도메인은 계속 변하고, 새로운 요구사항은 계속 생겨날 것입니다.
하지만 이번 재설계를 통해 우리는 변화를 견딜 수 있는 방향이 어떤 것인지, 그리고 시스템이 가져야 할 “탄력성”이 무엇인지 조금 더 잘 이해하게 되었습니다.
이 과정 자체가 우리 팀에게 큰 자산이 되었고, 앞으로의 개선과 기능 확장도 훨씬 더 단단한 기반 위에서 진행될 것이라고 확신합니다.
🚖 함께 할 동료를 찾고 있습니다 🚖
SWING은 더 나은 도시를 만들기 위해 노력하는 팀입니다. 데이터, 기술, 사용자 중심의 혁신을 통해 이동 경험을 바꾸고자 하는 분들을 기다리고 있습니다.
여정에 함께하고 싶다면, 망설임 없이 지금 바로
아래 링크를 통해 지원 부탁드립니다!
👉 SWING 채용 공고 확인하기 👈