연재

익명 봇 다시 만들기 1. 이름을 가려도 남는 것들

TL;DR

  • 봇 토큰으로 게시하면 메시지에는 이름이 안 뜬다. 그게 익명의 전부가 아니었다.
  • Slack 워크플로 양식에 올라온 파일은 앱이 읽을 수 없다. files:read가 있어도 목록에조차 없다.
  • 이미지를 그대로 다시 올리면 EXIF와 파일명이 따라간다. 둘 다 신원 단서다.
  • 닉네임을 붙일 때 범위가 곧 익명성의 수준이다. 오래 유지될수록 읽기는 편하고 추적은 쉬워진다.

소스를 찾을 수 없는 봇

사내 Slack에 익명으로 글을 쓰는 봇이 있다. 지금도 잘 쓰인다. 문제는 이걸 만든 개발자가 이직하면서 소스를 찾을 수 없게 됐다는 것이다. Slack App 설정도, 토큰도, 저장소도, 배포된 서버도 어디 있는지 모른다.

고칠 수 없는 상태로 두기에는 남은 문제가 있었다.

  • 진입이 슬래시 커맨드다. 명령어를 입력하는 동안 다른 사람에게 OOO님이 입력 중...이 보인다
  • 이미지를 익명으로 올릴 수 없다. Slack 기본 업로드는 사용자 계정으로 실행된다
  • 스레드 답글이 익명이 아니다. 원글만 익명이고 답글은 기본 기능이라 이름이 뜬다

기존 앱을 건드리는 대신 별도의 앱으로 다시 만들기로 했다.

봇이 대신 올려주면 익명인 줄 알았다

구조는 단순해 보였다. 사용자 입력을 받아서, 그걸 봇이 자기 이름으로 게시한다. 그러면 채널에는 봇만 보인다.

사용자 입력  →  앱  →  봇 토큰으로 게시  →  채널
                ↑
          여기서 신원이 끊긴다

실제로 텍스트는 이걸로 끝났다. 문제는 그 다음부터였다.

양식으로 받은 파일을 읽을 수 없다

Slack 워크플로에는 "양식에서 정보 수집" 단계가 있고 파일 업로드도 지원한다. 그 값을 커스텀 스텝으로 넘기면 봇이 받아서 다시 올릴 수 있을 것 같았다.

manifest에 파일 입력을 선언하고 양식과 연결했다. 실행하니 워크플로가 실패했다.

익명 글 게시 실패: An API error occurred: file_not_found

권한 문제라고 생각했다. files:read를 확인했는데 정상적으로 부여돼 있었다.

=== 실제 부여된 스코프 ===
chat:write, files:write, files:read

=== files.info(F0C1C6Z2K8Q) ===
ok: false | error: file_not_found

여기서 이상한 점이 있었다. 파일 ID는 정상적인 형태로 넘어왔다. 스코프도 있다. 그런데 조회가 안 된다.

목록을 찍어봤다.

=== 봇에게 보이는 최근 파일 ID ===
F0C0BFUT36X, F0C0DBFCP9B, F0C02EE9QEB
대상 ID 포함 여부: 미포함

권한이 없는 게 아니라 그 파일이 봇의 시야에 아예 없었다. 이 지점에서 생각이 바뀌었다. 문제는 스코프가 아니라 파일이 어디에 속해 있느냐였다.

Slack에서 봇이 파일을 읽을 수 있는 조건은 둘 중 하나다.

  1. 봇이 그 파일의 소유자다
  2. 봇이 멤버인 대화에 그 파일이 공유돼 있다

양식에 올라온 파일은 제출자 개인 파일로 남는다. 어느 채널에도 공유되지 않았으니 둘 다 아니다. 채널을 공개로 바꿔도 달라지지 않는다 — 실패한 호출에는 채널이 등장하지도 않는다.

파일 업로드 양식은 Slack 자체 단계를 위한 것이고, 서드파티 스텝에는 ID만 전달되고 접근 권한은 함께 주지 않는다. 관련 기능 요청이 아직 열린 상태인 걸로 봐서 미구현 영역이다.

그래서 파일은 앱이 직접 띄운 모달로 받기로 했다. 모달에 올린 파일은 앱에 귀속되니 조건 1을 충족한다.

다시 올리기만 하면 되는 게 아니었다

파일을 읽을 수 있게 되자 남은 건 간단해 보였다. 내려받아서 봇 이름으로 다시 올리면 된다.

