✍ Posted by Immersive Builder Seong

안녕하세요. Seong 입니다.
네이버클라우드에는 데이터를 객체 단위로 저장하고 접근할 수 있는 Object Storage라는 스토리지 서비스가 있습니다. 일반적으로 로그 파일, 데이터베이스 백업(DB dump), 설정 파일, 컨테이너 이미지뿐만 아니라 대용량 이미지나 영상 콘텐츠를 장기 보관하고 서비스하는 용도로 폭넓게 활용하고 있는데요.
이번 포스팅에서는 Object Storage를 활용하고 있는 한 고객사에서 있었던 파일 업로드 이슈를 해결한 사례를 공유하고자 합니다. 해당 고객사는 MLflow 및 내부 서비스의 아티팩트 저장소로 Object Storage를 활용하고 있었는데, 업로드 요청이 반복적으로 실패(Access Denied, 403)하면서 서비스 운영에 차질이 발생한 것입니다.
이 문제는 단순한 권한 오류로 보기 어려웠기 때문에, 원인을 체계적으로 분석하고 검증하는 과정이 필요했습니다. 따라서 기술 지원 과정에서 해당 이슈가 발생한 근본적인 원인과 함께 다양한 방법으로 시도하고 해결해나간 과정을 자세히 소개하고자 합니다.
◆ 개요
- 원인 파악 : Object Storage 업로드 불가 현상이 왜 발생하였는가?
- 해결 방안 1 : Presigned URL + HTTP PUT Request
- 해결 방안 2 : Checksum 설정 비활성화
- 해결 방안 3 : AWS CLI & boto3 특정 버전 사용
- 정리
1. Object Storage 업로드 불가 현상이 왜 발생하였는가?
업로드 불가 현상
고객사는 SDK(Python boto3)를 이용하여 Object Storage에 업로드를 시도하였으나, 하기와 같은 에러를 띄우며 실패하고 있었습니다.
"An error occurred (AccessDenied) when calling the PutObject operation: Access Denied"

에러 문구로 단순히 미루어보았을 때 처음에는 계정 권한, 버킷 접근 권한, 버킷 정책 등 단순한 권한 문제일 것으로 판단하였습니다. 하지만 서브 계정에는 NCP_OBJECT_STORAGE_MANAGER 정책이 이미 설정되어 있었으며, 서브 계정의 액세스 키 또한 올바른 값으로 할당되어 있었습니다. 또한, 대상 버킷에는 업로드 권한이 적절히 부여되어 있었습니다.



원인 분석
"그럼 403 에러가 왜 발생하는 것일까?"
우선 Object Storage 업로드가 실패하는 원인을 명확히 파악할 필요가 있었습니다. 계정 권한, 버킷 접근 권한, 버킷 정책, SDK(boto3) 버전 및 호환성 등 모든 잠재적인 요인을 가정하고 원인 분석에 들어갔습니다. 이를 위해 Object Storage에 객체를 업로드할 수 있는 여러 방식을 하나씩 테스트하며 어느 경우에 업로드가 성공하고, 어느 경우에 실패하는지를 비교·검증하는 단계부터 분석을 시작하였습니다.
Object Storage에 객체를 업로드하는 방식에는 대표적으로 5가지가 있습니다.
- 콘솔 UI 화면에서 직접 업로드
- S3 Browser를 이용하여 업로드
- AWS CLI를 이용하여 업로드
- SDK를 이용하여 업로드 ex) Python, Java, Javascript 등
- Rclone을 이용하여 업로드 (테스트에서 제외)
각 방식으로 테스트를 진행한 결과, 콘솔 업로드 방식과 S3 Browser를 이용한 업로드 방식은 정상적으로 동작하였으나, AWS CLI를 이용한 방식과 SDK를 이용한 업로드 방식은 실패함을 확인할 수 있었습니다.
| 업로드 방식 | 콘솔 UI | S3 Browser | AWS CLI | SDK(Python boto3) |
| 업로드 결과 | 성공 | 성공 | 실패 | 실패 |
세부적으로 테스트를 진행한 결과는 하기와 같습니다.
- 콘솔 UI 화면에서 직접 업로드 ▶ 성공


- S3 Browser를 이용하여 업로드 ▶ 성공



- AWS CLI를 이용하여 업로드 ▶ 실패
# 오브젝트 업로드
aws --endpoint-url=https://kr.object.ncloudstorage.com s3 cp <local_file_path> s3://<bucket_name>[/<object_name>]

