Claude가 하룻밤에 출시 가능한 1:1 DM을 만든 이야기: 범위, 3중 괴롭힘 방어, Turbo 실시간, 그리고 내가 나중에 메운 세 가지 polish.
Pickful이 오늘 DM을 출시했다. 1:1 대화, 텍스트+이미지, 실시간 전송, 2분 회수 가능, 포인트 등급별 속도 제한, 프라이버시 3단계, 차단 적용——38개 파일, +1376줄, 41개 spec, PR 한 개로 끝.
이 글은 "Claude 굉장하다" 같은 내용이 아니다. 1년 넘게 돌아가는 소셜 제품에 사용자가 진심으로 괴롭히려 들 만한 기능을 Claude에게 추가시킬 때, 직접 결정해야 했던 판단, Claude가 첫 번째 패스에서 놓친 디테일, 그리고 내가 나중에 메운 구멍에 관한 글이다.
DM은 댓글과 다르다. 댓글은 광장이라 던지면 누구나 본다. DM은 정확히 두 사람만 들어가는 밀실이다. 즉 데이터 모델, 권한 정책, 푸시 채널의 작은 틈은 그대로 "낯선 사람이 누군가의 얼굴에 광고를 붙일 수 있다"로 직결된다. 그래서 "채팅 만들어"라고 말하지 않고, 먼저 Claude와 범위를 못 박았다:
"먼저 칼날을 갈고 어느 나무를 벨지 정한다"를 Claude는 곧바로 이해했다. 이 원칙은 이후에도 반복해서 쓰게 된다——AI는 코드를 빨리 쓰지만, 제품의 경계는 사람이 정해야만 한다.
DM 모델링의 고전적 함정: A가 B에게 보내서 (A→B)를 만들고, B가 답해서 (B→A)를 만든다. 같은 대화에 대해 두 행이 존재하고 동기화가 안 된다.
Claude가 제안한 해법을 그대로 채택했다:
def self.between!(a, b)
raise ArgumentError, "cannot create conversation with self" if a.id == b.id
one_id, two_id = [a.id, b.id].sort
find_or_create_by!(user_one_id: one_id, user_two_id: two_id)
end
[a.id, b.id].sort + (user_one_id, user_two_id) 유니크 인덱스——누가 먼저 말을 걸든 양쪽이 같은 행에 도착한다. 단순, 미러 레코드 마이그레이션 필요 없음, 애플리케이션 레이어에서 두 행을 묶을 필요 없음.
이후의 peer(viewer)도 깔끔하게 떨어진다:
def peer(viewer)
viewer.id == user_one_id ? user_two : user_one
end
"상대가 누군가"는 영원히 삼항 연산자 한 줄. 불변량을 DB 레이어로 밀어 넣는 방식이 애플리케이션 코드 판정보다 훨씬 견고하다.
대화에는 본질적으로 "단방향"인 상태가 두 가지 있다:
초안에서 Claude는 사용자당 한 행씩 conversation_states 테이블을 만들려 했다. 멈추라고 했다——이 테이블은 대화당 영원히 2행이다. 테이블 하나 더 두고 join 한 단계를 끼울 가치가 없다. conversations에 네 컬럼을 직접 추가했다:
t.datetime :user_one_last_read_at
t.datetime :user_two_last_read_at
t.datetime :user_one_hidden_at
t.datetime :user_two_hidden_at
읽기/쓰기는 viewer가 one인지 two인지에 따라 컬럼명을 분기한다:
def column_name(viewer, suffix)
viewer.id == user_one_id ? :"user_one_#{suffix}" : :"user_two_#{suffix}"
end
코드에 viewer dispatch가 약간 늘었지만 스키마에서 join 테이블 하나와 인덱스 한 세트가 사라진다. 행 수가 영원히 N으로 고정되는 관계에는 별도 테이블을 만들지 마라——이건 DB 설계 판단 문제다. Claude는 안을 내고, 합칠지 말지는 사람이 결정한다.
프롬프트에 못 박았다: "DM 출시 조건은 기본값으로 괴롭힘 방어, 모든 토글은 안전한 방향으로." Claude가 돌려준 3계층 방어를 한 글자도 안 바꿨다:
| 계층 | 메커니즘 | 구현 위치 |
|---|---|---|
| 사용자 직접 차단 | blocked_users.exists?(id: other.id)로 단락 |
can_be_dmed_by? |
| 프라이버시 3단계 | enum :dm_privacy, { everyone: 0, followers_only: 1, nobody: 2 } |
User concern |
| 포인트 등급별 속도 제한 | <50pts→10/h, <500pts→60/h, 그 외 300/h | dm_hourly_limit |
세 번째 계층은 내가 추가 요청한 것. 소셜 제품 DM 출시 첫날 확실히 닥치는 재앙은 일회용 계정의 스팸이다. Claude 첫 버전은 "전원 60/h 고정"이었다. 포인트 등급별로 바꾸게 했다——비용 0의 신규 계정엔 거의 발언권을 주지 말고, 진짜 활성 사용자에겐 충분하게. 이런 "코드 레이어에서 산술적 공평을 강제하지 말고, 사용자 계층 설계에서 공평을 만들자"는 디테일은 제품 판단이지 기술 판단이 아니다.
게이트는 Pundit policy에 모두 모았다, 우회 불가:
def create?
return false unless user
return false unless record.conversation&.participant?(user)
recipient = record.conversation.peer(user)
return false unless recipient
return false unless recipient.can_be_dmed_by?(user)
return false if user.dm_rate_limited?
true
end
각 return false가 구체적인 괴롭힘 시나리오에 대응한다. 코드가 체크리스트처럼 읽힌다——policy는 이렇게 생겨야 한다.
회수는 필수다(오타, 잘못된 이미지, 잘못된 수신자). 다만 디테일에서 잘 넘어진다:
RETRACT_WINDOW = 2.minutes
def retractable_by?(user)
!deleted? && sender_id == user&.id && created_at >= RETRACT_WINDOW.ago
end
def retract!
update!(deleted_at: Time.current, content: nil)
image.purge_later if image.attached?
end
요점:
deleted_at 타임스탬프, 본문은 비우고, 행은 유지purge_later로 첨부 정리를 백그라운드로 보냄"본문은 지우되 기록은 남긴다"는 DM 시나리오의 표준 패턴이다. Claude는 첫 시도에서 이 로직을 맞게 썼다. 다만 image.purge_later는 내가 고친 것——원래는 image.purge였는데, 그건 응답을 블로킹한다.
실시간 부분은 Claude에게 Hotwire/Turbo를 쓰라고 지정했다(앱 전체가 그 스택이라). 흥미로운 건 broadcast 채널을 어떻게 자르냐다:
def broadcast_to_thread
[conversation.user_one, conversation.user_two].each do |viewer|
broadcast_append_to(
"conversation_#{conversation_id}_user_#{viewer.id}",
target: "conversation_#{conversation_id}_messages",
partial: "direct_messages/direct_message",
locals: { message: self, viewer: viewer }
)
end
end
채널명에 viewer.id를 포함시킨다——같은 메시지를 두 번 broadcast, 다른 두 stream에, viewer를 바꿔 렌더링한다.
왜냐? 메시지 partial에 "내가 보낸 거냐"와 "회수 가능하냐"라는 분기가 있어서, 렌더링 결과가 viewer마다 본질적으로 다르기 때문. 한 번만 broadcast하고 프런트에서 판단하게 한다면 sender_id를 프런트에 노출하거나 CSS 조건을 잔뜩 써야 한다——둘 다 서버에서 두 번 렌더링하는 것보다 깔끔하지 않다.
inbox 배지도 같은 모델:
Turbo::StreamsChannel.broadcast_replace_to(
"user_#{recipient_id}_inbox",
target: "dm_inbox_badge",
html: ApplicationController.render(
partial: "shared/dm_inbox_badge",
locals: { unread: recipient.total_unread_dm_count }
)
)
사용자 1명당 stream 1개, 각자 자기 미읽음 수를 갱신한다.
PR을 머지한 후 실제 인터랙션으로 몇 시간 써보니, Claude가 예상치 못한 문제 셋이 나왔다. 별도 polish 커밋으로 한꺼번에 수정:
1. 빈 대화는 양쪽에서 안 보이게
다른 사람의 "DM 보내기" 버튼을 누르면 대화 행이 생긴다. 그런데 나는 아직 아무 메시지도 안 보냈는데——그 시점에 상대 inbox에는 빈 대화가 나타난다. 사회적으로 어색하다: "할 말이 없으면 방해하지 마."
scope을 하나 더했다:
scope :visible_to, ->(user) {
for_user(user)
.where.not(last_message_at: nil)
.where(...hidden_at IS NULL...)
}
last_message_at IS NULL인 대화는 양쪽에서 보이지 않는다. 첫 메시지에서 bump_last_message!가 타임스탬프를 찍으면 대화가 떠오른다. Claude는 이걸 떠올리지 못했다——"작동하는가"만 신경 쓰고 "사용자가 도중에 마음을 바꾸면?"은 안 봤다.
2. 프로필의 "DM 보내기" 버튼이 Follow와 세로로 쌓여 답답
Claude의 레이아웃은 세로 쌓기였고, "Chat-bubble-left-right"라는 heroicon tooltip 이름이 그대로 새고 있었다. Follow 옆에 나란히 icon-only로 바꾸고, 명시적 title로 tooltip 누출을 덮었다.
3. 인박스와 대화 헤더에서 아바타 사이즈 깨짐
.avatar-wrapper의 width lock이 Tailwind w-X h-X와 충돌. user_card partial의 관행에 맞춰 wrapper 빼고 w-X h-X rounded-full object-cover를 직접 적용.
세 개 모두 개별로는 작다. 합치면 "동작한다"와 "정말 잘 어울린다" 사이의 차이다. Claude는 코드를 빨리 쓰지만, 실제로 써봐야만 보이는 종류의 결함은 위임할 수 없다——눈도 손가락도, 같은 앱의 다른 페이지에 대한 근육 기억도 없기 때문.
1. 범위 결정은 사람의 일, AI의 일이 아니다
"그룹 없음, 읽음 표시 없음, reaction 없음" 이 세 결정이 Claude가 쓴 모든 코드보다 가치 있었다. 어느 하나라도 풀면 작업량 2배, 출시 2주 지연. Claude는 범위 축소를 먼저 제안하지 않는다——당신의 출시 일정을 모르니까.
2. 불변량을 DB로 밀어 넣어라
[a.id, b.id].sort + 유니크 인덱스, distinct_participants 검증, 외래키 cascade——모두 Claude의 제안. 단 전제로 내가 "미러 대화 허용 안 함" "자기 자신에게 DM 금지" "사용자 삭제 시 깨끗하게 정리" 라고 못 박았다. 제약을 빡빡하게 쓸수록 후속 버그가 줄어든다.
3. 괴롭힘 방지는 spec에 표현하라
41개 spec 중 약 60%가 policy 테스트: 차단이 적용되는가, 프라이버시 설정이 적용되는가, 속도 제한이 적용되는가. 이 spec들은 "기능이 도는가"를 보지 않는다, "악용이 막히는가"를 본다. 괴롭힘 시나리오마다 spec 하나씩——Claude에게 리스트업을 맡길 수 있지만, 먼저 "막아야 할 시나리오가 무엇인지"를 말해줘야 한다.
4. UX 폴리시는 직접 만져봐야 보인다
PR 머지 후 2시간 실제 사용으로 코드 리뷰나 그린 테스트로는 절대 잡을 수 없는 세 건을 찾았다. 대안은 없다.
DM 기능은 "만들고 싶다"부터 "사용자가 쓸 수 있다"까지, 메인 PR이 21:53, polish가 23:36——하룻밤. 진짜 희소했던 건 코드 쓰는 시간이 아니라 범위·권한·괴롭힘 방어 세 가지를 끝까지 생각하는 시간이었다. 코드를 다 쓰는 건 시작일 뿐, 직접 써보고 거칠음을 갈아내야 진짜 출시다.