스프링 S3 Image 저장 트랜잭션과 맞추기

이진우·2024년 8월 28일
0

스프링 학습

목록 보기
37/41
post-thumbnail

발생한 상황

우리 cre8 서비스에서는

아래 사진과 같이

유저의 프로필을 수정시에 자기소개 글 등등과 함께 자신의 프로필 사진을 변경할 수 있다. 아래와 같이 말이다.

하지만 문득 이런 생각이 들었다.

서비스가 확장됨에 따라서 혹은 Member Entity 의 필드 조건들이 변경될 수 있지만 ,

관련 제약 사항 때문에 DB가 롤백 될 때 S3 에 이미 저장된 사진들은 어떻게 처리해야 하는가??

말로만 하면 어려우니 예시 상황을 들어보고자 한다.

@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class ProfileWithUserInfoEditRequestDto {

    @Schema(description = "사용자 닉네임")
    private String userNickName;

    @Schema(description = "이미지 데이터")
    private MultipartFile multipartFile;

    @Schema(description = "사용자의 유튜브 링크",example = "www.youtube.com")
    private String youtubeLink;

    @Schema(description = "사용자의 트위터 링크",example = "www.twiiter.com")
    private String twitterLink;

    @Schema(description = "사용자의 개인 링크",example = "www.personalLink.com")
    private String personalLink;

    @Schema(description = "사용자의 소개 html 문서 등등 ",example = "<h3> 저는 이런 사람입니다</h3> <li>짱짱한 사람</li>")
    private String personalStatement;

    @Builder
    public ProfileWithUserInfoEditRequestDto(String userNickName,
                                MultipartFile multipartFile,
                                String youtubeLink,
                                String twitterLink,
                                String personalLink,
                                 String personalStatement){
        this.youtubeLink = youtubeLink;
        this.twitterLink = twitterLink;
        this.personalLink = personalLink;
        this.personalStatement = personalStatement;
        this.multipartFile = multipartFile;
        this.userNickName = userNickName;
    }

}

위와 같은 EditDto 로 사용자의 정보를 수정할 때

userNickName 은 DB에서 Nullable이 false 임에도 불구하고

 @Column(length = 20,nullable = false,unique = true)
    private String nickName;

userNickName 에 대한 Empty 체크를 실수로 까먹었다고 가정하자.

그럼 아래와 같이 비즈니스 로직이 있는 서비스 코드에서

이미지 파일 저장을 통해서 accessUrl 을 얻어온 이후

update 를 시도하지만

nullable false 로 인해 실패하게 된다.

 @Transactional
    public void changeMemberProfile(final String loginId , final ProfileWithUserInfoEditRequestDto profileWithUserInfoEditRequestDto){

        Member member = getLoginMember(loginId);

        if(!member.getNickName().equals(profileWithUserInfoEditRequestDto.getUserNickName())&&
                memberRepository.existsByNickName(profileWithUserInfoEditRequestDto.getUserNickName())){

            throw new BadRequestException(ErrorCode.DUPLICATE_NICKNAME);
        }

        //multipart 파일이 비거나 null 인 경우 기존 memberURL 반환 , 그렇지 않으면 새로 생성 후 반환
        String accessUrl = getAccessUrl(profileWithUserInfoEditRequestDto.getMultipartFile(), member);

        

        MemberEditor memberEditor = MemberEditor.builder().youtubeLink(profileWithUserInfoEditRequestDto.getYoutubeLink())
                        .personalLink(profileWithUserInfoEditRequestDto.getPersonalLink())
                                .twitterLink(profileWithUserInfoEditRequestDto.getTwitterLink())
                                        .personalStatement(
                                                profileWithUserInfoEditRequestDto.getPersonalStatement())
                                                .nickName(
                                                        profileWithUserInfoEditRequestDto.getUserNickName())
                .accessUrl(accessUrl)
                .build();

        member.edit(memberEditor);

    }

    // dto 의 사진이 null 일 경우 기존의 url 반환 , 그렇지 않으면 새로 생성 후 저장
    private String getAccessUrl(MultipartFile multipartFile,Member member){

        if(checkInputMultiPartFileNull(multipartFile)){
            return member.getAccessUrl();
        }
        
        s3ImageService.deleteImage(member.getAccessUrl());

        return s3ImageService.saveImage(multipartFile,MEMBER_PROFILE_IMAGE,
                multipartFile.getOriginalFilename());
    }

    //받은 Multipart 값이 null  혹은 empty 인지 판단. 아니라면 false
    private boolean checkInputMultiPartFileNull(MultipartFile multipartFile){
        if(multipartFile==null || multipartFile.isEmpty()){
            return true;
        }

        return false;
    }

하지만 위와 같이 실패했음에도 불구하고

여전히 S3 에는 저장되어 있는 것이 문제이다.

