ChoSeyun

WOW DELIVERY

와우배달

Project summary

주변 가게를 찾고 주문을 받은 뒤,
한 명의 라이더에게 안전하게 연결하는 배달 API

와우배달 프로젝트 대표 이미지
Service flow검색부터 배차까지 이어지는 흐름
01가게 검색

S2 셀로 주변 가게 조회

02주문·결제

메뉴와 주문 상태 저장

03배차 요청

라이더의 동시 접근 처리

04상태 전달

Kafka 이벤트 발행

Overview

프로젝트 개요

음식점, 고객, 라이더 사이의 배달 과정을 서버에서 처음부터 끝까지 연결했습니다.

회원과 가게, 메뉴, 주문, 결제, 배차 기능을 REST API로 구현한 개인 프로젝트입니다. 기능을 많이 만드는 데서 멈추지 않고, 실제 서비스라면 트래픽이 몰리는 지점과 데이터가 충돌하는 순간을 먼저 찾았습니다.

주변 가게 검색 방식부터 동시 배차, 외부용 주문번호, 캐시와 배포 구조까지 대안을 비교하고 선택한 이유를 코드와 문서로 남겼습니다.

Role
백엔드 설계 · API 개발 · 테스트 · 배포 구성
Service
음식점 · 고객 · 라이더 중개
Core
가게 검색 · 주문 · 결제 · 배차
Type
Personal Project

Key challenges

먼저 풀어야 했던 문제

화면에 보이는 기능보다 요청이 한꺼번에 들어왔을 때 깨질 수 있는 부분을 먼저 살폈습니다.

  1. 01

    주변 가게를 어디까지 찾을 것인가

    위도·경도 범위 조회는 간단하지만 밀집 지역에서 불필요한 가게까지 읽게 됩니다. 등록한 밀집 도로명과 검색 반경에 따라 후보 범위를 좁히는 방식을 실험했습니다.

  2. 02

    한 주문을 누가 먼저 잡았는가

    여러 라이더가 같은 주문을 동시에 선택해도 실제 배차는 한 명에게만 완료되어야 합니다. Redis Set의 원자적 추가 결과를 이용해 최초 요청과 중복 요청을 구분했습니다.

  3. 03

    외부에 어떤 번호를 보여줄 것인가

    DB의 증가형 ID를 그대로 노출하면 주문량과 가게 수를 짐작할 수 있습니다. 내부 식별자와 고객이 확인하는 번호를 분리하되, 말로 전달하기 쉬운 길이를 찾아야 했습니다.

  4. 04

    반복 조회를 어떻게 줄일 것인가

    식사 시간에는 같은 가게 정보와 주문 상태를 여러 사용자가 반복해서 확인합니다. 조회는 빠르게 돌려주고, 정보가 바뀌면 오래된 캐시가 남지 않게 설계했습니다.

Location search

주변 가게 검색

단순 좌표 조회에서 시작해 네 가지 방법을 비교한 뒤, 이 프로젝트에는 S2 Geometry가 가장 적합하다고 판단했습니다.

위도와 경도의 최솟값·최댓값만 조회하면 실제 원형 반경 밖의 가게까지 읽게 됩니다. 밀집 지역에서는 결과가 커지고 같은 범위 조회가 겹쳐 DB 부담도 커집니다. GeoHash와 QuadTree도 검토했지만 경계 처리와 운영 복잡도를 함께 줄이기에는 S2 셀이 더 적합했습니다.

구현에서는 15개의 주요 도로명을 밀집 지역 목록으로 두었습니다. 목록에 포함되면 2km·Level 13, 그 외에는 4km·Level 12의 셀 토큰을 만들어 가게 데이터에 저장된 토큰과 비교합니다. 실제 인구 밀집도를 실시간으로 계산한 것은 아니며, 검색 전략을 확인하기 위한 정적 기준입니다.

등록한 밀집 도로명2km · Level 13

