TableNow 개발 회고 #1 – API 설계, 데이터 모델링, 시스템 아키텍처와 초기 구현

euphony·2025년 4월 14일

내일배움캠프

목록 보기
67/67

🍽️최종 프로젝트 시작!

드디어 최종 프로젝트를 시작했다. 사실 벌써 2주차가 끝났지만 정신없이 달려오다보니 블로그에 기록을 하지 못했다. 한 주가 끝날 때마다 꾸준히 기록하는 습관을 가지자.✍🏻

우리 팀은 테이블링 서비스를 주제로 정했다!

  • 프로젝트명 : TableNow (테이블나우)
  • 주제 : 식당을 예약하고 방문할 수 있는 실시간 예약 관리 시스템

📆 1 ~ 2주차 일정

  • S.A 작성 후 피드백 받기
  • 기본 기능 개발 완료
  • 기본 기능 개발 후 코드리뷰 받기

핵심 기능

우리 팀의 핵심 기능은 다음과 같이 정리할 수 있다.

기능상세
인증/보안Spring Security + JWT
Refresh Token 저장 및 관리(Redis)
OAuth2.0 로그인 연동
검색/캐싱인기 검색어 캐싱(Redis)
Elasticsearch
동시성 제어Redisson
Redis Sorted Set
MQRabbitMQ

나는 예약/이벤트 도메인을 맡았고, 동시성 제어는 처음이어서 핵심 기술에 대해 고민이 많았다. 기술적의사결정 과정은 다음과 같다.

Redisson (분산 락)

📌 목적: 예약 및 이벤트 요청 시 중복 처리를 방지하기 위한 분산 락 구현

[선택 이유]

  • Redis 기반으로 빠르고 가볍게 락을 제어 가능
  • Redisson은 다양한 락 타입 지원 (Fair, Reentrant 등)
  • TTL 설정으로 데드락 방지 가능
  • Spring Boot와의 통합이 쉬움

[다른 기술과의 비교]

  • DB 트랜잭션 락 (SELECT ... FOR UPDATE): 단일 DB 환경에서는 가능하지만, 분산 환경에서는 사용 불가
  • ZooKeeper: 고신뢰 락을 제공하지만 운영 복잡도가 매우 높음

[결론]
복잡한 락 시스템을 쓰기보다, Redis 기반의 간단하면서도 안정적인 락의 필요성을 느껴 Redisson으로 결정했다.
또한 다른 도메인에서도 Redis를 이미 사용하고 있으므로 추가적인 리스크 없이 도입이 가능하다.

Redis Sorted Set - 시간 기반 트리거 처리

📌 목적: 리마인드 알림, 이벤트 오픈과 같이 특정 시간에 발생해야 하는 작업을 스케줄링

[선택 이유]

  • Sorted Set의 score에 timestamp를 사용해 시간순 정렬 가능
  • 주기적인 polling으로 실행 대상만 빠르게 추출 가능
  • 배치보다 실시간에 가깝고, 구현과 운영이 간편함
  • Redis 인프라를 재사용할 수 있어 도입 비용 거의 없음

[다른 기술과의 비교]

  • Quartz Scheduler: 정기적인 작업을 정밀하게 관리해야 할 때 사용함
  • RabbitMQ Delayed Queue: 구현 복잡하고 유연성 낮음, 지연 시간 기반(ex. 5분 뒤, 10분 뒤)이라 특정 시간 트리거에는 불편함
  • 일반 DB + 배치: 실시간 반응이 어렵고 리소스 낭비가 될 수 있음

[결론]
시간을 score로 설정하여 정확한 시간에 맞춰서 작업을 트리거할 수 있다.
따라서 리마인드 알림, 이벤트 오픈 시간 도달 시 트리거(가게 상태 변경) 작업에 적합하다.
또한 이벤트 오픈 시 실시간 처리가 필요하므로 메모리 기반의 Redis가 효율적이다.

RabbitMQ - 비동기 이벤트 전파

📌 목적: 예약 완료, 리마인드 알림, 이벤트 오픈 알림을 다른 도메인으로 안전하게 전파