이것이 큰 문제가 아니라고 생각할 수도 있고 , 배치 처리를 통해서도 이런 낭비되는 자원을 제거할 수 도 있어보이지만

S3 에서는 이미지 수정 시도 시 기존의 s3 에 저장된 이미지가 삭제 되기 때문에 이런 부분을 더욱 꼼꼼히 작성해야 겠다고 생각한 점 등등

이 문제를 예방해서 나쁠 것은 전혀 없다고 생각했기 때문에 이 부분을 수정하고자 하였다.

또한

이러한 일은 비단 유저 정보 수정에 일어나는 일 뿐만 아니라

다양한 서비스 예를 들어

구인구직 게시글 대표 사진

포트폴리오 대표 사진 등등

여러 서비스에서 일어날 수 있다는 점에서 짚고 넘어가고 싶었다.

해결방법에 대한 고민

변경감지를 사용하지 않기?

jpa 에서 insert 와 delete 는 save 메서드나 delete 메서드가 호출될 때는 바로바로 관련 쿼리가 생성되지만

변경 감지를 통한 수정은 다르다.

변경 감지란 영속성 컨텍스트에 엔티티를 보관할 때 초기 상태를 가지고 보관하고 있다가

트랜잭션 커밋 시점에 기존값과 비교하여 update 쿼리를 발생 시킨다.

트랜잭션 커밋 시점에 update 쿼리가 발생되므로

update 메서드 코드 내 에서 s3 image 저장 부분과 DB 저장의 순서 차이는 아무 상관이 없다.

만약 이 변경 감지를 기능을 끄고, DB 저장 이후 , s3 Image 저장을 수행하면 성공적으로 원하는 바를 달성할 수 있지만,

이는 jpa 에서 제공해주는 변경 감지를 이용하지 않기에

개발자가 수동으로 update 를 수행해주어야 한다는 부담감이 존재하기에 다른 방법을 생각했다.

@TransactionalEventListener 사용

결국에 트랜잭션이 커밋된 이후에 이미지 저장 및 삭제 관련된 코드를 수행하는 것이 맞다고 생각하였다 .

따라서 이에 적합한 @TransactionalEventListener 를 사용하였다.

코드를 직접 보면

@Component
@RequiredArgsConstructor
public class S3UploadEventListener {

    private final S3ImageService s3ImageService;

    //단건으로 이미지 저장하는 로직 중 예외 시 새롭게 저장 된 이미지 롤백
    @TransactionalEventListener(phase = TransactionPhase.AFTER_ROLLBACK)
    public void transactionalEventListenerAfterRollback(final S3UploadImageRollbackEvent s3UploadImageRollbackEvent) {

        s3ImageService.deleteImage(s3UploadImageRollbackEvent.getNewAccessImageUrl());
    }

    //여러 이미지 저장하는 로직 중 예외 시 새롭게 저장 된 이미지들 롤백
    @TransactionalEventListener(phase = TransactionPhase.AFTER_ROLLBACK)
    public void transactionalEventListenerAfterRollback(final S3UploadImageListRollbackEvent s3UploadImageListRollbackEvent) {

        s3UploadImageListRollbackEvent.getNewAccessImageUrlList().forEach(accessUrl->{
            s3ImageService.deleteImage(accessUrl);
        });
    }

    //단건으로 이미지 저장하는 로직 중 commit 시 그제서야 이미지 삭제
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void transactionalEventListenerAfterCommit(final S3UploadImageCommitEvent s3UploadImageCommitEvent) {

        s3ImageService.deleteImage(s3UploadImageCommitEvent.getOldAccessImageUrl());
    }

    //여러 건으로 이미지 저장하는 로직 중 commit 시 그제서야 이미지들 삭제
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void transactionalEventListenerAfterCommit(final S3UploadImageListCommitEvent s3UploadImageListCommitEvent) {

        s3UploadImageListCommitEvent.getDeleteAccessImageUrlList().forEach(accessUrl->{
            s3ImageService.deleteImage(accessUrl);
        });
    }

}

위와 같이 EventListner 클래스 안에 @TransactionalEventListner 를 사용하여 트랜잭션이 커밋된 이후, 혹은 롤백된 이후 수행할 로직을 담을 수 있다.

이를 응용하여

