Articles

가상 사용자 600명 중 서른 명을 고르는 방법

자료를 읽은 AI에게 사람을 직접 고르라고 하지 않고 태그만 고르게 했어요. 나머지는 SQL이 연령대별로 골라요. AI가 잘하는 일과 데이터베이스가 잘하는 일을 나눈 방법을 정리했어요.

DropThePitch에는 가상 사용자 600명이 있고, 자료 하나에 서른 명을 골라 의견을 받아요. 누구를 고르느냐에 따라 리포트가 완전히 달라져요. 이 선별을 어떻게 만들었는지 정리했어요.

AI에게 사람을 고르게 하지 않았어요

가장 단순한 방법은 AI에게 600명의 정보를 다 주고 "이 자료에 맞는 서른 명을 골라 줘"라고 하는 것이에요. 해 보면 여러 문제가 있어요.

  • 600명의 정보를 매번 넣으면 입력이 너무 길고 비싸요.
  • AI가 없는 사람의 번호를 지어내기도 해요.
  • 같은 자료를 넣어도 매번 결과가 크게 달라요.
  • 연령대가 고르게 나오도록 보장하기 어려워요.

그래서 일을 둘로 나눴어요. AI는 "어떤 특성을 가진 사람이 이 자료에 반응할까"만 판단해서 태그를 골라요. 그 태그를 가장 많이 가진 사람을 찾는 일은 데이터베이스가 해요. 사람을 세고 정렬하는 일은 데이터베이스가 훨씬 정확하고 빨라요.

AI는 태그만 골라요

페르소나들이 가진 태그를 중복 없이 모아서 AI에게 목록으로 줘요. AI는 그 목록 안에서만 골라야 해요. 지시문의 핵심 규칙은 이래요.

1. 제공된 [태그 목록]에 있는 문자열만 쓴다. 새로 만들지 않고, 띄어쓰기와 글자를 한 자도 바꾸지 않는다. 목록에 없는 태그는 버려진다.
3. 20개 이상 35개 이하로 고른다. 수를 채우려고 관련 없는 태그를 넣지 않는다.
4. 주제가 아니라 사람의 조건을 고른다.
5. 좋아할 사람만 고르지 않는다. 이 자료 때문에 불편을 겪거나 거부할 사람의 특성도 함께 고른다.
6. 한 연령대에만 해당하는 태그로 채우지 않는다.

4번과 5번이 이 선별의 성격을 정해요. 예를 들어 노인을 위한 건강 관리 앱이라면, "건강에 관심 많음" 같은 주제 태그보다 "스마트폰 조작이 서툶", "자녀가 대신 결제" 같은 조건 태그가 의견을 더 다양하게 만들어요. 그리고 이 앱을 쓰기 어려운 사람의 특성까지 고르면, 리포트에 "글씨가 작아서 못 쓰겠다"는 의견이 들어올 수 있어요.

응답 형식은 JSON 스키마로 고정했어요. 태그 배열 하나만 받고, 개수도 20개에서 35개 사이로 제한해요.

"tags": {
  "type": "array",
  "minItems": 20,
  "maxItems": 35,
  "items": {
    "type": "string"
  }
}

받은 태그는 다시 확인해요

지시문에 "목록에 있는 것만 써라"라고 적어도 AI는 가끔 글자를 바꾸거나 비슷한 태그를 지어내요. 그래서 받은 태그를 실제 목록과 비교해서, 목록에 있는 것만 남기고 중복을 지워요.

private List<String> keepKnownTags(List<String> selected, List<String> allTags) {
    Set<String> known = Set.copyOf(allTags);
    List<String> kept = selected.stream().filter(known::contains).distinct().toList();

    if (kept.size() < selected.size()) {
        log.warn("[match] 쓸 수 없는 태그를 걸렀습니다: 받은 {}개 중 {}개 사용", selected.size(), kept.size());
    }
    if (kept.isEmpty()) {
        log.error("[match] 쓸 수 있는 태그가 하나도 없어 무작위로 선별합니다.");
    }
    return kept;
}

쓸 수 있는 태그가 하나도 남지 않아도 멈추지 않아요. 그때는 태그 조건 없이 연령대별로 무작위로 골라요. 선별이 덜 정확한 것보다 화면에 아무도 없는 것이 더 나빠요. 대신 오류 로그를 남겨서 이런 일이 얼마나 자주 생기는지 볼 수 있게 했어요.

