약 2년간 배송 설정과 의뢰 관리 등 운영 백오피스와 B2B 웹 서비스의 프론트엔드를 개발했습니다. JSP/jQuery 레거시의 구조와 의존 관계를 파악해 서비스 중단 없이 점진적으로 개선했고, Next.js·TypeScript 신규 화면에서는 운영자가 데이터를 확인하고 변경 내용을 검토한 뒤 저장하는 업무 흐름을 설계했습니다.
기능 구현에서 멈추지 않고 측정 → 원인 파악 → 개선 → 기록의 순서로 일합니다. 이 문서의 개인 프로젝트 코드는 전부 실제 저장소의 코드이며, 각 코드 블록에 파일 경로를 표기했습니다.
사내 코드는 공개할 수 없어, 이 섹션의 코드 블록은 실제 구현 구조를 바탕으로 재구성한 예시입니다. 코드 패널 우측 배지로 구분합니다.
case / query-key-redesign
Query Key 재설계로 문의게시판 API 호출 약 50% 감소
문제
대부분의 QueryKey가 ['queries']라는 공통 prefix로만 작성되어 있어, 댓글 하나를 수정해도 invalidateQueries(['queries'])로 넓은 범위가 무효화됐습니다. 실제 변경과 관계없는 데이터까지 refetch되면서 불필요한 API 재호출이 발생했고, 영향을 받지 않아도 되는 컴포넌트까지 함께 갱신됐습니다.
해결
mutation 전후의 API 요청 흐름을 추적해 기존 키 구조를 파악한 뒤, QueryKey를 도메인 / 조회 맥락 / 식별자 / 연관 데이터 기준으로 계층화했습니다. mutation 응답만으로는 서버에서 결정되는 최종 상태를 정확히 구성하기 어렵다고 판단해 setQueryData 대신 invalidateQueries를 유지하되, 무효화 범위를 실제 변경된 데이터로 좁혔습니다.
query-key 계층화 — before / after재구성 예시
// Before — 공통 prefix 하나로 전체 무효화
['queries']
invalidateQueries(['queries']) // 댓글 하나 수정에도 전 화면 refetch// After — 도메인 / 맥락 / 식별자 / 연관 데이터로 계층화
['queries', 'board', 'detail', id, 'comment']
// mutation 이후, onSuccess에서 실제 변경된 범위만 무효화
onSuccess: () => {
queryClient.invalidateQueries(
['queries', 'board', 'detail', id, 'comment']
)
}
결과
서버·클라이언트 상태 정합성을 유지하면서, Datadog 기준 문의게시판 API 호출 약 50% 감소
QueryKey 규칙을 도메인별로 표준화해, 이후 다른 화면에서도 동일한 기준으로 캐시 무효화 범위를 설계
case / settings-page
SSR + Query Hydration 기반 설정 관리 페이지
배경
전역 사용 여부, 센터별 설정, 예외일, 변경 이력을 한 화면에서 다루는 설정 통합 페이지였습니다. 운영에 즉각 영향을 주는 민감한 설정이라 "잘못된 저장 방지"와 "유지보수 가능한 구조"가 핵심 과제였습니다.
해결 1 — 초기 데이터는 SSR에서
설정값을 클라이언트에서만 조회하면 로딩 완료 후 입력값이 바뀌어 보이는 혼선이 생깁니다. getServerSideProps에서 주요 설정 API를 병렬로 prefetch하고 TanStack Query dehydration/hydration으로 초기 렌더링부터 기존 설정값이 반영되게 했습니다.
pages/settings — getServerSideProps재구성 예시
exportconst getServerSideProps = async () => {
const queryClient = new QueryClient()
// 여러 설정 API를 병렬로 prefetch하는 내부 유틸await prefetchAll(queryClient, {
'/api/happycall/self/config/global': ['selfConfigGlobal'],
'/api/happycall/self/center/status': ['getCenterStatus'],
})
return { props: { dehydratedState: dehydrate(queryClient) } }
}
해결 2 — 도메인별 훅 + ViewModel 분리
설정 도메인마다 담당 API와 UI 흐름이 달라, 도메인별 커스텀 훅(useSelfHappyCallConfig, useNoHopeDayConfig, useSelfConfig)으로 분리했습니다. API 응답을 테이블/리스트 형태로 바꾸는 로직은 ViewModel 생성 함수로 컴포넌트 밖에 두어, UI 컴포넌트는 렌더에만 집중합니다. 신규 설정 항목이 추가돼도 데이터 처리와 UI 변경의 영향 범위가 제한됩니다.
해결 3 — 로컬 편집 상태와 확인 팝업
입력할 때마다 서버 상태를 바꾸면 여러 설정을 함께 검토하기 어렵고 불완전한 값이 저장될 수 있습니다. 서버에서 조회한 설정값과 로컬 편집 상태를 분리하고, 운영자가 변경 사항을 검토한 뒤 명시적으로 일괄 저장하도록 설계했습니다.
편집 시작→로컬 상태 변경→저장 버튼→확인 팝업→mutate→invalidateQueries
취소→서버 원본으로 로컬 상태 복원
결과
초기 진입 시 로딩 지연·화면 깜빡임 없이 설정값 즉시 확인
설정 섹션이 늘어도 일관된 패턴(훅 + ViewModel)으로 확장 가능
확인 팝업 흐름으로 잘못된 설정 저장으로 인한 운영 리스크 감소
case / inquiry-board
URL 쿼리 동기화와 역할별 UI 분기를 적용한 문의 게시판
문제
여러 필터(키워드·회사·문의유형·처리상태·기간)를 개별 상태로 관리하면 조합·초기화 로직이 분산되고, 새로고침·URL 공유 시 검색 상태가 사라짐
사업자·내부 담당자 등 역할별 버튼·필터 노출 조건을 각 컴포넌트에 흩어 두면, 역할 추가 시 수정 범위와 누락 가능성이 커짐
해결
여러 검색 조건을 payload 모델 하나로 통합하고 URL 쿼리와 양방향으로 동기화했습니다. shallow: true로 필터 변경 시 SSR 재호출과 리마운트 없이 URL만 갱신되고, 초기 진입 시엔 router.query를 payload에 병합해 상태를 복원합니다.
useBoard — 필터 상태 ↔ URL 동기화재구성 예시
// 초기 진입: URL 쿼리를 payload에 병합해 검색 상태 복원const [payload, setPayload] = useState<IInquiryPayload>({
startDate: oneMonthAgo,
endDate: today,
...router.query,
assignedToSrl: Number(router.query.assignedToSrl) || undefined,
})
// 필터 변경: SSR 재호출 없이 URL만 갱신 → 같은 URL이면 같은 결과
useEffect(() => {
router.replace({ query: { ...payload } }, undefined, { shallow: true })
}, [payload])
// 역할별 노출 정책은 훅 안에서 일괄 결정 — UI는 결과값만 참조const isRegist = ['SELLERS', 'COMPANY_A'].includes(companyType)
const isHowser = companyType === 'HOWSER'
역할 분기 조건이 useBoard 훅에 집중되어, 역할이 추가되거나 노출 조건이 바뀔 때 수정 범위가 훅 하나로 한정됩니다.
결과
새로고침·링크 공유 시에도 동일한 문의 목록 재현 — 운영 담당자 간 공유 흐름 개선
UI 컴포넌트는 노출 결과만 참조하도록 단순화, 역할 추가 시 수정 범위 최소화
case / polymorphic-button
Polymorphic Props로 유사 Button 컴포넌트 통합
문제
공통 Button은 as props로 button, a, label 등 다른 요소로 렌더링할 수 있었지만, 요소를 바꿔도 href·target 같은 네이티브 props가 타입 수준에서 추론되지 않았습니다. 결국 요소별 Button을 따로 만들며 스타일과 동작이 중복됐습니다.
해결
as로 전달된 요소 타입을 제네릭으로 추론하고, 공통 props와 해당 요소의 네이티브 props를 결합하되 이름이 충돌하는 props는 Omit으로 제거하는 구조를 설계했습니다.
Button.types.ts — Polymorphic Props재구성 예시
// ComponentPropsWithoutRef<C>로 전달된 요소의 네이티브 props를 확장하고,// 이름이 겹치면 커스텀 props(TButtonProps)가 우선하도록 Omit으로 충돌 제거type PolymorphicProps<C extends React.ElementType> = {
as?: C
} & TButtonProps
& Omit<React.ComponentPropsWithoutRef<C>, keyof TButtonProps>
type ButtonProps<C extends React.ElementType> = PolymorphicProps<C>
// 사용처 — as에 따라 네이티브 props가 정확히 추론된다
<Button as="a" href="/docs" target="_blank">가이드</Button>
<Button as="label" htmlFor="file-input">파일 선택</Button>
결과
렌더링 요소를 바꿔도 네이티브 props를 타입 안전하게 사용, 잘못된 props는 컴파일 단계에서 차단
유사 Button 중복 구현 제거, 팀 내 컴포넌트 인터페이스 일관화
case / etc
기타 경험
잦은 변경이 있던 게시판에 Cypress E2E 테스트를 도입해 주요 CRUD 흐름의 회귀 확인을 자동화하고 QA 부담을 줄였습니다.
공통 컴포넌트 CheckboxGroup에서 문자열 includes 사용으로 발생하던 부분 일치 선택 버그를 발견해 배열 기반 비교로 수정했습니다.
이 섹션의 코드는 실제 저장소 코드 그대로입니다. 각 블록의 파일 경로로 GitHub에서 전체 맥락을 확인할 수 있습니다. Next.js(App Router) · TypeScript · TanStack Query · Supabase · Playwright E2E 20개 spec · Lighthouse 성능 측정 기록 관리.
case / performance-ledger
측정 환경부터 바로잡고 병목 수정 — TBT 611ms → 171ms
문제
취업 공고 데이터 증가 이후 공고 페이지의 Lighthouse Performance가 81점, TBT가 611ms까지 떨어졌습니다. 커밋 이력을 이분 탐색했지만 원인 커밋을 찾지 못했습니다. 알고 보니 테스트 데이터가 약 120건에서 9,000건으로 늘었는데, 측정 기록에 데이터 규모가 없어 코드 회귀처럼 보였던 것입니다.
해결 1 — 측정 환경부터 신뢰할 수 있게
측정마다 데이터 행 수를 함께 기록하고, 직전 측정보다 규모가 5% 및 10건 이상 달라지면 측정 기록에 비교 조건 불일치 경고를 자동으로 붙이도록 했습니다. 프로덕션 빌드에서 5회 측정한 중앙값을 사용해 실행 편차도 줄였습니다.
측정마다 데이터 볼륨을 스냅샷으로 남기고, 볼륨이 다르면 "코드 회귀가 아니다"라는 경고를 기록에 자동으로 적습니다.해결 2 — 병목은 DOM 노드 수
신뢰할 수 있게 된 측정으로 분석한 결과, 주요 병목은 DOM 노드 증가에 따른 Style & Layout 비용이었습니다. 한 번에 렌더링하는 공고를 20개로 제한하되, "방금 저장한 공고가 화면에 안 보이는" 부작용까지 함께 처리했습니다.
src/app/(dashboard)/jobs/_hooks/useJobsPage.ts실제 코드
// 한 번에 그리는 카드 수. 공고 카드는 아이콘 svg까지 포함해 1장당 약 30 DOM 노드라,// 전체를 한 번에 그리면 노드 수가 그대로 Style & Layout 비용이 된다// (공고 200개 = 5,995 노드 → styleLayout 1,257ms → TBT 681ms, 2026-07-29 측정).const PAGE_SIZE = 20// 목록은 마감일 오름차순이라 방금 추가한 공고가 잘린 뒤쪽에 놓일 수 있다. 그러면// "저장했는데 화면에 안 나타난다"가 된다 — 노출 범위를 그 항목까지 늘려서 막는다.const visiblePostings = useMemo(() => {
const createdIndex = createdId ? postings.findIndex((p) => p.id === createdId) : -1const count = createdIndex >= visibleCount ? createdIndex + 1 : visibleCount
return postings.slice(0, count)
}, [postings, visibleCount, createdId])
Performance95 / 81▲ +14점 개선
TBT171ms / 611▼ 72% 단축
DOM 노드778 / 5,995▼ 87% 감소
Style & Layout418ms / 1,257▼ 67% 단축
case / optimistic-update-race
낙관적 업데이트의 경쟁 상태 수정
문제
완료 체크박스를 연타하면 상태가 잠시 바뀌었다가 이전 값으로 돌아갔습니다. UI 렌더링 로직에는 문제가 없었고, mutation 응답 순서를 추적한 결과 먼저 보낸 '완료' 요청의 응답이 뒤이어 실행된 '완료 취소'의 낙관적 업데이트를 덮어쓰는 요청 경합이 원인이었습니다.
해결
mutationKey와 태스크 식별자로 진행 중인 mutation을 추적해, 마지막 사용자 조작과 일치하는 요청에만 응답 반영·롤백·쿼리 무효화를 수행하도록 했습니다. 콜백 시점에는 자기 mutation도 pending으로 집계되므로, 카운트가 1이면 자신이 최신이라는 판정을 쓸 수 있습니다.
src/app/(dashboard)/_hooks/tasks/useTaskMutations.ts실제 코드
const TOGGLE_TASK_MUTATION_KEY = ['toggle-task'] asconst// 같은 태스크에 대해 진행 중인 토글이 자기 자신뿐인지 (= 내가 최신 토글인지).// 콜백 시점에는 자기 뮤테이션도 pending으로 집계되므로 1이면 마지막이다.const isLatestToggleFor = (id: string) =>
queryClient.isMutating({
mutationKey: TOGGLE_TASK_MUTATION_KEY,
predicate: (m) =>
(m.state.variables as { id?: string } | undefined)?.id === id,
}) === 1// ...
onSuccess: (updated) => {
// 같은 태스크의 더 새로운 토글이 진행 중이면 이 응답은 이미 낡은// 값이므로 버린다. (완료→취소를 연타하면 먼저 온 "완료" 응답이 취소의// 낙관적 상태를 덮어써 체크박스가 완료로 깜빡였다가 돌아오는 문제)if (!isLatestToggleFor(updated.id)) return
queryClient.setQueryData<Task[]>(
taskKeys.byScope(scope, targetDate),
(old) =>
(old ?? []).map((task) => (task.id === updated.id ? updated : task))
)
},
onError: (_err, vars, context) => {
// 더 새로운 토글이 진행 중이면 그쪽의 낙관적 상태가 최신 의도이므로// 이 스냅샷으로 되돌리지 않는다 (되돌리면 최신 의도를 덮어쓴다).if (context?.previous && isLatestToggleFor(vars.id)) {
queryClient.setQueryData(taskKeys.byScope(scope, targetDate), context.previous)
}
},
회사에서의 Query Key 재설계 경험은 이 프로젝트에서 처음부터 key factory 패턴(taskKeys)으로 이어졌고, 무효화 범위 판단의 기준이 됐습니다.이어진 문제 — 요청 dedupe가 낡은 응답을 캐시에 앉힌다
템플릿 추가 후 오늘 목록에 최대 5분간 반영되지 않는 문제도 있었습니다. 일간 목록은 GET 자체가 서버에서 템플릿을 시딩하는 구조인데, 템플릿 추가 시점에 이미 조회가 진행 중이면 invalidateQueries의 재요청이 그 진행 중인 요청으로 dedupe되어 새 템플릿이 존재하기 전에 계산된 응답이 신선한 데이터로 캐시에 안착했습니다.
src/app/(dashboard)/_hooks/templates/useTaskTemplates.ts실제 코드
// cancelQueries가 먼저인 이유 — invalidateQueries만 호출하면 재요청이// 진행 중인 요청으로 dedupe되어, 그 낡은 응답이 신선한 데이터로 캐시에 안착한다.// 결과: 방금 추가한 템플릿의 태스크가 staleTime(5분) 동안 화면에 안 나타난다.// 진행 중인 요청을 먼저 취소해야 새 요청이 실제로 나간다.const invalidateToday = useCallback(async () => {
await queryClient.cancelQueries({ queryKey: taskKeys.scope('daily') })
await queryClient.invalidateQueries({ queryKey: taskKeys.scope('daily') })
}, [queryClient])
결과
연타·동시 요청 상황에서도 마지막 사용자 조작이 최종 상태로 유지 — 동일 조건 재현 테스트 6회 연속 통과
현재 조치는 경합 가능성을 낮춘 완화책으로 판단하고, 조회와 데이터 생성 책임을 분리하는 구조 개선안을 후속 과제로 문서화
case / hydration-mismatch
SSR hydration 불일치 해소
문제
CS 퀴즈 페이지 접속 시마다 hydration 오류가 발생하고, 초기 렌더링이 다시 수행되는 사이 즐겨찾기 클릭이 유실됐습니다. 원인은 상태 초기화 과정의 Math.random() 셔플 — 서버와 클라이언트의 문항 순서가 달라 렌더 트리가 재생성되고 있었습니다.
해결
"방문마다 순서가 달라진다"는 동작은 유지하되, 서버 컴포넌트가 요청별 시드를 생성하고 클라이언트가 같은 시드 기반 PRNG(mulberry32)로 셔플하도록 바꿔 양쪽이 동일한 초기 화면을 렌더링하게 했습니다.
src/app/(dashboard)/quiz/_hooks/useQuiz.ts실제 코드
// 시드 기반 PRNG(mulberry32). Math.random()으로 셔플하면 서버와 클라이언트의// 결과가 달라 hydration 불일치로 카드 트리가 재생성되고 그 사이 클릭이// 유실된다. 서버 컴포넌트가 내려준 시드로 양쪽이 같은 순서를 만들게 한다.function mulberry32(seed: number) {
let a = seed
return () => {
a |= 0
a = (a + 0x6d2b79f5) | 0let t = Math.imul(a ^ (a >>> 15), 1 | a)
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
}
}
exportfunction useQuiz(questions: QuizQuestion[], seed: number) {
const [shuffled] = useState(() => shuffle(questions, mulberry32(seed)))
// ...
}
결과
반복 접속 테스트에서 hydration 오류 미발생, 초기 클릭 유실 해소
"방문마다 다른 문항 순서"라는 기존 UX는 그대로 유지
case / drift-free-timer
타이머 재설계 — 매초 증가 대신 시각 차이로 파생 계산
문제
기존 타이머는 setInterval로 값을 매초 증가시키는 방식이라, 탭이 비활성화되거나 콜백 실행이 지연되면 실제 경과 시간과 화면 표시가 어긋났습니다. 새로고침하면 진행 상태도 사라졌습니다.
해결
시작 시각(startedAt)과 그 시점의 기준값(base)만 저장하고, 표시값은 현재 시각에서 파생 계산하도록 상태 모델을 재설계했습니다. 인터벌은 값을 만들지 않고 다시 그리기만 트리거하므로 스로틀돼도 드리프트가 없고, 상태를 localStorage에 저장해 새로고침·페이지 이동 후에도 이어서 돕니다. SSR 초기 렌더는 서버와 동일한 기본값을 쓰고 마운트 후 복원해 hydration 불일치도 피했습니다.
src/app/(dashboard)/timer/_hooks/useTimerPage.ts실제 코드
/**
* 타이머 상태 모델 — "몇 초 남았다/지났다"를 매 초 증감하는 대신
* 시작 시각(startedAt)과 그 시점의 기준값(base)만 저장하고 표시값은 현재
* 시각에서 파생 계산한다. 탭 스로틀로 setInterval이 늦게 돌아도 드리프트가
* 없고, localStorage에 저장하면 페이지 이탈/새로고침 후에도 이어서 돈다.
*/interface TimerState {
mode: TimerMode
/** 실행 중이면 시작(재개) 시각 epoch ms, 정지 상태면 null */
startedAt: number | null/** stopwatch: 마지막 정지 시점까지의 누적 경과 초 */
elapsedBase: number
/** timer: 시작(재개) 시점 기준 남은 초 */
remainingBase: number
configured: number
}
// 표시값은 항상 현재 시각에서 파생 — 인터벌은 now만 갱신한다const runningDelta = isRunning
? Math.max(0, Math.floor((now - state.startedAt) / 1000))
: 0const elapsed = state.elapsedBase + (state.mode === 'stopwatch' ? runningDelta : 0)
const remaining = Math.max(0, state.remainingBase - (state.mode === 'timer' ? runningDelta : 0))