포스트

[업무일지] 객체 키인 줄 알았던 S3 파일 참조 컬럼

파일 다운로드를 presigned URL로 통일하자 일부 과거 파일의 다운로드가 실패했습니다. 같은 컬럼에 객체 키와 두 종류의 S3 URL, URL 인코딩된 값이 섞여 있었습니다. 과거 값은 읽을 때 객체 키로 정규화하고, 함께 수정한 업로드 경로는 새로 저장하는 값을 객체 키로 고정했습니다.

Java 21, Spring Boot 3.3.x 환경에서 계약서와 사용자 문서를 내려받는 기능을 수정한 작업입니다. 이 글의 버킷 이름과 객체 경로는 모두 예시로 바꿨습니다.

https://면 그대로 반환하는 분기가 문제를 가리고 있었다

파일 참조 컬럼에는 S3 객체 키가 저장된다고 생각했습니다. 조회할 때 이 값을 SDK에 넘겨 일정 시간만 유효한 다운로드 URL을 만드는 구조였죠.

그런데 기존 코드에는 값이 https://로 시작하면 서명하지 않고 그대로 반환하는 분기가 있었습니다. 그 외의 값만 객체 키로 보고 presigner에 넘겼고요.

다운로드 경로를 presigned URL로 통일하면서 이 우회 분기를 제거하자 과거 파일 다운로드가 깨졌습니다. 전체 URL이 객체 키 자리에 들어가면서 S3에는 존재하지 않는 키를 가리키는 서명 URL을 만들었고, URL을 그대로 반환할 때는 동작하던 과거 데이터가 객체 키를 요구하는 경계에서는 더 이상 같은 의미가 아니었죠.

이 분기가 있다는 것 자체가 저장 형식이 하나가 아니라는 신호였습니다. 처음에는 이미 완성된 URL을 다시 처리하지 않기 위한 작은 예외로만 봤지, 서로 다른 저장 형식을 이어주는 호환 코드로 보지는 않았습니다. 객체 키가 들어오는 정상 흐름과 과거 URL을 그대로 내보내는 흐름이 한 컬럼 뒤에 숨어 있었던 겁니다.

문자열 한 칸에 URL·키·인코딩 값이 섞여 있었다

실제 레코드를 기준으로 입력 형태를 나누니 단순히 URL 또는 키 두 종류로 끝나지 않았습니다. 형식은 더 많았고요. 일반화하면 다음과 같은 값들이 한 컬럼에 공존했습니다.

저장된 값의 형태예시객체 키를 얻을 때 필요한 처리
객체 키documents/42/contract.pdf그대로 사용
슬래시가 붙은 객체 키/documents/42/contract.pdf맨 앞 / 제거
virtual-hosted-style URLhttps://example-bucket.s3.region.amazonaws.com/documents/42/contract.pdfURL의 path 사용
path-style URLhttps://s3.region.amazonaws.com/example-bucket/documents/42/contract.pdfpath의 첫 구간인 버킷 이름 제거
URL 인코딩된 값documents/42/%EA%B3%84%EC%95%BD%EC%84%9C.pdfUTF-8로 한 번 디코딩

특히 두 S3 URL 형식은 path만 잘라낸다고 같은 결과가 나오지 않습니다. AWS 문서대로 virtual-hosted-style은 버킷 이름이 host에 있는 반면 path-style은 path의 첫 구간에 버킷 이름이 들어가므로, 둘을 구분하지 않으면 버킷/객체 키 전체를 객체 키로 오인하게 됩니다.

인코딩도 따로 봐야 했습니다. 브라우저에서 본 URL에는 한글과 공백이 퍼센트 인코딩되어 있지만 서명 API가 기대하는 것은 실제 S3에 저장된 객체 키였기 때문에, 보이는 문자열이 비슷하다고 그대로 넘길 수는 없었죠.

결국 이 컬럼은 문자열이 아니라 객체 키 | S3 URL | 인코딩된 객체 키라는 합집합 타입에 가까웠습니다. 타입 구분을 스키마에 남기지 않은 비용을 읽는 쪽에서 치르고 있었습니다.

읽기 경계에서 객체 키 하나로 수렴시켰다

두 애플리케이션에서 과거 파일을 읽고 있었기 때문에 한쪽만 고쳐서는 부족했습니다. 두 곳을 함께 고쳤습니다. 각각의 다운로드 경계에 같은 정규화 순서를 적용했습니다.

객체 키 형태라면 선행 슬래시를 제거한 뒤 디코딩했습니다. HTTP URL이라면 URI로 파싱해서 path를 꺼내고, path-style일 때만 첫 구간의 버킷 이름을 제거했습니다. 마지막에는 같은 디코딩 단계를 거쳤고요.

결과는 객체 키 하나였죠. 정규화 뒤의 코드는 원본이 URL이었는지, 선행 슬래시가 있었는지 더는 알 필요가 없게 했습니다.

