Facade 계층을 활용한 트랜잭션 분리와 보상 로직으로 정합성 개선

2025. 12. 16. 05:45·YAPP

1. 기존 응원 등록 흐름의 구조적 한계

현재 Eatda의 응원 등록 기능은
DB 트랜잭션 내부에서 S3 이미지 복사 작업을 함께 수행하는 구조로 구현되어 있습니다.

응원 등록 요청 하나는 다음과 같은 흐름으로 처리됩니다.

  • 응원 정보 DB 저장
  • S3 임시 경로에 업로드된 이미지를 도메인 하위의 영구 경로로 복사
  • 응원 최종 발행 처리

이 모든 과정이 하나의 DB 트랜잭션 안에서 순차적으로 실행되고 있었고,
트랜잭션 범위 안에 S3 이미지 이동이라는 외부 작업이 포함되어 있습니다.

 

 

문제 1. 트랜잭션 내 외부 I/O로 인한 자원 점유

이 구조에서는 S3 호출이 트랜잭션 안에 포함되기에
요청 처리 시간이 길어질수록 DB 커넥션과 스레드를 불필요하게 오래 점유하게 됩니다.
트래픽이 증가할 경우, 이는 커넥션 풀 고갈이나 스레드 고갈로 이어질 수 있는 구조였습니다.

 

 

문제 2. 트랜잭션 경계 불일치로 인한 정합성 문제

또한 이미지 이동 중 예외가 발생하면
DB 트랜잭션은 롤백되지만, 이미 S3의 도메인 하위 경로로 복사된 파일은 그대로 남게 됩니다.

즉, 실패 시 DB는 되돌려지지만 S3는 되돌려지지 않는,
책임 범위가 다른 작업들이 하나의 트랜잭션 흐름 안에 섞여 있는 구조였습니다.

 

이 부분은 기능을 처음 구현할 당시에도 인지하고 있었고,
코드에는 트랜잭션 범위를 줄여야 한다는 주석을 남겨두었습니다.

 

하지만 서비스 확장과 트래픽 증가를 고려했을 때,
이 구조를 그대로 두는 것은 트랜잭션 범위와 외부 호출의 결합으로 인해
병목과 정합성 문제를 동시에 유발할 수 있는 상태라고 판단했고,
이번 작업을 통해 해당 흐름을 개선하기로 결정했습니다.

 


 

2. 트랜잭션 분리와 보상 전략 설계

응원 발행 성공 정책

트랜잭션을 분리를 위해 가장 먼저 결정해야 했던 것은 응원 발행 성공에 대한 정책 기준이었습니다.

응원 등록 과정에서는 이미지가 없을 수도 있고, 최대 3장까지 함께 처리됩니다.

이때 일부 이미지만 S3 이동에 성공했을 경우, 이를 응원 발행 성공으로 볼지 실패로 볼지를 먼저 정의해야 했습니다.

이에 팀 내 논의를 거쳐, 모든 이미지가 정상적으로 처리되었을 때만 응원 발행을 성공으로 간주하기로 했습니다.

 

 

이미지 처리 실행 방식

다음으로 고려한 것은 실행 방식이었습니다.

기존에는 이미지 처리를 비동기로 수행하고 있었고, 이미지 작업의 특성상 I/O 비중이 높기 때문에
가상 스레드 도입도 하나의 선택지로 검토했습니다.

 

하지만 비동기 처리를 유지할 경우, 실패 시점과 실패 원인을 요청 흐름 안에서 명확히 판단하기 어렵고,
이를 처리하기 위해 별도의 상태 테이블이 필요해집니다.

이 경우 작업 범위가 과도하게 커진다고 판단했고,
이번 작업에서는 응원 등록 요청 단위에서 이미지 이동과 그 결과를 명확히 판단할 수 있도록
동기 처리로 전환하는 방향
을 선택했습니다.

 

 

트랜잭션 분리 이후의 보상 전략

트랜잭션을 분리한 이후에는 DB 트랜잭션 커밋 이후 S3 이미지 이동이 실패할 수 있는 상황이 발생합니다.
이를 처리하기 위해 보상 트랜잭션을 도입했습니다.

보상 트랜잭션의 구현 방식으로는 외부 메시지 큐, 아웃박스 패턴, 내부 이벤트 발행 등을 검토했지만,
현재 서비스 규모를 고려해 요청 흐름 내부에서 직접 보상 로직을 실행하는 방식을 선택했습니다.

 

