KOK: A Restaurant Reservation and Waiting Platform
Team Sparta This project was part of the Sparta Java Spring Boot Backend Boot Camp, built with Team 5makase.

Preview of what I focused on
- Dev Lead
- API Gateway routing and service discovery (Spring Cloud Gateway, Eureka)
- User and Auth Service: signup, login, JWT based role management, owner approval workflow
- Infra setup and deployment: Docker Compose for local dev, EC2 for the final deployment
- Leading our load testing effort on the reservation flow to validate the Redis lock design against a plain DB lock
🐱 GitHub Link
https://github.com/5makase/KOK
KOK: Overview

Kok (콕) is a real time restaurant reservation and waiting platform, loosely modeled after CatchTable(Korean reservation service platform).
Our goals going in were straightforward to state and much harder to actually build well.
- Prevent overbooking when many reservation requests hit the same time slot at once
- Compare performance before and after introducing Redis caching and locking, then optimize based on the numbers
- Prove system stability with real load testing, not just a demo that works once
That last point ended up shaping a large part of our final two weeks, more on that below.
Built for Verification, Not Just a Demo
If there is one idea that shaped every decision on Kok, it is this: we built it with verification, real deployment, and large scale traffic in mind from the start, not just a demo that runs once on a laptop.
Our goal was never simply to write the code and call each feature done once it compiled and worked on a happy path.
The actual goal was to build every feature to a level where it could genuinely be deployed, use testing to find where it actually breaks, and fold whatever we found back into the MVP before we allowed ourselves to move on.
To chase that goal, we ran a layered set of tests across every domain rather than relying on one kind of check.
- JUnit unit and integration tests covered core logic like the owner approval workflow, the reservation service's distributed lock timeout and Outbox publish retry, and Kafka consumer idempotency.
- Postman API tests covered request level flows like duplicate waiting registration.
- JMeter and k6 load tests pushed Gateway, User, Reservation, Waiting, Store, and Notification toward the real traffic numbers from our system design doc (150 RPS at the edge and on Store's search, 1,000 Kafka messages a second on Notification, a conservative 10 RPS on User's CPU heavy login, and the Redis lock versus DB lock comparison on Reservation).
And on top of all of that,
- we ran deliberate failure injection tests, killing the Payment Service container mid reservation, firing a rating event against a store we had just soft deleted, forging an X-User-Id header at the Gateway, to check whether our failure handling actually held under a real failure, not just whether the happy path worked.





🖍️ Technical Highlights
Backend and MSA
- Java 17, Spring Boot for MSA based REST APIs
- Spring Cloud Gateway, Eureka, and OpenFeign for routing, service discovery, and synchronous service to service calls
- Resilience4j circuit breakers wrapping the Feign calls

Auth
- JWT based authentication, with separate roles for USER, OWNER, and MASTER
- New OWNER accounts require
MASTERapproval before they can manage a store

Data and Caching
- PostgreSQL as the database, split per service so each service only ever touches its own schema
- Redis and Redisson for waiting queue order, reservation locking, and read heavy caching

Messaging
- Kafka handling reservation, waiting, and review events asynchronously, connected through a transactional Outbox pattern so publishing an event and committing the DB change stay consistent
- Choreography style event flow rather than a central orchestrator, so each service reacts to the events it cares about

Observability
- Prometheus and Grafana for metrics, Loki and Promtail for logs, Zipkin for distributed tracing
- An internal AI ops assistant service that layers an LLM on top of the metrics for CPU and health alerts

You can watch the demonstration from here (Make sure to turn on subtitles - 1:25)
Load Testing
- JMeter and k6 for hitting the reservation and waiting APIs and comparing performance before and after our locking changes

Infra and Deployment
- Docker and Docker Compose so the whole team runs an identical local stack
- Terraform and GitHub Actions for infra as code and CI/CD
- EC2 for the final deployment

Services

Kok is split into seven domain services sitting behind a single API Gateway, plus an Eureka discovery server.
- user-service (8001): signup, login, JWT auth, role management, owner approval, Redis backed refresh tokens and a token blacklist
- store-service (8002): store and menu CRUD, category and area filtered search, Redis caching and ranking
- reservation-service (8003): reservation slot management, request/confirm/cancel flow, overbooking prevention, deposit state, publishes Kafka events
- waiting-service (8004): waiting list registration, Redis sorted set for queue order, entry handling, real time updates over SSE
- notification-service (8005): consumes Kafka events from reservation and waiting, sends Slack webhook alerts, stores an in app notification inbox
- review-service (8006): review CRUD, reports, replies, rating aggregation
- payment-service (8007): payment creation, refunds, expiration, exposed only as an internal API
Service to service communication is synchronous where it needs an immediate answer (Feign plus a circuit breaker), and asynchronous everywhere else through Kafka with the Outbox pattern, so a service never has to reach into another service's database directly.
🤹♀️ Team and My Responsibility


