개발 노트/Backend,CMS,API

[Backend] API 파일 및 이미지 업로드 보안 검수 기준 가이드

pposooj 2026. 5. 21. 13:23

웹이나 앱 서비스를 개발하고 운영하다 보면 프로필 이미지, 게시글 첨부파일, CMS 배너 등 파일을 업로드하는 기능을 구현할 일이 정말 많습니다. 처음에는 그냥 파일 받아다가 특정 폴더에 저장하면 끝나는 간단한 기능처럼 보이지만, 막상 운영하다 보면 보안적으로 신경 쓸 부분이 한두 가지가 아닙니다.

가장 위험한 실수는 클라이언트가 보낸 파일명, 확장자, MIME 타입을 그대로 믿고 서버에 저장하는 것입니다. 자칫하면 이미지가 아니라 실행 파일이 올라와 서버가 털리거나, 내부 경로가 노출되고, 너무 큰 파일 때문에 서버 디스크가 꽉 차는 장애가 생길 수도 있습니다.

정상적인 요청만 들어오면 참 편하겠지만, 실무에서는 항상 악의적인 입력이나 예외 상황을 먼저 막아야 하더군요. 나중에 다른 프로젝트 할 때도 까먹지 않고 바로 적용하려고, 파일 업로드 기능을 설계할 때 반드시 확인해야 할 보안 기준을 정리해 둡니다.

1. 업로드 보안이 없을 때 생기는 문제들

보안 기준을 꼼꼼하게 잡지 않으면 운영 중에 아래와 같은 골치 아픈 문제들을 마주하게 됩니다.

  • 허용하지 않은 위험한 확장자(php, sh, exe 등)의 파일이 업로드됨
  • 클라이언트가 위조해서 보낸 MIME 타입만 믿고 검증을 통과시켜 버림
  • 원본 파일명을 그대로 저장했다가 경로 조작(Directory Traversal) 공격을 당하거나 파일명이 충돌함
  • 내부 저장 경로나 AWS S3 버킷 정보가 API 응답에 그대로 노출됨
  • 대용량 파일 업로드 테러로 서버 메모리와 디스크 자원이 바닥남
  • 관리자용 CMS 업로드와 일반 앱 사용자 업로드에 동일한 느슨한 정책을 적용함

기본적으로 "정상적인 앱이나 프론트엔드를 통해서만 요청이 올 것"이라는 가정은 버리는 게 안전합니다. API는 언제든 직접 호출될 수 있으므로 서버에서 철저하게 제한해야 합니다.

2. 파일 업로드 보안의 기본 원칙

저의 경우 파일 업로드를 설계할 때 아래 7가지 기준을 기본으로 잡고 들어갑니다. 핵심은 위험한 걸 막는 denylist 방식이 아니라, 허용할 것만 명확히 정의하는 allowlist 방식을 쓰는 것입니다.

항목 보안 검수 기준
파일 크기 서버 계층(Nginx 등) 및 애플리리케이션에서 최대 크기 제한
확장자 allowlist 기반으로 허용된 확장자만 통과
MIME 타입 클라이언트가 보낸 값 말고, 서버에서 파일 바이너리로 재검증
파일명 원본 이름은 버리고 서버에서 난수(UUID 등)로 새 이름 생성
저장 경로 사용자 입력(파라미터)으로 절대 결정하지 않음
공개 URL 내부 저장 경로가 아닌 CDN이나 별도의 public URL만 반환
권한 업로드 주체(일반 유저/관리자)와 목적별로 정책 분리
오류 응답 에러 발생 시 앱이 파싱하기 좋은 고정된 JSON 형식 유지

3. 이미지 업로드 정책 및 Laravel 검증 예시

이미지만 받는 기능이라면 허용 범위를 최대한 좁게 가져가는 것이 안전합니다. PDF나 일반 문서 파일은 보안 검수 기준이 아예 다르기 때문에 정책을 따로 분리해야 합니다.

[이미지 정책 가이드라인]

  • 허용 확장자: jpg, jpeg, png, webp
  • 허용 MIME: image/jpeg, image/png, image/webp
  • 최대 용량: 5MB
  • 저장 파일명: 서버에서 생성한 UUID
  • 응답 데이터: 외부에서 접근 가능한 public URL과 식별자(ID)만 반환

Laravel Request 검증 코드

컨트롤러에 검증 로직을 지저분하게 두는 것보다, 아래처럼 Request 클래스나 Validation 레이어로 명확히 분리하는 편이 관리하기 좋습니다. 그래야 유저용 API와 관리자 CMS용 정책을 따로 나누기 편하더라고요.

PHP
 
final class UploadImageRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'image' => [
                'required',
                'file',
                'max:5120', // 5MB 제한
                'mimes:jpg,jpeg,png,webp',
                'mimetypes:image/jpeg,image/png,image/webp',
            ],
        ];
    }
}

4. 서버에서 파일명 안전하게 새로 만들기

클라이언트가 올린 원본 파일명(photo.jpg 등)을 그대로 저장소에 저장하는 건 정말 위험합니다. 파일명 충돌은 물론이고, 특수문자나 경로 조작 공격, 심지어 파일명에 개인정보나 내부 프로젝트명이 노출되는 문제가 생길 수 있습니다.

아래처럼 원본 확장자의 유효성만 체크한 뒤, 파일명 자체는 서버에서 UUID 등으로 새로 만들어야 합니다.