좁고 세밀한 셀로 조회 범위를 줄입니다.

목록에 없는 지역4km · Level 12

넓은 셀로 더 먼 가게까지 찾습니다.

빠른 시작

위도·경도 범위

구현은 단순하지만 사각 범위의 오버페치가 큽니다.

경계 보완 필요

GeoHash

문자열 인덱스는 쉽지만 이웃 셀과 정밀도 조정이 복잡합니다.

구현 부담

QuadTree

공간 분할은 직관적이지만 삽입·삭제와 경계 처리가 무겁습니다.

최종 선택

S2 Geometry

구면을 셀로 나눠 반경과 밀집도에 맞게 범위를 조절할 수 있습니다.

S2 셀 조회는 검색 후보를 빠르게 좁히는 단계입니다. 현재는 실제 거리로 한 번 더 거르는 후처리와 대용량 데이터 성능 비교가 없기 때문에, 정확한 반경 검색과 성능 개선을 완료했다고 표현하지 않았습니다.

Concurrency

중복 배차 요청 걸러내기

같은 주문을 여러 라이더가 눌러도 배차는 한 번만 완료되어야 합니다.

낙관적 락, 비관적 락, MySQL 네임드 락을 비교했습니다. 여러 애플리케이션 인스턴스가 같은 값을 확인하고 DB 락 부담을 늘리지 않도록 Redis Set을 사용했습니다. 주문 ID를 key로, 고정 문자열을 member로 넣는 SADD는 최초 요청에서 1을 반환하고 이미 값이 있으면 0을 반환합니다. 이 반환값으로 첫 요청만 배차 단계로 보내고 나머지는 중복 요청으로 처리했습니다.

다만 이것은 Redisson이나 SETNX로 만든 완전한 분산락이 아닙니다. 현재 코드에는 TTL과 락 소유자 정보가 없으며, 배달 완료 시 Set 값을 지우는 방식입니다. Redis 등록 뒤 DB 처리에 실패하면 값이 남을 수 있다는 한계가 있습니다.

RIDER ARIDER BRIDER C
REDIS SET · SADD1024 : assignRider최초 요청 1 · 중복 요청 0
ASSIGNEDRIDER A주문 상태 → DELIVERING
낙관적 락충돌이 몰리면 재시도가 늘어남
비관적 락대기와 DB 락 부담이 커짐
MySQL 네임드 락DB 인스턴스 범위에 묶임
Redis SetSADD의 원자성으로 첫 요청만 구분

다음 단계에서는 TTL과 소유자 토큰이 있는 락 또는 조건부 DB 업데이트를 적용하고, 여러 스레드와 여러 인스턴스가 실제 Redis에 동시에 접근하는 통합 테스트로 한 명만 배정되는지 확인해야 합니다.

Public ID

보여주는 번호는 따로

DB의 ID는 숨기고, 고객이 확인하는 주문번호와 가게번호를 따로 만들었습니다.

증가형 ID는 짧고 관리하기 쉽지만 주문량을 짐작하게 합니다. 반대로 긴 UUID를 그대로 보여주면 고객센터에서 읽고 전달하기 어렵습니다. UUID v4를 36진법으로 바꾼 뒤 7자리만 사용해 내부 정보 노출과 사용 편의 사이의 균형을 잡았습니다.

7자리 36진수로 만들 수 있는 값은 약 783억 개지만 충돌 가능성이 0이 되는 것은 아닙니다. 주문번호에는 DB unique constraint를 두어 마지막 단계에서 중복 저장을 막았습니다. 실제 운영이라면 제약조건 예외가 발생했을 때 새 번호를 생성해 다시 저장하는 흐름과 가게 public ID의 고유 제약도 추가해야 합니다.

DATABASE104238외부 비공개
→
UUID v48F14E45F…무작위 고유값
→
PUBLIC ID2K8M9QX7자리 36진수
약 783억7자리 36진수로 만들 수 있는 값
7자확인하고 전달하기 쉬운 길이
Unique주문번호의 DB 고유 제약

