가상 사용자 프로필을 미리 글로 만들어 Redis에 올려 두기
의견을 받을 때마다 페르소나 정보를 데이터베이스 일곱 개 테이블에서 모아 문장으로 바꾸는 건 낭비예요. 서버가 뜰 때 한 번 만들어 Redis에 올려 두고, 없으면 그때 채우는 방식을 정리했어요.
DropThePitch의 페르소나는 정보가 많아요. 기본 정보, 경제, 성향, 생활, 디지털, 판단 기준까지 항목이 마흔 개가 넘고, 데이터베이스에서는 성격별로 나눈 일곱 개 테이블에 흩어져 있어요.
의견을 받을 때는 이 정보를 AI에게 넘겨서 "당신은 이런 사람이에요"라고 알려 줘야 해요. 의견 수집 한 번에 페르소나 서른 명이니, 이 정보를 매번 모으면 꽤 많은 일을 반복하게 돼요.
매번 하던 일
페르소나 한 명의 프로필을 만들려면 이런 과정을 거쳐요.
- 페르소나와 딸린 테이블 여섯 개, 그리고 태그를 함께 읽어요.
- 응답 객체로 옮겨요.
- 항목마다 "이름: 값" 형태의 문장으로 바꿔서 하나의 글로 이어 붙여요.
그런데 이 결과는 거의 바뀌지 않아요. 페르소나는 미리 만들어 둔 600명이고, 페르소나를 고치는 API도 없어요. 사용자와 관리자 모두 조회만 해요. 매번 같은 재료로 같은 글을 만들고 있었던 거예요.
서버가 뜰 때 한 번에 만들어요
그래서 서버가 준비를 마친 시점에 모든 페르소나의 프로필을 글로 만들어 Redis에 올려 둬요.
@EventListener(ApplicationReadyEvent.class)
public void loadProfiles() {
Map<String, String> profiles = personaService.getAllPersonas().stream()
.collect(Collectors.toMap(
persona -> String.valueOf(persona.personaId()),
this::render,
(first, second) -> second));
if (profiles.isEmpty()) {
log.warn("[loadProfiles] 적재할 페르소나가 없습니다.");
return;
}
redisService.setHashValues(PROFILE_KEY, profiles);
}
ApplicationReadyEvent는 스프링이 모든 준비를 끝낸 뒤에 한 번 오는 이벤트예요. 이때는 데이터베이스 연결도, Redis 연결도 준비되어 있어요.
저장은 Redis의 해시 하나에 해요. persona:profile이라는 키 아래에 페르소나 id를 필드 이름으로, 프로필 글을 값으로 넣어요. 600개의 키를 따로 만드는 것보다 한 덩어리로 다루기 편하고, putAll 한 번으로 모두 올릴 수 있어요.
600명을 한 번에 읽을 때는 연관된 테이블을 모두 함께 읽도록 했어요. 그렇지 않으면 페르소나 한 명마다 테이블 여섯 개를 따로 조회해서, 600명이면 수천 번의 쿼리가 나가요.
없으면 그때 채워요
Redis가 재시작되거나, 서버가 뜬 뒤에 페르소나가 추가되면 프로필이 없을 수 있어요. 그래서 꺼낼 때 없으면 그 자리에서 만들어 넣어요.
public String getProfile(Long personaId) {
String loaded = redisService.getHashValue(PROFILE_KEY, String.valueOf(personaId));
if (loaded != null) {
return loaded;
}
log.info("[getProfile] Redis에 없는 페르소나: {}", personaId);
String profile = render(personaService.getPersona(personaId));
redisService.setHashValue(PROFILE_KEY, String.valueOf(personaId), profile);
return profile;
}
미리 올려 두는 것과 없을 때 채우는 것을 같이 쓰면, 평소에는 데이터베이스를 거치지 않고, 예외적인 경우에도 결과가 틀리지 않아요. 첫 요청이 조금 느려질 뿐이에요.
프로필은 사람이 읽는 글처럼
AI에게 넘길 프로필은 JSON이 아니라 글로 만들었어요. 섹션 제목을 붙이고, 항목마다 한 줄씩 써요.
appendSection(sb, "경제");
appendLine(sb, "소득 수준", persona.income());
appendLine(sb, "자산", persona.assets());
appendLine(sb, "소비 습관", persona.consumptionHabit());
appendLine(sb, "최우선 가치", persona.coreValue());
appendLine(sb, "유료 전환 기준", persona.paymentTrigger());
만들어진 글은 이런 모양이에요.
[페르소나]
<경제>
- 소득 수준: ...
- 소비 습관: ...
- 유료 전환 기준: ...
필드 이름을 영어 그대로 넘기면 paymentTrigger가 무슨 뜻인지 AI가 짐작해야 해요. "유료 전환 기준"이라고 쓰면 짐작할 필요가 없어요. 의견 지시문에서도 "이탈 트리거, 유료 전환 기준, 불신하는 지점"처럼 같은 이름으로 항목을 가리켜서, 지시문과 프로필이 같은 말을 쓰도록 맞췄어요.
값이 비어 있는 항목은 아예 쓰지 않아요.
private void appendLine(StringBuilder sb, String label, Object value) {
if (value == null || (value instanceof String text && text.isBlank())) {
return;
}
sb.append("- ").append(label).append(": ").append(value).append(System.lineSeparator());
}
"병역: null"이라고 적혀 있으면 AI가 그것도 특징으로 받아들여 엉뚱한 말을 할 수 있어요. 없는 정보는 없는 대로 두는 것이 나아요.
미리 만들 수 있는 것은 미리
이 방식이 잘 맞은 이유는 페르소나 프로필이 가진 성질 때문이에요.
- 거의 바뀌지 않아요.
- 개수가 정해져 있어요.
- 만드는 데 비용이 들지만, 결과는 작은 글이에요.
이런 데이터라면 요청이 올 때 만들 이유가 없어요. 반대로 사용자마다 다르거나 자주 바뀌는 데이터라면, 미리 만들어 두는 것이 오히려 틀린 결과를 보여주는 원인이 돼요.
정리
- 거의 바뀌지 않고 개수가 정해진 데이터는 서버가 뜰 때 미리 만들어 둬요.
- Redis 해시 하나에 모아 두면 한 번에 올리고 하나씩 꺼내기 편해요.
- 미리 올리기와 없을 때 채우기를 같이 쓰면, Redis가 비어도 결과는 틀리지 않아요.
- AI에게 넘길 프로필은 사람이 읽는 글처럼 만들고, 지시문과 같은 이름을 쓰고, 빈 값은 빼요.