우리 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 를 사용하였다.
코드를 직접 보면
@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;
}
바뀐 점은
ApplicationEventPublisher
를 주입 받았음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 를 사용하는 것은 그 의도와 맞지 않다고 생각하였다.