S3 고아 객체 제거하기
문제 상황
현재 프로젝트에선 S3 서버에 이미지를 업로드하고 식별자를 DB에 저장하고 있다.
코드를 간략히 소개하면 다음과 같다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// Service.java
@Transactional
public void invoke(Request request, List<MultipartFile> files) {
Entity entity = toEntity(request, toImageList(files)); // (1)
Long id = repository.save(quest).getId();
// ... (2) 추가 로직 수행 - 예외가 빈번히 발생
}
private List<Image> toImageList(List<MultipartFile> files) {
return files.stream().map((file) -> {
String identifier = generator.generate(file.getOriginalFilename()); // 파일의 이름에 UUID를 붙여 고유하게 만드는 로직
return images.upload(identifier, file)); // (3) S3에 업로드
}).toList();
}
private Entity toEntity(Request request, List<Image> images) {
// ... Dto를 Entity로 변환하는 코드
}
(3)의 images
는 AmazonS3
와 서비스 사이에 넣은 추상화 계층의 객체로, 실제 S3에 파일을 업로드하는 로직을 갖고 있다.
실제 S3에 업로드 하는 로직은 (1)의 toimageList()
를 호출하며 시작된다. 만약 S3에 이미지를 업로드 한 후 그 이후의 코드에서 예외가 발생하면 어떻게 될까? (2)에 모두 작성하진 않았으나 검증을 비롯한 추가적인 로직들이 존재하며, 예외가 발생할 확률이 크다.
예외가 발생하면 트랜잭션이 롤백되며 DB에 변경사항은 반영되지 않을 것이다. 하지만 S3에 이미 업로드된 파일을 롤백되지 않는다. 이러한 S3 객체를 고아 객체라고 부르겠다.
개발 서버를 띄우고 여러 예외 상황을 테스트 하던 중 S3 버킷에 고아 객체가 점점 드러차는 모습을 보며 문제가 있음을 느꼈다. 더 많은 사용자가 사용한다면 고아 객체는 점점 늘어날 것이고 S3 버킷은 비효율적이게 될 것이다.
고찰
위 문제를 해결하기 위해 여러 고민들을 해보았으나, 뭔가 확 와닿는 해결책은 없었다. 따라서 여러 고민 과정들을 적어보겠다.
다시 삭제?
이런 문제를 어떻게 해결할 수 있을까? 아마 바로 떠오르는 방식은 예외 발생 시 이미지를 삭제하는 것이다.
코드로 표현하면 아래와 같을 것이다.
1
2
3
4
5
6
7
8
9
10
@Transactional
public void invoke(Request request, List<MultipartFile> files) {
try {
final Entity entity = toEntity(request, toImageList(files));
final Long id = repository.save(quest).getId();
// ...
} catch (Exception e) {
images.delete(entity.getImages());
}
}
낙관적으로 생각하면 이렇게만 해도 큰 문제는 없을 것 같다. 하지만 삭제 로직이 실패한다면? 로그를 남겨 나중에 재시도할까? 로그는 어떻게 남기고 로그 남기는 작업마저 실패하면 어쩌지? 재시도는 또 얼마나 실행할 것인가? 끊임없는 고민들이 떠오른다.
또 추가적인 문제로, S3 서버 상에 두 번의 요청이 간다. 이미지를 업로드 하는 요청, 삭제하는 요청이 필요하기 때문이다. S3 서버는 요청의 수를 기준으로 요금을 측정하고, 무엇보다 네트워크를 거치기 때문에 최소한의 요청만 보내고 싶었다. (네트워크는 디스크보다도 느리기 때문이다)
코드의 가독성도 좋지 않다. 트랜잭션처럼 AOP 기술을 이용하여 try-catch
부분을 추상화하고 싶어도 사진의 정보가 필요하므로 쉽지 않았다.
코드 순서 변경
두번째로 떠오른 방법은 코드의 순서를 변경하는 것이다. OS 수업을 들을 시절, 데드락 해결 방법 중 하나인 자원을 점유하는 순서를 변경한다에서 떠오른 방법이다. 전혀 연관없는 문제지만 순서를 변경함으로써 문제를 해결할 수 있다라는 지식에서 떠올랐다.
위의 코드는 identifier
를 만드는 로직과 파일을 업로드하는 로직이 함께 존재한다. 이 두 로직을 분리하여, 파일을 업로드하는 로직을 invoke()
의 맨 마지막으로 보내는 것이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
// Service.java
@Transactional
public void invoke(Request request, List<MultipartFile> files) {
Entity entity = toEntity(request, toImageList(files));
Long id = repository.save(quest).getId();
// ... (2) 추가 로직 수행 - 예외가 빈번히 발생
images.upload(entity.getImages()); // 맨 마지막에 수행
}
private List<Image> toImageList(List<MultipartFile> files) {
return files.stream().map((file) -> new Image(generator.generate(file.getOriginalFilename()))).toList();
}
이렇게 하면 어떤 장점이 있을까? (2)에서 검증 로직을 수행하며 예외가 발생하여 트랜잭선이 롤백되더라도, S3엔 아무런 요청이 간 적이 없다. 만약 업로드 와중에 예외가 발생하더라도 트랜잭션은 롤백될 것이다.
이 방법은 문제가 없을까? 크게 두 가지 문제가 있다.
- 더티-체킹의 업데이트 쿼리 위 코드는
save()
상황이기에 큰 문제가 없으나, 만약 업데이트 쿼리라면 어떨까? Jpa는 엔티티를 변경하면flush()
호출 시 (일반적으로 해당 트랜잭션이 끝날 때) 더티 체킹을 통해 업데이트 쿼리를 발생시킨다. 즉 코드상으론 S3 업로드를 마지막에 위치시켜도 실제론 DB 쿼리가 더 나중에 발생한다. - 순서를 바꾸기 어려운 경우 라면? 위 코드는 쉽게 코드 순서를 바꿀 수 있었으나, 순서를 바꾸기 어려운 경우도 존재할 수 있다. (예시는 떠오르지 않으나 그런 상황이 있을 수도 있다)
이벤트를 통한 처리
2번의 문제는 이벤트를 통해 처리할 수 있다고 생각했다. 이벤트는 @TransactionalEventListener
를 통해 실행 시점을 제어할 수 있고 이 기능을 이용해보자.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// Service.java
@Transactional
public void invoke(Request request, List<MultipartFile> files) {
final Entity entity = toEntity(request, toImageList(files));
final Long id = repository.save(quest).getId();
// ...
}
private List<Image> toImageList(List<MultipartFile> files) {
return files.stream().map((file) -> {
publisher.publish(new ImageSavedEvent(...)); // (1) - 이벤트 발행
return new Image(generator.generate(file.getOriginalFilename());
})
.toList();
}
// EventListsener.java
@TransactionalEventListener(pase = BEFORE_COMMIT)
public void listenImageSavedEvent(final ImageSavedEvent event) {
images.upload(event.identifier(), event.inputStream());
}
위 코드와 같이 로직 중간에 이벤트를 발행하더라도 @TransactionalEventListener(phase = BEFORE_COMMIT)
에 의하여, 실제 이미지 업로드는 트랜잭션이 커밋되기 직전에 발생한다.
만약 트랜잭션이 롤백 된다면 해당 이벤트는 처리되지 않는다. BEFORE_COMMIT
이기 때문에 커밋 직전에만 수행되기 때문이다. 즉, 이미지는 롤백 시 업로드 시도조차 안한다.
만약 업로드가 실패 하면 트랜잭션도 롤백된다. 커밋 직전은 결국 커밋이 되지 않은 상태기 때문에 RuntimeException
발생 시 트랜잭션도 롤백된다.
일단 프로젝트엔 이 방법을 적용하였다. 이미지를 업로드한 후 삭제하는 방식은 위에서 언급한 문제들을 해결할 방법이 떠오르지 않았기 때문이다.
쿼리 순서 문제
그런데 위에서 언급한 1번의 문제가 남아있다. 실제로 디버깅을 해보면 서비스의 로직 수행 -> BEFORE_COMMIT 이벤트 수행 -> 더티 체킹을 통한 업데이트 쿼리 -> 커밋
의 순서로 실행되었다.
물론 entityManager.flush()
를 통해 강제로 업데이트 쿼리 발생 지점을 땡길 수 있지만 좋은 방법은 아닌 것 같았다.
사실 이 문제 자체를 없애기 위한 예방법은 찾지 못했다. 예방법이 떠오르지 않을 땐 후속 대처에 대해 고민해보자.
프로젝트의 특성
이 지점에서 프로젝트를 한번 쭉 훑어보았다. 도메인에 대한 검증을 세세하게 진행하고 있기 때문에 사실상 업데이트 쿼리에서 논리적 오류가 발생할 일은 적었다. (예를 들어, unique
컬럼을 중복되도록 업데이트 하는 오류)
즉, DB와의 커넥션이나 DB 서버 내부의 문제가 아닌 이상 업데이트 쿼리 자체가 실패할 확률은 매우 적다고 판단했다. 또, 애초에 파일 업로드와 업데이트 쿼리가 함께 발생할 상황이 많진 않았다. (대부분 INSERT
쿼리가 함께 발생했다.)
일어날 확률이 매우 적다. 발생했을 때 파급력은 어떨까? DB는 어차피 트랜잭션 단위로 롤백되기 때문에 데이터의 정합성이 깨지진 않는다. 영향을 받는건 S3뿐인데, 사실 고아 객체는 S3의 용량을 차지할 뿐 서비스를 무너트릴 장애는 아니다.
정리해보면,
- 이벤트를 통해 실행 순서를 바꾼 것만으로도 대부분의 고아 객체는 사라진다.
- 여전히 고아 객체가 생길 가능성은 남아있으나, 매우 낮은 확률이다.
- 매우 낮은 확률로 고아 객체가 발생하더라도 서비스에 큰 영향을 미치진 않는다.
따라서 서비스에서 즉각적인 방어 코드를 짜기보단, 추후에 스프링 배치와 같은 프레임워크를 이용해 처리하기로 결정했다. 일정 주기로 고아 객체를 찾아 한번에 제거하는 방식을 생각했다. 스프링 배치는 학습해본 적이 없어 나중에 기회가 되면 다른 글로 적겠다.
다만 1번은 추론을 통해 나온 전제로 적용 후 모니터링을 통해 실제로 확률이 줄었는지 확인해보아야 한다. 프로젝트에 적용 후 약 2주간 살펴보았는데 현재까진 고아 객체가 하나도 생성되지 않았다. 즉, 순서만 바꾸는 것이 실제로 큰 효과가 있던 것이다.
마치며
스프링 이벤트의 특성을 이용해 실행 순서를 변경하는 로직을 짜보았다. 이벤트는 패키지간 결합을 덜어내기 위해서만 사용해보았는데, 실행 순서때문에 사용한 건 이번이 처음이었다.
추가로 발생할 수 있는 문제점이나 해결책들에 대해서 공유해주면 감사하겠다.