전체 검색

글, 태그와 카테고리를 검색하고 바로 이동해요

로그인 없는 블로그에서 "공감 취소"는 어떻게 될까 — 독자 상호작용 만들기

공감 버튼 하나가 5분이면 될 줄 알았는데, 로그인 없는 블로그에서는 철학 문제가 되더라고요. 익명 독자에게 신원을 주는 쿠키, +1이 아니라 상태를 선언하는 API, 그리고 이 기능이 blog라는 단어를 모르는 이유예요.

2026. 07. 16. 14:05:208분 읽기프로젝트/arcaC/기술 노트
#독자 상호작용#플러그인 아키텍처#멱등성
조회 45
익명 방문자 접수대와 하트 토글 일러스트

글 아래에 공감 버튼을 붙이기로 했어요. 5분이면 끝날 것 같았는데, 첫 질문에서 바로 막혔습니다.

독자가 로그인을 안 하는데, "누가" 공감했는지 어떻게 알죠?

0편에서 예고한 그 하트예요. 하트 하나가 어쩌다 설계 문제가 됐는지 지금부터 적을게요.

먼저 소속부터 정했습니다

구현보다 먼저 정한 게 있어요. 이 기능이 어디에 사는가.

블로그 UI 안에 넣는 게 제일 자연스러워 보였습니다. 블로그 글에 붙는 기능이니까요.

그런데 안 그랬어요. 별도 기능 플러그인으로 뺐습니다. 그리고 그 플러그인의 계약에는 blog라는 단어가 없어요.

// 무엇에 대한 반응인가를 이렇게 표현한다
subject: { typeId: "blog.post", resourceId: "content-..." }

typeId를 문자열로 받는 거예요. 그래서 이 기능은 자기가 무엇에 붙었는지 몰라도 됩니다. 글이든 일정이든 앞으로 생길 무엇이든요.

이렇게 만든 이유는 하나예요. 다이어리에도, 캘린더에도 같은 요구가 올 게 뻔했거든요. 그때 복제하고 싶지 않았습니다.

(이 판단은 나중에 값을 했어요. 캘린더를 만들 때 이 플러그인은 손도 안 댔습니다.)

진짜 문제 — 익명 독자에게 신원 주기

공감은 누르면 켜지고 다시 누르면 꺼지는 기능이잖아요. 그러려면 "이 사람이 아까 그 사람"이라는 걸 알아야 합니다.

그런데 로그인이 없어요. 후보를 세 개 놓고 봤습니다.

방법문제
IP 주소같은 와이파이면 한 사람 취급 · 개인정보
브라우저 지문추적 기술 · 원치 않는 정밀도
익명 쿠키브라우저 지우면 초기화 ← 그래도 이게 나음

세 번째를 골랐어요. 완벽하지 않다는 걸 알면서요.

중요한 건 이 쿠키가 아무것도 식별하지 않는다는 겁니다. 누구인지 모르고, 알 필요도 없어요. "같은 브라우저"라는 사실만 알면 충분하거든요.

공감을 취소할 수 있게 하는 데 필요한 정보의 최소치가 그것뿐입니다. 그 이상을 수집하면 그건 다른 목적이 생긴 거예요.

쿠키를 고를 때 실제로 뭘 넣었는지도 적어 둘게요. 여기서 욕심이 생기기 쉽거든요.

// 쿠키에 들어가는 것
{ visitorId: "<무작위 UUID>" }

// 쿠키 속성
HttpOnly            // 스크립트가 못 읽습니다
SameSite=Lax
Secure
Max-Age=<장기>

// 들어가지 않는 것
// IP · User-Agent · 유입 경로 · 방문 이력 · 어떤 글을 봤는지

무작위 UUID 하나가 전부예요. 서버는 이 값으로 "같은 브라우저인가"만 판정합니다. 누구인지는 모르고, 알 방법도 없어요.

