본문으로 바로가기
Portfolio
Back to Home
backendJun 2026 –

CrossView

A study platform where people preparing for career transitions share resumes and practice mock interviews together. It started as a simple idea — 'online interview study groups would be convenient' — but once I actually deployed and started running it, I found myself facing a completely different kind of problem: 'how do I keep the costs under control to sustain a personal project long-term?'

Role: Solely designed, implemented, and operating the entire stack: frontend (Next.js), backend (Spring Boot), infrastructure (Terraform), and CI/CD (GitHub Actions). I spent more time building 'a structure I can operate alone and reliably' than on feature development. Once the features worked, I re-read the codebase from scratch looking for the places that run fine today but collapse when conditions change — concurrency, query growth, transaction boundaries, schema management — and fixed each one alongside a reproduction test.

// Architecture
Architecture diagram

Spring Boot, PostgreSQL, Redis, and Next.js run together via Docker Compose on a single EC2 instance. DB backups are automated with Spring Batch: pg_dump → S3 upload → local cleanup, running daily at 3 AM. When an error occurs, a custom Logback Appender catches it, sends it to Bedrock Claude Haiku for root-cause analysis, and delivers the result via email and Slack. More recently I added a remote-ops layer: Claude Code runs resident inside the server under tmux+systemd, reachable from my phone over outbound-only remote-control, so an incident alert lets me direct a fix — code change → git push → confirm the existing CD pipeline deploys it — from wherever I am.

Design Rationale

My first instinct was to use RDS — it's the obvious choice. But when I priced it out, even a db.t3.micro came to ~$15/month, and with storage and backup costs on top, it rivaled the EC2 bill itself. I asked myself: 'Does this service actually need RDS-level availability?' Honestly, for a study platform with a few dozen users, Multi-AZ failover was overkill. So I put PostgreSQL in Docker on EC2 and covered the data loss risk with daily S3 backups. An RPO of 24 hours means 'worst case, I lose one day of data' — and for this service, that's an acceptable tradeoff. For error alerting, I originally planned to just collect logs and email them. But after waking up to error emails at 3 AM and having to judge 'is this urgent or not?' every single time, it got exhausting. So I plugged in Bedrock Claude — but to keep costs predictable, I chose the cheapest Haiku model, capped context at 50 recent log lines, and added a 10-minute dedup cooldown for identical errors. The principle was: 'use AI, but never let the cost become unpredictable.'

// Tech Stack
Spring Boot / JPA / QueryDSLREST API, domain logic, dynamic queries

With 15 entities and complex relationships (groups, memberships, resumes, evaluations — lots of many-to-many), and dynamic filtering on the recruitment board, QueryDSL's type-safe query building made long-term maintenance far easier than string-based JPQL.

Docker Compose + PostgreSQLApplication runtime + data storage

RDS would have been convenient, but its monthly cost nearly matched the EC2 bill. When I honestly evaluated whether a personal project needs managed DB features like automatic backups and failover, the answer was no — Docker PostgreSQL plus S3 backups was sufficient. More operational overhead, but less than half the cost.

Spring Batch + S3Daily DB backup automation

A cron job with a shell script could have done it, but I needed retry on failure, alerting, and execution history tracking. Spring Batch's Job/Step structure cleanly separated the three stages — pg_dump, S3 upload, local cleanup — and JobParameters prevented duplicate runs for free.

Bedrock Claude Haiku + LogbackAutomated error analysis and alerting

Receiving raw error logs by email meant I had to judge 'is this urgent or ignorable?' every time. Haiku takes over that judgment call — root-cause analysis and severity assessment — and in exchange I capped context at 50 lines, added a 10-minute dedup cooldown, and limited the async thread pool to 3, so the cost of running it stays bounded.

TerraformFull AWS infrastructure as code

With 10+ resources (EC2, S3, Security Groups, CloudWatch alarms...), managing them through the console would inevitably lead to 'why is this security group rule open?' Code preserves intent in version history and makes the entire environment reproducible.

FlywayDatabase schema version control