이번 설계의 핵심은 다음과 같습니다.

  • DB 트랜잭션은 응원 데이터와 이미지 메타데이터 저장까지만 책임진다.
  • S3 이미지 이동은 트랜잭션 외부에서 수행한다.
  • 모든 이미지가 성공했을 때만 응원 발행을 성공으로 판단한다.
  • 이미지 이동 중 실패가 발생하면, 이미 커밋된 DB 데이터를 기준으로 보상 로직을 수행한다.

이 과정에서 트랜잭션 분리와 보상 로직을 어느 계층에서 처리할 것인지에 대한 고민이 있었습니다.

서비스 계층이 트랜잭션 관리와 보상 로직까지 모두 담당하게 되면 책임 범위가 지나치게 커진다고 생각했습니다.

 

그래서 서비스 계층은 도메인 로직을 유지한 채 동작하도록 두고,
트랜잭션 관리, 요청 흐름 조율, 보상 트랜잭션 처리는
컨트롤러와 서비스 계층 사이의 파사드 계층에서 담당하도록 분리했습니다.

이를 통해 트랜잭션 경계, 외부 작업 호출, 보상 로직을 하나의 흐름으로 관리하면서도,
도메인 서비스가 복잡해지는 것을 피할 수 있었습니다.

 


 

3. 구현

파사드에서 요청 흐름과 트랜잭션 경계 분리

설계에서 정의한 트랜잭션 분리를 코드에서는 파사드 계층과 서비스 계층의 역할 분리로 구현했습니다.

CheerRegisterFacade는 트랜잭션을 직접 관리하지 않고, 요청 흐름을 조율하는 역할만 담당합니다.
실제 DB 트랜잭션은 CheerService의 메서드 단위로만 시작되도록 구성했습니다.

public CheerResponse registerCheer(...) {
    CheerCreationResult creationResult = cheerService.createCheer(...);
    Cheer cheer = creationResult.cheer();
    ...
}

 

createCheer 메서드는 Transactional이 적용된 서비스 메서드로,
응원 생성에 대한 첫 번째 트랜잭션을 담당합니다.


이 시점에서 응원 데이터는 DB에 커밋되며, 이후 S3 이미지 복사는 트랜잭션 외부에서 수행됩니다.

이를 통해 DB 트랜잭션이 외부 I/O 작업에 의해 불필요하게 점유되는것을 방지했습니다.

 

 

이미지 복사는 트랜잭션 외부에서 동기적으로 수행

이미지 이동은 FileClient를 통해 동기적으로 실행됩니다.
Facade에서 직접 호출하며, 요청 흐름 안에서 성공 여부를 즉시 판단합니다.

private List<String> moveImages(ImageDomain domain,
                                    long cheerId,
                                    List<CheerRegisterRequest.UploadedImageDetail> sortedImages) {
        if (sortedImages.isEmpty()) {
            return List.of();
        }

        List<String> tempKeys = sortedImages.stream()
                .map(CheerRegisterRequest.UploadedImageDetail::imageKey)
                .toList();
        return fileClient.moveTempFilesToPermanent(domain.getName(), cheerId, tempKeys);
    }

 

이 단계는 DB 트랜잭션 외부에서 수행되며, 요청 흐름 안에서 이미지 이동의 성공 여부를 즉시 판단합니다.

이를 통해 비동기 처리나 별도의 상태 관리 없이도 요청 단위로 성공/실패를 명확히 구분할 수 있습니다.

 

 

3-3. 이미지 메타데이터 저장

이미지 복사가 성공한 이후에는 영구 경로로 복사된 이미지 정보를 DB에 저장해야 합니다.

이 작업은 응원 생성과는 별도의 트랜잭션으로 처리했습니다.

cheerService.saveCheerImages(cheer.getId(), sortedImages, permanentKeys);

 

saveCheerImages 메서드 역시 Transactional이 적용된 서비스 메서드로,두 번째 DB 트랜잭션을 담당합니다.
이 트랜잭션에서는 이미지의 키, 순서, 타입, 크기 등 이미지 메타데이터만 저장합니다.

 

이로써 응원 생성 트랜잭션과 이미지 메타데이터 저장 트랜잭션이
명확히 분리되며, 각 트랜잭션은 자신의 책임 범위만 가지도록 제한됩니다.

 

 

보상 트랜잭션의 적용 위치와 처리 방식

트랜잭션 분리 이후에는 DB 트랜잭션 커밋이 완료된 상태에서 S3 이미지 이동이 실패할 수 있습니다.

이를 처리하기 위해 Facade에서 보상 트랜잭션을 직접 수행하도록 구현했습니다.

} catch (Exception e) {
    log.error("응원 등록 프로세스 실패. 롤백 수행. cheerId={}", cheer.getId(), e);

    cheerService.deleteCheer(cheer.getId());

    if (!permanentKeys.isEmpty()) {
        fileClient.deleteFiles(permanentKeys);
    }
    throw e;
}

 

