한국 서비스를 기반으로 시스템 디자인 공부하기 – 런데이
시스템 디자인을 공부하다 보면 대부분의 자료가 영어로 작성되어 있고, 주로 영어권 서비스를 대상으로 하고 있습니다. 이 때문에 한국 개발자들은 시스템 디자인 자체뿐만 아니라 해당 서비스에 대한 이해도 함께 필요하게 되어 학습 과정이 더 복잡해지는 경우가 많습니다. 이를 해결하기 위해, 한국의 대표적인 서비스를 기반으로 시스템 디자인을 공부하고, 유사한 해외 서비스를 함께 연결해 보려 합니다. 다른 글 보기
이번에 다룰 서비스는 ‘런데이’입니다. ‘런데이’는 처음에 한빛소프트에서 개발, 서비스했고 이후 별도 법인으로 분리된 주식회사 땀에서 운영하는 러닝 코칭 애플리케이션으로, 사용자에게 단계별 음성 안내를 제공하여 체계적인 러닝 습관 형성을 돕습니다. 초보자부터 숙련자까지 다양한 수준의 러너를 위한 맞춤형 훈련 프로그램을 제공하며, 운동 데이터를 기록하고 목표 달성을 지원하는 기능을 갖추고 있습니다. 여기서는 런데이의 많은 기능 중에서도 가장 코어 기능이라고 할 수 있는, 달리기 플랜을 선택해서 실행하고 기록을 저장/조회하는 기능에 대해 시스템 디자인을 해보겠습니다.
런데이 운동의 기본 흐름 따라가기
일반적인 운동이 시작부터 종료까지 어떻게 진행되는지 흐름을 따라가 보겠습니다.
사용자는 먼저 운동하고자 하는 코스를 앱에서 다운로드합니다. 이후, 다운로드한 코스 중 하나를 선택하고 ‘운동 시작’ 버튼을 눌러 운동을 시작합니다.
운동이 시작되면, 코스에 따라 음성 안내가 제공되며 사용자는 해당 안내에 맞춰 운동하게 됩니다. 운동 중에는 일시정지와 재개가 모두 가능합니다.
운동이 종료되면, 오늘 운동한 코스의 거리와 페이스 등 주요 정보를 확인할 수 있습니다.
저장된 운동 기록은 ‘운동 내역’ 화면에서 다시 확인할 수 있습니다.
Requirements
시스템을 설계하려면 먼저 요구사항을 명확히 정리하는 것이 중요합니다. 시스템이 가져야 할 핵심 기능을 정의하고, 트래픽 증가나 동시성 문제에 대한 비기능 요구사항도 함께 고려해야 합니다. 앞서 정리한 티켓 예매 흐름을 바탕으로, 시스템 설계를 위한 요구사항을 분석해 보겠습니다.
Functional Requirements
런데이와 같은 운동 안내 앱이 기본적으로 제공해야 할 기능은 다음과 같습니다.
- 사용자는 코스를 검색하고, 조회하며, 실행할 수 있어야 합니다.
- 사용자가 코스를 실행하면, 해당 코스에 맞는 코칭이 재생됩니다. 코스 실행은 일시정지하거나 중단할 수 있어야 합니다.
- 사용자는 본인의 운동 기록을 확인할 수 있어야 하며, 친구의 운동 기록도 간단히 조회할 수 있어야 합니다.
이 글에서는 운동 흐름에 집중하기 위해 아래 기능들은 다루지 않습니다.
- 친구 검색 및 추가
- 크루 생성 및 가입/탈퇴와 같은 커뮤니티 기능
- 대회 기능
- 응원하기 기능
Non-Functional Requirements
- 운동 앱의 특성상, 데이터 일관성(consistency)보다는 가용성(availability)이 더 중요할 것으로 예상됩니다.
- 코스를 한 번 다운로드하면, 일시적으로 네트워크가 끊기더라도 운동을 계속할 수 있어야 합니다.
- 시스템은 동시에 여러 사용자가 운동을 실행하더라도 원활하게 동작해야 합니다.
- 운동 앱의 특성상 read보다는 write heavy할 것으로 예상됩니다. 현재 상태를 계속 저장해야 하기 때문입니다.
Core Entities
예상되는 핵심 엔티티들을 정의합니다. 우선 떠오르는 엔티티들을 간단히 정리하고, 이후 실제 설계 과정에서 필요에 따라 확장하거나 조정할 수 있습니다. 각 엔티티의 ID 필드는 생략합니다.
- User
- 사용자
- Friend[], Activity[]
- Course
- 런데이에서 제공하는 운동 코스
- name, description, duration, Step[]
- Step
- Course를 구성하는 최소 단위 (예: 천천히 걷기, 천천히 뛰기, 빠르게 걷기 등)
- type, duration, music[]
- Activity
- 사용자가 특정 Course를 선택해 운동한 기록
- userId, courseId, dateTime, totalTime, startedAt, finishedAt, status, activityRecordId
- Location
- 운동 중 실시간으로 기록된 위치 정보
- activityId, timestamp, lat, lng, altitude
- ActivityRecord
- 운동 종료 후 확인 가능한 상세 결과
- activityId, totalDistance, totalCalories, averagePace, StepRecord[]
- StepRecord
- Step별 운동 결과 상세 정보
- Friend
- 친구를 나타내는 엔티티. 다른 유저를 참조함
- userId
High level Design
시스템 디자인 인터뷰에서는 제한된 시간 내에 전체적인 아키텍처를 설계해야 합니다. 일반적으로 1시간 이내의 인터뷰에서, 소개나 마지막 질의응답, 그리고 Behavior Questions(행동 관련 질문) 등을 제외하면 실제 설계에 주어진 시간은 약 30~40분 정도입니다.
이 짧은 시간 안에 완벽한 이상적인 디자인을 만드는 것은 현실적으로 어렵기 때문에, High-Level Design 단계에서는 핵심 컴포넌트와 API 설계에 집중하고, 이후 Low-Level Design에서 세부적인 최적화를 논의하자고 합의를 하는 것이 좋을 것 같습니다.
유저가 코스를 다운로드하고 실행하는 기능
사용자가 코스를 실행하는 흐름을 떠올려보면, 음악 앱의 플레이리스트 기능과 유사하다는 생각이 들었습니다. 특정 코스를 선택하는 것은 하나의 플레이리스트를 고르는 것과 같고, 그 안에는 순서가 정해진 여러 Step들이 들어 있어, 사용자는 순서대로 따라가게 됩니다.
전체 구조는 왼쪽에 클라이언트, 오른쪽에 DB가 위치하는 형태로 구성할 수 있습니다. DB에는 Course와 Step 정보가 저장되어 있고, 사용자는 먼저 Course 리스트를 조회한 후, 특정 Course의 상세 정보를 다운로드합니다.
실제 런데이 앱에서는 여러 Course가 하나의 테마로 묶여 있고, 테마 단위로 다운로드가 이뤄지지만, 여기서는 복잡성을 줄이기 위해 단일 Course를 다운로드하는 것으로 가정하겠습니다. 이 과정에서 데이터 사용량 안내가 표시되는 점으로 미루어보아, 배경음악 등 관련 리소스도 이 시점에 함께 기기로 다운로드되는 것으로 보입니다.
음악 리소스는 S3와 같은 외부 스토리지에 저장되어 있다가, Course 다운로드 시 함께 받아오게 됩니다.
유저가 코스를 실행하고 일시정지, 완료, 혹은 중단하는 기능
운동이 시작되면 앱은 두 가지 역할을 수행해야 합니다.
- 다운로드한 Course의 Step들을 순서대로 안내합니다.
- 사용자의 운동 상황을 기록합니다.
Course의 Step을 차례대로 안내하는 기능은 프론트엔드 로직에서 처리하고, 여기서는 백엔드 시스템 설계에 집중하겠습니다.
운동이 시작되면, 시작 시각과 실행할 Course ID를 포함한 요청이 Activity Service로 전송되고, 이를 통해 새로운 Activity 엔티티가 생성됩니다. 사용자가 운동을 일시정지하거나, 중단하거나, 완료할 경우에는 PATCH /Activity API를 통해 상태를 업데이트할 수 있습니다. 중단 또는 완료 시에는 finishedAt 값을 함께 전달해 기록하는 것이 바람직합니다.
운동 기록을 조회할 수 있으려면, 운동 도중 주기적으로 위치 정보를 기록해야 합니다. 클라이언트는 일정 주기로 현재 위치와 타임스탬프를 서버로 전송하고, 가능하다면 페이스 등의 정보도 함께 보내도록 설계하는 것이 좋습니다.
유저가 운동을 종료하고 운동에 대한 상세 정보를 볼 수 있는 기능
운동을 종료하거나, 운동 내역 화면에서 특정 기록을 선택하면 사용자는 자신의 운동 기록을 확인할 수 있습니다. 또한 친구 목록을 통해 친구의 운동 기록도 간단히 확인할 수 있습니다.
이를 위해서는 운동 종료 시점에 Activity와 실시간으로 수집된 Location 정보를 모두 저장하고, 이를 기반으로 운동 기록을 계산해 저장해야 합니다. 이후 사용자가 기록을 조회할 때는 Record Service의 GET API를 호출하여 저장된 데이터를 불러오면 됩니다.
예상되는 문제점들
현재 예상할 수 있는 주요 문제점은 크게 세가지 입니다.
네트워크 유실 문제
런데이의 운동은 야외에서 이루어지기 때문에, 네트워크 연결이 불안정하거나 아예 끊기는 상황이 자주 발생합니다. 위치 데이터가 몇 초 단위로 전송되는 시스템에서는, 이 데이터 중 일부가 유실되는 경우 전체 거리 계산이나 페이스 결과에 큰 영향을 줄 수 있습니다.
이 경우 앱에서 데이터를 저장하고 있다가 인터넷이 연결되었을 때 데이터를 한번에 저장하는 것이 좋은 해결책이 될 것으로 보입니다. 사실, 런데이에서 실시간으로 데이터를 보내고 있는지 앱에서는 알 수 없어 보입니다. 운동을 중단/종료할 때 까지는 앱에서만 데이터를 가지고 있다가 종료 시점에 한번에 데이터를 보내면서 Activity를 생성하고 있는 것으로 보입니다.
많은 유저/데이터의 핸들링
운동 기록 앱의 특성상, 특정 시간대(예: 출퇴근 시간대, 점심시간 등)에 많은 사용자가 동시에 운동을 시작하고 위치 데이터를 업로드하게 됩니다. 이때, POST /location과 같은 API에 요청이 몰리게 되며, 동시에 서버 자원에 큰 부하가 걸릴 수 있습니다.
이런 상황이 반복된다면 서버의 CPU, Memory, DB connection pool이 빠르게 소진되며, DB 쓰기 지연이나 타임아웃이 발생하고 결국 서비스 장애로 이어질 수 있습니다.
또한 서비스가 오랫동안 유지된다고 했을 때 단순히 트래픽을 견디는 것뿐만 아니라, 시간이 지남에 따라 누적되는 Location 데이터를 어떻게 처리할지도 고려해야 합니다.
location 데이터 순서 문제
네트워크 유실이 없더라도, 위치 데이터는 클라이언트나 통신 상태에 따라 순서가 꼬이거나 중복되는 문제가 발생할 수 있습니다. 예를 들어, 서버에 18:30:03 시점의 위치가 먼저 도착하고 나중에 18:30:00 시점의 위치가 도착하는 식입니다. 이 순서 오류는 거리 계산에 큰 오차를 유발할 수 있습니다.
위에서 Availability를 우선하겠다고 했으나, 이러한 정합성 문제는 사용자가 가장 민감하게 반응하는 부분 중 하나입니다. 운동을 열심히 했는데 기록이 누락되거나 왜곡된다면, 서비스에 대한 신뢰가 크게 떨어지게 됩니다. 최대한 이를 보정하여 최종적으로는 Consistency를 달성해 정확한 기록 값을 제공해야 할 것입니다.
Low Level Design
in-memory buffer + 로컬 저장소에 위치 데이터 임시 저장
먼저, 네트워크 유실 문제를 해결해보겠습니다. 인터넷 문제로 서버에 데이터를 보내서 저장하는 것이 실패한다면, 클라이언트에 in-memory buffer를 두고 일시적으로 데이터를 저장합니다. 네트워크 연결이 복구되면 해당 데이터를 서버로 재전송(retry)하도록 하여 유실을 방지할 수 있습니다. 이때 각 위치 데이터에는 고유한 UUID를 부여하며, 서버는 중복 전송을 방지할 수 있도록 idempotent하게 처리합니다.
대규모 동시 요청 처리하기: Queue 기반 비동기 구조 도입
사용자가 운동을 시작하면, 3~5초 주기로 위치 정보가 서버에 전송됩니다. 동시에 수천 명이 운동 중이라면, 서버는 초당 수천 개 이상의 위치 요청을 처리해야 합니다. 이런 요청을 매번 RDB에 직접 저장하면, DB 쓰기 병목이 발생하고 결국 장애로 이어질 수 있습니다.
이를 방지하기 위해, 먼저 수신된 데이터를 Kafka, Amazon SQS, RabbitMQ와 같은 메시지 큐에 적재합니다. 메시지는 큐에 쌓이고, 별도의 워커(consumer)가 순차적으로 처리하여 DB에 기록하게 됩니다. 이렇게 하면 백엔드는 실시간 처리에 집중할 수 있고, 워커 수를 늘리는 방식으로 자연스럽게 수평 확장이 가능합니다.
Client → API gateway → Kafka Queue → Location Service → DB
만약 워커가 일시적으로 처리 속도를 따라가지 못하는 경우에도 큐에 메시지가 누적될 뿐, 전체 서비스가 중단되지는 않기 때문에 안정성이 크게 향상됩니다. 만약 사용자가 많아 지속적으로 큐에 많은 양의 메세지가 쌓인다면 뒷단의 Service를 스케일아웃할 수 있을 것입니다.
데이터 정합성을 위한 timestamp 기준 정렬
위에서 언급한 in-memory buffer의 도입이나 메세지 큐의 도입 모두 필연적으로 엄밀한 순서의 보장과는 멀어지게 됩니다. 하지만 운동 기록을 계산할 때는 location 순서가 중요하므로, location API에 timestamp를 포함하도록 하고, 이를 기준으로 데이터를 정렬해서 사용하는 것이 바람직합니다. 우리 시스템은 기본적으로 RDB 사용을 가정하고 디자인했는데, 데이터가 적을 때에는 인덱스 추가 정도로 괜찮겠지만 만약 사용자가 지속적으로 늘어나게 된다면 이 인덱스로 인한 비용도 만만치 않을 뿐더러, activity_id + timestamp를 기준으로 위치 데이터를 조회하거나 운동 종료 후 전체 데이터를 기준으로 통계를 계산할 때 성능이 저하될 수 있습니다.
여기까지 Location 정보의 특성을 생각해 봤을 때, 몇초마다 새로운 데이터가 계속 추가되고 수정될 일이 없는 write-heavy, append-only 데이터입니다. 그러면서 timestamp 기준으로 정렬되어 있는 편이 좋습니다. 이 지점에서 다른 DB를 사용하는것이 좋을 것 같습니다.
Cassandra 도입하기
지금까지 본 Location의 워크로드는 전통적인 RDB보다는 분산형 NoSQL DB에 더 적합합니다. 특히, 수평 확장이 용이하고 쓰기 성능에 최적화된 카산드라는 이런 요구사항에 잘 부합합니다.
Location 정보는 시간이 지남에 따라 계속 누적되며 쓰기 중심의 워크로드가 발생합니다. 물리적으로 분산된 여러 노드에 데이터를 분산 저장하는 카산드라는 이렇게 빈번하게 쓰이고 자주 수정되지 않는 데이터에 매우 적합합니다. 각 노드는 자체적으로 쓰기와 읽기를 처리할 수 있어, 사용자가 증가하더라도 노드를 추가하는 것만으로 처리량을 선형적으로 확장할 수 있습니다. 특히, 카산드라는 쓰기 연산 시 디스크에 직접 기록하지 않고 먼저 메모리에 저장한 뒤, 일정 조건이 충족되면 디스크에 비동기적으로 flush하는 구조이기 때문에, 대량의 쓰기 요청을 빠르게 흡수할 수 있습니다.
또한 카산드라는 파티션 키를 기준으로 데이터를 저장하고 조회하기 때문에, activity_id + timestamp를 파티션 키 혹은 클러스터링 키로 설계하면 위치 데이터를 시간 순으로 효율적으로 조회할 수 있습니다. 이는 운동 종료 후 사용자 경로를 정렬해 보여주거나, 운동 기록을 분석하는 데 유리한 구조입니다.
쿼리 패턴이 고정적인 환경에서는 카산드라의 효율성이 더욱 두드러집니다. 예를 들어, 특정 activity_id의 위치 데이터를 시간 순으로 조회하는 구조는 다음과 같이 테이블을 설계함으로써 자연스럽게 대응할 수 있습니다:
CREATE TABLE Location (
activity_id UUID,
timestamp TIMESTAMP,
location_id UUID,
lat DOUBLE,
lng DOUBLE,
altitude DOUBLE,
speed DOUBLE,
PRIMARY KEY (activity_id, timestamp)
) WITH CLUSTERING ORDER BY (timestamp ASC);
이 테이블은 activity_id를 파티션 키로, timestamp를 클러스터링 키로 사용함으로써, 동일한 활동(activity) 내의 위치 기록이 자동으로 시간 순으로 정렬되게 합니다. 이는 거리 계산, 페이스 분석 등과 같은 후처리에 매우 유리한 데이터 구조입니다.
물론, 카산드라는 조인, 집계, 정렬 쿼리에 제한이 있기 때문에, 읽기 패턴이 단순하고 명확한 시스템일수록 효과가 극대화됩니다. 이러한 제약을 잘 이해하고 데이터 모델링과 쿼리 설계를 병행한다면, 카산드라를 기반으로 한 시스템은 높은 확장성과 안정성을 동시에 갖춘 구조로 성장할 수 있습니다.
hot / cold storage 나누기
앱이 성장하고 사용자가 늘어나면, 매일 생성되는 Activity, Location, Record 데이터도 급속도로 늘어나게 됩니다. 초기에는 아무 문제가 없던 단순한 쿼리도, 수개월이 지나면 수백만~수천만 개의 레코드 위에서 수행되며 성능 저하로 이어질 수 있습니다.
앱의 사용을 생각해보면, 최근의 운동 기록은 자주 확인하지만 3년 전의 운동 기록은 자주 확인하지 않을 것입니다. 이런 사용자 패턴을 고려하여 최근 n개월의 데이터를 저장하는 hot테이블과 그보다 과거의 데이터, 비교적 조회가 덜 일어나는 cold테이블로 나누는 것이 성능을 유지하면서도 시스템 부담을 줄일 것 입니다.
위에서 카산드라를 선택한 것을 가정했을 때에도 이는 좋은 선택인데, 1. 디스크 용량 관리의 측면에서 데이터를 계속 넣기만 하면 SSTable이 늘어나게 됩니다. 2. 카산드라의 쓰기 최적화는 백그라운드에서 일어나는 Compaction 작업에 기인하는데, 오래된 대용량 데이터가 많을수록 이 비용이 커집니다. 만약 hot/cold 테이블을 나눈다면 cold 테이블은 compaction이 덜 일어나게 만들어거나 아얘 멈추게 할 수도 있습니다.
정리
이 외에도 Low level design에서 다룰 수 있는 주제는 많습니다. 예를 들어 친구에게 응원을 보내는 기능을 자세하게 파고 들어 graph 구조를 더 팔 수도 있고, SNS에서 자주 일어나는 핫스팟 문제를 이야기 할 수도 있을 것입니다. 실시간으로 친구에게 위치도 전송된다고 디자인하면 택시나 배달앱에서 사용하는 위치 정보 전송 디자인처럼 연결할 수도 있겠지요.
제가 준비한 런데이 시스템 디자인은 여기까지고, 기회가 되면 다른 서비스도 시스템 디자인 연습해보는 글로 돌아오도록 하겠습니다!
참고 자료
런데이 공식 홈페이지 Cassandra(카산드라) 내부 구조 [발 번역] Cassandra의 Write 에 관하여 카산드라(cassandra), 데이터 저장 구조 - 클러스터링 키(Clustering Key)