Cache strategy

피크 시간대 조회 줄이기

자주 읽는 가게 정보는 Redis에서 먼저 찾고, 수정될 때만 캐시를 비웁니다.

같은 가게 상세 요청이 반복될 때마다 MySQL을 조회하지 않도록 Cache Aside 흐름을 적용했습니다. 첫 요청은 DB에서 읽어 Redis에 저장하고, 이후 요청은 캐시에서 반환합니다. 가게 정보가 바뀌면 해당 키를 지워 다음 조회에서 최신 값을 다시 채웁니다.

가게 상세 캐시의 TTL은 3분으로 두었습니다. 수정 요청에서는 같은 key를 제거해 다음 조회가 DB를 거치게 했습니다. 캐시 적용과 삭제는 Spring Boot 통합 테스트로 확인했지만, 동시 사용자 수에 따른 응답 시간과 DB 조회 감소량은 아직 측정하지 않았습니다.

REQUEST가게 상세 조회
HIT · TTL 3MINRedis캐시 값 바로 반환
MISSMySQL조회 후 캐시 저장
UPDATECache Evict수정 시 해당 키 삭제

Architecture

운영 구조와 선택

학습용 개인 프로젝트의 비용을 기준으로 AWS 구조를 설계했습니다.

NLB의 고정 IP를 거쳐 ALB가 HTTP 요청을 나누고, Private Subnet의 애플리케이션과 데이터 영역으로 전달합니다. MySQL, Redis, Kafka는 비용을 고려해 EC2의 Docker 컨테이너에서 운영하고 Jenkins로 배포 흐름을 이어가는 구조로 정리했습니다.

현재 저장소에서 바로 확인할 수 있는 배포 자산은 Dockerfile과 Kafka용 docker-compose입니다. NLB·ALB·Subnet·Jenkins 구조는 README에 남긴 설계안이며, Terraform이나 CloudFormation으로 재현 가능한 상태는 아닙니다.

PUBLICClient
→
FIXED IPNLB
→
HTTP ROUTINGALB
→
PRIVATESpring Boot
MySQLRedisKafka
현재 선택

직접 운영해 비용 줄이기

NAT Instance, Bastion, EC2 내부 데이터 서비스를 사용하는 구조로 설계했습니다.

서비스가 커진다면

관리형 서비스로 운영 부담 줄이기

SSM, RDS, ElastiCache, MSK와 관리형 CI/CD 전환을 검토했습니다.

문서 속 인프라를 실제 역량의 근거로 만들려면 IaC, Jenkinsfile, 환경별 설정과 배포 로그가 저장소에 함께 있어야 합니다. 현재 페이지에서는 구현 완료가 아닌 아키텍처 설계 경험으로만 표현했습니다.

Technical debt

확장 전에 먼저 보완할 것

기능을 더 붙이기 전에 실패했을 때 복구할 수 있는 구조와 재현 가능한 검증 환경부터 만드는 것이 우선입니다.

프로젝트를 다시 점검하면서 설계 문서에서 기대한 수준과 실제 코드 사이의 차이를 정리했습니다. 아래 항목은 이미 해결했다고 포장하지 않고, 다음 구현에서 우선 처리할 기술 부채로 남겼습니다.

  1. 01

    중복 표시를 실제 동시성 제어로 확장

    현재 Redis Set에는 TTL과 소유자 정보가 없습니다. Redisson 또는 SET NX PX를 적용하고, 트랜잭션 실패 시 안전하게 해제되는지 동시 요청 테스트로 검증해야 합니다.

  2. 02

    비밀값과 인증 구조 정리

    DB·Redis·결제 키를 환경변수와 실행 프로필로 분리하고, 직접 만든 SHA-256 인증은 Spring Security와 BCrypt 또는 Argon2 기반으로 교체해야 합니다.

  3. 03

    DB와 Kafka 사이의 정합성 보완

    DB 커밋과 이벤트 발행이 따로 실패할 수 있습니다. Transactional Outbox, 재시도, DLQ와 소비자 멱등성을 더해 이벤트 전달 실패를 복구할 수 있어야 합니다.

  4. 04

    실행과 배포를 누구나 재현할 수 있게

    Testcontainers로 MySQL·Redis·Kafka 테스트 환경을 묶고, Terraform이나 CloudFormation과 Jenkinsfile을 저장소에 포함해 문서 속 구조를 코드로 남겨야 합니다.

