이미지 업로드와 모더레이션

채팅에 사진을 올리게 하는 법. 엔진은 파일을 받지 않는다. 메시지에는 주소와 메타데이터만 실리고, 파일을 받아 저장하고 검사하는 일은 내 백엔드와 내 버킷이 한다. 이 글은 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가 필요하다.

json
[
  {
    "AllowedOrigins": ["https://app.example.com"],
    "AllowedMethods": ["POST"],
    "AllowedHeaders": ["*"],
    "MaxAgeSeconds": 3000
  }
]

백엔드 역할(role)의 최소 권한이다. 키는 코드에 넣지 않고 실행 환경(EC2·ECS·Lambda)의 IAM 역할로 준다.

json
{
  "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단계를 건너뛴 파일은 수명 규칙으로 치운다.

json
{
  "Rules": [
    {
      "ID": "expire-incoming",
      "Status": "Enabled",
      "Filter": { "Prefix": "incoming/" },
      "Expiration": { "Days": 1 }
    }
  ]
}

2. 백엔드: 업로드 허가증 발급

로그인한 사용자에게만 발급한다. 정책에 정확한 키·콘텐츠 타입·크기 범위·만료를 넣고, 키는 서버가 정한다.

sh
npm install @aws-sdk/client-s3 @aws-sdk/s3-presigned-post @aws-sdk/client-rekognition sharp
ts
// 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 }
}
ts
// 라우트 예시(Express). 인증은 서비스의 세션을 쓴다
app.post('/api/uploads', requireLogin, async (req, res) => {
  res.json(await issueUpload(req.user.id, String(req.body.contentType)))
})

HttpError는 서비스의 에러 타입으로 바꿔 쓴다. 사용자별 발급 횟수 제한도 여기 건다(아래 「지킬 것」).

3. 브라우저: 올리고, 알리고, 보내기

S3 폼은 file 필드가 맨 마지막이어야 한다. 앞쪽 필드를 먼저 채우고 파일을 마지막에 붙인다.

ts
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단계가 부르는 엔드포인트다. 하는 일은 읽기 → 재인코딩 → 검사 → 공개 저장 → 원본 삭제, 이 순서다.

ts
// 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
  }
}
ts
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로 가른다.

ts
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에서 닿지 않는다.

json
{
  "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과 같은 값이어야 한다.

텍스트 모더레이션

글자는 같은 훅에서 거른다. 엔진은 본문을 이스케이프하지 않는다 — 금칙어는 훅의 몫이고, 화면에 그릴 때의 이스케이프는 그리는 쪽의 몫이다.

ts
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 역할로 받는다.