- SDK(Python boto3)를 이용하여 업로드 ▶ 실패
import boto3
service_name = 's3'
endpoint_url = 'https://kr.object.ncloudstorage.com'
region_name = 'kr-standard'
access_key = 'ncp_iam_BPA*******'
secret_key = 'ncp_iam_BPK*******'
if __name__ == "__main__":
s3 = boto3.client(service_name, endpoint_url=endpoint_url, aws_access_key_id=access_key,
aws_secret_access_key=secret_key)
bucket_name = 'seong-contents-bucket'
# create folder
object_name = 'sdk_test.txt'
s3.put_object(Bucket=bucket_name, Key=object_name)
# upload file
object_name = 'sdk_test.txt'
local_file_path = './upload_test/sdk_test.txt'
s3.upload_file(local_file_path, bucket_name, object_name)


보다 상세한 로그를 확인하기 위해 Python의 로깅 모듈을 이용해 로그 레벨을 DEBUG 모드로 설정하였습니다.
import logging
logging.basicConfig(level=logging.DEBUG,
format="%(asctime)s - %(levelname)s - %(name)s - %(message)s")
| %(asctime)s | 로그 발생 시각 |
| %(levelname)s | 로그 레벨 ex) DEBUG, INFO, WARNING, ERROR, CRITICAL |
| %(name)s | 로거 이름 |
| %(message)s | 로그 메시지 |
그리고 로그 분석 과정에서 하기와 같이 HTTP 요청 헤더가 포함되어 있음을 발견하였습니다. 이 중 content-encoding:aws-chunked와 x-amz-trailer:x-amz-checksum-crc32 헤더는 S3 표준에서는 허용되지만, 네이버클라우드 Object Storage에서는 지원되지 않을 가능성이 있습니다.

또한, s3.upload_file() 및 s3.put_object() 함수를 사용하여 PutObject API 호출 시에도 동일하게 두 개의 헤더가 자동으로 요청에 포함되는 것을 확인할 수 있었습니다. 결과적으로 이 두 헤더가 요청 패킷에 포함되면서 Object Storage에서 해당 요청을 거부하는 현상이 발생한 것으로 원인을 추정할 수 있었습니다.
"S3 Browser는 왜 성공한 것일까?"
S3 Browser는 단순히 S3 REST API 규격만 구현한 독립적인 클라이언트이기 때문에 botocore 기반의 체크섬 검증, 인코딩 기능을 사용하지 않아 정상적으로 업로드가 성공한 것입니다.
2. 해결 방안 1 : Presigned URL + HTTP PUT Request
Presigned URL 이란?
이에 대한 대안으로 Presigned URL을 활용하는 방법을 검토하였습니다. Presigned URL은 Object Storage에 업로드 권한이 있는 계정으로 사전에 서명된 URL을 생성하여 일정 시간 동안만 유효한 임시 접근 권한을 부여하는 방식입니다. 이 Presigned URL을 사용하면 권한이 없는 사용자라도 만료 시간 내에는 HTTP PUT 요청을 통해 객체를 직접 업로드할 수 있습니다.
upload_presignedUrl.py
따라서 Presigned URL을 생성한 후 HTTP PUT 요청을 전송하는 Python 코드(upload_presignedUrl.py)를 작성하였습니다.
해당 코드가 동작하는 방식은 하기와 같습니다.
- 서버에서 boto3가 인증 정보를 포함한 Presigned URL을 생성합니다.
- 클라이언트는 해당 URL로 HTTP PUT 요청을 전송하여 객체를 업로드합니다.
# upload_presignedUrl.py
import os
import sys
import boto3
import requests
from botocore.config import Config
from botocore.exceptions import NoCredentialsError, ClientError
endpoint_url = "https://kr.object.ncloudstorage.com"
region_name = "kr-standard"
access_key = "ncp_iam_BPA*******"
secret_key = "ncp_iam_BPK*******"
bucket_name = "seong-contents-bucket"
local_dir = "./upload_test" # 업로드할 로컬디렉토리 지정
def upload_file(s3, local_path, object_key):
try:
url = s3.generate_presigned_url(
ClientMethod="put_object",
Params={"Bucket": bucket_name, "Key": object_key},
ExpiresIn=3600,
HttpMethod="PUT"
)
with open(local_path, "rb") as f:
resp = requests.put(url, data=f)
if resp.status_code == 200:
print(f"[성공] {local_path} → s3://{bucket_name}/{object_key}")
print("-" * 40)
print("업로드가 완료되었습니다.")
else:
print(f"[실패] {local_path} 업로드 중 오류 발생:", resp.status_code, resp.text)
except ClientError as e:
print(f"에러 발생: {e}")
def main():
s3 = boto3.client(
's3',
endpoint_url=endpoint_url,
region_name=region_name,
aws_access_key_id=access_key,
aws_secret_access_key=secret_key,
config=Config(signature_version="s3v4", s3={"addressing_style":"path"})
)
for root, _, files in os.walk(local_dir):
for fname in files:
local_path = os.path.join(root, fname)
rel_path = os.path.relpath(local_path, local_dir)
object_key = rel_path.replace("\\", "/")
upload_file(s3, local_path, object_key)
if __name__ == "__main__":
main()
내부 로직을 살펴보겠습니다. boto3 클라이언트 객체가 생성될 때 인증 정보(Access Key/Secret Key)와 서명 방식이 메모리에 저장됩니다. 이후 s3.generate_presigned_url() 함수를 호출하면 메모리에 저장된 인증 정보를 불러와 버킷명, 객체 경로, 만료 시간, 요청 메서드(PUT) 등 파라미터와 함께 Signature Version 4 알고리즘을 적용하여 서명을 생성합니다. 그 결과 인증 정보와 서명이 포함된 Presigned URL이 생성되며, 이 URL은 설정된 1시간 동안만 유효합니다.
생성된 Presigned URL은 아래와 같은 형식을 가집니다. 여기서 X-Amz-Credential은 요청을 생성한 계정의 인증 정보를, X-Amz-Signature는 요청을 검증하기 위한 서명 값을 각각 의미합니다.
https://kr.object.ncloudstorage.com/seong-contents-bucket/flower.jpg?
X-Amz-Algorithm=AWS4-HMAC-SHA256&
X-Amz-Credential=ncp_iam_BPA*******%2F20251021%2Fkr-standard%2Fs3%2Faws4_request&
X-Amz-Date=20251021T043549Z&
X-Amz-Expires=3600&
X-Amz-SignedHeaders=host&
X-Amz-Signature=c2571*******0216a
다음 단계에서 requests.put() 함수를 통해 생성된 Presigned URL로 업로드를 시도하면 Object Storage는 URL 내 서명을 검증하여 유효한 경우에만 업로드를 허용합니다. 만약 인증 정보가 일치하지 않거나 서명이 만료된 경우라면 업로드 요청이 거부됩니다. 또한, requests.put() 함수는 객체 단위로 업로드를 수행하기 때문에 로컬 디렉토리 내 모든 파일을 업로드하기 위해 반복문을 적용하였습니다.
위 코드에서 버킷명, 액세스 키, 로컬 디렉토리 경로만 환경에 맞게 수정하면 바로 사용하실 수 있습니다.
- Presigned URL과 HTTP PUT Request를 이용하여 업로드 ▶ 성공
현재 로컬 디렉토리(upload_test)에는 flower.jpg, penguin.webp, test1.txt 3개의 파일이 존재합니다. 해당 디렉토리를 업로드할 디렉토리로 지정하여 Python 코드를 실행한 결과, 대상 버킷(seong-contents-bucket)에 모든 파일이 정상적으로 업로드된 것을 확인할 수 있습니다.


