← 전체 해설 목록  ·  week-03 · 2강

2강 — 감성 분석 심화 (다차원 감성 · ABSA)

🎯 학습 목표

📖 해설

지난 프로젝트(18차시)에서 멀티태스크 프롬프트로 리뷰 20여 개를 자동 분석하는 파이프라인을 만들었습니다 — 분석 함수에 리뷰를 넣어 for문으로 반복 처리하고, 각 결과를 감성·키워드·요약을 묶은 딕셔너리로 모아 CSV로 저장했죠(호출 사이엔 time.sleep()으로 API 부하를 막았습니다). 그런데 결과를 보면 MIXED가 꽤 많죠. "그래픽은 좋은데 스토리가 별로야"라는 리뷰는 MIXED로 묶이는데 — 그래서 뭐가 좋고 뭐가 별로인지는 알 수 없습니다. 오늘은 이 한계를 넘어 더 세밀하게 분석하는 두 기법, 다차원 감성 분류와 ABSA를 배웁니다.

1. 왜 심화가 필요한가 — "그래서 뭘 어쩌라고?"

분석 결과를 보니 이렇게 나왔다고 합시다.

감성 비율
POSITIVE 40%
NEGATIVE 30%
MIXED 30%

"MIXED가 30%다" — 그래서 개발팀에 뭘 하라는 걸까요? "혼합 감성이 많아요"라고 보고할 수는 없잖아요. 단순 감성 분류의 한계는 세 가지입니다.

  1. 정보 손실 — "그래픽은 좋은데 스토리가 별로야"를 MIXED로 묶으면 무엇이 좋고 나쁜지가 사라집니다.
  2. 실행 불가 — MIXED 30%라는 숫자로는 어떤 행동도 끌어낼 수 없습니다.
  3. 뉘앙스 무시 — "실망했다"와 "화가 났다"는 둘 다 NEGATIVE지만 전혀 다른 감정입니다. 실망은 기대에 못 미친 것, 분노는 뭔가 잘못됐다고 느끼는 것이죠.

그래서 오늘의 핵심 질문은 이것입니다.

좋은 분석은 "그래서 뭘 해야 하는데?"라는 질문에 답할 수 있어야 한다.

MIXED 30%보다 Performance 부정 45%가 훨씬 유용합니다. 후자는 "최적화팀 긴급 투입"이라는 행동으로 바로 이어지니까요.

오해는 마세요. 단순 분류가 무가치한 건 아닙니다. 빠르고 간단해서 "긍정 60%니까 대체로 반응이 좋구나" 같은 대략적 트렌드 파악엔 잘 맞습니다. 핵심은 목적에 따라 분석 깊이를 고르는 것 — 빠른 모니터링이면 단순 분류, 깊은 인사이트가 필요하면 오늘 배울 심화 기법을 쓰는 거죠.

환경 설정(라이브러리 설치 → API 키 → 클라이언트)은 1강과 동일하니, 맨 위에서 한 번 실행하고 시작합니다.

2. 해결책 1 — 다차원 감성 분류

긍정/부정 대신 구체적인 감정으로 분류하는 겁니다. 같은 NEGATIVE라도 나눠 봅시다.

왜 구분할까요? 대응 방법이 다르기 때문입니다. 실망한 유저는 다음 업데이트에서 기대치를 맞춰주면 되지만, 분노한 유저는 환불·사과 같은 즉각적인 대응이 필요합니다.

감정 종류를 여섯 가지로 정의하고, 각 유형마다 퓨샷 예시를 줍니다.

def classify_emotion(review):
    """리뷰에서 드러나는 주된 감정을 6가지 중 하나로 분류한다."""
    response = client.chat.completions.create(
        model=MODEL,
        messages=[
            {"role": "system", "content":
                "너는 게임 리뷰의 감정을 분석하는 전문가야.\n"
                "리뷰에서 드러나는 주된 감정을 분류해.\n\n"
                "감정 종류:\n"
                "- JOY: 기쁨, 만족, 즐거움\n"
                "- ANGER: 분노, 짜증, 화남\n"
                "- DISAPPOINTMENT: 실망, 아쉬움\n"
                "- SURPRISE: 놀람, 예상 밖\n"
                "- BOREDOM: 지루함, 무관심\n"
                "- NEUTRAL: 특별한 감정 없음\n\n"
                "### 예시 ###\n"
                'review: "Can\'t believe they released it in this state!"\n'
                "감정: ANGER\n이유: 출시 상태에 대한 분노 표현\n\n"
                'review: "Expected more from this studio..."\n'
                "감정: DISAPPOINTMENT\n이유: 기대에 못 미쳤다는 실망감\n"
                "### 예시 끝 ###\n\n"
                "답변 형식:\n감정: (감정 종류)\n이유: (한 줄 설명)"},
            {"role": "user", "content": f"리뷰: {review}"},
        ],
    )
    return response.choices[0].message.content

