projects#project#회고

[RumiClean] 경험 회고

2026년 4월 22일5분 읽기조회 73
공유

Next Career 프로젝트 기술 회고

이 글은 Next.js 기반 청소 서비스 플랫폼을 구현하면서 마주한 10가지 기술적 트러블슈팅 경험을 코드와 함께 정리한 회고입니다.


1. Axios 인터셉터를 활용한 snake_case ← → camelCase 자동 변환

문제

백엔드 API 응답이 snake_case로 내려오고, 프론트엔드는 camelCase를 사용하는 구조에서 API 함수마다 직접 변환 코드를 작성하면 코드가 중복되고 변환 누락 가능성이 생겼다.

해결

axios 인터셉터에서 camelcase-keys / snakecase-keys 라이브러리를 사용해 변환을 한 곳에서 처리했다.

// src/app/_lib/instance.ts

// 요청: camelCase → snake_case
apiInstance.interceptors.request.use(config => {
  if (config.data) {
    config.data = snakecaseKeys(config.data, { deep: true });
  }
  return config;
});

// 응답: snake_case → camelCase
apiInstance.interceptors.response.use(response => {
  if (response.data) {
    response.data = camelcaseKeys(response.data, { deep: true });
  }
  return response;
});

결과

  • API 함수 및 컴포넌트 코드 어디서도 수동 변환 로직이 사라짐
  • 중첩 객체(deep: true)까지 자동 처리
  • 백엔드 응답 스펙이 바뀌어도 인터셉터 한 곳만 수정하면 됨

2. Discriminated Union 기반 API 응답 타입 설계

문제

API 요청이 성공했을 때와 실패했을 때 응답 구조가 다른데, any 또는 옵셔널(data?: T)로 처리하면 성공/실패를 타입 수준에서 구분할 수 없어 런타임 에러가 발생할 위험이 있었다.

해결

success 필드를 literal type으로 선언해 TypeScript의 Discriminated Union을 활용했다.

// src/app/_lib/api-response.types.ts

interface ApiSuccessResponse<T> {
  success: true;   // literal type
  data: T;
}

interface ApiErrorResponse {
  success: false;  // literal type
  message: string;
}

type ApiResponse<T> = ApiSuccessResponse<T> | ApiErrorResponse;
// 사용 시 타입 내로잉
const response = await someApi();
if (!response.success) {
  throw new Error(response.message); // 이 블록에서 response는 ApiErrorResponse
}
// 이 아래는 response.data가 타입 안전하게 존재
doSomething(response.data);

결과

  • 컴파일 타임에 성공/실패 분기를 강제할 수 있어 런타임 에러 사전 차단
  • 각 API 함수의 반환 타입이 명확해져 자동완성 정확도 향상

3. 인증 상태 동기화 - 같은 탭과 다른 탭을 동시에 처리

문제

로그아웃 시 인증 상태 변경을 헤더 컴포넌트에 알려야 했다. Web Storage API의 storage 이벤트는 같은 탭에서 발생한 변경에는 발화하지 않는 브라우저 제약이 있었다.

해결

같은 탭 내 변경은 커스텀 이벤트(authChange)로, 다른 탭의 변경은 storage 이벤트로 각각 처리했다.

// axios 인터셉터: 401 응답 시
window.dispatchEvent(new Event('authChange')); // 같은 탭에 브로드캐스트
localStorage.removeItem('isLogin');

// Header.tsx
useEffect(() => {
  window.addEventListener('authChange', checkLoginStatus); // 같은 탭
  window.addEventListener('storage', handleStorageChange); // 다른 탭

  return () => {
    window.removeEventListener('authChange', checkLoginStatus);
    window.removeEventListener('storage', handleStorageChange);
  };
}, []);

결과

  • 로그아웃, 세션 만료(401) 시 헤더 UI가 즉시 반영됨
  • 다른 탭에서 로그인/로그아웃해도 현재 탭 UI가 동기화됨
  • Zustand 등 전역 상태관리 라이브러리 없이 이벤트 버스 패턴으로 해결

4. 시/도 API 중복 요청 방지 - 모듈 레벨 Request Deduplication

문제

청소자 서비스 지역 설정 페이지에서 시/도 목록을 불러오는 SGIS API가 페이지 렌더링마다 중복 호출되었다. 특히 여러 드롭다운이 동시에 마운트될 경우 동일 요청이 병렬로 여러 번 발생했다.

해결

React Query가 없는 레이어에서 모듈 레벨 변수로 캐시와 진행 중인 Promise를 공유해 수동 Request Deduplication을 구현했다.

// src/hooks/useSgisStage.ts

