이 블로그 댓글창에는 봇이 삽니다 — 독자 상호작용 2탄
빈 댓글창은 첫 댓글을 더 어렵게 만들어요. 그래서 캐릭터 봇 주민들을 초대했습니다 — 단, 사람인 척은 금지. 후보만 내는 모델과 게시 버튼을 쥔 트랜잭션을 왜 갈랐는지, 말투를 어떻게 조련했는지 적었어요.

기능은 다 만들었는데 댓글창이 비어 있었어요. 그리고 빈 댓글창은 그냥 비어 있는 게 아니더라고요.
아무도 없는 무도회장에서 첫 춤을 추고 싶은 사람은 없습니다. 첫 댓글의 심리적 비용이 두 번째 댓글보다 훨씬 커요.
기능을 아무리 잘 만들어도 0을 1로 만드는 건 기능이 못 합니다. 그래서 좀 이상한 결정을 했어요. 댓글창에 주민을 이사시켰습니다.
먼저 규칙부터 박았어요
구현보다 먼저 정한 게 있어요. 이 봇들은 사람인 척하지 않습니다.
| 금지 | 이유 |
|---|---|
| 사람인 척하기 | 독자를 속이는 순간 이 블로그 전체가 못 믿을 것이 됨 |
| 글쓴이 사칭 | 더 말할 것도 없음 |
| 조롱·비하 | 캐릭터라도 선은 있음 |
| 질문에 사실인 척 답하기 | 봇이 지어낸 정보가 사실로 유통됨 |
첫 줄이 제일 중요했어요. 봇이 봇인 걸 숨기면 이건 커뮤니티 활성화가 아니라 조작이거든요.
그래서 봇 댓글은 봇 댓글로 보입니다. 대신 재미있게 만들었어요. 정체를 숨기지 않으면서 매력이 있으면 되니까요.
(봇이 댓글 다는 블로그라니 싶으시죠. 저도 그랬어요. 지금은 걔들이 제일 성실한 독자입니다.)
주민에게는 성격이 필요했습니다
처음엔 그냥 "댓글을 생성"하게 했는데 결과가 밋밋했어요. 전부 비슷한 말투로 비슷한 칭찬을 하더라고요.
그래서 캐릭터를 줬습니다. 각자 관심사와 말투가 다르게요.
{
persona: "...", // 이 주민은 어떤 사람인가
personaKeys: [...], // 어떤 주제에 반응하는가
}관심사를 넣은 게 효과가 컸어요. 모든 봇이 모든 글에 댓글을 달면 그건 그냥 스팸인데, 관심사가 있으면 반응할 글을 고릅니다. 성능 글에 반응하는 주민과 회고 글에 반응하는 주민이 다른 거죠.
페르소나 정의는 이런 모양이에요.
{
id: "resident-kang",
displayName: "강주민",
isBot: true, // 화면에 항상 표시됩니다
persona: "성능 숫자를 좋아하고 근거를 캐묻는 사람",
interests: ["성능", "측정", "아키텍처"],
tone: { formality: "낮음", length: "짧게", emoji: "가끔" },
reactionRate: 0.4, // 관심사가 맞아도 항상 달진 않음
}마지막 줄이 은근히 중요했어요. 관심사가 맞는다고 매번 반응하면 그것도 티가 납니다. 사람은 관심 있는 글도 그냥 지나칠 때가 있잖아요.
| 설정 | 없을 때 | 있을 때 |
|---|---|---|
| 관심사 | 모든 글에 다 달림 — 스팸 | 반응할 글을 고름 |
| 말투 | 전부 비슷한 문체 | 주민끼리 구분됨 |
| 반응 확률 | 기계적으로 규칙적 | 들쭉날쭉해서 자연스러움 |
| 봇 표시 | 조작 | 밝히고 하는 연출 |
그리고 여기서 제일 중요한 설계
AI에게 댓글을 맡기면 자연스럽게 이런 구조가 나옵니다.
// 위험한 구조
const comment = await model.generate(prompt);
await db.comments.insert(comment); // 모델이 곧 게시이러면 모델 출력이 곧 게시물이에요. 프롬프트가 조금만 흔들려도, 모델이 이상한 걸 뱉어도, 그대로 블로그에 올라갑니다.
그래서 두 단계로 갈랐어요.
// 1단계: 모델은 후보만 만든다
const candidate = await generateCandidate(post, persona);
await db.candidates.insert(candidate); // 아직 공개 아님
// 2단계: 별도 판정을 통과한 것만 게시된다
await publishCandidate({ candidateId }); // 트랜잭션 안에서모델은 후보를 낼 뿐 게시 버튼을 쥐지 못합니다. 게시 버튼은 검사와 트랜잭션이 쥐고 있어요.
이 분리 덕에 얻은 게 여러 개입니다.
| 얻은 것 | 설명 |
|---|---|
| 판정 규칙을 코드로 강제 | 조롱 게이트·길이·중복 검사를 후보 단계에서 |
| 같은 후보가 두 번 게시되지 않음 | 트랜잭션이 보장 |
| 모델을 갈아도 게시 경로는 그대로 | 후보 생성기만 교체 |
| 문제 생기면 후보만 버림 | 공개된 적 없으니 되돌릴 것도 없음 |
마지막 줄이 실제로 편했어요. 초기에 말투가 이상했을 때, 이미 나간 댓글을 지우는 게 아니라 후보를 버리면 됐거든요.
두 단계로 가른 구조를 코드로 보면 이래요.
// 1단계 — 모델은 후보만 만듭니다
const candidate = await generateCandidate(post, persona);
await candidates.insert({ ...candidate, status: "pending" });
// 2단계 — 검사를 통과한 것만 게시됩니다
const checks = [
notEmpty, withinLength, noSlur, noPersonalInfo,
notDuplicate, relevantToPost, toneWithinRange,
];
for (const check of checks) {
const result = check(candidate);
if (!result.ok) return reject(candidate, result.reason);
}
await publishComment(candidate); // 트랜잭션 안에서| 분리해서 얻은 것 | 설명 |
|---|---|
| 모델 출력이 곧 게시물이 아님 | 프롬프트가 흔들려도 게시까지 가지 않음 |
| 거절 사유가 남음 | 어떤 검사에서 몇 개가 걸리는지 셀 수 있음 |
| 검사를 나중에 추가 가능 | 이미 나간 걸 지우는 게 아니라 후보를 버림 |
| 재현 가능 | 같은 후보를 다시 검사에 넣어볼 수 있음 |
두 번째 줄이 운영에서 제일 유용했어요. 어떤 검사가 자주 걸리는지 보면 프롬프트의 어디가 문제인지가 보이거든요. 거절률이 갑자기 오르면 그게 경보고요.
모델은 후보를 낼 뿐 게시 버튼을 쥐지 못합니다. 게시 버튼은 검사와 트랜잭션이 쥐고 있어요.
그런데 말투 조련이 제일 오래 걸렸어요
설계는 금방 끝났는데, 실제로 읽을 만한 댓글이 나오기까지가 오래 걸렸습니다.
문제 1 — 칭찬 봇
처음엔 전부 칭찬만 했어요. "좋은 글이네요", "많이 배웠습니다" 같은 거요.
이게 왜 나쁘냐면, 읽는 사람이 5초 만에 알아채거든요. 그리고 알아채는 순간 나머지 댓글도 다 가짜로 보입니다.
그래서 갸웃하는 것을 허용했어요. "이 부분은 좀 과한 거 아닌가요" 같은 반응이요. 대신 조롱은 게이트로 막았습니다. 의문은 되고 비하는 안 되는 선을 코드로 그은 거예요.
"의문은 되고 비하는 안 된다"를 코드로 그은 게 이 부분이에요.
// 허용 — 갸웃하는 반응
"이 부분은 좀 과한 거 아닌가요?"
"저는 반대 경험이 있어서 갸웃하네요."
// 차단 — 대상을 향한 비하
noSlur(candidate) // 욕설·비하 표현
noPersonAttack(candidate) // 글이 아니라 사람을 겨냥
noAbsolute(candidate) // "이건 완전히 틀렸다" 류 단정세 번째 검사는 나중에 추가했어요. 비하가 아니어도 단정적인 반박은 읽는 사람을 불편하게 만들더라고요. 봇이 단정하면 특히요.
선을 어디에 그을지는 몇 번 옮겼습니다. 처음엔 너무 좁게 잡아서 전부 밋밋해졌고, 풀었더니 가끔 날이 섰어요. 지금 자리도 최종은 아닐 거예요.
문제 2 — 한국어 판정
관련성 판정이 한국어에서 잘 안 맞았어요. 어미 변화 때문에 같은 단어를 다른 단어로 보거나, 조사가 붙은 형태를 못 알아보거나요.
그래서 판정 로직을 한국어 기준으로 보정했습니다. 이건 영어권 도구를 그대로 쓰면 반드시 만나는 문제더라고요.
문제 3 — 우르르 몰려오기
발행하자마자 봇 다섯이 동시에 댓글을 달았어요. 그러면 티가 확 납니다. 사람은 그렇게 안 오거든요.
그래서 도착 시간을 분산시켰습니다. 발행 후 몇십 분에 걸쳐 흩어져서 오게요.
기존에 발행된 글들의 재가동 시간도 같이 분산시켰어요. 안 그러면 기능을 켠 순간 옛 글 전부에 댓글이 동시에 달립니다.
(자연스러움은 대개 무작위가 아니라 분산이더라고요.)
도착 시간 분산은 이렇게 했어요.
// 발행 직후 우르르 → 티가 납니다
// 발행 후 창(window) 안에 흩뿌립니다
const delay =
MIN_DELAY +
Math.random() * (MAX_DELAY - MIN_DELAY) +
personaBias(persona); // 주민마다 평균 반응 속도가 다름
schedule(candidate, delay);
// 기존 글 재가동도 같은 방식으로 흩뿌립니다
// 안 그러면 기능을 켠 순간 옛 글 전부에 동시에 달립니다personaBias를 넣은 게 재미있었어요. 어떤 주민은 늘 빨리 오고 어떤 주민은 늦게 옵니다. 그것만으로도 "누가 먼저 오나"가 매번 달라져요.
자연스러움은 대개 무작위가 아니라 분산이더라고요. 완전 무작위로 하면 오히려 다섯이 몰리는 날이 생깁니다.
실행 환경도 가둬야 했어요
생성 작업은 우리 서버 프로세스 안에서 돌지 않습니다. 격리된 컨테이너 안에서 돌아요.
이유는 두 가지예요.
| 이유 | 설명 |
|---|---|
| 자원 격리 | 생성이 오래 걸려도 API 응답이 안 느려짐 |
| 권한 격리 | 생성기가 DB나 비밀값에 직접 못 닿음 |
두 번째가 본질이에요. 생성기는 "글 내용"과 "캐릭터 설정"만 받고, 결과로 "후보 텍스트"만 돌려줍니다. 그 이상의 권한이 없어요.
AI가 관련된 코드에서는 이 원칙이 특히 중요하다고 봐요. 모델은 예측 가능하지 않은데, 예측 가능하지 않은 것에는 권한을 최소로 줘야 하니까요.
격리 컨테이너 안에서 도는 생성기의 인터페이스가 이게 전부예요.
// 들어가는 것
{ postText, persona }
// 나오는 것
{ candidateText }
// 없는 것
// - DB 접근
// - 우리 API 호출
// - 파일 시스템
// - 게시 권한생성기는 글 내용과 캐릭터 설정만 받고, 결과로 후보 텍스트만 돌려줍니다. 그 이상의 권한이 없어요.
AI가 관련된 코드에서는 이 원칙이 특히 중요하다고 봐요. 모델은 예측 가능하지 않은데, 예측 가능하지 않은 것에는 권한을 최소로 줘야 하니까요.
대댓글과 멘션도 붙였어요
주민이 생기니 대화가 필요해졌습니다. 댓글에 답글을 달고, 서로를 부를 수 있게요.
여기서 작은 결정이 하나 있었어요. 봇끼리 무한히 대화하면 안 됩니다. 그래서 답글 깊이에 상한을 뒀어요.
그리고 저도 답글을 답니다. 봇한테요. 이상하다는 건 아는데, 대화가 이어져 있으면 다음에 온 사람이 덜 어색하거든요.
그래서 효과가 있었나
솔직히 말하면 아직 모릅니다. 사람 댓글이 폭발적으로 늘지는 않았어요.
다만 확실한 건 하나 있습니다. 댓글창이 더 이상 비어 있지 않아요. 그리고 저는 제 글에 반응이 달리는 걸 봅니다. 그게 다음 글을 쓰게 만들고요.
독자를 기다리는 대신, 독자가 앉을 자리를 먼저 데워두기로 한 거예요. 걔들은 그 일을 아주 성실하게 합니다.
이 시리즈는 여기까지예요. 아무도 안 오던 블로그에 하트가 찍히고, 댓글창에 주민이 생겼습니다. 우주에 던진 돌에서 퐁당 소리가 나기 시작했어요.


