Quiet Chatter 3 : Five Backend Design Cases
Quiet Chatter is closed now. This post describes its backend as it was.
Five problems from the backend of Quiet Chatter, each with the design I settled on and what it gave me. The backend is Kotlin on Spring Boot 3; the repositories are public (gateway, talk, book, member).
1. Reactions: An Event Queue and Batch Writes
A book talk can receive a like (👍) or an "I feel the same" (❤️). Users click these often. One database transaction per click means a flood of single-row UPDATEs: the connection pool drains fast and row-level lock contention grows.
I introduced an asynchronous event queue with batched, aggregated writes.
ReactionEventHandlerimplements the application layer'sReactionEventPublisherport. It holds aLinkedBlockingQueueand starts a dedicated consumer thread at@PostConstruct. An API request only puts an event on the queue and answers202 Accepted. The client adds one to the count on screen.- The consumer thread does not poll. It works only when the queue holds at least one event, and it is a single thread, so there is no contention.
ReactionRequestAggregatorcancels out an INSERT and a DELETE for the same(talkId, memberId, type)inside one batch. Like then unlike in quick succession reaches the database as nothing at all.ReactionBatchWorkerruns many INSERTs and DELETEs in one transaction with JDBCbatchUpdate.ON CONFLICT DO NOTHINGignores duplicates safely, and only rows actually affected are counted, so the talk's counters are updated with the fewest UPDATEs.- Graceful shutdown. On
ContextClosedEventthe consumer thread is interrupted, drains the queue, and then stops.
Result. One transaction per click became one batch transaction per up to 100 events. Rapid repeated clicks can cost zero database I/O. And because the application layer depends only on the port, the batch implementation can be replaced, for example with Kafka, without touching business logic.
2. Two JWTs and Silent Refresh
On a reading service, a session that expires while someone is reading or writing ruins the experience. With only an access token, expiry means logging in again; a long expiry raises the damage of a stolen token. The SPA and the backend are also on different domains, so cookie policy needed care.
I designed an access token (30 minutes) plus a refresh token (30 days).
AuthTokenServicesends both tokens as HttpOnly, Secure cookies.AppCookiePropertiestakes the domain,sameSite, andsecurevalues per environment from configuration, so cookies work across subdomains.AuthFilter, a custom filter in the Spring Security chain, verifies the access token on each request and uses the refresh token only when the access token has expired. The new pair goes out inSet-Cookie, so the front end never calls a refresh API.RefreshTokenRedisCachekeeps refresh token IDs in Redis for server-side revocation. On refresh the old token is deleted and a new one issued: rotation against replay.AuthMemberRedisCachecaches the authenticated member for two hours, so most requests skip the database.
Result. Users stay signed in for up to 30 days. A stolen access token is useful for 30 minutes at most. Authentication costs fewer queries per request.
3. Random Recommendations: An In-Memory Cache Refreshed by CAS
The home page shows a random list of talks to everyone, changing every five seconds. ORDER BY RANDOM() on every request makes PostgreSQL scan the whole table, so response time would fall apart as data grows.
I designed an in-memory cache with a lazy refresh guarded by compare-and-set.
RandomRecommendTalkMemoryholds the list in anAtomicReference<Set<Talk>>. A request always gets the cached data at once, with no query.- Stale-while-revalidate. On
get(), if the interval (five seconds by default) has passed,lastUpdatedAt.compareAndSet()lets exactly one thread start a refresh. Every other concurrent request takes the current cache and does not wait. - A dedicated single-thread executor (
maxPoolSize=1) keeps the random query off the API request threads. RandomTalkSamplerfetches only IDs withSELECT id FROM talk WHERE is_hidden = false ORDER BY RANDOM() LIMIT ?, then loads the entities through JPA, all on the background thread.
Result. The recommendation API answers in the time of a memory read, microseconds, instead of a database query. Under many concurrent requests the query still runs exactly once per interval. The interval is configurable (app.talk.recommend.interval-ms).
4. An External Book API: Merge or Persist
Book search comes from Naver's book search API. Using the external data as is would break existing search when the API is down, and calling the API for the same book every time is wasteful. A talk also refers to an internal book_id, so outside and inside data have to stay in step.
NaverBookSearcheris an outbound adapter implementing theExternalBookSearcherport. It calls the API with Spring'sRestClientand abstracts pagination asSlice<ExternalBook>for infinite scroll.BookQueryService.mergeOrPersist()looks up which results already exist locally in one bulk query by ISBN (findByIsbnIn). Existing books get their metadata refreshed from the API (title, author, cover); new ones are saved. A compositeTitleAndIsbnkey tells apart editions that share an ISBN.- Port abstraction. The application layer knows only
ExternalBookSearcher, so moving to Kakao's or Google Books' API changes no service logic.
Result. A book searched once is kept locally and stays available when the external API fails. N search results are checked with a single SELECT, so there is no N+1. Changing the search provider means replacing one adapter class.
5. Hexagonal Modules and Interface Segregation
Starting with a plain layered architecture, dependencies between layers tangle and a technology change reaches business logic. Building alone and adding features fast, I still wanted to swap technology and write tests later without pain.
I applied ports and adapters across the whole project. Some posts argue that a hexagonal design should even refuse JPA annotations on entities to keep them plain objects. I decided that keeping JPA annotations, query-method repositories, and dependency injection is worth more than that purity, following Toby Lee's Clean Spring course.
- Feature modules:
member,book,talk,reaction,security,customer, each withdomain,application, andadaptorpackages. - Segregated ports. Inbound ports are split by role, such as
MemberQueryable,MemberCommandable, andMemberRegistrable, so a client depends only on what it uses. Outbound ports such asReactionEventPublisher,ExternalBookSearcher, andRefreshTokenCachehide technical detail. - Package-private encapsulation. Service and adapter classes are package-private. Modules interact only through public interfaces.
- Business rules in the domain model. State changes like
Talk.validateContent()andMember.deactivate()live in the entities, independent of any framework.
Result. A new feature touches its own module's adapters and little else. Replacing Redis with Caffeine, or Naver's API with another, is one adapter class. Interfaces make mocks easy, so tests run fast without outside dependencies. And unintended references between modules fail at compile time, so the architecture enforces itself.
Comments 0
Be the first to comment.