Node.js MIMEType 가이드: Content-Type 파싱과 업로드 검증을 안전하게 다루는 법
HTTP API와 파일 업로드를 다루다 보면 Content-Type을 문자열로 비교하는 코드가 자주 나옵니다.
처음에는 contentType === 'application/json' 정도로 충분해 보이지만, 운영에서는 application/json; charset=utf-8, 대소문자 차이, 잘못된 파라미터, 비어 있는 헤더가 함께 들어옵니다.
이런 값을 매번 직접 쪼개면 정상 입력을 거부하거나, 반대로 막아야 할 입력을 통과시키기 쉽습니다.
Node.js에는 이런 문자열을 구조적으로 다룰 수 있는 node:util의 MIMEType과 MIMEParams가 있습니다.
MIMEType은 MIME 타입의 type, subtype, essence, params를 분리해 주고, MIMEParams는 charset 같은 파라미터를 맵처럼 다룰 수 있게 해 줍니다.
덕분에 Content-Type 검증 코드를 문자열 파싱이 아니라 명확한 정책 코드로 바꿀 수 있습니다.
이 글에서는 Node.js MIMEType을 이용해 Content-Type을 파싱하고, API 요청과 업로드 검증에 적용하는 기준을 정리합니다.
HTTP 헤더 자체의 유효성 검사는 Node.js HTTP validateHeaderName/Value 가이드, Blob 기반 업로드 흐름은 Node.js fs.openAsBlob 가이드, 중복 헤더 처리는 Node.js headersDistinct 가이드와 함께 보면 좋습니다.
MIMEType이 필요한 이유
문자열 비교는 실제 헤더 형태를 놓치기 쉽다
Content-Type은 단순한 타입 이름만 담지 않습니다.
미디어 타입 뒤에 charset, boundary 같은 파라미터가 붙을 수 있고, 클라이언트 구현에 따라 대소문자가 섞여 들어올 수도 있습니다.
const contentType = 'Application/JSON; Charset=UTF-8';
console.log(contentType === 'application/json');
// false
이런 비교는 너무 엄격합니다.
API가 실제로 받고 싶은 것은 JSON 본문이지, 문자열이 정확히 application/json과 같은지 여부가 아닙니다.
반대로 contentType.includes('json')처럼 느슨하게 검사하면 text/plain+jsonish 같은 값을 잘못 통과시킬 수 있습니다.
MIMEType을 사용하면 비교 기준을 essence로 좁힐 수 있습니다.
import { MIMEType } from 'node:util';
const type = new MIMEType('Application/JSON; Charset=UTF-8');
console.log(type.essence);
// application/json
console.log(type.params.get('charset'));
// UTF-8
essence는 파라미터를 제외한 핵심 MIME 타입입니다.
검증 정책은 이 값을 기준으로 두고, charset이나 boundary 같은 세부 조건은 별도로 확인하는 편이 읽기 쉽습니다.
파싱 실패를 명시적인 거부로 처리한다
외부 입력은 비어 있거나 잘못된 문자열일 수 있습니다.
MIMEType 생성자는 파싱할 수 없는 값이 들어오면 예외를 던집니다.
따라서 헤더 검증 함수에서는 예외를 잡아 “지원하지 않는 타입”과 “형식이 잘못된 타입”을 구분하는 것이 좋습니다.
import { MIMEType } from 'node:util';
export function parseContentType(value) {
if (!value || typeof value !== 'string') {
return null;
}
try {
return new MIMEType(value);
} catch {
return null;
}
}
이 작은 래퍼를 두면 이후 코드는 null만 확인하면 됩니다.
검증 실패를 예외 흐름으로 계속 끌고 가지 않아 API 핸들러와 테스트도 단순해집니다.
JSON API에서 Content-Type 검증하기
essence 기준으로 allowlist를 만든다
JSON API 서버라면 요청 본문을 읽기 전에 Content-Type을 확인해야 합니다.
이때 파라미터가 붙은 정상 JSON 요청까지 허용하려면 essence 기준 allowlist가 적합합니다.
import { MIMEType } from 'node:util';
const allowedJsonTypes = new Set([
'application/json',
'application/problem+json',
'application/vnd.api+json'
]);
export function assertJsonContentType(headerValue) {
let mime;
try {
mime = new MIMEType(headerValue);
} catch {
throw Object.assign(new Error('Invalid Content-Type'), { statusCode: 415 });
}
if (!allowedJsonTypes.has(mime.essence)) {
throw Object.assign(new Error(`Unsupported Content-Type: ${mime.essence}`), {
statusCode: 415
});
}
return mime;
}
application/problem+json이나 vendor 타입까지 허용할지는 서비스 계약에 따라 다릅니다.
중요한 것은 허용할 타입을 코드에 명확히 남기는 것입니다.
문자열 포함 검사보다 리뷰하기 쉽고, 새 타입을 추가할 때도 변경 범위가 작습니다.
charset은 필요한 경우에만 제한한다
JSON은 대부분 UTF-8로 처리하지만, 모든 API에서 charset=utf-8 파라미터가 반드시 있어야 하는 것은 아닙니다.
오히려 파라미터가 없다는 이유로 정상 요청을 거부하면 클라이언트 호환성이 나빠질 수 있습니다.
export function assertUtf8JsonContentType(headerValue) {
const mime = assertJsonContentType(headerValue);
const charset = mime.params.get('charset');
if (charset && charset.toLowerCase() !== 'utf-8') {
throw Object.assign(new Error(`Unsupported charset: ${charset}`), {
statusCode: 415
});
}
return mime;
}
이 함수는 charset이 없으면 통과시키고, 명시되어 있을 때만 UTF-8인지 확인합니다.
서비스가 반드시 UTF-8 명시를 요구한다면 조건을 더 엄격하게 바꿀 수 있지만, 그 정책은 API 문서에도 함께 적어야 합니다.
파일 업로드에서 MIMEType 사용하기
헤더 타입과 파일 확장자를 따로 본다
파일 업로드 검증에서 Content-Type은 중요한 신호지만, 단독 보안 장치가 아닙니다.
클라이언트가 보내는 헤더는 조작될 수 있고, 파일 확장자도 실제 내용과 다를 수 있습니다.
따라서 MIMEType은 1차 분류와 정책 표현에 쓰고, 필요한 경우 파일 시그니처 검사나 이미지 디코딩 검증을 추가해야 합니다.
import { MIMEType } from 'node:util';
const allowedUploadTypes = new Set([
'image/jpeg',
'image/png',
'image/webp'
]);
export function validateUploadContentType(headerValue) {
let mime;
try {
mime = new MIMEType(headerValue);
} catch {
return {
ok: false,
reason: 'invalid-content-type'
};
}
if (!allowedUploadTypes.has(mime.essence)) {
return {
ok: false,
reason: 'unsupported-content-type',
contentType: mime.essence
};
}
return {
ok: true,
contentType: mime.essence
};
}
이 함수는 사용자에게 그대로 노출할 메시지와 내부 로그를 분리하기 쉽습니다.
응답에는 “지원하지 않는 파일 형식”처럼 간단히 말하고, 서버 로그에는 contentType과 reason을 남기면 됩니다.
multipart boundary는 존재 여부를 확인한다
multipart/form-data 업로드에서는 boundary 파라미터가 중요합니다.
본문 파서가 boundary를 사용해 각 파트를 나누기 때문입니다.
직접 multipart를 파싱하지 않더라도, 프록시나 간단한 서버 레이어에서 사전 검증을 할 때는 boundary 누락을 빠르게 거부할 수 있습니다.
import { MIMEType } from 'node:util';
export function assertMultipartContentType(headerValue) {
let mime;
try {
mime = new MIMEType(headerValue);
} catch {
throw Object.assign(new Error('Invalid multipart Content-Type'), {
statusCode: 400
});
}
if (mime.essence !== 'multipart/form-data') {
throw Object.assign(new Error('Expected multipart/form-data'), {
statusCode: 415
});
}
if (!mime.params.has('boundary')) {
throw Object.assign(new Error('Missing multipart boundary'), {
statusCode: 400
});
}
return mime;
}
이 검사는 업로드 파서를 대체하지 않습니다. 다만 요청이 명백히 잘못되었을 때 더 앞단에서 일관된 에러를 반환하는 데 도움이 됩니다.
fetch 응답 검증에 적용하기
응답 body를 읽기 전에 타입을 확인한다
외부 API를 호출할 때도 Content-Type 확인은 유용합니다.
상대 서버가 오류 페이지 HTML을 반환했는데, 클라이언트 코드가 무조건 response.json()을 호출하면 원인을 알기 어려운 파싱 에러로 바뀝니다.
import { MIMEType } from 'node:util';
function getResponseMime(response) {
const value = response.headers.get('content-type');
if (!value) {
return null;
}
try {
return new MIMEType(value);
} catch {
return null;
}
}
export async function readJsonResponse(response) {
const mime = getResponseMime(response);
if (!mime || mime.essence !== 'application/json') {
const preview = await response.text();
throw new Error(
`Expected JSON response, got ${mime?.essence ?? 'missing'}: ${preview.slice(0, 120)}`
);
}
return response.json();
}
운영 코드에서는 preview를 로그에 남길 때 개인정보나 토큰이 포함되지 않도록 길이와 대상 로그를 제한해야 합니다.
응답 본문 전체를 에러 메시지에 넣는 방식은 피하는 편이 좋습니다.
문제 응답 타입을 별도로 다룬다
API가 application/problem+json을 사용한다면 성공 응답과 오류 응답의 타입 정책을 분리하는 것이 좋습니다.
성공 응답은 application/json만 허용하고, 오류 응답에서는 application/problem+json을 별도 파서로 넘길 수 있습니다.
export function classifyJsonResponse(response) {
const mime = getResponseMime(response);
const essence = mime?.essence;
if (response.ok && essence === 'application/json') {
return 'success-json';
}
if (!response.ok && essence === 'application/problem+json') {
return 'problem-json';
}
return 'unexpected';
}
이런 분류는 재시도, 알림, 사용자 메시지 생성에도 영향을 줍니다. Content-Type 검증을 단순 파싱 문제가 아니라 API 계약 확인 단계로 보는 것이 핵심입니다.
MIMEParams를 수정할 때의 주의점
params는 구조화된 값이지만 정책은 직접 정한다
MIMEType의 params는 MIMEParams 객체입니다.
get, set, has, delete 같은 메서드로 파라미터를 다룰 수 있어 테스트 fixture나 요청 생성 코드에서 유용합니다.
import { MIMEType } from 'node:util';
const mime = new MIMEType('text/plain');
mime.params.set('charset', 'utf-8');
console.log(String(mime));
// text/plain;charset=utf-8
다만 파라미터를 쉽게 수정할 수 있다고 해서 입력을 자동으로 신뢰해도 된다는 뜻은 아닙니다.
예를 들어 이미지 업로드에서 image/png; charset=utf-8 같은 값이 들어오면 파라미터를 무시할지, 거부할지 정책을 정해야 합니다.
간단한 allowlist는 essence만 보고 통과시킬 수 있지만, 엄격한 업로드 서버라면 예상하지 못한 파라미터를 거부하는 편이 진단하기 쉽습니다.
export function hasOnlyExpectedParams(mime, expectedNames) {
for (const [name] of mime.params) {
if (!expectedNames.has(name)) {
return false;
}
}
return true;
}
정책 함수는 작게 유지하세요. MIME 파싱, 허용 타입, 허용 파라미터, 실제 파일 내용 검증을 한 함수에 몰아넣으면 테스트 케이스가 급격히 복잡해집니다.
운영 체크리스트
입력 경계마다 실패 방식을 통일한다
MIME 타입 검증은 요청 본문 파싱 전, 업로드 저장 전, 외부 응답 디코딩 전에 놓는 것이 좋습니다. 각 경계에서 실패 방식을 통일하면 장애 분석이 쉬워집니다.
- 헤더가 없으면
missing-content-type으로 분류한다. - 파싱할 수 없으면
invalid-content-type으로 분류한다. - 파싱은 됐지만 허용 목록에 없으면
unsupported-content-type으로 분류한다. - 파라미터가 정책과 다르면
unsupported-content-type-parameter로 분류한다. - 파일 내용 검증 실패는
content-mismatch처럼 별도 사유로 남긴다.
응답 코드도 일관되게 정해야 합니다.
요청 Content-Type이 지원되지 않으면 보통 415 Unsupported Media Type이 자연스럽고, multipart boundary 누락처럼 요청 형식이 깨진 경우는 400 Bad Request가 더 읽기 쉽습니다.
보안 경계를 과대평가하지 않는다
MIMEType은 문자열을 구조화하는 도구입니다.
파일이 실제로 안전한지, 이미지가 디코딩 가능한지, HTML이나 SVG 안에 위험한 내용이 없는지까지 보장하지 않습니다.
사용자 업로드를 저장하거나 공개로 서빙한다면 아래 조건도 함께 확인해야 합니다.
- 허용 MIME 타입과 허용 확장자를 함께 관리한다.
- 이미지 파일은 가능한 경우 실제 디코더로 열어 본다.
- SVG, HTML, PDF처럼 스크립트나 링크가 포함될 수 있는 형식은 별도 정책을 둔다.
- 업로드 파일명은 신뢰하지 않고 서버에서 새 이름을 만든다.
- 응답 로그에는 업로드 원문이나 민감한 헤더를 남기지 않는다.
MIME 타입 검증은 방어선 중 하나입니다. 문자열 파싱 실수를 줄이고 정책을 명확히 만드는 데 집중해야 합니다.
FAQ
MIMEType만 쓰면 업로드 파일을 안전하게 검증할 수 있나요?
아니요.
MIMEType은 클라이언트가 보낸 Content-Type 문자열을 파싱할 뿐입니다.
업로드 보안에는 파일 크기 제한, 확장자 정책, 실제 파일 내용 검사, 저장 경로 분리, 공개 서빙 정책이 함께 필요합니다.
application/json; charset=utf-8은 application/json과 다르게 봐야 하나요?
대부분의 JSON API에서는 같은 JSON 타입으로 보고, charset은 별도 조건으로 확인하는 편이 실용적입니다.
MIMEType의 essence가 application/json이면 핵심 타입은 JSON이고, params.get('charset')으로 세부 파라미터를 판단할 수 있습니다.
직접 split(‘;’)로 나누면 안 되나요?
간단한 내부 스크립트에서는 가능하지만, 서버 경계에서는 권장하기 어렵습니다. 대소문자, 공백, 잘못된 파라미터, 예외 처리를 직접 관리해야 하기 때문입니다. Node.js 런타임이 제공하는 구조화 도구를 쓰면 검증 코드가 더 짧고 테스트하기 쉬워집니다.
마무리
Node.js MIMEType은 작지만 실무에서 자주 반복되는 Content-Type 처리 코드를 정리해 줍니다.
핵심은 MIME 문자열을 직접 비교하지 않고, 먼저 구조화한 뒤 essence와 params를 기준으로 정책을 적용하는 것입니다.
API 요청, 파일 업로드, 외부 fetch 응답처럼 입력 경계가 분명한 곳부터 적용해 보세요. 검증 실패 사유가 명확해지고, 보안 리뷰와 장애 분석에서 “어떤 타입을 왜 거부했는지”를 더 쉽게 설명할 수 있습니다.
이 글은 AI가 작성/정리하고, 오너가 방향을 결정한 프로젝트 운영 로그입니다.