S3 Transaction 처리 방법과 LocalStack을 통한 S3 테스트

공병주(Chris)·2023년 3월 8일
2
post-thumbnail

해당 게시글은 LocalStack 구축 환경 개선하기 게시글로 이어집니다.

2023 글로벌미디어학부 졸업 작품 프로젝트 Dandi 에서 이미지 기능을 제공해주어야 했습니다. 이전 우테코 프로젝트에서는 이미지 서버에 SpringBoot를 띄운 후 이미지를 받아서 단순 저장하는 방식을 사용했었습니다.

하지만, 이번에는 프리티어 EC2를 하나만 사용할 수 있습니다. 또한, 기존에 이미지 저장으로 S3를 많이 사용하기 때문에 S3를 사용하기로 결정했습니다.

S3를 구축한 과정은 많은 분들이 잘 정리해서 웹에 올려두었기 때문에, 따로 기록하지 않았습니다.

저는 S3를 다루면서 가졌더 고민들과 해결방안에 대해서 기록하려합니다.

S3 Transaction 처리

사용자 이미지에 변경이 생기면 S3에서 사진을 삽입/삭제/변경을 하고 DB를 업데이트 해야합니다. 저는 여기서, 하나의 Transaction에서 S3 예외 상황시 어떻게 핸들링할지에 대한 고민이 들었습니다.

S3를 통해 구현한 첫 기능이 프로필 사진 변경 기능이었습니다.

프로필 사진 변경 기능에서 아래와 같은 일을 해야합니다.

  • S3에 변경할 프로필 사진을 업로드
  • 기존 프로필 사진이 존재한다면 S3에서 삭제
  • Member의 profileImageUrl 컬럼을 Update

Transaction 문제 상황

만약, 위의 순서대로 로직을 처리한다고 가정해보겠습니다.

S3에 사진 업로드/삭제 처리를 완료했는데, DB update 과정에서 예외가 발생한다면 어떻게 될까요?

S3는 Springframework과 무관한 AWS 서비스이기 때문에 우리가 주로 사용하는 Spring의 @Transactional 어노테이션으로 S3를 rollback 시키는 것은 불가능합니다.

DB에는 변경되지 않은 사진 URL을 가지지만, S3에는 변경할 사진 객체가 올라가고 이전 프로필 사진 객체는 삭제된 상태가 됩니다.

해결방안) DB update 후에 사진을 업로드

따라서, 아래와 같은 절차로 진행해야겠다는 생각을 했습니다.

  1. member의 profileImageUrl 컬럼 업데이트
  2. S3에 변경할 사진을 업로드
  3. S3에서 기존 프로필 사진을 삭제
@Service
public class ProfileImageService {

    private static final String S3_FILE_KEY_FORMAT = "%s/%s_%s";

    private final MemberRepository memberRepository;
    private final ProfileImageUploader profileImageUploader;
    private final String profileImageDir;

    // ...

    @Transactional
    public ProfileImageUpdateResponse updateProfileImage(Long memberId, MultipartFile profileImage) throws IOException {
        Member member = memberRepository.findById(memberId)
                .orElseThrow(UnauthorizedException::notExistentMember);
        String fileKey = generateFileKey(profileImage);
        memberRepository.updateProfileImageUrl(member.getId(), fileKey);
        //member.updateProfileImageUrl(fileKey); 변경 감지 사용 불가능
        profileImageUploader.upload(fileKey, profileImage.getInputStream());
        profileImageUploader.delete(currentProfileImageUrl);
        return new ProfileImageUpdateResponse(fileKey);
    }

    private String generateFileKey(MultipartFile imageFile) {
        // ...
    }
]

위와 같은 방식이라면 DB를 update하고 S3에 접근해서 업로드/삭제를 하면 위의 Transaction 문제상황은 해결할 수 있습니다.

변경 감지 사용 불가