질문이 쿠키로 답할 수 있나
이 사람이 아까 그 사람인가
이 사람이 누구인가아니오
이 사람이 어떤 글들을 봤나아니오 (기록을 안 남김)
같은 사람이 다른 기기에서 왔나아니오

공감을 취소할 수 있게 하는 데 필요한 정보의 최소치가 첫 줄 하나뿐이에요. 그 이상을 수집하면 그건 다른 목적이 생긴 겁니다.

그리고 이 최소성이 나중에 편해요. 수집한 게 없으면 지울 것도 없고, 새면 곤란한 것도 없거든요.

+1 대신 상태를 선언합니다

여기가 이 설계의 핵심이에요. API를 어떻게 만드느냐.

직관적인 방식은 이겁니다.

POST /reactions/increment    // +1
POST /reactions/decrement    // -1

그런데 이건 버튼 연타에 무너져요. 네트워크가 느릴 때 사용자가 세 번 누르면 +3이 됩니다. 눌렀다 취소하면 0이 아니라 이상한 값이 남고요.

그래서 반대로 갔습니다. 증감이 아니라 상태를 선언하게요.

{ action: "reaction.set", selected: true }   // 나는 공감한 상태다
{ action: "reaction.set", selected: false }  // 나는 공감 안 한 상태다

열 번을 보내도 결과가 같아요. 마지막 선언이 이깁니다.

증감 방식선언 방식
같은 요청 반복값이 계속 변함결과 동일
요청 순서 뒤바뀜최종값이 틀어짐마지막 것이 이김 (의도대로)
재시도위험 — 중복 반영안전
상태 복구서버 값을 믿어야 함클라이언트가 선언 가능

세 번째 줄이 실무적으로 큰 차이예요. 증감 방식은 재시도를 못 합니다. 실패했는지 성공했는지 모르는 상태에서 다시 보내면 두 번 반영될 수 있거든요.

선언 방식은 그냥 다시 보내면 됩니다. 그래서 낙관적 업데이트도 마음 편히 붙일 수 있어요.

선언형 API가 실제로 서버에서 어떻게 처리되는지도 보여드릴게요.

// 증감이었다면 — 이전 값을 읽고 더해야 합니다
const current = await db.getCount(subject);
await db.setCount(subject, current + 1);
// 두 요청이 겹치면 하나가 사라집니다

// 선언이면 — 한 행을 있게 하거나 없게 하면 끝
if (selected) {
  await db.query(`
    INSERT INTO reaction (subject_id, visitor_id)
    VALUES ($1, $2)
    ON CONFLICT (subject_id, visitor_id) DO NOTHING`);
} else {
  await db.query(`DELETE FROM reaction WHERE subject_id = $1 AND visitor_id = $2`);
}

// 개수는 셀 때 셉니다
SELECT count(*) FROM reaction WHERE subject_id = $1

`ON CONFLICT DO NOTHING`이 멱등성의 실체예요. 같은 요청이 열 번 와도 행은 하나입니다. 애플리케이션이 판단하는 게 아니라 유니크 제약이 보장해요.

그리고 개수를 따로 저장하지 않는 것도 결정이었어요. 저장하면 빨라지는데, 개수와 실제 행이 어긋날 수 있거든요. 어긋난 카운터는 고치기가 아주 번거롭습니다.

규모가 커지면 캐시를 얹으면 되고, 그때도 원본은 행이에요. 진실을 한 곳에만 두는 게 먼저입니다.

그런데 응답이 역전되면

여기서 또 한 겹이 필요했어요. 빠르게 두 번 누르면 요청이 두 개 날아가는데, 늦게 출발한 게 먼저 도착할 수 있습니다.

요청 A: selected=true  (먼저 출발)
요청 B: selected=false (나중 출발)
응답 B 도착 → 화면 off
응답 A 도착 → 화면 on   ← 사용자가 마지막에 끈 건데 켜짐

그래서 응답을 받을 때 "이게 최신 요청의 응답인가"를 확인합니다. 아니면 버려요. 화면은 항상 사용자의 마지막 의도를 따릅니다.

