안녕하세요. distance의 백엔드 개발자 이준석입니다.
이번 블로그에선 총학정보를 수정하는 API를 설계하면서 고민한 내용을 정리해보겠습니다!
우선 총학정보를 수정하는 API에선 제목, 내용, 이미지 총 3가지를 입력할 수 있는데 제가 고민한 내용은 아래와 같습니다.
1. 기존에 있던 이미지를 다 지우고 새롭게 등록한 이미지를 저장한다. (V1 API)
2. 기존에 있던 이미지와 현재 업로드한 이미지를 비교하여 미사용하는 이미지만 지운다. (V2 API)
3. V2 API와 로직은 비슷하지만 삭제를 하지않고 컬럼 중 isUsed컬럼을 true -> false로 변경 후 스케줄러를 통해 삭제 (V3 API)
둘다 장단점이 너무 분명하게 보여서 고민이 되었습니다...
첫번째 방안은 코드상에서는 깔끔해져도 매번 업데이트API를 호출할때마다 S3 버킷에 있는 이미지와 DB에 있는 이미지를 다 지우는건 성능에 좋지 않을 것 이라고 판단하였습니다.
하지만 두 번째 방식 또한 저는 좋지 않다고 생각을 하였습니다.
그 이유는 저희 서비스에는 채팅기능이 MVP 기능이고 이는 DB I/O부하가 심하기 때문에 최대한 DB의 스트레스를 줄여야했기 때문입니다.
📌 테스트 환경
1. 3개의 이미지를 저장 및 수정하는 상황으로 가정
2. 기존 DB에 저장되어 있던 이미지와 새롭게 등록하려는 이미지의 중복은 1개로 통일
우선 첫번째 방식으로 구현한 경우를 보겠습니다.
위의 사진을 보면 V1 API의 실행시간은 1458ms이 걸린 것을 확인할 수 있고, DB Log를 보면 1번의 flush를 수행하였고, 총 3번의 Partial Flush, 즉 3개의 insert문이 작업한 것을 확인할 수 있었습니다.
뿐만 아니라 10개의 엔티티와 10개의 컬렉션이 flush를 수행한 것을 확인할 수 있는데 단순히 이미지를 업데이트하는데 이 정도가 수행되는 것은 성능에 좋지 않다고 생각하였습니다.
그 다음 방법은 새롭게 등록하려는 이미지가 기존 DB에 있는 이미지라면 아무 작업을 수행하지 않고, 새로운 이미지만 등록하는 방식의
V2 API를 테스트 해보겠습니다.
위의 사진은 V2 API를 실행한 결과인데 수행시간은 조금 준 것을 확인할 수 있었지만 이는 제가 신경쓰는 DB I/O랑은 상관이 없고 중요한건 두번 째 사진입니다.
보면 총 1번의 flush를 수행하였고, 4번의 Partial Flush를 수행한 것을 확인할 수 있었습니다....
또한 16개의 엔티티와 16개의 플러시 작업이 수행된 것을 확인할 수 있었는데 이는 DB성능에 더더욱 비효율적입니다..
이렇게 된 이유는 기존에 있는 이미지와 새롭게 등록하려는 이미지를 비교하고 반복을 돌면서 미사용이미지를 삭제함으로써 DB데이터에 동기화를 해야하기 때문에 flush가 발생하였습니다.
(물론 flush 모드를 "FlushModeType.COMMIT" 이렇게 하면 나아지겠지만 이 부분을 하기전에 성능을 비교해보고 싶었음)
마지막 3번째 방식은 2번째와 비슷하지만 바로 삭제를 하는 것이 아닌 isUsed컬럼의 값을 true -> false로 변경하는 로직입니다.
그럼 문득 이것도 어찌됐든 DB의 상태가 변화가 되니 Partial Flush가 V2와 동일하지 않을까? 하는 생각을 가질 수 있습니다.
하지만 V2의 경우는 엔티티를 삭제하는 작업으로 다른 엔티티나 컬렉션에 영향을 미칠 수 있지만, V3처럼 단순히 엔티티의 상태를 변경하는 것은 Partial Flush를 수행할 필요가 없기 때문에 성능면에서 나을 것 이라고 확신(젭알...)합니다,,ㅎ (아래는 결과)
역시나 Partial Flush가 확연히 준 것을 확인할 수 있었습니다..ㅎ
굳이 이렇게 한 이유는 실시간으로 채팅이 진행중이면 많은 DB I/O가 발생할텐데 동시에 부가적인 DB I/O가 발생하는 현상을 최대한 줄이고자 엔티티의 상태를 변경하는 방향으로 구현을 해보았고, false로 되어있는 이미지는 미사용 이미 지이기 때문에 스케줄러를 통해
DB I/O가 가장 뜸한 시간에 작업을 하도록 구현을 하였습니다.
@Transactional
public void updateV3(List<MultipartFile> files, StudentCouncil studentCouncil)
throws IOException, NoSuchAlgorithmException {
List<CouncilImage> imageList = councilImageReader.findImageEntity(studentCouncil);
List<CouncilImage> imagesToDelete = new ArrayList<>();
List<MultipartFile> filesToUpload = new ArrayList<>();
Map<String, MultipartFile> fileHashMap = new HashMap<>();
for (MultipartFile file : files) {
String fileHash = s3UploadImage.calculateMD5(file);
fileHashMap.put(fileHash, file);
}
for (CouncilImage councilImage : imageList) {
if (fileHashMap.containsKey(councilImage.getImageHash())) {
fileHashMap.remove(councilImage.getImageHash());
} else {
imagesToDelete.add(councilImage);
}
}
for (CouncilImage imageToDelete : imagesToDelete) {
imageToDelete.updateIsUsed();
}
filesToUpload.addAll(fileHashMap.values());
councilImageCreator.create(filesToUpload, studentCouncil);
}
위의 코드처럼
for (CouncilImage imageToDelete : imagesToDelete) {
imageToDelete.updateIsUsed();
}
이 부분을 통해 엔티티의 상태를 변화함으로써 Partial Flush작업을 줄일 수 있게 되었습니다.
물론 이렇게 하는 방법은 코드의 복잡성이 굉장히 복잡해진다는 단점이 있습니다.
V1 API의 코드를 보면
@Transactional
public void update(List<MultipartFile> files, StudentCouncil studentCouncil) {
deleteImage(studentCouncil);
councilImageCreator.create(files, studentCouncil);
}
기존의 이미지를 지우고, 새롭게 이미지를 생성하는 단순한 로직인 걸 확인할 수 있습니다.
위에 말한거처럼 저는 장단점이 확실히 보이는 방식 중에서 최대한 DB에 스트레스를 최소화에 목적을 두고 수행하였기 때문에 3번째 방식을 채택하게 되었습니다..!