I started with ddl-auto=update. It was fast to develop against, but it leaves you unable to answer 'what state is production actually in?' from the code. Dropped columns never get applied, nothing is reversible, and there is nothing to review. Moving to Flyway turned schema changes into SQL files that go through code review, and left JPA with a single job via ddl-auto=validate: fail startup when entities and the real schema diverge. Failing at deploy time is far better than discovering a missing column at runtime.

Claude Code (Remote Control)AI agent resident on the server for remote ops

Getting an incident email at night was useless if I wasn't at my computer to act on it. Leaving SSH open around the clock for phone access would have broken this server's security model, which closes port 22 by default. Remote-control keeps only an outbound connection alive, and I withheld deploy rights from it — it can commit and git push, nothing more — so the existing CD pipeline's build, health check, and rollback safeguards stay exactly as they were.

// Problem Solving
Issue
Re-reading the join logic, I realized a 6-person group could logically end up with 7 members.
Analysis
The flow was 'check capacity, then add the member' — and I had missed that another request can slip between those two steps. With one seat left, two simultaneous requests both see room and both insert. Duplicate joins are caught by the unique(user_id, group_id) constraint, but 'member count <= capacity' is not the kind of condition a database constraint can express, so the application has to guarantee it. Worse, the capacity check read the JPA collection size, and a collection loaded into the persistence context cannot see a member another transaction just inserted — it was the wrong basis for the decision to begin with. I wrote the reproduction test first: with a 2-person group that already had its owner, 10 concurrent join requests all succeeded.
Solution
I took a write lock on the group row (SELECT ... FOR UPDATE) to serialize joins for that group. I considered optimistic locking, but inserting a membership does not modify the group row, so no version would ever bump. Since the lock scope is a single group, joins to different groups never wait on each other, which made pessimistic locking the right fit. The capacity check now uses a COUNT query instead of the collection size. Duplicate joins still rely on the unique constraint as the last line of defense, but saveAndFlush surfaces the violation inside the service so it can be translated into a domain error — with plain save, the INSERT is deferred to commit, the exception escapes the service, and the user gets a 500. As a safety net for any path I might have missed, I also added a DataIntegrityViolationException handler in the GlobalExceptionHandler that maps to a 409. The recruitment-approval path had the same race, so it got the same treatment.
Result
In the same reproduction test, exactly 1 of 10 succeeded and the rest were rejected as full, leaving exactly 2 members. I also verified that 5 concurrent requests from the same user still produce exactly 1 membership. To confirm the test actually catches regressions rather than passing by coincidence, I removed the lock again and watched it fail immediately (expected 1, got 10). Race conditions cannot be reproduced with mocks, and a rollback-based @Transactional test cannot run multiple transactions at once, so this lives as an integration test using a real database and real threads. This work brought the full suite to 50 passing tests.
Issue
The recruitment board's 'current members' count was computed as group.getMemberships().size() — loading every membership row just to display a single number.
Analysis
The screen only needs a count, but the whole collection was landing in the persistence context to produce it. A detail view (one group) can just run a single COUNT query, but a list view has many groups on one page — counting each group separately would just be a different N+1. So a single-item view and a list view can't be solved the same way.
Solution
I introduced a projection interface, GroupMemberCount (groupId, memberCount), and for list views, collected all the group IDs on the page and ran one IN + GROUP BY query to aggregate member counts into a Map. For the detail view, where there's exactly one group, batching adds nothing, so it keeps a plain COUNT query. Same underlying problem, different fix depending on the calling context.
Result
List queries now issue exactly one aggregate query regardless of how many groups are on the page. The same change grew the test suite covering this logic from 24 to 48 tests, locking in the regression.
Issue
An incident email at 3 AM was useless if I wasn't at my computer — I wanted to direct a fix from my phone, from code change through deploy confirmation, without being physically present.
Analysis
My first instinct — SSH plus a persistent tmux session — collided with this server's deploy pipeline (cd.yml), which keeps port 22 closed by default and only opens it to the GitHub Actions runner's IP for the duration of a deploy. Leaving SSH open around the clock for phone access would have broken that security model outright. There was also a structural trap: the deploy directory (/app/repo) gets overwritten by git reset --hard on every deploy, so if a server-resident AI edited files directly without pushing them, the next deploy would silently wipe the fix.
Solution
I switched to Claude Code's /remote-control feature. Unlike SSH, where the client connects inbound to the server, the server-side Claude Code process keeps an outbound connection to Anthropic open, and the phone connects the same way and gets relayed through — no inbound port ever opens. I withheld deploy rights from it: it can commit and git push, nothing further, so the fix flows through the already-verified CD pipeline's build, health check, and rollback safeguards instead of a new, unproven path. tmux plus systemd means the session survives a server reboot automatically, and ~/.claude/settings.json splits permissions into allow (git operations, read-only checks), ask (deletion, sudo, service restarts), and deny (destructive commands) so the auto-approved scope stays bounded even when I'm not watching closely. When I lost the SSH private key partway through setup and couldn't reach the server at all, I didn't cut a new backdoor — I reused the SSH secrets the CD pipeline already held, via a workflow_dispatch workflow, to build an install/diagnose channel instead.
Result
The system got tested for real almost immediately: a 502 appeared right after resizing the instance. I initially suspected the resize, but the deploy history showed the previous deploy had already died on a failed health check before the resize even started — and on top of that, cd.yml's failure-log step was printing logs for the wrong container name (app-app-1 instead of the actual crossview-app), so the crash logs had never once been visible. Chasing the deploy history and the logging bug instead of trusting the obvious-looking cause led to the real one — t3.micro's CPU credit limits plus a health-check timeout — and only then did redeploys start succeeding again, with the remote-ops setup proving itself in production on day one.
// Retrospective