두 가지가 따라간다.

사진 안의 EXIF. GPS 좌표, 카메라 일련번호, 소유자 이름이 들어 있을 수 있다. 익명 채널에서 이건 그냥 신원이다.

파일 이름. 스크린샷 2026-09-09 홍길동.png 같은 것. 화면에 크게 보이지는 않지만 파일을 누르면 나온다.

파일명은 순번으로 바꾸면 끝이다. metadata는 라이브러리 없이 컨테이너를 다시 쓰는 방식으로 처리했다. 픽셀은 건드리지 않고 metadata를 담는 구간만 걷어낸다.

JPEG   APP1(EXIF/GPS) · APP13(IPTC) · APPn(메이커노트) · 주석 제거
       APP0(JFIF) · APP2(ICC) 는 유지 — 빼면 렌더링이 달라진다
PNG    tEXt · zTXt · iTXt · eXIf · tIME 청크 제거
       IEND 뒤에 덧붙은 바이트도 버린다
GIF    주석 확장 · 응용 확장 제거
       NETSCAPE2.0 루프 블록은 유지 — 애니메이션이 멈춘다

JPEG/PNG/GIF만 받고 나머지는 거부했다. 파싱을 신뢰할 수 없는 컨테이너를 그대로 통과시키는 게 이 기능에서 가장 위험한 실패 방식이라고 봤다. "처리했다고 생각했는데 안 된" 상태가 빈손보다 나쁘다.

합성 컨테이너로 단위 테스트를 만들고 실제 파일로 확인했다.

앱 로그     attachments=1 stripped=90B
Slack 조회  image-1.png | 소유자: 봇 | 379,331 bytes | metadata 없음

입력 379,421 → 출력 379,331. 90바이트가 빠졌고 eXIf 마커가 사라졌다.

처음 실제 테스트에서는 stripped=0B가 나왔다. 로직이 안 도는 줄 알았는데 그 이미지에 애초에 metadata가 없었다. 제거량을 로그에 남기도록 바꾸고 나서야 "제거할 게 없었다"와 "제거를 못 했다"가 구분됐다. 이 둘을 구분하지 못하는 로그는 검증에 쓸 수 없다.

익명이 되니까 대화가 안 읽혔다

동작은 하는데 채널이 이상했다. 모든 글이 봇 한 이름으로 올라오니 스레드가 혼자 떠드는 것처럼 보였다.

봇
  익명  나는 이렇게 생각해
  익명  그건 좀 아닌 것 같은데
  익명  왜 그렇게 생각해?

세 사람인지 한 사람인지 알 수 없다. Slack은 같은 작성자의 연속 메시지를 묶어서 보여주기 때문에 시각적으로도 한 덩어리가 된다.

닉네임을 붙이기로 했는데, 여기서 범위를 어떻게 잡느냐가 곧 익명성의 수준이라는 걸 알게 됐다.

범위 읽기 문제
채널 전체 고정 가장 좋음 지속적인 가명 정체성이 생긴다
시간 단위(1시간 등) 중간 스레드가 경계를 넘으면 같은 사람 이름이 중간에 바뀐다
스레드 단위 충분함 글 사이를 잇지 못한다 (이게 목적이다)

채널 전체로 고정하면 글이 쌓이면서 문체·주제·활동 시간대가 한 이름 아래 누적된다. 모집단이 작은 조직이면 그것만으로 좁혀진다. 저장소에 매핑 테이블을 만들지 않아도, 공개된 글들이 그 역할을 대신한다.

시간 단위는 다른 문제가 있다. 정각마다 모두 바뀌면 "사라진 A와 나타난 B가 같은 사람"이라는 추론이 가능해진다. 경계가 규칙적일수록 추론이 쉽다.

스레드 단위로 갔다. 한 스레드 안에서는 유지되고 글을 넘으면 끊긴다.

글 A   조용한도토리  원글
         담백한원숭이  댓글
         조용한도토리  댓글      ← 같은 스레드라 유지

글 B   촉촉한파파야  원글        ← A의 조용한도토리와 같은 사람일 수 있으나 알 수 없다

닉네임은 저장하지 않고 매번 계산한다.

닉네임 = HMAC(secret, userId + postSalt)

