[7-2] [트랜잭션 전파 실제 적용기 — Pinup 최적화 기록]

minpractice_jhj·2025년 9월 27일

Side Projects

목록 보기
9/16
post-thumbnail

최근 목표는 트랜잭션을 공부 → 가설 세우기 → 실제 서비스에 적용 → 로그로 검증이었다.

이번 글은 그중 “범위(Scope)”에만 집중한다. 요지는 간단하다.

업로드 같은 I/O는 트랜잭션 밖에서 먼저 끝내고, DB에는 URL만 짧게 반영한다.

그리고 auto-commit=false + provider_disables_autocommit=true커넥션 점유 구간을 최소화한다.


0) 사전 실험 요약(한 줄씩)

  • 1️⃣ @Transactional 없이 베이스라인 측정 → 커넥션을 언제 빌리는지 감 잡기
  • 2️⃣ REQUIRED vs REQUIRES_NEW경계 분리가 미치는 영향 확인
  • 3️⃣ Self-invocation → 프록시 미적용 구간 드러남
  • 4️⃣ auto-commit=false + hibernate.connection.provider_disables_autocommit=true점유 시간 큰 폭 감소

이제 이 원리를 실제 핀업 흐름에 녹였다.

1) A/B 흐름 정의

그림 — 왼쪽: A안(업로드가 트랜잭션 안이라 active=1 유지), 오른쪽: B안(업로드 먼저, DB는 짧게 active=1→0). auto-commit=false + provider_disables_autocommit=true 적용.

A안(기존)

  • 흐름: [요청] → first SQL(=post save) → post_images save → (업로드) → thumbnail update → commit
  • 특징: 업로드가 트랜잭션 내부에 포함 → DB 커넥션 점유 시간이 길다.

B안(변경)

  • 흐름: [요청] → (업로드만) → first SQL(=post save) → post_images save → thumbnail update → commit
  • 특징: 업로드를 트랜잭션 밖에서 먼저 끝내고, DB에는 URL만 짧게 반영한다.

2) 적용 설정

커넥션 지연획득을 돕고 트랜잭션 경계를 최소화하기 위해 다음만 적용했다.

spring:
  datasource:
    hikari:
      auto-commit: false
  jpa:
    properties:
      hibernate.connection.provider_disables_autocommit: true
  • auto-commit=false: 커밋 시점을 애플리케이션이 통제.
  • hibernate.connection.provider_disables_autocommit=true: 하이버네이트가 드라이버의 오토커밋을 건드리지 않아 커넥션 점유 구간 축소.

3) 변경 포인트 (서비스 레벨)

3-1) PostImageService — 업로드/저장 분리 + 보상 삭제

기존에는 업로드 + DB 저장을 하나의 메서드에서 처리했다. 이를 아래 3개 역할로 분리했다(코드는 비공개, 메서드 역할만 명시).

  1. 업로드만 수행하고 URL 리스트만 반환 — uploadImagesOnly(...)
  2. 업로드된 URL을 이용해 post_images만 DB 저장saveImageUrls(...)
  3. 보상 삭제(Compensation): DB 실패 시 업로드했던 URL 리스트로 S3 객체를 조용히 삭제(예외는 삼켜서 DB 예외를 덮지 않음) — deleteS3ByUrlsQuietly(...)

3-2) PostService — 전/후 비교가 명확

A안(기존) — 업로드가 트랜잭션 내부

@Transactional
public PostResponse createPost(MemberInfo memberInfo,
                               CreatePostRequest createPostRequest,
                               CreatePostImageRequest createPostImageRequest) {
    Post post = createPostEntity(memberInfo, createPostRequest);
    post = postRepository.save(post);

    appLogger.info(new InfoLog("게시글 생성 완료")
            .setStatus("201")
            .setTargetId(post.getId().toString())
            .addDetails("writer", post.getMember().getNickname(), "title", post.getTitle()));

    // 업로드 + DB 저장이 같은 경계 안
    List<PostImage> postImages = postImageService.savePostImages(createPostImageRequest, post);

    if (!postImages.isEmpty()) {
        post.updateThumbnail(postImages.get(0).getS3Url());
    }
    return PostResponse.from(post);
}

B안(변경) — 업로드 먼저, DB 실패 시 보상 삭제

@Timed("post.create")
@Transactional
public PostResponse createPost(MemberInfo memberInfo,
                               CreatePostRequest createPostRequest,
                               CreatePostImageRequest createPostImageRequest) {
    StepWatch sw = new StepWatch("PostService.createPost");

    // 1) 업로드만 먼저 (S3) — 트랜잭션 밖 I/O
    sw.start("1. upload only");
    List<String> uploadedUrls = postImageService.uploadImagesOnly(createPostImageRequest);
    sw.stop();

    try {
        // 2) post insert
        sw.start("2. post insert");
        Post post = createPostEntity(memberInfo, createPostRequest);
        post = postRepository.save(post);
        sw.stop();

        // 3) post_images DB 저장
        sw.start("3. postImages save");
        List<PostImage> postImages = postImageService.saveImageUrls(post, uploadedUrls);
        sw.stop();

        // 4) 썸네일 업데이트
        sw.start("4. thumbnail update");
        if (!postImages.isEmpty()) {
            post.updateThumbnail(postImages.get(0).getS3Url());
        }
        sw.stop();

        return PostResponse.from(post);

    } catch (Exception e) {
        // DB 단계 실패 시 → 이미 업로드된 S3 보상 삭제
        postImageService.deleteS3ByUrlsQuietly(uploadedUrls);
        throw e; // 원래 예외를 그대로 전파하여 트랜잭션 롤백
    }
}