데이터베이스가 연령대별로 다섯 명씩 골라요

10대부터 60대까지 여섯 연령대에서 다섯 명씩, 모두 서른 명을 골라요. 연령대마다 아래 쿼리를 한 번씩 실행해요.

select p.id from persona p
left join persona_tag pt on pt.persona_id = p.id and pt.name in (:tags)
where p.age between :startAge and :endAge
  and p.id not in (:excludedIds)
group by p.id
order by count(pt.id) desc, rand()
limit :limit

하나씩 뜯어보면 이래요.

  • left join의 조건에 태그 목록을 넣어서, 고른 태그와 겹치는 태그만 붙여요.
  • count(pt.id)가 겹치는 태그 수예요. 많이 겹칠수록 위로 올라가요.
  • left join이라 겹치는 태그가 하나도 없는 사람도 0개로 남아요. 연령대에 맞는 사람이 적어도 자리가 비지 않아요.
  • 겹치는 수가 같으면 rand()로 섞어요. 같은 자료를 다시 올려도 매번 똑같은 사람만 나오지 않아요.

연령대를 쿼리 밖에서 나눈 이유는 고르게 뽑기 위해서예요. 전체에서 태그가 많이 겹치는 순으로 서른 명을 뽑으면, 자료에 따라 20대와 30대만 잔뜩 나올 수 있어요. 연령대별로 따로 뽑으면 항상 다섯 명씩 나와서 리포트에서 연령대끼리 비교할 수 있어요.

빈 목록을 넣으면 쿼리가 깨져요

이 쿼리를 쓰면서 걸린 부분이 있어요. in () 안에 빈 목록을 넣으면 SQL 문법 오류가 나요. 그런데 두 경우에 목록이 빌 수 있어요.

  • 처음 선별할 때는 제외할 사람이 없어요.
  • 앞에서 본 것처럼 쓸 수 있는 태그가 하나도 없을 수 있어요.

그래서 빈 목록 대신 아무것도 걸리지 않는 값을 넣었어요.

private static final List<Long> NOTHING_EXCLUDED = List.of(0L);
private static final List<String> NO_TAG = List.of("");

id가 0인 페르소나는 없고, 이름이 빈 문자열인 태그도 없어요. 쿼리는 정상적으로 돌고, 결과는 목록이 빈 것과 같아요.

제외 목록은 페르소나를 바꿀 때 쓰여요. 사용자가 한 명을 다른 사람으로 바꾸면, 이미 뽑힌 사람들을 제외하고 같은 연령대에서 같은 쿼리로 한 명을 다시 골라요.

페르소나에게 넘기는 자료는 줄여요

고른 서른 명에게는 자료 분석 결과를 넘겨서 의견을 받아요. 그런데 분석 결과를 그대로 넘기면 서른 명이 비슷한 말을 했어요. 분석 결과에 "정보가 부족한 부분", "논의할 점" 같은 평가 항목이 이미 정리되어 있어서, 페르소나들이 그 목록을 그대로 따라 읊었던 거예요.

그래서 페르소나에게 넘기기 전에 평가 항목을 걷어내고, 자료에 무엇이 있는지만 남겨요.

private static final List<String> REMOVED_FIELDS = List.of("informationGaps", "discussionPoints");
private static final List<String> REMOVED_CLAIM_FIELDS = List.of("evidenceType", "evidence");

자료에 설명이 없는 용어도 용어집에서 지워요. 설명 없는 용어가 남아 있으면 모든 페르소나가 "이 용어가 무슨 뜻인지 모르겠다"는 같은 지적을 했어요. 모르는 사람은 모르는 대로 반응하는 것이 더 자연스러웠어요.

걸러내다가 실패하면 원본을 그대로 써요. 의견이 조금 비슷해지는 것이 의견을 못 받는 것보다 나아요.

정리

  • AI에게는 판단만 맡기고, 사람을 세고 고르는 일은 데이터베이스에 맡겨요.
  • AI가 고를 수 있는 값을 목록으로 주고, 받은 값은 목록과 다시 비교해요.
  • 좋아할 사람과 불편을 겪을 사람을 함께 골라야 의견이 갈려요.
  • 연령대별로 따로 뽑으면 구성이 항상 고르게 나와요.
  • 페르소나에게 넘기는 자료에서 이미 정리된 평가를 빼면 의견이 덜 겹쳐요.
다음 글 PDF와 영상에서 썸네일을 만들고, 실패해도 분석은 멈추지 않게