공공 도시재생 정보시스템을 12개월간 유지보수하며, 약 3,000만 건 규모의 연간 데이터 갱신을
다시 실행해도 같은 결과가 나오도록 재설계했습니다. 이후 개인·팀 프로젝트에서 트랜잭션과 잠금,
멱등 적재, 외부 API 보호, 백업과 롤백까지 직접 설계하고 운영했습니다.
라인업, 경기 통계, 타임라인은 경기 상세 백필로 채운 데이터입니다. 진행 중인 경기를 15초 주기로 갱신하는 폴러를 만들었고, 이전 상태로 되돌아가는 쓰기는 조건부 UPDATE로 막습니다.
/matches
19개 대회의 일정과 결과를 대회와 경기 상태별로 봅니다. 외부 API 호출은 모두 한 클래스를 거치고, 분당 450콜 이하로 제한됩니다.
/standings?competition=premier-league
대회와 시즌별 순위표입니다. "대회당 현재 시즌은 하나" 같은 규칙은 부분 unique 인덱스로 DB가 지킵니다.
/stats?competition=premier-league
대회별 득점, 도움 순위입니다. 오른쪽 아래 "질문하기"는 AI 어시스턴트로, 숫자를 직접 만들지 않고 서버의 결정적 도구만 호출합니다.
2026년 10월 13일 실제 서비스에서 캡처했습니다. 화면을 누르면 크게 볼 수 있습니다.
규모
13,724경기
2,110팀
30,925선수 시즌 통계
480백엔드 테스트
136PR
데이터 수치는 2026.09.17 기준입니다.
첫 버전에서
2026 월드컵 기간에 Spring Boot와 Spring Batch로 첫 버전을 만들어 운영했습니다. 회고에서 검증 장치 부재, 0건 수집을 성공으로 처리한 '조용한 실패', 배치와 스케줄러의 상호 호출을 문제로 정리했고, 범위를 넓히면서 NestJS와 Prisma로 다시 설계했습니다. 전환 근거는 ADR 문서로 남겼습니다.
데이터 수집 경로: 스케줄러 잡이 수집 계층을 깨우면 API 클라이언트가 외부 API를 호출하고, 받은 데이터를 PostgreSQL에 배치 upsert합니다.
핵심 설계 결정
외부 API 보호
외부 호출을 한 클래스(ApiFootballClient)로 모으고 10초 제한 시간, 분당 450콜 자체 제한, 네트워크 오류와 429·5xx에만 1, 2, 4초 간격 재시도를 걸었습니다. 응답이 200이어도 오류 필드가 있으면 실패로 처리하고, 하루 한도에 닿으면 즉시 멈춰 '조용한 실패'를 막았습니다.
큐 없는 백필
약 4만 3천 콜 규모의 경기 상세 백필을 메시지 큐 대신 backfill_jobs 테이블의 커서와 단일 루프로 처리했습니다. 하루 자율 상한(5,700콜)과 중단 사유를 기록해 멈춘 커서부터 이어 가고, 라이브 수집은 백필이 끝난 뒤 켜서 같은 한도를 두고 다투지 않게 했습니다.
DB 설계
"대회당 현재 시즌은 하나" 같은 규칙은 부분 unique 인덱스로 DB가 지키게 했습니다. 대량 적재 속도를 위해 외래 키를 두지 않는 대신 참조 컬럼 인덱스 69개와 고아 행 검사(CI, 매일)로 무결성을 확인하고, 테스트는 운영과 같은 PostgreSQL에서 돌립니다.
해결한 문제
적재 성능
행 단위 upsert가 원격 DB 왕복에 막힌 문제
기초 데이터 적재 200초에서 71초, 경기 13,724건 무인 적재
문제
행마다 upsert하자 원격 DB 왕복 때문에 5초 트랜잭션 한도에 걸렸습니다. 20개 팀을 저장하는 데만 약 4초가 걸려, 수십만 행 규모의 백필은 불가능했습니다.
원인
행마다 약 60ms의 네트워크 왕복이 생겼고, ORM의 upsert는 경우에 따라 조회와 저장 두 번의 쿼리로 나갔습니다.
해결
테이블당 한 문장의 INSERT … ON CONFLICT DO UPDATE로 묶는 batchUpsert를 만들고, 충돌 키 중복 제거, 명시적 타입 변환, 파라미터 65,535개 한도에 맞춘 분할, COALESCE로 기존 값 보호까지 처리했습니다.
DB 리전을 앱 서버와 같은 서울로 옮겼습니다.
결과
대회·시즌 하나당 쿼리가 3문장으로 줄었고, 기초 데이터 적재 시간이 200초에서 71초로 줄었습니다(리전 이전 효과 포함). 경기 13,724건과 상세 백필을 무인으로 끝냈습니다.
동시성
라이브 스코어 역행과 동시 저장 충돌
조건부 UPDATE로 역행 쓰기 차단, advisory lock으로 동시 저장 오류 제거
문제
15초 주기 라이브 폴러와 매일 도는 일정 수집 잡이 같은 경기 상태를 덮어써, 진행 중인 스코어가 이전 상태로 되돌아갈 수 있었습니다. 즐겨찾기를 동시에 저장하면 unique 충돌로 500 오류가 났습니다.
해결
상태 순위와 경과 시간을 WHERE 절에서 비교하는 조건부 UPDATE 한 문장으로 바꿔, 읽고 쓰는 사이의 경합 없이 역행하는 쓰기를 막았습니다. 쓸 때마다 data_version을 올립니다.
잡마다 matches 테이블에서 쓸 수 있는 컬럼을 나눠 서로 덮어쓰지 않게 했습니다.
즐겨찾기 저장은 트랜잭션 안에서 사용자 단위 pg_advisory_xact_lock으로 직렬화했습니다.
결과
막힌 쓰기는 /health에 차단 건수로 집계되고, 동시 저장 시나리오는 테스트로 고정했습니다.
배포
재시작 루프와 롤백본 유실
헬스체크를 통과한 버전만 롤백본으로 남기도록 배포 스크립트 수정
문제
필수 환경변수 검증을 추가해 배포했는데 운영 서버에 그 값이 없어 서비스가 재시작을 반복했습니다. 이어서 재배포하자 깨진 빌드가 롤백본을 덮어써 되돌릴 버전이 사라졌습니다.
해결
환경변수 템플릿에 필수 항목을 추가하고, 배포 스크립트가 헬스체크를 통과한 버전만 롤백본(dist.prev)으로 남기도록 바꿨습니다.
결과
실패한 배포가 마지막 정상 버전을 지울 수 없게 됐습니다.
운영
백업과 복원
매일 04:00(KST)에 pg_dump로 백업해 S3에 올리고, 최근 8개와 월별 12개를 보관합니다. 일회용 컨테이너에 복원한 뒤 원본과 행 수를 대조하는 리허설을 두 번 통과했습니다.
CI
빈 PostgreSQL 16에 마이그레이션을 적용하고, 부분 인덱스 확인, 타입 검사와 린트, 단위 테스트 289개와 E2E 테스트 191개, 고아 행 검사를 통과해야 병합됩니다. 작업 브랜치에 보호 규칙을 걸고 PR 136개로 병합했습니다.
AI 활용과 검증
Claude Code로 개발하되 pre-commit 훅(널 바이트, 인코딩, API 키 유출 차단), 브랜치 보호, CI 필수 검사로 결과물을 검증했습니다. 어시스턴트는 숫자를 만들지 않고 서버의 결정적 도구만 호출하며, 답에 기준 시각과 근거를 붙입니다.
다음에 할 일
라이브 쓰기 모드의 실제 경기 관측 검증, 외부 알림 연동, 배포 자동화가 남아 있습니다.
팀 프로젝트K-뉴딜 아카데미2026.09 – 2026.105인, 백엔드 기반 설계 담당NestJS 12, Prisma 7, PostgreSQL, Next.js 16, Jest
FantaSteel ERP
원료 입고부터 제선, 제강, 연주, 열연, 출하까지 일관제철 공정을 다루는 철강 제조 협업 ERP입니다. 팀의 백엔드 기반(스키마, 공통 모듈, 모듈별 구현 가이드)을 맡았고, 구매, MRP, 조직, 업무 알림 모듈을 직접 구현했습니다.
발주 8t 중 5t이 들어온 부분 입고 상태입니다. 입고를 확정하면 발주 행을 잠그고 미입고량을 다시 확인한 뒤 원료 LOT을 만듭니다.
/purchase-requisitions
등록, 부서장 승인, 발주로 이어지는 상태 흐름입니다. 승인과 반려는 현재 상태가 기대한 상태일 때만 바꾸는 조건부 UPDATE로 처리합니다.
/mrp
생산 계획의 히트 톤, 제강 수율, 원단위로 원료 소요량을 구하고, 원료 LOT 잔량과 입고 예정을 빼서 순소요량을 계산합니다.
/org-chart
부서와 사원 구조입니다. 승인 권한은 권한 코드가 아니라 요청자 부서의 부서장인지로 판단합니다.
로컬 개발 환경에서 시드 데이터로 실행해 캡처했습니다. 제가 맡은 부분은 이 화면들이 쓰는 구매, MRP, 조직, 알림 API입니다.
맡은 일
기반 설계
ERD를 Prisma 스키마(44개 모델)와 초기 마이그레이션으로 옮기고, CHECK 제약과 부분 unique 인덱스를 정의했습니다. 인증·권한 가드, 공통 예외 필터, 채번, 작업 로그 기록기를 만들었습니다.
구현 가이드
16개 모듈 중 15개의 구현 가이드를 썼습니다. 테이블, API, 업무 규칙, 동시성 조건, 필수 테스트를 정의해 팀원들이 같은 기준으로 구현하게 했습니다.
직접 구현
구매(요청, 승인, 발주, 입고), MRP, 조직, 업무·알림 4개 모듈의 API 31개와 서버 테스트 약 120개.
협업
팀 PR 216개 중 124개를 병합하고, 팀 공용 DB 반영을 맡았습니다.
설계 포인트
구매 데이터 모델
구매요청부터 발주, 입고, 원료 LOT, 작업 로그까지 이어지는 흐름을 테이블로 나눴습니다. 입고 한 건은 원료 LOT 하나를 만들고, 작업 로그는 LOT와 N:M으로 연결됩니다.
색칠한 테이블은 직접 구현한 구매·알림 모듈과 공통 작업 로그입니다.
초과 입고 방지
입고를 확정할 때 발주 품목이 아니라 상위 발주 행을 FOR UPDATE로 잠급니다. 발주 상태(부분 입고, 입고 완료)는 같은 발주의 모든 품목 입고 합계로 정해지기 때문입니다. 60t씩 동시에 두 번 입고하면 하나만 성공하고 원료 LOT도 하나만 생기는 것을 테스트로 확인했습니다.
요청 A60t 입고준비
요청 B60t 입고준비
발주 100t 중 미입고100t원료 LOT0개
하나의 트랜잭션
발주 행 잠금FOR UPDATE
미입고량 확인초과면 거부
입고 저장원료 LOT 생성
발주 상태 갱신부분, 완료
작업 로그알림 기록
같은 발주 품목에 60t 입고가 동시에 두 번 들어온 경우입니다.
상태 전이
승인, 반려, 재요청은 "현재 상태가 기대한 상태일 때만" 바꾸는 조건부 UPDATE로 처리해, 동시에 들어와도 한쪽만 반영됩니다. 여러 구매요청으로 발주를 만들 때는 id 순서로 잠가 교착을 피했습니다.
알림의 원자성
작업 로그와 알림을 본 거래와 같은 트랜잭션에서 기록해, 거래가 롤백되면 알림도 남지 않게 했습니다. 중복 알림은 부분 unique 인덱스로 막았습니다.
MRP 계산
생산 계획의 히트 톤, 제강 수율, 원단위로 원료 소요량을 구하고, 원료 LOT 잔량과 입고 예정을 빼 순소요량을 계산합니다. 같은 공급을 두 계획에서 중복으로 빼지 않도록 공급 사용 순서를 정했고, Decimal로 계산한 뒤 마지막에 한 번만 반올림합니다.
권한
승인은 권한 코드가 아니라 요청자 부서의 부서장인지로 판단하고, 본인 요청은 승인할 수 없게 했습니다. 권한은 요청마다 DB에서 다시 읽어 변경이 바로 반영됩니다.
해결한 문제
트랜잭션
트랜잭션 안에서 쿼리가 겹치는 문제
경고는 5건이었지만 실제 겹침은 789회, 구매 전 구간 0회로 고정
문제
테스트에서 pg 드라이버 경고 client is already executing a query가 나왔습니다. 다음 메이저 버전에서는 오류로 바뀌는 경고였습니다.
원인
Prisma 트랜잭션 안에서 include로 여러 관계를 한 번에 읽으면, 하나의 연결에 쿼리가 동시에 들어갔습니다. 쿼리 호출을 가로채 대기열을 세어 보니 경고는 5건이었지만 실제로 겹친 횟수는 789회였습니다.
해결
트랜잭션 안에서는 관계를 하나씩 읽고, 응답 모양을 만드는 조회는 커밋 뒤로 옮겼습니다.
결과
구매요청 등록부터 입고까지 전 구간에서 겹침 0회를 테스트로 고정했고, 같은 원인이 있는 다른 모듈의 위치를 PR에 정리해 팀에 공유했습니다.
팀 개발 환경
병합 전 변경이 공용 DB에 들어갈 위험
로컬 DB에서만 마이그레이션과 시드가 돌도록 막고, 공용 DB 반영은 명령 하나로
문제
개발 서버 실행 명령이 DATABASE_URL이 가리키는 DB에 마이그레이션과 시드를 자동으로 실행해, 병합 전 브랜치의 변경이 팀 공용 DB에 들어갈 위험이 있었습니다.
해결
대상 DB가 로컬인지 판정하는 스크립트로 마이그레이션과 시드를 감싸고, 공용 DB 반영은 develop 브랜치, 깨끗한 작업 트리, 원격과의 일치를 확인하는 명령 하나로만 하게 했습니다.
결과
원격 주소로 실행하면 막히는 것을 확인했고, 그 과정에서 로컬(PostgreSQL 18)과 공용 DB(17)의 버전 차이를 발견해 리뷰 체크리스트에 넣었습니다.
팀 프로젝트코리아IT아카데미2024.04 – 2024.053인, 팀장Java 11, Spring Boot 2.6, MyBatis, MySQL, React 18, Docker, Nginx, GitHub Actions, AWS EC2와 S3
과외나무 LessonTree
학생과 선생님을 이어 주는 과외 매칭 웹 서비스입니다. 팀장으로 DB를 설계하고 이슈와 PR 흐름을 운영했으며, 배포 파이프라인을 직접 만들었습니다.
DB 설계, 학생 공고와 선생님 프로필 조회·검색, 과외 신청과 프로필 메일, 관리자·신고·계정 비활성화, CI/CD
협업
이슈, 브랜치, PR, 팀장 병합 순서로 작업 흐름을 운영했습니다. 이슈 187개, PR 172개 중 65개를 작성하고 152개를 병합했습니다.
무중단 배포
main에 반영되면 GitHub Actions가 빌드한 이미지를 운영 중이 아닌 쪽 포트에 띄우고, 헬스체크를 통과하면 Nginx 대상을 바꿉니다. 적용 하루 전에 별도 저장소에서 같은 구조를 먼저 검증했습니다.
GitHub Actions에서 빌드
시크릿 주입설정 파일 생성
Maven 빌드JDK 11
Docker 이미지Docker Hub에 push
AWS EC2에 배포
현재 색 확인/server/env
반대 색 기동8080 또는 9090
헬스체크10초 간격, 최대 3회
Nginx 전환설정 갱신, reload
이전 컨테이너중지, 삭제
blue(8080)가 요청을 받는 중에 main에 새 버전이 반영된 경우입니다.
다중 조건 검색
지역, 과목, 요일, 학생 유형 조건을 MyBatis 동적 SQL로 조합해 공고 ID만 먼저 뽑고, 그 ID로 과목·요일 같은 1:N 상세를 한 번에 조회했습니다. 조건 필터가 상세 목록을 잘라내지 않고, 비활성 회원의 공고는 빠지게 했습니다.
리팩토링
학생과 선생님 기능이 몰려 커진 AccountService를 StudentService와 TeacherService로 나눴습니다(14개 파일, 506줄 삭제, 265줄 추가).
메일
처음에는 메일 본문에 HTML 문자열을 직접 이어 붙였는데, 코드가 길어지고 데이터가 제대로 전달되지 않아 Thymeleaf 템플릿 4종(인증, 결과, 프로필, 과외 신청)으로 바꿨습니다.
돌아보면
신고와 계정 비활성화를 기획 단계에서 빠뜨려 개발 중에 테이블과 컬럼을 추가하고 계정 기능을 고쳐야 했습니다. 그 뒤로는 기능 목록을 먼저 확정하고 DB를 설계합니다. 배포 워크플로는 PR 이벤트에서도 배포 단계가 돌도록 되어 있었는데, 지금이라면 push 이벤트로만 제한하겠습니다.
경력과 학습
2026.09 – 2026.10
FantaSteel ERPK-뉴딜 아카데미 팀 프로젝트(5인). 백엔드 기반 설계, 구매·MRP·조직·알림 모듈
2026.08 – 2026.10
PitchLog League개인 프로젝트. 유럽 19개 대회 축구 데이터 서비스 설계부터 운영까지
2026.08 – 현재
K-뉴딜 아카데미수강 중
2026 – 현재
AI-Byte-Club 스터디『주니어 백엔드 개발자가 반드시 알아야 할 실무 지식』 챕터별 토론. DB 인덱스, 동시성, 인증과 암호화
2024.09 – 2025.08
도시재생 종합정보체계 유지관리 사업프리랜서(주임), 응용SW개발, 12개월
2024.12 – 2025.05
백준 알고리즘 스터디같은 문제를 Java와 JavaScript로 풀며 주 단위 마감 유지
2024.05 – 2024.08
Table MaidQR 비대면 주문·결제 팀 프로젝트(4인). 계정 찾기, 임시 비밀번호 메일, 서버 헬스체크 API
2024.04 – 2024.05
과외나무 LessonTree팀 프로젝트(3인) 팀장
2023.12 – 2024.05
코리아IT아카데미빅데이터·AI 기반 시니어 디지털 헬스케어 웰니스 플랫폼 개발 과정. Java, Spring Boot, React, MySQL, AWS