- 발행일
저장 버튼이 다른 탭에서 담은 장소를 지우고 있었다
저장 버튼이 다른 탭에서 담은 장소를 지우고 있었다
TripTune2025-01-06협업형 여행 계획 플랫폼 TripTune — 2인 팀에서 프런트엔드 전체를 맡은 기록피그마처럼 여럿이 함께 일정을 짜는 서비스를 2인 팀으로 만들었습니다. MVP는 6개월, 이후 운영·개선까지 25개월간 프런트엔드 전체를 맡았어요. 인증 토큰 자동 갱신·STOMP 실시간 채팅·드래그 앤 드롭 일정 편집이 핵심이었고, React-Query와 Fetch를 역할로 나눈 기준과 MVP 이후에 드러난 것들을 함께 정리했습니다.의 일정 편집 화면에서는 여행 루트, 곧 방문할 장소와 그 순서를 고칩니다. 일정에는 참석자를 최대 5명까지 초대할 수 있어서 같은 일정을 여럿이 동시에 열어 둘 수 있어요. 10월 11일에 루트 저장 방식을 바꾸자는 제안서를 백엔드 검토용으로 썼고, 이 글은 그 제안이 11월 초까지 어떻게 바뀌어 들어갔는지를 정리한 기록입니다.
[저장] 한 번이 루트 전체를 덮어썼다
일정 상세 화면은 편집 중인 루트를 Zustand 스토어에 들고 있다가, [저장]을 누르면 일정명·날짜와 함께 루트 배열 전체를 PATCH /api/schedules/{id}로 보냈습니다. 서버는 받은 배열로 루트를 통째로 바꿨고요.
그런데 여행지 상세 화면의 "내 일정 담기"는 같은 루트에 POST /api/schedules/{id}/routes로 장소를 바로 저장하고 있었습니다. 같은 데이터를 한쪽은 모아 뒀다가 한꺼번에, 한쪽은 즉시 저장한 거예요. 그래서 사람 한 명, 탭 두 개로도 재현되는 버그가 있었습니다.
- 탭 A에서 일정 상세를 열어 둔다.
- 탭 B에서 여행지 상세의 "내 일정 담기"로 장소를 담는다. 서버에는 바로 들어간다.
- 탭 A에서 [저장]을 누른다. 탭 A가 들고 있던 배열로 전체를 바꾸니 2번에서 담은 장소가 지워진다.
여럿이 같이 고치면 더 조용히 사라졌어요. 버전도 수정 시각도 비교하지 않으니, 나중에 저장한 사람이 앞사람 작업을 덮어써도 아무도 알 수 없었습니다.
| 순서 | 동작 | 서버의 루트 |
|---|---|---|
| 처음 | [1, 2, 3] | |
| 1 | A가 4를 추가하고 저장 | [1, 2, 3, 4] |
| 2 | B가 4를 모른 채 순서를 바꿔 저장 | [3, 2, 1] |
A가 담은 4는 없어졌고 알림도 없었습니다.
STOMP로 "누가 저장했다"는 이벤트를 받는 것도 답이 되지 못했어요. 이벤트가 왔을 때 내 편집본을 버리고 서버 상태로 덮으면 내 작업이 사라지고, 무시하면 내 다음 저장이 상대 작업을 덮습니다. 모아 뒀다가 한 번에 저장하는 방식은 혼자 쓰는 폼에 맞는 모델이라, 여럿이 실시간으로 고치는 화면과는 처음부터 맞지 않았습니다.
이 화면의 루트 동기화는 전에도 한 번 손본 적이 있습니다. 일정 데이터 동기화 문제 해결하기2025-01-11일정 데이터 동기화 문제 해결하기 — 그리고 이 해결책은 나중에 뒤집혔다여행 루트 목록에서 과거 데이터 병합 오류·삭제 데이터 미반영이 반복돼 병합 로직을 재작성했던 기록입니다. 당시엔 해결이라고 썼는데, 이 방식은 결국 뒤집혔어요 — 병합 자체가 문제의 근원이라 react-query를 이 화면에서 걷어내는 것으로 끝났습니다. 중간 기록으로서의 가치가 있어 결말과 함께 남깁니다. (원래 2023-09로 잘못 적혀 있던 날짜를 커밋 기준 2025-01로 교정했습니다.)에서는 캐시와 스토어를 병합하다가 결국 스토어 하나만 남겼는데, 이번에는 그 스토어와 서버 사이가 문제였어요.
제안: 쓰기는 누를 때마다, 알림은 STOMP로
제안서의 요지는 세 줄이었습니다.
- 루트 추가·삭제·순서 변경은 누를 때마다 바로 REST로 저장한다.
- [저장] 버튼은 일정명·시작일·종료일만 저장한다.
- 루트나 일정이 바뀌면 서버가 STOMP로 "바뀌었다"는 알림만 보내고, 받은 쪽이 다시 조회한다.
서버가 유일한 기준이 되니 클라이언트가 지켜야 할 "저장 안 된 초안"이 없어집니다. 알림에 데이터를 싣지 않고 다시 조회하게 한 건, 이벤트가 두 번 오거나 순서가 뒤바뀌어도 결과가 같게 하려는 거였어요. "요청이 늘어 서버 부담이 커진다"는 걱정에는, 참석자가 많아야 5명이고 드래그도 놓는 순간 한 번만 보내니 오히려 저장할 때마다 루트 전체를 다시 보내는 지금 방식이 더 무겁다고 적었습니다.
충돌은 이렇게 풀자고 했습니다. 추가와 삭제는 장소 집합에 넣고 빼는 일이라 순서가 바뀌어도 결과가 같고, 순서 변경은 마지막에 쓴 사람이 이긴다(last-writer-wins). 여기에 "순서 변경 요청에 없는 기존 장소는 지우지 않고 뒤에 붙인다"는 규칙을 더해, 순서를 바꾸다가 남이 막 담은 장소를 지우는 일을 막으려 했어요. 참석자가 5명뿐이니 버전 검사나 409까지는 필요 없다고 봤습니다.
이 규칙대로라면 앞의 예시는 이렇게 됩니다.
| 서버에 도착한 순서 | 처리 | 결과 |
|---|---|---|
| 추가(4) → 순서([3, 2, 1]) | 순서 요청에 4가 없으니 지우지 않고 뒤에 붙임 | [3, 2, 1, 4] |
| 순서([3, 2, 1]) → 추가(4) | 순서를 바꾼 뒤 4를 맨 뒤에 추가 | [3, 2, 1, 4] |
어느 쪽 작업도 사라지지 않고 모두 같은 결과에 모입니다. 제안서에는 이 표를 근거로 넣었어요.
들어간 건 합치기가 아니라 거절이었다
이벤트 설계를 정리하면서 두 안이 나왔습니다. 1안은 [저장] 버튼을 그대로 두고 버전을 실어 루트 전체를 저장하는 것, 2안은 클릭할 때마다 한 건씩 저장하는 것이었어요. 10월 16일에 1안을 먼저 짜고 같은 날 2안으로 바꿨고, 2안이 최종이 됐습니다. 누를 때마다 저장한다는 방향은 제안서 그대로였어요.
충돌 처리는 달랐습니다. 일정에 routeVersion과 scheduleVersion이 생겼고, 쓰기 요청마다 내가 알고 있는 버전을 같이 보냅니다. 서버는 버전을 올리는 UPDATE에 조건을 걸어 뒀어요.
update TravelSchedule s
set s.routeVersion = :routeVersion + 1,
s.updatedAt = now()
where s.scheduleId = :scheduleId
and s.routeVersion = :routeVersion
요청에 실린 버전이 서버 값과 같을 때만 행이 바뀌니, 같은 버전으로 거의 동시에 온 두 요청 중 하나만 성공합니다. 나머지는 409와 함께 여행 루트가 변경되었습니다. 최신 정보를 확인해주세요.를 받아요. 일정명·날짜도 scheduleVersion으로 같은 규칙을 따릅니다.
API는 제안서와 이렇게 갈렸습니다.
| 동작 | 제안 | 실제 |
|---|---|---|
| 추가 | POST /routes { placeId }. 이미 있으면 아무것도 하지 않음 | POST /routes { placeId, routeVersion }. 같은 장소도 한 번 더 들어간다 |
| 삭제 | DELETE /routes/{placeId}. 없으면 아무것도 하지 않음 | DELETE /routes/{routeId}?routeVersion=n. 없으면 404 |
| 순서 변경 | PUT /routes/order { placeIds }. 빠진 장소는 뒤에 붙임 | PATCH /routes { routeVersion, orderedRouteIds }. 지금 루트와 정확히 같은 목록이어야 하고, 다르면 400 |
| 일정명·날짜 | PATCH /schedules/{id}. travelRoutes를 빼면 루트는 그대로 | travelRoutes 필드가 아예 빠지고 scheduleVersion이 필수 |
| 알림 토픽 | /sub/schedules/{id}/updates | /sub/schedules/{id}/events. 참석자 이벤트와 같은 토픽 |
같은 장소가 루트에 두 번 들어갈 수 있으니 장소 id로는 어느 항목을 지울지 정할 수 없고, 그래서 삭제는 루트 항목의 id(routeId)로 합니다. 순서 변경도 "빠진 장소는 뒤에 붙인다" 대신 "지금 루트와 정확히 같아야 한다"가 됐어요. 그 사이 누가 장소를 담았다면 버전이 이미 올라가 있어 409로 걸리니, 담은 장소는 서버가 목록을 맞춰 주지 않아도 버전 검사가 지켜 줍니다.
알림은 제안처럼 DB 커밋이 끝난 뒤에 나갑니다. 루트 이벤트에는 데이터 없이 바뀐 routeVersion만 실리고, 일정명·날짜 이벤트는 제안과 달리 바뀐 값을 그대로 실어 와서 다시 조회하지 않아도 돼요.
같은 예시를 실제 규칙으로 다시 보면
처음 루트는 [1, 2, 3], routeVersion은 5입니다. A가 4를 추가하고 B가 거의 동시에 [3, 2, 1]로 순서를 보내요. 둘 다 버전 5를 싣고 있습니다.
| 서버에 도착한 순서 | 처리 | 결과 |
|---|---|---|
| 추가(4) → 순서 | 추가 성공, 버전 6. 순서 요청은 버전 5라 409 | [1, 2, 3, 4]. B는 최신 루트를 보고 순서를 다시 바꾼다 |
| 순서 → 추가(4) | 순서 변경 성공, 버전 6. 추가 요청은 버전 5라 409 | [3, 2, 1]. A는 4를 다시 담는다 |
제안서의 표처럼 둘 다 살아남지는 않습니다. 거절된 쪽이 한 번 더 해야 해요. 대신 조용히 사라지는 일은 없고, 거절은 화면에 메시지로 보입니다. 처음 문제였던 덮어쓰기는 "감지조차 못 하던" 데서 "거절되는 게 보이는" 쪽으로 바뀌었어요.
탭 두 개 버그도 없어졌습니다. [저장]이 더 이상 루트를 보내지 않고, "내 일정 담기"도 routeVersion을 같이 보내서 오래된 목록에서 담으면 409를 받고 목록을 다시 불러옵니다.
프런트에서 맞춘 것
스토어는 서버 루트를 그대로 비추는 쪽으로 줄였습니다. 제안서에는 추가·삭제를 화면에 먼저 그리고(낙관적 반영) 실패하면 되돌리자고 적었지만, 실제로는 그렇게 하지 않았어요. 요청을 보내고, 응답의 routeVersion을 반영하고, 성공이든 실패든 루트를 다시 불러오는 순서입니다. 드래그는 react-dnd로 짠 목록2026-07-29react-dnd 정렬, item.index 한 줄이 빠지면 순서가 튄다여행 일정의 순서를 드래그로 바꾸는 기능을 react-dnd로 만들며 겪은 것들입니다. drop이 아니라 hover 시점에 정렬하는 이유, 드래그 아이템의 index를 직접 갱신하지 않으면 hover마다 이동이 재실행되어 순서가 튀는 고전 함정, 그리고 이 글을 쓰며 지금 코드에서 새로 찾은 버그 — 삭제 드롭존이 한 번 빨개지면 안 돌아오는 문제 — 까지 정리했습니다.에서 hover 동안 화면 순서만 바꾸고, 놓는 순간 PATCH /routes를 한 번 보내요. 목록 밖에 놓으면 저장하지 않고 서버 순서로 되돌립니다.
버전이 생기자 이벤트를 다루기도 쉬워졌어요. 이벤트에 실린 버전이 이미 반영한 버전 이하면 무시하면 됩니다. 내 요청으로 나간 이벤트는 응답의 버전을 먼저 반영해 뒀으니 여기서 걸러지고요. 제안서에 넣었던 actor(누가 바꿨는지) 필드는 들어가지 않았고, 자기 이벤트는 이 버전 비교로 거릅니다.
11월 3일에는 동시 편집에서 남은 문제 둘을 더 고쳤습니다.
- 이벤트가 HTTP 응답보다 먼저 오는 경우. 이벤트를 받고 한 번, 응답 뒤에 또 한 번 재조회가 나갔어요. 응답을 기다리는 내 요청이 있으면 이벤트를 건너뛰고, 응답 뒤 재조회 한 번으로 끝냅니다.
- 늦게 도착한 예전 조회. 동시에 나간 조회 중 먼저 보낸 것이 늦게 오면 화면이 예전 루트로 돌아가고, 그 버전으로 보낸 다음 수정이 409를 받았어요. 지금 버전보다 낮은 조회 결과는 버립니다.
// 이미 반영된 version이면 무시. 내 요청이 응답 전이면 응답 후 어차피 재조회하므로 건너뛴다
onRouteEvent: async (scheduleId: number, routeVersion: number) => {
if (pendingRouteRequests > 0) return;
if (routeVersion <= (get().routeVersion ?? -1)) return;
await get().loadRoutes(scheduleId);
},
// 동시에 나간 조회 중 먼저 보낸 것이 늦게 도착하면 예전 루트·version이라 버린다
// (덮어쓰면 화면이 되돌아가고 다음 수정이 409가 된다)
if (
routeVersion !== undefined &&
routeVersion < (get().routeVersion ?? -1)
)
return;
같은 날 일정에서 내보내진 경우도 고쳤습니다. 내보내졌다는 이벤트가 나를 가리키면 안내를 띄우고 3초 뒤 일정 목록으로 보내요. 전에는 내보내진 뒤 참석자 재조회가 실패하면서 원래 있던 이동 처리가 동작하지 않았습니다.
이 동작들은 스토어 테스트 8건으로 묶었어요. 전체 페이지 조회, 현재 버전으로 추가, 놓는 순간 전체 순서 전송, 409면 메시지와 최신 루트 복구, [-]로 같은 장소 전부 삭제, 늦게 온 조회 무시, 응답 전 이벤트 건너뛰기, 편집 권한이 없으면 요청하지 않기입니다.
남은 것
- 다시 연결될 때 재조회하지 않습니다. 제안서에는 STOMP가 다시 붙으면 루트와 일정을 한 번 다시 읽자고 적었는데, 지금은 구독만 다시 걸어요. 끊긴 동안 놓친 변경은 다음 이벤트가 오거나 내 다음 요청이 409를 받아야 채워집니다.
- STOMP 클라이언트가 아직 채팅 컴포넌트 안에 있습니다. 제안서대로 공용으로 빼지 못해서, 이벤트 구독은 일정 상세를 불러와 채팅 패널이 뜬 뒤에야 시작돼요.
- 403 문구가 서버와 프런트에서 끝말이 다릅니다. 서버는 "…회원 입니다.", 프런트는 "…사용자 입니다."라고 써요. 프런트 공통 에러 처리는 문구를 보고 "뒤로 가기"를 고르는데, 서버 문구와 맞지 않아 그 분기는 지금 한 번도 타지 않습니다. 어느 쪽에 맞출지 정해야 해요.
정리
제안서에는 "5명 규모라 버전 검사는 필요 없다"고 적었는데, 들어간 건 그 버전 검사였습니다. 둘 다 살리는 병합은 서버가 지켜야 할 규칙이 여럿이었고, 버전 검사는 거절된 사람이 한 번 더 해야 하는 대신 조용히 사라지는 일을 조건 하나로 막았어요. 다음에 동시 편집을 설계한다면 "어떻게 합칠까"보다 "충돌을 사용자에게 어떻게 보여 줄까"를 먼저 묻겠습니다.