[선택 이유]

  • 메시지 전송의 신뢰성 보장
  • 비동기 메시징 시스템으로 각 도메인 간 느슨한 결합 유지
  • 다양한 메시징 패턴 (Fanout, Topic 등)을 제공해 유연한 라우팅 가능

[다른 기술과의 비교]

  • Kafka: 대용량 데이터 처리, 실시간, 고성능, 고가용성을 제공하지만 로그 기반 스트리밍에 초점, 복잡성 및 설정이 과도함
  • Redis Pub/Sub: 설정이 간단하고 미들웨어가 없어 가볍지만 메시지 유실 가능성이 있음
  • AWS SQS: 클라우드 종속적이고 비용이 발생할 수 있음

[결론]
MQ를 사용하는 것은 이번이 처음이지만, 대용량 데이터 처리나 로그 추적, 재처리가 필요한 상황은 아니었다.
다만 리마인드 알림뿐 아니라 빈자리 알림, 이벤트 오픈 알림처럼 선착순으로 처리되어야 하는 이벤트들이 존재했기 때문에
메시지의 정확한 도착 보장이 무엇보다 중요했다.
이에 따라 Kafka보다 속도는 느리더라도, 정확한 메시지 전달과 유연한 라우팅이 가능한 RabbitMQ를 선택하게 되었다.

와이어프레임

API 설계

노션으로 팀원들과 작성한 API 명세서다. 분류, 담당자, 진행사항, 메서드, 기능, URI, 응답/요청 헤더 및 바디, 상태 코드 등으로 이루어져 있다. RESTful한 API를 설계하고자 했다.

데이터 모델링

초기 ERD는 리뷰, 댓글, 좋아요, 즐겨찾기 등 부가적인 기능이 많았다. 하지만 비즈니스적으로 다가가 기능을 추가하는 것보다는 기술적인 도전을 하는 것에 초점을 맞추기로 했다. 따라서 테이블 수를 줄이고 각자 핵심적인 1 ~ 2개의 도메인만 맡아 진행했다.

아마 기술 고도화가 진행되면 좀 더 수정될 것이다.

아키텍처 설계

우리 팀은 무중단 배포 방식을 원했는데, 현실적인 상황에 맞춰 EC2 1대에 컨테이너 2개를 띄우고 그 앞단에 Nginx와 같은 웹서버를 두는 방향으로 정했다. Blue-Green 방식으로 진행하고자 한다.

프로젝트 구조

table-now
├── .github
│   └── ISSUE_TEMPLATE
│       ├── feature-request-issue-template.md
│       └── PULL_REQUEST_TEMPLATE.md
├── src
│   ├── main
│   │   ├── java
│   │   │   └── org.example.tablenow
│   │   │       ├── domain
│   │   │       │   ├── auth
│   │   │       │   ├── category
│   │   │       │   ├── event
│   │   │       │   ├── image
│   │   │       │   ├── notification
│   │   │       │   ├── payment
│   │   │       │   ├── reservation
│   │   │       │   ├── store
│   │   │       │   ├── user
│   │   │       │   └── waitlist
│   │   │       ├── global
│   │   │       │   ├── annotation
│   │   │       │   ├── config
│   │   │       │   ├── dto
│   │   │       │   ├── entity
│   │   │       │   ├── exception
│   │   │       │   ├── filter
│   │   │       │   ├── security
│   │   │       │   └── util
│   │   │       └── TableNowApplication.java
│   │   └── resources
│   │       ├── application.yml
│   │       ├── application-local.yml
│   │       └── application-test.yml
│   └── test
├── Dockerfile
├── docker-compose.yml
├── .env
├── .env.local
├── .env.test
├── .gitignore
├── .gitattributes
├── build.gradle
├── gradlew
├── gradlew.bat
├── settings.gradle

🤔 환경변수 관리는 어떻게 할까?
우리는 환경마다 다른 설정을 유연하게 관리하기 위해,
Spring Boot의 application.yml 계층 구조와 .env 파일을 함께 사용하는 방식을 선택했다.

  • application.yml : 공통 설정
  • application-local.yml : 로컬 개발 환경 설정
  • application-test.yml : 테스트 환경 설정

