프론트엔드에서 API 20번 호출을 1번으로 최적화한 이야기
2026-03-17 · FRONTEND
경연곡 관리 기능에서 불필요한 API 호출을 하나로 통합하고, 트랜잭션으로 데이터 무결성을 보장한 과정을 소개합니다.
문제 상황
경연곡 관리 기능은 사이드바에서 라운드별로 곡을 추가/삭제/수정한 뒤 "저장하기" 버튼을 누르면 서버에 반영되는 구조였습니다.
처음 구현한 저장 로직은 이랬습니다:
[저장 버튼 클릭]
1. 기존 데이터 전부 삭제
DELETE /repertoire-group/1
DELETE /repertoire-group/2
DELETE /repertoire-group/3
2. 새 데이터 하나씩 생성
POST /repertoire-group → 그룹A 생성 → id: 10
POST /repertoire-item → 그룹A의 곡1
POST /repertoire-item → 그룹A의 곡2
POST /repertoire-group → 그룹B 생성 → id: 11
POST /repertoire-item → 그룹B의 곡1
...
3. 라운드 설명 저장
POST /repertoire-step (round1)
POST /repertoire-step (round2)
POST /repertoire-step (final)
악기 하나에 그룹 5개, 곡 20개면 약 28번의 API 호출이 순차적으로 발생했습니다.
왜 문제인가
문제 1: 데이터 유실 가능
DELETE 그룹1 → 성공 (DB에서 삭제됨)
DELETE 그룹2 → 성공 (DB에서 삭제됨)
DELETE 그룹3 → 성공 (DB에서 삭제됨)
POST 그룹A → 성공 (DB에 생성됨)
POST 그룹B → ❌ 네트워크 에러
결과: 기존 3개는 삭제됐고, 새 데이터는 1개만 생성됨
→ 사용자가 입력한 그룹B, C의 데이터가 사라짐
각 API 호출이 독립적이기 때문에, 삭제는 됐는데 생성이 실패하면 중간 상태가 됩니다.
문제 2: 느리다
28번의 순차 HTTP 요청. 각각 100ms라면 저장에 2.8초. 사용자는 저장 버튼을 누르고 3초 동안 기다려야 합니다.
문제 3: 저장 중 조작 가능
저장이 진행되는 2.8초 동안 사용자가:
- 다른 악기를 클릭 → 사이드바 데이터가 바뀜
- 사이드바를 닫음 → 진행 중인 API가 에러
- 곡을 수정 → 저장 중인 데이터와 폼 데이터가 불일치
어떻게 고쳤는가
1. 백엔드에 bulk-save API 추가
프론트에서 28번 호출하던 걸, 서버에서 한 번에 처리하는 API를 만들었습니다.
// Before: 프론트가 직접 삭제 + 생성을 순차 호출
프론트 → DELETE /group/1
프론트 → DELETE /group/2
프론트 → POST /group (A)
프론트 → POST /item (A-1)
프론트 → POST /item (A-2)
프론트 → POST /group (B)
프론트 → POST /item (B-1)
... (28번)
// After: 프론트가 데이터를 한 번에 보냄
프론트 → POST /repertoire-group/bulk-save
{
instrumentId: 1,
groups: [
{ stepCode: "round1", repertoireType: "required", items: [...] },
{ stepCode: "round1", repertoireType: "choice", items: [...] },
...
],
steps: [
{ stepCode: "round1", description: "..." },
{ stepCode: "final", description: "윈드 오케스트라 협연" },
]
}
서버는 이 요청을 받으면 트랜잭션 안에서 처리합니다:
// Rust (서버)
let txn = db.begin().await?; // 트랜잭션 시작
// 1. 기존 그룹 전부 삭제
RepertoireGroup::delete_many()
.filter(Column::InstrumentId.eq(instrument_id))
.exec(&txn).await?;
// 2. 새 그룹 + 아이템 생성
for group in &req.groups {
let created = insert_group(&txn, group).await?;
for item in &group.items {
insert_item(&txn, created.id, item).await?;
}
}
// 3. 라운드 설명 upsert
for step in &req.steps {
upsert_step(&txn, instrument_id, step).await?;
}
txn.commit().await?; // 전부 성공 → 확정
// 어디서든 에러 → txn이 drop되면서 자동 롤백 → 아무것도 안 바뀜
트랜잭션의 핵심: commit()이 호출되기 전에 에러가 나면, 삭제도 생성도 전부 없던 일이 됩니다. 중간 상태가 존재할 수 없습니다.
2. 프론트의 handleSave 단순화
// Before — 28번의 순차 API 호출
const handleSave = async () => {
// 기존 그룹 하나씩 삭제
for (const groupId of existingGroupIds) {
await deleteRepertoireGroup(groupId)
}
// 새 그룹 + 아이템 하나씩 생성
for (const group of allGroups) {
const created = await createRepertoireGroup(...)
for (const item of group.items) {
await createRepertoireItem(...)
}
}
// 라운드 설명 하나씩 저장
for (const step of steps) {
await upsertRepertoireStep(...)
}
}
// After — 1번의 API 호출
const handleSave = async () => {
// 폼 상태를 요청 바디로 변환
const groups = ROUND_ORDER.flatMap(roundCode =>
roundGroups[roundCode].map(group => ({
stepCode: roundCode,
repertoireType: group.repertoireType,
label: group.label || undefined,
items: group.items.filter(hasContent).map(item => ({
composerName: item.composer,
title: item.title,
...
})),
}))
)
const steps = ROUND_ORDER.map(roundCode => ({
stepCode: roundCode,
description: stepDescriptions[roundCode].description || undefined,
...
}))
// 단일 호출 — 서버가 트랜잭션으로 처리
await bulkSaveRepertoire({ instrumentId, groups, steps })
}
더 이상 existingGroupIds를 추적할 필요도 없습니다. 서버가 "이 악기의 기존 데이터를 전부 지우고 새로 넣어줘"를 한 번에 처리니까요.
3. 저장 중 조작 차단
<VStack
opacity={isSaving ? '0.6' : '1'} // 시각적 피드백
pointerEvents={isSaving ? 'none' : 'auto'} // 클릭 차단
>
{/* 전체 폼 내용 */}
</VStack>
<Sidebar
onClose={() => { if (!isSaving) onClose() }} // 저장 중 닫기 차단
>
저장 중에는:
- 폼 전체가 반투명해지고 클릭 불가
- 사이드바 닫기 버튼/배경 클릭이 무시됨
- 저장 버튼은 이미
disabled={isSaving}
결과
| Before | After | | ----------------- | ------------------------ | | 28번 API 호출 | 1번 API 호출 | | 2.8초 소요 | ~100ms | | 중간 상태 위험 | 트랜잭션으로 원자적 처리 | | 저장 중 조작 가능 | 저장 중 조작 차단 |
배운 점
-
API 호출 횟수는 최소화할수록 좋다는 것: 네트워크 요청은 비용입니다. 가능하다면 하나로 합치는 게 좋습니다.
-
트랜잭션은 데이터 무결성의Friend: 여러 단계를 하나의 원자적 작업으로 묶어주면, 중간 상태로 인한 데이터 유실을 방지할 수 있습니다.
-
UX도 중요하다: 저장 중 사용자가 다른 작업을 시도하면 예상치 못한 에러가 발생할 수 있습니다. 시각적 피드백과 조작 차단을 통해 더 좋은用户体验를 제공할 수 있습니다.