지금 진행하고 있는 의약품정보 사이트에서 안전성서한을 보여주는 기능이 필요해서 구현하게 되었다.
pdf와 한글 자료들은 클라이언트쪽에서 제공해주었기 때문에 난 이 자료들을 웹 사이트에서 보여주고 다운로드 할 수 있게 구현해야 했다.
일단 백엔드 공부, aws를 거의 챗지피티와 함께 공부했기 때문에 pdf를 어디에 넣고 어떻게 다운로드 받게 해야할지 막막했다.
결과적으로 나는 FastAPI + AWS S3 + Presigned URL을 이용해서 pdf 다운로드 기능을 구현했다.
0. 전체 구조 개요

다운로드 정책은 전부 서버에서 통제, 프론트엔드는 S3 존재 모름
1. S3 + Presigned URL 구조 선택
일단 처음에는 무식하게 db가 있으니까 db안에 pdf파일을 통째로 넣으면 되는거 아닌가 싶었다.
파일을 백엔드 서버가 직접 다루면 어떤 문제가 생길까?
일반적인 api요청은 대부분 JSON 데이터를 주고받는다.
하지만 파일은 용량이 대부분 수백 KB ~ MB다. 요청 하나가 서버 메모리와 네트워크를 오래 점유한다.
이런 트래픽을 전부 백엔드 서버가 감당하는건 확장성 측면에서 위험할 수 있다.
그러면 프론트에서 바로 S3에 접근해도 될까?
이건 보안적으로 문제가 있다고 한다.
S3 버킷을 Public으로 열어야 하고 파일 경로가 노출될 위험이 있고 링크가 퍼지면 통제가 불가느애진다.
특히 나중에 서비스 측면에서 특정 조건에서만 접근을 허용하고 싶을 수도 있고, 나중에 정책이 바뀔수도 있다.
그렇기 때문에 파일 접근의 결정권은 클라이언트가 아니라 서버에 두는게 안전하다.
그래서 Presigned URL을 선택했다.
이 방식을 사용하면 파일 전송 자체는 서버가 직접 하지 않지만 접근 권한에 대한 판단은 서버에 있다.
실제 파일 전송은 S3가 담당하도록 한다.
흐름은 이렇게 된다.
| 백엔드 | 클라이언트 | S3 |
| 이 파일을 내려줘도 되는지 판단 일정 시간 동안만 유효한 다운로드 URL 발급 |
서버에서 받은 url로 s3에 직접 요청 | 실제 파일 전송 담당 |
서버는 파일을 직접 다루지 않고 결정만 하게 된다.
2. IAM 설정
서버는 어떤 권한으로 S3에 접근하는 걸까? 나는 AWS에 IAM을 활용하였다.
AWS에서 리소스에 접근하려면 항상 두 가지가 필요하다.
1. 누가 접근하는지 (Identitiy)
2. 무엇을 할 수 있는지 (Permission)
이걸 담당하는게 바로 IAM이다. 서버가 S3에 Presigned URL을 생성하려면 이 버킷의 파일을 읽을 수 있는 권한 증명이 필요하다.
key를 사용하지 않은 이유는 사실 귀찮아서다. .env파일로 관리하는것도 귀찮고 aws ec2안에 있는 .env 파일안에 또 따로 써줘야 했기 때문에 귀찮아서 안했다.
AWS는 IAM Role을 서버에 부여하는 방식을 제공한다. 그래서 내가 따로 ec2안에서 관리할 필요가 없어서 편했다.
지금 내가 필요한 기능은 pdf 다운로드밖에 없었기 때문에 IAM 정책은 다음 기준으로 설계했다.
s3: GetObject
특정 버킷
특정 prefix(bucket_name/) 하위만
쉽게 말하면 안전성 서한 pdf가 저장된 위치의 파일을 읽을 수만 있다고 정책을 설정했다.
특정 prefix에만 권한을 준 건 안전성 서한은 항상 저 prefix아래에 저장되었기 때문이다.
import boto3
s3 = boto3.client("s3")
백엔드에서는 이 코드만 써주면 된다. AWS SDK가 이 서버에 부여된 역항르 자동으로 인식해석 권한을 처리해준다.
정리해보자면,
| IAM | Presigend URL |
| 서버가 이 파일에 접근할 자격이 있는가? | 이 url을 가진 클라이언트가 제한된 시간 동안 접근 가능 |
3. 첨부파일 메타데이터 설계
안전성 서한은 첨부파일이 여러개 있을 수도 있다.(위에 사진처럼 pdf가 2개일수도 있음!)
그래서 DB에는 파일 자체가 아니라 파일 메타데이터만 JSON 배열로 저장했다.
[
{
"original_name": "...pdf",
"s3_key": "bucekt_name/....pdf",
"size": 178588
}
]
s3_key: s3 내부 저장 경로, 외부에 직접 노출x
original_name: 사용자가 보게 되는 실제 파일명, 다운로드 시 이 이름으로 저장되도록 사용
저장용 식별자와 사용자 노출용 파일명을 분리해서 관리하였다.
4. 다운로드 API 설계
프론트는 S3를 직접 알지 못하기 때문에 항상 서버를 거친다.
GET /api/bucket_name/{letterId}/files/{fileIndex}/download
서버는 이 요청을 받으면 다음 순서로 검증한다.
1. 안전성서한 존재 여부
2. 첨부파일 목록 존재 여부
3. fileIndex 범위 검증
4. 메타데이터 유효성 확인
검증이 끝나면 유효한 Presigned URL을 생성해 반환한다.
5. Presigned URL 생성 로직
아래 함수는 AWS S3 객체를 다운로드할 수 있는 Presigned URL을 생성하는 역할을 한다.
Presigned URL은 일정 시간 동안만 유효한 임시 접근 URL로, 클라이언트가 AWS 자격 증명 없이도 S3 객체에 접근할 수 있게 해준다.
S3관련 설정값으로는 bucket, prefix, expires가 있다.
파일명에 특수문자도 많고 한글로 이루어졌기 때문에 다운로드시 파일 명이 깨지지 않게 하기 위해 filename*=로 UTF-8 인코딩된 파일을 명시했다.
def _presigned_url(key: str, filename: str):
bucket = os.getenv("S3_BUCKET_NAME")
prefix = (os.getenv("S3_PREFIX", "docs") or "").strip("/")
expires = int(os.getenv("PRESIGNED_EXPIRES_SECONDS", "300"))
if not bucket:
raise HTTPException(status_code=500, detail="S3_BUCKET_NAME is not set")
# prefix 누락 데이터 보정
if prefix and not key.startswith(prefix + "/"):
key = f"{prefix}/{key.lstrip('/')}"
key = key.replace("//", "/")
# 파일명 헤더 안전 처리 (최소)
safe_name = (filename or "download").replace("\r", "").replace("\n", "")
quoted = urllib.parse.quote(safe_name)
content_disp = f"attachment; filename*=UTF-8''{quoted}"
s3 = _get_s3_client()
return s3.generate_presigned_url(
ClientMethod="get_object",
Params={
"Bucket": bucket,
"Key": key,
"ResponseContentDisposition": content_disp,
},
ExpiresIn=expires,
)
6. 프론트엔드 다운로드 방식
위에서 계속 말했지만 프론트엔드는 S3를 직접 호출하지 않는다.
const onDownload = async (letterId: number, fileIndex: number) => {
const { url } = await fetchSafetyLetterDownloadUrl({ letterId, fileIndex });
window.open(url, '_blank', 'noopener,noreferrer');
};
서버에서 받은 presigned URL만 새 탭으로 연다.
[구현 화면]