이처럼 Presigned URL 방식을 사용하면 별도의 인증 절차 없이도 객체 업로드가 가능합니다.
3. 해결 방안 2 : Checksum 설정 비활성화
"MLflow 환경에서는 Presigned URL 방식을 적용할 수 없어요."
Presigned URL을 활용한 우회 방법으로 업로드 이슈가 일단락된 듯 보였지만, 그로부터 정확히 한달 후 고객사로부터 동일한 이슈가 재발한다며 지원 요청이 들어왔습니다. 요구 사항은 MLflow의 특성상 표준 SDK(Python boto3)의 PutObject API가 정상적으로 동작해야 한다는 것이었습니다. 즉, mlflow.set_tracking_uri() 함수 내부에서 boto3의 PutObject를 직접 호출하고 있었기 때문에 Presigned URL 방식을 적용할 수 없는 구조였던 것입니다. 이에 따라 이슈를 근본적으로 해결할 필요가 있었습니다.
Checksum 설정 비활성화
앞서 분석했던 것처럼 content-encoding:aws-chunked와 x-amz-trailer:x-amz-checksum-crc32 이 두 헤더가 요청 패킷에 포함됨에 따라 Object Storage에서 업로드 요청을 거부하게 됩니다. 그렇다면 '이 두 요청 헤더가 전송되지 않도록 설정하면 이슈를 해결할 수 있지 않을까?'하는 의문이 들었습니다. 이 부분에 대해 딥다이브하게 찾아본 결과, 업로드 요청 시 자동으로 추가되는 체크섬 기능을 비활성화할 수 있는 설정 옵션이 존재함을 알게 되었습니다.
SDK(Python boto3)의 경우, boto3 클라이언트 객체의 Config 값에 하기 두 개의 _checksum_ 관련 옵션을 추가하여 기능을 비활성화할 수 있습니다.
- SDK(Python boto3) Checksum 비활성화
- request_checksum_calculation="when_required"
- response_checksum_validation="when_required"
실제로 box.jpg 이미지 파일 한 개를 대상으로 업로드를 요청하였을 때 대상 버킷에 해당 파일이 정상적으로 업로드된 것을 확인할 수 있습니다.
# upload_file_to_bucket.py
import boto3
from botocore.config import Config
from botocore.exceptions import ClientError
service_name = 's3'
endpoint_url = 'https://kr.object.ncloudstorage.com'
region_name = 'kr-standard'
access_key = 'ncp_iam_BPA*******'
secret_key = 'ncp_iam_BPK*******'
if __name__ == "__main__":
s3 = boto3.client(service_name, endpoint_url=endpoint_url, aws_access_key_id=access_key,
aws_secret_access_key=secret_key, config=Config(signature_version="s3v4", s3={"addressing_style":"path"},
request_checksum_calculation="when_required", response_checksum_validation="when_required"))
bucket_name = 'seong-contents-bucket'
# create folder
object_name = 'box.jpg'
s3.put_object(Bucket=bucket_name, Key=object_name)
# upload file
object_name = 'box.jpg'
local_file_path = './upload_test/box.jpg'
try:
s3.upload_file(local_file_path, bucket_name, object_name)
print(f"[성공] {local_file_path} → s3://{bucket_name}/{object_name}")
print("-" * 40)
print("업로드가 완료되었습니다.")
except ClientError as e:
print("-" * 40)
print("업로드 실패:", e.response)