Essentially all of the code in this project was written by AI. So the question I carried through development wasn't how fast I could ship — it was what I would trust code I didn't write. The answer turned out to be tests. If I pinned down what had to hold before handing off the implementation, I could verify the behavior regardless of who wrote the code.

So I treated testing as a question of which layer verifies what, not how many tests I could write. Domain rules — anything decidable from collaborating objects alone — stayed in fast unit tests with mocks. Anything where infrastructure changes the outcome, like which queries the database actually issues or whether a lock actually engages, moved into integration tests against a real PostgreSQL. The criterion was simple: does the thing I'm trying to verify disappear the moment I swap in a mock? If it does, it doesn't belong in a unit test.

Concurrency was exactly that case. I wrote the reproduction test first — 10 people joining a 2-person group simultaneously — watched all 10 succeed, and only then added the lock. After fixing it, I deliberately removed the lock again to confirm the test failed with expected 1, actual 10. That was when it clicked that a test passing matters less than whether it fails reliably when the code is wrong. Race conditions can't be reproduced with mocks at all, and an ordinary transactional test that rolls back at the end can't spin up concurrent transactions either, so those moved into integration tests with a real database and real threads. I handled the N+1 the same way: the assertion had to be 'the aggregate query stays at exactly one regardless of how many groups are on the page,' not a vague sense that things felt slow — otherwise nothing catches the regression.

What became just as clear is that directing AI well required me to know the domain and the features precisely. That 'member count <= capacity' isn't a condition a database can enforce the way a unique constraint can, so the application has to guarantee it. That counting members one way works for a detail view — a single COUNT — but doing the same thing in a list view creates another N+1. That isn't the kind of knowledge a code generator hands you. I had to understand the problem to ask the right question, and I had to know what needed verifying to decide which layer a test belonged in. Domain understanding was the input to test design, and those tests were what made AI-written code trustworthy. The suite grew to 50 along the way, but what stayed with me was the ordering, not the count. The more I built with AI, the more domain understanding and test design turned out to matter.

The blind spots are just as real. Monitoring is skewed toward error alerting, so performance signals like response time and query latency are thin. I validated the performance work only by query count, never by putting real load on the system. Pessimistic locking is safe at this scale, but I have no data on how contention builds when requests pile onto a popular group. The gap between what I can say I fixed and what I actually measured still sits with me.