배경

학원 관리 시스템에서 학생 프로필 사진을 저장하고 서빙하는 기능을 구현했다. Firebase Functions 백엔드에서 GCS(Google Cloud Storage)를 직접 사용하는 구조다.


1. 버킷 설계: 목적별 분리

처음에는 {projectId}-student-profiles처럼 도메인별로 버킷을 만들려 했다. 하지만 버킷이 늘어날수록 관리가 복잡해지므로, **목적별(public/private)**로 분리하는 구조로 변경했다.

{projectId}-assets-public     ← 공개 에셋 (현재 사용)
{projectId}-assets-private    ← 민감 데이터 (추후 필요 시)

버킷 안에서 도메인 구분은 경로로 한다:

assets-public/
  └── students/{studentId}/profile_{timestamp}.webp
  └── students/{studentId}/profile_{timestamp}_thumb.webp
  └── classes/{classId}/cover.webp          ← 추후 확장 시

이렇게 하면 버킷 수는 최소화하면서, IAM 정책(공개/비공개)은 버킷 단위로 깔끔하게 관리된다.


2. Public 버킷 vs Signed URL: 보안 관점

Public 버킷 방식

버킷에 allUsers → Storage Object Viewer 권한 부여
→ URL을 아는 누구나 접근 가능

구현은 간단하지만, URL 패턴이 students/{studentId}/profile_{timestamp}.webp이므로 studentId와 timestamp를 추측하면 누구나 학생 얼굴을 볼 수 있다. 학생 사진은 민감 정보이므로 이 방식은 부적절하다.

Signed URL 방식 (채택)

버킷은 private (외부 접근 차단)
→ 서버가 인증된 요청에 대해서만 시간 제한 URL을 발급
→ URL이 만료되면 접근 불가
일반 URL:   <https://storage.googleapis.com/bucket/image.webp>
Signed URL: <https://storage.googleapis.com/bucket/image.webp>
              ?X-Goog-Signature=abc123...
              &X-Goog-Expires=3600

Signed URL은 서버의 서비스 계정 키로 서명되어 있어서, GCS가 서명을 검증한 뒤에만 응답한다. 만료 시간이 지나면 같은 URL로 접근해도 403이 반환된다.

흐름:

클라이언트 → 백엔드 "학생 조회 API"
               → Firestore에서 GCS 경로 읽음 (students/abc/profile_123.webp)
               → 서비스 계정 권한으로 Signed URL 생성 (유효기간 1시간)
               → Signed URL을 응답에 포함하여 반환

클라이언트 → Signed URL로 GCS에 직접 요청 → 이미지 수신