-
AWS S3 이미지 저장 및 삭제와 DB로직 트랜잭션 분리Project 2023. 7. 27. 03:11
트랜잭션 분리
최근에 Real MySQL 이라는 책을 읽으면서 트랜잭션에 대한 내용을 읽었는데, 많은 지식들을 얻을 수 있었지만 그중 가장 크게 와닿았던 내용이 바로 트랜잭션의 범위를 최소로 해야 한다는 내용이었습니다.
특히 외부 API를 호출하거나 이메일을 보내는 등의 서비스를 하나의 트랜잭션으로 묶게 된다면 해당 트랜잭션을 수행하는 커넥션을 오래 갖게되고 이로 인해 데드락 등의 문제가 발생할 가능성이 올라가기 때문에 가능하면 트랜잭션의 범위를 좁히는 것이 좋다고 읽었고, 마침 프로젝트에 AWS S3에 이미지를 업로드 하거나 삭제하는 기능이 존재했었기 때문에 이번 기회에 트랜잭션 범위를 줄이도록 코드를 변경했습니다.
수정해야 하는 코드는 이미지를 업로드 하는 코드와 이미지를 삭제하는 코드였습니다.
해당 코드는 각각 아래와 같았습니다.
// 이미지 업로드 @Transactional public S3ImgInfoDto uploadGalleryImg(Long usersId, Long boardsId, MultipartFile file) { Boards boards = boardsRepository.findByIdAndUsersIdNotDeleted(boardsId, usersId) .orElseThrow(() -> new IllegalArgumentException("해당하는 결혼 게시판이 없습니다.")); String galleryImgUrl = s3Repository.uploadObject(file, usersId); GalleryImg galleryImg = new GalleryImg(boards, galleryImgUrl); GalleryImg savedGalleryImg = galleryImgRepository.save(galleryImg); return S3ImgInfoDto.from(savedGalleryImg); } // 이미지 삭제 @Transactional public void deleteGalleryImg(Long galleryImgId) { GalleryImg galleryImg = galleryImgRepository.findById(galleryImgId) .orElseThrow(() -> new IllegalArgumentException("해당하는 사진이 없습니다.")); String galleryImgUrl = galleryImg.getGalleryImgUrl(); s3Repository.deleteObject(galleryImgUrl); galleryImgRepository.delete(galleryImg); }
위 코드의 경우 각 메서드가 시작하면서 트랜잭션이 시작하기 때문에 S3 저장소에 이미지를 저장하거나 삭제하는 시기가 모두 트랜잭션에 포함되기 때문에 그만큼 DB 커넥션을 많이 갖고 있는 문제가 있습니다.
해결 방법
1) 이미지 업로드
[ 로직 순서 변경 ]
기존 코드를 보면 이미 DB에서 먼저 특정 게시판이 있는지 확인을 하는 코드가 있었기 때문에 S3에 이미지를 업로드 하고 트랜잭션을 시작하고 DB에 접근하는 식으로 순서를 변경을 로직상 할 수 없었습니다.
[ 트랜잭션의 시작을 순수하게 save 하는 부분에서 시작 ]
가장 처음에 게시판을 select 하는 경우 사실 단순 조회이기 때문에 반드시 트랜잭션을 사용할 이유가 없다고 판단했습니다. 따라서 기존 순서를 유지하고 S3 저장소에 이미지 저장 후 트랜잭션을 시작하도록 변경했습니다.
따라서 트랜잭션을 시작하며 이미지를 저장하는 Facade 클래스를 하나 만들어서 해당 클래스에서 저장 로직을 진행하도록 구현했습니다.
Facade 패턴이 여러 기능을 하나의 클래스에 모아둔다 정도로 이해하고 있었기 때문에 해당 기능을 Facade로 만들어야 하는지는 정확하지 않습니다. 찾아봤을 때는 좀 더 복잡한 로직을 처리하는 Facade 크래스를 만들었는데, 현재 상황에서는 단순히 트랜잭션을 시작하고 에러를 내보내는 역할 정도가 전부이기 때문에 Facade 패턴이 맞는지는 추가 확인이 필요합니다.
아래 코드는 Facade 클래스에 구현한 save 메서드입니다.
@Transactional public GalleryImg save(GalleryImg galleryImg) { printConnectionStatus(); try { return galleryImgRepository.save(galleryImg); } catch (Exception e) { throw new RuntimeException("갤러리 이미지 저장 중 에러가 발생했습니다.", e); } }
메서드에 @Transactional 어노테이션을 작성하여 해당 메서드가 실행 시 트랜잭션을 시작하고 해당 메서드가 끝나면 트랜잭션이 끝나도록 구현했습니다.
스프링 트랜잭션 커밋, 롤백 작동 원리
스프링에서 예외가 발생할 때 모든 예외에서 롤백을 하는 것이 아니라 체크 예외인지, 언체크 예외인지에 따라 작동 방식이 다릅니다.
기본적으로 체크 예외의 경우 그대로 커밋하고 언체크 예외의 경우에만 롤백을 진행하도록 작동합니다.
따라서 필자는 DB의 저장 시 어떤 에러가 발생하던 RuntimeException으로 에러를 발생시키도록 하여 해당 로직에서 에러가 발생하면 무조건 런타임 에러이기 때문에 롤백을 진행하도록 구현했습니다.
참고로 printConnectionStatus() 메서드의 경우 단순히 커넥션을 몇 개 잡고있는지 현재 상태를 확인하기 위해 테스트 코드에만 로그를 찍도록 구현한 메서드입니다.
[ DB 저장 실패 이후 후처리 ]
가장 고민했던 부분이 DB에 저장하지 못하고 롤백이 된 후 S3에 이미 저장된 이미지를 어떻게 삭제할 것인지에 대한 고민이었습니다.
1. 처음 생각한 방법은 실패에 대한 이벤트를 발생시키고 이벤트를 처리하는 부분에서 잘못 저장된 이미지를 삭제하도록 구현하는 방법이었습니다.
하지만 해당 방법은 이벤트에 발생과 해당 이벤트를 처리해야 하는 부분을 추가로 다른 곳에 구현해야 하는 부분 때문에 아직 초기 프로젝트로는 적합하지 않다고 생각했습니다.
2. 다음으로 이벤트를 발생시키기 보다는 캐시에 저장해서 스케줄러를 통해 주기적으로 삭제하는 방법도 생각했습니다.
3. 마지막으로 DB 저장에서 실패하면 런타임 에러를 던지기 때문에 이 부분을 catch로 잡아서 바로 해당 이미지를 S3 저장소에서 삭제하는 방법입니다.
결국 최종적으로 2번과 3번의 방법을 전부 활용했습니다. 3번만 사용할 경우 S3에 저장된 이미지를 삭제할 때 만약 또 네트워크 에러가 발생한다면 결국 이를 언젠가는 다시 삭제해야 하기 때문에 이럴 경우 캐시에 저장한 후 스케줄러를 통해 다시 삭제하도록 설정하였기 때문에 2,3번 방법을 모두 사용하여 해결했습니다.
아래는 최종 수정한 코드입니다.
public S3ImgInfoDto uploadGalleryImg(Long usersId, Long boardsId, MultipartFile file) { Boards boards = boardsRepository.findByIdAndUsersIdNotDeleted(boardsId, usersId) .orElseThrow(() -> new IllegalArgumentException("해당하는 결혼 게시판이 없습니다.")); String galleryImgUrl = s3Repository.uploadObject(file, usersId); printConnectionStatus(); GalleryImg galleryImg = new GalleryImg(boards, galleryImgUrl); try { GalleryImg savedGalleryImg = galleryImgRepositoryFacade.save(galleryImg); return S3ImgInfoDto.from(savedGalleryImg); } catch (RuntimeException e) { s3Repository.deleteObject(galleryImgUrl); throw new NotSaveGalleryImgException(e.getMessage(), e); } }
s3Repository.deleteObject() 메서드에 관한 코드입니다.
@Override public void deleteObject(String galleryImgUrl) { String[] splitUrl = galleryImgUrl.split("/"); String key = splitUrl[splitUrl.length - 1]; try { amazonS3.deleteObject(BUCKET_NAME, key); } catch (Exception e) { s3ImgUrlsNeedDelete.add(key); throw new S3ObjectException("이미지 삭제 중 문제가 발생했습니다.", e); } }
위 코드에서 이미지 삭제를 실패할 경우 s3ImgUrlsneedDelete 라는 리스트에 해당 key 값을 저장해서 이후에 스케줄러를 통해 하루에 한번 해당 리스트에 저장된 모든 이미지를 삭제하도록 구현했습니다.
아래 코드에서 확인할 수 있듯이 하루에 한번 씩 삭제가 필요한 목록들을 S3 저장소에서 삭제하도록 구현했습니다.
@Scheduled(initialDelay = 0, fixedDelay = 1000 * 60 * 60 * 24) public void deleteNotUsingS3Img() { log.info("deleteNotUsingS3Img batch start at = {}", LocalDateTime.now()); log.info("s3ImgUrlsNeedDelete = {}", s3ImgUrlsNeedDelete); s3ImgUrlsNeedDelete.forEach(key -> amazonS3.deleteObject(BUCKET_NAME, key)); s3ImgUrlsNeedDelete.clear(); }
따라서 위 과정들을 통해 이미지를 저장할 때 트랜잭션을 분리할 수 있었고 DB에 저장하다 문제가 발생해도 이미 S3에 저장된 이미지들까지 바로 삭제하거나 적어도 하루가 지난 뒤에는 삭제할 수 있도록 구현하였습니다.
2) 이미지 삭제
이미지 삭제는 위에서 이미지를 저장하면서 저장 실패 시 이미지를 삭제하는 부분을 미리 설명했기 때문에 간단하게 얘기하고 마무리 하겠습니다.
먼저 기존 트랜잭션을 분리하지 않았던 코드를 다시 보겠습니다.
// 이미지 삭제 @Transactional public void deleteGalleryImg(Long galleryImgId) { GalleryImg galleryImg = galleryImgRepository.findById(galleryImgId) .orElseThrow(() -> new IllegalArgumentException("해당하는 사진이 없습니다.")); String galleryImgUrl = galleryImg.getGalleryImgUrl(); s3Repository.deleteObject(galleryImgUrl); galleryImgRepository.delete(galleryImg); }
위 코드의 순서를 살펴보면 먼저 해당하는 이미지를 DB에서 찾고 S3 저장소에서 해당 이미지를 지운 후 DB에 있는 이미지를 지우는 순서로 되어 있는 것을 알 수 있습니다.
위 로직도 먼저 select 하는 부분에 트랜잭션을 시작하지 않고 실제 DB에 삭제하는 부분에서 트랜잭션을 시작해서 처리하는 방법도 있었지만 그럴경우 한 가지 문제가 발생했습니다.
JPA에서 delete 메서드의 경우 entity를 먼저 조회 한 후 삭제를 하는 방식으로 동작하였는데 이때 처음 select와 이후 delete 메서드 상에서 동작하는 select가 서로 다른 트랜잭션으로 분리되어 있기 때문에 영속성 컨텍스트를 이용하지 못하고 동일한 select 쿼리가 2번 발생하는 문제가 있었습니다.
따라서 DB에서 먼저 저장한 후 S3 저장소에 있는 이미지를 삭제하는 방법으로 로직 순서를 변경하기로 했습니다.
아래 코드는 DB에서 이미지를 먼저 삭제해주는 Facade 클래스의 delete 메서드입니다.
@Transactional public String deleteById(Long galleryImgId) { printConnectionStatus(); try { GalleryImg galleryImg = galleryImgRepository.findById(galleryImgId) .orElseThrow(() -> new NoSuchElementException("해당하는 사진이 없습니다.")); galleryImgRepository.delete(galleryImg); return galleryImg.getGalleryImgUrl(); } catch (NoSuchElementException e) { throw e; } catch (Exception e) { throw new NotDeleteGalleryImgException("갤러리 이미지 삭제 중 에러가 발생했습니다", e); } }
위에서 catch를 통해 다시 서로 다른 에러를 발생시킨 이유는 각각 서로 다른 원인의 에러이기 때문에 exceptionHandler에서 별개로 처리하기 위함입니다.
Facade 클래스와 로직을 변경한 이미지 삭제 코드를 살펴보겠습니다.
public void deleteGalleryImg(Long galleryImgId) { String deletedGalleryImgUrl = galleryImgRepositoryFacade.deleteById(galleryImgId); printConnectionStatus(); s3Repository.deleteObject(deletedGalleryImgUrl); }
이번에는 따로 에러를 catch로 잡을 필요가 없었습니다. 이유는 어짜피 DB에서 삭제할 때 에러가 발생하면 이후 로직인 S3 저장소에서 이미지를 삭제하는 로직을 진행하지 않기 때문에 catch 문을 통해 에러를 잡을 필요 없이 깔끔하게 트랜잭션을 분리할 수 있었습니다.
s3Repository.deleteObject() 메서드에 대한 설명은 앞에서 이미지 저장 부분에 에러 처리에서 설명 했기 때문에 여기서는 따로 설명하지 않겠습니다. 간단하게 얘기하면 DB에서 삭제를 성공하고 S3 저장소에서 이미지 저장 실패 시 캐시에 해당 이미지를 저장하고 스케줄러로 추후 삭제하도록 진행합니다.
마무리
지금까지 트랜잭션을 최소화 한 이유와 어떤 방법을 통해 최소화 시켰는지에 대한 과정이었습니다.
개인적으로 생각했을 때 이미지를 S3에 저장한 후 DB에 저장할 때 에러가 발생한 경우 위에서는 다시 S3에 이미지를 삭제하도록 구현했는데 이는 api 응답 시간이 느려지게 되지 않을까라는 생각을 좀 하고 있습니다.
하지만 아직 프로젝트 초기이기 때문에 이를 처리하기 위해 이벤트를 발행하고 또 해당 이벤트를 받아서 S3에 있는 이미지를 삭제하는 서비스를 추가 개발하기에는 너무 과하다는 생각을 했습니다.
만약 서비스를 오픈하고 많은 사람들이 사용하게 된다면 지금의 방식보다는 이벤트 큐에 해당 메시지를 발생하고 다른 어플리케이션에서 메시지를 받아서 삭제하는 방법을 이용하는게 전반적인 api 응답 속도가 향상할 것이라고 생각하기 때문에 그런 방법이 더 좋아보이고 지금의 방법은 현재 상황에 맞도록 진행한 방법이니 참고만 하면 좋을 것 같습니다.
이번에 트랜잭션을 분리하며 참고했던 블로그입니다.
Spring - Service Layer에서 Storage를 다룰 때 트랜잭션 처리하기!(+ with Hibernate, ...)
게시글과 같은 엔터티를 등록할 때 파일을 추가로 업로드하는 경우가 많다. (조회를 할 때 파일도 추가로 조회해야 하는 경우도 있다.) 다음의 예시를 보자. @Service @RequiredArgsConstructor public class Me
jaehoney.tistory.com
S3 Transaction 처리 방법과 LocalStack을 통한 S3 테스트
S3 Transaction 처리 방법과 LocalStack을 통한 S3 테스트
velog.io
'Project' 카테고리의 다른 글
Github Actions CI 구성 (0) 2023.08.22 mono-repo / multi-module 프로젝트에서 spring 자동 설정 이용하기 (0) 2023.08.22 사이드 프로젝트 백엔드 TB jenkins CI/CD 구축 (0) 2023.03.03