URL 파싱에 실패했을 때 무리하게 일부 문자열을 잘라 서명하지는 않았습니다. HTTP URL이면 기존 값을 반환하고 경고 로그를 남겼는데, 잘못 만든 객체 키로 정상처럼 보이는 URL을 내려주는 것보다 기존 동작을 보존하면서 원본을 추적할 수 있는 편을 택했기 때문입니다.

디코딩은 원본을 확인한 뒤 마지막에 한 번만

여기서 한 번 더 걸렸습니다. 처음에는 Java 21의 URI.getPath()로 충분해 보였지만 이 메서드는 디코딩된 path를 돌려주므로, 입력이 어떤 상태였는지 확인하기 전에 변환이 일어나는 셈이죠.

디코딩은 마지막 한 번뿐입니다. URL에서는 URI.getRawPath()로 쿼리 문자열과 host를 제외한 원래 path를 먼저 확보하고, 버킷 구간과 선행 슬래시를 정리한 뒤 UTF-8로 한 번만 디코딩했습니다. 객체 키 형태로 들어온 값도 같은 마지막 단계를 거치게 했고요.

여기에는 전제가 있습니다. 과거 레코드의 %xx 값이 S3 객체 키에 포함된 리터럴 퍼센트가 아니라, 파일명을 URL 표현으로 바꾼 결과라는 점입니다. 모든 S3 시스템에서 객체 키를 무조건 디코딩해도 된다는 뜻은 아닙니다. 실제 키에 %2F 같은 문자열을 의도적으로 넣을 수 있다면 별도의 형식 표식 없이 구분할 수 없습니다.

새로 저장하는 값은 객체 키로 고정했다

읽기 호환만 추가하면 시간이 지나도 같은 문제가 반복됩니다. 쓰기 경계도 바꿨고요. 함께 수정한 업로드 경로에서는 응답으로 받은 공개 URL 대신, 업로드할 때 계산한 객체 키를 새 레코드에 저장하도록 했습니다.

저장 값은 객체의 식별자이고, 다운로드 URL은 그때그때 권한과 만료 시간을 반영해 만들어지는 표현입니다. 둘을 분리하니 공개 URL에서 presigned URL로 다운로드 방식을 바꾸거나 버킷 도메인이 달라져도 저장 데이터를 다시 고칠 필요가 없어졌습니다.

과거 데이터를 일괄 UPDATE하는 마이그레이션은 이번 변경에 넣지 않았습니다. 대신 읽는 쪽은 기존 형태를 모두 받아들이고, 쓰는 쪽은 객체 키만 만들게 했습니다. 한 번에 과거 값을 정리하는 대신 새 데이터부터 형식을 수렴시키는 방식이었죠.

이 선택 덕분에 데이터 변환 배포를 따로 조율하지 않아도 됐지만, 정규화 코드를 바로 지울 수는 없게 됐습니다. 과거 레코드가 남아 있는 동안에는 호환층도 함께 남습니다.

테스트는 분기보다 값의 모양으로 나눴다

테스트 기준도 바꿨습니다. 정규화 메서드의 내부 분기를 그대로 따라가기보다, 실제로 저장될 수 있는 값의 모양을 입력으로 만들었습니다.

  • virtual-hosted-style URL에서 path만 객체 키로 남는지
  • path-style URL에서 버킷 이름이 제거되는지
  • 선행 슬래시가 있는 키가 같은 객체 키로 수렴하는지
  • 한글과 공백이 인코딩된 값이 한 번만 디코딩되는지
  • 새 업로드 레코드에는 http로 시작하는 URL이 저장되지 않는지

두 애플리케이션 모두 과거 URL과 인코딩된 키가 올바른 객체 키로 바뀌는지 확인했습니다. 입력 형태가 더 많았던 조회 경로에서는 path-style URL과 선행 슬래시도 추가했고, 업로드 테스트에서는 새 저장 값이 http로 시작하지 않는지 확인했습니다. 한 서비스에서 정상화해도 다른 다운로드 경로가 과거 값을 그대로 presigner에 넘기면 문제는 남기 때문입니다.

맺음말

포기한 선택지는 과거 레코드의 즉시 일괄 변환입니다. 그 대신 런타임 정규화 분기와 호환 코드의 수명을 떠안았습니다. 중복도 남았고요. 같은 로직이 두 애플리케이션에 각각 들어가 있습니다.

URL과 객체 키를 형식 표식 없이 한 문자열로 받는 한, 임의의 외부 URL이나 리터럴 %가 포함된 키를 완벽하게 구분할 수는 없습니다. 장기적으로는 저장 타입을 객체 키로 제한하고, 과거 데이터가 사라진 시점에 호환층을 걷어내야 합니다. 이번 작업은 그 방향으로 입력을 한쪽으로 모은 첫 단계였습니다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.