- 발행일
일정에 담은 장소를 컴포넌트 state에서 스토어로 옮겼다 — userId를 nickname으로 바꾸던 날
일정에 담은 장소를 컴포넌트 state에서 스토어로 옮겼다
이 글은 2026년 9월에 당시 커밋 이력을 다시 보며 정리한 것입니다. 날짜는 작업한 날 기준입니다.
TripTune의 일정 만들기 화면은 왼쪽에서 여행지를 검색해 담고, 오른쪽에서 담은 순서를 드래그로 바꾸는 구조입니다. 2024년 10월 26일 a60a49c에서 이 화면을 크게 손보면서 react-dnd·react-dnd-html5-backend·dayjs·react-intersection-observer를 한꺼번에 넣었어요. 23개 파일, +1,985/−1,568짜리 커밋이었습니다.
그로부터 한 달 남짓 뒤인 11월 20일, 서버 응답의 사용자 식별자가 userId에서 nickname으로 바뀌었습니다. 누가 먼저 정했는지는 커밋에 없어요. 그걸 반영하는 커밋 3581ac3 "User id change nick name (#147)"이 이 글의 주인공인데, 제목과 달리 diff의 대부분은 담은 장소 목록을 어디에 둘 것인가를 바꾼 것이었습니다. 7개 파일, +150/−196.
그 전까지 — 주인이 둘인 목록
11월 20일 전의 ScheduleRoute.tsx는 담은 장소를 자기 state로 갖고 있었습니다.
const ScheduleRoute = ({ places }: ScheduleRouteProps) => {
const [routePlaces, setRoutePlaces] = useState<Place[]>([]);
const travelRouteQuery = useScheduleTravelRoute(Number(scheduleId), 1, true);
useEffect(() => {
if (travelRouteQuery.isSuccess && travelRouteQuery.data?.pages) {
const apiPlaces = travelRouteQuery.data.pages.flatMap(/* ... */);
setRoutePlaces(apiPlaces);
}
}, [travelRouteQuery.isSuccess, travelRouteQuery.data]);
useEffect(() => {
if (places) {
setRoutePlaces((prevPlaces) => [
...prevPlaces,
...places.filter((place) => !prevPlaces.some((p) => p.placeId === place.placeId)),
]);
}
}, [places]);
routePlaces를 서버 응답이 덮어쓰고, 부모가 내려준 places가 또 병합합니다. 두 effect의 순서는 응답 타이밍에 달려 있어요. 두 달 전 무한 스크롤에서는 왜 이슈가 생긴 걸까2024-09-25무한 스크롤에서는 왜 이슈가 생긴 걸까 — 상태의 주인이 둘일 때무한 스크롤을 붙인 화면마다 이슈가 났는데, 돌아보니 라이브러리 문제가 아니라 전부 "누적된 목록의 주인이 누구인가"가 흐릿해서 생긴 문제였습니다. react-query 캐시와 zustand 스토어가 같은 목록을 두고 싸우다 한쪽을 걷어낸 이야기, 페이지 번호를 컴포넌트가 들고 있다가 캐시에 넘긴 이야기를 정리했습니다.에서 "상태의 주인이 둘이면 문제가 생긴다"고 써 놓고, 다른 화면에서 같은 모양을 또 만들고 있었습니다. 그 글을 쓸 때는 목록 페이지만 보고 있었고, 일정 화면은 다른 문제라고 생각했어요.
스토어로 옮기다
3581ac3에서 src/store/scheduleStore.ts를 새로 만들었습니다.
export const useTravelStore = create<TravelStore>((set) => ({
addedPlaces: [],
addPlace: (place) =>
set((state) => ({
addedPlaces: state.addedPlaces.some((p) => p.placeId === place.placeId)
? state.addedPlaces
: [...state.addedPlaces, place],
})),
removePlace: (placeId) =>
set((state) => ({ addedPlaces: state.addedPlaces.filter((p) => p.placeId !== placeId) })),
movePlace: (dragIndex, hoverIndex) =>
set((state) => {
const updatedPlaces = [...state.addedPlaces];
const [movedPlace] = updatedPlaces.splice(dragIndex, 1);
updatedPlaces.splice(hoverIndex, 0, movedPlace);
return { addedPlaces: updatedPlaces };
}),
}));
ScheduleRoute는 state와 effect 두 개를 잃고 props 세 개를 받는 컴포넌트가 됐습니다.
interface ScheduleRouteProps {
places: Place[];
onMovePlace: (dragIndex: number, hoverIndex: number) => void;
onDeletePlace: (placeId: number) => void;
}
ScheduleMake가 스토어에서 addedPlaces·addPlace·removePlace·movePlace를 꺼내 내려줍니다. 검색 결과에서 담기를 누르면 addPlace, 드래그하면 movePlace, 삭제 영역에 놓으면 removePlace. 목록의 주인이 스토어 하나가 됐어요. 중복 검사도 addPlace 안으로 들어가서 effect의 filter가 필요 없어졌습니다.
zustand 스토어가 이 프로젝트에 처음 생긴 건 이날이 아닙니다. 9월 12일 db3b905에 검색 페이지의 currentPage·searchTerm·isSearching을 담은 travelStore.ts(19줄)가 이미 있었어요. 그러니까 이건 두 번째 스토어이고, "서버에서 온 목록을 화면이 편집한다"는 종류의 상태를 처음으로 컴포넌트 밖에 둔 날입니다.
같은 커밋에서 생긴 두 가지
첫째, 스토어 훅 이름입니다. 새 파일의 export가 useTravelStore인데, 두 달 전 travelStore.ts의 export도 useTravelStore예요. 파일이 달라서 import 경로로 구분되니 빌드는 됩니다. 이름을 고민하지 않고 옆 파일을 복사해 만든 흔적인데, 이 이름 충돌은 2026년 9월 현재 저장소에도 그대로 있습니다. scheduleStore.ts의 useTravelStore와 travelStore.ts의 useTravelStore.
둘째, PlaceItem의 위치입니다. 전에는 파일 상단에 따로 정의된 컴포넌트였는데, 이 커밋에서 ScheduleRoute 함수 안으로 들어갔습니다.
const ScheduleRoute = ({ places, onMovePlace, onDeletePlace }: ScheduleRouteProps) => {
const PlaceItem = ({ place, index }: { place: Place; index: number }) => {
const ref = useRef<HTMLLIElement>(null);
const [, drop] = useDrop({ /* ... onMovePlace(dragIndex, hoverIndex) ... */ });
props로 받은 onMovePlace를 클로저로 잡으려고 안으로 넣은 것으로 보이는데, 컴포넌트를 렌더 함수 안에서 정의하면 렌더마다 새 컴포넌트 타입이 됩니다. React는 매번 언마운트하고 다시 마운트해요. 드래그 정렬은 hover마다 렌더가 일어나는 기능이라 제일 안 맞는 조합입니다. 그때는 동작하니까 넘어갔고, 이게 문제라는 걸 적은 건 20개월 뒤 react-dnd 드래그 정렬2026-07-29react-dnd 드래그 정렬 — item.index 한 줄을 빼먹으면 순서가 튄다여행 일정의 순서를 드래그로 바꾸는 기능을 react-dnd로 만들며 겪은 것들입니다. drop이 아니라 hover 시점에 정렬하는 이유, 드래그 아이템의 index를 직접 갱신하지 않으면 hover마다 이동이 재실행되어 순서가 튀는 고전 함정, 그리고 이 글을 쓰며 지금 코드에서 새로 찾은 버그 — 삭제 드롭존이 한 번 빨개지면 안 돌아오는 문제 — 까지 정리했습니다. 글에서였습니다. 그 글이 "현행 버그"로 지적한 것의 시작이 이 커밋이에요.
nickname 쪽은 diff가 작았습니다. 타입에 nickname: string을 넣고 userId는 optional로 남겼고, 목록의 key를 user.userId에서 user.nickname으로 바꿨어요. 그리고 이런 줄이 하나 들어갔습니다.
- userId: user.userId || 0,
+ nickname: user.nickname || 0,
문자열 필드의 기본값이 0입니다. userId || 0을 이름만 바꾼 자리예요. 이 map 콜백의 user가 any라 user.nickname || 0도 any가 되어 타입 검사에 걸리지 않았습니다.
검증
- 검증 기록은 없습니다. 담기·드래그·삭제를 화면에서 눌러 본 것 이상의 확인은 커밋에 남아 있지 않아요.
- 테스트는 없었습니다. 이 시점 저장소에 테스트 파일은 0개였습니다.
useTravelStore이름 충돌과PlaceItem위치는 이 글을 쓰면서 diff를 다시 보고 안 것입니다. 당시에는 둘 다 인지하지 못했습니다.
남은 것 · 한계
- 스토어로 옮긴 판단은 맞았습니다. 주인이 둘이던 상태가 하나가 됐고, 3주 뒤
5d9c065(12월 9일)에서 이 스토어가 112줄로 커지면서 일정 화면의 중심이 됐어요. - 같은 커밋에서 만든 문제 둘은 못 봤습니다. 훅 이름 충돌은 지금도 있고, 컴포넌트 안의 컴포넌트는 2026년 7월에야 글로 지적했지 고치지는 않았습니다.
ScheduleMake안의ScheduleRouteWrapper도 같은 모양이에요. nickname || 0은 그대로 커밋됐습니다. 문자열에 숫자 기본값을 준 자리인데, 서버가 nickname을 늘 주니 실제로0이 찍힌 적은 없을 겁니다. "터지지 않는 잘못된 코드"가 어떻게 남는지의 예로 적어 둡니다.- 스토어를 쓰기 시작하면서 서버 상태(react-query)와 화면 상태(zustand)의 경계를 어떻게 그을지는 정하지 않았습니다. 그 경계가 흔들려서 생긴 일이 일정 데이터 동기화 문제 해결하기2025-01-11일정 데이터 동기화 문제 해결하기 — 그리고 이 해결책은 나중에 뒤집혔다여행 루트 목록에서 과거 데이터 병합 오류·삭제 데이터 미반영이 반복돼 병합 로직을 재작성했던 기록입니다. 당시엔 해결이라고 썼는데, 이 방식은 결국 뒤집혔어요 — 병합 자체가 문제의 근원이라 react-query를 이 화면에서 걷어내는 것으로 끝났습니다. 중간 기록으로서의 가치가 있어 결말과 함께 남깁니다. (원래 2023-09로 잘못 적혀 있던 날짜를 커밋 기준 2025-01로 교정했습니다.)에 있고, 그 글의 해결책도 나중에 뒤집혔습니다.
관련 글: 무한 스크롤에서는 왜 이슈가 생긴 걸까 — 상태의 주인이 둘일 때2024-09-25무한 스크롤에서는 왜 이슈가 생긴 걸까 — 상태의 주인이 둘일 때무한 스크롤을 붙인 화면마다 이슈가 났는데, 돌아보니 라이브러리 문제가 아니라 전부 "누적된 목록의 주인이 누구인가"가 흐릿해서 생긴 문제였습니다. react-query 캐시와 zustand 스토어가 같은 목록을 두고 싸우다 한쪽을 걷어낸 이야기, 페이지 번호를 컴포넌트가 들고 있다가 캐시에 넘긴 이야기를 정리했습니다. · react-dnd 드래그 정렬 — item.index 한 줄을 빼먹으면 순서가 튄다2026-07-29react-dnd 드래그 정렬 — item.index 한 줄을 빼먹으면 순서가 튄다여행 일정의 순서를 드래그로 바꾸는 기능을 react-dnd로 만들며 겪은 것들입니다. drop이 아니라 hover 시점에 정렬하는 이유, 드래그 아이템의 index를 직접 갱신하지 않으면 hover마다 이동이 재실행되어 순서가 튀는 고전 함정, 그리고 이 글을 쓰며 지금 코드에서 새로 찾은 버그 — 삭제 드롭존이 한 번 빨개지면 안 돌아오는 문제 — 까지 정리했습니다. · 일정 데이터 동기화 문제 해결하기2025-01-11일정 데이터 동기화 문제 해결하기 — 그리고 이 해결책은 나중에 뒤집혔다여행 루트 목록에서 과거 데이터 병합 오류·삭제 데이터 미반영이 반복돼 병합 로직을 재작성했던 기록입니다. 당시엔 해결이라고 썼는데, 이 방식은 결국 뒤집혔어요 — 병합 자체가 문제의 근원이라 react-query를 이 화면에서 걷어내는 것으로 끝났습니다. 중간 기록으로서의 가치가 있어 결말과 함께 남깁니다. (원래 2023-09로 잘못 적혀 있던 날짜를 커밋 기준 2025-01로 교정했습니다.) · useCallback 17개를 지운 "최적화" 커밋2024-11-07useCallback 17개를 지운 "최적화" 커밋 — 그리고 같은 커밋에서 새로 만든 문제커밋 제목이 "useCallback 최적화"인데 diff를 보면 useCallback을 추가한 줄은 0, 지운 줄은 17이었습니다. 의존성 배열에 zustand 세터를 넣고 있었고, 변수에 담지도 않은 useCallback 호출도 하나 있었어요. 지운 판단 자체는 지금도 맞다고 보는데, 같은 커밋에서 메모이제이션을 벗긴 함수를 useEffect 의존성에 넣어 매 렌더마다 인증 검사를 돌게 만들었습니다. 측정은 없었습니다.