BLOG

Jikji Cloud의 Presigned URL은 왜 엔드포인트를 둘로 나누었을까

Jikji Cloud의 Presigned URL은 왜 엔드포인트를 둘로 나누었을까

대용량 파일을 올릴 때 API 서버가 파일 본문을 받아 Object Storage로 다시 전달하면, 업로드 데이터가 서버를 한 번 더 통과하면서 불필요한 네트워크 대역폭과 연결 자원을 소모하게 됩니다.

Jikji Cloud에서는 이 중계를 피하기 위해 백엔드가 짧은 만료 시간을 가진 Presigned URL을 발급하고, 브라우저가 Object Storage에 직접 파일을 올리거나 내려받도록 설계했습니다.

이 구조를 만들 때 가장 먼저 맞닥뜨린 고민은

"URL에 어떤 주소를 넣어야 하는가"

였습니다.

서버가 사용하는 스토리지 주소와 외부 브라우저가 접근할 수 있는 주소가 서로 다를 수 있기 때문입니다.
──────

서명할 주소와 접근할 주소의 불일치

서버 내부망에서만 유효한 주소로 Presigned URL을 만들면, 사외망에 있는 사용자의 브라우저는 당연히 그 URL에 접근할 수 없습니다.

그렇다고 발급된 URL의 호스트 문자열만 외부 주소로 치환하는 방식은 통하지 않습니다. AWS Signature Version 4(SigV4) 서명 과정에는 요청의 Host 헤더가 핵심 해시 입력값으로 포함됩니다. 따라서 주소를 임의로 바꾸면 스토리지 백엔드에서 서명 불일치(SignatureDoesNotMatch) 에러를 뱉으며 요청을 거부합니다.

결국 서명하는 시점부터 브라우저가 실제로 호출할 주소를 반영해야 합니다. 이를 위해 Jikji Cloud에서는 환경 변수로 엔드포인트의 역할을 분리했습니다.

• OBJECT_STORAGE_ENDPOINT: 백엔드가 일반적인 내부 S3 작업을 수행할 때 사용
• OBJECT_STORAGE_PUBLIC_ENDPOINT: 브라우저에 내려줄 Presigned URL을 생성할 때 사용

코드에서는 동일한 설정 구조체를 넘기되, 일반 작업용과 서명용 클라이언트를 각각 생성합니다.

rustfsCfg := rustfs.Config{
    Endpoint:       cfg.ObjectStorage.Endpoint,
    PublicEndpoint: cfg.ObjectStorage.PublicEndpoint,
    // 자격 증명과 리전 설정
}

// 1. 일반 백엔드 작업용 클라이언트 (내부 엔드포인트 사용)
s3Client, err := rustfs.NewObjectStorageClient(rustfsCfg, nil)
if err != nil {
    return nil, nil, fmt.Errorf("init rustfs s3 client: %w", err)
}

// 2. Presigned URL 발급 전용 클라이언트 (공개 엔드포인트 기준 서명)
presignClient, err := rustfs.NewPresignObjectStorageClient(rustfsCfg, nil)
if err != nil {
    return nil, nil, fmt.Errorf("init rustfs presign client: %w", err)
}

물리적인 스토리지가 둘로 나뉜 것은 아닙니다. 일반 백엔드 작업은 내부 엔드포인트를 그대로 타고, Presigned URL 생성기만 브라우저가 접근할 공개 도메인을 기준으로 서명하도록 역할을 나눈 것입니다.
──────

공개 엔드포인트 설정이 누락된다면?

환경 변수 OBJECT_STORAGE_PUBLIC_ENDPOINT가 비어 있으면 내부 OBJECT_STORAGE_ENDPOINT를 대신 바라보도록 폴백(Fallback)을 두었습니다. 단일 엔드포인트로 단순하게 실행하는 로컬 개발 환경의 구성을 깨지 않기 위해서입니다.

하지만 내부망과 외부망 주소가 분리된 배포 환경에서는 공개 엔드포인트 설정이 필수입니다. 이 값을 빠뜨리면 발급된 URL이 내부 주소를 가리키게 되어 브라우저에서 접근하지 못합니다.

따라서 테스트를 작성할 때도 단순히 URL 생성 성공 여부만 검증해서는 안 됩니다. 공개 엔드포인트를 설정한 경우와 생략한 경우를 각각 확인해, URL의 호스트가 의도한 주소(공개 주소 vs 내부 주소)를 가리키고 있는지 검증해야 합니다.
──────

