← 개발 로그 목록

website: Cloudflare R2 연동 준비하다가 OCI Object Storage로 갈아탄 파일 업로드 인프라

/ 6분 분량 / 개발 로그

이미지 업로드용 오브젝트 스토리지를 붙이는 작업인데, 시작은 Cloudflare R2였다가 중간에 OCI Object Storage로 방향을 튼 PR이다.

원래 #57에서 R2 버킷을 쓸 예정으로 Spring 쪽 연동 코드를 미리 셋업해뒀다. AWS SDK v2의 S3Client를 그대로 쓰되 엔드포인트만 R2 쪽으로 바꾸는 방식이었고, R2Config/R2StorageService에 r2.* 설정과 환경변수 5개(엔드포인트, 액세스키, 시크릿키, 버킷, 퍼블릭 URL)를 넣어뒀다. R2가 S3 호환 API를 제공해서 SDK 자체는 그대로 재사용할 수 있다는 전제였다.

그런데 실제 인프라 결정이 OCI Object Storage로 바뀌면서, 이 PR에서 바로 전환 작업을 했다. 다행히 OCI Object Storage도 S3 호환 API를 지원해서 SDK 레벨에서 새로 짤 건 없었고, 클래스명과 설정값만 갈아끼우면 됐다 — R2Config는 OciStorageConfig로, R2StorageService는 OciStorageService로. 다만 그냥 이름만 바꾼 건 아니고 두 가지를 고쳐야 했다.

하나는 region이었다. R2 연동 코드에서는 region을 "auto"로 하드코딩해뒀는데, OCI의 SigV4 서명 방식은 실제 region 값을 요구한다. R2는 리전 개념이 느슨해서 auto로 퉁칠 수 있었지만 OCI는 그렇지 않아서, ap-tokyo-1 같은 실제 값을 설정에서 받아오도록 바꿨다.

다른 하나는 addressing 방식이었다. OCI의 S3 호환 엔드포인트는 namespace가 이미 호스트 이름에 고정되어 있는 구조라, virtual-hosted-style(bucket.namespace.compat.objectstorage...)이 아니라 path-style(endpoint/bucket/key)로 접근해야 한다. 그래서 OciStorageConfig에 S3Configuration.builder().pathStyleAccessEnabled(true)를 추가했다.

.serviceConfiguration(S3Configuration.builder()
        .pathStyleAccessEnabled(true)
        .build())

인프라 쪽 .env.*.example 파일도 R2 환경변수 체계에서 OCI 체계로 갈아엎었다. namespace(nrvbyrcay0ao), region(ap-tokyo-1), 버킷명(likelion-prod/likelion-dev)을 확정 반영했는데, 실제 access/secret key는 예시 파일엔 넣지 않고 서버의 실제 .env.prod/.env.stage(gitignore 대상)에만 넣도록 했다. 그리고 나중에 서버에 반영된 버킷 rename(dev→stage)이 예시 템플릿에는 반영이 안 돼 있던 걸 발견해서 likelion-dev를 likelion-stage로 정정하는 커밋도 따로 넣었다.

PR을 올린 뒤에 Copilot 리뷰에서 두 가지 문제가 잡혔다. 하나는 upload()에서 prefix를 그대로 이어붙이다 보니 prefix가 null이거나 앞뒤에 슬래시가 붙어 있으면 key나 URL에 //나 불필요한 선행 /가 섞일 수 있다는 지적이었다. 그래서 prefix 정규화 로직을 따로 뽑았다.

private String normalizePrefix(String prefix) {
    if (prefix == null || prefix.isBlank()) return "";
    String trimmed = prefix.strip();
    int start = 0;
    int end = trimmed.length();
    while (start < end && trimmed.charAt(start) == '/') start++;
    while (end > start && trimmed.charAt(end - 1) == '/') end--;
    String normalized = trimmed.substring(start, end);
    return normalized.isEmpty() ? "" : normalized + "/";
}

다른 하나는 업로드 방식이었다. 처음엔 RequestBody.fromBytes()로 파일 전체를 바이트 배열로 읽어서 올렸는데, 이 방식은 파일 크기만큼 메모리에 그대로 올라가기 때문에 큰 파일이 들어오면 OOM 위험이 있다는 지적이 맞았다. RequestBody.fromInputStream(file.getInputStream(), file.getSize())로 스트리밍 업로드로 바꿨다. 이 두 가지를 고치면서 회귀를 잡기 위한 단위 테스트도 함께 추가했다 — 일반 prefix, 앞뒤 슬래시가 섞인 prefix, 빈 prefix, null prefix 네 가지 케이스로 key 생성 결과를 검증하고, delete()가 bucket/key를 제대로 넘기는지도 확인하는 테스트다. 이후에 contentType이 실제로 PutObjectRequest에 전달되는지 검증하는 케이스가 빠져 있어서 한 번 더 보강했다.

머지 전에는 실제 OCI 버킷 접근이 되는지 확인이 필요했다. 로컬 앱이 실제로 스토리지에 붙는 것과 동일한 검증을 위해 팀원에게 aws s3api put-object/delete-object로 직접 테스트해달라고 요청했는데, PR 설명에 aws s3 cp 같은 고수준 명령은 AWS CLI v2.23+의 기본 체크섬 동작 때문에 권한 문제가 아닌데도 SignatureDoesNotMatch가 날 수 있다는 점을 미리 적어뒀다. 실제로 이후 DB 백업 자동화 작업 중에 저수준 aws s3api 명령도 같은 자격증명으로 방금 성공했다가 바로 다음 호출에서 같은 에러를 내는 간헐적인 케이스를 확인했는데, awscrt 서명기 쪽 이슈로 추정되고 OCI 쪽 문제는 아니어서 이 내용은 별도로 pm/docs/learnings.md에 남겨뒀다.

인프라 벤더가 중간에 바뀌는 상황을 겪으면서, S3 호환 API를 표준으로 잡아두면 벤더 전환 자체는 설정값과 몇 가지 SDK 옵션(region, path-style) 조정선에서 끝난다는 걸 확인한 작업이었다. 다만 그 몇 가지 차이(하드코딩된 region, addressing 방식)를 놓치면 조용히 인증 실패로 이어질 수 있다는 점도 같이 남았다.