// 컴포넌트 외부 (모듈 레벨) - 싱글톤처럼 동작
let provinceOptionsCache: DropdownOption[] | null = null;
let provinceOptionsPromise: Promise<DropdownOption[]> | null = null;

const fetchProvinces = async () => {
  // 이미 캐시된 경우
  if (provinceOptionsCache) return provinceOptionsCache;

  // 이미 요청 중인 경우 - 같은 Promise 공유 (중복 요청 차단)
  if (provinceOptionsPromise) return provinceOptionsPromise;

  provinceOptionsPromise = (async () => {
    const data = await fetch('/api/sgis/stage');
    provinceOptionsCache = transformData(data);
    provinceOptionsPromise = null; // 완료 후 초기화
    return provinceOptionsCache;
  })();

  return provinceOptionsPromise;
};

결과

  • 앱 전체 생명주기에서 시/도 API는 최대 1번만 호출
  • 동시 마운트 시에도 단 하나의 네트워크 요청으로 처리
  • 시/군/구, 읍/면/동처럼 선택에 따라 달라지는 데이터는 매번 요청하도록 구분

5. 무한 스크롤 이중 잠금 - 연속 스크롤 중복 요청 방지

문제

React Query의 isFetchingNextPage 상태만으로 중복 요청을 막으면, 빠른 연속 스크롤 시 렌더링 사이클 사이의 간격에서 같은 요청이 두 번 발생하는 문제가 있었다. useState는 비동기로 업데이트되기 때문에 스크롤 이벤트가 상태 업데이트 전에 다시 발화하면 막을 수 없었다.

해결

리렌더를 유발하지 않는 useRef를 추가해 동기 잠금을 구현했다.

// src/hooks/useCursorInfiniteScroll.ts

const isLoadingMoreRef = useRef(false); // 동기 락

const handleScroll = useCallback(() => {
  // React Query 상태(비동기) + ref(동기) 이중 체크
  if (isLoadingMoreRef.current || isFetchingNextPage || !hasNextPage) return;

  if (distanceFromBottom <= threshold) {
    isLoadingMoreRef.current = true; // 즉시 잠금 (동기)
    fetchNextPage().finally(() => {
      isLoadingMoreRef.current = false; // 완료 후 해제
    });
  }
}, [isFetchingNextPage, hasNextPage, fetchNextPage]);

결과

  • 빠른 연속 스크롤에서 중복 API 호출 완전 차단
  • ref는 리렌더 없이 즉시 값을 변경하므로 다음 스크롤 이벤트에서 바로 반영

6. 드래그 가능한 BottomSheet 구현 - DOMMatrix와 touch 처리

문제

모바일에서 지도 위에 카드 목록을 보여주는 BottomSheet를 직접 구현해야 했다. 드래그 도중 현재 translateY 위치를 알아야 드래그 시작 지점을 정확히 잡을 수 있는데, CSS transform 값은 문자열(matrix(...))로만 읽을 수 있었다.

또한 터치 이벤트가 브라우저 기본 스크롤과 충돌해 e.preventDefault()가 필요했지만, 기본적으로 touch 이벤트 리스너는 passive: true라 preventDefault()를 호출할 수 없었다.

해결

DOMMatrix로 transform 매트릭스에서 translateY 값을 직접 파싱하고, 이벤트 리스너를 passive: false로 등록했다.

// src/app/_components/atoms/BottomSheet.tsx

const handleDragStart = (clientY: number) => {
  const transform = window.getComputedStyle(sheetRef.current!).transform;
  if (transform !== 'none') {
    const matrix = new DOMMatrix(transform);
    currentTranslate.current = matrix.m42; // translateY 값 추출
  }
  startY.current = clientY;
};

// 드래그 중 transition 비활성화 → 스냅 시 복원
sheetRef.current.style.transition = isDragging ? 'none' : 'transform 0.3s ease';

// passive: false로 등록해야 preventDefault() 호출 가능
sheetRef.current.addEventListener('touchstart', onTouchStart, { passive: false });

첫 렌더링 시 transition을 none으로 처리해 애니메이션 없이 초기 위치에 즉시 배치했다.

결과

  • 드래그 시작 위치가 현재 시트 위치를 기준으로 정확히 계산됨
  • 드래그 중 transition 없이 즉각 반응, 스냅 시 부드러운 애니메이션
  • 브라우저 스크롤과 충돌 없이 바텀시트 내부 스크롤 처리

7. iOS Safari 모바일 스크롤 잠금 충돌 해결

문제