# 같은 NEGATIVE라도 다른 감정으로 갈린다
for r in [
    "Expected more from this studio. Disappointed.",   # → DISAPPOINTMENT
    "Unacceptable! I demand a refund!",                # → ANGER
    "Repetitive and boring after 2 hours.",            # → BOREDOM
    "Didn't expect the twist at the end! Wow!",        # → SURPRISE
]:
    print(r[:40], "→")
    print(classify_emotion(r), "\n")
# 결과는 모델·환경에 따라 다를 수 있음

이제 "NEGATIVE 30% 중에 분노가 10%, 실망이 15%, 지루함이 5%"처럼 대응 전략을 세울 수 있는 형태로 분석할 수 있습니다.

3. 해결책 2 — ABSA(에스펙트 기반 감성 분석)

ABSA(Aspect-Based Sentiment Analysis)는 무엇에 대한 감성인지 쪼개 분석하는 기법입니다.

음식점 리뷰로 비유해 봅시다. "음식은 맛있는데 서비스가 별로"라는 리뷰를 그냥 MIXED라고 하면 의미가 없죠. 대신 음식: 긍정 / 서비스: 부정으로 나누면, 사장님이 무엇을 개선해야 할지 압니다. 여기서 에스펙트(aspect)는 평가 대상(음식, 서비스, 분위기, 가격), 감성은 그 대상에 대한 의견입니다.

게임 리뷰의 주요 에스펙트는 보통 여섯 가지로 잡습니다.

에스펙트 평가 대상
graphics 그래픽, 비주얼, 아트 스타일
gameplay 조작감, 전투, 게임 메카닉
story 스토리, 캐릭터, 세계관
performance 최적화, 버그, 크래시
value 가격 대비 만족도, 콘텐츠 양
multiplayer 온라인, 매칭, 서버

이 목록은 고정이 아닙니다. 분석 목적에 따라 얼마든지 더하거나 뺄 수 있어요. 음악이 중요한 게임이면 music을, 난이도가 화두면 difficulty를 추가하면 됩니다.

수도 코드부터 — "무엇을 만들지"를 먼저 적는다

바로 코드를 짜기 전에 수도 코드(의사 코드)로 설계를 먼저 적습니다. 말로 짜는 코드라고 생각하세요.

1. 에스펙트 정의 섹션  — 6개 에스펙트와 각각의 의미
2. 분석 규칙 섹션      — 언급된 에스펙트만 분석(없으면 null), 감성 3종, 근거(evidence)
3. 퓨샷 예시 섹션      — 복합 리뷰 1개 + 예상 출력 JSON
4. 출력 형식           — JSON으로 구조화

앞으로 AI 기반 코딩이 더 활발해질수록, 이렇게 "무엇을 어떻게 만들지" 설계하는 영역의 가치가 커집니다. 기존 교육이 코드 작성(아래쪽) 중심이었다면, 점점 이 설계(위쪽)가 중요해지고 있어요. 그래서 일부러 수도 코드를 먼저 쓰고 실제 코드로 옮깁니다.

실제 코드 — null과 evidence가 핵심

설계를 코드로 옮깁니다. 언급되지 않은 에스펙트는 억지로 만들지 말고 null로 두는 게 포인트입니다. 없는 걸 지어내면 정확도가 떨어지니까요. (null은 프로그래밍에서 "값이 없다"는 뜻입니다.)

import json

