Articles

로그인 없이 내 채팅을 구분하는 방법

회원가입을 받지 않으면서도 내가 만든 분석은 나만 이어서 쓸 수 있게 하고 싶었어요. 브라우저에 무작위 키 하나를 두는 방식과 그 한계를 정리했어요.

WhoIsLoafing에는 로그인이 없어요. 레포 링크만 넣으면 바로 쓸 수 있게 하는 것이 처음부터의 목표였어요.

그런데 분석 결과를 저장하고 링크로 공유하는 기능을 넣으려니 문제가 생겼어요. 저장된 채팅에는 주인이 있어야 해요. 주소만 알면 아무나 남의 채팅을 이어서 쓰거나 공유해 버릴 수 있으면 안 되니까요.

계정 없이 "이 채팅은 이 사람 것"을 어떻게 알 수 있을까요?

브라우저마다 무작위 키 하나

방법은 단순해요. 브라우저가 처음 왔을 때 아주 긴 무작위 값을 하나 만들어서 저장해 둬요.

const OWNER_KEY_STORAGE = 'whoisloafing:owner'

function ownerKey(): string {
  try {
    let key = localStorage.getItem(OWNER_KEY_STORAGE)
    if (!key) {
      key = `${crypto.randomUUID()}${crypto.randomUUID()}`
      localStorage.setItem(OWNER_KEY_STORAGE, key)
    }
    return key
  } catch {
    return ''
  }
}

그리고 서버에 요청을 보낼 때마다 이 값을 헤더에 실어 보내요.

headers: { 'X-Owner-Key': key }

채팅을 처음 저장할 때 서버는 이 키를 채팅과 함께 기록해요. 나중에 같은 채팅에 요청이 오면 키가 같은지 비교해요. 같으면 주인이고, 다르면 주인이 아니에요.

이 키는 로그인 정보가 아니에요. 이름도 이메일도 담겨 있지 않고, 누구인지 알려 주지 않아요. "이 채팅을 만든 바로 그 브라우저"라는 것만 증명해요.

서버에는 해시만 저장해요

키를 그대로 저장하지는 않아요. 저장하는 것은 키의 SHA-256 해시예요.

export const hashOwnerKey = (key: string) =>
  createHash('sha256').update(key).digest('hex')

비교할 때는 들어온 키를 같은 방식으로 해시해서 저장된 값과 맞춰 봐요.

const owner = !!ownerKey && hashOwnerKey(ownerKey) === chat.ownerKeyHash

이렇게 하는 이유는 데이터베이스가 유출되는 경우를 생각해서예요. 키가 그대로 들어 있으면 유출된 값을 헤더에 넣기만 해도 주인 행세를 할 수 있어요. 해시만 들어 있으면 원래 키를 알아낼 수 없어요.

비밀번호는 사람이 정하니 짧고 뻔해서 해시를 느리게 만드는 장치가 필요해요. 이 키는 무작위 UUID 두 개를 이어 붙인 값이라 추측으로 맞힐 수 없어서, 빠른 해시 한 번으로 충분해요.

채팅 주소도 브라우저가 만들어요

채팅의 주소는 /c/ 뒤에 UUID가 붙는 형태예요. 이 UUID도 서버가 아니라 브라우저가 만들어요.

const id = crypto.randomUUID()
window.history.pushState(null, '', `/c/${id}`)

링크를 내는 순간 주소가 바로 바뀌어요. 서버의 답을 기다리지 않아도 되니 화면 전환이 빨라요. 서버는 받은 값이 UUID 모양인지 확인한 다음에만 저장해요.

UUID는 추측할 수 없을 만큼 길어서, 주소를 모르는 사람이 남의 채팅을 우연히 찾아낼 일은 없어요.

볼 수 있는 사람과 쓸 수 있는 사람

주인 키와 공유 여부를 합치면 권한이 이렇게 정리돼요.

상태 주인 다른 사람
공유하기 전 보고 이어서 쓸 수 있어요 열 수 없어요
공유한 뒤 볼 수만 있어요 볼 수만 있어요

공유한 뒤에는 주인도 더 이어서 쓸 수 없게 한 것이 눈에 띌 거예요. 이유는 공유 링크를 받은 사람의 입장에 있어요. 어제 받은 링크를 오늘 열었는데 내용이 달라져 있으면 그 링크를 믿을 수 없어요. 공유는 그 시점의 결과를 고정하는 일로 정했어요.

그래서 공유는 되돌릴 수 없고, 누르기 전에 확인 창으로 한 번 더 물어봐요.

화면에서 막는 것으로는 부족해요

읽기 전용 화면에서는 버튼을 숨겨요. 하지만 버튼을 숨기는 것은 편의일 뿐 보안이 아니에요. 요청을 직접 만들어 보내면 화면은 아무것도 막지 못해요.

그래서 서버가 요청마다 다시 확인해요.

if (existing?.sharedAt) {
  emit({ type: 'error', text: '공유한 채팅에서는 더 이상 분석을 이어갈 수 없어요.' })
} else if (existing && existing.ownerKeyHash !== hashOwnerKey(ownerKey!)) {
  emit({ type: 'error', text: '이 채팅은 다른 브라우저에서 만든 채팅이라 이어서 쓸 수 없어요.' })
}

조회할 때도 구분해요. 없는 채팅은 404, 있지만 볼 권한이 없는 채팅은 403을 돌려줘요.

이 방식의 한계

편한 만큼 포기한 것도 분명해요.

  • 다른 기기에서 이어서 쓸 수 없어요. 키가 브라우저 안에만 있어서, 노트북에서 만든 채팅을 휴대폰에서는 이어서 쓸 수 없어요. 공유하면 볼 수는 있어요.
  • 브라우저 데이터를 지우면 주인이 아니게 돼요. 키가 사라지면 되찾을 방법이 없어요. 공유하지 않은 채팅은 다시 열 수 없게 돼요.
  • 시크릿 창에서는 창을 닫으면 끝이에요.
  • 같은 컴퓨터를 쓰는 다른 사람과 구분되지 않아요. 키는 사람이 아니라 브라우저를 가리켜요.

이 서비스에서는 받아들일 만한 한계라고 판단했어요. 분석은 언제든 다시 돌릴 수 있고, 잃어버려도 큰일이 나는 자료가 아니기 때문이에요. 결제 내역이나 개인 문서처럼 잃으면 안 되는 자료였다면 이 방식을 쓰면 안 돼요.

언제 쓸 만한가요

로그인을 붙이면 문제는 깔끔하게 풀리지만, 처음 온 사람이 서비스를 써 보기 전에 가입부터 해야 해요. 아래 조건이 맞는다면 무작위 키 방식이 좋은 출발점이에요.

  • 처음 온 사람이 바로 써 볼 수 있어야 해요.
  • 저장하는 자료가 잃어버려도 다시 만들 수 있는 것이에요.
  • 여러 기기에서 이어서 쓰는 일이 드물어요.

나중에 로그인을 붙이더라도 이 키를 계정에 연결하면 그동안 만든 채팅을 넘겨받을 수 있어요. 처음부터 무거운 인증을 넣기보다, 가벼운 방식으로 시작하고 필요해질 때 올리는 편이 작은 팀에게는 맞았어요.

다음 글 AI 호출이 실패해도 서비스가 멈추지 않게 만들기