사실 나는 s3에 파일 업로드하는게 제일 어려웠다. chatgpt와 개발하면서 느끼는 한계점이란 이런 것 같다. 분명히 더 쉬운 방법이 있는데 찾기 귀찮아서 chatgpt 말대로 하게 되는 것...
chatgpt 말대로 할 때는 로컬에 있는 파일을 ec2를 통해서 s3에 스크립트로 올리는 어려운 작업을 했는데
오늘 블로그 글 쓰면서 aws console 사이트에서 s3 버킷보니까 웹 ui로도 로컬에 있는 파일 업로드 할 수 있었다..

이런식으로 폴더도 만들고 파일도 바로 업로드 할 수 있어서 살짝 허무했다.
다음에는 바로 웹 ui 활용해서 업로드 할 것 같음..!
'DevLog' 카테고리의 다른 글
| [BE devlog] FAERS 약물명 정규화 작업 (1) | 2026.03.23 |
|---|---|
| [BE devlog] FAERS 약물명 통합 쿼리 최적화 (0) | 2026.03.21 |
| [GitHub] GitHub 라벨 설정 한번에 등록하기 (0) | 2026.01.31 |
| [BE devlog] React로 관리자 페이지 분리 후 발생한 CORS 이슈 해결하기 (0) | 2025.12.04 |