CORS와 권한은 다른 문제

브라우저가 Object Storage에 직접 요청을 보내려면 CORS(Cross-Origin Resource Sharing) 설정도 필요합니다. Jikji Cloud는 JIKJI_CONSOLE_ORIGIN에 지정된 콘솔 도메인을 기준으로 버킷의 CORS 규칙을 자동 적용합니다.

여기서 몇 가지 세부 정책을 두었습니다.

  1. 오리진 누락 시 와일드카드(*) 미적용: 오리진 설정이 없으면 와일드카드 CORS 규칙을 적용하지 않고, 동기화를 건너뛴 뒤 LastCorsError에 오류를 기록합니다.
  2. 비치명적(Non-fatal) 에러 처리: CORS 규칙 적용 오류는 버킷의 다른 상태 전환을 막지 않으므로, 버킷 상태는 정상(Running)을 유지합니다. 버킷 상태가 정상이더라도 브라우저 요청이 실패한다면 이 CORS 에러 기록을 따로 확인해야 합니다.
  3. CORS와 권한의 구분: CORS는 브라우저의 교차 출처 요청을 제어하는 규칙이지, 사용자에게 스토리지 권한을 부여하는 장치가 아닙니다. 업로드 URL을 발급할 때는 쓰기 권한을, 다운로드 URL을 발급할 때는 읽기 권한을 백엔드에서 확인한 뒤 발급합니다.
    ──────

만료 시간(TTL)과 보안

Presigned URL의 기본 만료 시간은 5분(300초)입니다. JIKJI_OBJ_PRESIGN_TTL_SECONDS 설정을 통해 60초에서 900초 사이로 조정할 수 있습니다.

짧은 만료 시간은 URL이 외부로 노출되었을 때 사용할 수 있는 유효 시간을 줄이는 데 도움이 됩니다. Presigned URL은 만료 전까지는 해당 URL을 가진 누구나 요청을 보낼 수 있는 권한처럼 동작하므로, 서버 로그나 감사 기록에 전체 URL을 남기지 않는 보안 관리도 함께 챙겨야 합니다.
──────

마치며

내부와 공개 엔드포인트를 나누는 방식이 모든 배포 환경에 필요한 것은 아닙니다. 하나의 주소로 서버와 브라우저가 모두 스토리지에 접근할 수 있다면 단일 엔드포인트로도 충분합니다.

하지만 두 네트워크 경계가 다르다면, URL을 서명하는 시점부터 브라우저가 실제로 접근할 주소를 사용해야 서명이 어긋나지 않습니다.

저희가 분리한 것은 저장소가 아니라 접근 경로였습니다. 일반적인 백엔드 작업은 내부 주소로 처리하고, 브라우저에 넘길 URL은 공개 주소를 기준으로 서명합니다. 여기에 올바른 CORS 설정과 URL 만료 시간 관리까지 더해져야 직접 업로드 흐름을 안정적으로 운영할 수 있습니다.

flowchart TB
    subgraph External["외부망 (Public Network)"]
        Browser["웹 브라우저 (사용자 콘솔)"]
    end

    subgraph Internal["내부망 (K8s / Private Network)"]
        subgraph ControlPlane["Jikji Cloud 백엔드 (Control Plane)"]
            AuthCheck["권한 검증 (Viewer 쓰기 제한 등)"]
            OpsClient["1. 일반 S3 클라이언트<br/>(내부 엔드포인트 사용)"]
            PresignClient["2. Presign 전용 클라이언트<br/>(공개 엔드포인트 기준 서명)"]
        end

        Storage["Object Storage (RustFS)"]
    end

    Browser -->|"1. 업로드 URL 요청"| AuthCheck
    AuthCheck --> PresignClient
    PresignClient -->|"2. Presigned PUT URL 발급 (https://storage.jikji.io/...)"| Browser

    Browser -->|"3. CORS Preflight 및 직접 업로드 (PUT)"| Storage
    OpsClient -.->|"내부 S3 관리 작업 (버킷 관리, CORS 동기화, 목록 조회)"| Storage

    classDef public fill:#e8f4fd,stroke:#1d70b8,stroke-width:2px;
    classDef private fill:#f3f4f6,stroke:#4b5563,stroke-width:2px;
    classDef highlight fill:#fef3c7,stroke:#d97706,stroke-width:2px;

    class External,Browser public;
    class Internal,Storage private;
    class PresignClient highlight;

구조도