상담은 3일 뒤에 하는데, 추천 결과는 오늘 것이다
추천 결과를 보고 상담을 신청한 사용자와, 며칠 뒤 그 상담을 진행하는 상담원이 서로 다른 결과를 보게 되는 문제. 계산해서 보여줄 것인가 저장해둘 것인가에 대한 기록
1. 문제 상황: 상담원이 보는 화면을 만들다가 멈췄다
추천 결과 화면에서 바로 상담을 신청할 수 있는 기능을 붙였다. 관리자 쪽에는 신청 목록과 상세 화면이 필요했다. 상담원이 전화를 걸기 전에 이 사용자가 어떤 답변을 했고 어떤 학교를 추천받았는지 봐야 하기 때문이다.
상세 화면 API를 짜다가 손이 멈췄다. 처음 구상은 단순했다.
상담 신청 조회 → 연결된 설문 응답 조회 → 추천 로직 다시 실행 → 화면에 표시
그런데 이 흐름에는 전제가 하나 깔려 있다. 지금 계산한 결과가 그때 사용자가 본 결과와 같다는 전제.
이 전제는 유지되지 않는다.
2. 왜 다시 계산하면 안 되는가
상담은 신청 즉시 진행되지 않는다. 운영자가 목록을 확인하고 연락을 돌리기까지 며칠이 걸린다. 그 사이에 바뀔 수 있는 것들이 있었다.
추천 알고리즘. 가중치를 조정하거나 점수 계산 방식을 손보면 같은 답변에도 다른 학교가 나온다. 실제로 개발 기간 중에 학교 등급 데이터의 방향이 반대로 저장되어 있던 걸 발견해 변환 로직을 넣은 적이 있다. 그 수정 전후로 추천 결과는 당연히 달라진다.
학교 데이터. 운영자가 Excel로 학교 정보를 갱신한다. 취업률이 바뀌고, 학과가 추가되고, 좌표가 보정된다. 거리 점수와 진로 점수가 함께 움직인다.
설문 문항. 문항이 개편되면 과거 응답이 가리키는 의미 자체가 달라질 수 있다.
이 상황에서 상담원 화면에 지금 계산한 결과를 띄우면 어떻게 되는가.
사용자: "1순위로 나온 학교 때문에 상담 신청했는데요." 상담원: (화면에는 다른 학교가 1순위로 떠 있다)
기능은 정상 동작하고 에러도 없다. 하지만 서비스는 이미 신뢰를 잃는다. 그리고 이건 시간이 지나야만 드러나는 종류의 버그다. 개발 중에는 알고리즘도 데이터도 안 바뀌니 테스트에서 절대 안 잡힌다.
3. 무엇을 저장할 것인가
다시 계산하지 않고 저장하기로 했다. 그렇다면 무엇을 저장해야 하는가.
처음에는 추천 학교 3개 이름만 남기면 되겠다고 생각했다. 그런데 상담원이 실제로 하는 말을 떠올려보니 부족했다.
"○○고가 1순위로 나왔는데요, 관심 분야가 잘 맞고 통학 거리도 가까워서 그렇습니다."
이 문장을 하려면 점수 근거가 있어야 한다. 결국 사용자가 화면에서 본 것과 같은 정보가 전부 필요했다.
- 설문 정보와 사용자 답변
- 추천 학교 Top 3
- 관심사 / 거리 / 성취도 / 진로 / 학습성향 점수 breakdown
- 매칭된 관심 분야, 추천 학과
- 실제 거리, 상향 지원 여부
여기서 하나 더 걸렸다. 답변만 저장하면 안 된다는 것.
응답은 questionId → answer 형태로 저장된다. 나중에 설문이 개편되어 문항이 바뀌면, 저장된 답변은 남아 있어도 "그 답변이 무슨 질문에 대한 것이었는지"를 잃는다. 그래서 스냅샷에는 질문 원문까지 함께 넣었다.
4. 구현: 신청 시점에 찍어서 함께 저장
상담 신청 API에서 ConsultationRequest 를 만들 때 스냅샷을 같이 저장했다.
// consultations.service.ts (요약)
async create(userId: number, dto: CreateConsultationDto) {
const result = await this.prisma.matchingResult.findFirst({
where: { surveyResponseId: dto.surveyResponseId, userId },
include: { surveyResponse: { include: { template: true } } },
});
// 지금 이 순간의 상태를 그대로 찍는다
const snapshot = {
survey: {
templateId: result.surveyResponse.template.id,
version: result.surveyResponse.template.version,
// 답변만이 아니라 질문 원문까지 함께
answers: buildAnsweredQuestions(
result.surveyResponse.template.questions,
result.surveyResponse.answers,
),
},
recommendations: result.details, // 점수 breakdown 포함
};
return this.prisma.consultationRequest.create({
data: { ...dto, userId, snapshot },
});
}
관리자 상담 상세 화면은 이 snapshot 만 읽는다. 추천 로직을 호출하지 않는다. 알고리즘이 몇 번 바뀌든 상담원이 보는 화면은 그대로다.
5. 트레이드오프: 중복 저장을 받아들이기
이 방식은 같은 데이터를 두 벌 갖게 된다. MatchingResult 에도 있고 ConsultationRequest.snapshot 에도 있다. 정규화 관점에서는 당연히 좋지 않다.
그럼에도 이렇게 한 이유는, 두 데이터가 의미하는 시점이 다르기 때문이다.
MatchingResult: 현재 알고리즘 기준의 최신 추천 결과snapshot: 사용자가 그날 화면에서 실제로 본 것
이름은 같아도 역할이 다르다. 하나는 갱신되어야 하고 하나는 고정되어야 한다. 같은 값을 담고 있다고 해서 하나로 합치면, 고정되어야 할 쪽이 갱신에 휩쓸린다.
결제 내역에 상품명과 금액을 그대로 박아두는 것과 같은 이유다. 상품 가격이 오르면 과거 영수증도 같이 오르면 안 된다.
6. 고찰: 시간이 지나도 설명할 수 있는가
이 문제가 눈에 들어온 계기는 코드가 아니라 상담원의 하루를 떠올려본 것이었다. 신청이 들어오고, 며칠 뒤 전화를 걸고, 사용자에게 결과를 설명한다. 그 사이에 무엇이 바뀔 수 있는지를 따라가 보니 답이 나왔다.
지금 정상 동작하는 코드라도 시간이 지나면 틀린 답을 내놓는 경우가 있다. 데이터가 변하기 때문이다. 이런 건 테스트로 못 잡는다. 개발하는 동안에는 시간이 흐르지 않으니까.
그래서 기능을 만들 때 이 질문을 하나 더 붙이게 됐다.
이 화면은 6개월 뒤에 열어봐도 같은 걸 보여주는가? 같아야 하는 화면인가, 달라져야 하는 화면인가?
달라져야 하는 화면이면 계산해서 보여주면 된다. 같아야 하는 화면이면 저장해둬야 한다. 이 구분을 안 하고 만들면 둘 중 하나는 반드시 틀린다.