축구 영상 분석 서비스 Fine Play를 백엔드·인프라부터 직접 만들어 운영하고 있습니다. 여기 담은 케이스는 모두 그 과정에서 실제 트래픽으로 마주한 것들입니다. 커넥션 풀 고갈, N+1, 트랜잭션 전파, 캐시를 원인부터 파고들어 해결했고, 각 케이스에 문제 → 원인 → 해결 → 결과를 담았습니다.
Fine Play(축구 분석)와 Fine Day(선수 매칭)를 운영하는데, 인증과 인프라, 배포를 서비스마다 따로 만들 여력이 없었습니다.
중복 인증과 배포를 없애 소수 팀이 두 서비스를 함께 굴릴 수 있게 됐고, iOS·Android·Web으로 출시했습니다(Apple 심사 반복 돌파).
무엇을 합치고 무엇을 나눌지 초기에 정해두니, 이후 기능이 늘어도 운영 복잡도가 크게 오르지 않았습니다.

운영 중 Too many connections로 앱이 못 뜨고, Docker가 자동 재시작하며 커넥션이 더 쌓이는 악순환으로 전 인스턴스가 내려갔습니다. 대회로 트래픽이 몰리던 시점이었습니다.
# application.yml — 클러스터 전체 합을 DB 한계 아래로 고정 spring: datasource: hikari: maximum-pool-size: 10 # 4 인스턴스 × 10 = 40 ≤ 151 max-lifetime: 300000 # 커넥션 수명 제한 → 잔존이 오래 남지 않게 connection-timeout: 3000 keepalive-time: 120000
토큰이 탈취·재사용돼도 피해를 막고 싶었고, 동시에 Redis가 멈춰도 인증과 서비스가 함께 죽지 않게 하고 싶었습니다.
setIfAbsent 분산락으로 하나만 통과시켰습니다.재사용 공격에 대응하는 인증 구조를 갖췄고, Redis가 죽어도 서비스가 멈추지 않게 만들었습니다.
정상 동작만이 아니라, 장애가 났을 때 무엇을 열고 무엇을 닫을지(페일오픈/클로즈)를 함께 정해야 한다는 걸 배웠습니다.
// 캐시 실패를 흡수 — 단일 장애점 제거 (fail-open) try { return redis.opsForValue().get(key); // 정상: 캐시 조회 } catch (RedisConnectionFailureException e) { log.warn("Redis down — degrade", e); return loadFromSource(); // 장애: 원본 폴백 }
유저 리스트가 길어질수록 화면 하나에 나가는 네트워크 요청·쿼리가 유저 수에 비례해 늘었습니다. 동작은 그대로 두고 비용만 줄여야 했습니다.
@ManyToOne을 지연 로딩으로 바꿔 N+1 쿼리 제거.// 1) 즉시 로딩(N+1) → 지연 로딩 @ManyToOne(fetch = FetchType.LAZY) private Team team; // 2) 목록은 필요한 컬럼만 DTO로 한 번에 (→1) @Query("select new UserCardDto(u.id, u.name, u.imageUrl) from User u") List<UserCardDto> findCards();
하나의 트랜잭션에 일을 너무 많이 묶으면, 뒤에서 난 예외가 앞의 성공까지 되돌립니다. 어긋나면 안 되는 데이터를 지켜야 했습니다.
REQUIRES_NEW로 분리해 독립 커밋되게 했습니다.롤백에 데이터가 휩쓸리는 경로가 사라졌고, 결제 성공 뒤 500이 재발하지 않았습니다.
트랜잭션은 크게 묶을수록 안전한 게 아니라, 같은 커밋에 둘 것과 분리할 것을 나누는 설계라는 걸 배웠습니다.
// 강등: 상위 롤백에 안 휩쓸리게 독립 커밋 @Transactional(propagation = REQUIRES_NEW) public void demote(Subscription s) { ... } // 알림·외부 호출: 커밋 성공 후에만 → 성공 후 500 방지 @TransactionalEventListener(phase = AFTER_COMMIT) public void onPaid(PaidEvent e) { notify(e); }