이번에는 upload_test2 디렉토리를 지정하여 업로드를 요청하였을 때 해당 디렉토리 내 모든 파일이 대상 버킷에 업로드된 것을 확인할 수 있습니다.
# upload_dir_to_bucket.py
import os
import boto3
from botocore.config import Config
from botocore.exceptions import ClientError
service_name = 's3'
endpoint_url = 'https://kr.object.ncloudstorage.com'
region_name = 'kr-standard'
access_key = 'ncp_iam_BPA*******'
secret_key = 'ncp_iam_BPK*******'
bucket_name = "seong-contents-bucket"
local_dir = "./upload_test2" # 업로드할 로컬디렉토리 지정
def main():
s3 = boto3.client(service_name, endpoint_url=endpoint_url, aws_access_key_id=access_key,
aws_secret_access_key=secret_key, config=Config(signature_version="s3v4", s3={"addressing_style":"path"},
request_checksum_calculation="when_required", response_checksum_validation="when_required"))
for root, _, files in os.walk(local_dir):
for fname in files:
local_file_path = os.path.join(root, fname)
rel_path = os.path.relpath(local_file_path, local_dir)
object_name = rel_path.replace("\\", "/")
try:
s3.upload_file(local_file_path, bucket_name, object_name)
print(f"[성공] {local_file_path} → s3://{bucket_name}/{object_name}")
print("-" * 40)
print("업로드가 완료되었습니다.")
except ClientError as e:
print("-" * 40)
print("업로드 실패:", e.response)
if __name__ == "__main__":
main()


그 외 Java, Javascript용 등 다른 SDK에서도 체크섬 기능 비활성화를 적용해볼 수 있습니다.
참고로 AWS CLI의 경우, AWS config 파일(~/.aws/config)에 동일한 옵션을 추가하여 체크섬 기능을 비활성화할 수 있습니다.
- AWS CLI Checksum 비활성화
# .aws/config
[default]
request_checksum_calculation = WHEN_REQUIRED
response_checksum_validation = WHEN_REQUIRED



