똑똑 — 상명대학교 동아리 리크루팅 서비스
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())가 있었지만 동아리 도메인에만 적용되지 않았다.측정 조건(로컬): 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가 났고 CI는 초록이었다.
이관을 결정한 동기(비용 등)는 원문에 없음.
검증 중 발견한 결함 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를 자동 할당(uid=100, gid=101)해, 호스트 설정 파일 소유권(디렉터리 0770 1001:1003, 파일 0640 1002:1003)과 맞지 않았다. Spring Boot 기본 탐색 경로가 optional:file:./config/라 읽기 실패를 예외 없이 건너뛰고, 한참 뒤 jwt.secret 플레이스홀더에서 터져 원인과 증상이 멀었다.systemd-resolved 업스트림 2개 중 하나가 SERVFAIL이라, 캐시 만료 후 고장난 쪽을 고르면 curl exit 6(Could not resolve host)으로 실패했다. 서비스는 정상(200)이었다.정량 비교는 원문에 없음. 결정이 여러 개라 결정별로 정리했다 (이슈 #354·#372·#374·#376 원문 기준):
| 결정 | 택한 방법 | 버린 방법과 이유 |
|---|---|---|
| 파일 스토리지 | MinIO 자체 호스팅 + 기존 공개 도메인 유지 | DB URL 치환 마이그레이션. URL이 단순 컬럼뿐 아니라 마크다운 본문·JSON·jsonb 안에도 있어 부분 치환 위험이 큼 |
| 메일 | Postfix 릴레이 컨테이너 경유 → 기존 외부 릴레이 | 서버 직접 발송. 공인 IP에 PTR이 없고(NXDOMAIN) Spamhaus PBL 등재라 거절/스팸 처리됨 |
| 포트 | nginx 80/443만 공개, 인프라는 127.0.0.1 바인딩 |
UFW로 차단. Docker가 삽입한 iptables 규칙보다 늦게 평가돼 published 포트를 막지 못함 |
| DB 이관 | 기존 덤프 복원 | Flyway로 신규 생성. 첫 스크립트가 ALTER TABLE로 시작해 빈 DB에서 불가 |
| #372 권한 | Dockerfile에서 UID/GID를 호스트와 같게 고정(1001:1003) | 파일 권한 완화(0644). JWT 시크릿·Firebase 키라 권한을 넓히지 않음 |
| #374 DNS | 공개 리졸버(1.1.1.1 / 8.8.8.8 / 9.9.9.9)에 DoH로 조회 후 curl --resolve |
dig 사용. 의존성을 늘리지 않으려고 이미 쓰는 curl로 처리 |
| #376 방어 | setup.sh는 없을 때만 생성 + deploy.sh가 전환 직후 컨테이너 시야 확인, 어긋나면 구 색을 살린 채 배포만 실패 |
setup.sh만 수정. 편집기 저장·sed -i·파일 복사도 inode를 바꾸므로 배포 단계 확인이 필요 |
측정 조건: 컷오버 전, 운영용 자체 서버에서 workflow_dispatch로 develop 기준 배포를 반복 실행해 확인 (PR #378). 요청 생성 방식·속도는 PR #378 원문에 없음 (이슈 #354 인수 조건은 "프록시에 지속 요청을 걸어둔 상태로 배포 실행 → 비200 응답 0"). 실사용자 트래픽 여부는 원문에 없음.
green → blue, 전환 전·중·후 및 구 색 드레인/제거 구간 전체, 18:33:00 ~ 18:40:00): 총 요청 12,995건 전부 200, 비200 0건Verify return code: 0, HTTP/2, HSTS. 인증서 갱신 certbot 12시간 주기 + reload 04:30 + 만료 감시 05:00 (메일 경고)status=sent, 오픈릴레이 차단 확인push: main 트리거 경로는 머지 시점에 미검증이었고, 머지 후 첫 배포 결과는 원문에 없음.