기존 코드를 아래와 같이 수정한다.

    private final ApplicationEventPublisher eventPublisher;
    
    @Transactional
    public void changeMemberProfile(final String loginId , final ProfileWithUserInfoEditRequestDto profileWithUserInfoEditRequestDto){

        Member member = getLoginMember(loginId);

        if(!member.getNickName().equals(profileWithUserInfoEditRequestDto.getUserNickName())&&
                memberRepository.existsByNickName(profileWithUserInfoEditRequestDto.getUserNickName())){

            throw new BadRequestException(ErrorCode.DUPLICATE_NICKNAME);
        }

        //multipart 파일이 비거나 null 인 경우 기존 memberURL 반환 , 그렇지 않으면 새로 생성 후 반환
        String accessUrl = getAccessUrlWithSettingEvent(profileWithUserInfoEditRequestDto.getMultipartFile(), member);

        MemberEditor memberEditor = MemberEditor.builder().youtubeLink(profileWithUserInfoEditRequestDto.getYoutubeLink())
                        .personalLink(profileWithUserInfoEditRequestDto.getPersonalLink())
                                .twitterLink(profileWithUserInfoEditRequestDto.getTwitterLink())
                                        .personalStatement(
                                                profileWithUserInfoEditRequestDto.getPersonalStatement())
                                                .nickName(
                                                        profileWithUserInfoEditRequestDto.getUserNickName())
                .accessUrl(accessUrl)
                .build();

        member.edit(memberEditor);

    }

    // dto 의 사진이 null 일 경우 기존의 url 반환 , 그렇지 않으면 새로 생성 후 저장
    private String getAccessUrlWithSettingEvent(MultipartFile multipartFile,Member member){

        if(checkInputMultiPartFileNull(multipartFile)){
            return member.getAccessUrl();
        }

        String newImageAccessUrl = s3ImageService.saveImage(multipartFile,MEMBER_PROFILE_IMAGE,
                multipartFile.getOriginalFilename());

        //성공적으로 커밋 시 -> S3 에서 기존 URL 삭제.
        eventPublisher.publishEvent(S3UploadImageCommitEvent.builder().oldAccessImageUrl(member.getAccessUrl()).build());

        //롤백 시  -> S3 에서 새로 저장한 url 삭제
        eventPublisher.publishEvent(S3UploadImageRollbackEvent.builder().newAccessImageUrl(newImageAccessUrl).build());

        return newImageAccessUrl;
    }

    //받은 Multipart 값이 null  혹은 empty 인지 판단. 아니라면 false
    private boolean checkInputMultiPartFileNull(MultipartFile multipartFile){
        if(multipartFile==null || multipartFile.isEmpty()){
            return true;
        }

        return false;
    }

바뀐 점은

  • Event 를 발생시키기 위해서 ApplicationEventPublisher 를 주입 받았음
  • 이미지를 s3에 저장하는 과정에서 publishEvent 를 통해서 이벤트를 발행함
  • eventPublisher.publishEvent(S3UploadImageCommitEvent.builder().oldAccessImageUrl(member.getAccessUrl()).build()); : 처음 이벤트가 성공적으로 commit 시에 발생하는 기존 accessURL 을 s3 에서 삭제하는 이벤트
  • 문제가 발생하여 롤백 시에
    eventPublisher.publishEvent(S3UploadImageRollbackEvent.builder().newAccessImageUrl(newImageAccessUrl).build()); : 를 통해서 RollBack 시 발생하는 새롭게 저장한 accessURL 을 s3 에서 삭제하는 이벤트

아래는 각각의 이벤트에 값을 전달하기 위한 클래스들이다 .

@Getter
public class S3UploadImageCommitEvent {

    private String oldAccessImageUrl;
    @Builder
    public S3UploadImageCommitEvent(final String oldAccessImageUrl) {
        this.oldAccessImageUrl = oldAccessImageUrl;
    }
}

@Getter
public class S3UploadImageRollbackEvent {

    private String newAccessImageUrl;
    @Builder
    public S3UploadImageRollbackEvent(final String newAccessImageUrl) {
        this.newAccessImageUrl = newAccessImageUrl;
    }

}

결국 이제는 로직이 이렇게 바뀐 것이다.

일단 새로운 이미지 저장 및 access URL 생성

만약 트랜잭션이 성공적으로 커밋된다면 ?? => 기존의 url 삭제
만약 트랜잭션이 예외 상황에 직면하여 롤백된다면 ?? => 새롭게 저장한 URL 삭제

테스트

위에서 사용한 똑같은 방식으로 테스트를 진행한다.

userNickName 으로 빈값으로 두고 보면

위와 같이 오류가 발생하지만

이전에는 S3 에 이미지가 저장되는 것과는 달리

이전의 Member 이미지가 그대로 유지 되는 것을 볼 수 있다.

고민했던 점

다양한 부분에 s3 이미지가 사용되면서
트랜잭션이 실패하였을 때 새롭게 저장된 url 을 삭제하는 부분을

스프링 AOP 를 활용하는 것이 어떨까?? 라는 생각을 하였다.

스프링 AOP 는 핵심 비즈니스 로직 이 아닌 여러 비즈니스 로직에서 공통적으로 사용될 수 있는 로깅, 트랜잭션, 알림 서비스 등등에서 사용되지만

나의 경우 S3 의 이미지 저장과 비즈니스 로직 자체가 밀접하게 연관이 되어 있으므로
AOP 를 사용하는 것은 그 의도와 맞지 않다고 생각하였다.

profile
기록을 통해 실력을 쌓아가자

0개의 댓글