서로 다른 직군의 7명의 팀원이 모여 완성한 spotit 프로젝트의 MVP가 완성되었습니다. 부트캠프를 하며 개발자로만 이루어진 팀만 경험하다가, 비개발 직군과 함께한 협업은 완전히 다른 느낌이었습니다. 그만큼 미숙한 점이 많았지만 분명 성장한 점이 눈에 띄일만큼 소중한 경험이었습니다.
글을 쓰는 지금은 팀 리뷰와 파트리뷰를 모두 완료한 상태인데, 팀원들의 회고글을 읽어보며 다시 깨닫게 되는 부분이 있었습니다. 개인 리뷰를 적으며 동료리뷰중 인상깊었던 점들도 차례로 풀어보겠습니다.
상태관리 라이브러리인 Zustand
를 다뤄보았습니다. Redux
에 비해 보일러 플레이트가 적고 직관적으로 상태를 정의할 수 있어서 빠르게 적응할 수 있었습니다. 특히 중간에 유저 정보 유지를 위해 persist
를 도입해야 했었는데 zustand에서는 해당 기능이 내장되어 있어서 추가로 라이브러리가 필요하지 않았던 점이 편리했습니다.
hasHydrated
: zustand persist로 로컬 스토리지의 값이 클라이언트 상태에 복원되었는지 여부를 알기 위함.
onRehydrateStorage
훅에서 복원 완료 시 _setHasHydrated(true)
실행 🔜 스토어 안의 _hasHydrated
값이 true로 바뀌며 hydration 이 완료됨을 알려줍니다.
onRehydrateStorage
의 시그니처
1. 첫번째 함수 : rehydration이 시작될 때 호출됨
2. 두번째 함수 : 첫번째 함수가 리턴하는 콜백으로 rehydration이 끝났을 때 호출됨
type Store = {
userState: UserState;
clearUser: () => void;
setUser: (user: User) => void;
_hasHydrated: boolean; // client에서 hydration완료 여부 확인용
_setHasHydrated: (v: boolean) => void;
};
export const useUserStore = create<Store>()(
persist(
set => ({
userState: initialUserState,
clearUser: () => set({ userState: initialUserState }),
setUser: (user: User) => set({ userState: { isLoggedIn: true, user } }),
_hasHydrated: false,
_setHasHydrated: v => set({ _hasHydrated: v }),
}),
{
name: 'user',
storage:
typeof window !== 'undefined'
? createJSONStorage(() => localStorage)
: undefined,
onRehydrateStorage: () => state => {
state?._setHasHydrated(true);
},
}
)
);
export function useUserHydrated() {
return useUserStore(s => s._hasHydrated);
}
useUserHydrated
로 hydration상태를 읽어서, 복원이 끝나기 전까지는 로딩만 보여줌으로써 mismatch 방지할 수 있었습니다.
const AuthGuard = dynamic(() => import('@/features/auth/lib/AuthGuard'), {
ssr: false,
loading: () => (
<div>
<div>
유저정보 확인 중이에요…
</div>
</div>
),
});
export default function AuthRequiredLayout({
children,
}: {
children: React.ReactNode;
}) {
const hasHydrated = useUserHydrated();
if (!hasHydrated) return null;
return <AuthGuard>{children}</AuthGuard>;
}
서버 상태 관리를 위한 @tanstack/react-query
를 더 깊게 다뤄보았습니다. 이전에도 사용 경험이 있었지만, 이번에는 v4 → v5 업그레이드를 진행하면서 쿼리 옵션과 다양한 훅을 더 깊이 이해할 수 있었습니다. 기존에는 useQuery
, useMutation
정도만 사용했는데, 이번에는 Suspense 기반 훅을 활용하며 로딩/에러 상태 처리와 에러 핸들링 방식을 보다 세밀하게 다룰 수 있게 되었습니다.
특히 v5로 업그레이드되면서 useQuery
훅 옵션으로 제공되던 onSuccess
, onError
(및 onSettled
) 콜백 사용을 지양하는 흐름이 강해졌습니다. 따라서 데이터 패칭과 사이드 이펙트 처리를 분리하여, 커스텀 훅을 통해 보다 명시적으로 제어하는 방식을 적용하였습니다.
😀 커스텀 훅 구현하기 (useQueryEffects)왜 deprecated(또는 지양)되었을까?
① 사이드 이펙트의 명시적 분리 부족
쿼리 옵션에 사이드 이펙트를 넣으면 데이터 fetching과 부수 효과가 암묵적으로 결합되어 컴포넌트의 책임이 모호해집니다.② 리렌더링 및 의존성 관리 문제
부모 컴포넌트에서 inline 콜백을 전달하면, 렌더링마다 새로운 함수 참조가 생성되어useEffect
의존성에 의해 반복 실행될 위험이 있습니다.③ 테스트·예측 가능성 저해
내부적으로 자동 실행되는 콜백은 실행 타이밍을 예측하기 어려워 디버깅과 유지보수가 힘들어집니다.
import { useEffect, useRef } from 'react';
import type { UseSuspenseInfiniteQueryResult } from '@tanstack/react-query';
type QueryEffectsOptions<TData, TError> = {
onSuccess?: (data: TData) => void;
onError?: (error: TError) => void;
onSettled?: (data: TData | undefined, error: TError | null) => void;
};
export function useQueryEffects<TData, TError>(
query: UseSuspenseInfiniteQueryResult<TData, TError>,
options: QueryEffectsOptions<TData, TError>
) {
const { onSuccess, onError, onSettled } = options;
// 이전 상태를 추적하기 위한 ref
const prevStateRef = useRef({
isSuccess: false,
isError: false,
data: undefined as TData | undefined,
error: null as TError | null,
});
useEffect(() => {
const { isSuccess, isError, data, error } = query;
const prev = prevStateRef.current;
// 새로운 성공 전이 시에만 실행
if (isSuccess && onSuccess && !prev.isSuccess) {
onSuccess(data as TData);
}
// 새로운 에러 전이 시에만 실행
if (isError && onError && !prev.isError) {
onError(error as TError);
}
// 성공 또는 에러로 처음 전이되었을 때만 실행
if ((isSuccess || isError) && onSettled && !(prev.isSuccess || prev.isError)) {
onSettled(data, error);
}
// 현재 상태 저장
prevStateRef.current = { isSuccess, isError, data, error };
}, [
query.isSuccess,
query.isError,
query.data,
query.error,
onSuccess,
onError,
onSettled,
]);
return query;
}
💡 실제 사용처에서 콜백 넘겨주기
const query = useSuspenseInfiniteQuery({
queryKey: ['popup', 'list', { ...request }],
queryFn: /* ... */,
});
useQueryEffects(query, {
onSuccess: (data) => {
if (process.env.NEXT_PUBLIC_ENV === 'DEVELOP') {
console.log('[onSuccess]:', data);
}
},
onError: (error) => {
handleNetworkError(error);
console.error('[onError]:', error);
throw error;
},
onSettled: (data, error) => {
if (process.env.NEXT_PUBLIC_ENV === 'DEVELOP') {
console.log('[onSettled]:', data, error);
}
},
});
Next.js의 캐싱·ISR·태깅(revalidate, tags, revalidateTag)은 fetch에 최적화되어 있어 axios 대신 fetch 래퍼(API Builder) 를 만들어 사용하였습니다.
목표는
(1) 요청마다 캐시 정책이 드러나게 하고(cache, next:{ revalidate, tags })
(2) 서버/클라이언트 인증 분기를 안전하게 처리하며
(3) 파라미터 직렬화·타임아웃·에러 매핑을 공통화하는 것입니다
따라서 API Builder
에서 설정과 체이닝을 담당하고, API.call()
로 fetch를 실행하도록 구성하였습니다.
setCache(cache: RequestCache)
메서드 → no-store/force-cache 등next(config: NextFetchRequestConfig)
메서드 → { revalidate, tags } 지정.auth()
를 호출하면 내부의 authInternal()
이 실행됩니다.export const getPopupDetailApi = async (
popupId
) => {
const response = await APIBuilder.get(
POPUP_DETAIL_ENDPOINTS.GET_POPUP_DETAIL(popupId)
)
.timeout(5000)
.setCache('force-cache')
.next({ revalidate: 300, tags: [`popup:${popupId}`] })
.build()
.call<PopupDetailResponseDto>();
return response.data;
};
export default async function getUserApi() {
const response = await (
await APIBuilder.get('/auth/me')
.timeout(5000)
.withCredentials(true)
.auth()
.setCache('no-store')
.buildAsync()
).call<UserResponse>();
return response.data;
}
프론트/백엔드 개발은 모노레포로 프로젝트를 관리하여 상호 코드리뷰를 통해 코드 품질을 지키려 노력하였습니다.모노레포를 적용하며 파트간 리뷰뿐 아니라 백 <-> 프론트간 리뷰도 진행되어 구현 사항에 관한 논의가 활발하게 진행되었던 점, 이슈트랙킹을 한눈에 볼 수 있던 점이 좋았습니다.
코드 리뷰는 필연적으로 상대방의 부족한 점에 대한 지적이 포함되기 때문에, 자칫 감정적으로 상할 수 있다고 생각하였습니다.
그래서 리뷰 시에는 보완할 점뿐 아니라 인상 깊었던 부분에 대한 칭찬도 함께 전달하였고, 😊 같은 이모지를 활용하여 좀 더 부드럽고 긍정적인 대화가 이루어지도록 하였습니다.
또한 프로젝트 중반부터는 코드 리뷰 형식을 정립하여, 리뷰의 중요도를 표기할 수 있는 간단한 형식을 제작하였습니다. 이를 통해 리뷰의 우선순위를 명확히 하고, 피드백을 보다 체계적으로 전달할 수 있었습니다.
코드리뷰 통해 컴포넌트의 적절한 책임 단위의 기준을 배우고 이후에는 스스로 점검하는 습관이 생겼습니다.
초기 리뷰 중 FSD 아키텍처 적용 시 폴더 구조를 어떻게 설계할지에 대한 논의 (pr#37)가 있었습니다.
이 과정에서 동료와 충분히 의견을 나누고, 합의점을 도출해낸 점이 이상적인 리뷰 사례로 팀 회고에서 언급되었습니다.
이를 통해 코드 리뷰가 단순한 코드 수정 지적을 넘어, 팀 전체의 아키텍처 방향성을 함께 만들어가는 과정이 될 수 있다는 점을 확인할 수 있었습니다.
여러 직군의 동료와 협업하며 개선해야 할 점을 몇가지 발견하였습니다.
프로젝트 진행 중 디자인 변경 사항이 프론트엔드 개발 과정에 원활히 반영되지 못하는 문제를 경험하였습니다.
이번 프로젝트는 ‘UI 구현 완료 → API 연결 및 기능 개발 → QA’ 의 순서로 진행되었는데, 기능 구현 단계에서 시안이 수정되었음에도 불구하고 이를 제때 공유받지 못하는 상황이 있었습니다.
그 결과 QA 단계에 이르러서야 변경 사항을 확인하는 경우가 발생하였습니다.
CSS 수정 자체는 어렵지 않았으나, 디자인 변경 사실이 개발자에게 늦게 전달된다는 점은 협업 과정의 문제라고 판단하였습니다.
추가적으로 디자이너와의 협업 효율을 높이기 위해 몇 가지 툴을 검토하였습니다.
Design Lint (플러그인)
: 디자인 시안에서 “디자인 시스템 규칙 위반”을 자동으로 탐지합니다.
(예: 디자인 시스템에는 #4A90E2
색상만 사용해야 하는데, 실수로 #4A90E3
을 사용한 경우 자동으로 탐지)
Version History Notifie (플러그인, API)
: Figma 파일의 변경 이력을 추적하고 알림으로 전달합니다.
이 경험을 통해 디자인 변경 사항의 즉각적인 공유가 개발 효율에 직결된다는 점을 깨달았습니다.
또한 단순히 개인 간의 커뮤니케이션 문제가 아니라, 프로세스와 협업 도구 차원에서 해결해야 하는 문제임을 인식하게 되었습니다.
프로젝트 진행 중 API 문서와 실제 구현 간 불일치로 인해 불필요한 디버깅 시간이 자주 발생하였습니다.
예를 들어,
프론트엔드에서 직접 디버깅을 통해 원인을 찾아야 했으며, 이러한 상황이 예상보다 자주 발생하면서 협업 효율에 부정적인 영향을 주었습니다.
이를 개선하기 위해 문서 관리 방식을 GitHub Wiki에서 OpenAPI(Swagger) 기반으로 전환하자는 제안을 하였습니다.
백엔드 팀 역시 관리되지 않는 문서로 인한 문제에 공감하였으나,
결론적으로는 다음 페이즈부터 Swagger를 도입하기로 합의하였습니다.
Swagger는 기존 GitHub Wiki보다 훨씬 간략하게 스펙을 명시할 수 있어 관리 포인트를 줄이면서도 협업 효율을 높일 수 있을것 같기 때문입니다.
이 논의를 통해 문서가 친절해질수록 관리 포인트가 늘어나고, 간략해질수록 관리 포인트는 줄지만 프론트엔드에서 일정 부분 추론이 필요하다는 점을 알게 되었습니다.
결국 프로젝트 상황과 팀 역량에 맞는 현실적인 균형점을 찾는 것이 중요하다는 교훈을 얻었습니다.
비개발자 동료와 함께하는 회의에서 문제 상황을 설명할 때, 저는 주로 문제의 원인(Why) 에 초점을 맞춰 설명하였습니다.
하지만 원인을 설명하다 보면 자연스럽게 구현 방식까지 들어가게 되고, 이 과정에서 개발 용어 사용 빈도가 높아졌습니다.
이로 인해 비개발 직군 입장에서는 결론이 흐려지고 소외감을 느낄 수 있다는 점을 간과하였습니다.
이러한 방식은 효율적이지 못할 뿐만 아니라, 상대방의 입장을 고려하지 못한 부적절한 커뮤니케이션 방식이라고 판단하였습니다.
따라서 ‘왜(Why)’ 보다는 ‘어떻게(How)’에 초점을 맞추는 방식으로 접근하는 것이 바람직하다고 생각하게 되었습니다.
이 경험을 통해, 커뮤니케이션에서 중요한 것은 상대방이 이해할 수 있는 방식으로 핵심을 전달하는 것임을 배웠습니다.
특히 비개발 직군과의 소통에서는 원인 설명보다 해결 방법과 영향에 집중해야 협업 효율과 신뢰를 높일 수 있다는 점을 깨달았습니다.
리뷰위크 전까지 MVP를 최종 완성해야 했으나, 같은 파트 팀원의 일정이 지연되면서 전날 새벽까지 트러블슈팅과 기능 구현을 병행해야 하는 상황이 발생하였습니다.
사실 리뷰위크 2주 전부터 진행된 QA 과정에서도 해당 기능이 구현되지 않아 충분한 테스트가 어려웠고, 결국 일정이 연이어 밀리면서 마감 직전까지 이어진 사례였습니다.
또한 팀원의 일정 지연으로 인해, 원래 해당 팀원이 맡은 기능 중 일부를 제가 대신 처리해야 했습니다.
이 상황에서 제 과제는 단순히 마감을 맞추는 것뿐만 아니라, 팀 내 일정 지연 문제의 원인을 파악하고 재발을 방지할 수 있는 협업 방식을 마련하는 것이었습니다.
이러한 과정을 통해 일정 지연 문제가 구조적으로 개선되었고, 팀 전체의 협업 방식이 한층 더 명확해졌습니다.
무엇보다도 갈등 상황에서는 감정적인 대응보다는 상대방의 상황을 이해하고, 제도적 개선책으로 풀어내는 접근이 효과적이라는 점을 배웠습니다.