Next architecture

확장을 위해 검토한 것

기술 부채를 먼저 정리한 뒤 MSA로 나눈다면, Redis 세션을 그대로 옮기지 않는 쪽으로 검토했습니다.

모든 서비스가 하나의 세션 저장소를 바라보면 확장할수록 Redis 의존성과 결합도가 커집니다. API Gateway에서 공통 인증을 처리하고, 각 서비스는 필요한 최소 정보만 담은 토큰을 검증하는 방향을 검토했습니다. JWT도 강제 로그아웃과 정보 변경을 즉시 반영하기 어렵고, 만료 시간을 줄이면 갱신 비용이 늘기 때문에 서비스 규모에 맞춘 설계가 필요합니다.

01

API Gateway

외부 요청의 인증과 공통 처리를 한곳에서 담당

02

Stateless token

서비스마다 세션을 공유하지 않고 필요한 정보만 검증

03

Circuit breaker

한 서비스의 지연이 전체 장애로 번지는 상황 방지

이 부분은 현재 구현 결과가 아니라, 모놀리식 구조를 MSA로 바꿀 때 필요한 다음 단계의 설계 검토입니다. 서비스 규모와 장애 요구사항이 확인되기 전에 MSA를 먼저 도입하는 것이 정답이라고 판단하지는 않았습니다.

Stack

사용한 기술

Backend

Java 17 · Spring Boot 3.2 · JPA

회원, 가게, 메뉴, 주문, 결제, 배차 도메인을 REST API로 구현했습니다.

Data

MySQL 8.0 · Redis · Kafka

영구 데이터, 반복 조회 캐시, 중복 요청 표시, 이벤트 발행의 역할을 나눴습니다.

Test

JUnit · Mockito

외부 API와 분리된 단위 테스트로 서비스의 주요 흐름을 확인했습니다.

Deploy

Docker · AWS · Jenkins

Docker 실행 환경을 만들고 AWS 네트워크와 Jenkins 배포 구조를 문서로 설계했습니다.

Outcome

만든 결과

S2Level 12·13 셀을 이용한 주변 가게 후보 조회
Redis3분 캐시와 SADD 기반 중복 요청 표시
Kafka주문과 배차 이후 비동기 이벤트 발행

회원가입부터 가게 조회, 주문과 결제, 라이더 배차까지 배달 서비스의 주요 흐름을 API로 구현했습니다. 각 기능을 단순히 연결하는 데서 끝내지 않고, 위치 검색의 정확도와 조회 부하, 동시성, 외부 정보 노출, 운영 비용까지 하나의 서비스 관점에서 검토했습니다.

동시에 구현하지 못한 부분도 분명합니다. Redis Set은 완전한 분산락이 아니고, Kafka에는 Outbox와 DLQ가 없으며, AWS 설계는 IaC로 재현되지 않습니다. 대신 어디까지 구현했고 무엇을 더 검증해야 하는지 구분해 다음 작업의 우선순위를 정할 수 있게 됐습니다.

실사용 트래픽, 캐시 적용 전후의 응답 시간, S2 검색량, 동시 배차 처리량은 측정하지 않았습니다. 따라서 포트폴리오에는 코드와 테스트로 확인할 수 있는 구현 범위와 설계 판단만 담았습니다.

GitHub와우배달 코드와 설계 문서 보기