Next.js AI 서비스에서 유해 콘텐츠 업로드 차단하기
격리 업로드 구조를 통해 AI 서비스에서 유해 콘텐츠를 업로드부터 검증까지 서버를 거치지 않고도 안전하게 처리하는 방법을 단계별로 제시합니다. 핵심은 사전 검증 전 파일 노출 차단, 1회용 업로드 URL, 판별 API의 점수 기반 처리, 그리고 영상/프롬프트까지의 포괄적 방어 체계입니다.
목차
사용자가 파일을 업로드할 수 있는 AI 서비스는 출시 초기부터 성인물(유해 콘텐츠) 게시를 차단할 수 있는 선제적 장치를 반드시 마련해야 합니다. 배포된 파일은 캐시나 캡처 등을 통해 확산될 위험이 크므로, 사후 삭제 방식으로는 대응에 한계가 있습니다.
본 문서에서는 Next.js 서비스 환경에서 적용 가능한 '격리 업로드(Isolated Upload)' 구조를 단계별로 체계화합니다. 핵심은 사전 검증이 완료되기 전까지는 어떠한 경로로도 파일이 외부로 노출되지 않도록 제어하는 것입니다. 코드 예시는 AWS S3 및 OpenAI Moderation API를 기준으로 작성되었으나, 타 스토리지나 판별 API를 활용하더라도 동일한 아키텍처로 구현이 가능합니다.
mindmap
root(("브라우저"))
n1("격리버킷")
n2("서버 검사")
n3("공개버킷")
n4("사람검토")
n5("삭제")
격리 업로드 구성
격리 업로드 방식은 버킷을 이원화하는 것에서 시작합니다. 격리 버킷은 외부 접근이 차단된 비공개 상태로 유지하고, 공개 버킷만을 CDN과 연동합니다. 이때 사용자는 격리 버킷으로만 파일을 업로드할 수 있습니다.
파일 전송 시 Next.js 서버를 경유하지 않도록 구성합니다. 서버는 1회용 업로드 주소(Presigned URL)를 생성하여 전달하고, 브라우저가 해당 주소를 통해 스토리지로 직접 업로드하는 방식을 취합니다. 이를 통해 서버 부하를 방지하고 용량 제한 문제를 효율적으로 해결할 수 있습니다.
// app/api/upload-url/route.ts
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
const s3 = new S3Client({ region: "ap-northeast-2" });
export async function POST(req: Request) {
const user = await requireUser(req); // 로그인 확인
const { contentType, size } = await req.json();
if (!ALLOWED_TYPES.includes(contentType)) return new Response(null, { status: 415 });
if (size > MAX_BYTES) return new Response(null, { status: 413 });
const key = `quarantine/${crypto.randomUUID()}`;
const url = await getSignedUrl(
s3,
new PutObjectCommand({
Bucket: process.env.QUARANTINE_BUCKET,
Key: key,
ContentType: contentType,
}),
{ expiresIn: 60 } // 60초 뒤 만료
);
await db.upload.create({ data: { key, userId: user.id, status: "pending" } });
return Response.json({ url, key });
}이 작업 수행 시 준수해야 할 네 가지 핵심 사항을 다음과 같이 공유드립니다.
첫째, 파일명은 보안을 위해 사용자 입력값을 배제하고 서버에서 무작위로 생성하여 관리합니다.
둘째, 데이터베이스에는 'pending' 상태로 기록하며, 사용자 화면에는 '검토 중'으로 표기합니다.
셋째, 파일 업로드 URL의 유효 시간은 최소화하여 운영합니다.
넷째, 파일 형식은 확장자가 아닌 실제 콘텐츠를 기반으로 검증합니다. 확장자 및 Content-Type은 변조 가능성이 있으므로 신뢰하지 않습니다.
서버 처리
검사는 반드시 서버에서 합니다. 브라우저에서 돌리는 검사는 요청을 직접 보내는 것만으로 우회됩니다. 업로드가 끝나면 브라우저가 완료 API를 호출하고, 서버가 격리 버킷의 파일을 판별 API에 넘깁니다. 이때 판별 API가 파일을 읽을 수 있도록 유효 시간이 짧은 읽기 전용 주소를 만들어 전달합니다.
// app/api/upload-complete/route.ts
import OpenAI from "openai";
import { GetObjectCommand } from "@aws-sdk/client-s3";
const openai = new OpenAI();
export async function POST(req: Request) {
const user = await requireUser(req);
const { key } = await req.json();
const upload = await db.upload.findFirst({ where: { key, userId: user.id } });
if (!upload || upload.status !== "pending") return new Response(null, { status: 404 });
const readUrl = await getSignedUrl(
s3,
new GetObjectCommand({ Bucket: process.env.QUARANTINE_BUCKET, Key: key }),
{ expiresIn: 120 }
);
const res = await openai.moderations.create({
model: "omni-moderation-latest",
input: [{ type: "image_url", image_url: { url: readUrl } }],
});
const score = res.results[0].category_scores.sexual;
const status = await decide(key, score); // 3단계에서 설명
return Response.json({ status });
}이미지 처리는 요청 내에서 즉시 수행 가능합니다. 영상과 같이 처리 시간이 긴 작업은 서버리스 함수의 실행 시간 제한이 있으므로, 큐에 적재하여 별도 워커를 통해 처리되도록 설계해야 합니다.
또한, 브라우저가 완료 API를 호출하지 않고 이탈할 가능성에 대비해야 합니다. 일정 시간 이상 ‘pending’ 상태인 파일은 주기적으로 정리하거나, 스토리지의 업로드 이벤트를 트리거로 검사를 시작하도록 구현하여 프로세스의 누락을 방지하시기 바랍니다.
점수 처리
판별 API는 성인물 여부를 이분법적으로 구분하지 않고 0에서 1 사이의 점수로 반환하며, 두 개의 기준선을 설정하여 세 구간으로 분류합니다.
const BLOCK = 0.8; // 이 이상이면 자동 차단
const REVIEW = 0.3; // 이 이상이면 사람 검토
async function decide(key: string, score: number) {
if (score >= BLOCK) {
await s3.send(new DeleteObjectCommand({ Bucket: QUARANTINE, Key: key }));
return setStatus(key, "rejected", score);
}
if (score >= REVIEW) {
return setStatus(key, "review", score); // 격리 버킷에 그대로 둠
}
const publicKey = key.replace("quarantine/", "public/");
await s3.send(new CopyObjectCommand({
Bucket: PUBLIC,
Key: publicKey,
CopySource: `${QUARANTINE}/${key}`,
}));
await s3.send(new DeleteObjectCommand({ Bucket: QUARANTINE, Key: key }));
return setStatus(key, "approved", score);
}0.8과 0.3은 예시값입니다. 실제 서비스에 올라올 법한 샘플을 모아 실행해 보며 놓친 비율과 잘 막은 비율을 확인하고 조정해야 합니다. 가장 중요한 규칙은 오류가 발생하면 통과시키지 않는 것입니다. 판별 API가 응답하지 않거나 오류가 나면 파일은 pending에 남고 재시도 대상이 됩니다. 장애가 난 상태에서 검사를 건너뛰게 만들면 위험할 수 있습니다.
영상, 프롬프트, AI 생성물
이미지 외의 경로도 같은 원칙으로 막습니다. 영상은 ffmpeg로 1~2초 간격의 프레임을 뽑아 각각 이미지처럼 검사합니다. 한 장이라도 차단선을 넘으면 영상 전체를 거부합니다. 프레임 수가 곧 비용이므로 업로드 가능한 영상 길이에 상한을 둡니다.
텍스트 프롬프트는 생성 요청을 처리하기 전에 텍스트 검사를 거칩니다. 같은 판별 API에 문자열을 넘기면 됩니다. AI가 만든 결과물도 사용자에게 보여주기 전에 2단계와 3단계를 똑같이 거칩니다. 입력만 막으면 우회 표현에 뚫리기 때문에, 실제로는 출력 검사가 더 든든한 방어선입니다.
판별 API와 비용
처음에는 무료인 OpenAI Moderation으로 시작하고, 영상이나 세밀한 분류가 필요해질 때 유료 서비스로 넘어가는 순서가 가장 경제적입니다. 아래 가격은 2026년 10월 7일에 확인한 값이며 단위는 미국 달러입니다.
| 서비스 | 지원 형식 | 가격 | 이미지 월 1만 장 |
|---|---|---|---|
| OpenAI Moderation | 텍스트, 이미지 | 무료 | $0 |
| Azure AI Content Safety | 텍스트, 이미지 | 이미지 1,000장당 $0.75, 텍스트 1,000건당 $0.38 | 약 $7.5 |
| AWS Rekognition | 이미지, 영상 | 이미지 1장당 $0.001, 영상 1분당 $0.10 | 약 $10 |
| Google Cloud Vision SafeSearch | 이미지 | 매달 첫 1,000장 무료, 이후 1,000장당 $1.50 | 약 $13.5 |
| Sightengine | 텍스트, 이미지, 영상 | 무료 월 2,000건, Starter 월 $29에 1만 건, 초과 건당 $0.002 | $29 |
| Hive Moderation | 텍스트, 이미지, 영상, 오디오 | 이미지 1,000건당 $3.00, 텍스트 1,000건당 $1.50 | 약 $30 |
OpenAI는 무료이지만 호출 한도가 있으며, 일반 유해성 분류로 인해 노출 수위를 세밀하게 나누지는 못합니다. 또한 영상은 프레임을 직접 뽑아 보내야 합니다. Sightengine은 한 이미지에 판별 모델을 여러 개 돌리면 모델 수만큼 건수가 차감됩니다. 영상의 경우 기본적으로 초당 1프레임으로 처리되므로 건수가 빨리 소진됩니다. AWS의 영상 전용 API는 프레임 추출 방식에 비해 비용 면에서 유리하지 않습니다. 60초 영상 기준으로 영상 API의 비용은 0.10달러이고, 이미지 API로 초당 2프레임을 보내면 0.12달러입니다.
사후 장치와 법적 의무
자동 검사는 완벽하지 않으므로, 통과한 뒤에도 걸러낼 장치를 두는 것이 좋습니다. 신고 버튼은 모든 콘텐츠에 달고, 신고가 누적되면 자동으로 비공개 전환한 뒤 재검토합니다. 계정 제재는 위반 횟수를 계정에 기록하고, 반복되면 업로드를 제한하거나 정지합니다. 판정 로그는 점수, 시각, 처리 결과를 남깁니다. 기준선을 조정할 때와 분쟁이 생겼을 때 근거가 됩니다. 재업로드 차단은 거부된 파일의 해시를 저장해 같은 파일이 다시 올라오면 즉시 막도록 하세요.
매우 중요한 사안
아동 성착취물은 일반 성인물과 다르게 다뤄야 합니다. 의심되는 파일은 일반 검토 대기열에 넣지 말고 즉시 차단합니다. 보관, 신고, 삭제 절차에 법적 의무가 걸려 있으므로 출시 전에 전문가 확인을 받는 것이 안전합니다.
한국에서는 일정 규모 이상의 사업자에게 불법촬영물 필터링 같은 기술적 조치 의무가 있습니다. 내 서비스가 대상인지는 이용자 수와 매출 기준으로 갈리므로, 이 역시 출시 전에 확인할 항목입니다.
댓글 0
- 댓글을 불러오는 중이에요.