개요
현재 개발하고 있는 서비스에서 이미지 업로드, 다운로드 로직을 개발하면서 의문이 들었다. sns를 예로 들면, 이미지와 글을 업로드 할 때 만약 서버 오류로 글이 업로드가 되지 않으면 이미지는 어떻게 해야 하는거지? 그리고 만약 이미지 10장을 한번에 클라우드 스토리지에 올리다가 오류가 발생하면 롤백할 수는 있나? 와 같은 의문이 들었다. 그래서 여러 자료들을 찾아보며, 앞으로 런칭할 서비스의 클라우드 스토리지와 DB의 동기화 전략을 세워보려 한다.
현재 상황
현재 사용하고 있는 클라우드 스토리지와 서버, 데이터베이스 정보이다.
- Oracle Object Storage(bucket, 앞으로 편의상 oracle bucket으로 통일하겠다.)
- Spring boot 3.x
- mysql 8
지금까지 개발한 이미지 로직은 단순히 spring boot를 거쳐서, oracle bucket에 업로드하고 있다. 다른 비즈니스 로직과 함께 작성되지는 않았고 단순히 파일 업로드 기능만 구현한 상태이다.
배경지식
일단, 클라우드 스토리지와 DB 동기화를 위해 DB의 트랜잭션에 대해 간단하게 공부해보자.
정말 간단하게 설명해보자면, DB의 내부 구조는 크게 저장 시스템, 버퍼 관리자로 이루어져 있다.
버퍼 관리자는 캐싱 시스템과 비슷하다. 트랜잭션이 특정 페이지의 데이터 읽기/수정을 요청하면 버퍼 관리자는 요청된 페이지가 버퍼에 있는지 확인하고 없다면 디스크에서 페이지를 읽어와 버퍼에 저장, 페이지를 트랜잭션에 제공한다. 트랜잭션이 버퍼의 페이지 데이터를 수정하면, 버퍼 관리자는 수정된 페이지를 “더티 페이지”로 표시하고 수정 내용을 추적한다. 또한 해당 페이지가 다른 트랜잭션과 충돌하지 않도록 관리한다. 그리고 트랜잭션이 성공적으로 완료되면, 버퍼 관리자는 변경된 페이지들이 안전하게 디스크에 저장되도록 보장하고, 해당 페이지들에 대한 잠금 해제를 수행한다.
UNDO 오퍼레이션
버퍼도 메모리(RAM)의 일부 공간이기 때문에 버퍼가 부족해질 때가 있다. 버퍼 관리자는 이때 어떤 페이지를 버퍼에서 제거할지 결정하고 수정된 페이지라면 디스크에 기록(STEAL 정책)한다. 이런 오퍼레이션 수행 중에 수정된 페이지들이 버퍼 관리자의 버퍼 교체 알고리즘에 따라서 디스크에 출력될 수 있다. 버퍼 교체는 전적으로 버퍼의 상태에 따라서 결정되며, 일관성 관점에서 봤을 때는 임의의 방식으로 일어나게 된다.
만약 다음과 같은 상황이 발생했다고 가정해보자.
버퍼(RAM의 일부) 상태
- 총 공간: 100MB
- 사용 중: 95MB
- 새로운 8MB 페이지를 로드해야 하는 상황 → 버퍼에 공간이 부족합니다!
이후에 버퍼 관리자는 어떤 페이지를 버퍼에서 제거해야 할지 결정해야 한다. 버퍼 관리자는 페이지 사용 빈도, 최근 접근 시간, 수정 여부, 페이지 크기, 현재 잠금(lock) 상태 등을 판단하여 페이지를 버퍼에서 제거한다.
예시 상황
트랜잭션 A: 페이지 1, 2, 3 수정 중
트랜잭션 B: 페이지 4, 5 수정 중
버퍼 부족 발생 시
- 트랜잭션 A가 아직 완료되지 않았어도 페이지 1이 디스크에 써질 수 있음
- 트랜잭션 B의 페이지 4가 먼저 디스크에 써질 수도 있음
다음과 같은 상황에서 일관성 관점에서는 임의의 방식으로 일어나게 된다.
즉 아직 완료되지 않은 트랜잭션이 수정한 페이지들도 디스크에 출력될 수 있으므로, 만약 해당 트랜잭션이 어떤 이유든 정상적으로 종료될 수 없게 되면 트랜잭션이 변경한 페이지들은 원상 복구되어야 한다. 이런 복구를 UNDO라고 한다.
만약 버퍼 관리자가 트랜잭션 종료 전에는 어떤 경우에도 수정된 페이지들을 디스크에 쓰지 않는다면, UNDO 오퍼레이션은 메모리 버퍼에 대해서만 이루어지면 되는 식으로 매우 간단해질 수 있다. 이 부분은 매력적이지만 이 정책은 매우 큰 크기의 메모리 버퍼가 필요하다는 문제점을 가지고 있다. 수정된 페이지를 디스크에 쓰는 시점을 기준으로 다음과 같은 두 개의 정책으로 나누어 볼 수 있다.
- STEAL: 수정된 페이지를 언제든지 디스크에 쓸 수 있는 정책
- ¬STEAL: 수정된 페이지들을 최소한 트랜잭션 종료 시점(EOT, End of Transaction)까지는 버퍼에 유지하는 정책
STEAL 정책은 수정된 페이지가 어떠한 시점에도 디스크에 써질 수 있기 때문에 필연적으로 UNDO 로깅과 복구를 수반하는데, 거의 모든 DBMS가 채택하는 버퍼 관리 정책이다.
REDO 오퍼레이션
이제 트랜잭션이 수정한 작업을 디스크에 적용을 해야 하는데, 적용이 안된 상황이 있을 수 있다. 이때, 커밋한 트랜잭션의 수정을 디스크에 재반영하는 복구 작업을 REDO 복구라고 한다.
앞서 설명한 바와 같이 커밋한 트랜잭션의 수정은 어떤 경우에도 유지(durability)되어야 한다. REDO 복구 역시 UNDO 복구와 마찬가지로 버퍼 관리 정책에 영향을 받는다. 트랜잭션이 종료되는 시점에 해당 트랜잭션이 수정한 페이지들을 디스크에도 쓸 것인가 여부로 두 가지 정책이 구분된다.
- FORCE: 수정했던 모든 페이지를 트랜잭션 커밋 시점에 디스크에 반영하는 정책
- ¬FORCE: 수정했던 페이지를 트랜잭션 커밋 시점에 디스크에 반영하지 않는 정책
여기서 주의 깊게 봐야 할 부분은 ¬FORCE 정책이 수정했던 페이지(데이터)를 디스크에 반영하지 않는다는 점이지 커밋 시점에 어떠한 것도 쓰지 않는다는 것은 아니다. 이 때에는 해당 트랜잭션이 했던 작업을 로그 기록을 보고 다시 한번 실행(REDO)하게 된다.
FORCE 정책을 따르면 트랜잭션이 커밋되면 수정되었던 페이지들이 이미 디스크 상의 데이터베이스에 반영되었으므로 REDO 복구가 필요 없게 된다. 반면에 ¬FORCE 정책을 따른다면 커밋한 트랜잭션의 내용이 디스크 상의 데이터베이스 상에 반영되어 있지 않을 수 있기 때문에 반드시 REDO 복구가 필요하게 된다. 사실 FORCE 정책을 따르더라도 데이터베이스 백업으로부터의 복구, 즉 미디어(media) 복구 시에는 REDO 복구가 요구된다. 거의 모든 DBMS가 채택하는 정책은 ¬FORCE 정책이다.
정리해보면 DBMS는 버퍼 관리 정책으로 STEAL과 ¬FORCE 정책을 채택하고 있어, 이로 인해서 UNDO 복구와 REDO 복구가 모두 필요하게 된다. 따라서 ¬FORCE 정책을 위해 로그 활용이 DBMS에서는 필수적이다.
전략
지금은 OCI에 버킷이 하나만 올라가 있다. 이 버킷은 앞서 보았던 데이터베이스의 디스크 역할을 하고 있다고 보면 된다. 그렇다면, 버퍼 역할을 하는 버킷 하나를 추가로 생성하여 요청을 처리하면 될 것 같다. 그 다음 고민은 이제 버퍼 매니저 역할을 하는 로직이 필요한데, 누가 어떻게 어느정도까지 할 것인지를 결정해야 한다.
버퍼 매니저 역할
버킷은 오토 스케일링이 되기 때문에, 실제 데이터베이스와 같이 전략을 세세하게 짤 필요는 없을 것 같다. 지금 상황은 데이터베이스와 조금 다르기 때문에, 클라우드 스토리지에서의 버퍼역할을 하는 버킷(이제 줄여서 버퍼 버킷이라고 하겠다.)이 어떤 역할을 할 것이고 버퍼 매니저는 누가 어떻게, 어느정도까지 맡을 지 결정해야 한다.(버퍼 매니저 역할을 서버가 맡을 수도 있고 인프라에 맡길 수 있다.)
일단 기본적인 상황과 예외 상황을 나눠 생각해보자.
기본적인 상황
이미지 하나를 업로드한다고 하였을 때를 가정해보자. 사용자는 이미지를 올리고 글을 작성을 할 것이다. 이때 올리는 이미지는 버퍼 버킷에 저장한다.(임시 파일명) 이후 글을 작성하고 게시글을 업로드하게 되면, 버퍼 버킷에 있던 이미지를 디스크 버킷에 복사한다.(메타데이터가 포함된 파일명)
예외 상황 1: 사용자가 이미지 업로드 후, 게시글 업로드를 하지 않음
이 상황에서는 버퍼 버킷에 이미지가 남아있지만, 디스크 버킷에는 이미지가 없다. 만약 이런 데이터들이 쌓이게 되면, 추후 버퍼 버킷이 무의미한 데이터로 비용처리가 높아지기 때문에 주기적으로 삭제해줘야 할 것 같다. 또한, 버퍼버킷에 저장할때의 임시 파일명에 시간을 추가해야 할 것 같다.(파일명으로만 언제 업로드했는지 알 수 있기 때문)
예외 상황 2: 사용자가 이미지 업로드를 실패함
이 상황은 아직 필자도 어떤 오류로 실패했는지 알 수 없다. 버퍼 버킷이 꽉 차는 경우가 없기 때문이다. 이후 개발하면서 만약 발생한다면 그때 처리하는 것으로 하자.
예외 상황 3: 게시글 업로드 시 디스크 버킷의 이미지가 반영이 안됨
이 상황에서는 서버에서 예외처리를 하여 데이터베이스의 REDO 오퍼레이션과 비슷한 작업이 이루어져야 한다.
규칙 / 결론
앞서 본 상황들을 토대로 최소 규칙정도는 정해야 할 것 같다.
- 버퍼 버킷의 이미지가 언제 디스크 버킷으로 이미지를 복사할 것인지
- 버퍼 버킷의 이미지는 언제 삭제할 것인지
일단 게시글을 개제하는 순간, 버퍼 버킷의 이미지가 디스크 버킷으로 옮겨져야 할 것으로 보인다. 그리고 만약 반영이 되지 않았을 때, 직접 반영할 수 있게 로그를 남겨야 한다. 유지보수 차원에서 로그를 따로 남기기보다는, 해당 이미지 주소가 있는 테이블에 임시 주소도 같이 할당하여 확인할 수 있게 처리해야 할 것으로 보인다.
그리고 버퍼 버킷의 이미지는 일단 10일 단위로 정리를 하려고 한다. 이후 만약 데이터 양이 많아진다면, 10일에서 더 줄여서 관리하는 것으로 하려 한다.
구현
OCI Client, OCI policy, 그리고 IAM 설정은 생략하였다. 일단 OCI에서 버퍼 버킷과 디스크 버킷을 생성하였다.
그리고 다음과 같이 application.yml에 환경변수를 추가하였다.
oci:
config:
userId: ${OCI_BUCKET_USER_ID}
tenancyId: ${OCI_BUCKET_TENANCY_ID}
fingerprint: ${OCI_BUCKET_FINGERPRINT}
privateKeyPath: ${BUCKET_PRIVATE_KEY_PATH}
region: ${OCI_BUCKET_REGION}
storage:
bucketName: ${OCI_BUCKET_NAME}
compartmentId: ${OCI_BUCKET_COMPARTMENT_ID}
namespace: ${OCI_BUCKET_NAMESPACE}
region: ${OCI_BUCKET_REGION}
buffer-bucket-name: ${OCI_BUFFER_BUCKET_NAME}
disk-bucket-name: ${OCI_DISK_BUCKET_NAME}
각 설정을 추가한다.
- OracleCloudConfig
@Configuration
@ConfigurationProperties(prefix = "oci.config")
@Getter
@Setter
public class OracleCloudConfig {
private String userId;
private String tenancyId;
private String fingerprint;
private String privateKeyPath;
private String region;
@Bean
public ObjectStorage objectStorageClient() throws Exception {
AuthenticationDetailsProvider provider = SimpleAuthenticationDetailsProvider.builder()
.userId(userId)
.tenantId(tenancyId)
.fingerprint(fingerprint)
.privateKeySupplier(() -> {
try {
return new FileInputStream(privateKeyPath);
} catch (FileNotFoundException e) {
throw new RuntimeException(e);
}
})
.region(Region.fromRegionId(region))
.build();
return ObjectStorageClient.builder()
.build(provider);
}
}
- StorageProperties
@Configuration
@ConfigurationProperties(prefix = "oci.storage")
@Getter
@Setter
public class StorageProperties {
private String bucketName;
private String compartmentId;
private String namespace;
private String region;
private String bufferBucketName;
private String diskBucketName;
}
다음은 service layer의 ObjectStorageService이다. 일단 버퍼 버킷에 저장하였다. 버퍼 버킷에 저장할때의 이름은 업로드 시간, 그리고 UUID와 원본 파일의 이름을 섞은 이름으로 업로드되게 하였다.
// 버퍼 버킷에 임시 저장
public FileMetadataDto uploadToBuffer(MultipartFile file) throws IOException {
String temporaryFileName = generateTemporaryFileName(file.getOriginalFilename());
PutObjectRequest request = PutObjectRequest.builder()
.bucketName(storageProperties.getBufferBucketName())
.namespaceName(storageProperties.getNamespace())
.objectName(temporaryFileName)
.contentType(file.getContentType())
.putObjectBody(file.getInputStream())
.build();
objectStorageClient.putObject(request);
return FileMetadataDto.builder()
.originalFileName(file.getOriginalFilename())
.temporaryFileName(temporaryFileName)
.uploadTime(LocalDateTime.now().toString())
.contentType(file.getContentType())
.fileSize(file.getSize())
.build();
}
이후 버퍼 버킷에 있는 임시 파일을 디스크 버킷으로 옮기는 함수를 구현하였다.
// 디스크 버킷으로 복사
public String copyToDisk(FileMetadataDto metadata, String finalFileName) {
CopyObjectDetails copyObjectDetails = CopyObjectDetails.builder()
.sourceObjectName(metadata.getTemporaryFileName())
.destinationBucket(storageProperties.getDiskBucketName())
.destinationNamespace(storageProperties.getNamespace())
.destinationObjectName(finalFileName)
.destinationRegion(storageProperties.getRegion())
.build();
CopyObjectRequest copyRequest = CopyObjectRequest.builder()
.namespaceName(storageProperties.getNamespace())
.bucketName(storageProperties.getBufferBucketName())
.copyObjectDetails(copyObjectDetails)
.build();
objectStorageClient.copyObject(copyRequest);
return generateFileUrl(storageProperties.getDiskBucketName(), finalFileName);
}
버킷에서 버킷으로 파일을 복사하는 경우에는, CopyObjectDetails, CopyObjectRequest를 사용하였다. 관련 SDK는 아래 출처에 있다. 컨트롤러 계층의 코드들은 다른 컨트롤러 코드들과 비슷하고, 게시글 업로드를 하는 경우 다른 컨트롤러에서 호출할 여지가 있어보여 생략하였다.
아직 부족한 부분들이 많다. 파일을 여러개를 올려야 하고, 실제 DB와 같이 처리를 해야 해서 게시글 기능을 구현하면서 같이 구현하려 한다.