응답 역전을 막는 코드는 이래요.

let requestSeq = 0;
let lastAppliedSeq = 0;

async function setReaction(selected) {
  const seq = ++requestSeq;
  setOptimistic(selected);            // 화면 먼저

  const result = await api.setReaction({ subject, selected });

  if (seq < lastAppliedSeq) return;   // 늦게 도착한 옛 응답은 버립니다
  lastAppliedSeq = seq;
  applyServerState(result);
}

순번을 붙이는 게 전부예요. 그런데 이게 없으면 빠르게 두 번 누른 사용자에게 하트가 제멋대로 깜빡입니다.

화면은 항상 사용자의 마지막 의도를 따라야 해요. 서버 응답이 아니라요. 응답은 확인일 뿐이고, 의도가 더 최신이면 확인 쪽을 버립니다.

구독은 다른 문제였어요

공감과 댓글은 익명으로 되는데, 구독은 안 됩니다. 이메일을 받아야 하니까요.

여기서 성격이 갈렸어요. 공감은 흔적을 안 남기고 싶은 행위인데, 구독은 일부러 흔적을 남기는 행위거든요.

공감구독
필요한 정보같은 브라우저인지만연락처
동의불필요명시적 동의 필요
철회다시 누르면 끝해지 경로가 따로 필요
보관 기간짧아도 됨발송할 동안 유지

그래서 구독에는 동의 체크를 붙였어요. "새 글 알림을 위한 이메일 수집과 이용에 동의합니다."

이걸 기본 체크로 두고 싶은 유혹이 있었는데 안 했습니다. 기본 체크는 동의가 아니라 관성이거든요.

(전환율은 떨어져요. 그런데 가족이 쓰는 사이트에서 어두운 패턴을 쓰긴 싫었습니다.)

댓글은 왜 같은 플러그인에 넣었나

공감과 댓글은 꽤 다른 기능이잖아요. 하나는 클릭이고 하나는 글쓰기니까요.

그런데 같은 플러그인에 넣었어요. 공유하는 게 많았거든요.

// 셋 다 같은 모양의 대상을 가리킨다
subject: { typeId, resourceId }

// 셋 다 같은 익명 신원을 쓴다
// 셋 다 같은 제한 정책을 지난다
// 셋 다 같은 에러 경계 안에 있다

기능이 아니라 공유하는 개념으로 묶은 거예요. 이렇게 묶으면 나중에 하나를 고칠 때 나머지도 같이 좋아집니다.

반대로 화면 위치로 묶었다면 — "글 아래에 있는 것들" — 개념이 안 겹치는 것들이 한 덩어리가 됐을 거예요.

실패를 어떻게 보여주나

네트워크가 실패했을 때가 은근히 어려웠어요. 공감은 작은 동작이라 큰 에러 화면을 띄우면 과합니다.

그렇다고 조용히 실패하면 사용자는 눌린 줄 알아요. 나중에 새로고침하면 사라져 있고요.

그래서 그 자리에 작게 남기기로 했습니다. 하트가 원래 상태로 돌아가고, 옆에 다시 시도할 수 있는 표시가 뜨는 식으로요.

댓글은 좀 달라요. 사용자가 쓴 글이 날아가면 안 되니까, 실패해도 입력한 내용은 남깁니다. 이건 타협 없이 지켜야 하는 부분이에요.

가벼운 동작의 실패는 가볍게, 무거운 동작의 실패는 무겁게 다뤄야 합니다. 같은 방식으로 처리하면 둘 중 하나는 반드시 어색해져요.

그리고 남용을 막아야 했어요

익명이라는 건 누구나 부를 수 있다는 뜻이기도 해요. 그래서 제한이 필요했습니다.

여기서 순서가 중요했어요. 제한을 어디에 두느냐요.

검사순서이유
요청 형식이 맞는가1틀린 요청에 자원을 쓸 이유 없음
대상이 실제로 존재하는가2없는 글에 공감을 저장하면 쓰레기가 쌓임
이 브라우저가 과하게 부르는가3앞의 둘을 통과한 것만 셈
저장4