PHP
 
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.');
        }

        // UUID 기반의 안전한 파일명 반환
        return sprintf('%s.%s', Str::uuid()->toString(), $safeExtension);
    }
}

Tip: 만약 업무상 원본 파일명이 꼭 필요하다면, 실제 저장 파일명은 UUID로 바꾸되 DB에 original_name 같은 필드를 두고 화면 표시용으로만 사용하는 것이 좋습니다. 단, 이 경우에도 다운로드 시 취약점이 생기지 않도록 보안 처리가 필요합니다.

5. 저장 경로 조작 방지 및 외부 URL 분리

업로드 경로를 클라이언트 요청 파라미터로 받는 구조는 절대 피해야 합니다. 예를 들어 ?path=../../private 같은 식으로 악의적인 경로 조작 요청이 들어오면 대형 사고가 납니다.

경로는 서버가 업로드 목적(Type)에 따라 직접 지정해야 안전합니다.

  • 나쁜 예시: /upload?path=../../private (클라이언트가 경로 지정)
  • 좋은 예시: 서버 내부 코드에서 목적별 고정 경로 맵핑
업로드 목적 서버 내부 지정 경로 예시
프로필 이미지 profiles/{userId}/
공지사항 이미지 notices/{noticeId}/
임시 업로드 tmp/{date}/
CMS 배너 이미지 banners/{yyyy}/{mm}/

API 응답에서 내부 정보 숨기기

S3 버킷 이름, 내부 디렉토리 경로, 접근 키 등이 API 응답 결과에 노출되면 공격자에게 힌트를 주는 꼴입니다. 외부 앱이나 프론트엔드에는 딱 접근 가능한 public URL이나 fileId만 던져주는 게 맞습니다.

JSON
 
{
  "data": {
    "fileId": "file_sample_123",
    "url": "https://cdn.example.com/images/sample.webp",
    "mimeType": "image/webp",
    "size": 123456
  }
}

비공개 파일이라 Signed URL을 써야 하는 상황이라면, 만료 시간(Expires)을 타이트하게 잡고 권한 범위를 최소화해서 발급하도록 설계해야 합니다.

6. 업로드 후 이미지 처리 및 에러 규격화

이미지는 그냥 저장하고 끝내기보다, 백엔드에서 후처리를 해주면 성능과 보안 측면에서 모두 이득입니다. 특히 스마트폰으로 찍은 사진은 EXIF 메타데이터에 GPS 위치 정보가 그대로 남아있어서 사용자 개인정보 유출 우려가 있습니다. 후처리 단계에서 메타데이터를 날려주는 게 좋습니다.

  • 리사이즈: 원본이 너무 크면 대역폭 낭비가 심하므로 적정 크기로 축소
  • 포맷 변환: 용량 최적화를 위해 webp 같은 최적화 포맷으로 변환 검토
  • 메타데이터 제거: 개인정보 보호를 위해 사진의 EXIF(위치 정보 등) 데이터 삭제
  • 썸네일 생성: 목록 화면 성능을 위해 별도의 작은 이미지 생성
  • 바이러스 검사: 일반 문서나 첨부파일의 경우 백신 솔루션 연동 고려

클라이언트 대응을 위한 에러 응답 규격화

업로드 실패 시 어떤 이유로 막혔는지 고정된 에러 코드를 내려주면 프론트엔드나 앱에서 예외 처리하기가 훨씬 수월해집니다.

JSON
 
{
  "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

7. 구현 후 필수 테스트 체크리스트

업로드 기능은 "잘 올라가네?" 하고 성공 케이스만 보면 안 됩니다. "막혀야 할 게 잘 막히나?"를 테스트하는 것이 훨씬 중요합니다. 개발 마무리에 아래 항목들을 꼭 체크해 보세요.

  • [ ] 허용된 확장자의 정상 이미지가 잘 올라가는지 확인
  • [ ] .php, .sh, .exe 등 허용하지 않은 확장자 업로드 시 잘 차단되는지 확인
  • [ ] 확장자만 .jpg로 변환한 실행 파일이나 텍스트 파일이 MIME 검증에서 걸러지는지 확인
  • [ ] 설정한 최대 용량(예: 5MB)을 넘는 파일 요청이 제대로 거부되는지 확인
  • [ ] 저장소에 저장될 때 원본 파일명이 아닌 서버 생성 UUID로 바뀌어 있는지 확인
  • [ ] API 응답 결과에 S3 버킷 명이나 내부 저장 경로 정보가 노출되지 않는지 확인
  • [ ] 업로드 실패 시 에러 응답이 약속된 JSON 포맷으로 정확히 내려오는지 확인

마치며

파일 업로드는 백엔드 개발에서 아주 흔하게 구현하는 기능이지만, 보안 경계를 세우지 않으면 서비스 전체를 위험하게 만들 수 있는 양날의 검이기도 합니다.

운영 관점에서는 조금 번거롭더라도 처음 설계할 때부터 크기 제한, 확장자/MIME allowlist, 파일명 서버 생성, 내부 경로 비노출 규칙을 뼈대로 잡아두는 것이 나중에 보안 검수를 받거나 정책을 변경할 때 고생하지 않는 지름길입니다. 업로드 기능을 만드실 때 이 기준들을 하나씩 대입해 보시길 권장합니다.