똑똑 — 상명대학교 동아리 리크루팅 서비스
2025.05 ~엑셀로 흩어져 관리되던 동아리 홍보·지원·부원 선발을 하나로 통합한 서비스(FE 2·BE 2·디자이너 1). 관리자 API, DB 설계, CI/CD와 운영 인프라 담당
엑셀로 흩어져 관리되던 동아리 홍보·지원·부원 선발을 하나로 통합한 서비스(FE 2·BE 2·디자이너 1). 관리자 API, DB 설계, CI/CD와 운영 인프라 담당
레고 시세·직거래·컬렉션 플랫폼(gole.co.kr). 홍보 자동화 LLM 에이전트와 백엔드 기능 개발
동시 요청에서 조회수 증가분의 90.14%가 사라지던 문제를, 6가지 처리 방식을 같은 조건으로 실측 비교해 해결했다. 고른 방식은 유실 0%에 처리량까지 1.93배가 됐고, 같은 원자적 UPDATE라도 문장 위치만으로 1.84배 차이가 나는 원인을 PostgreSQL 락 보유 기간에서 찾았다.
동아리 상세 조회 시 조회수 증가가 read-modify-write(findById → viewCount += 1)라, 동시 요청에서 증가분이 유실됐다.
100 VU 부하에서 유실률이 90.14%였다.
NoticeRepository.increaseViewCount())로 같은 문제를 풀고 있어, 동아리 도메인도 같은 방식으로 통일했다.sequenceDiagram participant A as 요청 A participant DB as PostgreSQL (동아리 행) participant B as 요청 B Note over A,B: 변경 전 — read-modify-write A->>DB: SELECT view_count → 10 (예시) B->>DB: SELECT view_count → 10 A->>DB: UPDATE view_count = 11 B->>DB: UPDATE view_count = 11 Note over DB: 두 요청 중 하나의 증가분 유실 Note over A,B: 변경 후 — 원자적 UPDATE를 트랜잭션 맨 끝에 A->>DB: 상세 조회 쿼리 2개 (락 없음) A->>DB: UPDATE view_count = view_count + 1 (행 락 획득) B->>DB: UPDATE … (A의 커밋까지 대기) A->>DB: COMMIT (락 해제) B->>DB: 이어서 +1 반영 후 COMMIT Note over A,DB: 락 보유 구간이 UPDATE~COMMIT으로 줄어 처리량 1.84배
측정 조건(로컬): 100 VU / 5,000 요청, 워밍업 2,000 요청 후 측정, 각 변형 1회.
| 변형 | 유실 | 실패율 | RPS | p50 | p95 | p99 |
|---|---|---|---|---|---|---|
| 현행 (read-modify-write) | 90.14% | 0% | 311.7 | 300.1 | 481.5 | 622.4 |
| 원자적 UPDATE — 증가 먼저 | 0% | 0% | 327.3 | 296.3 | 371.4 | 406.8 |
| 원자적 UPDATE — 증가 마지막 (채택) | 0% | 0% | 601.4 | 157.5 | 244.8 | 325.0 |
| 원자적 UPDATE — 트랜잭션 미사용 | 0% | 0% | 319.4 | 299.2 | 472.5 | 586.0 |
| 낙관락 (@Version, 3회 재시도) | 0% | 67.36% | 150.8 | 699.6 | 1011.5 | 1205.1 |
| 비관락 (SELECT … FOR UPDATE) | 0% | 0% | 245.9 | 401.1 | 480.7 | 503.6 |
| 방법 | 장점 | 단점 | 판단 |
|---|---|---|---|
| 현행 read-modify-write | 코드가 단순하다 | 동시 요청에서 갱신이 유실된다(90.14%) | 버림 — 문제의 원인 |
| 원자적 UPDATE, 증가 먼저 | 유실 0%, 스키마 변경 없음 | 행 락이 뒤따르는 조회 2개 동안 유지돼 처리량이 거의 그대로(327.3 RPS) | 버림 |
| 원자적 UPDATE, 증가 마지막 | 유실 0%, 락 보유 시간이 가장 짧아 처리량 1.93배 | 문장 순서가 성능을 좌우하는 암묵적 계약이 된다 | 채택 — 순서를 InOrder 테스트로 고정 |
| 원자적 UPDATE, 트랜잭션 미사용 | 유실 0% | 처리량 319.4 RPS로 이득이 없다 | 버림 |
낙관락(@Version, 3회 재시도) |
락을 잡지 않는다 | 충돌률 100% 워크로드라 5,000 요청 중 1,632건만 성공, 재시도 트랜잭션 16,297개 낭비. @Version 컬럼 필요 |
버림 |
비관락(SELECT … FOR UPDATE) |
유실 0%, "읽고 판단해서 쓰기"에도 쓸 수 있다 | 처리량 245.9 RPS로 현행(311.7)보다도 느리다 | 버림 — 지원 정원 체크(maxApplyCount)처럼 판단이 필요한 곳에 맞다고 정리 |
findById 제거). 스키마 변경 없음.load-test/results/issue-a/REPORT.md (같은 저장소)EB·RDS·ElastiCache·S3로 나뉜 운영 환경을 자체 서버 한 대의 docker-compose 스택으로 옮기고 블루-그린 배포를 구축했다. 전환 구간 12,995 요청 중 비200 0건. 검증 중 "배포는 성공인데 전 요청 502"가 나던 결함의 원인을 inode 수준까지 추적해 막았다.
운영 환경이 EB(앱) + RDS(PostgreSQL) + ElastiCache(Redis) + S3/CloudFront(파일)로 나뉘어 있었고, 이를 자체 우분투 서버 한 대의 docker-compose 스택으로 통합해야 했다.
main의 배포 워크플로우는 EB 기준(runs-on: ubuntu-latest, beanstalk-deploy)이었다.
이관 검증 중 "배포는 성공인데 서비스가 죽는" 결함을 찾아냈다 (#376). 블루-그린 전환 구간은 정상이었지만 구 색 컨테이너를 정리하는 순간부터 1180건 연속 502가 났고, 파이프라인은 성공으로 끝나 결함이 드러나지 않았다.
검증 중 발견한 결함 3건의 원인 (이슈 원문 기준):
upstream.conf 바인드 마운트 단절: setup.sh가 install로 파일을 배치하는데, install은 덮어쓰지 않고 교체해 inode가 바뀐다(실증: inode 8870 → 8871, > 리다이렉트는 inode 유지). 단일 파일 바인드 마운트는 옛 inode를 물고 있어서, 이후 deploy.sh의 truncate-write가 컨테이너에 닿지 않았다. 전환 절차(nginx -t / reload / state 갱신 / 구 색 정리)가 전부 성공을 보고하므로 조용히 실패한다. 또 upstream.conf는 설정이 아니라 런타임 상태(현재 활성 색)인데 setup.sh 재실행 때마다 blue로 되돌아갔다.adduser -S가 UID를 자동 할당해, 호스트 설정 파일의 소유자와 맞지 않았다. Spring Boot 기본 탐색 경로가 optional:file:./config/라 읽기 실패를 예외 없이 건너뛰고, 한참 뒤 jwt.secret 플레이스홀더에서 터져 원인과 증상이 멀었다.systemd-resolved 업스트림 2개 중 하나가 SERVFAIL이라, 캐시 만료 후 고장난 쪽을 고르면 curl exit 6(Could not resolve host)으로 실패했다. 서비스는 정상(200)이었다.sequenceDiagram
participant CI as Actions 러너
participant D as deploy.sh
participant New as 새 색 컨테이너
participant N as Nginx
participant Old as 기존 색 컨테이너
CI->>D: 이미지 빌드 후 배포 시작
D->>New: 비활성 색으로 기동
D->>New: 헬스체크
alt 헬스체크 실패
D-->>CI: 전환 없이 실패 (기존 색이 계속 서비스)
else 통과
D->>N: upstream을 새 색으로 바꾸고 reload
D->>N: 컨테이너 안에서 바뀐 upstream이 보이는지 확인 (이슈 376 방어)
alt 어긋남
D-->>CI: 기존 색을 살린 채 배포만 실패
else 일치
D->>Old: 드레인 후 정리
CI->>N: 스모크 테스트
end
end
결정이 여러 개라 결정별로 정리했다 (이슈 #354·#372·#374·#376 기준):
| 결정 | 택한 방법 | 버린 방법과 이유 |
|---|---|---|
| 파일 스토리지 | MinIO 자체 호스팅 + 기존 공개 도메인 유지 | DB URL 치환 마이그레이션. URL이 단순 컬럼뿐 아니라 마크다운 본문·JSON·jsonb 안에도 있어 부분 치환 위험이 큼 |
| 메일 | Postfix 릴레이 컨테이너 경유 → 기존 외부 릴레이 | 서버 직접 발송. 자체 서버에서 직접 보내면 수신 측에서 거절·스팸 처리될 위험 |
| 외부 노출 | 외부에는 nginx만 공개하고 내부 서비스는 외부에 열지 않음 | UFW로 차단. Docker가 삽입한 iptables 규칙보다 늦게 평가돼 published 포트를 막지 못함 |
| DB 이관 | 기존 덤프 복원 | Flyway로 신규 생성. 첫 스크립트가 ALTER TABLE로 시작해 빈 DB에서 불가 |
| #372 권한 | Dockerfile에서 UID/GID를 호스트와 같게 고정 | 파일 권한 완화. JWT 시크릿·Firebase 키라 권한을 넓히지 않음 |
| #374 DNS | 공개 DoH 리졸버로 조회 후 curl --resolve |
dig 사용. 의존성을 늘리지 않으려고 이미 쓰는 curl로 처리 |
| #376 방어 | setup.sh는 없을 때만 생성 + deploy.sh가 전환 직후 컨테이너 시야 확인, 어긋나면 구 색을 살린 채 배포만 실패 |
setup.sh만 수정. 편집기 저장·sed -i·파일 복사도 inode를 바꾸므로 배포 단계 확인이 필요 |
측정 조건(운영 환경 검증): 컷오버 전, 운영용 자체 서버에서 develop 기준 배포를 실행해 이슈 #354의 인수 조건("프록시에 지속 요청을 걸어둔 상태로 배포 실행 → 비200 응답 0")을 확인 (PR #378).
green → blue, 전환 전·중·후 및 구 색 드레인/제거 구간 전체 7분): 총 요청 12,995건 전부 200, 비200 0건Verify return code: 0, HTTP/2, HSTS. 인증서 자동 갱신과 만료 감시(메일 경고)status=sent, 오픈릴레이 차단 확인홍보 에이전트의 모델 호출을 API 키 과금 대신 외부 서버에 로그인된 claude·codex CLI로 옮겼다. 포트를 열지 않고 GitHub 러너가 SSH로 표준 라이브러리 단독 파일을 실행해 stdin/stdout JSON으로 주고받는 게이트웨이를 만들었고, 공개 저장소 로그로 서버 정보가 새지 않게 응답을 좁혔다. 실서버 리허설에서만 codex 다듬기가 조용히 실패하는 문제를 서버 커널의 비특권 user namespace 차단으로 규명했고, 샌드박스를 끄지 않고 codex가 저장한 원래 자리에서 결과를 가져오게 고쳐 85초에 끝에서 끝까지 통과했다.
~/.codex/generated_images/<세션ID>/에 저장했다. 그런데 그 파일을 작업 폴더 out/으로 옮기는 셸 명령이 모두 bwrap: No permissions to create a new namespace로 실패했다.out/ 폴더에 저장하지 못했습니다"가 에이전트까지 오지 않았다. 서버에 일회성 디버그 스크립트를 보내 같은 요청을 재현해 확인했다.sequenceDiagram participant R as Actions 러너 (에이전트) participant S as CLI 서버 (sshd) participant G as gole_llm_gateway.py participant CLI as claude / codex CLI R->>S: SSH 접속 (호스트 키 고정) + 요청 JSON을 stdin으로 S->>G: 게이트웨이 단독 파일 실행 G->>G: 요청별 임시 폴더 생성, 잠금으로 1건씩 G->>CLI: claude -p (첨부 읽기만) / codex exec (작업 폴더 쓰기만) CLI-->>G: 결과 (codex 이미지는 generated_images/세션ID/에서 회수) G->>G: 임시 폴더 삭제, 오류 상세는 서버 로그에만 G-->>R: 필요한 필드만 담은 응답 JSON을 stdout으로
호출 경로
| 방법 | 장점 | 단점 | 판단 |
|---|---|---|---|
| Anthropic API 키 | 공식 경로, 서버 불필요 | 사용량 과금 | 버림 — 이미 쓰는 CLI 구독을 활용 |
| 웹훅 서버·Tailscale로 서버 호출 | 상시 응답 가능 | 포트·상주 프로세스·추가 인프라 | 버림 — 이미 열려 있는 SSH 하나로 끝남 |
| 서버를 self-hosted 러너로 등록 | 트리거를 그대로 씀 | 스택까지 서버가 떠안음, 운영 계정 자격증명이 서버로 감 | 이 PR 안에서 만들었다가 걷어냄 |
| SSH로 게이트웨이 단독 파일 실행 (stdin/stdout JSON) | 포트·상주 없음, 표준 라이브러리만, 서버는 CLI만 돌림(약 0.5GB) | SSH 자격증명 관리 필요 | 채택 |
codex 결과 유실
| 방법 | 장점 | 단점 | 판단 |
|---|---|---|---|
샌드박스 끄기(danger-full-access) |
셸이 돌아 out/ 복사 성공 |
codex가 서버 셸을 마음대로 실행 | 버림 |
| 서버 커널 설정 변경 | 근본 해결 | 서버 root 권한 필요 | 버림 |
codex 출력의 세션 ID로 generated_images/<세션ID>/에서 가져오고 그 폴더만 삭제 |
샌드박스 유지, 서버에 이미지가 쌓이지 않음 | codex 저장 위치에 의존 | 채택 (18e91b70) |
gole_llm_gateway.py): 요청마다 본인만 접근 가능한 임시 폴더를 쓰고 지움, 잠금으로 1건씩, claude는 첨부 폴더 읽기만·codex는 작업 폴더 쓰기만, 오류 상세는 서버 로그에만, model은 영숫자로 시작하는 이름만·system은 파일로(옵션 주입 차단), codex 이미지 요청에는 글을 돌려주지 않음.REF=18e91b70 재설치): 85초에 제출. 다듬은 이미지(1254×1254)와 원본(1440×1024)이 검토 패널에 나란히 표시됐다..kiro/specs/promotion-review/spec.md D20, D22 (PR 본문 "관련 스펙")릴리스 화면을 찍어 홍보 초안을 쓰는 LLM 에이전트가 운영 VM 타이머에서 6회 연속 멈춰 있었다. 직접 원인(비어 있는 자격증명 파일)을 찾았지만, 채워도 여유 메모리 900 MB 안팎에서 Chromium을 돌려야 하는 구조적 제약이 남아 실행 위치를 GitHub 러너로 옮겼다. 러너 안에 대상 릴리스의 데모 스택을 띄워 찍게 하고, 트리거가 실행 경로를 정하게 하고, AI 판단이 필요 없는 발행은 규칙으로 바꿨다.
gole-promotion-agent.timer는 켜져 있었지만 9/24~9/29 6회 모두 실패했고, 초안 제출과 모델 호출로 이어지지 못했다./etc/gole/promotion-agent.env가 0바이트라 시작하자마자 PROMOTION_AGENT_ADMIN_EMAIL_REQUIRED로 종료됐다.mem_limit 합계가 8,192 MB 중 6,784 MB라 실효 여유가 900 MB 안팎이었다. Chromium 하나가 400 MB~1 GB를 쓰므로 상주는 불가능하고 잠깐 빌려 쓰는 일회성 컨테이너만 가능했다.flowchart TB
T1[CD 성공<br/>main 배포] --> F
T2[월·수·금 10:00<br/>크론] --> F
T3[수동 실행] --> F
F{사전 필터<br/>웹 변경 없음·초안 있음·<br/>검토 대기 5건 이상이면 중단}
subgraph R[GitHub Actions 러너]
S[대상 SHA로<br/>데모 스택 기동] --> C[Playwright 캡처]
C --> L[LLM<br/>화면 선택·캡션·다듬기]
end
F -->|기능 홍보| S
F -->|서비스 소개| A[(지난 릴리스 캡처<br/>아티팩트 재사용)]
A --> L
L --> Q[운영 API<br/>PENDING_REVIEW 제출]
Q --> H[관리자 검토·승인]
H --> P[publish-next 규칙<br/>6시간 간격 발행]
| 방법 | 장점 | 단점 | 판단 |
|---|---|---|---|
| 운영 VM 일회성 컨테이너 유지 (자격증명만 채움) | 기존 코드·인프라 그대로 | 여유 900 MB에서 Chromium 실행, 체크포인트로 복잡도 증가, 즉시 실행 불가, 운영 서버와 자원 경쟁 | 버림 — 직접 원인만 고치고 구조적 제약은 남는다 |
self-hosted 러너 + 서버의 claude 직접 실행 |
모델을 CLI 구독으로 부를 수 있음 | 무거운 스택까지 개인 서버가 떠안음, 운영 계정 자격증명이 그 서버로 감 | 이 PR 안에서 만들었다가 걷어냄 — 서버 메모리와 운영 계정 보호에 맞지 않음 |
| 프로덕션 화면을 찍음 | 스택을 띄울 필요 없음 | 실제 이용자 사진·닉네임이 홍보물에 실림, 배포 안 된 화면을 찍을 위험 | 버림 |
| GitHub 러너 안에 대상 SHA의 데모 스택을 띄워 찍음 | 운영 자원과 분리, 찍는 화면과 홍보 대상 릴리스가 같은 커밋, 수동 실행 가능 | 실행마다 스택 기동 비용 | 채택 |
| 서비스 소개(월·수·금)도 매번 스택 기동 | 항상 최신 화면 | 화면은 릴리스로만 바뀌는데 매번 같은 비용 | 버림 — 마지막 릴리스 캡처(promotion-captures 아티팩트, 90일)를 재사용하고 없을 때만 스택을 띄움 |
| 발행도 에이전트가 판단 | 유연함 | 판단할 것이 "승인된 글 중 무엇부터"뿐인데 API 키와 실패 지점이 늘어남 | 버림 — 규칙(publish-next, 6시간 간격)으로 대체 |
production 환경(배포 브랜치 main 전용) 시크릿으로만 두고, 체크포인트·재개·LangGraph 루프는 걷어냈다.PENDING_REVIEW 제출까지 확인했다..kiro/specs/promotion-review/spec.md D10, D16, D20, D21 (PR 본문 "관련 스펙")