def analyze_aspects(review):
    """리뷰에서 에스펙트별 감성을 분석한다. 언급 안 된 에스펙트는 null."""
    response = client.chat.completions.create(
        model=MODEL,
        messages=[
            {"role": "system", "content":
                "너는 게임 리뷰 분석 전문가야.\n"
                "리뷰에서 각 Aspect(측면)에 대한 감성을 분석해.\n\n"
                "### Aspect 정의 ###\n"
                "- graphics: 그래픽, 비주얼, 아트\n"
                "- gameplay: 조작감, 전투, 메카닉\n"
                "- story: 스토리, 캐릭터, 세계관\n"
                "- performance: 최적화, 버그, 크래시\n"
                "- value: 가격 대비 만족도\n"
                "- multiplayer: 온라인, 매칭, 서버\n\n"
                "### 분석 규칙 ###\n"
                "- 언급된 Aspect만 분석 (언급 안 되면 null)\n"
                "- 감성: POSITIVE, NEGATIVE, MIXED 중 하나\n"
                "- evidence: 판단 근거(해당 부분 인용 또는 요약)\n\n"
                "### 예시 ###\n"
                'review: "Stunning visuals but crashes every hour. Story is okay."\n'
                "{\n"
                '  "graphics": {"sentiment": "POSITIVE", "evidence": "Stunning visuals"},\n'
                '  "gameplay": null,\n'
                '  "story": {"sentiment": "MIXED", "evidence": "okay"},\n'
                '  "performance": {"sentiment": "NEGATIVE", "evidence": "crashes every hour"},\n'
                '  "value": null,\n'
                '  "multiplayer": null\n'
                "}\n"
                "### 예시 끝 ###\n\n"
                "JSON 형식으로만 답해."},
            {"role": "user", "content": f"리뷰: {review}"},
        ],
    )
    return response.choices[0].message.content

test_review = """Beautiful game with amazing art direction.
However, the combat feels clunky and unresponsive.
Story is engaging but too short.
Worth it on sale."""

raw = analyze_aspects(test_review)
print(raw)

