Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

/**
* Runs a coding agent for one user, on that user's own key.
Expand All @@ -21,6 +20,13 @@
* looked up by {@code (userId, vendor)} and there is no fallback to a deployment-wide key. A user
* without a registered key gets a clear "register one" error instead of quietly spending the
* operator's credit — which is the behaviour the providers' terms require, not merely a nicety.</p>
*
* <p>여기에 {@code @Transactional} 을 두지 않는 이유: 아래 {@code run()} 은 컨테이너 안의 CLI 가
* 끝날 때까지 최대 10분을 기다린다. 트랜잭션을 걸면 그 10분 내내 커넥션 하나가 묶이고, 동시
* CODE 태스크 수만큼 곱해져 풀이 마른다(2026-09-08 dev 고갈 사고와 같은 계열, #337). 이 메서드가
* DB 에서 하는 일은 자격증명 조회 한 건뿐이라 리포지토리 자신의 짧은 트랜잭션으로 충분하고,
* {@code AiProviderCredential} 은 JPA 엔티티가 아닌 도메인 모델이라 반환 뒤 세션이 필요 없다.
* {@code AgentPlanExecutor.execute()}·{@code InfraOpsAgentService} 와 같은 원칙이다.</p>
*/
@Slf4j
@Service
Expand All @@ -35,12 +41,10 @@ public class CodingAgentExecutionService {
* @param provider a coding-agent provider ({@code CLAUDE_CODE} / {@code CODEX})
* @param workspaceDir absolute host path of the checkout the agent may edit
*/
@Transactional(readOnly = true)
public CodingAgentResult run(Long userId, AiProvider provider, String prompt, String workspaceDir) {
return run(userId, provider, prompt, workspaceDir, properties.getTimeout());
}

@Transactional(readOnly = true)
public CodingAgentResult run(Long userId,
AiProvider provider,
String prompt,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,15 @@ public class AuthCommandService {
/**
* GitHub OAuth 로그인
* OAuth Token은 유저 정보 조회 후 버림 (저장 X)
*
* <p>트랜잭션을 걸지 않는다 — GitHub OAuth·User API 두 번을 기다리는 동안 커넥션을 붙들던
* 자리다(#337). 로그인은 가장 자주 열리는 경로라 그 점유가 그대로 풀 고갈로 이어졌다.</p>
*
* <p>실패 시 동작은 그대로다: 외부 호출 두 건이 모두 저장보다 앞에 있어, 둘 중 하나라도
* 던지면 아래 저장에 도달하지 않는다 — 예전 롤백과 같은 결과다. 저장 두 건(유저·리프레시
* 토큰)이 더는 한 트랜잭션이 아니지만, 이 경로는 githubId 로 찾아 없으면 만드는 멱등 연산이라
* 뒤의 저장이 실패해도 재시도가 그대로 복구한다(유저 행만 남고 손상은 없다).</p>
*/
@Transactional
public TokenResult loginWithGithub(GithubLoginCommand command) {
oAuthStateManager.verify(command.state());
String oauthToken = githubOAuthPort.getAccessToken(command.code());
Expand Down Expand Up @@ -112,19 +119,29 @@ public void logout(Long userId, String accessToken) {
/**
* GitHub App 설치 완료 콜백 처리
* installation_id 저장 + code가 있으면 GitHub App User Token 발급
*
* <p>트랜잭션을 걷어내면서(#337) 순서를 바꿨다. 예전에는 installationId 를 먼저 반영하고
* 그 뒤에 GitHub 토큰 교환을 호출했는데, 교환이 실패하면 롤백이 installationId 반영까지
* 되돌려 <b>아무것도 저장되지 않는</b> 것이 이 메서드의 실제 동작이었다. 롤백이 사라진
* 지금 같은 결과를 얻으려면 외부 호출을 저장보다 앞에 두는 수밖에 없다 — 교환이 던지면
* 아래 저장 구간에 도달하지 않는다.</p>
*
* <p>유저 조회는 외부 호출보다 앞에 남겨 둔다. 없는 유저면 code 를 소모하기 전에 404 가
* 나가던 기존 순서를 그대로 지키기 위해서다.</p>
*/
@Transactional
public void linkGithubApp(Long userId, Long installationId, String code) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new NotFoundException("유저를 찾을 수 없습니다: " + userId));

GithubAppPort.GithubUserTokenInfo tokenInfo = code == null ? null : githubAppPort.getUserToken(code);

// 여기부터가 저장 구간 — 위 외부 호출이 실패했다면 도달하지 않는다.
// 재인증 콜백은 installation_id 없이 올 수 있음 — 저장된 값 유지
if (installationId != null) {
authDomainService.updateInstallationId(user, installationId);
}

if (code != null) {
GithubAppPort.GithubUserTokenInfo tokenInfo = githubAppPort.getUserToken(code);
if (tokenInfo != null) {
LocalDateTime expiresAt = LocalDateTime.now().plusSeconds(tokenInfo.expiresInSeconds());
user.updateUserToken(tokenInfo.accessToken(), tokenInfo.refreshToken(), expiresAt);
log.info("GitHub App User Token 발급 완료: userId={}", userId);
Expand All @@ -138,8 +155,10 @@ public void linkGithubApp(Long userId, Long installationId, String code) {
/**
* GitHub App 설치 설정 페이지(state 없음)에서 오는 콜백 처리
* code로 User Token 발급 → GitHub 유저 정보로 DB 유저 식별
*
* <p>트랜잭션을 걸지 않는다 — GitHub 호출 두 번이 이미 저장보다 앞에 있어, 실패하면 저장에
* 도달하지 않는 것은 그대로다(#337). 저장도 {@code save} 한 번뿐이라 원자성이 줄지 않는다.</p>
*/
@Transactional
public void linkGithubAppByCode(Long installationId, String code) {
if (code == null) {
throw new IllegalArgumentException("code가 없어 유저를 식별할 수 없습니다");
Expand Down Expand Up @@ -170,8 +189,12 @@ public void linkGithubAppByCode(Long installationId, String code) {
* bad_refresh_token 을 맞는다(2026-08-18 운영 실측: 저장소 연결 승인이 이 경로로 실패했다).
*
* 그러니 갱신 후에는 다시 읽지 말고 이 반환값을 쓸 것.
*
* <p>여기에는 트랜잭션을 걸지 않는다(#337). 이 메서드는 자기 DB 작업이 없고 아래 두 호출이
* 모두 {@code REQUIRES_NEW} 라, 바깥 트랜잭션은 GitHub 갱신을 기다리는 내내 아무 일도 하지
* 않으면서 커넥션 하나를 더 붙들고 있을 뿐이었다(안쪽까지 합쳐 동시에 두 개). 되돌릴 것이
* 없으니 롤백에 기대던 동작도 없다.</p>
*/
@Transactional
public String refreshGithubUserToken(Long userId) {
// 빠른 경로 — 다른 흐름이 이미 갱신했으면 잠금까지 가지 않는다. 그 갱신은 별도
// 트랜잭션으로 커밋되지만 호출자의 영속성 컨텍스트에는 옛 UserEntity 가 남아 여전히
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,15 @@ public class ChangeService {
private final DockerContainerService dockerService;
private final ProjectRepository projectRepository;

@Transactional
/**
* 트랜잭션을 걸지 않는다 — {@link #captureDiff} 가 Docker exec 를 두 번 돌고 그중 하나는
* {@code apk add git} 이라 네트워크 설치까지 기다린다. 트랜잭션 안에 두면 그 내내 커넥션이
* 묶였다(#337).
*
* <p>실패 시 동작은 그대로다: diff 를 뜨다 예외가 나면 아래 저장에 도달하지 못하므로
* Change 행이 남지 않는다 — 예전에 롤백이 해주던 것과 같은 결과를, 외부 호출을 저장보다
* 먼저 두는 순서로 얻는다.</p>
*/
public void record(String taskId, String summary) {
AgentTask task = taskStore.get(taskId);
PreviewSessionInfo preview = previewSessionService.findByTaskId(taskId)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,12 @@ public class DeploymentQueryService {
private final UserRepository userRepository;
private final GithubActionsPort githubActionsPort;

@Transactional(readOnly = true)
/**
* 트랜잭션을 걸지 않는다 — IN_PROGRESS 동안 FE 가 주기적으로 폴링하는 경로라, 아래 GitHub
* Actions 호출(최대 2회)이 끝날 때까지 커넥션을 붙들면 폴링하는 배포 수만큼 풀이 잠긴다(#337).
* DB 에서 하는 일은 읽기 세 건뿐이고 전부 도메인 모델로 나오므로 각 리포지토리의 짧은
* 트랜잭션으로 충분하다. 쓰기가 없어 롤백에 기대던 성질도 없다.
*/
public DeploymentStatusResult getDeploymentStatus(Long ownerUserId, Long historyId) {
DeploymentHistory history = deploymentHistoryRepository.findById(historyId)
.orElseThrow(() -> new NotFoundException("배포 이력을 찾을 수 없습니다. historyId=" + historyId));
Expand Down Expand Up @@ -203,7 +208,7 @@ public List<DeploymentCandidateResult> getDeploymentCandidates(Long ownerUserId,
.toList();
}

@Transactional(readOnly = true)
/** {@link #getDeploymentStatus} 와 같은 이유 — job 로그 다운로드까지 트랜잭션 안에 있었다. */
public DeploymentLogsResult getDeploymentLogs(Long ownerUserId, Long historyId) {
DeploymentHistory history = deploymentHistoryRepository.findById(historyId)
.orElseThrow(() -> new NotFoundException("배포 이력을 찾을 수 없습니다. historyId=" + historyId));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,20 @@ public class DomainBindingCommandService {
private final S3CdnProvisioningPort s3CdnProvisioningPort;
private final BackendAddressPort backendAddressPort;

@Transactional
/**
* 트랜잭션을 걸지 않는다 — 어느 경로든 Cloudflare · GitHub Pages · ACM 을 호출하고 그중
* {@code GithubPagesDomainHostingAdapter} 는 재시도 사이에 {@code Thread.sleep} 까지 한다.
* 그 내내 커넥션이 묶였고, {@code DomainVerificationWorker} 가 배치 20건을 순차로 태우면서
* 곱해졌다(#337).
*
* <p><b>실패 시 저장하지 않는 성질은 트랜잭션 롤백이 아니라 순서로 지킨다.</b> 예전에는
* 외부 호출이 던지면 롤백이 저장을 되돌려 줬다. 트랜잭션이 없는 지금은 세 하위 경로
* ({@link #bindManagedSubdomain} · {@link #bindCustomDomain} · {@link #bindS3Frontend}) 모두
* 외부 호출을 <b>유일한</b> {@code domainBindingRepository.save} 보다 앞에 두어, 외부가 4xx 를
* 주면 그 자리에서 던지고 저장 줄에 도달하지 못한다. 저장이 경로마다 한 건뿐이라 원자성도
* 줄지 않는다. 이 순서는 바꾸면 안 된다 — 바꾸는 순간 "외부 실패인데 행은 남는" 상태가
* 생긴다({@code DomainBindingCommandServiceTest} 가 이를 고정한다).</p>
*/
public DomainBindingResult bindDomain(Long ownerUserId, Long projectId, BindDomainCommand command) {
Project project = resolveProject(ownerUserId, projectId);
DomainBindingResult result;
Expand Down Expand Up @@ -98,7 +111,11 @@ public DomainBindingResult bindDomain(Long ownerUserId, Long projectId, BindDoma
return result;
}

@Transactional
/**
* 트랜잭션을 걸지 않는다 — {@link #verify} 가 호스팅 어댑터 · Cloudflare · DNS 조회를 잇달아
* 호출한다(#337). 저장은 {@code verify} 끝의 {@code save} 한 건이고 외부 호출이 전부 그보다
* 앞이라, 외부가 던지면 저장에 도달하지 않는 것은 이전과 같다.
*/
public DomainBindingResult checkVerification(Long ownerUserId, Long domainId) {
DomainBinding domain = resolveDomainOwnedBy(domainId, ownerUserId);
return verify(domain, resolveProject(ownerUserId, domain.getProjectId()), ownerUserId);
Expand All @@ -107,8 +124,10 @@ public DomainBindingResult checkVerification(Long ownerUserId, Long domainId) {
/**
* 검증 워커 경로. 요청한 사용자가 없으므로 도메인이 속한 프로젝트에서 소유자를 찾아
* 같은 검증을 돌린다. 소유권을 확인하는 것이 아니라 검증에 쓸 토큰의 주인을 찾는 것이다.
*
* <p>워커가 배치로 태우는 입구라 {@link #checkVerification} 보다 트랜잭션 제거 효과가 크다 —
* 예전에는 20건을 순차 검증하는 동안 매 건이 외부 응답을 기다리며 커넥션을 붙들었다(#337).
*/
@Transactional
public DomainBindingResult checkVerificationAsSystem(Long domainId) {
DomainBinding domain = domainBindingRepository.findById(domainId)
.orElseThrow(() -> new NotFoundException("도메인을 찾을 수 없습니다. domainId=" + domainId));
Expand Down Expand Up @@ -158,7 +177,6 @@ private DomainBindingResult verify(DomainBinding domain, Project project, Long o
}

/** HTTP path — no Agent task (see {@link #deleteDomain(Long, Long, String)}). */
@Transactional
public void deleteDomain(Long ownerUserId, Long domainId) {
deleteDomain(ownerUserId, domainId, null);
}
Expand All @@ -167,8 +185,12 @@ public void deleteDomain(Long ownerUserId, Long domainId) {
* @param taskId nullable — non-null only when the Agent-driven delete path (design H11,
* ADR-A8) called this; a direct HTTP call always passes null via the 2-arg
* overload above.
*
* <p>트랜잭션을 걸지 않는다(#337). 외부 정리(어댑터 unbind · Cloudflare 레코드 삭제 · S3
* teardown)가 전부 유일한 쓰기인 {@code deleteById} 보다 앞에 있어, 외부가 실패하면 행이
* 그대로 남는 기존 동작이 순서로 유지된다. 감사 기록은 원래부터 {@code AuditRecorder} 가
* 별도로 커밋하며 절대 던지지 않는다.</p>
*/
@Transactional
public void deleteDomain(Long ownerUserId, Long domainId, String taskId) {
DomainBinding domain = resolveDomainOwnedBy(domainId, ownerUserId);
if (domain.getHostingTarget() == DomainHostingTarget.AWS_S3_FRONTEND) {
Expand Down Expand Up @@ -322,8 +344,11 @@ private DomainBindingResult verifyS3Frontend(DomainBinding domain) {
* 프로젝트 삭제 시 그 프로젝트의 S3 프론트 도메인을 정리한다(Cloudflare 레코드·CloudFront·인증서).
* 시스템 내부 호출이라 소유권 검사는 상위(프로젝트 삭제)가 이미 했다. 한 도메인 정리가 실패해도
* 나머지는 계속한다(best-effort).
*
* <p>트랜잭션을 걷어냈다(#337). 예전에는 배치 전체가 트랜잭션 하나라, 건별 try/catch 가
* 있어도 뒤쪽 한 건의 삭제 실패가 앞서 성공한 삭제까지 되돌릴 수 있었다 — best-effort 라는
* 주석과 실제 동작이 어긋나 있었다. 이제 건별로 커밋된다.</p>
*/
@Transactional
public void cleanupProjectS3Domains(Long projectId) {
for (DomainBinding domain : domainBindingRepository.findByProjectIdOrderByCreatedAtDesc(projectId)) {
if (domain.getHostingTarget() != DomainHostingTarget.AWS_S3_FRONTEND) {
Expand Down Expand Up @@ -387,8 +412,11 @@ private void safeCleanup(Runnable cleanup) {
* 우리 서브도메인이 남의 서버를 가리키는 dangling DNS(서브도메인 탈취)가 된다 — 그래서 레코드를 반드시
* 지운다. 시스템 내부 호출(종료 정리)이라 소유권 검사는 상위(terminate)가 이미 했다. 한 도메인 정리가
* 실패해도 나머지·종료는 계속한다(best-effort).
*
* <p>{@link #cleanupProjectS3Domains} 와 같은 이유로 트랜잭션을 걷어냈다(#337). Cloudflare
* 삭제가 {@code deleteById} 보다 앞이라, 레코드가 안 지워지면 행도 남는 순서는 그대로다 —
* dangling DNS 를 남기느니 행을 남겨 다음 정리에 걸리게 하는 편이 안전하다.</p>
*/
@Transactional
public void releaseServerDomains(Long projectId, String ipAddress) {
if (ipAddress == null || ipAddress.isBlank()) {
return;
Expand Down
Loading
Loading