위에 주석되어있는 member.updateProfileImageUrl(fileKey)처럼 변경감지를 통해 member의 profileImageUrl를 update하려고 하면 먼저 DB를 update를 실행하고 사진 S3에 upload가 불가합니다.
JPA 변경감지의 특성상 영속성 컨텍스트가 flush되기 직전에, Dirty Checking을 하기 때문이죠.

직접 변경감지를 통한 로직을 실행해보면 S3에 upload가 된 후에, update 쿼리가 실행됩니다.

따라서, JPQL 호출을 통해 update Query가 먼저 실행되도록 하였습니다.

@Transactional rollback 기준

하지만, 문제는 100% 해결하지 못했다고 생각합니다.

spring의 @Transactional rollbackFor 기본값이 RuntimeException.class, Error.class입니다.

AmazonS3의 명세를 보면 아래와 같은 예외들이 발생할 수 있다고 나와있습니다.

*@throws SdkClientException
If any errors are encountered on the client while making the request or handling the response.
@throws AmazonServiceException
If any errors occurred in Amazon S3 while processing the request*

SdkClientException은 AmazonServiceException의 상위타입

두 예외는 java의 RuntimeException을 상속하고 있기 때문에 @Transactional의 기본 설정으로 rollback을 진행할 수 있습니다.

하지만, S3에서 IOException과 같은 Exception이 발생한다면 @Transactional의 기본 설정으로는 DB rollback이 불가능합니다.

따라서, S3 작업시에 SdkClientException과 IOException이 발생할 경우, 아래처럼 제가 정의하였고 RuntimeException을 상속하는 ImageUploadFailedException이 발생하도록 했습니다.

@Service
public class ProfileImageService {

    private static final String S3_FILE_KEY_FORMAT = "%s/%s_%s";

    private final MemberRepository memberRepository;
    private final ProfileImageUploader profileImageUploader;
    private final String profileImageDir;

    // ...

    @Transactional
    public ProfileImageUpdateResponse updateProfileImage(Long memberId, MultipartFile profileImage) {
        Member member = memberRepository.findById(memberId)
                .orElseThrow(UnauthorizedException::notExistentMember);
        String fileKey = generateFileKey(profileImage);
        memberRepository.updateProfileImageUrl(member.getId(), fileKey);
        uploadImage(fileKey, profileImage);
        profileImageUploader.delete(currentProfileImageUrl);
        return new ProfileImageUpdateResponse(fileKey);
    }

    private void uploadImage(String fileKey, MultipartFile profileImage) {
        try {
            profileImageUploader.upload(fileKey, profileImage.getInputStream());
        } catch (SdkClientException | IOException e) {
            throw new ImageUploadFailedException(); // 필자가 정의한 Exception(RuntimeException을 상속)
        }
    }
    // ...
}

S3 기존 프로필 이미지 삭제 실패 경우엔 rollback하지 않음

S3 이미지 삭제 실패의 경우에 DB가 rollback 되지 않도록 하였습니다. S3에 새로운 프로필 사진이 업로드가 되었고 DB에 새로운 프로필 사진 URL이 update까지가 사용자에게 필수적인 과정이라고 생각하기 때문입니다.

기존의 사진이 삭제되지 않아도 사용자는 프로필 사진이 변경된 것을 확인 가능합니다.

따라서, 기존 프로필 이미지 삭제 실패 경우엔 예외 발생을 통해 rollback 시키지 않고, 아래처럼 logging을 하도록 해두었습니다.

추후에, 일정 기간 단위로 log를 확인해서 S3에 불필요한 파일을 삭제하도록 해야할 것 같습니다.

@Service
public class ProfileImageService {

    private final MemberRepository memberRepository;
    private final ProfileImageUploader profileImageUploader;
    private final String profileImageDir;

    // ...

    @Transactional
    public ProfileImageUpdateResponse updateProfileImage(Long memberId, MultipartFile profileImage) throws IOException {
        Member member = memberRepository.findById(memberId)
                .orElseThrow(UnauthorizedException::notExistentMember);
        String fileKey = generateFileKey(profileImage);
        memberRepository.updateProfileImageUrl(member.getId(), fileKey);
        uploadImage(fileKey, profileImage);
        deleteCurrentProfileImageIfExists(member);
        return new ProfileImageUpdateResponse(fileKey);
    }