지도 페이지(/search)에서 BottomSheet가 열려 있을 때 배경 지도가 스크롤되는 현상이 발생했다. overflow: hidden만으로는 iOS Safari에서 pull-to-refresh를 막을 수 없었다.

또한 BottomSheet가 lg:hidden으로 시각적으로 숨겨진 데스크탑 환경에서도 useEffect가 실행되어 데스크탑 스크롤이 잠기는 버그가 있었다. usePreventMobileScroll과 BottomSheet의 cleanup 함수 실행 순서가 불확정적이어서, 한쪽이 스타일을 복원한 뒤 다른 쪽이 다시 덮어쓰는 문제도 있었다.

해결

// src/hooks/usePreventMobileScroll.ts

useEffect(() => {
  const checkMobile = () => {
    if (window.innerWidth >= breakpoint) return; // 데스크탑에서는 실행 안 함

    document.body.style.overflow = 'hidden';
    document.body.style.position = 'fixed';          // pull-to-refresh 차단
    document.body.style.height = '100dvh';           // dynamic viewport height (iOS 주소창 대응)
    document.body.style.overscrollBehavior = 'none';
  };

  checkMobile();
  window.addEventListener('resize', checkMobile);

  return () => {
    // 원본 스타일 복원 대신 항상 빈 값으로 리셋
    // → 여러 cleanup 실행 순서에 무관하게 항상 동일한 결과
    document.body.style.overflow = '';
    document.body.style.position = '';
    document.body.style.height = '';
    document.body.style.overscrollBehavior = '';
    window.removeEventListener('resize', checkMobile);
  };
}, [breakpoint]);

결과

  • iOS Safari에서 배경 스크롤 및 pull-to-refresh 완전 차단
  • 100dvh로 주소창 높이 변화에 동적으로 대응
  • cleanup 간 실행 순서 의존성 제거로 데스크탑 스크롤 잠금 버그 해소

8. S3 파일 업로드 CORS 우회 - Next.js API Route 프록시

문제

S3 presigned URL로 브라우저에서 직접 PUT 요청을 보낼 때 CORS 오류가 발생했다. S3 버킷 CORS 정책을 수정하거나 API 인증 키를 클라이언트에 노출하지 않고 해결해야 했다.

해결

Next.js API Route를 서버 프록시로 사용해 클라이언트 → Next.js 서버 → S3 흐름으로 우회했다.

클라이언트 → POST /api/s3/upload (Next.js) → PUT {presigned URL} (S3)
// src/app/api/s3/upload/route.ts

export async function POST(request: Request) {
  const formData = await request.formData();
  const file = formData.get('file') as File;
  const uploadUrl = formData.get('uploadUrl') as string;

  const buffer = Buffer.from(await file.arrayBuffer());

  // 서버에서 S3로 직접 PUT 요청 (CORS 무관)
  const response = await fetch(uploadUrl, {
    method: 'PUT',
    headers: {
      'Content-Type': file.type,
      'Content-Length': buffer.byteLength.toString(),
    },
    body: buffer,
  });

  return NextResponse.json({ success: response.ok });
}

결과

  • 브라우저 CORS 정책 우회 (서버 to 서버는 CORS 비적용)
  • S3 인증 정보를 클라이언트에 노출하지 않음
  • 프록시 경로 충돌(/api/s3/...) 발생 후 라우트 구조 재정리

9. 카카오맵 SDK 동적 로드 및 중복 초기화 방지

문제

카카오맵 SDK를 Next.js App Router 환경에서 사용할 때 두 가지 문제가 있었다.

  1. <Script> 태그를 여러 컴포넌트가 삽입하면 SDK가 중복 로드됨
  2. window.kakao.maps API는 SDK 로드 완료 직후가 아닌 kakao.maps.load() 콜백 내에서만 안전하게 사용 가능

해결

// src/app/_components/atoms/KakaoMap.tsx

useEffect(() => {
  // 이미 로드된 경우 바로 사용
  if (window.kakao?.maps) { setIsScriptLoaded(true); return; }

  // 로드 중인 스크립트가 있으면 폴링으로 대기 (중복 삽입 방지)
  const existing = document.querySelector('script[src*="dapi.kakao.com"]');
  if (existing) {
    const interval = setInterval(() => {
      if (window.kakao?.maps) { setIsScriptLoaded(true); clearInterval(interval); }
    }, 100);
    return () => clearInterval(interval);
  }

  // 없는 경우에만 동적 삽입 (autoload=false로 수동 초기화 제어)
  const script = document.createElement('script');
  script.src = `//dapi.kakao.com/v2/maps/sdk.js?appkey=${KEY}&autoload=false`;
  script.onload = () => setIsScriptLoaded(true);
  document.head.appendChild(script);
}, []);

