프로젝트를 진행하던 중, 외부 저장소 ( S3 ) 를 변경해야 하는 상황이 발생하였고 변경까지 오랜 시간이 소요되는 것이 아니었기에 서버를 실행 시켜둔 상태로 기존 S3 를 종료하고 새로운 S3를 반영하던 중, 회원 등록 API가 호출되며 사진 데이터가 유실되는 문제가 발생하였다.
모든 프로젝트가 그러하겠지만, 해당 프로젝트는 실제 운영 중인 매장에서 사용하기 위해 개발 중인 만큼 데이터가 유실 되지 않는 것이 중요한 포인트였기에 대안을 생각하기로 하였다.
현재 회원 등록 API의 요청 값은, 이름/성별/나이 그리고 이미지 URL 의 Json 데이터를 담은 req와 이미지 파일을 담은
MultipartFile 형식의 file을 함께 전달 받고 있다.
public ApiResult<Void> addCustomer(
@Valid @RequestPart AddCustomerDto.Request req,
@RequestPart(value = "file", required = false) MultipartFile file
){}
처음 떠오른 방법은 크게 세 가지였다.
첫 번째는 서브 외부 저장소를 추가적으로 연결하는 방안이었다.
서로 다른 외부 저장소를 연결하는 것으로 한 쪽에 문제가 발생해도 정상 작동할 수 있으며 서버에서 추가적인 자원을 사용하지 않아도 되는 다는 장점이 있었으나 관리해야하는 저장소가 늘어난다는 점과 외부 저장소에 의존해서 발생하는 근본적인 문제를 해결할 수 없다는 단점이 존재했다.
두 번째는 큐 혹은 서버 내부에 고객 등록 API 의 요청 자체를 저장해두는 방안이었다.
외부 저장소에 업로드가 실패해도 안전하고 확실하게 데이터를 저장해두고 상황에 맞춰 자유롭게 꺼내 사용할 수 있기에 데이터의 유실 방지에는 적절할 수 있으나, 외부 저장소가 복구 되기 전까지 회원 등록 자체가 불가능하게 된다는 치명적인 문제가 존재했다.
그리고 결정한 최종 방안은 업로드에 실패한 이미지 데이터만 저장해두는 방안이었다.
회원 등록 중 이미지 파일의 업로드 실패 시 이미지 URL 컬럼에는 NULL 혹은 임의의 값을 저장, 실패한 이미지 데이터를 따로 저장함으로 외부 저장소의 동작 여부와 관계 없이 고객 등록 API 를 사용할 수 있으며 데이터도 유실 되지 않을 수 있었다.
이미지 데이터 자체를 저장하게 되어 서버에 많은 용량 부담이 있을 수 있다는 문제가 존재하지만, 기본적으로 외부 저장소에 문제가 발생했을 경우만 저장하기에 사용 횟수 자체가 많지 않을 거라 예상 되었고, 이미지 파일의 품질이 중요한 프로젝트는 아니었기에 압축을 활용해 용량을 줄이는 방법 또한 고려할 수 있었다.
이러한 이미지 데이터는 서버 내부에 파일로 저장하는 방법과 DB 내부에 저장하는 방법이 존재하였고,
현재는 단일 서버로 동작하지만 이후 다중 서버로 동작하게 되면 데이터가 각각의 서버에 나누어 저장되어 관리가 어려울 것같다는 점에서 최종적으로 DB에 byte[] 형식으로 저장하는 것으로 결정하였다.
우선 업로드에 실패한 이미지 데이터와 해당 이미지의 회원 정보를 저장하는 엔티티를 생성하고,
@Entity
@Getter
@NoArgsConstructor
public class CustomerPhoto {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@OneToOne
@JoinColumn(unique = true)
private Customer customer;
@Lob
private byte[] data;
@Builder
public CustomerPhoto(Customer customer, byte[] data) {
this.customer = customer;
this.data = data;
}
public void updateData(byte[] data) {
this.data = data;
}
}
다음으로는 외부 저장소가 존재하지 않을 때, 위에서 생성한 엔티티를 저장하도록 하였다.
회원 등록 뿐 아닌 회원 수정 API 를 통해 하나의 회원에게 여러 개의 이미지 파일이 등록될 수도 있기에 회원 컬럼은 유니크 키로 지정하고 if문을 통해 이미 존재하는 회원의 데이터의 경우 이미지 데이터를 덮어씌우는 방식으로 저장하도록 하였다.
public void saveTempImage(Customer customer, byte[] file) {
CustomerPhoto customerPhoto = customerPhotoReader.findByCustomer(customer);
if (customerPhoto == null) {
customerPhoto = CustomerPhoto.builder()
.customer(customer)
.data(file)
.build();
} else {
customerPhoto.updateData(file);
}
customerPhotoCreator.save(customerPhoto);
}
이제 이렇게 저장된 파일을 외부 저장소가 정상 동작하게 되면 업로드하고 회원 정보를 업데이트 해주면 되는데, 이러한 과정을 일일이 처리하기는 번거로울 것같아 스케쥴러를 이용하기로 했다.
매일 3시, 임시 저장된 사진이 존재할 때 파일 업로드, 회원 정보 업데이트 후 사진 데이터를 삭제하도록 하였다.
// 매일 3시 임시 저장된 사진 업로드
@Scheduled(cron = "0 0 3 * * ?")
public void uploadPhoto() {
List<CustomerPhoto> customerPhotos = customerPhotoReader.findAll();
for (CustomerPhoto customerPhoto : customerPhotos) {
try {
ByteArrayInputStream file = new ByteArrayInputStream(customerPhoto.getData());
s3Manager.upload(file);
customerPhotoDeleter.delete(customerPhoto);
} catch (Exception e) {}
}
}
위와 같은 방식으로 외부 저장소에 업로드가 실패했을 경우의 처리를 했으나, 현재 코드에는 두 가지 치명적인 문제점이 존재한다.
첫 번째는 저장 되어있는 사진 데이터에 문제가 있을 경우이다.
이때는 외부 저장소가 정상 작동하여도 사진을 업로드하지 못 하기에 오류를 반환하게 된다.
이러한 사항은 이미지 데이터를 저장하기 전 유효한 이미지인지 확인하는 방식으로 처리할 수 있을 것같다.
두 번째는 트렌젝션 처리이다.
위의 코드에서 앞 과정을 성공하고 마지막 customerPhotoDeleter.delete 에 실패하게 되었을 경우
- @Transactional 어노테이션을 사용한 경우 :
- S3에는 이미지 파일이 저장이 되어 있지만 회원 정보를 업데이트 하지 못 해 S3 내부에 미사용 파일이 저장이 되거나, 이러한 사항을 방지하기 위해 별도로 저장한 파일을 삭제하도록 하면 이후에도 동일한 파일을 저장하는 필요 없는 n번의 요청을 더 처리해야 한다.
- @Transactional 어노테이션을 사용하지 않을 경우 :
- 이미 업로드 완료된 데이터가 삭제 되지 못 해 중복된 처리를 반복해야한다는 문제가 발생한다.
이러한 사항은 CustomerPhoto 엔티티에 status 컬럼을 추가하여 S3 등록 여부를 체크하고,
S3 와 status 업데이트를 하나의 트렌젝션으로, 회원 정보 업데이트와 데이터 삭제를 하나의 트렌젝션으로 따로따로 관리하는 방법으로 간단하게 처리할 수 있을 것같다.
이외에도 일시적인 네트워크 장애로 인한 업로드 실패나, 예상 보다 대량의 데이터를 처리해야 하는 경우, 예상치 못한 원인으로의 실패 시 slack 등의 알람 등 보완해야할 사항이 있으며 이러한 사항은 차차 개선해나가며 글을 작성하고자 한다.
'기술적 고민' 카테고리의 다른 글
(MSA) 주문 및 결제에 이벤트 기반 SAGA 패턴을 활용한 이유 (0) | 2025.04.26 |
---|---|
동시성 제어에 캐시와 분산락 Redisson 을 도입한 이유 (1) | 2025.04.09 |
예약 관리 서비스에 RabbitMQ 를 도입하게 된 이유 (0) | 2025.03.26 |
무한스크롤에서 Offset 방식의 문제와 Keyset Pagination 사용 이유 (1) | 2025.02.20 |
메타데이터를 이용한 객체 값 자동 생성 ( DatabaseMetaData ) (0) | 2024.10.08 |