    // ...

    private void deleteCurrentProfileImageIfExists(Member member) {
        String currentProfileImageUrl = member.getProfileImgUrl();
        if (Objects.nonNull(currentProfileImageUrl)) {
            deletePreviousProfileImage(currentProfileImageUrl);
        }
    }

    private void deletePreviousProfileImage(String currentProfileImageUrl) {
        try {
            profileImageUploader.delete(currentProfileImageUrl);
        } catch (SdkClientException | IOException e) {
            logger.info("Profile Image Deletion Failed : " + currentProfileImageUrl);
        }
    }
}

위처럼 프로필 사진 변경 기능

Localstack

local 환경과 test 시점에 실제 S3에 접근해서 파일들을 다루는 것은 좋지 않습니다. 트래픽도 섞이고 test, local, prod 환경의 파일들이 섞일 수 있기 때문입니다.

또한, local 환경과 test에서 실제 S3에 접근하는 것은 협업에서 어려운 일입니다. 여러 사람이 테스트에 공유된 자원을 사용하기 때문입니다. 모든 팀원들이 개인을 위한 테스트 S3를 구축하는 것도 비용적으로 좋지 않다고 생각합니다.

이를 해결해주는 것이 Localstack입니다.

Localstack이란

Localstack은 AWS 클라우드 서비스를 에뮬레이션을 통해 로컬 환경에 cloud와 같은 환경을 구축해주는 오픈소스입니다. Docker를 통해 쉽게 구축할 수 있습니다.

의존성

implementation "org.testcontainers:localstack:1.16.3"

LocalStack S3 빈 등록

우아한형제들 기술블로그 - S3 LocalstackTestcontainers 문서 Localstack 글을 참고하여, TestContainers를 통해 Localstack 환경을 구축했습니다.

@Configuration
@Profile({"local", "test"})
public class LocalStackConfig {

    private static final DockerImageName LOCAL_STACK_IMAGE = DockerImageName.parse("localstack/localstack");

    private final String bucketName;

    public LocalStackConfig(@Value("${cloud.aws.s3.bucket-name}") String bucketName) {
        this.bucketName = bucketName;
    }

    @Bean(initMethod = "start", destroyMethod = "stop")
    public LocalStackContainer localStackContainer() {
        return new LocalStackContainer(LOCAL_STACK_IMAGE)
                .withServices(S3);
    }

    @Bean
    public AmazonS3 amazonS3(LocalStackContainer localStackContainer) {
        AmazonS3 amazonS3 = AmazonS3ClientBuilder.standard()
                .withEndpointConfiguration(localStackContainer.getEndpointConfiguration(S3))
                .withCredentials(localStackContainer.getDefaultCredentialsProvider())
                .build();
        amazonS3.createBucket(bucketName);
        return amazonS3;
    }
}

test와 local 환경에서만 Localstack을 통한 S3를 하도록 Profile을 설정해두었습니다.

@Bean의 속성에 start와 stop은 컨테이너를 실행하고 종료하기 위해 지정해주었습다.

LocalStackContainer의 상위클래스인 GenericContainer에서 Docker를 통해 container를 실행하는 메서드가 startcontainer를 멈추는 메서드가 stop입니다. 따라서, 빈 생명주기와 container의 생명주기를 동일하게 해주었습니다.

위 Bean 설정을 하고 test를 실행하거나 local 환경에서 서버를 구동하면 아래처럼 testContainers와 localstack이 Docker에서 실행됩니다.

위처럼 Localstack을 통해 S3를 local, test 환경에서 사용할 수 있습니다.


참고자료

https://techblog.woowahan.com/2638/
https://localstack.cloud/

1개의 댓글

comment-user-thumbnail
2023년 8월 14일

신기하네요

답글 달기