[Backend/API] 파일과 이미지 업로드 보안을 설계하는 기준
Backend 앱 API나 CMS에서 파일과 이미지 업로드 기능은 자주 필요합니다. 프로필 이미지, 게시글 첨부 이미지, 배너 이미지, 문의 첨부 파일처럼 사용자나 관리자가 파일을 올리는 경우는 생각보다 많고, 업로드는 단순히 파일을 받아 저장하는 기능이 아니기 때문에 보안 검수가 꼭 필요한 영역입니다.
파일 업로드에서 가장 위험한 실수는 클라이언트가 보낸 파일명, 확장자, MIME 타입, 저장 경로를 그대로 믿는 것입니다. 이미지라고 생각하고 받았는데 실제로는 실행 가능한 파일이거나, 내부 저장 경로가 응답에 노출되거나, 너무 큰 파일이 서버 자원을 과도하게 사용할 수 있습니다.
업로드 기능은 정상 케이스만 보면 단순하게 느껴질 수 있습니다. 막상 정리해보면 실패 케이스와 악의적인 입력을 어떻게 제한할지가 더 중요해집니다. 이 글에서는 Backend 앱 API에서 파일과 이미지 업로드 보안을 설계할 때 확인하면 좋은 기준을 정리해보겠습니다.
이 글에서 다룰 문제
업로드 보안 기준이 없으면 다음 문제가 생길 수 있습니다.
- 허용하지 않은 확장자의 파일이 업로드된다.
- 클라이언트가 보낸 MIME 타입만 믿고 검증을 통과시킨다.
- 원본 파일명을 그대로 저장해 경로 조작이나 충돌 위험이 생긴다.
- 내부 저장 경로나 bucket 정보가 API 응답에 노출된다.
- 너무 큰 파일 업로드로 서버 메모리와 디스크가 과도하게 사용된다.
- 관리자용 업로드와 앱 사용자 업로드가 같은 정책을 사용한다.
업로드 기능은 한 번 열어두면 다양한 입력이 들어옵니다. 그래서 정상적인 앱에서만 요청할 것이라고 가정하지 말고, 서버에서 반드시 제한하는 편이 안전합니다.
업로드 보안의 기본 원칙
파일 업로드는 아래 기준을 기본으로 잡는 것이 좋습니다. 가장 중요한 것은 allowlist 방식입니다. 위험한 확장자를 하나씩 막는 denylist보다, 허용할 확장자만 명확히 정하는 편이 더 안전합니다.
| 항목 | 기준 |
|---|---|
| 파일 크기 | 서버에서 최대 크기 제한 |
| 확장자 | allowlist 기반 허용 |
| MIME 타입 | 서버에서 재검증 |
| 파일명 | 서버에서 새 이름 생성 |
| 저장 경로 | 사용자 입력으로 결정하지 않음 |
| 공개 URL | 내부 경로가 아닌 public URL만 반환 |
| 권한 | 업로드 주체와 목적별로 분리 |
| 오류 응답 | 앱이 처리 가능한 JSON 형식 유지 |
업로드는 클라이언트 입력을 그대로 믿으면 위험한 기능입니다. 파일명, 확장자, MIME 타입, 저장 위치를 모두 서버 기준으로 다시 확인해야 합니다.
이미지 업로드 정책 예시
이미지 업로드라면 허용 범위를 좁게 잡을 수 있습니다. PDF나 문서 파일을 받을 때는 별도 정책을 두는 것이 좋습니다. 이미지와 문서는 보안 검수 기준이 다르기 때문입니다.
| 항목 | 정책 예시 |
|---|---|
| 허용 확장자 | jpg, jpeg, png, webp |
| 허용 MIME | image/jpeg, image/png, image/webp |
| 최대 용량 | 5MB |
| 저장 파일명 | 서버 생성 UUID |
| 응답 | 공개 가능한 URL과 식별자만 반환 |
운영 관점에서는 업로드 목적별로 정책을 나누는 것이 중요합니다. 관리자 CMS에서 올리는 배너 이미지와 앱 사용자가 올리는 프로필 이미지는 허용 용량이나 검증 기준이 다를 수 있습니다.
Laravel 업로드 검증 예시
final class UploadImageRequest extends FormRequest
{
public function rules(): array
{
return [
'image' => [
'required',
'file',
'max:5120',
'mimes:jpg,jpeg,png,webp',
'mimetypes:image/jpeg,image/png,image/webp',
],
];
}
}
검증은 controller에 직접 흩어두기보다 request class나 validation layer로 분리하는 편이 좋습니다. 그래야 관리자 업로드와 앱 API 업로드의 정책을 따로 관리하기 쉽습니다.
코드를 나눠보다 보면 업로드 정책은 화면보다 요청 검증 계층에 두는 편이 더 관리하기 좋습니다. 같은 이미지 업로드라도 주체와 목적에 따라 허용 기준이 달라질 수 있기 때문입니다.
서버에서 파일명 생성하기
클라이언트가 보낸 원본 파일명을 그대로 저장하지 않는 것이 좋습니다. 파일명 충돌, 특수문자, 경로 조작, 개인정보 노출 가능성이 있기 때문입니다.
final class StoredFileName
{
public static function image(string $extension): string
{
$safeExtension = strtolower($extension);
if (!in_array($safeExtension, ['jpg', 'jpeg', 'png', 'webp'], true)) {
throw new InvalidArgumentException('Unsupported image extension.');
}
return sprintf('%s.%s', Str::uuid()->toString(), $safeExtension);
}
}
원본 파일명이 필요하다면 DB에 별도 표시용 필드로 저장할 수 있습니다. 다만 공개 응답에 꼭 필요한 값인지 다시 확인하는 편이 좋습니다. 사용자 이름이나 내부 프로젝트명이 파일명에 들어갈 수 있기 때문입니다.
저장 경로를 사용자 입력으로 만들지 않기
업로드 경로를 요청 파라미터로 받는 것은 위험합니다. 서버는 업로드 목적에 따라 저장 위치를 결정해야 합니다.
나쁜 예: /upload?path=../../private
좋은 예: 서버에서 upload type에 따라 고정 경로 선택
| 업로드 목적 | 저장 위치 예시 |
|---|---|
| 프로필 이미지 | profiles/{userId}/ |
| 공지 이미지 | notices/{noticeId}/ |
| 임시 업로드 | tmp/{date}/ |
| CMS 배너 | banners/{yyyy}/{mm}/ |
실제 저장소의 내부 경로를 API 응답에 그대로 반환하면 안 됩니다. 앱에는 public URL 또는 fileId만 전달하는 것이 좋습니다.
공개 URL과 내부 저장 경로 분리하기
업로드 후 응답은 공개 가능한 정보만 포함해야 합니다. 내부 저장 경로, bucket 이름, access key, signed URL 생성 secret은 응답에 포함하면 안 됩니다.
{
"data": {
"fileId": "file_sample_123",
"url": "https://cdn.example.com/images/sample.webp",
"mimeType": "image/webp",
"size": 123456
}
}
signed URL을 사용해야 한다면 만료 시간과 권한 범위를 제한해야 합니다. 막상 업로드 응답을 설계해보면 저장 위치와 공개 URL을 분리하는 일이 생각보다 중요해집니다. 내부 경로는 운영 정보에 가깝고, 앱에는 접근 가능한 결과만 전달하는 편이 안전합니다.
업로드 후 이미지 처리 기준
이미지는 업로드 후 리사이즈나 재인코딩을 검토할 수 있습니다. 특히 사진에는 EXIF 위치 정보가 포함될 수 있습니다. 사용자 업로드 이미지라면 메타데이터 제거를 검토하는 것이 좋습니다.
| 처리 | 목적 |
|---|---|
| 리사이즈 | 너무 큰 이미지로 인한 트래픽 감소 |
| 포맷 변환 | webp 등 최적화 포맷 사용 |
| 메타데이터 제거 | 위치 정보 등 EXIF 제거 |
| 썸네일 생성 | 목록 화면 성능 개선 |
| 바이러스 검사 | 문서/첨부 파일 업로드 보안 강화 |
이미지 최적화는 성능 개선에도 도움이 됩니다. 다만 처리 비용이 생길 수 있으므로 업로드 시점에 처리할지, 비동기 작업으로 넘길지도 함께 검토해야 합니다.
업로드 오류 응답 형식
앱이 처리할 수 있도록 오류 응답도 고정해야 합니다. 확장자 오류, 용량 초과, MIME 불일치, 저장 실패를 구분하면 앱에서 더 정확한 안내를 할 수 있습니다.
{
"error": {
"code": "UPLOAD_FILE_TOO_LARGE",
"message": "파일 용량이 너무 큽니다.",
"details": {
"maxSizeMb": 5
}
}
}
| 상황 | error code |
|---|---|
| 용량 초과 | UPLOAD_FILE_TOO_LARGE |
| 확장자 불가 | UPLOAD_EXTENSION_NOT_ALLOWED |
| MIME 불일치 | UPLOAD_MIME_NOT_ALLOWED |
| 저장 실패 | UPLOAD_STORAGE_FAILED |
| 권한 없음 | FORBIDDEN |
오류 응답이 고정되어 있으면 앱에서도 안내 문구를 만들기 쉽습니다. 사용자는 파일이 너무 큰지, 형식이 맞지 않는지, 다시 시도해야 하는지 구분할 수 있습니다.
테스트 기준
업로드 기능은 정상 케이스보다 실패 케이스 테스트가 중요합니다. 성공 업로드만 확인하면 위험한 입력이 통과하는지 놓칠 수 있습니다.
- 허용 확장자의 정상 이미지가 업로드되는지 확인한다.
- 허용하지 않은 확장자가 거부되는지 확인한다.
- MIME 타입이 맞지 않는 파일이 거부되는지 확인한다.
- 최대 용량을 넘는 파일이 거부되는지 확인한다.
- 원본 파일명이 저장 경로에 그대로 쓰이지 않는지 확인한다.
- 응답에 내부 경로, bucket 이름, access key가 없는지 확인한다.
- 업로드 오류가 JSON 계약으로 반환되는지 확인한다.
보안 검수에서는 성공하는지보다 실패해야 할 요청이 제대로 막히는지가 더 중요해질 수 있습니다. 파일 업로드는 다양한 입력이 들어오는 기능이라, 실패 케이스를 테스트 기준에 꼭 포함하는 편이 좋습니다.
자주 하는 실수
1. 확장자만 검사하는 경우가 있습니다. 확장자는 쉽게 바꿀 수 있으므로 MIME 타입과 실제 파일 검사를 함께 고려해야 합니다.
2. 원본 파일명을 그대로 저장하는 경우도 있습니다. 파일명에 개인정보가 들어 있거나 경로 충돌이 생길 수 있습니다.
3. 내부 저장 경로를 응답에 반환하는 경우가 있습니다. 앱에는 public URL이나 fileId만 알려주는 편이 좋습니다.
4. 사용자별 업로드와 관리자 업로드 정책을 똑같이 두는 경우도 있습니다. 관리자 CMS는 대용량 이미지를 허용할 수 있지만, 앱 사용자 업로드는 더 엄격해야 할 수 있습니다.
결론
Backend 앱 API에서 파일과 이미지 업로드는 단순 저장 기능이 아니라 보안 경계가 필요한 기능입니다. 클라이언트가 보낸 파일명, 확장자, MIME 타입, 저장 경로를 그대로 믿으면 안 됩니다.
좋은 구조는 파일 크기 제한, 확장자/MIME allowlist, 서버 생성 파일명, 내부 경로 비노출, 공개 URL 분리, 업로드 오류 응답 계약을 갖추는 것입니다. 이미지라면 리사이즈, 메타데이터 제거, 썸네일 생성도 함께 검토할 수 있습니다.
업로드 기능은 성공 케이스만 보면 쉬워 보이는 영역입니다. 운영 관점에서는 실패 케이스와 악의적인 입력을 어떻게 막는지가 더 중요합니다. 설계 단계부터 업로드 범위와 검증 기준을 정해두면 나중에 보안 검수나 정책 변경에도 대응하기 쉬워집니다.
참고 자료
- Laravel File Upload, Validation, Filesystem 공식 문서
- OWASP File Upload 보안 가이드
- Object Storage/CDN public URL 설계 문서
- 프로젝트 내부 업로드 정책, MIME allowlist, 파일 응답 계약 문서