MSG CTF (1st, 2nd)
여러 대학이 함께 여는 CTF 대회 플랫폼입니다. 1차에서는 인증, 2차에서는 정답 제출 동시성과 운영 기능을 맡았습니다.
내 역할
1차: 회원가입, 로그인, 토큰 재발급, 이메일 인증 등 User API와 인증 흐름. 2차: 정답 제출 동시성, 동적 점수, 퍼스트 블러드, 관리자 검증, 팀과 유저 삭제 정합성.
결과
2차에서 락 안의 작업을 줄이고 후처리를 분리해, 동시 제출 50건 테스트의 성공률을 50%에서 100%로 높였습니다.
담당 역할
1차(개발 2024.12 - 2025.03, 대회 2025.04) 백엔드 팀에서 User API를 중심으로 회원가입, 로그인, 로그아웃, 토큰 재발급, 이메일 인증, 프로필 조회 흐름을 구현했습니다. 초기 인증 구조를 잡는 단계라 Spring Security 필터 체인, JWT Access/Refresh Token 분리, Refresh Token 저장과 삭제, Access Token 블랙리스트까지 직접 다뤘습니다. 문제 목록과 상세 조회 API 일부도 구현했습니다. 2차(개발 2025.07 - 2025.11, 대회 2025.11) 정답 제출과 채점, 동시 제출 정합성, 동적 점수, 팀 점수와 마일리지, 퍼스트 블러드, 관리자 검증, 팀과 유저 삭제 시 데이터 정합성을 맡았습니다. 1차 때 팀원이 넣은 문제 단위 Redisson 잠금 위에, 비관적 락과 실시간 solvers 계산을 더하고 후처리를 비동기로 분리해 동시 제출과 504 timeout 문제를 풀었습니다.

Tech Stack
아키텍처 / ERD
1차는 인증 흐름이 중심입니다. 로그인에 성공하면 Access Token은 Authorization 헤더로, Refresh Token은 HttpOnly Cookie로 전달하고, JWT 필터가 만료 여부, tokenType, 블랙리스트 여부를 검증합니다. 이메일 인증 코드는 Redis에 5분 TTL로 저장합니다. 2차의 핵심 제출 흐름은 User → Challenge → History/TeamHistory → Challenge Score → Team Score로 이어집니다. 정답 제출 시 개인과 팀 중복 제출을 먼저 확인하고, 문제 단위 잠금 안에서 Challenge를 비관적 락으로 다시 읽어 solvers와 점수를 갱신한 뒤, 커밋 이후 팀 점수 재계산과 퍼스트 블러드 알림 같은 후처리를 분리했습니다.
1차 시스템 아키텍처
1차 README에 정리한 운영 구조입니다. 백엔드, 프론트엔드, DB, Redis, NGINX, Discord Bot이 함께 동작합니다.

2차 시스템 아키텍처
2차 대회 플랫폼의 팀 전체 구조입니다. 정답 제출과 채점, 순위와 마일리지, 디스코드 알림이 나뉘어 있습니다.

ERD
유저, 팀, 문제, 제출 기록, 점수, 토큰 데이터의 관계입니다.