이 설정 파일들과 연동되는 .env, .env.local, .env.test 파일을 각각 만들어
DB 접속 정보, Redis 설정, AWS 자격 증명 등 외부 노출되면 안 되는 민감한 값을 여기에 따로 분리해 관리하고 있다.

예를 들어 .env.local에는 아래와 같은 값들이 들어간다.

이렇게 하면 민감한 정보는 Git에 올라가지 않고,
각 환경별로 .env만 바꿔주면 바로 설정이 적용되어 훨씬 안전하고 편하다.

💡 EnvFile 플러그인 함께 사용하기
EnvFile 플러그인을 사용하면 Run/Debug Configuration.env 파일을 쉽게 연동할 수 있다.

아래처럼 Run 설정에서 "Enable EnvFile" 옵션을 체크하고 원하는 .env 파일 경로를 지정해주면, 실행 시 해당 환경변수들이 자동으로 애플리케이션에 주입된다.

기능 구현

2주차는 기술 고도화를 위한 기본 기능을 개발하고, 각자 받은 코드리뷰를 바탕으로 리팩토링을 진행했다.

  • 예약 생성, 조회, 수정, 취소, 처리 기능 구현
  • 이벤트 등록, 조회, 수정, 삭제, 신청(참여) 기능 구현

구현한 부분을 일일이 적기엔 양이 많고 기본적인 코드만 있어서 내가 신경 쓴 부분, 리팩토링 한 부분, 고민했던 점 등을 간단히 적어본다.

검증 책임 분리

예약 기능에서는 예약 가능 여부, 중복 여부, 매장 상태 등의 다양한 조건을 검증해야 했다. 조건이 복잡해질수록 코드가 비대해질 수 있기 때문에, 검증 책임을 메서드 단위로 분리하여 클린 코드 원칙(단일 책임, 가독성, 유지보수성)을 고려해 설계했다. 이 부분은 튜터님께서도 잘 구성했다고 긍정적인 피드백을 주셨다.

아래는 실제로 분리한 검증 메서드들이다.

이렇게 분리한 검증 로직은 메서드 내에서 다음과 같이 예약 생성 전 유효성 검사를 명확히 수행하도록 구성되어 있다.

도메인이 책임지는 예약 변경 로직

예약 상태 변경 로직은 단순히 서비스 레이어에서 처리하는 것이 아니라, 예약 도메인 자체가 그 책임을 가지도록 구성했다.
예를 들어 tryCancel() 메서드는 단순한 setter가 아니라, 예약이 취소 가능한 상태인지 자체 검증을 포함한 명확한 도메인 행위로 정의되어 있다.

또한 메서드 네이밍도 tryCancel, updateStatus처럼 의도를 잘 드러내는 이름으로 구성하여 코드를 읽는 사람도 도메인 흐름을 쉽게 이해할 수 있도록 했다.

서비스 레이어에서는 단순히 해당 도메인의 메서드를 호출하는 구조로, 책임이 자연스럽게 나뉜다.

서비스 로직에서 직접 상태를 변경하는 대신, 도메인 객체에게 그 책임을 위임함으로써 비즈니스 로직을 더욱 명확하고 견고하게 관리할 수 있었다. 물론 이런 방식이 모든 상황에 정답은 아니지만, 상황에 따라 잘 판단해서 사용하면 좋을 것 같다.

예약 시간 검증 리팩토링

초기에는 예약 시간의 유효성을 서비스단에서 메서드로 직접 검증했다.
아래와 같이 validateReservedAtHalfHour() 메서드를 통해 정시(00분) 또는 30분 단위가 아닌 경우 예외를 발생시키는 방식이었다.

하지만 튜터님으로부터 "요청 DTO의 단일 필드 유효성은 컨트롤러 단에서 처리하는 것이 적절하다" 는 피드백을 받고, 해당 검증 책임을 요청 객체 쪽으로 이동했다. 이에 따라 다른 팀원 분이 하신 방식을 따라 커스텀 어노테이션 @HalfHourOnly를 이용해 컨트롤러 진입 시점에서 입력값의 형식을 검증할 수 있도록 리팩토링했다.