이미지 이동 중 예외가 발생하면,

  • 이미 커밋된 응원 데이터를 삭제하고
  • 이동에 성공했던 S3 파일이 존재할 경우 함께 정리합니다.

이 보상 로직은 트랜잭션 외부에서 발생한 실패를 기준으로 이미 커밋된 데이터를 정리하는 역할을 수행합니다.

 

 

서비스 계층의 책임 유지

CheerService는 도메인 로직과 DB 접근에만 집중하도록 유지했습니다.

  • 응원 생성 (createCheer)
  • 이미지 메타데이터 저장 (saveCheerImages)
  • 응원 삭제 (deleteCheer)

각각의 메서드는 독립적인 트랜잭션 단위로 동작하며,
서비스 계층 내부에는 외부 시스템 호출이나 요청 흐름 제어 로직이 포함되지 않습니다.

 


 

4. 테스트 시나리오와 검증 결과

트랜잭션 분리와 보상 트랜잭션이 의도한 대로 동작하는지를 검증하는 데 초점을 두고
CheerRegisterFacade 단위에서 테스트를 구성했습니다.

 

 

이미지 이동 성공 시 응원 발행

이미지 이동이 정상적으로 완료되었을 경우 아래 내용을 검증했습니다.

  • S3 이미지 이동이 수행되고
  • 이미지 메타데이터가 DB에 저장되며
  • 최종 응원 응답이 반환되는지
verify(fileClient)
    .moveTempFilesToPermanent(
        eq(ImageDomain.CHEER.getName()),
        anyLong(),
        anyList()
    );
 

응원 설명, 태그, 이미지 개수가 모두 기대값과 일치하며,
이미지 이동 로직이 실제로 호출되었음을 확인했습니다.

 

 

이미지 이동 중 실패 시 보상 트랜잭션 검증

이미지 이동 과정에서 예외가 발생했을 경우,

  • 응원 생성 트랜잭션은 이미 커밋되었더라도
  • 최종적으로 응원 데이터가 DB에 남지 않아야 합니다.
            given(fileClient.moveTempFilesToPermanent(
                    anyString(),
                    anyLong(),
                    anyList()
            )).willThrow(
                    SdkException.builder().build()
            );

            assertThrows(SdkException.class, () ->
                    cheerRegisterFacade.registerCheer(
                            request,
                            storeResult,
                            member.getId(),
                            ImageDomain.CHEER
                    )
            );

            assertThat(cheerRepository.count()).isZero();

 

이 테스트를 통해 S3 이동 실패 시 Facade에서 보상 트랜잭션이 수행되어
응원 데이터가 정상적으로 정리되는 것을 확인했습니다.

 

 

이미지 이동이 부분적으로 성공한 후 실패하는 경우

여러 장의 이미지 중 일부만 이동된 뒤 예외가 발생하는 경우에도
응원 발행은 성공으로 간주되지 않아야 합니다.

이 경우 역시,

  • 응원 데이터는 DB에서 삭제되고
  • 부분적으로 이동된 이미지가 남지 않도록 정리되는지 검증했습니다.
            given(fileClient.moveTempFilesToPermanent(
                    eq(ImageDomain.CHEER.getName()),
                    anyLong(),
                    anyList()
            )).willAnswer(invocation -> {
                throw SdkException.builder().build();
            });

            assertThrows(SdkException.class, () ->
                    cheerRegisterFacade.registerCheer(
                            request,
                            storeResult,
                            member.getId(),
                            ImageDomain.CHEER
                    )
            );

            assertThat(cheerRepository.count())
                    .as("부분 성공 후 실패 시 Cheer는 삭제되어야 한다.")
                    .isZero();
 

이를 통해 설계 단계에서 정의한 모든 이미지가 성공해야 응원 발행 성공 정책이
부분 성공 케이스에서도 일관되게 적용됨을 확인했습니다.

 

 

이미지 이동은 성공했으나 DB 저장에 실패하는 경우

이미지 이동은 정상적으로 완료되었지만,
이미지 메타데이터 저장 과정에서 DB 예외가 발생하는 경우도 테스트했습니다.

이 경우에는

  • 이미 커밋된 응원 데이터가 삭제되고
  • S3에 이동된 파일 역시 함께 정리되는지를 검증했습니다.
            assertThrows(Exception.class, () ->
                    cheerRegisterFacade.registerCheer(
                            request,
                            storeResult,
                            member.getId(),
                            ImageDomain.CHEER
                    )
            );

            assertThat(cheerRepository.count())
                    .as("DB 에러(컬럼 길이 초과) 발생 시 응원글은 삭제되어야 한다.")
                    .isZero();

            verify(fileClient).deleteFiles(movedKeys);
 

