환경  nginx · PHP-FPM 8.x · 2026-10-04 확인

로컬에서는 잘 되던 업로드가 서버에 올리자 큰 파일에서만 실패한다. 어떤 때는 브라우저에 알 수 없는 오류가 뜨고, 어떤 때는 폼이 그냥 빈 채로 다시 나타난다. 앱 로그에는 아무것도 없다. 업로드 크기를 막는 한도가 웹 서버와 PHP 양쪽에 따로 있고, 둘이 실패하는 모습이 전혀 다르기 때문이다.

증상은 두 가지로 갈린다

첫 번째는 요청이 앱까지 오지도 못하는 경우다. 브라우저 개발자 도구의 네트워크 탭을 열면 상태 코드가 413 으로 찍혀 있다. 그런데 화면에는 그 사실이 제대로 안 보인다. 자바스크립트로 올리는 화면이라면 그냥 "업로드 실패" 한 줄이고, 일반 폼이라면 nginx 기본 오류 페이지가 잠깐 뜨거나 연결이 끊긴 것처럼 보인다. 앱 로그가 비어 있는 게 당연하다. PHP 는 이 요청을 받은 적이 없다.

두 번째가 더 고약하다. 요청은 PHP 까지 들어왔는데 컨트롤러에서 보면 $_FILES 도 $_POST 도 비어 있다. 파일만 빠진 게 아니라 제목, 설명, CSRF 토큰까지 몽땅 사라진다. 그래서 "제목을 입력하세요" 같은 엉뚱한 검증 오류가 뜨거나, CSRF 검사에 걸려 419·403 이 나온다. 이쪽을 보고 업로드 크기를 떠올리기는 쉽지 않다.

두 증상 모두 작은 파일에서는 재현되지 않는다는 게 공통점이다. 테스트할 때 쓰는 스크린샷 몇 장은 다 통과하고, 실제 사용자가 휴대폰으로 찍은 영상이나 고해상도 사진을 올릴 때만 터진다.

파일만이 아니라 폼 값 전체가 비어 있다면, 검증 로직보다 업로드 크기 한도를 먼저 의심한다.

curl 로 크기를 바꿔 가며 재현한다

브라우저로 헤매지 말고 크기가 정해진 더미 파일을 만들어 curl 로 보내 본다. 1MB 아래, 1MB 조금 위, 10MB 처럼 몇 단계로 나눠 보면 어느 한도에서 막히는지 바로 갈린다. 상태 코드와 함께 응답 본문 앞부분을 보면 nginx 가 막았는지 PHP 가 받았는지도 구분된다.

nginx 가 막은 경우는 상태 코드가 413 이고 본문에 nginx 라는 글자가 들어간 짧은 HTML 이 온다. PHP 까지 간 경우는 앱이 내는 평소 응답이 오는데, 업로드 처리 결과만 이상하다. 이때는 PHP 쪽 에러 로그를 함께 본다. post_max_size 를 넘긴 요청은 POST Content-Length 가 한도를 넘었다는 경고 한 줄을 남긴다. 앱 로그가 아니라 PHP-FPM 이나 웹 서버의 에러 로그 쪽이라 놓치기 쉽다.

# 크기별 더미 파일
for n in 500K 1500K 3M 10M; do head -c $n /dev/urandom > /tmp/up-$n.bin; done

# 상태 코드와 본문 앞부분만 본다
for n in 500K 1500K 3M 10M; do
  printf '%s  ' $n
  curl -s -o /tmp/res.txt -w '%{http_code}\n' \
    -F "title=test" -F "file=@/tmp/up-$n.bin" https://example.com/upload
  head -c 120 /tmp/res.txt; echo
done

# PHP 쪽 경고 확인 (경로는 배포판마다 다르다)
sudo grep -i 'exceeds the limit' /var/log/php*-fpm.log | tail -3

크기를 몇 단계로 나눠 어느 층에서 막히는지 가른다

한도는 세 겹이고 기본값이 다 작다

가장 바깥은 nginx 의 client_max_body_size 다. 요청 본문 크기의 상한이고 기본값이 1m 다. 이 값을 넘으면 nginx 는 413 Request Entity Too Large 를 돌려주고 요청을 뒤로 넘기지 않는다. nginx 문서에도 브라우저가 이 오류를 제대로 보여 주지 못한다고 따로 적혀 있다. 화면만 봐서는 원인을 알기 어려운 이유가 여기 있다.

nginx 를 통과하면 PHP 의 두 한도가 기다린다. post_max_size 는 POST 본문 전체의 상한으로 기본값이 8M 이고, upload_max_filesize 는 파일 하나의 상한으로 기본값이 2M 다. 순서가 중요하다. post_max_size 는 파일과 폼 필드를 다 합친 크기라서 upload_max_filesize 보다 커야 한다.

두 PHP 한도는 넘었을 때 동작이 다르다. upload_max_filesize 를 넘긴 파일은 $_FILES 에 항목은 남고 error 값이 UPLOAD_ERR_INI_SIZE, 즉 1 로 들어온다. 코드에서 error 를 확인하면 잡을 수 있다. 반면 post_max_size 를 넘기면 PHP 는 본문을 아예 해석하지 않는다. $_POST 와 $_FILES 가 둘 다 빈 배열이 되고, 앱이 받을 수 있는 실마리는 하나도 남지 않는다. 앞에서 본 "폼 전체가 비는" 증상이 바로 이것이다.