4. 해결 방안 3 : AWS CLI & boto3 특정 버전 사용
문득 해당 이슈에 대한 네이버클라우드의 공식 답변이 궁금해졌습니다. 그래서 온라인 문의를 남겼고, 전달받은 네이버클라우드 측의 공식 답변은 이러했습니다.
"AWS CLI 및 SDK 특정 버전(AWS CLI v2.23.0, boto3 v1.36) 이후 버전에서는 Object Storage에서 지원하지 않는 새로운 체크섬 알고리즘이 기본적으로 활성화되기 때문에 요청에 실패할 수 있습니다. 따라서 가이드에 안내된 버전 또는 이전 버전을 사용하시거나, 최신 버전에서 해당 체크섬 설정을 비활성화하여 요청하시기 바랍니다."
결론적으로 AWS CLI & SDK(boto3)와 네이버클라우드 Object Storatge 간 버전 및 호환성이 이슈가 발생한 근본 원인이었음이 분명해졌습니다. 고객사 환경은 python3.9 기반의 boto3 v1.40을 사용하고 있었으므로 업로드 이슈가 발생했던 것이죠.
AWS CLI 동작 원리
AWS CLI는 사용자가 명령어를 입력하기 위한 인터페이스에 불과하며, 실제로 AWS API 요청을 호출하는 역할은 botocore라는 low-level의 엔진이 담당합니다.
| aws-cli | CLI 명령어 제공 및 사용자 인터페이스 |
| botocore | AWS API 호출 로직, 인증, HTTP 통신 등 핵심 기능 |
| boto3 | botocore를 기반으로 한 Python SDK |
AWS CLI 버전에 따른 botocore 지원 버전은 하기와 같습니다. AWS CLI V1은 Python 패키지 형태로 설치되며 botocore와 같은 환경을 공유합니다.
| aws cli | botocore |
| v1.15.85 | v1.10.84 |
| v1.25.95 | v1.27.94 |
| v1.35.24 | v1.35.58 |
| v1.42.55 | v1.40.55 |
AWS CLI & boto3 특정 버전 사용
위 공식 답변에서 알 수 있듯이 특정 버전을 사용하려면 다음 조건을 만족해야 합니다.
- AWS CLI v2.23.0 이전 버전
- AWS CLI v1.35 버전에서 업로드 테스트 ▶ 성공


- boto3 v1.36 이전 버전
- boto3 v1.35 버전에서 업로드 테스트 ▶ 성공


◇ 정리
Object Storage에 객체 업로드 시 Access Denied 에러가 발생한다면 아래 항목을 먼저 확인해보시기 바랍니다.
- 메인 계정이 아닌 서브 계정의 액세스 키를 사용하였는가?
- 메인 계정의 액세스 키를 사용하여 암호화 설정이 적용된 버킷을 대상으로 API를 호출할 경우 권한 관련 오류가 발생할 수 있습니다.
- 서브 계정의 권한에 NCP_OBJECT_STORAGE_MANAGER 정책이 설정되어 있는가?
- 서브 계정에 NCP_OBJECT_STORAGE_MANGER 또는 NCP_ADMINISTRATOR 정책이 설정되어 있어야 Object Storage를 이용할 수 있는 권한이 부여됩니다.
- 버킷 접근 권한에 업로드 권한이 부여되어 있는가?
- 목록 조회, 업로드, ACL 조회, ACL 수정 권한 중에 업로드 권한이 있는지 확인합니다.
- AWS CLI와 SDK의 사용 버전은 무엇인가?
- 특정 버전을 기준으로 Object Storage에서 지원하지 않는 체크섬 알고리즘이 활성화됩니다.
또한, 객체 업로드 이슈를 해결하기 위한 3가지 방안을 소개해드렸습니다.
각자 상황에 맞는 방안을 선택하여 적용해보시기 바랍니다.
Presigned URL 방식은 외부 사용자에게 제한적인 업로드 권한을 위임할 때 유용할 것입니다. 다만, URL이 외부에 노출될 경우 만료 시간 전까지는 누구나 접근 가능하므로 보안 수준이 중요한 환경에서는 짧은 만료 시간 설정과 함께 HTTPS 기반 통신을 적용하는 것이 좋겠습니다.
체크섬 설정 비활성화 방안은 이미 AWS CLI 또는 SDK 최신 버전을 설치하여 사용하는 경우 적절할 것입니다. 만약 최신 버전 설치가 어려운 환경이거나 설치 이전 상황이라면 특정 버전 기준하여 이전 버전을 설치하는 방법도 고려해볼 수 있겠습니다.
이와 같이 Object Storage 업로드 불가 이슈를 해결한 사례를 정리해보았습니다.
긴 글 읽어주셔서 감사합니다.

'NCloud > Service' 카테고리의 다른 글
| [NAVER DAN25] '네이버가 Ncloud Storage를 만든 이유' 세션 요약 정리 (0) | 2025.12.01 |
|---|---|
| [NCloud] 100% 활용하는 Cloud Insight 의 정석 Hands-on Lab.2 (1) | 2023.09.13 |
| [NCloud] 100% 활용하는 Cloud Insight 의 정석 Hands-on Lab.1 (1) | 2023.09.12 |
| [NCloud] HyperCLOVA X 에 대해서 알아보자 (2) | 2023.08.29 |
| [NCloud] Cloud DB for MySQL을 생성해보자 (1) | 2023.08.14 |