- Ria Choi : 👑 Dev Lead & Infra Setup
Our team goal was written down early and we kept coming back to it whenever a design discussion went in circles: build a backend that actually holds up under deployment and real traffic, not just one that passes a local demo.
🧑💻 Code Highlights: What I Actually Built
User / Auth Service (services/user-service/src/main/java/com/omakase/kok/user)
infrastructure/security/JwtProvider.java: issues and validates access and refresh tokensinfrastructure/security/JwtAuthenticationFilter.java: reads the JWT on incoming requests and sets the authenticated principal before it reaches a controllerinfrastructure/persistence/RedisRefreshTokenStore.javaandinfrastructure/security/RefreshTokenStore.java: Redis backed refresh token storage, plus the blacklist that invalidates a token on logoutglobal/config/SecurityConfig.java: the Spring Security filter chain, stateless session policy, and which paths need authapplication/auth/service/AuthService.javaandpresentation/auth/controller/AuthController.java: login, signup, and token refresh flow end to enddomain/user/entity/OwnerApproval.java,domain/user/enums/ApprovalStatus.java,domain/user/repository/OwnerApprovalRepository.java: the data model behind the "new OWNER needs MASTER approval" ruleapplication/user/service/OwnerApprovalService.javaandpresentation/user/controller/OwnerApprovalInternalController.java: approve/reject logic, exposed only as an internal endpoint so it can't be hit from outside the Gatewaypresentation/user/controller/UserController.javaandapplication/user/service/UserQueryService.java: the profile lookup endpoint,GET /api/v1/users/me, which later became the target for our Gateway k6 tests since it is the lightest authenticated call in the systemglobal/exception/GlobalExceptionHandler.java,AuthErrorCode.java,UserErrorCode.java: centralized error codes and responses for auth and user failures- Test coverage:
test/.../auth/application/service/AuthServiceTest.javaandtest/.../user/presentation/controller/OwnerApprovalServiceTest.java, including the approve, reject, and concurrency cases
API Gateway (infrastructure/api-gateway/src/main/java/com/omakase/kok/gateway)
filter/JwtAuthenticationFilter.java: the global filter that verifies the JWT at the edge before anything is routed downstream, and rebuilds the internalX-User-Idheader from the verified token instead of trusting whatever a client sentfilter/InternalBlockFilter.java: rejects any external call aimed at an internal only path, so/internal/**endpoints (like the owner approval controller above) can only be reached service to serviceutil/JwtUtil.java: shared JWT parsing and claim extraction used by the filterexception/GlobalExceptionHandler.java: a consistent error shape for auth failures at the Gateway layer- Test coverage:
filter/JwtAuthenticationFilterUnitTest.javaandfilter/JwtAuthenticationFilterSwaggerWhiteListTest.java, covering both the auth logic itself and which paths (like Swagger docs) are allowed to skip it
Check the scenario section with 🚨 emoji / pink text on the Notion document.


🚨 Domain Level Failure Scenarios and Test Results
Every domain in Kok has its own failure scenarios and tests, JUnit, Postman, JMeter, or k6 depending on what made sense. The two below are from my domains.
Gateway
📺 Scenario: someone calls an API and tries to inject an X-User-Id header directly, hoping to impersonate another user without a valid token.
🔧 Defense: the Gateway strips any incoming X-User-Id before routing, then regenerates it itself from the verified JWT claims, so an injected value is simply thrown away. Covered by a JUnit unit test, automated, tested June 28th.
User
📺 Scenario: the owner approval flow needs to behave correctly under approve, reject, and concurrent requests at once. Covered by a JUnit integration test against an H2 database, automated, tested June 28th.
The rest of the team ran the same kind of exercise on their own domains. Reservation alone has four separate cases (Outbox publish retry, distributed lock timeout, overbooking prevention, and a k6 load test on top of the lock itself), Waiting has a duplicate registration check plus a JMeter bottleneck test, Store has a k6 search load test plus an idempotency test for duplicate review events, and Notification has a JMeter test pushing 1,000 messages a second through Kafka to see how the consumer keeps up.
Check the flow test section with 🚨 emoji / green text on the Notion document.

🚨 Core Flow Level Failure Scenarios and Test Results
Here's the summary rewritten without dashes:
1. Late review on a closed store
📺 Scenario: A user visits and earns review rights, but the store gets soft deleted before they write the review. The rating update event then hits a store that no longer exists. If the consumer throws an exception instead of handling it, Kafka won't commit the offset, so it retries forever and blocks the whole consumer.
🔧 Fix: The store consumer now uses findById plus a status check instead of findActiveOrThrow. If the store is missing, deleted, or inactive, it just logs and skips instead of throwing, so the retry loop never starts.
🪔 Test: First confirm the normal flow works (review to Kafka to store rating update). Then publish a rating event for a closed store and confirm it's skipped with no exception, followed by a normal event right after to confirm it's processed fine (consumer lag stays at 0).
2. Payment failure during reservation
📺 Scenario: Payment Service is down when a user reserves. The Feign call times out or returns a 503. If the reservation is left as PENDING, the reserved slot's remaining seats never get restored, blocking other users from booking.
🔧 Fix: On payment call failure, a compensating transaction runs immediately: the reservation is set to CANCELLED and the Redis remaining seat count is restored.
🪔 Test: Kill the Payment Service container, call the reservation API, then check that the reservation status is CANCELLED, the Redis count matches the pre request value, and payment errors are returned properly. After restarting Payment, confirm a normal reservation now succeeds with status CONFIRMED.
3. Duplicate "waiting completed" event causing duplicate review eligibility
📺 Scenario: When a waiting customer is checked in, Waiting Service publishes WAITING_COMPLETED, and Review Service grants review rights (ReviewEligibility). If Kafka redelivers the same event due to an offset commit failure or consumer restart, it could create duplicate review rights for the same visit.
🔧 Fix: An idempotency check on sourceType plus sourceId (for example WAITING plus waitingId) runs before creating ReviewEligibility, along with a database level unique constraint on that pair. Duplicate events are logged and skipped.
🪔 Test: Trigger WAITING_COMPLETED once and confirm one ReviewEligibility row is created. Manually republish the same event and confirm it's skipped in the logs with the row count staying at one. Finally confirm the user can only write one review for that visit.
Load Testing Our Reservation Flow
Reservation is the service most exposed to concurrency problems, since the whole point is that many users can try to book the exact same slot at once, and only some of them should succeed.
My teammate 이진일 owns the reservation logic itself, including two different locking strategies we wanted to compare head to head: a Redis distributed lock and a plain database pessimistic lock.
We designed six separate test scenarios, because a single "throw traffic at it" test only ever answers one question.
Step Up test. Ramp the request rate in stages, 50 to 80 to 100 to 130 to 150 RPS, and record response time (P95 and P99), error rate, and TPS at each stage, looking for the point where performance starts to fall apart.

Spike test. Normal traffic sits around 1 to 5 RPS. We simulate a sudden jump straight to 150 RPS to see how the system reacts to a burst instead of a gradual ramp.
Planned but didn’t go through
Soak or endurance test. Hold a lower rate, around 30 to 50 RPS, for 30 minutes to an hour, since this is where memory leaks, connection leaks, and Redis TTL problems (locks that never get released) actually show up.
Planned but didn’t go through
Single API isolation test. Test only the reservation creation endpoint, either through Gateway then Reservation Service, or straight to Reservation Service. This tells us whether a bottleneck lives in the Gateway and JWT verification layer, or inside the reservation logic itself.
Planned but didn’t go through
Redis lock versus DB lock comparison. This is the real point of the whole project. The raw RPS number matters less than the relative gain: under identical conditions, how much better does the Redis approach do compared to the database lock.
Planned but didn’t go through
Concurrency correctness test. Separate from throughput entirely, we send more concurrent reservation requests than a slot has capacity for and check that overbooking is always exactly zero.
Planned but didn’t go through
We Also Used K6
JMeter is great for the elaborate, multi stage scenarios above, but for quick per service checks we leaned on k6 instead, one folder per service under k6/, each with its own README describing what it targets and why.
- For the Gateway, there is no business logic of its own to hit directly, so
k6/gateway/protected-route-load-test-vu.jsandprotected-route-load-test-rps.jsboth target the lightest authenticated call in the system,GET /api/v1/users/me, since the backend work behind it is trivial and almost all of the measured latency is actually JWT verification and routing overhead at the Gateway itself. - The VU script ramps up to 150 virtual users, and the RPS script holds a constant 150 requests per second, matching the peak target from our system design doc, with thresholds of p95 under 300ms and error rate under 1 percent.
- For User Service,
k6/user/login-load-test-vu.jsandlogin-load-test-rps.jshit the login endpoint directly, since User Service is intentionally scaled small (min and max of one instance) and login is CPU heavy because of BCrypt hashing. - The targets are deliberately more conservative here, 30 VU and a constant 10 RPS, with a looser p95 threshold of 500ms to account for the hashing cost.
- Reservation and Store have their own k6 scripts too: Reservation drives high concurrency traffic at a single slot to double check the lock behavior under load, and Store runs a 150 RPS search load test to validate caching and ranking hold up.
Wrapping Up
The MSA split forced clear ownership boundaries between services, the Kafka Outbox pattern kept our async events honest, and the load testing work, even in its rough first round, has already told us more about our reservation flow than any code review could have.
You can check the full source code and project board through the links below.
⛓️ Repository
📺 Project Demonstration - Backend



