김수민 포트폴리오

Projects

똑똑 — 상명대학교 동아리 리크루팅 서비스

2025.05 ~

엑셀로 흩어져 관리되던 동아리 홍보·지원·부원 선발을 하나로 통합한 서비스(FE 2·BE 2·디자이너 1). 관리자 API, DB 설계, CI/CD와 운영 인프라 담당

Java 17Spring Boot 3.5JPAQueryDSLPostgreSQLRedisFlywayDocker ComposeNginxk6PrometheusGrafana
90.14% → 0%조회수 동시성 유실률
1.93배처리량 (311.7 → 601.4 RPS)
12,995 / 0무중단 전환 구간 요청 / 비200 응답

GoLe — 브릭 중고거래 플랫폼

2026.08 ~

레고 시세·직거래·컬렉션 플랫폼(gole.co.kr). 홍보 자동화 LLM 에이전트와 백엔드 기능 개발

Java 21Spring Boot 4MongoDBRedisPythonLLM AgentGitHub ActionsNext.js
85초실서버 SSH 리허설, 화면 선택·다듬기·검토 요청까지 끝에서 끝까지
포트 0개 · 상주 프로세스 0개이미 쓰던 SSH만으로 호출
9장 → 3장로컬 e2e 스택에서 캡처 9장 중 3장 선택·캡션·다듬기 후 검토 요청까지
← 전체 프로젝트
똑똑 · 2026.07 · PR #347

동아리 조회수 동시성 유실 해결과 처리 방식별 트레이드오프 실측

동시 요청에서 조회수 증가분의 90.14%가 사라지던 문제를, 6가지 처리 방식을 같은 조건으로 실측 비교해 해결했다. 고른 방식은 유실 0%에 처리량까지 1.93배가 됐고, 같은 원자적 UPDATE라도 문장 위치만으로 1.84배 차이가 나는 원인을 PostgreSQL 락 보유 기간에서 찾았다.

90.14% → 0%조회수 동시성 유실률
1.93배처리량 (311.7 → 601.4 RPS)
1.84배같은 UPDATE를 트랜잭션 끝으로 옮긴 것만으로 생긴 처리량 차이

문제

동아리 상세 조회 시 조회수 증가가 read-modify-write(findById → viewCount += 1)라, 동시 요청에서 증가분이 유실됐다. 100 VU 부하에서 유실률이 90.14%였다.

원인

구조

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, 높을수록 좋음) · 100 VU / 5,000 요청, 워밍업 2,000 후, 로컬
현행 read-modify-write 유실 90.14%
311.7
원자적 UPDATE · 증가 먼저
327.3
원자적 UPDATE · 증가 마지막
601.4
원자적 UPDATE · 트랜잭션 미사용
319.4
비관락 FOR UPDATE
245.9
낙관락 @Version 실패 67.36%
150.8
변형 유실 실패율 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)처럼 판단이 필요한 곳에 맞다고 정리

결과

근거

← 전체 프로젝트
똑똑 · 2026.08 · PR #378

AWS Elastic Beanstalk → 자체 서버 이관과 블루-그린 무중단 배포 컷오버

EB·RDS·ElastiCache·S3로 나뉜 운영 환경을 자체 서버 한 대의 docker-compose 스택으로 옮기고 블루-그린 배포를 구축했다. 전환 구간 12,995 요청 중 비200 0건. 검증 중 "배포는 성공인데 전 요청 502"가 나던 결함의 원인을 inode 수준까지 추적해 막았다.

12,995 / 0무중단 전환 구간 요청 / 비200 응답
151 / 151MinIO 오브젝트 HTTPS 200
AWS 의존 0EB·RDS·S3·SES 전부 제거

문제

운영 환경이 EB(앱) + RDS(PostgreSQL) + ElastiCache(Redis) + S3/CloudFront(파일)로 나뉘어 있었고, 이를 자체 우분투 서버 한 대의 docker-compose 스택으로 통합해야 했다. main의 배포 워크플로우는 EB 기준(runs-on: ubuntu-latest, beanstalk-deploy)이었다.

이관 검증 중 "배포는 성공인데 서비스가 죽는" 결함을 찾아냈다 (#376). 블루-그린 전환 구간은 정상이었지만 구 색 컨테이너를 정리하는 순간부터 1180건 연속 502가 났고, 파이프라인은 성공으로 끝나 결함이 드러나지 않았다.

원인

검증 중 발견한 결함 3건의 원인 (이슈 원문 기준):

구조

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).

근거

← 전체 프로젝트
GoLe · 2026.10 · PR #166

API 키 대신 CLI 구독으로: SSH 한 줄로 부르는 LLM 게이트웨이와 샌드박스 트러블슈팅

홍보 에이전트의 모델 호출을 API 키 과금 대신 외부 서버에 로그인된 claude·codex CLI로 옮겼다. 포트를 열지 않고 GitHub 러너가 SSH로 표준 라이브러리 단독 파일을 실행해 stdin/stdout JSON으로 주고받는 게이트웨이를 만들었고, 공개 저장소 로그로 서버 정보가 새지 않게 응답을 좁혔다. 실서버 리허설에서만 codex 다듬기가 조용히 실패하는 문제를 서버 커널의 비특권 user namespace 차단으로 규명했고, 샌드박스를 끄지 않고 codex가 저장한 원래 자리에서 결과를 가져오게 고쳐 85초에 끝에서 끝까지 통과했다.

85초실서버 SSH 리허설, 화면 선택·다듬기·검토 요청까지 끝에서 끝까지
포트 0개 · 상주 프로세스 0개이미 쓰던 SSH만으로 호출
63건홍보 테스트 통과 (샌드박스 회귀 테스트 추가)

문제

원인

구조

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 · 2026.10 · PR #166

운영 VM에 묶여 있던 홍보 에이전트를 GitHub Actions 위로 재설계

릴리스 화면을 찍어 홍보 초안을 쓰는 LLM 에이전트가 운영 VM 타이머에서 6회 연속 멈춰 있었다. 직접 원인(비어 있는 자격증명 파일)을 찾았지만, 채워도 여유 메모리 900 MB 안팎에서 Chromium을 돌려야 하는 구조적 제약이 남아 실행 위치를 GitHub 러너로 옮겼다. 러너 안에 대상 릴리스의 데모 스택을 띄워 찍게 하고, 트리거가 실행 경로를 정하게 하고, AI 판단이 필요 없는 발행은 규칙으로 바꿨다.

9장 → 3장로컬 e2e 스택에서 캡처 9장 중 3장 선택·캡션·다듬기 후 검토 요청까지
900 MB 제약 제거Chromium(400 MB~1 GB)을 운영 VM 대신 GitHub 러너에서 실행

문제

원인

구조

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시간 간격)으로 대체

결과

근거