이를 통해 두 번째 트랜잭션(DB 저장) 실패 시에도 보상 로직이 정상적으로 수행되는지를 확인했습니다.

 

 

이미지가 없는 경우 응원 발행

이미지가 없는 요청의 경우에는

  • 이미지 이동 로직이 호출되지 않고
  • 응원 데이터만 정상적으로 저장되는지를 검증했습니다.
assertThat(response.images()).isEmpty();
            assertThat(cheerRepository.count()).isEqualTo(1);

            verify(fileClient, Mockito.never())
                    .moveTempFilesToPermanent(anyString(), anyLong(), anyList());
 

이를 통해 이미지 유무에 따른 분기 로직이 불필요한 외부 호출 없이 동작함을 확인했습니다.

 

 

테스트 결과 정리

테스트를 통해 다음 사항을 확인할 수 있었습니다.

  • 응원 발행 성공/실패 정책이 모든 케이스에서 일관되게 적용된다.
  • 트랜잭션 분리 이후에도 요청 단위 정합성이 유지된다.
  • 외부 작업(S3) 실패 시 보상 트랜잭션이 정상적으로 수행된다.
  • 서비스 계층과 파사드 계층의 책임 분리가 테스트로 검증된다.

이를 통해 설계 단계에서 의도한 트랜잭션 분리 + 보상 트랜잭션 구조가
구현과 테스트까지 일관되게 유지되고 있음
을 확인했습니다.

 


5. 향후 개선점

이번 개선은 이미지 이동을 요청 흐름 안에서 동기적으로 처리하기에

요청 처리 시간이 외부 IO에 직접적으로 영향을 받는 한계가 있습니다.

 

트래픽이 증가하고 이미지 처리 요청이 동시에 몰리게 되면,
요청 단위에서 이미지 이동을 처리하는 구조는
응답 지연과 처리량 한계로 이어질 가능성이 있습니다.

 

그때는 이미지 이동 작업을 외부 메시지 큐로 분리하고,
별도의 워커가 비동기로 처리하는 구조가 더 안정적일것이라고 생각합니다.

  • 이미지 처리 요청을 큐에 적재하고
  • 이미지 처리 상태를 별도의 저장소에 관리하며
  • 실패 시 보상 트랜잭션을 오케스트레이터가 담당

하여 요청 흐름과 외부 작업을 완전히 분리할 수 있습니다.

다만 이와 같은 구조는 상태 관리, 재시도 정책, 메시지 중복 처리 등이 필요하게 되므로,
현재 단계에서는 트레이드오프를 충분히 고려한 뒤 점진적으로 확장해 나가고자 합니다.

 

저작자표시 비영리 변경금지 (새창열림)

'YAPP' 카테고리의 다른 글

Eatda 운영 개선기: Datadog Terraform, 알람 분리, 월간 리포트 자동화  (0) 2025.11.30
API Latency Postmortem - 3 : Batch Fetch로 인메모리 페이징 병목 60% 개선  (0) 2025.10.11
Datadog을 활용한 SLI/SLO 모니터링 및 Burn Rate 알람 적용기  (0) 2025.09.18
격리된 Prod 복제 환경에서 안전하게 DB 마이그레이션 수행하기  (2) 2025.08.29
API Latency Postmortem - 2 : 이미지 업로드 아키텍처 개선으로 주요 API 레이턴시 단축  (3) 2025.08.28
'YAPP' 카테고리의 다른 글
  • Eatda 운영 개선기: Datadog Terraform, 알람 분리, 월간 리포트 자동화
  • API Latency Postmortem - 3 : Batch Fetch로 인메모리 페이징 병목 60% 개선
  • Datadog을 활용한 SLI/SLO 모니터링 및 Burn Rate 알람 적용기
  • 격리된 Prod 복제 환경에서 안전하게 DB 마이그레이션 수행하기
로승리
로승리
  • 로승리
    Roy's Blog
    로승리
  • 전체
    오늘
    어제
    • 분류 전체보기
      • Issuefy
      • Language
      • Spring
      • Kubernetes
      • AWS
      • YAPP
      • 코드스쿼드
      • 코딩 테스트
      • 국비지원
      • 회고
      • 컨퍼런스, 세미나
  • 블로그 메뉴

    • 홈
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.0
로승리
Facade 계층을 활용한 트랜잭션 분리와 보상 로직으로 정합성 개선
상단으로

티스토리툴바