S3는 롤백의 개념이 없다. 일단 MySQL에 저장되어있는 데이터 정확성을 지키자! 부터 생각해보기 시작했다.
file 업로드와 관련된 로직(MySQL, AWS S3)은 트랜잭션 처리를 하고,
만약? ProductFile 테이블에 filename을 저장하지 못했을 경우, S3에 올렸던 파일을 삭제해주는 로직으로 코드를 작성했다.
S3에 업로드 후 3번까지 처리되고 에러가 발생한다면?
롤백이 되고(ProductFile의 filename 데이터 복구) S3에 업로드 했던 file만 삭제시켜주면 된다.
4번까지 실행된다면?
트랜잭션 성공 후, 롤백이나 추가적인 삭제 작업 없이 함수가 종료된다!
MySQL Product 테이블에 관련된 로직은 제외하고, ProductFile 테이블과 AWS S3에 관련된 코드만 기록했다.
// 상품 생성
async createProduct(
files: Express.Multer.File | Express.Multer.File[],
createProductDTO: CreateProductDTO,
): Promise<Product> {
let uploadedFilesToS3: string[];
const queryRunner = this.datasource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
const createdProduct = await this.productsRepository.create(createProductDTO);
if (files) {
// 상품 관련 미디어 파일 S3에 업로드
uploadedFilesToS3 = await this.uploadsService.uploadFilesToS3(files);
// ProductFile 테이블에 filename 저장
await this.productFilesRepository.create(createdProduct.id, uploadedFilesToS3);
}
await queryRunner.commitTransaction();
return createdProduct;
} catch (error) {
// 에러 발생 시 롤백
await queryRunner.rollbackTransaction();
if (uploadedFilesToS3.length > 0) {
// 롤백을 위해 S3에 업로드 했던 파일 삭제
await this.uploadsService.deleteFilesToS3(uploadedFilesToS3);
}
throw error;
} finally {
await queryRunner.release();
}
}
// 상품 수정
async updateProduct(
productId,
files: Express.Multer.File | Express.Multer.File[],
updateProductDTO: UpdateProductDTO,
): Promise<Product> {
let uploadedFilesToS3: string[];
const queryRunner = this.datasource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
const updatedProduct = await this.productsRepository.update(productId, updateProductDTO);
if (files) {
const existingFilenames = await this.productFilesRepository.findByProductId(productId);
const s3KeysToDelete = existingFilenames.map(file => file.filename);
// 상품 관련 새로운 미디어 파일 S3에 업로드
uploadedFilesToS3 = await this.uploadsService.uploadFilesToS3(files);
// ProductFile 테이블에 저장되어 있던 filename 삭제
const deletedFiles = await this.productFilesRepository.delete(productId);
// ProductFile 테이블에 새로운 filename 저장
const savedFiles = await this.productFilesRepository.create(productId, uploadedFilesToS3);
// 상품 관련 미디어 파일 S3에서 삭제
const deletedToS3 = await this.uploadsService.deleteFilesToS3(s3KeysToDelete);
}
await queryRunner.commitTransaction();
return updatedProduct;
} catch (error) {
await queryRunner.rollbackTransaction();
if (uploadedFilesToS3.length > 0) {
await this.uploadsService.deleteFilesToS3(uploadedFilesToS3);
}
throw error;
} finally {
await queryRunner.release();
}
}
트랜잭션 관련 처리 후, 더 좋은 트랜잭션 처리는 없었을까? 호기심이 생겨 퇴근 후 RealMySQL 1 책의 트랜잭션 부분을 읽어보았다.
두둥 -! 다섯 페이지를 넘기니 너 잘 못 생각했어 ~ 라고 바로 나왔다. 감사한데 슬프고 기쁘다...🫢
메일 전송이나 FTP 파일 전송 작업 또는 네트워크를 통해 원격 서버와 통신하는 등과 같은작업은 어떻게 해서든 DBMS의 트랜잭션 내에서 제거하는 것이 좋다.
프로그램이 실행되는 동안 메일 서버와 통신할 수 없는 상황이 발생한다면 웹 서버뿐 아니라 DBMS 서버까지 위험해지는 상황이 발생할 것이다.
오? 나는 AWS S3도 같은 트랜잭션으로 묶었는데...
AWS도... 네트워크를 통해 작업... 하는 거 아닌가요,, 🥲
구글링해서 트랜잭션 내에서의 네트워크 기반 작업에 대해 찾아보았다.
네트워크 지연이나 외부 서비스의 문제로 인해 요청이 타임아웃 될 경우, 트랜잭션은 롤백되어야 한다.
이것은.. 예상은 했는데 애써 무시했었다..
트랜잭션 내에서 외부 서비스와의 통신을 기다리는 동안, DBMS의 리소스(ex: 연결, 잠금)가 소모될 수 있으며, 이는 성능 저하 및 병목 현상을 초래할 수 있다.
외부 서비스와의 통신에 성공했지만, 다른 이유로 트랜잭션이 롤백되는 경우, 외부 서비스와의 통신 결과는 되돌릴 수 없다. 이로 인해 데이터의 일관성이 깨질 수 있다.
그래서 트랜잭션 에러 발생시 롤백과 함께 AWS S3에 업로드했던 file을 삭제해주는 로직을 추가했었다.
원자성 위반 관련된 처리는 해주었지만, 타임아웃으로 인해 원치 않는 롤백이나 리소스 소모! 특히... 내가 제일 무서워 하는 ,, 성능 저하 및 병목 현상....
당장 수정합시다.
수정! 하면 되지!
상품을 create, update 하는 코드 모두 수정했지만, 로직이 조금 더 복잡한 update 코드만 기록해보겠다!
async updateProduct(
productId,
files: Express.Multer.File | Express.Multer.File[],
updateProductDTO: UpdateProductDTO,
): Promise<Product> {
let uploadedFilesToS3: string[];
if (files) {
// 상품 관련 새로운 미디어 파일 S3에 업로드
uploadedFilesToS3 = await this.uploadsService.uploadFilesToS3(files);
}
const queryRunner = this.datasource.createQueryRunner();
try {
await queryRunner.connect();
await queryRunner.startTransaction();
const updatedProduct = await this.productsRepository.update(productId, updateProductDTO);
if (files) {
// ProductFile 테이블에 저장되어 있던 filename 삭제
const deletedFiles = await this.productFilesRepository.delete(productId);
// ProductFile 테이블에 새로운 filename 저장
const savedFiles = await this.productFilesRepository.create(productId, uploadedFilesToS3);
}
await queryRunner.commitTransaction();
if (files) {
// 상품 관련 미디어 파일 S3에서 삭제
const existingFilenames = await this.productFilesRepository.findByProductId(productId);
const s3KeysToDelete = existingFilenames.map(file => file.filename);
const deletedToS3 = await this.uploadsService.deleteFilesToS3(s3KeysToDelete);
}
return updatedProduct;
} catch (error) {
await queryRunner.rollbackTransaction();
if (uploadedFilesToS3.length > 0) {
await this.uploadsService.deleteFilesToS3(uploadedFilesToS3);
}
throw error;
} finally {
await queryRunner.release();
}
}
AWS S3에 file 업로드 하는 코드를 트랜잭션 시작 전에 작성했다.
트랜잭션 내에서는 데이터베이스(MySQL)와 관련된 작업만을 수행하고, 트랜잭션 외부에서는 S3와 관련된 작업을 처리해준다.
기존에 저장되어있던 filename 데이터는 삭제되고 새로운 filename 데이터가 저장 되었다!
기존에 저장되어있던 file은 삭제되고 새로운 file이 저장 되었다!
수정 후에 잘 작동했다!
데이터의 일관성과 S3의 저장 공간 효율성을 보장하기 위해 위와 같이 설계했지만(요청 데이터에 file 데이터가 있을 경우에만 file관련된 로직 실행) if(files) 블록이 세 번이나 등장하게 되어 코드 길이도 길어지고 가독성이 저하된 것 같아.. 새로운 고민이 생겼다...
일단! RealMySQL 책을 더 읽어보고 트랜잭션 위주로 수정을 더 해보고! 가독성도 챙겨야겠다. 😊 제게 가르침을 제발 주세요