이미지 업로드와 모더레이션
채팅에 사진을 올리게 하는 법. 엔진은 파일을 받지 않는다. 메시지에는 주소와 메타데이터만 실리고, 파일을 받아 저장하고 검사하는 일은 내 백엔드와 내 버킷이 한다. 이 글은 AWS(S3 + Rekognition) 기준의 권장 구조다.
요점은 하나다: 검사를 통과하기 전의 파일은 비공개 접두사에만 있고, 통과한 파일만 공개 접두사로 옮겨진다. 그러면
before_publish훅이 할 일은 "이 주소가 내 공개 접두사의 이 사람 파일인가"를 문자열로 확인하는 것 하나뿐이다.
엔진이 하는 일과 안 하는 일
| 하는 일 | body.attachments[]를 저장하고 방에 전달한다. 한 메시지에 기본 10개까지, url은 https://여야 한다 |
| 안 하는 일 | 파일을 받지 않는다. 주소가 가리키는 곳을 열어 보지 않고, 형식을 바꾸거나 검사하거나 지우지 않는다 |
| 첨부 모양 | { type: "image", url, key?, mime?, w?, h?, name?, size? } — 엔진이 읽는 것은 url뿐이고 나머지는 화면이 쓴다 |
| 내보내기 | GET /v1/export에는 이 메타데이터만 들어가고 파일은 없다. 파일은 내 버킷에서 따로 가져간다 |
흐름
1 브라우저 ──→ 내 백엔드 업로드 요청 → presigned POST 발급(타입·크기·만료가 서명에 묶임)
2 브라우저 ──→ S3 incoming/ 파일을 S3로 직접 POST (비공개 접두사, 내 서버를 거치지 않는다)
3 브라우저 ──→ 내 백엔드 "다 올렸다" 알림
4 내 백엔드 ←→ S3, Rekognition 읽기 → 재인코딩 → DetectModerationLabels
통과: public/ 에 저장하고 incoming/ 삭제 / 실패: incoming/ 삭제, 422
5 브라우저 ──→ 채팅 엔진 room.send({ attachments: [4단계가 돌려준 첨부] })
6 채팅 엔진 ──→ 내 백엔드 before_publish: 주소가 내 CDN의 public/{보낸 사람}/ 아래인가각 선택의 이유:
| 선택 | 이유 |
|---|---|
| 접두사를 둘로 가른다 | incoming/은 CDN에 물리지 않아 검사 전에는 아무도 볼 수 없다. public/은 백엔드만 쓴다 |
| 타입·크기를 서명 조건으로 묶는다 | 브라우저가 정책을 어기면 S3가 업로드 자체를 거절한다. presigned PUT은 크기를 못 묶으니 POST 정책을 쓴다 |
| 키에 사용자 id를 넣는다 | 훅이 "내 파일만 첨부"를 문자열로 확인할 수 있고, 탈퇴 때 public/{userId}/를 통째로 지울 수 있다 |
| 서버가 S3에서 바이트를 직접 읽는다 | 브라우저가 준 URL을 서버가 받아 오는 방식은 내부 주소를 찌르는 SSRF 통로가 된다 |
| 재인코딩한 뒤 검사한다 | EXIF(촬영 위치 등)가 빠지고, 이미지로 위장한 파일이 걸러지고, 검사한 바이트가 곧 서빙하는 바이트가 된다 |
| 훅은 AWS를 부르지 않는다 | 훅은 발행을 막고 기다리는 동기 호출이다(기본 1.5초, 최대 5초). 느린 검사는 업로드 쪽에서 끝낸다 |
1. 버킷과 권한
버킷 my-chat-uploads는 퍼블릭 액세스를 전부 차단한다. 브라우저가 S3에 직접 POST하므로 CORS가 필요하다.
[
{
"AllowedOrigins": ["https://app.example.com"],
"AllowedMethods": ["POST"],
"AllowedHeaders": ["*"],
"MaxAgeSeconds": 3000
}
]백엔드 역할(role)의 최소 권한이다. 키는 코드에 넣지 않고 실행 환경(EC2·ECS·Lambda)의 IAM 역할로 준다.
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "Incoming",
"Effect": "Allow",
"Action": ["s3:PutObject", "s3:GetObject", "s3:DeleteObject"],
"Resource": "arn:aws:s3:::my-chat-uploads/incoming/*"
},
{
"Sid": "Public",
"Effect": "Allow",
"Action": ["s3:PutObject", "s3:DeleteObject"],
"Resource": "arn:aws:s3:::my-chat-uploads/public/*"
},
{
"Sid": "ListPublicForCleanup",
"Effect": "Allow",
"Action": "s3:ListBucket",
"Resource": "arn:aws:s3:::my-chat-uploads",
"Condition": { "StringLike": { "s3:prefix": ["public/*"] } }
},
{
"Sid": "Moderation",
"Effect": "Allow",
"Action": "rekognition:DetectModerationLabels",
"Resource": "*"
}
]
}검사 전 파일은 incoming/에만 올라가므로, 검사에 실패하거나 사용자가 3단계를 건너뛴 파일은 수명 규칙으로 치운다.
{
"Rules": [
{
"ID": "expire-incoming",
"Status": "Enabled",
"Filter": { "Prefix": "incoming/" },
"Expiration": { "Days": 1 }
}
]
}2. 백엔드: 업로드 허가증 발급
로그인한 사용자에게만 발급한다. 정책에 정확한 키·콘텐츠 타입·크기 범위·만료를 넣고, 키는 서버가 정한다.
npm install @aws-sdk/client-s3 @aws-sdk/s3-presigned-post @aws-sdk/client-rekognition sharp// uploads.ts
import { randomUUID } from 'node:crypto'
import { S3Client } from '@aws-sdk/client-s3'
import { createPresignedPost } from '@aws-sdk/s3-presigned-post'
export const s3 = new S3Client({}) // 리전·자격 증명은 실행 환경에서 온다
export const BUCKET = process.env.UPLOAD_BUCKET! // my-chat-uploads
export const MAX_BYTES = 5_000_000
const TYPES = new Set(['image/jpeg', 'image/png', 'image/webp']) // svg는 받지 않는다(스크립트를 품을 수 있다)
// userId는 내 서비스의 유저 id다. 키에 들어가므로 [A-Za-z0-9_-]만 쓰는 값이어야 한다.
export async function issueUpload(userId: string, contentType: string) {
if (!TYPES.has(contentType)) throw new HttpError(400, 'invalid_content_type')
const id = randomUUID()
const { url, fields } = await createPresignedPost(s3, {
Bucket: BUCKET,
Key: `incoming/${userId}/${id}`,
Fields: { 'Content-Type': contentType },
Conditions: [
['eq', '$Content-Type', contentType], // 이 타입 하나만
['content-length-range', 1, MAX_BYTES] // 1바이트 이상 MAX_BYTES 이하
],
Expires: 60 // 초. 짧게
})
return { id, url, fields }
}// 라우트 예시(Express). 인증은 서비스의 세션을 쓴다
app.post('/api/uploads', requireLogin, async (req, res) => {
res.json(await issueUpload(req.user.id, String(req.body.contentType)))
})HttpError는 서비스의 에러 타입으로 바꿔 쓴다. 사용자별 발급 횟수 제한도 여기 건다(아래 「지킬 것」).
3. 브라우저: 올리고, 알리고, 보내기
S3 폼은 file 필드가 맨 마지막이어야 한다. 앞쪽 필드를 먼저 채우고 파일을 마지막에 붙인다.
import { ChatError } from '@kispi/chat'
import { chat } from './chat' // 시작하기의 createChatClient
const room = chat.roomByKey('lobby')
await room.subscribe()
async function sendImage(file: File, text?: string) {
// 1. 허가증
const ticket = await api('/api/uploads', { contentType: file.type })
// 2. S3로 직접 업로드
const form = new FormData()
for (const [name, value] of Object.entries(ticket.fields)) form.append(name, value as string)
form.append('file', file) // 맨 마지막
const up = await fetch(ticket.url, { method: 'POST', body: form })
if (!up.ok) throw new Error('upload_failed')
// 3. 검사와 공개 전환(백엔드). 통과하면 첨부 객체를 돌려준다
const attachment = await api(`/api/uploads/${ticket.id}/complete`)
// 4. 채팅 엔진에 발행. 서버가 돌려준 객체를 그대로 싣는다
try {
await room.send({ text, attachments: [attachment] })
} catch (err) {
if (err instanceof ChatError && err.code === 'moderation_denied') {
showError(err.message) // 훅이 거절했다. err.appCode로 분기할 수 있다
} else throw err
}
}
// POST JSON을 보내고 JSON을 받는 작은 헬퍼. 2xx가 아니면 던진다
async function api(path: string, body?: unknown) {
const res = await fetch(path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body ?? {})
})
if (!res.ok) throw new Error((await res.json()).code ?? 'request_failed')
return res.json()
}4. 백엔드: 검사하고 공개로 옮기기
3단계가 부르는 엔드포인트다. 하는 일은 읽기 → 재인코딩 → 검사 → 공개 저장 → 원본 삭제, 이 순서다.
// uploads.ts (이어서)
import { GetObjectCommand, PutObjectCommand, DeleteObjectCommand } from '@aws-sdk/client-s3'
import { RekognitionClient, DetectModerationLabelsCommand } from '@aws-sdk/client-rekognition'
import sharp from 'sharp'
const rekognition = new RekognitionClient({})
export const CDN = process.env.CDN_ORIGIN! // https://cdn.example.com
export const UUID = '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}'
const MIN_CONFIDENCE = 80 // 이 신뢰도 미만의 라벨은 Rekognition이 돌려주지 않는다
// 서비스 정책으로 고른다. 이름은 AWS 문서의 모더레이션 라벨 목록(L1·L2)에서 가져온다
const BLOCKED = new Set([
'Explicit',
'Graphic Violence',
'Visually Disturbing',
'Hate Symbols'
])
const drop = (Key: string) =>
s3.send(new DeleteObjectCommand({ Bucket: BUCKET, Key }))
export async function completeUpload(userId: string, id: string) {
if (!new RegExp(`^${UUID}$`).test(id)) throw new HttpError(400, 'invalid_id')
const inKey = `incoming/${userId}/${id}` // 남의 키는 만들어질 수 없다
// 1) 읽기. 정책이 크기를 막았지만 서버가 한 번 더 본다
const obj = await s3.send(new GetObjectCommand({ Bucket: BUCKET, Key: inKey }))
if ((obj.ContentLength ?? 0) > MAX_BYTES) {
await drop(inKey)
throw new HttpError(413, 'too_large')
}
const raw = Buffer.from(await obj.Body!.transformToByteArray())
// 2) 재인코딩. 디코딩이 안 되면 이미지가 아니다. sharp는 출력에서 EXIF·GPS를 뺀다
let out: { data: Buffer; info: sharp.OutputInfo }
try {
out = await sharp(raw, { limitInputPixels: 40_000_000 })
.rotate() // EXIF 방향을 픽셀에 반영해 둔다(메타데이터는 지워지므로)
.resize({ width: 2048, height: 2048, fit: 'inside', withoutEnlargement: true })
.flatten({ background: '#ffffff' }) // 투명 PNG를 JPEG로 바꿀 때 배경
.jpeg({ quality: 85 })
.toBuffer({ resolveWithObject: true })
} catch {
await drop(inKey)
throw new HttpError(422, 'invalid_image')
}
// 3) 검사. 서빙할 바이트(재인코딩 결과)를 그대로 보낸다
let labels
try {
const res = await rekognition.send(
new DetectModerationLabelsCommand({
Image: { Bytes: out.data },
MinConfidence: MIN_CONFIDENCE
})
)
labels = res.ModerationLabels ?? []
} catch {
// 검사를 못 했으면 통과시키지 않는다. incoming/은 남겨 두므로 같은 요청을 다시 부르면 된다
throw new HttpError(502, 'check_failed')
}
const hit = labels.find(
(l) => BLOCKED.has(l.Name ?? '') || BLOCKED.has(l.ParentName ?? '')
)
if (hit) {
await drop(inKey)
console.warn('image rejected', { userId, label: hit.Name, confidence: hit.Confidence })
throw new HttpError(422, 'image_rejected')
}
// 4) 공개 저장. 이 접두사는 이 함수만 쓴다
const key = `public/${userId}/${id}.jpg`
await s3.send(
new PutObjectCommand({
Bucket: BUCKET,
Key: key,
Body: out.data,
ContentType: 'image/jpeg',
CacheControl: 'public, max-age=31536000, immutable' // 키가 유일하니 영구 캐시해도 된다
})
)
await drop(inKey)
return {
type: 'image',
url: `${CDN}/${key}`,
key,
mime: 'image/jpeg',
w: out.info.width,
h: out.info.height,
size: out.info.size
}
}app.post('/api/uploads/:id/complete', requireLogin, async (req, res) => {
res.json(await completeUpload(req.user.id, req.params.id))
})- Rekognition은 JPEG와 PNG만 받는다. 위처럼 먼저 JPEG로 바꾸면 WebP도 받을 수 있다.
- 라벨은 계층이다. 하위 라벨(L2)이 걸리면 상위(L1)도 함께 오므로, 이름은
Name과ParentName양쪽에서 찾는다. AWS는 L1·L2로 거르기를 권한다.MIN_CONFIDENCE가 낮을수록 더 많이 걸리고(오탐 증가) 높을수록 덜 걸린다 (미탐 증가) — 서비스의 사정에 맞게 정하고, 기본값은 50이다. - 거절 사유는 사용자에게 라벨 이름 대신 일반 문구로 보여 준다.
5. 훅: 첨부가 내 공개 파일인지 확인
엔진은 https://인지만 본다. 그러므로 훅이 없으면 클라이언트가 아무 주소나 첨부로 보낼 수 있다(검사하지 않은
외부 이미지, 추적 픽셀). 콘솔의 웹훅 화면에서 before_publish를 켜고, 이미 웹훅을 받는 엔드포인트가 있으면 같은
라우트에서 event로 가른다.
import express from 'express'
import { createChatServer } from '@kispi/chat/server'
import { CDN, UUID } from './uploads'
const chat = createChatServer({
secretKey: process.env.CHAT_SECRET_KEY!,
webhookSecret: process.env.CHAT_WEBHOOK_SECRET
})
type BeforePublish = {
op: 'create' | 'update'
sender: { id: string; name: string }
body: { text?: string; attachments?: { type?: string; url?: unknown }[] }
}
// 보낸 사람 본인의 공개 접두사 아래, 우리가 만든 파일 이름 모양이어야 한다
const fileName = new RegExp(`^${UUID}\\.jpg$`)
const isMine = (url: unknown, senderId: string) => {
const prefix = `${CDN}/public/${senderId}/`
return typeof url === 'string' && url.startsWith(prefix) && fileName.test(url.slice(prefix.length))
}
function decide({ sender, body }: BeforePublish) {
for (const a of body.attachments ?? []) {
if (a.type !== 'image' || !isMine(a.url, sender.id)) {
return { action: 'deny', code: 'attachment_not_allowed', message: '올릴 수 없는 첨부예요' }
}
}
return { action: 'allow' }
}
app.post('/chat-hook', express.raw({ type: 'application/json' }), (req, res) => {
let hook
try {
hook = chat.webhooks.verify(req.headers, req.body.toString('utf8'))
} catch {
return res.sendStatus(400) // 서명이 틀렸다
}
if (hook.event === 'before_publish') return res.json(decide(hook.data as BeforePublish))
// 그 밖의 이벤트(message.created 등)는 웹훅 문서의 받는 코드대로 처리한다
res.sendStatus(200)
})- 훅은 발행과 편집 모두에 걸린다. 편집으로 남의 주소를 끼워 넣는 길도 같은 코드로 막힌다.
- 훅의 응답에 쓰는
code는 클라이언트의err.appCode로,message는 에러 문구로 간다. - 화면이 본문 텍스트 안의 이미지 주소도 그림으로 바꿔 보여 준다면 훅이
body.text의 주소도 검사해야 한다. 첨부만 그리도록 만드는 편이 쉽다. - 서버 키(
sk_)로 보내는 메시지는 훅을 거치지 않는다. 내 백엔드가 직접 올릴 때는 4단계의 결과를 그대로 쓰면 된다.
6. CloudFront로 서빙 (선택이지만 권장)
버킷을 공개하지 않고 CloudFront가 읽게 한다. 배포의 원본(origin)을 S3 버킷으로 하고 원본 접근 제어(OAC) 를 켠 뒤,
동작(behavior)의 경로 패턴을 public/* 하나로만 연다. incoming/은 CDN에서 닿지 않는다.
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "CloudFrontReadPublicOnly",
"Effect": "Allow",
"Principal": { "Service": "cloudfront.amazonaws.com" },
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::my-chat-uploads/public/*",
"Condition": {
"StringEquals": {
"AWS:SourceArn": "arn:aws:cloudfront::111122223333:distribution/EXXXXXXXXXXXXX"
}
}
}
]
}응답 헤더 정책에 X-Content-Type-Options: nosniff를 더하고, 내 도메인(cdn.example.com)을 붙이면 4단계의 CDN_ORIGIN이
된다. 이 주소가 첨부 url의 앞부분이므로 훅의 CDN과 같은 값이어야 한다.
텍스트 모더레이션
글자는 같은 훅에서 거른다. 엔진은 본문을 이스케이프하지 않는다 — 금칙어는 훅의 몫이고, 화면에 그릴 때의 이스케이프는 그리는 쪽의 몫이다.
const BAD = /욕설1|욕설2/g // 서비스의 금칙어 목록
// decide()에서 첨부 검사를 통과한 뒤 이어서 부른다
async function decideText(body: BeforePublish['body']) {
const text = body.text ?? ''
const masked = text.replace(BAD, (m) => '*'.repeat(m.length))
if (masked !== text) return { action: 'allow', body: { ...body, text: masked } } // 바꿔서 통과
return { action: 'allow' }
}- 돌려주는
body는 본문 전체다. 일부만 보내면 나머지가 사라진다. - 외부 모더레이션 서비스를 부른다면 훅의 제한 시간보다 짧은 타임아웃을 직접 건다(훅 기본 1.5초, 최대 5초).
AbortSignal.timeout(1000)같은 식으로 끊고, 그때 허용할지 거절할지를 코드에서 정한다. - 훅 자체가 타임아웃되거나 에러면 엔진은
before_publish_on_error를 따르고 기본은 **allow**다. 첨부 검증을 훅에 기대고 있다면 이 설정이 곧 "훅이 죽었을 때 검증도 꺼진다"는 뜻이다.deny로 바꾸면 안전하지만 훅이 죽는 동안 그 앱의 모든 발행이 멈춘다 — 훅은 가볍게 만들고 모니터링한다.
지킬 것
- 크기. 서명 조건(
content-length-range) + 서버의 재확인 + 픽셀 상한(limitInputPixels) 세 겹으로 건다. Rekognition에 보낼 수 있는 이미지 크기에도 한도가 있으니 AWS의 한도 문서(Guidelines and quotas)를 확인하고MAX_BYTES를 그 안에 둔다. - 형식. JPEG·PNG·WebP만 받는다. SVG는 스크립트를 품을 수 있어 받지 않는다. 움직이는 GIF는 첫 프레임만 검사하면 우회되므로 받지 않는 편이 안전하다.
- EXIF. 재인코딩이 지운다. 원본을 그대로 서빙해야 한다면 EXIF의 GPS를 지우는 단계를 따로 넣는다.
- 호출 제한. 허가증 발급과
complete는 사용자마다 횟수를 제한한다.complete가 부를 때마다 Rekognition 비용이 든다. - 비용. Rekognition은 이미지 한 장마다 과금된다. 단가는 바뀔 수 있으니 AWS의 Amazon Rekognition 가격 페이지에서 Image Moderation 항목을 보고, 예상 업로드 수를 곱해 본다. S3 저장과 CloudFront 전송도 따로 든다.
- 한계. Rekognition은 불법 촬영물(CSAM 등) 여부를 판정하지 않는다고 AWS가 밝힌다. 신고 버튼, 사람의 검토, 서비스가 지는 법적 의무(신고·삭제 절차)를 따로 갖춘다.
- 삭제. 메시지를 지워도(
message.deleted) 엔진은 내 버킷의 파일을 지우지 않는다. 파일까지 지우려면message.created웹훅의body.attachments[].key를 메시지id와 함께 내 DB에 적어 두었다가,message.deleted({id, seq})가 오면 그 키의 객체를 지운다. 신고로 거두는 경우는chat.messages.delete와 객체 삭제, 필요하면 밴을 한 흐름으로 묶는다. 탈퇴·개인정보 삭제 요청에는public/{userId}/아래를 목록 조회해 모두 지운다. - 내보내기. 앱 전체 내보내기(
GET /v1/export)에는 첨부 파일이 들어가지 않는다. 파일의 백업과 이전은 버킷 쪽(복제·aws s3 sync)에서 한다. - 키 관리.
sk_·웹훅 시크릿·AWS 자격 증명은 브라우저와 저장소에 두지 않는다. AWS는 실행 환경의 IAM 역할로 받는다.