Typefy에 결과 저장 기능을 붙인 방법 — 자동 저장, Upsert, 결과 복원까지
Typefy에 결과 저장 기능을 붙인 방법 — 자동 저장, Upsert, 결과 복원까지#
"아까 내 결과 뭐였지?"#
Typefy는 성격 유형 퀴즈를 만들고 공유하는 서비스입니다. 반려동물 MBTI처럼 12문항을 풀고 결과를 확인합니다.
그런데 한 가지 불편한 점이 있었습니다. 결과를 다시 보려면 퀴즈를 처음부터 다시 풀어야 합니다.
친구에게 "나 ENFP 고양이였어"라고 자랑하고 싶은데, 정확한 결과가 기억나지 않습니다. 결국 12문항을 다시 풀게 됩니다. 로그인 사용자에 한해 퀴즈 결과를 자동 저장하고, "내 결과" 페이지에서 목록 조회, 결과 복원, 삭제까지 할 수 있는 시스템을 구축했습니다.
시스템 아키텍처 개요#
전체 흐름은 다음과 같습니다.
[사용자가 퀴즈 완료]
↓
[결과 화면 렌더링] → useEffect 트리거
↓
[POST /api/v1/test-completions] → JWT 인증
↓
[UserTestCompletionService.save()] → Upsert 로직
↓
[user_test_completions 테이블 저장]
↓
[React Query 캐시 무효화] → 목록 자동 갱신
핵심 결정: 버튼 없이 자동 저장#
결과 저장을 "버튼 클릭"으로 할지 "자동"으로 할지가 첫 번째 의사결정이었습니다. 결론은 자동 저장입니다.
- 사용자가 결과 화면에 도달했다는 건 이미 퀴즈를 완료했다는 뜻입니다
- "나중에 다시 보고 싶다"는 니즈는 항상 존재합니다
- 저장 실패가 발생해도 결과 화면 자체는 정상 표시됩니다
결과 화면이 뜨는 순간 백그라운드에서 API 호출이 일어나고, 성공 시 "자동 저장됨"이라는 작은 텍스트만 보여줍니다.
데이터베이스 설계#
테이블 구조#
user_test_completions 테이블 하나로 충분합니다.
CREATE TABLE user_test_completions (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL,
test_definition_id BIGINT NOT NULL,
result_tag VARCHAR(50) NOT NULL,
answers TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_user_test_completions_user_id
ON user_test_completions(user_id);
CREATE INDEX idx_user_test_completions_test_definition_id
ON user_test_completions(test_definition_id);
설계 결정 포인트#
-
Upsert 패턴: 같은 사용자가 같은 테스트를 재응시하면 기존 레코드를 덮어씁니다. 테스트당 최신 결과 1개만 유지하는 구조입니다. 모든 기록을 남기는 방법도 있었지만, MVP에서는 "내 결과" 목록이 깔끔하게 유지되는 쪽을 선택했습니다.
-
answers 컬럼 (TEXT): 사용자의 선택지를
{"questionId": "choiceId"}형태의 JSON으로 저장합니다. 이 데이터가 있으면 나중에 "내가 뭐라고 답했지?"를 확인하고, 결과 화면을 그대로 복원할 수 있습니다. -
FK 제약 조건 없음: weggle-plus의 정책에 따라 외래 키 제약 조건을 사용하지 않습니다. 관계는 애플리케이션 레이어에서 관리합니다.
백엔드 구현 (Spring Boot + Kotlin)#
API 엔드포인트#
| Method | Path | 인증 | 응답 |
|---|---|---|---|
| POST | /api/v1/test-completions | JWT 필수 | 201 Created |
| GET | /api/v1/test-completions | JWT 필수 | 200 OK (배열) |
| GET | /api/v1/test-completions/{id} | JWT 필수 | 200 OK |
| DELETE | /api/v1/test-completions/{id} | JWT 필수 | 204 No Content |
모든 엔드포인트는 @AuthenticationPrincipal로 로그인한 사용자를 식별합니다. 삭제 시에는 본인 소유 여부도 검증합니다.
엔티티#
@Entity
@Table(name = "user_test_completions")
class UserTestCompletion(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long = 0,
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "user_id")
val user: UserAccount,
@Column(name = "test_definition_id", nullable = false)
val testDefinitionId: Long,
@Column(name = "result_tag", length = 50, nullable = false)
var resultTag: String,
@Column(name = "answers", columnDefinition = "text")
var answers: String? = null,
) : BaseEntity()
@ManyToOne(fetch = FetchType.LAZY)로 UserAccount를 지연 로딩합니다. 목록 조회 시 사용자 정보를 매번 로딩할 필요가 없기 때문입니다.
서비스 레이어의 핵심: 배치 로딩#
목록 조회에서 N+1 쿼리를 피하기 위해 배치 로딩 패턴을 적용했습니다. 각 completion에 대해 테스트 정의(TestDefinition)와 결과 정보(TestResult)가 필요합니다. 개별 조회 대신 한 번에 모두 가져옵니다.
fun listByUser(userId: Long): List<TestCompletionResponse> {
val completions = repository.findByUserIdOrderByCreatedAtDesc(userId)
val testIds = completions.map { it.testDefinitionId }.distinct()
val definitions = testDefinitionRepository.findAllById(testIds)
.associateBy { it.id }
val results = testResultRepository.findAllByTestDefinitionIdIn(testIds)
return completions.mapNotNull { completion ->
val def = definitions[completion.testDefinitionId] ?: return@mapNotNull null
val result = results.find {
it.testDefinitionId == def.id && it.tag == completion.resultTag
} ?: return@mapNotNull null
TestCompletionResponse.from(completion, def, result)
}
}
10개의 저장된 결과가 있어도 쿼리는 정확히 3번입니다. completions 1회 + definitions 1회 + results 1회.
응답 DTO#
저장된 결과를 반환할 때 테스트 이름, 결과 제목, 톤 컬러까지 포함합니다. 프론트엔드에서 추가 API 호출 없이 카드를 렌더링할 수 있습니다.
data class TestCompletionResponse(
val id: Long,
val testDefinitionId: Long,
val testSlug: String,
val testTitle: String,
val resultTag: String,
val resultTitle: String,
val resultDescription: String,
val resultTone: String,
val resultImageUrl: String?,
val answers: Map<String, String>?,
val createdAt: OffsetDateTime,
)
프론트엔드 구현 (Next.js + React Query)#
자동 저장 메커니즘#
결과 화면이 렌더링되는 순간 useEffect로 저장을 트리거합니다.
useEffect(() => {
if (showResult && accessToken && !isRestored) {
saveMutation.mutate({
testDefinitionId: test.id,
resultTag: result.tag,
answers,
});
}
}, [showResult]);
세 가지 조건을 모두 만족해야 저장합니다:
showResult: 결과 화면이 표시된 상태accessToken: 로그인된 사용자!isRestored: 이전 결과를 복원한 게 아닌 경우 (중복 저장 방지)
React Query 캐시 전략#
const TEST_COMPLETIONS_KEY = ["test-completions"] as const;
export function useMyTestCompletions(accessToken?: string) {
return useQuery({
queryKey: TEST_COMPLETIONS_KEY,
queryFn: () => fetchMyTestCompletions(accessToken!),
enabled: !!accessToken,
staleTime: 1000 * 60, // 1분
});
}
staleTime: 60초: 페이지 이동 후 돌아와도 1분 이내라면 캐시된 데이터를 사용합니다enabled: !!accessToken: 토큰이 없으면 쿼리를 실행하지 않습니다- 저장/삭제 mutation 성공 시
invalidateQueries로 목록을 자동 갱신합니다
결과 복원 기능#
"내 결과" 페이지에서 카드를 클릭하면 /typefy/{slug}?completionId={id} URL로 이동합니다.
completionId가 URL 파라미터에 있으면 TestRunnerClient가 이를 감지하고:
- 저장된 completion을 API에서 조회합니다
- answers 데이터로 선택지를 복원합니다
- 결과 화면을 바로 표시합니다
isRestored = true로 설정하여 중복 저장을 방지합니다
사용자 입장에서는 이전 테스트를 "다시 여는" 것처럼 자연스럽습니다.
놓치기 쉬운 디테일들#
1. 삭제 시 이벤트 버블링 차단#
카드 안에 삭제 버튼이 있으므로, 삭제 클릭 시 카드의 링크 이동이 발생하지 않도록 e.stopPropagation()을 처리했습니다.
2. 삭제된 테스트 처리#
테스트가 관리자에 의해 삭제되면 해당 completion의 테스트 정의를 찾을 수 없습니다. mapNotNull로 이런 경우를 조용히 필터링합니다.
3. SSR/CSR 토큰 불일치#
getAccessToken()은 localStorage를 사용합니다. 서버 사이드에서는 접근할 수 없으므로, useEffect 내에서 클라이언트 마운트 후 토큰을 읽습니다.
기술 스택 정리#
| 레이어 | 기술 |
|---|---|
| Backend | Spring Boot 4 + Kotlin + JPA |
| Database | PostgreSQL + Flyway |
| Frontend | Next.js 16 + React 19 + TypeScript |
| 상태관리 | React Query (TanStack Query v5) |
| 인증 | JWT (Bearer Token) |
| 스타일링 | Tailwind CSS v4 |
마무리#
결과 저장 기능의 핵심은 "사용자가 의식하지 않아도 동작하는 시스템"이었습니다. 자동 저장, 캐시 무효화, 결과 복원. 이 세 가지가 맞물려야 "결과를 저장하고 다시 본다"는 단순한 사용자 스토리가 완성됩니다.
총 구현 범위는 백엔드 API 4개, DB 마이그레이션 2개, 프론트엔드 페이지 1개, 훅 4개였습니다. 기능의 크기와 사용자 경험의 품질은 비례하지 않습니다. 코드량은 적지만 자동 저장, 결과 복원, 캐시 무효화 같은 세부 사항에 신경 쓰니까 "잘 동작하는 작은 기능"이 되었습니다.
비슷한 고민을 하고 계시거나 같이 실험해보고 싶은 분은 커피챗 하고 싶습니다 ☕️ 📩 ksy90101@gmail.com
함께 읽으면 좋은 글
댓글
0의견을 남겨보세요. 로그인하면 닉네임이 자동으로 입력됩니다.
댓글을 불러오는 중...