프로젝트를 하면서 마주쳤던 문제 및 생각들을 한번 정리한다.
Flow를 보면 페이지에 걸쳐서 상태를 공유하면서 CRUD가 가능해야 한다. 그래서 어떤 저장소가 좋은 지 한번 고려를 해봤다.
App 전체에서 활용되는 client 전역 상태는 다음 세가지 조건을 모두 만족할 때만 생성한다. 아래 조건은 실무경험 및 공부를 통해 정립되었다.
<Context />로 풀어낼 수 없는 목적인 경우<Context />는 의존성 주입을 위한 도구이며, 외부에서 생성된 값을 하위 트리에 전달하기 위해 사용된다.읽기 중심의 값에는 적합하지만, 공유할 데이터가 자주 변경되거나 여러 위치에서 동시에 수정된다면 <Context />의 사용 목적과 맞지 않는다<Context />로 전달이 가능한 경우 필요하지 않다.그럼 이번 신규 서비스 업데이트에서 필요한 구조는 어떨까?
<ReduxStore>
<Routes>
<DeliveryAddress /> // 배송지 정보
<ServiceA />
<ServiceB />
<ServiceC />
<ServiceD /> // 상태 A
<ServiceE /> // 상태 A
<ServiceF /> // 상태 A
<ServiceG /> // 상태 A, 배송지 정보
</Routes>
</ReduxStore/>
내가 필요한 정보 교환 구간은 아래 두 개이다.
<ServiceD />, <ServiceE />, <ServiceF />, <ServiceG /> 간 교환<DeliveryAddress />와 <ServiceG />간 교환먼저 배송지 관련 정보는 이미 전역상태가 있기에 이를 활용하기로 했다.
상태 A는 어떨까?
<Context />를 고려했으나, 상태 A는 사용자의 입력값처럼 자주 변경되는 값이기 때문에 적합하지 않다고 판단Client 상태이지만 전역 상태 조건에는 부합하지 않고, 서비스 신청 완료 시 필요 없는 정보이기에 메모리를 계속 잡아 두지 않는 것이 좋을 것 같았다. 따라서, sessionStorage에 저장하고 useSyncExternalStore를 활용하여 react 상태주기에 연결시키는 것이 좋아보였다.
useSyncExternalStore에 대한 자세한 설명은 공식 사이트에 들어가면 더 자세히 볼 수 있다.
내가 마주한 문제는 직렬화(JSON.stringify)로 저장하는 포멧으로 인해 발생했다.
API 구조에 맞게 session에 저장할 구조를 미리 만들고 이미지 파일들을 제외하고 나머지 값들이 저장이 잘 되는 것을 확인 후에 이미지 저장을 하려고 직렬화를 했는데, File이 빈 객체로 저장이 되었다.
직렬화가 가능한 값으로는 아래와 같이 인지를 하고 있었는데, FIle도 안 된다.
결국, fileReader().onLoad를 통해서 직렬화된 image값을 저장을 하고, API에서는 File타입으로 전환을 해야 하기에 제출 전 File타입으로 복구를 다시 해줘야 하는 작업을 추가로 해줬다.
이미지 복원에 대한 로직
현재 프로젝트에서는 onloadend callback을 활용하여 처리했다.
const handleAddImage = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files;
if (!file) {
return;
}
const fs = new FileReader();
fs.onloadend = () => {
// 추가 로직
};
// https://developer.mozilla.org/en-US/docs/Web/API/FileReader#instance_methods
fs.[instance_methods](file[0]);
};
나는 직렬화를 해서 session을 저장해야 하니까 아래와 같이 추가로직을 해서 session에 저장을 하고
const handleAddImage = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files;
if (!file) {
return;
}
const fs = new FileReader();
fs.onloadend = () => {
const toSave: ImageType = Object.assign(
{ name: file[0].name },
{ uri: fs.result },
);
setImages(insertFirstAvailable(images, toSave));
};
fs.readAsDataURL(file[0]);
};
복원 작업은 아래와 같이 했다.
const { uri, name } = sessionImageInfo;
const byteString = atob(uri.split(",")[1]);
const mimeType = uri.split(",")[0].match(/:(.*?);/)?.[1];
// 1. 버퍼를 만들고
const ab = new ArrayBuffer(byteString.length);
const ia = new Uint8Array(ab);
// 2. 버퍼에 base64 디코딩한 값을 복사
for (let i = 0; i < byteString.length; i++) {
ia[i] = byteString.charCodeAt(i);
}
// 3. 파일 복구
const restoredFile = new File([ab], name, { type: mimeType });
그러나 QA를 하던 중 아래와 같은 에러를 콘솔에서 발견했다.
Failed to execute 'setItem' on 'Storage': Setting the value of 'xxx' exceeded the quota
나는 저장소 용량을 간과하고 있었다. 해당 에러 문구는 session 저장소의 용량 초과를 뜻한다. session은 5 ~ 10mb 사이를 유지하는데, 이 용량이면 유저가 이론적으로 무한적으로 추가할 수 있는 구조에서 사진을 담기가 힘들거라고 들었다.
그래서 급하지만 한 번 도 사용해본 적이 없는 indexedDB를 도입하기로 했다. 그 전에 indexedDB의 용량을 확인한 결과 iOS 기준 1GB까지는 지원하지만 아래의 이유로 프론트에서도 이미지 용량을 조절할 필요가 있었다.
이 부분은 처음하기에 GPT를 이용했다. useSyncExternalStore는 동기적으로 값을 tracking하기에 비동기 코를 활용할 수 없다고 한다. 그래서 useEffect및 useState를 활용을 하여 만들었다.
활용 도중에 한 쪽에서 업데이트 한 값이 다른 한쪽에서 업데이트가 되지 않는 다는 것을 알았고, 각 호출된 상태가 전역상태 처럼 한쪽의 업데이트가 다른 업데이트를 유발하지 않는 다는 것이었다. 따라서, 변경할 때마다 custom event를 만들고 이를 catch하여 업데이트 하는 방식으로 작성했다.
export const useIndexedDelivery = () => {
const [dbData, setDbData] = useState<IndexedAppraisalDeliveryInfo | null>(
null,
);
const [loading, setLoading] = useState(true);
const DB_NAME = "appraisal-delivery-db";
const STORE_NAME = "deliveryInfo";
const KEY = "session-delivery";
useEffect(() => {
const init = async () => {
const db = await new Promise<IDBDatabase>((resolve, reject) => {
const request = indexedDB.open(DB_NAME, 1);
request.onupgradeneeded = () => {
const db = request.result;
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME);
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
const tx = db.transaction(STORE_NAME, "readonly");
const store = tx.objectStore(STORE_NAME);
const getRequest = store.get(KEY);
getRequest.onsuccess = () => {
if (getRequest.result) {
setDbData(getRequest.result);
setLoading(false);
} else {
const initial = setInitialIndexedDelivery();
const writeTx = db.transaction(STORE_NAME, "readwrite");
const writeStore = writeTx.objectStore(STORE_NAME);
writeStore.put(initial, KEY);
setDbData(initial);
setLoading(false);
}
};
getRequest.onerror = () => {
console.error("IndexedDB read failed:", getRequest.error);
setLoading(false);
};
};
init();
}, []);
/** saveToDB에서 날린 CustomEvent를 catch.
* - saveToDB 내부에서 바로 setDbData(info)로 업데이트하면 전역상태처럼 구독한 컴포넌트가 업데이트 전부 업데이트 되지 않음
* - 그래서 event로 날려서 구독한 컴포넌트에서 useEffect로 받아서 업데이트
*/
useEffect(() => {
const handler = (e: Event) => {
const customEvent = e as CustomEvent<IndexedAppraisalDeliveryInfo>;
setDbData(customEvent.detail); // 새로운 데이터로 업데이트
};
window.addEventListener("indexed-delivery-update", handler);
return () => window.removeEventListener("indexed-delivery-update", handler);
}, []);
const saveToDB = async (info: IndexedAppraisalDeliveryInfo) => {
const db = await new Promise<IDBDatabase>((resolve, reject) => {
const request = indexedDB.open(DB_NAME, 1);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
const tx = db.transaction(STORE_NAME, "readwrite");
const store = tx.objectStore(STORE_NAME);
store.put(info, KEY);
window.dispatchEvent(
new CustomEvent("indexed-delivery-update", { detail: info }),
);
};
return { dbData, loading, saveToDB };
};
이미지 저장은 기존에는 base64를 사용했지만, Blob으로 전환한 이유는 다음과 같다.
useSyncExternalStore에 동일한 값(출처)을 주려고 사용. 그러나 indexedDB에는 굳이 직렬화가 필요 없기에 타입은 자유그러나, Blob이 무조건 좋지 않다. Blob을 <img src={imageSrc}/>에 넣으려면 URL.createObjectURL(blob)을 진행해줘야 하는데, 이 때 생성된 메모리를 명시적으로 URL.revokeObjectURL()하지 않으면 계속 남아있다. 따라서, 생성된 url을 관리를 할 수만 있으면 Blob을 활용하기 좋다.
나는 Image 컴포넌트를 따로 만들고 useEffect내부 return문에서 URL.revokeObjectURL()를 통해서 메모리 최적화를 진행해줬다.
이미지 리사이징은 webp로 진행을 했고 react-image-file-resizer를 활용하여서 최적화 시켰다.
그 결과 다음과 같이 고용량 사진이 98%(7.9MB ➡️ 102KB)이상 줄어든 것을 알 수 있다.

기존 API 구조는 application/json 포맷만을 허용하고, 이미지 데이터를 중첩 객체의 image 필드로 전달하는 방식이 불가능했습니다. 이에 따라, 먼저 이미지 제외 정보를 서버에 전달한 뒤, 각 항목에 대해 서버로부터 받은 id 값을 기반으로 별도로 이미지를 업로드하는 방식을 선택했다.
이렇게 분리한 이유는 서버가 멀티스레드로 동작하기 때문에, 클라이언트가 1번, 2번, 3번, 4번 순서로 요청을 보내더라도 서버에서는 먼저 처리 완료된 순서대로 작업이 진행되기 때문이다. 따라서 고유한 id를 활용해 이미지와 항목 간의 정확한 매핑이 가능하도록 했다.
또한, 다수의 이미지를 동시에 저장 요청했을 때 일부 이미지만 저장되는 문제가 발생했다. 이는 HTTP/2에서 동시 요청 수가 브라우저 기준 최대 6개로 제한되기 때문이었습니다. 이를 해결하기 위해 이미지 업로드 요청을 2개씩 짝지어 순차적으로 보내는 방식으로 조정했다.
const limit = 2;
const chunks = [];
for (let i = 0; i < forming.length; i += limit) {
const chunk = forming.slice(i, i + limit);
const chunkResults = await Promise.all(
chunk.map((item) =>
mutateAsyncRegisterImage({ id: item.simpleId, data: item.formData }),
),
);
chunks.push(...chunkResults);
QA 진행하다가 아래와 같은 항목이 달렸다.
안드로이드에서 뒤로가기 눌렀는데, 팝업이 안 뜹니다.
입사 후 대부분 웹 기반 어드민을 해왔고 첫 유저화면은 외부 협력사였다. 당시에는 협력사로부터 앱 기능 미지원이 기본 정책이었기에 history stack 관련 문제를 해결 못 했다.
위 경험으로 인하여 자사 앱에서 이런 문제를 해결하는 컴포넌트가 있는지 확인을 했고 아래와 같은 이벤트 등록 코드를 발견했다
useEffect(() => {
const isCustomPath = Object.keys(customRoutes).some((pattern) =>
matchPath(pattern, location.pathname),
);
const func1 = () => { ... }
const func2 = () => { ... };
// 전체 상태 감시를 위한 MutationObserver
const observer = new MutationObserver(() => {
if (func1()) {
return;
}
if (func2()) {
return;
}
sendGoBackMessage(isCustomPath ? "CUSTOM" : "DEFAULT");
});
observer.observe(document.body, {
attributes: true,
childList: true,
subtree: true,
});
// 메시지 리스너 정의
const listener = (event: MessageEvent) => {
.../
};
// 메시지 리스너 등록
window.addEventListener("message", listener);
// 컴포넌트 언마운트 시 observer와 메시지 리스너 해제
return () => {
observer.disconnect();
window.removeEventListener("message", listener);
};
}, [location.pathname, navigate, dbData]);
요약하자면:
이 코드를 통하여 특정 페이지에서 안드로이드 뒤로가기 버튼을 클릭했을 때 팝업과 관련 함수를 노출시킬 수 있었다.
추가 기능을 포함하여 등록된 페이지들은 검수 중에 특정 페이지에서 등록된 이벤트가 발생되지 않는 것을 확인했다. 문제를 확인한 결과 기존에 등록된 이벤트 리스너가 여전히 살아있어 안드로이드 뒤로가기 버튼이 다르게 작동하는 것을 확인했다.
MutationObserver는 분명히 원하는 tag하위에 있는 모든 변경점을 감지하는데, 페이지 이동 시 감지 못하는 것이 이상했다. 그래서 MutationObserver 내부에 감지되는 모든 것을 log해봤다.
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
console.log("mutation observed:", mutation);
}
...
}
logging되는 모든 것을 보면서 아래와 같이 생각했다.
document.body가 아닌 document.getElementById('root')를 observer에 등록해도 React가 변경하는 모든 tag들이 찍힌다.useEffect를 활용하여 tag를 추가해보니 observer가 감지했다useEffect의 발동은 React의 commit단계 이후이다. 그러면 Observer 등록은 commit 이전에 등록을 해보면 되지 않을까?useLayoutEffect를 사용해 이벤트 리스너를 선등록했고, 문제의 페이지들에서도 logging이 보이며 뒤로가기가 정상적으로 작동했다이 QA를 통해서 배운 것은 이벤트의 등록 및 제거만 신경쓰는 것이 아닌 “언제 HTML에 추가”되는 지도 중요하다는 점이었다.
배송지 추가 후 뒤로가기 시 페이지 이동이 되지 않습니다.
QA 내용을 봤을 때 history stack이 증가한 것으로 판단이 되었고 재현해보니 history stack이 증가한 것을 콘솔을 통해서 알 수 있었다.
해당 액션은 이벤트 핸들러 코드 쪽에서 발생하는 것 같았다.
const editMutaion = useEditMutaion()
const submitMutation = useSubmitMutation()
const handleSubmit = () => {
if(isEditing){
// 아래 mutation에서만 발생
return editMutaion(payload)
}
return submitMutation(payload)
}
그래서 다음과 같이 진행했다.
useCustomMutation으로 기본 useMutation을 한번 wrapping해서 사용하고 있다. 그러나 useCustomMutation 내부에서는 그 무엇도 history stack을 추가하는 것이 없었다.redux의 dispacth가 문제일까? dispatch되는 모든 action을 tracking하고 redux-dev-tools을 확인하여 상태값 변경을 확인해도 history stack을 증가시키는 것이 없었다useEffect가 있는지 체크useEffect를 체크해봤고 문제가 일어난 페이지 파일과 관련이 없는 것을 봤다. 2번에서 발견한 것이 없어서 앱 어딘 가에서 react-router-dom 동작일까 싶이서 아래와 같이 전역 컴포넌트를 만들어서 실행했다.
// useNavigationTracker.ts
import { useEffect } from "react";
import { useLocation, useNavigationType } from "react-router-dom";
export const useNavigationTracker = () => {
const location = useLocation();
const navType = useNavigationType(); // 'PUSH' | 'REPLACE' | 'POP'
useEffect(() => {
console.log("[NAVIGATION EVENT]", navType, location.pathname);
}, [location, navType]);
};
그러나 mutaion을 발동하면 로깅이 찍히지 않지만 history stack이 늘어났다
console.log3번까지 전부 발견하지 못 했을 때, form과 관련된 컴포넌트이기에 HTML element를 확인했다.
그러나:
1. 컴포넌트를 확인한 결과 실제 form관련 semantic tag는 아니고
2. <div>로 이루어진 제어 컴포넌트였다.
이 때도 GPT랑 같이 디버깅을 하면서 어떤 HTML tag들의 기본 action이 history stack이 추가하는 지 같이 봤다.
| 동작 | history stack 추가 여부 | 설명 |
|---|---|---|
<a href="/..."> 클릭 | ✅ yes | 링크 클릭은 기본적으로 PUSH |
| `<form action="/..." method="GET | POST">` + submit | ✅ yes |
window.location.href = '/...'; | ✅ yes | 명시적 리디렉션 |
history.pushState() | ✅ yes | 수동으로 stack 추가 |
replaceState() | ❌ no | 현재 히스토리 항목 덮어씀 |
window.location.replace() | ❌ no | 현재 히스토리를 교체 |
navigate(..., { replace: true }) | ❌ no | React Router에서 replace 옵션 사용 |
위 정보를 토대로 chrome developer tools의 elements 탭에서 tag를 조사했지만 찾을 수 없었다.
<iframe />내부의 tag는 검색이 안 된단다고 한다 결국 제어 컴포넌트 내부에서 전부 console.log(history.length)를 추가하여 logging을 했고 주소록 검색에 사용되는 라이브러리 이벤트 발생 시 history가 쌓이는 것을 알 수 있었다.
먼저, github issue에 동일한 증상이 있는지 확인을 했다. 그러나 관련 issue는 없었다.
크롬의 developer tools 내부의 elements tab에서 라이브러리가 그리는 HTML 전부 타고 들어가봤고 아래와 같은 tag를 발견했다
<form action="http://postcode.map.daum.net/search" id="searchForm" class="form_search" target="_self" method="GET">
......
</form>
form이 존재하는 것을 확인했고 한번 더 GPT에게 form의 기본 action 중 어떤 상황이 history를 증가시키는지 체크했다
| 조건 | history stack 추가 여부 | 설명 |
|---|---|---|
action이 현재 페이지와 다른 경로 | ✅ Yes | PUSH 발생 |
action이 현재 경로와 동일 | ❌ No | 페이지가 리로드되지만, 히스토리 변화 없음 |
target="_blank" | ❌ No | 새 창에서 열리므로 현재 히스토리에 영향 없음 |
JS에서 event.preventDefault() 호출 | ❌ No | 기본 동작이 막힘, 히스토리 변화 없음 |
method="GET" 또는 "POST" | ✅ Yes (둘 다) | 기본적으로 둘 다 히스토리 push를 동반함 |
form이 submit되면 location.href 변경 | ✅ Yes | URL이 바뀌면 PUSH됨 |
문제가 발생하는 위치를 알았으니 해결 방법은 두 가지라고 봤다.
onSearch함수를 제공했다. <CustomGoBack />애도 추가했다이 QA를 통해서 디버깅을 할 때 라이브러리에 대한 문제점을 한번 체크해주는 것이 좋다라는 것을 배울 수 있었다.
작년 이맘때 쯤, 외부 협력사 어플에 회사관련 쇼핑몰을 호스팅하기 위해서 프로젝트를 진행한 적이 있다.
이 때까지만 해도, GPT의 능력이 좋지 않았던 것 같고, 결제를 하지 않고 무료로 쓰고 있기에 더 좋다고 느껴지지 않았을 수도 있다.
이번에는 유료버전의 GPT랑 같이 설계도 하고, 코드도 만들고, 리팩터링까지 같이 하면서 이제는 GPT의 능력이 실감이 된다. HTTP의 정책이라든지, 특정 기능을 위한 CSS작성이라든지, 리팩터링에서 많은 부분 물어봤고 좋은 답변과 바로 적용가능한 코드까지도 반환을 헀다.
프로젝트 중반부터 Cursor를 사용했지만, Cursor에게 조건부로 렌더링 로직을 맞겨서 에러가 난 횟수보다 GPT가 더 깔끔하게 처리가 되는 것도 느꼈다.
지금까지 사내서비스만 만들다가 B2C 제품을 만들면서 AI를 써본 결과, AI의 발전으로 쉽게 일처리가 되는 것을 이번 기회에 상당히 체감을 했다. 그러나, 무조건 사용하기에는 아직 부족했다. 내가 알고 있는 지식을 통해서 답변을 한번 더 확인하고 재검증을 해야 좋은 코드가 나왔다. 주변 사람 또는 글에서 AI를 왜 비서처럼 쓰라는 지 단번에 이해할 수 있었다.