포인트

  • 업로드는 트랜잭션 밖에서 선행되므로 DB 커넥션을 점유하지 않음
  • DB 실패 시 deleteS3ByUrlsQuietly(...)S3 고아 객체 방지
  • @TimedStepWatch단계별 시간을 남겨 병목을 확인

4) 측정 결과 (로그 기반)

구분Tx 길이*DB 커넥션 점유**전체 처리시간(StepWatch)비고
설정 전 · A안≈448ms≈431ms≈402–406ms업로드가 Tx 안에 포함
설정 전 · B안≈533ms≈178ms≈479ms업로드 먼저 수행
설정 후 · A안≈641ms≈607ms≈589–592msTx/DB 점유 모두 증가
설정 후 · B안≈208ms≈39ms≈194ms업로드 먼저 + 설정 적용
  • Tx 길이: JpaTransactionManager 시작~커밋 로그 간격

** DB 점유: p6spy SQL 시작~커밋 로그 간격

최적 비교(설정 전 · A안 → 설정 후 · B안)

  • Tx: 448 → 208ms (−54%)
  • DB 점유: 431 → 39ms (−91%)
  • 전체: 402 → 194ms (−52%)

트랜잭션/커넥션 점유 시간 감소가 응답 시간 개선으로 직결되었다.


5) 증빙 로그

설정 전 · A안 (업로드가 트랜잭션 안)

핵심 포인트: 업로드가 트랜잭션 내부에 있어 커넥션이 긴 시간(active=1) 점유된다.

18:00:06.210  Creating new transaction ... [PostService.createPost]

# (트랜잭션 시작 직후부터 DB 점유)
18:00:06.223  select ... from members where nickname='흥미로운 허브'
18:00:06.333  insert into posts (...)
18:00:06.340  HikariPool-1 - Pool stats (total=10, active=1, idle=9, waiting=0)   <-- active=1 유지 시작

# (업로드가 Tx 안에서 수행됨 → DB 커넥션 계속 점유)
18:00:06.458  S3 PUT /pinup/post/2620c004-..._1697261010140.jpg
18:00:06.515  S3 200 OK
18:00:06.568  S3 PUT /pinup/post/5565cfb8-..._full-stack-developer.jpg
18:00:06.583  S3 200 OK

# (이미지 메타 저장 + 커밋)
18:00:06.618  insert into post_images (...)
18:00:06.622  insert into post_images (...)
18:00:06.658  commit
18:00:06.658  HikariPool-1 - Pool stats (total=10, active=0, idle=10, waiting=0)   <-- 커밋 후 반납

요약

  • 업로드 구간: active=1 (트랜잭션 내부이므로 DB 커넥션 계속 점유)
  • DB 구간: 업로드 포함 전체가 하나의 긴 점유 구간
  • 측정값: DB 점유 ≈ 431ms, Tx ≈ 448ms, 전체 ≈ 402–406ms

설정 후 · B안 (업로드 먼저, DB는 짧게)

핵심 포인트: 업로드를 먼저 끝낸 뒤 필요한 순간에만 DB 커넥션을 잠깐 빌린다.

18:37:31.792  Creating new transaction ... [PostService.createPost]

# (업로드 전용 구간) — DB 미사용
18:37:31.801  S3 PUT /pinup/post/3e140853-..._지도도.webp
18:37:31.823  S3 200 OK
18:37:31.851  S3 PUT /pinup/post/9d6ba0d9-..._통합본.jpg
18:37:31.929  S3 200 OK
18:37:31.930  HikariPool-1 - Pool stats (total=10, active=0, idle=10, waiting=0)   <-- 업로드 동안 active=0

# (DB 구간 진입) — 짧게 빌리고 바로 반납
18:37:31.961  select ... from members where nickname='흥미로운 허브'
18:37:31.966  select ... from stores where id=9
18:37:31.975  HikariPool-1 - Pool stats (total=10, active=1, idle=9, waiting=0)    <-- DB 커넥션 점유 시작(스파이크)

18:37:31.974  insert into posts (...)
18:37:31.980  insert into post_images (...)
18:37:31.985  insert into post_images (...)
18:37:31.991  update posts set ... thumbnail_url=...
18:37:32.000  commit
18:37:32.000  HikariPool-1 - Pool stats (total=10, active=0, idle=10, waiting=0)   <-- 커밋 즉시 반납

요약

  • 업로드 구간: active=0 (DB 커넥션 미점유)
  • DB 구간: 잠깐 active=1 → 커밋 직후 다시 0
  • 측정값: DB 점유 ≈ 39ms, Tx ≈ 208ms, 전체 ≈ 194ms

위 Pool stats 라인은 업로드 종료/첫 SQL 직후/커밋 직후 등 스텝 경계에서 출력했다. 이 지점만 찍어도 active=0 → 1 → 0 패턴이 선명히 드러난다.


6) 결론

  • 원리: 업로드를 트랜잭션 경계 밖으로 분리하고, DB에는 URL만 짧게 반영한다.
  • 설정: auto-commit=false + hibernate.connection.provider_disables_autocommit=true 조합으로 커넥션 점유 구간을 줄인다.
  • 정합성: DB 실패 시 S3 보상 삭제로 고아 파일을 방지한다.
  • 효과: Tx −54%, DB 점유 −91%, 전체 −52%. 최종 응답 시간이 의미 있게 단축되었다.
profile
운동처럼 개발도 작은 실천이 성장의 힘이 된다고 믿는 개발자 minpractice_jhj 기록

0개의 댓글