AI 3종이 밸런스 게임에 참전한다 — Versus AI 의견 기능의 설계와 구현
AI 3종이 밸런스 게임에 참전한다 — Versus AI 의견 기능의 설계와 구현#
Versus는 매일 하나의 밸런스 게임이 올라오는 서비스입니다. "짜장면 vs 짬뽕", "아침형 인간 vs 저녁형 인간"처럼 두 선택지 중 하나를 고르고, 다른 사람들의 선택 비율을 확인하는 재미가 핵심입니다.
그런데 한 가지 고민이 있었습니다. 사용자가 투표하고 나면 "나만 이렇게 생각하나?" 하는 순간이 옵니다. 다른 사람들의 의견이 궁금한데, 댓글이 아직 없으면 그 궁금증이 해소되지 않습니다.
여기서 아이디어가 나왔습니다. AI가 먼저 의견을 남기면 어떨까?
그것도 하나가 아니라 세 개의 AI가 각자 다른 관점에서 의견을 남기면, 사용자 입장에서 "아, 이런 시각도 있구나"라는 반응이 나올 수 있습니다. 이 글에서는 Gemini, GPT, Claude 세 AI가 매일 밸런스 게임에 참여하는 기능을 어떻게 만들었는지 전체 과정을 공유합니다.
전체 아키텍처#
이 기능의 핵심 흐름은 다음과 같습니다.
- Cron Job (Slack Agent) — 매일 정해진 시간에 실행
- Admin API 호출 — AI 투표 + 댓글 생성 요청
- 백엔드 — AI 전용 계정으로 투표/댓글 저장, 멱등성 보장
- 프론트엔드 — "AI's Opinion" 전용 섹션에 표시
[Slack Agent Cron]
↓ POST /api/v1/admin/balance-games/ai-participations
[Spring Boot Backend]
↓ AI 계정 조회 → 투표 → 댓글 저장
[PostgreSQL]
↓
[Next.js Frontend]
↓ GET /api/v1/balance-game-comments/ai
[AiOpinionSection 컴포넌트]
각 레이어가 하나의 책임만 가지도록 설계했습니다. Slack Agent는 AI 응답 생성만, 백엔드는 데이터 저장만, 프론트엔드는 표시만 담당합니다.
AI 페르소나 설계 — 세 AI의 개성#
단순히 "AI가 댓글을 남긴다"면 재미가 없습니다. 세 AI가 같은 질문에 대해 비슷한 답을 내면 존재 이유가 없습니다. 그래서 각 AI에게 명확한 페르소나를 부여했습니다.
| AI | 성격 | 말투 | 관점 |
|---|---|---|---|
| Gemini | 분석적, 데이터 지향 | 통계와 수치를 인용 | "데이터에 따르면..." |
| GPT | 유머러스, 공감형 | 가볍고 위트 있는 표현 | "솔직히 이건..." |
| Claude | 철학적, 사색형 | 깊이 있는 성찰 | "이 선택의 본질은..." |
예를 들어 "짜장면 vs 짬뽕" 질문에 대해:
- Gemini: "한국인 외식 빈도 통계를 보면 짜장면이 중화요리 주문의 62%를 차지합니다. 대중의 선택에는 이유가 있습니다."
- GPT: "짬뽕 먹으려고 중국집 갔다가 결국 짜장면 시키는 게 인간입니다. 하지만 저는 그 유혹을 이겨내고 짬뽕을 선택합니다."
- Claude: "이 질문은 결국 '익숙함 vs 자극'의 대립입니다. 짜장면의 안정감을 선택하느냐, 짬뽕의 도전을 선택하느냐. 저는 오늘 도전 쪽에 한 표를 던집니다."
이렇게 하면 같은 질문이라도 세 가지 다른 시각이 제공됩니다. 사용자는 자신과 비슷한 AI를 발견하는 재미도 있습니다.
백엔드 구현 — SSOT와 멱등성#
AiParticipant Enum: 단일 진실 원천#
AI 모델 정보를 관리하는 핵심은 AiParticipant enum입니다.
enum class AiParticipant(val email: String, val modelName: String) {
GEMINI("ai-gemini@weggle-plus.co.kr", "Gemini"),
GPT("ai-gpt@weggle-plus.co.kr", "GPT"),
CLAUDE("ai-claude@weggle-plus.co.kr", "Claude"),
;
companion object {
private val modelNameToParticipant = entries.associateBy { it.modelName.uppercase() }
private val emailToParticipant = entries.associateBy { it.email }
val ALL_EMAILS: Set<String> = emailToParticipant.keys
fun fromModelName(modelName: String): AiParticipant =
modelNameToParticipant[modelName.uppercase()]
?: throw IllegalArgumentException("Unknown AI model: $modelName")
fun fromEmail(email: String): AiParticipant? = emailToParticipant[email]
}
}
이 enum이 SSOT(Single Source of Truth)입니다. AI 모델이 추가되면 이 enum에 한 줄만 추가하면 됩니다. 이메일 → 모델명, 모델명 → 이메일 양방향 변환이 모두 이 enum을 통합니다. Map 기반 룩업으로 O(1) 조회를 보장합니다.
왜 이메일 기반일까요? AI도 일반 사용자와 동일한 UserAccount 테이블을 사용합니다. 별도의 AI 전용 테이블을 만들지 않았습니다. 이메일 컨벤션(ai-{model}@weggle-plus.co.kr)으로 AI 계정을 식별합니다. 이렇게 하면 기존 투표/댓글 시스템을 전혀 수정하지 않고도 AI가 참여할 수 있습니다.
Flyway 마이그레이션: AI 계정 생성#
AI 전용 계정은 Flyway 마이그레이션으로 생성합니다. 이렇게 하면 어떤 환경에서든 동일한 AI 계정이 존재하는 것이 보장됩니다.
-- V59__create_ai_participant_accounts.sql
INSERT INTO user_account (email, nickname, profile_image, provider, created_at, updated_at)
VALUES
('ai-gemini@weggle-plus.co.kr', 'Gemini', NULL, 'SYSTEM', NOW(), NOW()),
('ai-gpt@weggle-plus.co.kr', 'GPT', NULL, 'SYSTEM', NOW(), NOW()),
('ai-claude@weggle-plus.co.kr', 'Claude', NULL, 'SYSTEM', NOW(), NOW())
ON CONFLICT (email) DO NOTHING;
ON CONFLICT DO NOTHING으로 멱등성을 보장합니다. 마이그레이션이 여러 번 실행되어도 중복 생성되지 않습니다.
Admin API: AI 참여 엔드포인트#
AI 참여 요청은 Admin API를 통해 들어옵니다.
@PostMapping("/ai-participations")
fun createAiParticipation(
@Valid @RequestBody request: AiParticipationRequest,
): AiParticipationResponse {
return balanceGameService.createAiParticipation(request)
}
요청 본문은 간단합니다.
{
"questionId": 85,
"modelName": "Gemini",
"selectedOption": "OPTION_A",
"comment": "통계적으로 보면..."
}
서비스 레이어에서는 다음과 같은 순서로 처리합니다.
AiParticipant.fromModelName()으로 유효한 모델인지 확인- 이메일로
UserAccount조회 - 이미 투표했는지 확인 (멱등성)
- 투표 저장
- 댓글 저장
- 응답 반환
멱등성이 핵심입니다. 같은 AI가 같은 질문에 두 번 참여하면 두 번째 요청은 아무것도 하지 않고 alreadyParticipated: true를 반환합니다. Cron Job이 실패 후 재시도되거나, 네트워크 이슈로 중복 호출되어도 데이터가 꼬이지 않습니다.
N+1 방지: 배치 조회 패턴#
AI 댓글을 조회할 때 N+1 문제를 방지하기 위해 배치 조회를 사용합니다.
@Transactional(readOnly = true)
fun getAiComments(questionId: Long, currentUserId: Long?): List<BalanceGameCommentResponse> {
val aiUsers = userAccountRepository.findByEmailIn(AiParticipant.ALL_EMAILS)
if (aiUsers.isEmpty()) return emptyList()
val aiUserIds = aiUsers.map { it.id }
val comments = commentRepository
.findByQuestionIdAndUserIdInAndDeletedAtIsNull(questionId, aiUserIds)
val usersMap = aiUsers.associateBy { it.id }
val aiUserMap = resolveAiUserMap(aiUsers)
return comments.map { comment ->
val user = usersMap[comment.userId]
val aiModel = aiUserMap[comment.userId]
comment.toResponse(user, aiModel, currentUserId)
}
}
쿼리는 딱 2번 실행됩니다. AI 사용자 조회 1번, AI 댓글 조회 1번. AI가 3명이든 10명이든 쿼리 수는 동일합니다.
일반 댓글에서 AI 댓글 분리#
AI 댓글은 "AI's Opinion" 전용 섹션에만 표시되어야 합니다. 일반 댓글 목록에 같이 나오면 "이게 사람이야 AI야?" 하는 혼란이 생깁니다.
처음 배포했을 때는 이 분리를 하지 않아서 AI 댓글이 양쪽에 모두 나왔습니다. 빠르게 수정했습니다.
fun getComments(questionId: Long, cursor: Long?, size: Int, currentUserId: Long?) {
val aiUserIds = userAccountRepository
.findByEmailIn(AiParticipant.ALL_EMAILS)
.map { it.id }
val comments = if (aiUserIds.isNotEmpty()) {
commentRepository.findByQuestionIdCursorExcludingUsers(
questionId, cursor, aiUserIds, pageable
)
} else {
commentRepository.findByQuestionIdCursor(questionId, cursor, pageable)
}
// ...
}
NOT IN 쿼리로 AI 유저를 제외합니다. 빈 리스트 방어도 포함되어 있습니다. JPA의 NOT IN에 빈 리스트를 넘기면 예상치 못한 동작을 할 수 있기 때문에, AI 유저가 없으면 기존 쿼리를 그대로 사용합니다.
Repository에 추가한 JPQL 쿼리는 다음과 같습니다.
@Query("""
SELECT c FROM BalanceGameComment c
WHERE c.questionId = :questionId
AND c.deletedAt IS NULL
AND (:cursor IS NULL OR c.id < :cursor)
AND c.userId NOT IN :excludedUserIds
ORDER BY c.id DESC
""")
fun findByQuestionIdCursorExcludingUsers(
@Param("questionId") questionId: Long,
@Param("cursor") cursor: Long?,
@Param("excludedUserIds") excludedUserIds: List<Long>,
pageable: Pageable,
): List<BalanceGameComment>
커서 기반 페이지네이션은 그대로 유지하면서 AI 유저만 제외하는 조건이 추가되었습니다.
프론트엔드 구현 — AI 의견 전용 섹션#
AiOpinionSection 컴포넌트#
프론트엔드에서는 AI 댓글을 별도 섹션으로 분리했습니다.
export default function AiOpinionSection({ questionId }: AiOpinionSectionProps) {
const { data: aiComments, isLoading } = useAiComments(questionId);
if (isLoading) return null;
if (!aiComments || aiComments.length === 0) return null;
return (
<section className="mt-[24px]">
<h3 className="text-[11px] font-semibold tracking-[0.2em] text-[#9e9e9e] uppercase">
AI'S OPINION
</h3>
<div className="mt-[12px] flex flex-col gap-[8px]">
{aiComments.map((comment) => (
<AiCommentCard key={comment.id} comment={comment} />
))}
</div>
</section>
);
}
핵심 설계 결정 몇 가지를 공유합니다.
데이터가 없으면 아무것도 렌더링하지 않습니다. AI 댓글이 아직 없는 질문(아직 Cron이 돌지 않은 질문)에서는 섹션 자체가 사라집니다. 빈 "AI's Opinion" 제목만 덩그러니 남는 일이 없습니다.
각 AI 모델별 시각적 차별화를 적용했습니다. Gemini는 파란색, GPT는 녹색, Claude는 보라색. 사용자가 글을 읽지 않아도 "아, 다른 AI구나"를 직관적으로 알 수 있습니다.
const AI_MODEL_CONFIG = {
Gemini: { color: "text-[#4285F4]", bgColor: "bg-[#4285F4]/10", icon: LuSparkles },
GPT: { color: "text-[#10A37F]", bgColor: "bg-[#10A37F]/10", icon: LuLightbulb },
Claude: { color: "text-[#A855F7]", bgColor: "bg-[#A855F7]/10", icon: LuBrain },
};
각 AI의 브랜드 컬러를 가져와서 일관성을 유지했습니다. 구글 블루, OpenAI 그린, Anthropic 퍼플. 사용자가 해당 AI 서비스를 이미 알고 있다면 색상만으로도 어떤 AI인지 연상할 수 있습니다.
React Query 기반 데이터 페칭#
AI 댓글 조회는 React Query 훅으로 분리했습니다.
export function useAiComments(questionId: number) {
return useQuery({
queryKey: ["versus", "comments", "ai", questionId],
queryFn: () => fetchAiComments(questionId),
staleTime: 1000 * 60 * 5, // 5분
});
}
staleTime을 5분으로 설정한 이유가 있습니다. AI 댓글은 하루에 한 번만 생성됩니다. Cron Job이 돌면 그날의 AI 댓글이 확정됩니다. 따라서 5초짜리 staleTime은 불필요한 API 호출만 늘립니다. 반대로 Infinity를 설정하면 사용자가 오래 머무르는 경우 Cron 실행 직후의 새 댓글을 못 볼 수 있습니다. 5분은 이 두 가지의 균형점입니다.
자동화 — Slack Agent Cron#
매일 AI가 참여하는 과정은 Slack Agent의 Cron Job이 담당합니다.
흐름은 다음과 같습니다.
- 매일 지정된 시간에 Cron 실행
- 현재 오픈된 밸런스 게임 목록 조회
- 각 게임에 대해 Gemini, GPT, Claude 순서로 AI API 호출
- AI의 선택(OPTION_A/OPTION_B)과 댓글을 Admin API로 전송
- 이미 참여한 게임은 자동 스킵 (멱등성)
Slack Agent에서 AI API를 호출할 때 각 AI의 페르소나를 프롬프트에 포함합니다. "당신은 분석적이고 데이터를 좋아하는 AI입니다. 이 밸런스 게임에 대한 의견을 150자 이내로 남겨주세요."와 같은 시스템 프롬프트로 일관된 캐릭터를 유지합니다.
여기서 중요한 것은 비용 관리입니다. 외부 AI API를 호출하면 비용이 발생합니다. 매일 1개의 질문에 3개의 AI가 참여하면 하루 3번의 API 호출입니다. Slack Agent 내부에서 처리하면 별도의 API 키 비용 없이 기존 인프라를 활용할 수 있습니다.
응답 DTO 설계 — isAi와 aiModel 필드#
댓글 응답에 AI 여부를 판별할 수 있는 필드를 추가했습니다.
{
"id": 42,
"questionId": 85,
"userId": 2,
"userNickname": "Gemini",
"content": "통계적으로 보면...",
"createdAt": "2026-03-18T09:00:00+09:00",
"isMine": false,
"isAi": true,
"aiModel": "Gemini"
}
isAi 필드가 true이면 프론트엔드에서 AI 전용 스타일을 적용합니다. aiModel 필드로 어떤 AI인지 구분하여 모델별 색상과 아이콘을 매칭합니다.
이 필드들의 값은 서버에서 계산됩니다. 클라이언트가 "이 유저가 AI인지" 판단하지 않습니다. AiParticipant.fromEmail()을 통해 서버가 확정적으로 판별합니다.
private fun BalanceGameComment.toResponse(
user: UserAccount?,
aiModel: String?,
currentUserId: Long?,
): BalanceGameCommentResponse = BalanceGameCommentResponse(
id = id,
questionId = questionId,
userId = userId,
userNickname = user?.nickname ?: "알 수 없음",
content = content,
createdAt = createdAt,
isMine = currentUserId != null && userId == currentUserId,
isAi = aiModel != null,
aiModel = aiModel,
)
배포 후 발견한 문제와 해결#
문제: AI 댓글이 두 곳에 나타남#
첫 배포 후 AI 댓글이 "AI's Opinion" 섹션에도, 일반 댓글 목록에도 동시에 나타났습니다. AI 댓글은 전용 섹션에서만 보여야 사용자 경험이 깔끔합니다.
원인은 일반 댓글 조회 API가 모든 유저의 댓글을 가져오고 있었기 때문입니다. AI 유저를 필터링하는 로직이 없었습니다.
해결은 위에서 설명한 findByQuestionIdCursorExcludingUsers 쿼리를 추가하는 것이었습니다. 백엔드에서 필터링하면 프론트엔드는 변경할 필요가 없습니다. API 응답 자체에서 AI 댓글이 빠지니까요.
교훈: 데이터 흐름을 먼저 그려라#
이 문제는 설계 단계에서 데이터 흐름을 좀 더 꼼꼼하게 그렸으면 방지할 수 있었습니다. "AI 댓글은 어디서 저장되고, 어디서 조회되고, 어디에 표시되는가?" 이 질문에 대한 답을 그림으로 그려보면 "아, 일반 댓글 조회에서도 나오겠구나"를 미리 발견할 수 있습니다.
확장 가능성#
새로운 AI 모델 추가#
AiParticipant enum에 한 줄 추가하고, Flyway 마이그레이션으로 계정을 생성하면 끝입니다. 프론트엔드의 AI_MODEL_CONFIG에 색상과 아이콘을 추가하면 UI도 자동으로 대응됩니다.
// 새 AI 추가 예시
LLAMA("ai-llama@weggle-plus.co.kr", "Llama"),
AI 페르소나 커스터마이징#
현재 페르소나는 Slack Agent의 프롬프트에 하드코딩되어 있습니다. 이를 DB로 옮기면 Admin 페이지에서 AI의 말투와 성격을 실시간으로 조정할 수 있습니다. "이번 주는 Gemini를 좀 더 유머러스하게 해볼까?" 같은 실험이 가능해집니다.
사용자 반응 데이터 활용#
"이 AI 의견이 도움이 되었나요?" 같은 피드백을 수집하면, 어떤 AI의 어떤 스타일이 사용자에게 가장 공감을 얻는지 분석할 수 있습니다. 이 데이터는 페르소나 튜닝의 근거가 됩니다.
정리#
이 기능의 핵심 설계 원칙을 정리하면 다음과 같습니다.
-
기존 시스템을 재활용한다. AI 전용 테이블을 만들지 않았습니다. 기존 UserAccount, 기존 투표 시스템, 기존 댓글 시스템을 그대로 사용합니다. 새로 만든 것은
AiParticipantenum과 Admin API 엔드포인트뿐입니다. -
멱등성을 보장한다. 같은 요청이 두 번 와도 결과는 같습니다. Cron Job이 실패 후 재시도되어도 데이터가 꼬이지 않습니다.
-
SSOT를 지킨다. AI 모델 정보는
AiParticipantenum 한 곳에만 있습니다. 이 enum을 수정하면 조회, 검증, 응답 생성 모든 곳에 자동 반영됩니다. -
N+1을 설계 단계에서 방지한다. 배치 조회 패턴으로 쿼리 수를 예측 가능하게 유지합니다.
-
백엔드에서 필터링한다. 프론트엔드가 "이 댓글을 보여줄지 말지" 판단하지 않습니다. 서버가 정확한 데이터만 내려줍니다.
AI가 밸런스 게임에 참여하는 것은 기술적으로 복잡하지 않습니다. 복잡한 것은 "어떻게 하면 자연스럽게, 재미있게, 그리고 확장 가능하게 만들 것인가"라는 설계 판단입니다. 이 글이 비슷한 기능을 고민하는 분들에게 참고가 되면 좋겠습니다.
위글플러스의 다른 서비스도 만나보세요#
- Typefy - 나를 발견하다: MBTI·여행 DNA·직업 성향 등 다양한 무료 심리 테스트
- Versus - 오늘의 선택, 당신의 생각: 매일 새로운 A vs B 밸런스 게임
- Nomad's - 세상을 탐색하다: 디지털 노마드 지역 리뷰와 체류 경험
- 구독 비용 계산기 - 지출을 파악하다: 디지털 구독 비용 계산
- Nomad'With - 일상을 나누다: 디지털 노마드 피드
- JobSkill - 역량을 키우다: 현직자 직무 스킬 세미나
- ThingsInThing - 미디어 속 숨겨진 것들: 책, TV 속 숨겨진 것들을 발견
- staglit - EDM의 밤을 함께: 국내 EDM DJ 공연 정보
비슷한 고민을 하고 계시거나 같이 실험해보고 싶은 분은 커피챗 하고 싶습니다 ☕️ 📩 ksy90101@gmail.com
함께 읽으면 좋은 글
댓글
0의견을 남겨보세요. 로그인하면 닉네임이 자동으로 입력됩니다.
댓글을 불러오는 중...