그래서 로컬에서는 멀쩡했던 것이다. 개발용 내장 서버나 도커 이미지는 nginx 를 거치지 않거나 php.ini 를 개발용으로 넉넉하게 잡아 두는 일이 많다. 운영 서버에 와서야 1m 와 2M 라는 기본값을 처음 만난다.

<?php
// post_max_size 초과는 $_FILES 가 비는 것으로만 드러난다
$len = (int)($_SERVER['CONTENT_LENGTH'] ?? 0);
if ($_SERVER['REQUEST_METHOD'] === 'POST' && $len > 0 && empty($_POST) && empty($_FILES)) {
    http_response_code(413);
    exit('업로드 용량이 서버 한도를 넘었습니다.');
}

// upload_max_filesize 초과는 error 코드로 남는다
$err = $_FILES['file']['error'] ?? UPLOAD_ERR_NO_FILE;
if ($err === UPLOAD_ERR_INI_SIZE) {
    exit('파일 하나가 너무 큽니다.');
}

빈 폼과 크기 초과를 구분해 사용자에게 제대로 알려 준다

post_max_size 를 넘기면 PHP 는 본문을 통째로 버린다. 에러 코드도, 남는 필드도 없다.

바깥에서 안쪽 순서로 값을 맞춘다

고칠 때는 세 값을 한 번에 맞춘다. 허용할 파일 크기를 먼저 정하고, upload_max_filesize 를 그 값으로, post_max_size 를 그보다 조금 크게, client_max_body_size 를 post_max_size 이상으로 잡는다. 하나만 올리면 다음 층에서 다시 막힌다. nginx 만 50m 로 올리고 PHP 를 그대로 두면 413 은 사라지지만, 이번엔 폼이 비는 두 번째 증상으로 바뀔 뿐이다.

client_max_body_size 는 http, server, location 어디에나 쓸 수 있다. 사이트 전체를 열어 두기보다 업로드를 받는 location 에만 크게 주는 편이 안전하다. 0 으로 두면 검사 자체를 끄는 것이라 운영에서는 쓰지 않는다.

PHP 쪽은 한 가지 함정이 더 있다. 두 값 모두 INI_PERDIR 라서 코드 안에서 ini_set 으로 바꿀 수 없다. 요청 본문은 스크립트가 돌기 전에 이미 해석이 끝나기 때문이다. php.ini 나 PHP-FPM 풀 설정, 아니면 문서 루트의 .user.ini 로 바꿔야 한다. .user.ini 는 FastCGI 에서만 읽고 기본 5분 동안 캐시하니, 고친 직후 바로 확인하려면 PHP-FPM 을 다시 읽혀야 한다.

적용이 됐는지는 설정 파일이 아니라 실제로 돌고 있는 값으로 확인한다. php -i 는 CLI 설정을 보여 줄 뿐이라 FPM 값과 다를 수 있다. 웹으로 도는 PHP 가 읽은 값을 봐야 한다.

# /etc/nginx/sites-available/app.conf
location /upload {
    client_max_body_size 64m;
    include fastcgi_params;
    fastcgi_pass unix:/run/php/php8.4-fpm-app.sock;
}

# /etc/php/8.4/fpm/pool.d/app.conf (풀 단위로 덮어쓰기)
php_admin_value[upload_max_filesize] = 50M
php_admin_value[post_max_size] = 60M

# 적용 후 문법 확인과 재적재
sudo nginx -t && sudo systemctl reload nginx
sudo php-fpm8.4 -t && sudo systemctl reload php8.4-fpm

upload_max_filesize ≤ post_max_size ≤ client_max_body_size 순으로 맞춘다

다시 같은 일을 겪지 않으려면

한도 값을 코드와 같은 곳에서 관리한다. 업로드 상한을 앱 설정에 한 번 적고, nginx 와 PHP-FPM 설정 템플릿이 그 값을 같이 쓰게 해 두면 셋이 따로 노는 일이 줄어든다. 적어도 배포 문서에 세 값을 나란히 적어 두면, 누가 한 곳만 바꿨을 때 눈에 띈다.

화면에서도 미리 막는다. 브라우저에서 파일을 고른 순간 크기를 재서 상한을 넘으면 보내기 전에 알려 주면, 사용자는 몇 분짜리 업로드를 기다렸다가 실패하지 않아도 된다. 서버 쪽 한도는 그대로 두고 화면 검사를 한 겹 더 얹는 것이다. 그리고 배포 뒤 점검 목록에 한도보다 조금 작은 파일과 조금 큰 파일을 하나씩 올려 보는 항목을 넣어 두면, 다음 서버 이전 때 같은 자리에서 다시 넘어지지 않는다.

조치 후 확인할 것

  • 한도보다 조금 작은 파일은 올라가고 조금 큰 파일은 413 또는 안내 문구로 끝나는지 curl 로 확인한다.
  • 웹으로 도는 PHP 의 upload_max_filesize·post_max_size 값을 phpinfo 나 ini_get 으로 찍어 설정 파일 값과 같은지 본다.
  • post_max_size 가 upload_max_filesize 보다 크고, client_max_body_size 가 post_max_size 이상인지 확인한다.
  • 폼이 통째로 비는 요청을 413 으로 돌려주는 분기가 컨트롤러 앞단에 있는지 본다.

업로드 문제는 대개 코드보다 한 층 바깥에서 생긴다. 413 이 보이면 nginx, 폼이 비면 post_max_size, error 가 1 이면 upload_max_filesize. 이 세 갈래만 기억해 두면 로그가 비어 있어도 어디를 열어야 할지 정해진다.