제한 검사를 맨 앞에 두면 형식이 틀린 요청까지 카운트에 잡혀서, 버그 하나가 정상 사용자를 막을 수 있어요.

제한을 어디에 두느냐도 순서 문제였어요.

// 순서가 중요합니다
1. 요청 형식 검증        ← 틀리면 여기서 끝
2. 대상 존재 확인        ← 없는 글이면 여기서 끝
3. 속도 제한 검사        ← 여기서부터 카운트
4. 실제 처리

// 3번을 맨 앞에 두면
// 형식이 틀린 요청까지 카운트에 잡혀서,
// 버그 하나가 정상 사용자를 막습니다
제한 단위막는 것부작용
방문자 쿠키별같은 브라우저의 연타쿠키를 지우면 우회됨
IP별한 사람의 대량 요청공용 IP 뒤의 여러 사람이 같이 막힘
대상별 총량한 글에 몰리는 요청정상적인 인기 글도 막힐 수 있음

셋 다 완벽하지 않아요. 그래서 목적을 낮게 잡았습니다. 작정한 사람을 막는 게 아니라 실수나 자동 스크립트를 걸러내는 정도로요.

작정한 사람을 막으려면 로그인을 요구해야 하는데, 그러면 애초에 이 편의 전제가 무너집니다. 익명으로 두기로 한 이상 감수하는 부분이에요.

에러 경계도 필요했습니다

마지막으로 신경 쓴 게 있어요. 이 기능이 죽어도 글은 읽혀야 합니다.

공감·댓글 영역을 에러 경계로 감쌌어요. 이 안에서 무슨 일이 나도 본문은 멀쩡합니다.

당연해 보이는데, 실제로는 잘 안 지켜지는 원칙이에요. 부가 기능이 본문을 인질로 잡는 사이트가 생각보다 많거든요.

본문은 항상 부가 기능보다 중요합니다. 읽으러 온 거지 누르러 온 게 아니니까요.

에러 경계도 위치가 중요했어요.

// 이러면 부가 기능이 본문을 인질로 잡습니다
<ErrorBoundary>
  <PostBody />
  <Reactions />
  <Comments />
</ErrorBoundary>

// 그래서 이렇게
<PostBody />
<ErrorBoundary fallback={null}>      <Reactions /> </ErrorBoundary>
<ErrorBoundary fallback={<Retry />}> <Comments />  </ErrorBoundary>

fallback도 다르게 뒀어요. 공감이 죽으면 그냥 안 보이면 됩니다 — 없어도 글을 읽는 데 지장이 없으니까요. 댓글은 다시 시도할 수 있는 안내를 남기고요.

가벼운 동작의 실패는 가볍게, 무거운 동작의 실패는 무겁게. 같은 방식으로 처리하면 둘 중 하나는 반드시 어색해집니다.

돌아보면 한 문장으로 수렴해요

이 편에서 내린 결정들이 결국 같은 곳을 향하고 있었어요.

익명 환경에서는 행위를 누적하지 말고, 상태를 선언하게 하라.

공감은 "누른 횟수"가 아니라 "지금 공감한 상태인가"예요. 구독도 "신청 횟수"가 아니라 "구독 중인가"고요.

행위를 세면 누가 몇 번 눌렀는지를 관리해야 하는데, 익명 환경에서 그건 애초에 불가능한 일이거든요. 상태로 바꾸면 그 문제가 사라집니다.


다음 편은 댓글창이에요. 그리고 거긴, 주민이 살고 있습니다.

시리즈 이어읽기

독자가 생겼다

2 / 3

이야기 나누기

반응을 남기고 새 글 소식을 받아보세요.

댓글0
서로를 존중하는 대화를 남겨주세요.

첫 댓글을 남겨 대화를 시작해보세요.

새 글 구독
새 글이 공개되면 이메일로 알려드려요.