CrossView
직무 전환을 준비하는 사람들이 서로의 이력서를 공유하고, 모의 면접을 연습하는 스터디 플랫폼입니다. 처음에는 '면접 스터디를 온라인으로 하면 편하겠다' 정도의 가벼운 출발이었는데, 실제로 배포하고 운영하면서 '개인 프로젝트를 지속적으로 돌리려면 비용을 어떻게 통제할 것인가'라는 전혀 다른 종류의 문제와 마주하게 됐습니다.
담당 역할: 프론트엔드(Next.js), 백엔드(Spring Boot), 인프라(Terraform), CI/CD(GitHub Actions)까지 전 과정을 단독으로 설계·구현·운영하고 있습니다. 기능 구현보다 '혼자서도 안정적으로 운영할 수 있는 구조'를 만드는 데 더 많은 시간을 쏟았고, 기능이 다 돈 뒤에는 코드를 처음부터 다시 읽으며 동시성·쿼리·트랜잭션 경계·스키마 관리처럼 '지금은 잘 도는데 조건이 바뀌면 무너지는' 지점을 찾아 하나씩 재현 테스트와 함께 고쳤습니다.
EC2 위에 Docker Compose로 Spring Boot·PostgreSQL·Redis·Next.js를 묶어 운영하고, DB 백업은 Spring Batch로 매일 새벽 3시에 pg_dump → S3 업로드 → 로컬 정리 3단계로 자동화했습니다. 에러가 발생하면 Logback 커스텀 Appender가 잡아서 Bedrock Claude Haiku로 원인을 분석한 뒤 이메일·Slack으로 알림을 보냅니다. 최근에는 서버 안에 Claude Code를 tmux+systemd로 상주시켜 outbound 기반 remote-control로 폰에서 접속, 장애 알림을 받으면 그 자리에서 코드 수정 → git push → 기존 CD 파이프라인 배포 확인까지 지시할 수 있는 원격관제 체계도 추가했습니다.
설계 근거
처음에는 당연히 RDS를 쓰려고 했습니다. 그런데 견적을 내보니 db.t3.micro만 해도 월 15달러, 거기에 스토리지와 백업 비용까지 더하면 개인 프로젝트치고 부담이 컸습니다. '이 서비스가 RDS 수준의 가용성이 정말 필요한가?'를 스스로에게 물었고, 솔직히 사용자가 수십 명인 스터디 플랫폼에서 Multi-AZ 페일오버는 과했습니다. 그래서 EC2 안에 Docker PostgreSQL을 올리고, 데이터 유실 리스크는 S3 일일 백업으로 커버하기로 했습니다. RPO 24시간이라는 건 '최악의 경우 하루치 데이터를 잃을 수 있다'는 뜻인데, 이 서비스의 성격상 그 정도는 허용 가능한 트레이드오프라고 판단했습니다. 에러 알림도 처음에는 단순히 로그를 모아서 이메일로 보내려고 했는데, 새벽에 온 에러 메일을 읽으면서 '이게 심각한 건지 아닌 건지'를 매번 직접 판단하는 게 피곤했습니다. 그래서 Bedrock Claude를 끼워 넣었고, 비용을 통제하기 위해 가장 저렴한 Haiku 모델을 쓰되 컨텍스트를 최근 50줄로 제한하고, 같은 에러는 10분간 중복 발송하지 않도록 했습니다. 'AI를 쓰되 비용이 예측 불가능해지면 안 된다'는 원칙이었습니다.
15개 엔티티 간 관계가 복잡하고(그룹-멤버십-이력서-평가 등 다대다 관계가 많았습니다), 모집 게시판 필터링처럼 조건이 동적으로 바뀌는 쿼리가 많아서 QueryDSL로 타입 안전하게 작성하는 게 유지보수에 유리했습니다.
RDS를 쓰면 편하지만 월 비용이 EC2 요금과 맞먹었습니다. 개인 프로젝트에서 관리형 DB의 자동 백업·페일오버가 정말 필요한지 따져보니, Docker PostgreSQL + S3 백업이면 충분했습니다. 관리 부담은 늘었지만 월 비용을 절반 이하로 줄였습니다.
cron + 쉘 스크립트로도 할 수 있었지만, 백업 실패 시 재시도·알림·이력 추적이 필요했습니다. Spring Batch의 Job/Step 구조가 pg_dump → S3 업로드 → 로컬 정리 3단계를 명확하게 분리해줬고, JobParameters로 중복 실행 방지까지 얻었습니다.
에러 로그를 그대로 메일로 받으면 '이게 긴급한 건지, 무시해도 되는 건지' 판단이 매번 필요했습니다. Haiku 모델로 원인 분석·심각도 판단을 자동화해 그 판단 자체를 대신하게 했고, 대신 컨텍스트 50줄 제한·10분 중복 쿨다운·async 스레드풀 3개 상한을 걸어 비용이 통제 밖으로 나가지 않게 했습니다.
EC2·S3·Security Group·CloudWatch 알람 등 리소스가 10개가 넘으니, 콘솔에서 하나씩 만들다 보면 '이 보안 그룹 규칙이 왜 열려 있지?'를 추적할 수 없었습니다. 코드로 정의하면 변경 이력이 남고, 필요하면 환경을 통째로 재현할 수 있습니다.
초기에는 ddl-auto=update로 스키마를 자동 반영했습니다. 개발 속도는 빨랐지만, 이 방식은 '지금 운영 DB가 어떤 상태인지'를 코드 어디에서도 알 수 없게 만듭니다. 컬럼을 지워도 반영되지 않고, 되돌릴 수도 없고, 리뷰할 대상도 남지 않습니다. Flyway로 옮기면서 스키마 변경을 코드 리뷰 대상인 SQL 파일로 만들고, JPA는 ddl-auto=validate로 '엔티티와 실제 스키마가 어긋나면 기동을 실패시키는' 역할만 맡겼습니다. 런타임에 '컬럼 없음' 오류가 나는 것보다 배포 시점에 실패하는 편이 훨씬 낫다고 판단했습니다.
장애 알림 메일을 받아도 PC 앞이 아니면 대응이 늦어지는 게 불편했습니다. SSH를 상시로 열어 폰에서 접속하는 방식은 이 프로젝트가 평소 22번 포트를 차단해두는 보안 설계와 정면으로 충돌해서, 서버가 outbound 연결만 유지하고 폰도 같은 방식으로 중계받는 remote-control을 택했습니다. 배포 권한은 주지 않고 git push까지만 하도록 제한해 기존 CD 파이프라인의 빌드·헬스체크·롤백 안전장치를 그대로 유지했습니다.
- 이슈
- 그룹 정원이 6명인데 7명이 가입되는 상황이 논리적으로 가능하다는 걸 코드를 다시 읽다가 발견했습니다.
- 분석
- 참여 로직은 '정원을 확인한 뒤 멤버를 추가한다'는 순서였는데, 이 두 단계 사이에 다른 요청이 끼어들 수 있다는 걸 놓치고 있었습니다. 남은 자리가 1개일 때 두 요청이 동시에 도착하면 둘 다 '아직 여유 있음'을 확인하고 둘 다 저장합니다. 중복 가입은 unique(user_id, group_id) 제약이 막아주지만, '멤버 수 <= 정원'은 DB 제약으로 표현할 수 있는 종류의 조건이 아니어서 애플리케이션이 직접 보장해야 했습니다. 게다가 정원 판단을 JPA 컬렉션 크기(memberships.size())로 하고 있었는데, 영속성 컨텍스트에 올라온 컬렉션은 다른 트랜잭션이 방금 추가한 멤버를 반영하지 못하므로 애초에 판단 근거로 부적절했습니다. 재현 테스트를 먼저 작성해보니 정원 2명 그룹(그룹장 1명 있음)에 10명이 동시에 요청했을 때 10명 전원이 가입에 성공했습니다.
- 해결
- 그룹 행에 쓰기 잠금(SELECT ... FOR UPDATE)을 걸어 같은 그룹의 참여 처리를 직렬화했습니다. 낙관적 락도 검토했지만 멤버십 INSERT는 그룹 행을 수정하지 않아 버전이 올라가지 않고, 잠금 범위가 그룹 단위라 서로 다른 그룹의 참여는 서로를 기다리지 않으므로 비관적 락이 적절하다고 판단했습니다. 정원 판단은 컬렉션 크기 대신 COUNT 쿼리 결과로 바꿨습니다. 중복 가입은 여전히 unique 제약을 최종 방어선으로 두되, saveAndFlush로 제약 위반을 서비스 안에서 즉시 잡아 도메인 예외로 번역했습니다. save만 쓰면 INSERT가 커밋 시점까지 미뤄져 예외가 서비스 밖에서 터지고, 그러면 사용자에게 500이 나갑니다. 혹시 놓치는 경로가 있을 경우를 대비해 GlobalExceptionHandler에도 DataIntegrityViolationException을 잡아 409로 변환하는 안전망을 추가했습니다. 모집 신청 승인 경로도 같은 경쟁 상태여서 동일하게 적용했습니다.
- 결과
- 동일한 재현 테스트에서 10명 중 1명만 성공하고 나머지는 정원 초과로 거절됐으며, 최종 인원은 정확히 2명이었습니다. 동일 사용자가 5번 동시에 요청해도 멤버십은 1건만 생기는 것도 함께 검증했습니다. 이 테스트가 실제로 회귀를 잡는지 확인하려고 잠금을 다시 제거해봤더니 곧바로 실패(기대 1, 실제 10)해서, 테스트가 형식적으로 통과하는 게 아니라는 것도 검증했습니다. 경쟁 상태는 모킹으로는 재현 자체가 불가능하고 롤백되는 트랜잭션 테스트로도 여러 트랜잭션을 동시에 띄울 수 없어서, 실제 DB와 실제 스레드를 쓰는 통합 테스트로 분리했습니다. 이 작업을 포함해 테스트는 총 50개가 통과했습니다.
- 이슈
- 모집글 목록 화면에서 '현재 인원'을 group.getMemberships().size()로 계산하고 있었는데, 이게 숫자 하나를 보여주려고 그 그룹의 멤버십 행 전체를 로딩하는 동작이라는 걸 뒤늦게 발견했습니다.
- 분석
- 화면엔 인원수 하나만 필요한데 컬렉션 전체가 영속성 컨텍스트에 올라오고 있었습니다. 상세 조회처럼 그룹이 하나뿐인 화면이면 COUNT 쿼리 한 번으로 끝나지만, 목록 조회는 페이지 안에 그룹이 여러 개라 그룹마다 COUNT를 따로 날리면 그 자체로 또 다른 N+1이 됩니다. 그래서 '단건 조회'와 '목록 조회'를 같은 방식으로 풀면 안 된다고 판단했습니다.
- 해결
- GroupMemberCount라는 projection 인터페이스(groupId, memberCount)를 만들고, 목록 조회에서는 페이지에 담긴 그룹 ID를 모아 IN + GROUP BY 쿼리 한 번으로 그룹별 멤버 수를 집계해 Map으로 매핑했습니다. 반대로 단건 조회는 그룹이 하나뿐이라 배치가 필요 없어서 단순 COUNT 쿼리를 그대로 썼습니다. 같은 문제라도 호출 맥락(단건/목록)에 따라 다른 해법을 쓴 것입니다.
- 결과
- 목록 조회 시 그룹 수와 무관하게 집계 쿼리가 1회로 고정됐습니다. 같은 작업에서 이 로직을 포함한 테스트를 24개에서 48개로 늘려 회귀를 검증했습니다.
- 이슈
- 야간에 장애 알림 메일이 와도 PC 앞에 있지 않으면 즉시 대응할 수 없어서, 폰으로 지시만 하면 서버 코드 수정부터 배포 확인까지 이어지는 원격관제 환경이 필요했습니다.
- 분석
- 가장 먼저 떠올린 SSH+tmux 상주 방식은 이 서버의 배포 파이프라인(cd.yml)과 충돌했습니다. 평소엔 SSH 22번 포트를 완전히 차단하고 GitHub Actions가 배포할 때만 러너 IP를 보안그룹에 임시로 여는 구조인데, 폰 접속을 위해 SSH를 상시로 열어두면 이 보안 모델 자체가 무너집니다. 또 배포 디렉터리(/app/repo)가 매 배포마다 git reset --hard로 덮어써지는 구조라, 서버에 상주한 AI가 직접 docker-compose를 조작하게 하면 다음 배포 때 방금 고친 코드가 통째로 날아갈 위험도 있었습니다.
- 해결
- Claude Code의 /remote-control 기능으로 방향을 바꿨습니다. 클라이언트가 서버로 접속하는 SSH와 달리, 서버의 Claude Code 프로세스가 먼저 Anthropic 서버로 나가는 outbound 연결을 유지하고 폰도 같은 서버에 접속해 중계받는 구조라 인바운드 포트를 하나도 열지 않고 목표를 달성할 수 있었습니다. 배포는 AI가 직접 하지 않고 커밋·git push까지만 하도록 제한해, 이미 검증된 기존 CD의 빌드·헬스체크·롤백 안전장치를 그대로 재사용했습니다. tmux+systemd로 서버 재부팅에도 세션이 자동 복구되게 했고, ~/.claude/settings.json에 allow(git 조작, 읽기 전용 점검)/ask(삭제, sudo, 서비스 재시작)/deny(파괴적 명령) 권한 스코프를 나눠 원격 자동승인 범위를 제한했습니다. 설치 도중 SSH 개인키를 분실해 서버 접속 자체가 막혔을 때도 새 백도어를 뚫는 대신, 기존 CD가 이미 갖고 있던 SSH 시크릿을 workflow_dispatch 워크플로우로 재사용해 설치·진단 채널을 만들었습니다.
- 결과
- 구축 직후 인스턴스 리사이징 뒤 발생한 502 장애를 이 환경으로 직접 진단했습니다. 처음엔 리사이징이 원인이라 의심했지만 배포 이력을 보니 리사이징 전에 이미 직전 배포가 헬스체크 실패로 죽어 있었고, 게다가 cd.yml의 실패 로그 코드가 실제 컨테이너 이름(crossview-app)이 아닌 잘못된 이름(app-app-1)을 찍고 있어 크래시 로그가 한 번도 보인 적이 없었다는 버그까지 함께 찾아 고쳤습니다. 표면적 증상에 속지 않고 배포 이력·로깅 버그까지 추적해 진짜 원인(t3.micro CPU 크레딧 제약 + 헬스체크 타임아웃)을 특정한 뒤에야 재배포가 정상화됐고, 이 원격관제 체계가 실전에서 바로 검증됐습니다.
이 프로젝트의 코드는 사실상 전부 AI로 작성했습니다. 그래서 개발 내내 붙잡고 있던 질문은 '얼마나 빨리 짜느냐'가 아니라 '내가 짜지 않은 코드를 무엇으로 믿을 것인가'였습니다. 결론은 테스트였습니다. 구현을 맡기기 전에 무엇이 보장돼야 하는지를 테스트로 먼저 고정해두면, 코드를 누가 썼는지와 무관하게 동작을 검증할 수 있었습니다.
그래서 테스트를 '많이 쓰는 것'이 아니라 '무엇을 어느 층에서 검증할지 나누는 것'으로 접근했습니다. 도메인 규칙처럼 협력 객체만 있으면 판단할 수 있는 것은 모킹을 쓴 단위 테스트로 빠르게 돌리고, DB가 실제로 어떤 쿼리를 날리는지·잠금이 실제로 걸리는지처럼 인프라가 개입해야 결과가 갈리는 것은 실제 PostgreSQL을 띄운 통합 테스트로 분리했습니다. 기준은 단순했습니다. 모킹으로 바꿔치기하는 순간 검증하려던 대상 자체가 사라지는가. 사라진다면 그건 단위 테스트로 쓰면 안 되는 것이었습니다.
동시성 문제가 정확히 그 경우였습니다. 정원 2명 그룹에 10명이 동시에 요청하는 재현 테스트를 먼저 짜서 10명 전원이 가입되는 걸 눈으로 확인한 뒤에 잠금을 걸었고, 고친 다음에는 일부러 잠금을 다시 제거해 테스트가 기대 1·실제 10으로 실패하는지까지 확인했습니다. 테스트가 통과하는 것보다 '틀렸을 때 확실히 실패하는지'가 더 중요하다는 걸 그때 알았습니다. 경쟁 상태는 모킹으로는 재현 자체가 안 되고, 테스트 종료 시 롤백되는 일반적인 트랜잭션 테스트로도 여러 트랜잭션을 동시에 띄울 수 없어서, 실제 DB와 실제 스레드를 쓰는 통합 테스트로 따로 뺐습니다. N+1도 같은 방식으로 다뤘습니다. '느려진다'는 감각이 아니라 '집계 쿼리가 그룹 수와 무관하게 1회로 고정되는가'를 검증 대상으로 삼아야 회귀를 잡을 수 있었습니다.
동시에 알게 된 건, AI에게 제대로 지시하려면 제가 도메인과 기능을 정확히 알고 있어야 한다는 점이었습니다. '멤버 수 <= 정원'은 unique 제약처럼 DB가 막아줄 수 있는 조건이 아니라 애플리케이션이 직접 보장해야 하는 규칙이라는 것, 같은 '멤버 수 세기'라도 단건 조회는 COUNT 한 번이면 되지만 목록 조회는 그렇게 하면 또 다른 N+1이 된다는 것 — 이건 코드 생성기가 알려주는 종류의 지식이 아니었습니다. 문제를 알고 있어야 질문을 정확히 던질 수 있고, 무엇을 검증해야 하는지 알아야 테스트를 어느 층에 둘지 정할 수 있었습니다. 결국 도메인 이해가 테스트 설계의 입력이었고, 그 테스트가 AI가 써준 코드를 믿을 근거가 됐습니다. 이 작업들을 거치며 테스트는 50개까지 늘었지만, 남은 건 개수가 아니라 그 순서였습니다. AI로 개발할수록 도메인 이해와 테스트 설계가 오히려 더 중요해진다는 게 이 프로젝트에서 가장 크게 남은 생각입니다.
반대로 아직 못 본 영역도 분명합니다. 모니터링이 에러 알림에 치중되어 있어 응답 시간이나 쿼리 지연 같은 성능 지표 관측은 부족하고, 성능 개선도 쿼리 개수로만 검증했을 뿐 실제 부하를 걸어보지는 못했습니다. 비관적 락도 지금 규모에서는 안전하지만 인기 그룹에 요청이 몰릴 때 대기가 어떻게 쌓이는지는 데이터로 확인하지 못했습니다. 고쳤다고 말할 수 있는 범위와 실제로 측정해본 범위가 다르다는 점이 계속 남아 있습니다.