← Home
· 6 min

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.

  1. ReactionEventHandler implements the application layer's ReactionEventPublisher port. It holds a LinkedBlockingQueue and starts a dedicated consumer thread at @PostConstruct. An API request only puts an event on the queue and answers 202 Accepted. The client adds one to the count on screen.
  2. 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.
  3. ReactionRequestAggregator cancels 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.
  4. ReactionBatchWorker runs many INSERTs and DELETEs in one transaction with JDBC batchUpdate. ON CONFLICT DO NOTHING ignores duplicates safely, and only rows actually affected are counted, so the talk's counters are updated with the fewest UPDATEs.
  5. Graceful shutdown. On ContextClosedEvent the 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).

  1. AuthTokenService sends both tokens as HttpOnly, Secure cookies. AppCookieProperties takes the domain, sameSite, and secure values per environment from configuration, so cookies work across subdomains.
  2. 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 in Set-Cookie, so the front end never calls a refresh API.
  3. RefreshTokenRedisCache keeps refresh token IDs in Redis for server-side revocation. On refresh the old token is deleted and a new one issued: rotation against replay.
  4. AuthMemberRedisCache caches 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.

  1. RandomRecommendTalkMemory holds the list in an AtomicReference<Set<Talk>>. A request always gets the cached data at once, with no query.
  2. 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.
  3. A dedicated single-thread executor (maxPoolSize=1) keeps the random query off the API request threads.
  4. RandomTalkSampler fetches only IDs with SELECT 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.

  1. NaverBookSearcher is an outbound adapter implementing the ExternalBookSearcher port. It calls the API with Spring's RestClient and abstracts pagination as Slice<ExternalBook> for infinite scroll.
  2. 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 composite TitleAndIsbn key tells apart editions that share an ISBN.
  3. 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.

  1. Feature modules: member, book, talk, reaction, security, customer, each with domain, application, and adaptor packages.
  2. Segregated ports. Inbound ports are split by role, such as MemberQueryable, MemberCommandable, and MemberRegistrable, so a client depends only on what it uses. Outbound ports such as ReactionEventPublisher, ExternalBookSearcher, and RefreshTokenCache hide technical detail.
  3. Package-private encapsulation. Service and adapter classes are package-private. Modules interact only through public interfaces.
  4. Business rules in the domain model. State changes like Talk.validateContent() and Member.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.

Next: how I develop it with AI agents.

Comments 0

Be the first to comment.