드디어 최종 프로젝트를 시작했다. 사실 벌써 2주차가 끝났지만 정신없이 달려오다보니 블로그에 기록을 하지 못했다. 한 주가 끝날 때마다 꾸준히 기록하는 습관을 가지자.✍🏻
우리 팀은 테이블링 서비스를 주제로 정했다!
📆 1 ~ 2주차 일정
우리 팀의 핵심 기능은 다음과 같이 정리할 수 있다.
| 기능 | 상세 |
|---|---|
| 인증/보안 | Spring Security + JWT Refresh Token 저장 및 관리(Redis) OAuth2.0 로그인 연동 |
| 검색/캐싱 | 인기 검색어 캐싱(Redis) Elasticsearch |
| 동시성 제어 | Redisson Redis Sorted Set |
| MQ | RabbitMQ |
나는 예약/이벤트 도메인을 맡았고, 동시성 제어는 처음이어서 핵심 기술에 대해 고민이 많았다. 기술적의사결정 과정은 다음과 같다.
📌 목적: 예약 및 이벤트 요청 시 중복 처리를 방지하기 위한 분산 락 구현
[선택 이유]
[다른 기술과의 비교]
[결론]
복잡한 락 시스템을 쓰기보다, Redis 기반의 간단하면서도 안정적인 락의 필요성을 느껴 Redisson으로 결정했다.
또한 다른 도메인에서도 Redis를 이미 사용하고 있으므로 추가적인 리스크 없이 도입이 가능하다.
📌 목적: 리마인드 알림, 이벤트 오픈과 같이 특정 시간에 발생해야 하는 작업을 스케줄링
[선택 이유]
[다른 기술과의 비교]
[결론]
시간을 score로 설정하여 정확한 시간에 맞춰서 작업을 트리거할 수 있다.
따라서 리마인드 알림, 이벤트 오픈 시간 도달 시 트리거(가게 상태 변경) 작업에 적합하다.
또한 이벤트 오픈 시 실시간 처리가 필요하므로 메모리 기반의 Redis가 효율적이다.
📌 목적: 예약 완료, 리마인드 알림, 이벤트 오픈 알림을 다른 도메인으로 안전하게 전파
[선택 이유]
[다른 기술과의 비교]
[결론]
MQ를 사용하는 것은 이번이 처음이지만, 대용량 데이터 처리나 로그 추적, 재처리가 필요한 상황은 아니었다.
다만 리마인드 알림뿐 아니라 빈자리 알림, 이벤트 오픈 알림처럼 선착순으로 처리되어야 하는 이벤트들이 존재했기 때문에
메시지의 정확한 도착 보장이 무엇보다 중요했다.
이에 따라 Kafka보다 속도는 느리더라도, 정확한 메시지 전달과 유연한 라우팅이 가능한 RabbitMQ를 선택하게 되었다.

노션으로 팀원들과 작성한 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를 이용해 컨트롤러 진입 시점에서 입력값의 형식을 검증할 수 있도록 리팩토링했다.

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

처음에는 정말 기본 기능만 구현한 후 Redisson을 적용하려고 했는데, 막상 진행하려니 아주 기본적인 구조여서 베이스 라인으로 하기에는 부실한 것 같다는 생각이 들었다. 고민하다가 튜터님께 질문하니 "현재 상황과 Redisson 락 적용을 비교하는 것은 크게 의미가 없다" 라고 하셨다. 따라서 추후 비교를 한다면 DB 락 vs Redisson 분산 락을 해보는 것이 더 낫다는 것이다.
따라서 우선은 DB 락을 먼저 적용해보기로 하고, JMeter도 익힐 겸 v1과 v2를 나눠 진행했다.
v1 : 별도의 락 없이 기본 로직 구현v2 : Pessimistic Lock(DB 락)을 적용해 중복 참여 방지 및 정합성 확보
코드 자체는 이벤트를 가져올 때 제외하고는 모두 동일하다. v2에서는 다음과 같이 비관적 락이 적용된 findByIdForUpdate() 메서드를 이용해 이벤트를 가져온다.

정원이 10명인 이벤트에 200명이 동시에 이벤트를 신청하다고 가정하고 테스트 해보니, 그 결과는 다음과 같았다.
| No Lock | DB Lock |
|---|---|
![]() | ![]() |
![]() | ![]() |
정리해보면, No Lock 버전은 응답 속도는 빠르지만 초과 신청이 발생해 정합성이 깨졌다. 반면, DB Lock을 적용한 버전은 응답 시간과 처리량은 감소했지만, 정확히 10명만 신청되며 정합성을 보장할 수 있었다.
Redisson 기반 분산 락(v3) 적용 시에는 이 두 가지 요소를 모두 개선하는 방향으로 고도화를 진행할 예정이다.
| 항목 | No Lock | DB Lock |
|---|---|---|
| 요청 수 | 200 | 200 |
| 평균 응답 시간 | 53ms 🟢 | 415ms 🔴 |
| 최대 응답 시간 | 199ms | 753ms |
| 에러율 | 90.50% | 95.00% |
| Throughput | 507.6/sec 🟢 | 233.9/sec |
| 전송/수신 KB/sec | 208.22 / 109.06 | 94.20 / 50.26 |
현재는 DB 락 기반으로 이벤트 신청 API의 동시성 문제를 방지하고 있으며, JMeter를 활용해 정합성 중심의 테스트를 수행했다.
단일 요청 기준의 API는 Postman으로 충분했지만, 이벤트와 같이 여러 사용자가 동시에 접근할 수 있는 기능에 대해서는
멀티 스레드 환경에서 예상치 못한 충돌이 없는지 중점적으로 검증했다.
이후 Redisson 적용 시, 동일한 시나리오로 성능 및 안정성 테스트를 진행해볼 예정이다.