응답을 파싱해 봅시다. 1강에서 만든 정제 함수를 그대로 가져와 ```json 군더더기를 떼고 json.loads()로 딕셔너리를 만듭니다.

def clean_json_string(s):
    """응답 앞뒤의 ```json 마크다운 표시를 제거한다."""
    if s.startswith('```json\n'):
        s = s[len('```json\n'):]
    if s.endswith('\n```'):
        s = s[:-len('\n```')]
    return s

try:
    parsed = json.loads(clean_json_string(raw))
    for aspect, data in parsed.items():
        if data is not None:                      # 언급된 에스펙트만
            print(f"[{data['sentiment']}] {aspect}  ← {data['evidence']}")
        else:
            print(f"[ ] {aspect}: (언급 없음)")
except json.JSONDecodeError as e:
    print("파싱 실패:", e)

```json을 떼는 정제 함수를 또 쓰는 이유는, 모델마다 응답이 다르기 때문입니다. 최근 고급 모델은 ```json 없이 깔끔하게 주지만, 그렇지 않은 모델도 있죠. 그렇다고 "잘 주는 모델만 쓰자"는 건 선택지를 좁히는 일입니다. 성능이 조금 낮아도 우리 목적을 달성하면 그 모델을 쓰는 게 비즈니스 관점에서 더 효율적일 수 있어요. 우리가 OpenRouter를 쓰는 이유가 여기 있습니다 — 그 안의 다양한 모델을 바꿔가며 테스트해 비용 대비 충분한 조합을 찾는 노하우를 쌓는 겁니다. 정제 함수는 그 자유를 받쳐 주는 안전장치고요.

이제 단순히 MIXED가 아니라 구체적으로 보입니다.

Aspect 감성 근거
graphics POSITIVE beautiful, amazing art
gameplay NEGATIVE clunky, unresponsive
story MIXED engaging but too short
value POSITIVE worth it on sale

그러면 액션이 나옵니다 — 그래픽팀은 유지, 전투팀은 조작감 개선, 스토리팀은 콘텐츠 추가 검토.

4. 여러 리뷰로 통계 내기 — 가장 아픈 곳 찾기

한 건이 아니라 여러 리뷰를 모아 에스펙트별 감성 분포를 계산하면, 어디가 가장 문제인지 우선순위가 드러납니다. 리뷰마다 analyze_aspects()를 돌려 결과를 모은 뒤, collections의 도구로 집계합니다.

import time
from collections import defaultdict

sample_reviews = [
    "Amazing graphics but terrible performance. Crashes constantly.",
    "Story is masterpiece. Graphics could be better. Worth every penny.",
    "Gameplay is addictive. Server issues ruin multiplayer though.",
    "Beautiful visuals, engaging story, but too short for the price.",
    "Combat feels great! No bugs so far. Highly recommend.",
]

# 1) 리뷰마다 분석해서 결과를 모은다
all_results = []
for i, review in enumerate(sample_reviews, 1):
    raw = analyze_aspects(review)
    try:
        all_results.append(json.loads(clean_json_string(raw)))
    except json.JSONDecodeError:
        all_results.append(None)        # 파싱 실패는 건너뛰기
    time.sleep(1)                        # API 부하를 막으려 호출 사이에 잠깐 대기

# 2) 에스펙트별로 감성을 센다 (defaultdict: 없는 키도 자동으로 0부터 시작)
stats = defaultdict(lambda: {"POSITIVE": 0, "NEGATIVE": 0, "MIXED": 0, "total": 0})
for result in all_results:
    if not result:
        continue
    for aspect, data in result.items():
        if data is not None:
            stats[aspect][data["sentiment"]] += 1
            stats[aspect]["total"] += 1

# 3) 부정 비율이 높은 순으로 정렬 → 개선 우선순위
problems = []
for aspect, s in stats.items():
    if s["total"]:
        problems.append((aspect, s["NEGATIVE"] / s["total"], s["total"]))
problems.sort(key=lambda x: x[1], reverse=True)

print("=== 개선 우선순위 ===")
for rank, (aspect, neg_ratio, n) in enumerate(problems, 1):
    if neg_ratio > 0:
        print(f"{rank}. {aspect}: 부정 {neg_ratio*100:.0f}% (언급 {n}회)")

time.sleep(1)은 API 서버가 "너무 빨리 요청하지 마세요" 하고 막는 걸(레이트 리미트) 방지하는 안전장치입니다. 이렇게 집계하면 "멀티플레이어 부정 100%"처럼 가장 아픈 에스펙트가 숫자로 떠오르고, 거기서부터 행동을 정할 수 있습니다.

5. 복합 감성 다루기 — 에스펙트 입자 크기의 균형

실무에선 애매한 경우가 있습니다.

여기서 트레이드오프가 생깁니다. 에스펙트를 너무 크게 잡으면 MIXED가 많아지고, 너무 잘게 나누면 복잡해집니다. 분석 목적에 맞는 적절한 균형을 찾는 게 설계자의 감각입니다.

🔑 핵심 용어

용어 쉬운 설명
다차원 감성 분류 긍정/부정 대신 기쁨·분노·실망·놀람·지루함 등 구체적 감정으로 분류
ABSA(에스펙트 기반 감성 분석) "무엇에 대한" 감성인지 대상(aspect)별로 쪼개 분석
에스펙트(aspect) 평가 대상. graphics·gameplay·story·performance·value·multiplayer 등
evidence(근거) 그 감성으로 판단한 인용·요약. 검증과 신뢰를 높임
null "값이 없음"을 뜻하는 값. 언급 안 된 에스펙트는 억지로 채우지 않기
수도 코드(의사 코드) "무엇을 어떻게 만들지"를 말로 먼저 적는 설계. AI 코딩 시대에 가치↑
defaultdict 없는 키도 기본값부터 자동 생성하는 딕셔너리. 집계에 편리
time.sleep() 호출 사이에 잠깐 대기해 레이트 리미트를 피하는 안전장치

🎮 게임 기획자 시점

ABSA는 리뷰 분석을 "여론"에서 "백로그"로 바꿔 줍니다. "평이 좀 갈려요"가 아니라 "performance 부정 45%, multiplayer 부정 60%"라는 표가 나오면, 그대로 스프린트 우선순위가 되죠. 다차원 감성은 한 발 더 나아가 대응의 결을 정해 줍니다 — 같은 부정이라도 분노 비중이 높으면 핫픽스·공지 같은 즉각 대응이, 실망이 높으면 다음 로드맵에서 기대치를 맞추는 게 정답입니다. 기획자가 직접 손볼 곳은 코드가 아니라 에스펙트 목록과 입자 크기 — 우리 게임에서 유저가 무엇을 기준으로 평가하는지 아는 사람이 그 축을 설계해야 분석이 살아납니다.

⚠️ 흔한 오해 / 짚고 갈 점

✅ 30초 정리

  1. 단순 감성(P/N/M)은 정보 손실·행동 불가가 문제. 좋은 분석은 "그래서 뭘 해야 하는데?"에 답해야 합니다.
  2. 다차원 감성으로 실망·분노 등 구체 감정을 나누면 대응 전략이, ABSA로 에스펙트를 나누면 구체적 개선점이 나옵니다.
  3. 여러 리뷰를 집계해 부정 비율이 높은 에스펙트를 찾으면, 바로 개선 우선순위가 됩니다.

▶️ 다음 강 연결

ABSA의 약점은 에스펙트를 우리가 미리 정한다는 것입니다. 유저들이 우리가 생각 못 한 주제(예: 로딩 시간)를 100명이 말하고 있어도, 목록에 없으면 놓치죠. 다음 20차시 키워드 추출과 토픽 분석에서는 이 빈틈을 메웁니다. 리뷰에서 키워드를 직접 뽑아 올리는 바텀업(bottom-up) 방식으로, 미처 정의하지 못한 이슈까지 데이터에서 발견하는 법을 배웁니다.

전체📚 강의 목록