검증 로직을 컨트롤러 단으로 이동시킴으로써 서비스 단의 책임을 명확히 하고, 비즈니스 로직 흐름을 더 깔끔하게 유지할 수 있었다.

DB Lock 구현

처음에는 정말 기본 기능만 구현한 후 Redisson을 적용하려고 했는데, 막상 진행하려니 아주 기본적인 구조여서 베이스 라인으로 하기에는 부실한 것 같다는 생각이 들었다. 고민하다가 튜터님께 질문하니 "현재 상황과 Redisson 락 적용을 비교하는 것은 크게 의미가 없다" 라고 하셨다. 따라서 추후 비교를 한다면 DB 락 vs Redisson 분산 락을 해보는 것이 더 낫다는 것이다.

따라서 우선은 DB 락을 먼저 적용해보기로 하고, JMeter도 익힐 겸 v1과 v2를 나눠 진행했다.

  • v1 : 별도의 락 없이 기본 로직 구현
  • v2 : Pessimistic Lock(DB 락)을 적용해 중복 참여 방지 및 정합성 확보

코드 자체는 이벤트를 가져올 때 제외하고는 모두 동일하다. v2에서는 다음과 같이 비관적 락이 적용된 findByIdForUpdate() 메서드를 이용해 이벤트를 가져온다.

정원이 10명인 이벤트에 200명이 동시에 이벤트를 신청하다고 가정하고 테스트 해보니, 그 결과는 다음과 같았다.

No LockDB Lock

정리해보면, No Lock 버전은 응답 속도는 빠르지만 초과 신청이 발생해 정합성이 깨졌다. 반면, DB Lock을 적용한 버전은 응답 시간과 처리량은 감소했지만, 정확히 10명만 신청되며 정합성을 보장할 수 있었다.

Redisson 기반 분산 락(v3) 적용 시에는 이 두 가지 요소를 모두 개선하는 방향으로 고도화를 진행할 예정이다.

항목No LockDB Lock
요청 수200200
평균 응답 시간53ms 🟢415ms 🔴
최대 응답 시간199ms753ms
에러율90.50%95.00%
Throughput507.6/sec 🟢233.9/sec
전송/수신 KB/sec208.22 / 109.0694.20 / 50.26

테스트

현재는 DB 락 기반으로 이벤트 신청 API의 동시성 문제를 방지하고 있으며, JMeter를 활용해 정합성 중심의 테스트를 수행했다.
단일 요청 기준의 API는 Postman으로 충분했지만, 이벤트와 같이 여러 사용자가 동시에 접근할 수 있는 기능에 대해서는
멀티 스레드 환경에서 예상치 못한 충돌이 없는지 중점적으로 검증했다.
이후 Redisson 적용 시, 동일한 시나리오로 성능 및 안정성 테스트를 진행해볼 예정이다.

Postman

  • 주요 용도: REST API 기본 기능 테스트 및 빠른 검증
  • 활용 내용
    • 인증, 회원가입, 예약, 이벤트 등 API의 정상 응답/에러 응답 확인
    • 다양한 요청 시나리오를 환경 변수 설정을 통해 반복 실행
    • 팀원 간 공유를 위해 테스트 컬렉션 구성 및 문서화

JMeter

  • 주요 용도: 이벤트 신청 API 등 동시성 문제 발생 가능 구간의 정합성 테스트, 성능 테스트
  • 활용 내용
    • 현재는 DB 락(Pessimistic Lock)을 적용한 상태
    • 이벤트 신청 시 중복 참여나 초과 신청이 발생하지 않는지 확인
    • 다양한 스레드 수로 시뮬레이션하며 정합성 유지 여부 체크
  • 성능보다 데이터 충돌 방지 및 동시 요청 시 안정성 확인이 목적
  • 향후 Redisson 분산 락 전환 전 비교 테스트를 위한 기반 마련

0개의 댓글