postSalt는 글을 쓸 때 만들어 그 글의 댓글 버튼 안에 실어 보낸다. 댓글은 그 값을 회수해 같은 닉네임을 다시 계산한다. 앱이 아무것도 기억하지 않아도 된다.

원래는 원글의 타임스탬프를 쓰려 했는데 막혔다. 게시하기 전에는 그 값을 모르고, 이미지가 붙은 글은 업로드 API가 타임스탬프를 돌려주지도 않는다. 씨앗을 직접 만들면 그 의존이 사라진다.

이름을 바꿀 수 있는 곳과 없는 곳

닉네임을 어디에 표시할지도 갈렸다. 메시지 본문에 적으면 Slack이 연속 메시지를 묶어버려서 여전히 한 덩어리로 보인다. 작성자 이름 자체를 바꾸면 분리된다.

메시지 게시 API에는 작성자 이름을 바꾸는 옵션이 있다. 파일 업로드 API에는 없다.

텍스트 글   조용한도토리  앱     본문
이미지 글   봇 이름       앱     조용한도토리님: 본문
                                [이미지]

우회로를 찾아봤지만 막혀 있었다. 파일을 비공개로 올리고 이미지 블록으로 참조하려면 그 파일을 인터넷에 공개해야 한다. 익명 채널의 첨부를 공개 URL로 만드는 건 선택지가 아니다.

그래서 두 형태가 섞인다. 일관성은 없지만 어느 쪽이든 작성자 구분은 유지된다. 텍스트 글이 대부분이라 실사용에서는 이쪽이 낫다고 판단했다.

익명이 아닌 것을 먼저 말해야 한다

다 만들고 채널 안내를 쓰면서 알게 된 게 있다. 익명 기능을 만드는 것보다 무엇이 익명이 아닌지 알리는 게 더 중요했다.

  • 이모지 리액션은 누가 눌렀는지 보인다
  • Slack 기본 답장은 이름이 표시된다. 익명으로 답하려면 글에 붙은 버튼을 써야 한다

리액션이 기명이라는 건 이 봇이 어떻게 만들어졌든 바뀌지 않는다. 그런데 익명 채널이라는 맥락 때문에 사용자는 리액션도 익명이라고 생각하기 쉽다. 잘못 알면 되돌릴 수 없는 종류의 오해다.

그리고 이건 "완전한 익명"이 아니다. 채널에서는 작성자를 알 수 없지만 Slack과 앱 서버는 처리 과정에서 요청자를 식별할 수 있다. 안내에 그대로 적었다. 과장하면 사용자가 실제보다 안전하다고 믿고 행동한다.

무엇이 달라졌나

이전 이후
슬래시 커맨드 입력 중 입력 중... 노출 컴포저 버튼 클릭 — 타이핑이 없다
이미지를 익명으로 못 올림 봇이 metadata 제거 후 자기 소유로 재업로드
스레드 답글에 이름이 뜸 원글의 버튼 → 모달 → 봇이 스레드에 게시
모든 글이 봇 한 이름 스레드 안에서 사람별 닉네임
소스를 찾을 수 없음 회사 저장소 + 회사 계정 배포

정리

익명은 이름을 가리는 것이라고 생각했는데, 실제로는 신원이 새는 경로를 하나씩 막는 일이었다. 막아야 할 경로가 다섯 개 나왔다.

  • 메시지 작성자 — 봇 토큰으로 게시
  • 파일 소유자 — 봇이 다시 올림
  • 파일 안의 metadata — 컨테이너 재작성으로 제거
  • 파일 이름 — 순번으로 교체
  • 닉네임의 유효 범위 — 스레드 단위로 제한

다섯 중 넷은 처음 설계에 없었다. 하나씩 부딪혀서 알게 된 것이고, 그중 셋은 Slack이 무엇을 허용하고 무엇을 허용하지 않는지 확인하는 과정에서 나왔다.

마지막 것 — 닉네임 범위 — 이 제일 오래 걸렸다. 기술적으로는 한 줄 차이인데, 그 한 줄이 "대화가 읽히는가"와 "누가 썼는지 좁힐 수 있는가"를 동시에 정한다. 읽기 편할수록 추적하기 쉬워지는 관계라, 어느 쪽으로도 공짜가 없었다.

다음 편에서는 이걸 서버에 올리면서 만난 문제를 쓴다. 응답을 먼저 보내고 나면 그 다음 작업은 누가 실행하는가에 대한 이야기다.