주요 기능
- ✓[2차] 동시 플래그 제출 상황에서도 정답 기록, solvers, 동적 점수가 어긋나지 않도록, 1차 때 팀원이 넣은 문제 단위 Redisson 잠금 안에 DB 비관적 락과 실시간 solvers 계산을 더하고 후처리를 비동기로 분리했습니다.
- ✓[2차] 문제를 푼 팀에게 점수와 마일리지를 반영하고, 퍼스트 블러드 팀에는 보너스 마일리지를 지급하는 흐름을 구현했습니다.
- ✓[2차] 관리자가 문제를 검수할 때는 정답/오답만 확인하고 히스토리와 랭킹에는 반영되지 않도록 제출 흐름을 분리했습니다.
- ✓[2차] 팀 삭제 시 제출 기록, solvers, 다이나믹 스코어, 퍼스트 블러드, 팀원 소속, 결제 히스토리를 함께 정리하도록 운영 기능을 구현했습니다.
- ✓[2차] 유저 삭제 시 토큰/리더보드/제출 데이터는 제거하고, 문제 해결 히스토리는 익명화해 대회 기록 통계를 보존하도록 처리했습니다.
- ✓[1차] 회원가입 시 ID, 이메일, 비밀번호 형식과 중복 여부를 검증하고, 학교 이메일 도메인만 가입할 수 있도록 제한했습니다. 일반 회원가입 요청에서는 role을 항상 ROLE_USER로 고정해 사용자가 요청 본문으로 관리자 권한을 지정하지 못하도록 했습니다.
- ✓[1차] Gmail SMTP로 6자리 이메일 인증 코드를 전송하고, Redis에 인증 코드와 인증 완료 상태를 TTL 기반으로 저장했습니다. 인증 시도 횟수를 제한해 잘못된 코드 반복 입력을 방지했습니다.
- ✓[1차] 로그인 성공 시 Access Token과 Refresh Token을 분리 발급했습니다. Access Token은 Authorization 헤더로, Refresh Token은 HttpOnly Cookie와 DB에 저장해 토큰 재발급과 로그아웃 흐름을 구성했습니다.
- ✓[1차] 로그아웃 시 Refresh Token을 삭제하고 Access Token을 블랙리스트에 저장해 같은 Access Token으로 다시 인증되는 문제를 막았습니다. 만료된 블랙리스트 토큰은 스케줄러로 주기적으로 정리하도록 구현했습니다.
- ✓[1차] 문제 전체 조회와 특정 문제 상세 조회 API를 구현하고, 문제 상세 응답에 solvers 필드를 추가했습니다. 프론트엔드가 문제별 풀이 인원 정보를 함께 표시할 수 있도록 응답 데이터를 보강했습니다.
트러블슈팅
- [2차] 동시 제출 시 Redisson 락 타임아웃 문제문제 상황50개의 플래그 제출 요청이 동시에 들어오면 Redisson 분산 락의 waitTime 10초 안에 모든 요청이 락을 획득하지 못했다기존 제출 흐름은 락 내부에서 히스토리 저장, 퍼스트 블러드 판정, 팀 점수 계산, 다이나믹 스코어링, 전체 팀 재계산까지 순차 처리했다요청당 약 320~370ms가 소요되면서 10초 안에 약 25개 요청만 정상 처리되고, 나머지는 Try again later 또는 504 timeout으로 실패할 수 있었다해결 방안락 없이 가능한 검증과 오답 처리는 먼저 수행하고, 락 내부에서는 중복 재검증, History/TeamHistory 저장, DB 비관적 락 기반 Challenge 재조회, solvers/points 갱신만 처리하도록 범위를 줄였다결과PR 동시성 테스트 기준 성공률을 50%(25/50)에서 100%(50/50)로 개선했고, 평균 응답 시간은 약 5초에서 약 52ms 수준으로 줄였다
- [2차] 동적 점수 정합성 문제문제 상황동시에 여러 팀이 같은 문제를 풀면 solvers 증가 순서와 팀 점수 재계산 시점이 어긋나 팀마다 다른 점수가 반영될 수 있었다비동기 처리만으로 후처리를 분리했을 때도 여러 스레드가 같은 Challenge를 읽고 수정하면서 Lost Update가 발생할 수 있었다해결 방안락 획득 후 영속성 컨텍스트를 정리하고, Challenge를 비관적 락으로 다시 조회해 최신 solvers 기준으로 points를 계산했다이후 영향받은 팀 점수를 재계산하도록 제출 흐름을 정리했다결과여러 팀이 같은 문제를 동시에 풀어도 solvers와 팀 점수가 어긋나지 않고, Lost Update 경로가 사라졌다
- [2차] 팀 삭제 후 대회 데이터 정합성 문제문제 상황팀을 삭제하면 해당 팀이 푼 문제의 solvers, 다이나믹 스코어, 퍼스트 블러드, 팀원 소속 정보가 함께 바뀌어야 했다제출 기록만 삭제하면 랭킹, 문제 점수, 퍼스트 블러드가 실제 데이터와 어긋날 수 있었다해결 방안팀 히스토리와 개인 히스토리를 정리하고, 영향받은 문제들의 점수와 퍼스트 블러드를 다시 계산했다이후 전체 팀 점수를 재계산했다결과팀을 삭제해도 랭킹, 문제 점수, 퍼스트 블러드가 실제 데이터와 어긋나지 않는다
- [2차] 유저 삭제 트랜잭션 롤백 문제문제 상황유저 삭제 중 히스토리를 먼저 익명화한 뒤 loginId 기준으로 다시 삭제하려 하면서 삭제 대상 조회가 불가능해지고 트랜잭션이 롤백되는 문제가 있었다모든 데이터를 단순 삭제하면 대회 문제 풀이 통계까지 함께 사라지는 문제도 있었다해결 방안삭제 대상과 보존 대상의 의존성을 분리했다토큰, 제출 기록, 리더보드처럼 유저에 강하게 묶인 데이터는 삭제하고, History는 삭제 대신 익명화했다결과삭제 요청이 롤백 없이 끝나고, 유저를 지워도 대회 풀이 통계는 남는다
- [1차] 회원가입 권한 상승 가능성문제 상황초기 회원가입 요청에서 role 값을 사용자가 직접 전달할 수 있으면, 일반 사용자가 요청 본문에 admin 권한을 넣어 가입을 시도할 수 있었다해결 방안일반 회원가입에서는 요청 role을 신뢰하지 않고 ROLE_USER로 고정했다관리자 계정 생성은 별도 관리자 API 흐름으로 분리해 공개 회원가입과 권한 부여 책임을 나눴다결과외부 사용자가 회원가입 API만으로 관리자 권한을 얻을 수 있는 경로를 차단했다
- [1차] 이메일 인증 코드 만료와 반복 시도 문제문제 상황학교 이메일 인증 없이 회원가입이 가능하거나, 인증 코드를 무제한으로 시도할 수 있으면 대회 참가자 검증이 약해질 수 있었다해결 방안인증 코드는 Redis에 5분 TTL로 저장하고, 인증 성공 상태는 30분 TTL로 저장했다잘못된 인증 시도는 email 기준으로 카운트하고 5회를 넘으면 인증 코드와 시도 횟수 키를 삭제하도록 처리했다결과학교 이메일 인증을 회원가입의 필수 단계로 만들고, 만료/오입력/반복 시도 상황을 명확히 제어했다
- [1차] 로그아웃 후 Access Token 재사용 문제문제 상황초기 로그아웃 흐름은 Refresh Token 삭제 중심이어서, 이미 발급된 Access Token이 만료 전까지 남아 있으면 같은 토큰으로 인증 요청이 가능할 수 있었다해결 방안로그아웃 시 Access Token을 블랙리스트 테이블에 저장하고, JwtFilter에서 블랙리스트 토큰이면 요청을 거부하도록 수정했다토큰 만료 시간이 지난 블랙리스트 데이터는 30분 주기 스케줄러로 삭제했다결과로그아웃된 Access Token의 재사용을 서버 측에서 차단하고, 블랙리스트 데이터가 무기한 쌓이지 않도록 관리했다
성능 / 안정성
- [2차] 응답 시간 개선 기존 제출 흐름은 락 안에서 여러 후처리를 수행해 동시 요청이 몰릴 때 평균 응답 시간이 약 5초까지 늘어났습니다. 락 범위를 줄이고 후처리를 분리한 뒤, PR 동시성 테스트에서 평균 응답 시간 약 52ms, P95 약 85ms, P99 약 100ms 수준을 확인했습니다.
- [2차] 비관적 락 기반 정합성 유지 분산 락은 애플리케이션 레벨의 제출 순서를 제어하지만, 락 밖에서 조회한 엔티티가 최신 상태라는 보장까지 제공하지는 않습니다. 락 획득 후 flush/clear로 영속성 컨텍스트를 정리하고, PESSIMISTIC_WRITE 기반 조회로 Challenge 행을 다시 잠근 뒤 solvers와 동적 점수를 갱신했습니다.
- [2차] 커밋 이후 재계산 예약 점수 재계산이 제출 트랜잭션과 강하게 묶이면 응답 지연과 중복 재계산이 발생할 수 있었습니다. 트랜잭션 커밋 이후 필요한 재계산을 예약하는 방식으로 제출 응답과 후처리의 결합도를 낮췄습니다.
- [2차] 동시성 테스트 기반 검증 Python 테스트 스크립트로 50명의 테스트 유저와 50개 팀을 생성하고, 같은 문제에 대한 동시 제출 상황을 재현했습니다. 개선 전에는 락 타임아웃으로 약 25개 요청만 정상 처리되던 흐름을, 개선 후 50개 요청 모두 실패 없이 처리되는지 검증했습니다.
- [1차] 이메일 인증 상태와 인증 코드를 Redis TTL로 관리해 DB에 일회성 인증 데이터를 저장하지 않도록 했습니다.
- [1차] 만료된 블랙리스트 토큰을 스케줄러로 정리해 로그아웃 토큰 저장소가 계속 커지지 않도록 했습니다.
- [1차] Access/Refresh Token을 분리해 짧은 수명의 Access Token과 재발급 가능한 Refresh Token 구조를 만들었습니다.
보안 고려 사항
- [2차] 관리자 제출 분리 관리자는 문제 검수를 위해 플래그를 제출할 수 있지만, 대회 히스토리와 점수에는 반영되면 안 됩니다. 관리자 요청은 정답/오답 검증만 수행하고 History, solvers, team score에는 영향을 주지 않도록 분리했습니다.
- [2차] 오답 시도 추적 오답 제출은 단순 실패 응답으로 끝내지 않고 IP/유저/문제 기준으로 기록해 브루트포스 탐지와 차단 정책에 활용할 수 있도록 했습니다.
- [2차] 인증/인가 기반 운영 API Spring Security와 JWT 기반 인증을 사용하고, 관리자 검증/팀 삭제/유저 삭제 같은 운영 API는 권한이 있는 사용자만 접근하도록 구성했습니다.
- [1차] 비밀번호는 PasswordEncoder로 해싱해 저장하고, 로그인 시 평문 비교 대신 matches로 검증했습니다.
- [1차] Refresh Token은 HttpOnly Cookie로 전달하고 DB에 저장해 재발급과 로그아웃 시 서버 측 검증이 가능하도록 했습니다.
- [1차] 학교 이메일 도메인을 제한하고 SMTP 인증 코드를 요구해 대회 참가자 범위를 제어했습니다.
- [1차] CORS는 로컬 프론트엔드와 운영 도메인을 기준으로 허용 Origin을 제한했습니다.
아쉬웠던 점
초기에는 정답 제출 후 필요한 처리를 한 흐름 안에서 모두 끝내는 방식이 이해하기 쉬웠지만, 실제 대회 운영에서는 동시 제출과 점수 재계산이 겹치면서 락 대기, 응답 지연, 정합성 문제가 드러났습니다. 이후 제출 경로를 검증, 기록, 점수 갱신, 재계산, 알림으로 나누면서 CTF 플랫폼은 단순 채점 기능보다 동시성 제어와 운영 데이터 정합성이 더 중요하다는 점을 배웠습니다. 처음 인증 구조를 만들면서 Access Token과 Refresh Token의 역할, 로그아웃 처리, 토큰 블랙리스트의 필요성을 직접 겪었습니다. 초기에는 Refresh Token 삭제만으로 로그아웃을 처리하려 했지만, 리뷰 과정에서 Access Token 재사용 문제가 드러났고 서버 측 블랙리스트 검증을 추가했습니다. 다만 `/api/users/**` 전체 permitAll처럼 개발 편의를 위해 넓게 열어둔 인가 설정은 운영 관점에서 더 세밀하게 분리했어야 합니다. 이 경험 이후 인증 기능은 단순히 로그인 성공 여부가 아니라 토큰 생명주기, 권한 경계, 로그아웃 이후 상태까지 함께 설계해야 한다는 점을 배웠습니다.
프로젝트 유형
팀 프로젝트
기간
2024.12 - 2025.03, 2025.07 - 2025.11
완료
2025.11
사용 기술
11 Technologies