// SDK 로드 완료 후 load() 콜백에서만 초기화
useEffect(() => {
  if (!isScriptLoaded || isMapInitialized.current) return;
  window.kakao.maps.load(() => {
    isMapInitialized.current = true;
    initMap();
  });
}, [isScriptLoaded]);

결과

  • 동일 페이지 내 여러 컴포넌트에서 사용해도 스크립트 중복 로드 없음
  • isMapInitialized.current ref로 지도 객체 중복 생성 방지
  • autoload=false + load() 콜백으로 SDK 준비 타이밍 정확히 제어

10. 지도 재검색 UX - 좌표 상태 3중 분리

문제

지도 드래그 시 API를 매번 호출하면 불필요한 요청이 너무 많이 발생한다. 반면 드래그 후 자동으로 검색하지 않으면 사용자가 원하는 위치의 결과를 볼 수 없다.

해결

좌표 상태를 역할별로 3개로 분리하고, 사용자가 명시적으로 재검색을 트리거할 때만 API를 호출하도록 설계했다.

const [center, setCenter] = useState(null);       // 지도 초기화/이동용
const [mapCenter, setMapCenter] = useState(null); // 드래그 중 실시간 지도 중심
const [searchCenter, setSearchCenter] = useState(null); // API 호출 기준 좌표
const [position, setPosition] = useState(null);   // GPS 현재 위치 (마커용)

const handleMapCenterChanged = (newCenter) => {
  setMapCenter(newCenter);
  // searchCenter와 0.0001도 이상 차이 날 때만 재검색 버튼 표시
  if (Math.abs(newCenter.latitude - searchCenter.latitude) > 0.0001) {
    setShowResearchButton(true);
  }
};

const handleResearch = () => {
  setSearchCenter({ ...mapCenter }); // 새 객체로 React 업데이트 트리거
  setShowResearchButton(false);
  refetch();
};

결과

  • 드래그 중 불필요한 API 호출 없음
  • 사용자가 "이 지역에서 검색" 버튼을 눌렀을 때만 API 호출
  • GPS 마커, 지도 중심, 검색 기준이 서로 독립적으로 동작해 UX 충돌 없음

마치며

이번 프로젝트에서 가장 많이 한 말은 "왜 안 되지?"였다.

passive: true가 기본값이라 preventDefault()가 안 된다는 것, storage 이벤트가 같은 탭에선 발화하지 않는다는 것, useState가 비동기라 스크롤 이벤트를 못 막는다는 것 - 전부 문서 어딘가에 적혀 있는 내용이지만, 직접 막혀보기 전까지는 읽어도 실감이 없다.

"왜 안 되지?"에서 시작해 "이래서 안 됐구나"에 도달하는 과정이 쌓이면 결국 처음부터 안 되는 이유를 예측하는 사람이 된다. 이번 10가지가 그 과정의 기록이다.

10가지를 돌아보면 결국 세 가지 키워드로 압축된다. 중복 제거, 경계 설계, 브라우저 제약 이해.

인터셉터로 케이스 변환을 한 곳에 모은 것도, useRef로 동기 잠금을 추가한 것도, 커스텀 이벤트와 storage 이벤트를 분리한 것도 - 모두 "어디서 처리할 것인가"와 "어디까지 믿을 것인가"의 경계를 명확히 한 결과였다.

모듈 레벨 캐시, passive: false 리스너, DOMMatrix 파싱처럼 브라우저 동작을 정확히 알아야만 풀리는 문제들도 있었다. 라이브러리가 추상화해주지 않는 영역에서 직접 손을 댈 수 있는 범위가 이번 프로젝트를 거치며 확실히 넓어졌다.

다음에 또 쓸 수 있는 것들을 꼽자면 - 인터셉터 기반 변환 중앙화, 모듈 레벨 Request Deduplication, useRef 동기 잠금 패턴, Next.js API Route 프록시. 이 네 가지는 프로젝트가 달라져도 거의 그대로 가져다 쓸 수 있다.

반면 이번에 놓친 것도 있다. 100dvh 대응은 뒤늦게 붙였고, 카카오맵 중복 초기화 방지도 문제가 터진 뒤에야 설계했다. 처음부터 "이 SDK는 중복 로드될 수 있다"고 가정하고 들어갔다면 더 빨랐을 거다.

다음 프로젝트에선 외부 SDK 연동과 모바일 엣지 케이스를 초기 설계 단계에서 먼저 검토하는 것을 습관으로 만들고 싶다.

관련 포스트

댓글

방명록을 남겨주세요