Skip to content

[DB] 트랜잭션 안에서 외부 I/O 를 기다리던 여덟 지점을 걷어낸다 [ #337 ] - #351

Merged
Danto7632 merged 9 commits into
developfrom
danto/perf-tx-boundary
Sep 11, 2026
Merged

[DB] 트랜잭션 안에서 외부 I/O 를 기다리던 여덟 지점을 걷어낸다 [ #337 ]#351
Danto7632 merged 9 commits into
developfrom
danto/perf-tx-boundary

Conversation

@Danto7632

Copy link
Copy Markdown
Member

요약

트랜잭션이 열린 채 외부 I/O(GitHub·Cloudflare·Docker·Thread.sleep)를 기다리던 여덟 지점을 걷어냈다(#337). 2026-09-08 dev 커넥션 풀 22분 고갈 사고와 같은 계열이다 — OSIV 를 끈 것(#313)은 SSE 한 경로를 막은 것이고, 이 구조 자체는 그대로 남아 있었다.

원칙: 외부 호출 먼저(tx 밖) → 결과만 짧은 tx 로 저장.

# 위치
A1 CodingAgentExecutionService @Transactional(readOnly) 안에서 Docker CLI 최대 10분 → 커넥션 1개 10분 점유 × 동시 CODE 4~8
A2 DeploymentQueryService FE 폴링 경로가 tx 안에서 GitHub Actions 호출(로그 다운로드 포함)
A3 ProjectQueryService 대시보드 개요가 tx 안에서 GitHub 2회 + 하위 목록 4개
A4 DomainBindingCommandService 5개 메서드 tx 안에서 Cloudflare + GitHub Pages + Thread.sleep 재시도
A5 ChangeService.record tx 안에서 Docker exec 2회(그중 apk add git)
A6 PreviewSessionService 5개 메서드 @Scheduled + @Transactional 한 덩어리에서 컨테이너 제거
A7 AuthCommandService 3개 로그인·App 연동 tx 안에서 GitHub OAuth/User API
A8 ProjectCommandService 저장소 연결·삭제 tx 안에서 GitHub API

A9(승인→PR 머지)는 건드리지 않았다 — 설계 D8 의 의도이고, #336 이 GitHub 호출에 60초 상한을 걸어 뒀다.

외부 실패 시 동작이 이전과 같다는 근거

A4·A5·A8·A2 는 원래부터 외부 호출이 유일한 save 보다 앞에 있었다. 롤백이 아니라 순서가 이미 보장하고 있었고, RestClient.retrieve() 는 4xx 에서 던지므로 저장 줄에 도달하지 못한다. 경로별 저장이 한 건씩이라 원자성도 줄지 않는다.

롤백에 실제로 기대던 두 곳은 명시적으로 되살렸다:

  1. AuthCommandService.linkGithubApp — 예전엔 installationId 를 먼저 반영하고 토큰 교환을 호출해, 교환 실패 시 롤백이 그 반영까지 되돌렸다. 외부 호출을 저장 앞으로 옮겨 같은 결과를 만들었다. 유저 조회는 외부 호출 앞에 남겨 "없는 유저면 code 소모 전에 404" 순서를 유지했다.
  2. PreviewSessionService.expire() — 제거 실패 시 롤백이 상태 변경을 되돌려 세션이 ACTIVE 로 남아 다음 기회에 다시 회수됐다. 제거를 저장 앞으로 옮겨 동일하게 만들었다.

실패 경로 테스트: A4 5건(Cloudflare 4xx / Pages bind 4xx + DNS 보상삭제 / custom 4xx / unbind 4xx / verify 4xx → save·deleteById 미호출), A5·A6 3건, A7 2건, A8 2건.

증거

  • 실측: CodingAgentConnectionHoldIntegrationTest — 가짜 CLI 실행 중 Hikari active 최솟값 0. 같은 테스트가 TransactionTemplate 으로 재현한 옛 구조에서는 ≥1. 실행 중 isActualTransactionActive() = false.
  • 구조: ExternalIoTransactionBoundaryTest — Spring 이 런타임에 실제로 쓰는 AnnotationTransactionAttributeSource 로 A1~A8 전 메서드를 검사. ChangeService.record@Transactional되돌려 붙여 실제로 깨지는 것을 확인했다. 외부 호출이 없는 쓰기 경로는 트랜잭션을 유지한다는 반대 방향도 함께 고정.

API 동작 변화

HTTP 상태·오류 코드 변화 없음. 단 배치 경로의 실패 반열림이 달라진다:

  • cleanupProjectS3Domains·releaseServerDomains·cleanupExpired·closeAllOwned — 건별 커밋이라 뒤쪽 한 건이 실패해도 앞서 성공한 것이 되돌아가지 않는다. 앞 둘은 주석이 이미 best-effort 라고 선언했는데 실제 동작이 어긋나 있던 것을 맞춘 것이다.
  • loginWithGithub 의 저장 2건(유저·리프레시 토큰)이 더는 한 트랜잭션이 아니다. githubId 기준 멱등이라 재시도가 복구한다.
  • A4 ensureHostnameAvailable→save 의 TOCTOU 창이 외부 호출 시간만큼 넓어진다. hostname UK 가 최종 방어라 결과는 같다.

deleteProject 는 로컬 정리(대화 삭제 + 프로젝트 삭제)가 갈라지면 대화만 사라진 프로젝트가 남으므로 트랜잭션이 필요하다 — 자기 호출로는 프록시가 안 걸려 ProjectDeletionService 로 분리했다(같은 패키지 RepositoryProvisioningService 와 같은 방식).

범위를 넘어 손댄 것 (같은 파일·같은 결함, 전부 1줄급)

createProject(템플릿 카탈로그가 RestClient HTTP), refreshGithubUserToken(자기 DB 작업 없이 REQUIRES_NEW 두 개를 감싸 커넥션을 하나 더 점유), releaseServerDomains.

검증

  • 기준선 1488 전체 / 1476 통과 → 1511 전체 / 1499 통과 / 실패 0 / 스킵 12
  • 3차 웨이브 5개 통합 후 1678 전체 / 1666 통과 / 실패 0

Closes #337

https://claude.ai/code/session_01APAyBZVYxUZzZsVyEXy6Qr

readOnly 트랜잭션 안에서 컨테이너의 CLI 가 끝날 때까지 최대 10분을 기다리고 있었다.
커넥션 하나를 그 시간 내내 점유하고, 동시 CODE 태스크 수만큼 곱해진다.

DB 작업은 자격증명 조회 한 건뿐이고 AiProviderCredential 은 JPA 엔티티가 아닌
도메인 모델이라, 리포지토리 자신의 짧은 트랜잭션으로 충분하다. 쓰기가 없어
롤백에 기대던 동작도 없다.

실측을 테스트로 남긴다 — 가짜 CLI 가 도는 동안 Hikari active 최솟값이 0 이고,
같은 테스트가 재현한 옛 구조(readOnly 트랜잭션 + DB 접촉)에서는 1 이상이다.

Claude-Session: https://claude.ai/code/session_01APAyBZVYxUZzZsVyEXy6Qr
FE 가 IN_PROGRESS 동안 주기적으로 폴링하는 경로가 readOnly 트랜잭션 안에서
GitHub Actions 를 1~2회 호출하고 있었다. getDeploymentLogs 는 job 로그
다운로드까지 트랜잭션 안이었다. 동시에 폴링하는 배포 수만큼 커넥션이 잠긴다.

DB 는 읽기 세 건뿐이고 전부 도메인 모델로 나오므로 각 리포지토리의 짧은
트랜잭션으로 충분하다. 쓰기가 없어 실패 시 동작도 그대로다.

Claude-Session: https://claude.ai/code/session_01APAyBZVYxUZzZsVyEXy6Qr
클래스 레벨 @transactional(readOnly) 아래에서 개요 조회가 GitHub 을 두 번
(최근 커밋·저장소 상태) 호출하고 하위 목록 4개를 같은 트랜잭션에 묶고 있었다.
listRepositories 는 최대 10페이지를 넘긴다.

클래스 레벨 애너테이션은 순수 DB 조회들을 위해 남기고, GitHub 을 타는 넷만
NOT_SUPPORTED 로 빼낸다. 모두 읽기라 롤백에 기대는 동작이 없다.

Claude-Session: https://claude.ai/code/session_01APAyBZVYxUZzZsVyEXy6Qr
Cloudflare · GitHub Pages · ACM 호출이, 그리고 Pages 어댑터의 재시도 대기
(Thread.sleep)까지 트랜잭션 안에 있었다. DomainVerificationWorker 가 배치
20건을 이 경로로 순차 처리하면서 그만큼 곱해진다.

주의해서 살린 것: 이 파일은 "외부 호출이 실패하면 저장도 안 된다" 를 롤백으로
얻고 있었다. 분리하면 그 성질이 자동으로 사라지므로, 세 바인딩 경로 모두
외부 호출을 유일한 save 보다 앞에 두는 순서로 같은 결과를 만든다 — 외부가
4xx 를 주면 그 자리에서 던지고 저장 줄에 도달하지 못한다. 경로별 저장이
한 건씩이라 원자성도 줄지 않는다. 그 순서를 테스트 다섯 건으로 고정했다.

cleanupProjectS3Domains·releaseServerDomains 는 건별 try/catch 가 이미 있었지만
배치 전체가 트랜잭션 하나라 뒤쪽 실패가 앞서 성공한 삭제를 되돌릴 수 있었다 —
best-effort 라는 주석과 실제 동작이 어긋나 있었고, 이제 건별로 커밋된다.

외부 호출이 없는 abandonVerification 은 그대로 둔다.

Claude-Session: https://claude.ai/code/session_01APAyBZVYxUZzZsVyEXy6Qr
record() 가 트랜잭션 안에서 Docker exec 을 두 번 돌았고, 그중 하나는
apk add git 이라 네트워크 설치까지 기다렸다.

diff 수집이 이미 저장보다 앞에 있으므로 트랜잭션만 걷어내면 된다 — 수집 중
예외가 나면 저장에 도달하지 않아 Change 행이 남지 않는 것은 그대로다.
(종료 코드가 0 이 아닌 경우는 빈 diff 로 저장하는 별개 경로이며 건드리지 않았다.)

Claude-Session: https://claude.ai/code/session_01APAyBZVYxUZzZsVyEXy6Qr
@scheduled + @transactional 한 덩어리에서 만료 세션마다 removeContainer 를
불렀고, markServing 은 포트 조회와 도달 확인(앱이 뜰 때까지 대기)을 트랜잭션
안에서 기다렸다.

실패 시 동작을 순서로 살린다. expire() 는 예전에 저장이 먼저였고 제거가
실패하면 롤백이 상태 변경을 되돌려 세션이 ACTIVE 로 남았다 — 이제 제거를
저장 앞에 두어 같은 결과를 만든다. markServing 도 같은 이유로 Docker 작업을
저장 앞으로 모았다(실패하면 PROVISIONING 유지).

cleanupExpired 는 건별 try/catch 를 더한다. 예전에는 배치 전체가 트랜잭션
하나라 한 건의 Docker 실패가 나머지 세션 정리까지 통째로 롤백시켰다 —
컨테이너 하나가 고장나면 만료 정리가 영영 진행되지 않는 구조였다.

Claude-Session: https://claude.ai/code/session_01APAyBZVYxUZzZsVyEXy6Qr
로그인이 트랜잭션 안에서 GitHub OAuth·User API 를 두 번 기다렸다. 가장 자주
열리는 경로라 그 점유가 그대로 풀 고갈로 이어졌다.

linkGithubApp 은 순서를 바꿨다. 예전에는 installationId 를 먼저 반영하고 그
뒤에 토큰 교환을 호출했는데, 교환이 실패하면 롤백이 그 반영까지 되돌려
아무것도 저장되지 않는 것이 실제 동작이었다. 롤백이 사라진 지금 같은 결과를
얻으려면 외부 호출을 저장 앞에 두는 수밖에 없다. 유저 조회는 외부 호출보다
앞에 남겨 뒀다 — 없는 유저면 code 를 소모하기 전에 404 가 나가던 순서를
그대로 지키기 위해서다(기존 테스트가 이미 그것을 고정하고 있다).

로그인의 저장 두 건(유저·리프레시 토큰)은 더는 한 트랜잭션이 아니다. githubId
로 찾아 없으면 만드는 멱등 연산이라 뒤가 실패해도 재시도가 그대로 복구한다.

refreshGithubUserToken 도 함께 뗀다 — 자기 DB 작업이 없고 안쪽 두 호출이 모두
REQUIRES_NEW 라, 바깥 트랜잭션은 GitHub 갱신을 기다리는 내내 아무 일도 하지
않으면서 커넥션을 하나 더 붙들고 있을 뿐이었다.

Claude-Session: https://claude.ai/code/session_01APAyBZVYxUZzZsVyEXy6Qr
connectRepository 는 GitHub 조회·생성과 preparePreviewBranch 까지 최대 네 번의
외부 호출을 트랜잭션 안에서 기다렸다. 저장이 한 건뿐이고 외부 호출이 전부 그
앞이라, 트랜잭션만 걷어내면 실패 시 동작이 그대로다.

deleteProject 는 갈라야 했다. 로컬 정리(대화 삭제 + 프로젝트 삭제)가 갈라지면
대화만 사라진 프로젝트가 남으므로 한 트랜잭션이어야 하는데, 자기 호출로는
프록시가 걸리지 않는다. 그래서 그 정리만 ProjectDeletionService 로 뺐다 —
같은 패키지 RepositoryProvisioningService 와 같은 결이다. 되돌릴 수 없는 GitHub
삭제는 그 앞, 트랜잭션 밖에서 끝낸다. 감사 기록을 삭제 직후에 남기는 순서(H3)는
그대로 뒀다.

createProject 도 함께 뗀다 — 템플릿 카탈로그 확인이 RestClient HTTP 호출이라
같은 결함이고, 저장이 한 건이라 semantics 가 동일하다.

Claude-Session: https://claude.ai/code/session_01APAyBZVYxUZzZsVyEXy6Qr
A1~A8 여덟 지점의 진입 메서드를, Spring 이 런타임에 실제로 쓰는 판정기
(AnnotationTransactionAttributeSource — 메서드와 클래스 레벨을 모두 본다)에
직접 물어 트랜잭션이 열리지 않는 것을 확인한다. 이 경로들은 Docker·GitHub·
Cloudflare·AWS 가 있어야 태울 수 있어 통합 테스트로는 덮기 어렵다.

반대 방향도 함께 고정한다. 이 작업은 트랜잭션을 걷어내는 것이 아니라 외부
호출을 트랜잭션 밖으로 옮기는 것이므로, 외부 호출이 없는 쓰기 경로
(ProjectDeletionService.purge · disconnectRepository · abandonVerification 등)는
여전히 트랜잭션 안에 있어야 한다.

Claude-Session: https://claude.ai/code/session_01APAyBZVYxUZzZsVyEXy6Qr
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant