서울의 2030이라면 Meet Meet에서 모임을 가지고 새로운 친구를 만들어보세요! 혼자라고 걱정할 필요없어요😊
이창욱(window-ook) 영남대 경영학과 17 FE |
오상훈(OhSSangHoon) 영남대 컴퓨터공학과 18 FE |
2025.05.15 ~ 2025.06.22
이메일: test@sample.com 패스워드: 1567@zcvb
다른 사용자들이 게시한 모임을 둘러볼 수 있습니다. 모임은 메인, 서브 카테고리로 분류되어 있습니다. 필터링으로 원하는 조건의 모임을 찾을 수 있습니다.
- 북적북적: 외향적인 / 활동적인 성격의 가진 모임입니다!
- 엔터테인먼트 ex) 스포츠 관람, 공연 관람, 축제 참가, 다같이 모여서 맥주 한잔 등
- 액티비티 ex) 풋살, 농구, 러닝 크루, 실내 사격, 클라이밍 등
- 도란도란: 내향적인 성격의 모임입니다! ex) 같이 카공하기, 취미 모임, 독서 모임 등
모임에 누가 참여하고 있는지 확인하고, 다른 사용자가 남긴 리뷰도 확인할 수 있습니다.
- 모임 이름, 모임 장소, 모임 날짜, 모임 게시자(id) 확인, 모임 참여자 정보 및 참여자 수 확인
- 찜하기, 참여하기, 참여 취소하기, 모임 삭제하기(게시자), 공유하기
원하는 조건의 모임을 생성할 수 있습니다.
- 이름, 모집 장소, 모임 타입, 모임 날짜, 모집 마감 날짜, 모집 정원
모임 찾기, 모임 상세 정보 페이지에서 찜한 모임들을 확인할 수 있습니다. 모임 카테고리 필터링으로 원하는 조건의 모임만 확인할 수 있습니다.
모든 모임의 리뷰 히스토리를 확인할 수 있습니다. 날짜, 모임 카테고리 필터링으로 원하는 조건의 모임의 리뷰만 확인할 수 있습니다.
참여중인 모임
현재 로그인한 계정으로 참여한 모임의 목록을 볼 수 있습니다.
- 모집 마감 여부 / 이용 여부 / 개설 상태를 확인할 수 있습니다.
- 리뷰를 남기거나 참여를 취소할 수 있습니다.
나의 리뷰
참여한 모임 중에서 리뷰 작성이 가능한 모임, 이미 작성한 리뷰를 필터링하여 볼 수 있습니다.
내가 만든 모임
내가 만든 모임 중 모집 취소되지 않은 모임을 확인할 수 있습니다.
📁 components
├── 🔐 auth
├── 👥 gatherings
├── 📄 mypage
├── ⭐ reviews
├── 💾 saved
├── shadcn-ui
└── 🌐 shared
🪝 hooks/api
├── 👥 gatherings
├── 📄 mypage
├── ⭐ reviews
└── 💾 saved
⚖️ utils
├── 🔐 auth
├── 👥 gatherings
├── 📄 mypage
├── ⭐ reviews
└── 🌐 shared
components
UI와 클라이언트 로직을 담당하는 컴포넌트들이 위치합니다.
hooks/api
Tanstack Query에서 제공하는 Hook 기반 커스텀 Hooks입니다.
이는 Async Surf
의 주요 구성요소로 역할을 하기도 합니다. 자세한 설명은 아키텍처 설명에 있습니다.
utils
유틸리티 함수가 대부분을 차지하고, 극히 일부 유효성 검사 목적의 스키마나 상수도 포함되어있습니다.
컴포넌트에 위치할 필요가 없고 순수 함수의 성격을 띌 수 있으면서 분리가 가능한 함수는 이곳에 위치합니다.
components
, hook/api
, utils
에 위한 shared
는 global, common과 동일한 의미를 가진 디렉토리입니다.
'전역에서 Feature에 구애받지 않고 참조하여 사용할 수 있는' 재사용 컴포넌트가 위치합니다.
이를 제외한 나머지 디렉토리는 모두 역할 기반 디렉토리입니다.
이 중 gatherings
만 내부에 shared
디렉토리를 2nd detph로 가지고 있습니다.
왜일까요?
👥 gatherings
├── detail
│ ├── Footer
│ ├── GatheringDetail
│ └── ...
│
├── shared
│ └── JoinedCountsProgressBar
│
├── CreateGatheringDialog
├── DateTimePick
└── ...
gatherings
는 모임 찾기, 모임 상세 페이지를 포함한 Feature입니다.
페이지 경로 또한 gatherings/detail
로 gatherings의 하위 엔드포인트입니다.
따라서 찾기와 상세에서 공통 재사용되는 컴포넌트와 페이지별 따로 쓰는 컴포넌트를 구분하기 위해 내부 shared
가 하나 존재합니다.
FSD 도입은 글쎄? '호미로 막을걸 가래로 막는다'
이번 프로젝트는 시간 제한과 요구사항이 명확한 프로젝트였습니다. 또한, 전체 경로 수나 앱이 가진 기능이 소~중 규모에 가깝기에 모두 익숙하면서 러닝커브가 낮은 구조가 적절하다고 판단했습니다.
공통적으로 사용되는 컴포넌트를 어디에 위치시켜야 할지 기준이 명확해 혼선이 없었습니다. 따라서 컴포넌트 모듈화, 유틸리티 함수의 증가에 따른 파일의 확장이 생겨도 배치에 관해서 소모되는 시간이 거의 없었습니다. 덕분에 협업 기준으로 팀의 DX가 매우 쾌적했고, 제한된 기간에 요구사항을 모두 충족하면서 디테일까지 추가할 수 있었습니다.
Surf는 비동기 통신 흐름을 마치 서프를 타는 것에 비유한 것입니다.
이는 클라이언트 사이드로부터 출발하는 Surf와 서버 사이드에서 출발하는 Surf로 분리되어있습니다.
1. API Hooks
이번 프로젝트에서는 클라이언트 컴포넌트에서 API 라우트를 거쳐 요청을 보내는 비동기 통신이 중심이었습니다.
useQuery
, useInfiniteQuery
, useMutation
이 핵심 도구로써 빈번히 사용될 거라 생각했고, 커스텀 Hook으로 모두 모듈화하는 방법을 설계했습니다.
모두 API 라우트를 거쳐 백엔드의 API를 사용하기 때문에 API Hooks로 이름 붙여졌습니다.
이들을 별도의 커스텀 훅으로 분리한 까닭은, 컴포넌트에서 Tanstack Query Hook의 로직을 관리하는 것은 가독성, 중앙화, 유지보수 모두 좋지 않다고 생각했기 때문입니다.
ex) src/hooks/api/gatherings/useCreateGathering.ts
/**
* 모임 생성 훅
* @param token 토큰
* @param onCallback 모달에 표시할 메세지를 전달
* @returns {function} createGathering - 모임 생성 함수
*/
export const useCreateGathering = ({ token, onCallback }: GatheringApiParams) => {
const queryClient = useQueryClient();
const createGathering = useMutation({
mutationFn: async (formData: FormData) => {
if (!token) throw new Error('로그인이 필요합니다.');
const response = await internalClient.post(INTERNAL_PATHS.GATHERINGS, formData, { headers: { 'Content-Type': 'multipart/form-data' } });
return response.data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: gatheringsQuery.all() });
queryClient.invalidateQueries({ queryKey: myPageQuery.createdGatherings(token!) });
onCallback?.('모임을 생성했습니다');
},
onError: (error) => {
const err = error as AxiosError;
const message = (isErrorResponse(err?.response?.data) && err?.response?.data.message) || '모임 생성에 실패했습니다';
onCallback?.(message);
}
});
return { createGathering: createGathering.mutate }
}
2. Queries Station
훅은 모두 queryKey를 사용합니다. queryKey는 하드 코딩으로 관리하지 않고 Queries Station이 중앙화하여 관리합니다. Queries Station은 FSD에서 제공하는 Query Factory Pattern에서 영감을 얻었습니다. Quries Station은 쿼리키를 중앙화하여 queryKey를 사용하는 곳에서 참조하는 방식으로 사용합니다. 따라서 queryKey를 하드코딩으로 선언하면서 실수로 추가하거나, 파라미터를 다르게 사용하는 등의 문제를 방지하죠.
근데 FSD 안 쓴다며? 네 FSD는 쓰지 않지만 중앙화의 도구로써 Async Surf 패턴에 유지보수 용이성과 확장성에 견고함을 더하기에 Query Factory는 참고했습니다.
ex) queries/gatherings.query.ts
export const gatheringsQuery = {
all: () => ['gatherings'] as const,
infinite: (
mainType?: string,
location?: string,
date?: string,
sortBy?: string,
sortOrder?: string,
startIndex?: number,
filterSavedIds?: string
) => [...gatheringsQuery.all(), { mainType, location, date, sortBy, sortOrder, startIndex, filterSavedIds }] as const,
};
export const gatheringDetailQuery = {
detail: (id: string | number) => ['gatheringDetail', id],
reviews: (id: string | number) => ['gatheringReviews', id],
checkJoined: (id: string | number) => ['checkGatheringJoined', id],
};
3. API Paths Station
클라이언트와 Next.js 서버에서 내외부 통신을 위한 API의 경로를 중앙화한 객체 모듈입니다.
EXTERNAL_PATHS
: API 라우트 → 백엔드, 서버 액션 → 백엔드로의 외부 통신을 위한 경로입니다.INTERNAL_PATHS
: 클라이언트 → API 라우트로의 외부 통신을 위한 경로입니다.
엔드 포인트 변경에 대응하기도 쉽고, 새로운 경로가 추가되어도 이를 관리하기 용이합니다.
export const EXTERNAL_PATHS = {
SIGN_IN: '/auths/signin',
SIGN_OUT: '/auths/signout',
SIGN_UP: '/auths/signup',
USER: '/auths/user',
GATHERINGS: '/gatherings',
REVIEWS: '/reviews',
CHECK_JOINED: '/gatherings/joined',
fetchGatheringDetail: (id: number) => `/gatherings/${id}`,
fetchDetailReview: (id: number) =>
`/reviews?gatheringId=${id}&limit=4&offset=0`,
fetchGatheringParticipants: (id: number) =>
`/gatherings/${id}/participants?limit=100`,
joinGathering: (id: number) => `/gatherings/${id}/join`,
cancelGathering: (id: number) => `/gatherings/${id}/cancel`,
leaveGathering: (id: number) => `/gatherings/${id}/leave`,
} as const;
export const INTERNAL_PATHS = {
SIGN_IN: '/api/auth/signin',
SIGN_OUT: '/api/auth/signout',
SIGN_UP: '/api/auth/signup',
USER: '/api/auth/user',
GATHERINGS: '/api/gatherings',
REVIEWS: `/api/reviews`,
CHECK_JOINED: `/api/gatherings/joined`,
fetchGatheringDetail: (id: number) => `/api/gatherings/detail?id=${id}`,
fetchGatheringParticipants: (id: number) =>
`/api/gatherings/participants?id=${id}`,
joinGathering: (id: number) => `/api/gatherings/join?id=${id}`,
cancelGathering: (id: number) => `/api/gatherings/cancel?id=${id}`,
leaveGathering: (id: number) => `/api/gatherings/leave?id=${id}`,
FETCH_JOINED_GATHERINGS: `/api/gatherings/joined?&limit=100`,
fetchCreatedGatherings: (userId: number) =>
`/api/gatherings?createdBy=${userId}&limit=100&sortBy=registrationEnd`,
} as const;
4. Client Fetchers
axios
기반 클라이언트 사이드 전용 내외부 통신 fetchers입니다.
axios
를 활용한 이유는 http 상태 코드 기반 자동 에러 처리, 응답 데이터의 자동 직렬화 / 역직렬화 기능이 내장되어있기 때문입니다.
클라이언트 컴포넌트에서 시작하는 비동기 통신이 주를 이루는 프로젝트에서 2명의 팀원만으로 제한된 기간 내에 개발해야 하는 상황에서 가장 이상적인 선택이었습니다.
internalClient
: 요청 인터셉터, 응답 인터셉터를 적용하였습니다.
- baseURL(클라이언트 도메인), ‘withCredentials: true’
- 요청 인터셉터:
internalClient
의 요청을 가로채서 Access Token을 headers에 추가합니다. - 응답 인터셉터:
parseAxiosError
라는 에러 핸들링 함수를 이용하여 message와 status만 정제 후 반환합니다.
/**
* 내부 통신용 클라이언트
* @param baseURL - API 기본 URL
* @param withCredentials - 쿠키 포함 여부
* @warning 외부 경로에는 사용하지 말 것
*/
export const internalClient = axios.create({
baseURL: process.env.NEXT_PUBLIC_BASE_URL,
withCredentials: true,
});
// 요청 인터셉터: headers에 토큰 추가
internalClient.interceptors.request.use(
(config) => {
const token = typeof window !== 'undefined' ? localStorage.getItem('token') : null;
if (token) config.headers['Authorization'] = `Bearer ${token}`;
return config;
},
(error) => Promise.reject(error)
);
// 응답 인터셉터: 에러 메세지, 에러 코드 parsing 후 반환
internalClient.interceptors.response.use(
response => response,
error => {
const message = parseAxiosError(error);
error.parsedMessage = message;
error.parsedStatus = error?.response?.status ?? null;
return Promise.reject(error);
}
);
// 에러 메세지 파싱
const parseAxiosError = (error: unknown, message = '요청 중 에러가 발생했습니다.') => {
if (axios.isAxiosError(error)) {
const serverError = error?.response?.data?.error;
if (serverError && typeof serverError === 'object' && 'message' in serverError) return serverError.message;
if (error?.response?.data?.message) return error.response.data.message;
}
return message;
};
externalClient
- baseURL: 백엔드 API의 도메인입니다.
- withCredentials: true
- 내부 통신에 인터셉터를 붙여놨기 때문에 외부 통신은 단순히 역할 분리를 위한 최소한의 설정만 되어있습니다.
/**
* 외부 통신용 클라이언트
* @param baseURL - 기본 URL
* @param withCredentials - 쿠키 포함 여부
* @warning 내부 경로에는 사용하지 말 것
*/
export const externalClient = axios.create({
baseURL: process.env.API_URL,
withCredentials: true,
});
5. Surf Guard
API Hooks와 API 라우트에서 발생하는 에러를 일관되게 처리하기 위한 유틸리티 모듈입니다.
isErrorResponse
와 handleApiError
를 각각 API Hooks와 API 라우트로 지원합니다.
isErrorResponse
: 에러 응답 데이터가 예상된 형식(ErrorResponse
)을 따르는지 검증하는 타입 가드 함수입니다.
/**
* 에러 응답 타입 가드 함수
* @description 에러 응답 데이터가 ErrorResponse 타입인지 확인
* @param data - 에러 응답 데이터
* @returns {boolean}
*/
export function isErrorResponse(data: unknown): data is ErrorResponse {
return typeof data === 'object' && data !== null;
}
handleApiError
: 외부 통신 중 발생한 에러를 상황별로 적절한 적절히 처리하는 에러 핸들러입니다. 먼저 에러가 존재하는지, 객체 타입인지, 에러에 isAxiosError가 있는지 검증합니다. 만약 검증이 통과한 상황이라면 아래와 같이 처리하는 방법이 나뉩니다.
- 응답이 있는 경우
- 401 상태 특별 처리: 401 상태 코드와 함께 '유효하지 않은 토큰입니다'라는 메시지가 오면 '로그인이 만료되었습니다'로 변환합니다. (다이얼로그 UI에 나타낼 사용자를 위한 메세지로 변환)
- 기본: 원본 에러 데이터 그대로 전달
- 응답이 없는 경우
- 네트워크 연결 실패, 서버 다운, 타임아웃
- 요청 자체 에러
- 잘못된 URL, 잘못된 요청 형식, 기타 요청 구성 에러
검증이 통과하지 않은, AxiosError가 아니라면(런타임 에러, 에러 타입) SERVER_ERROR라는 코드와 기본 fallback 메세지를 반환합니다.
/**
* 에러 응답 처리
* @param error - 에러 객체
* @param fallbackMessage - 기본 메시지
* @param fallbackStatus - 기본 상태 코드
* @returns {NextResponse} 응답 객체
*/
export function handleApiError(error: unknown, fallbackMessage = '서버 에러 확인이 필요합니다', fallbackStatus = 500): NextResponse {
if (error && typeof error === 'object' && 'isAxiosError' in error && (error as AxiosError).isAxiosError) {
const err = error as AxiosError;
if (err.response) {
if (err.response.status === 401 &&
isErrorResponse(err.response.data) &&
err.response.data.message === '유효하지 않은 토큰입니다') {
return new NextResponse(JSON.stringify({
...err.response.data,
message: '로그인이 만료되었습니다'
}), { status: err.response.status });
}
return new NextResponse(JSON.stringify(err.response.data), { status: err.response.status || fallbackStatus });
}
else if (err.request) return new NextResponse(JSON.stringify({ code: 'SERVER_ERROR', message: '서버에서 응답이 없습니다.' }), { status: 500 });
else return new NextResponse(JSON.stringify({ code: 'REQUEST_ERROR', message: err.message || fallbackMessage }), { status: 500 });
}
return new NextResponse(JSON.stringify({ code: 'SERVER_ERROR', message: fallbackMessage }), { status: fallbackStatus });
}
따라서 서버로부터 온 모든 에러를 { code, message } 형식을 따르도록 보장하기 때문에 클라이언트에서는 일관된 에러 포맷을 전달받습니다. 이를 가지고 ConfirmDialog 와 같은 사용자 알림용 다이얼로그에 메세지를 나타내기 쉬웠습니다.
1. Server Fetcher (based on Fetch API)
Fetch API
기반 서버 사이드 전용 외부 통신 fetcher입니다.
그럼 왜 서버 사이드는 Fetch API
를 썼을까요?
- Next.js의
cache
옵션: 페이지 렌더링 방식을 선택할 수 있습니다. (SSG/ISR/SSR) Request Memoization
: React Component Tree를 렌더링 하는 동안 같은 요청(fetch)이 반복되면, 첫 호출에만 네트워크 요청이 수행되고 이후 호출은 캐시된 결과로 반환합니다.
export interface ServerFetcherOptions extends RequestInit {
next?: {
revalidate?: number;
};
}
/**
* Server Side API Fetcher (SSG)
* @param url
* @param options
* @returns Response JSON
*/
export async function serverFetcher<T = unknown>(
url: string,
options?: ServerFetcherOptions,
): Promise<T> {
const defaultHeaders = { 'Content-Type': 'application/json' };
const mergedOptions: RequestInit = {
...options, // revalidate, no-store, cache 등 옵션 설정
headers: {
...defaultHeaders,
...(options?.headers || {}),
},
};
const fullUrl = `${process.env.API_URL}${url}`;
const response = await fetch(fullUrl, mergedOptions);
if (!response.ok) {
// 서버사이드라면 에러를 throw해서 상위에서 핸들링하도록
const errorText = await response.text();
throw new Error(`serverFetcher: ${response.status} ${response.statusText} - ${errorText}`);
}
try {
return await response.json();
} catch (error) {
console.error('serverFetcher: ', error);
return (await response.text()) as T;
}
}
주요 기능 구현을 위해
을 활용하는 컴포넌트가 5개 필요했습니다. 초기에 useState와 핸들러를 개별적으로 생성하여 비효율적인 방식으로 먼저 개발 후 React Hook Form과 Zod를 사용하여 리팩토링을 하였습니다.대표적인 예시로 회원가입 페이지에서 폼 컴포넌트인 SignUpForm.tsx
의 리팩토링 전과 후 코드를 비교하며 어떤 개선과 결과를 도출했는지 설명해드리겠습니다.
- 15개의 useState로 만든 상태
const [name, setUsername] = useState('');
const [email, setEmail] = useState('');
const [companyName, setCompanyName] = useState('');
const [password, setPassword] = useState('');
const [passwordCheck, setPasswordCheck] = useState('');
const [emailError, setEmailError] = useState(false);
const [passwordError, setPasswordError] = useState(false);
const [passwordCheckError, setPasswordCheckError] = useState(false);
const [isPasswordVisible, setIsPasswordVisible] = useState(false);
const [isPasswordCheckVisible, setIsPasswordCheckVisible] = useState(false);
const [errorResponseMessage, setErrorResponseMessage] = useState<string | null>(
null,
);
- 필드마다 유효성 검사를 포함한 핸들러(8개) 구현
const handleUsernameValidation = (e: React.ChangeEvent<HTMLInputElement>) => {
/* 5줄 */
};
const handleEmailValidation = (e: React.ChangeEvent<HTMLInputElement>) => {
/* 5줄 */
};
const handlePasswordValidation = (e: React.ChangeEvent<HTMLInputElement>) => {
/* 5줄 */
};
const handlePasswordCheckValidation = (
e: React.ChangeEvent<HTMLInputElement>,
) => {
/* 5줄 */
};
const handlePasswordVisibility = (e: React.MouseEvent<HTMLButtonElement>) => {
/* 3줄 */
};
const handlePasswordCheckVisibility = (
e: React.MouseEvent<HTMLButtonElement>,
) => {
/* 3줄 */
};
const handleCompanyNameValidation = (
e: React.ChangeEvent<HTMLInputElement>,
) => {
/* 3줄 */
};
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
/* 25줄 */
};
- UI 유효성 조건문 hell
{
errorResponseMessage ? (
<span className="text-sm text-red-600">{errorResponseMessage}</span>
) : !email ? (
<span className="text-sm text-red-600">이메일을 입력해 주세요.</span>
) : email && emailError ? (
<span className="text-sm text-red-600">올바른 이메일 형식이 아닙니다.</span>
) : email && !emailError ? (
<span className="text-sm text-green-500">✓</span>
) : null;
}
→ 상태 관리, 가독성, 유지보수성 모두 나쁨
- 인풋 value 및 API param 상태 관리는 useForm 1개로 해결
const {
register,
handleSubmit,
watch,
formState: { errors, isSubmitting, isSubmitted },
} = useForm<SignupFormSchemaType>({
resolver: zodResolver(signUpFormSchema),
});
- 난잡한 핸들러를 모두 제거하고 handleSubmit에 필요한 onSubmit 핸들러 1개로 해결
const onSubmit = async (data: SignUpFormSchemaType) => {
try {
await signUp({
email: escapeForXSS(data.email),
password: escapeForXSS(data.password),
name: escapeForXSS(data.name),
companyName: escapeForXSS(data.companyName),
});
} catch (error) {
if (axios.isAxiosError(error)) {
const serverError = error?.response?.data;
if (serverError?.message) setErrorResponseMessage(serverError.message);
else setErrorResponseMessage('회원가입 처리 중 오류가 발생했습니다.');
} else {
setErrorResponseMessage('알 수 없는 오류가 발생했습니다.');
}
}
};
- Zod를 활용하여 유효성 검사 수행을 1줄로 깔끔히 정리
// useForm의 리졸버 -> 내부적으로 zod 스키마를 활용하여 유효성 검사 진행
resolver: zodResolver(signUpFormSchema);
// authSchema.ts
import { z } from 'zod';
export const signUpFormSchema = z.object({
name: z.string().min(1, '이름을 입력해 주세요.').max(20, '이름은 20자 이하로 입력해 주세요.'),
email: z.string().min(1, '이메일을 입력해 주세요.').max(30, '이메일은 30자 이하로 입력해 주세요.').email('올바른 이메일 형식이 아닙니다.'),
companyName: z.string().min(1, '크루 이름을 정확하게 입력해 주세요.').max(20, '크루 이름은 20자 이하로 입력해 주세요.'),
password: z.string().min(8, '비밀번호가 8자 이상이 되도록 해주세요.').refine(
(password) => {
const hasSpecialChar = /[!@#$%^&*(),.?":{}|<>]/.test(password);
const hasNumber = /\d/.test(password);
const hasLowercase = /[a-z]/.test(password);
return hasSpecialChar && hasNumber && hasLowercase;
},
{ message: '영문 소문자, 숫자, 특수문자를 포함해야 합니다.' }
),
});
export type SignUpFormSchemaType = z.infer<typeof signUpFormSchema>;
- 폼에서 재사용될 컴포넌트를 활용하여 props에 따른 조건부 UI 처리
const InputField = React.forwardRef<HTMLInputElement, InputFieldProps>(
({ label, labelSize = 'text-sm', id, type, placeholder, isError, errorResponseMessage, disabled, isPasswordVisible, handlePasswordVisibility, ...props }, ref) => (
<div className="w-full flex flex-col gap-2">
<label htmlFor={id} className={`block ${labelSize} font-bold dark:text-white`}>{label}</label>
<div className='relative'>
<input
ref={ref}
type={label === '비밀번호' ? (isPasswordVisible ? 'text' : 'password') : type}
id={id}
placeholder={placeholder}
aria-invalid={disabled ? (isError ? 'true' : 'false') : undefined}
className={`block w-full p-2.5 rounded-lg bg-gray-50 text-sm border-2 focus:outline-none ${isError || errorResponseMessage ? 'border-red-600' : 'focus:border-main-300'}`}
{...props}
/>
{label === '비밀번호' && (
<button
type="button"
className="absolute right-2.5 top-1/2 -translate-y-1/2 cursor-pointer hover:opacity-60"
onClick={handlePasswordVisibility}
tabIndex={-1}
>
<Image
src={isPasswordVisible ? "https://res.cloudinary.com/dbvzbdffi/image/upload/v1749713866/visibility_on_j
4D1F
h4sec.svg" : "https://res.cloudinary.com/dbvzbdffi/image/upload/v1749713865/visibility_off_qtspno.svg"}
alt="비밀번호 보기 숨김"
width={24}
height={24}
/>
</button>
)}
</div>
{errorResponseMessage ? (
<p className='text-red-600 text-sm'>{errorResponseMessage}</p>
) :
(isError && <p className='text-red-600 text-sm'>{isError}</p>)
}
</div>
)
);
InputField.displayName = 'InputField';
export default InputField;
// SignUpForm, SignInForm
<InputField
label="이름"
id="user-name"
type="text"
placeholder="이름 입력"
{...register('name')}
disabled={isSubmitted}
isError={errors.name?.message}
/>
<InputField
label="이메일"
id="email"
type="email"
placeholder="이메일 입력"
{...register('email')}
disabled={isSubmitted}
isError={errors.email?.message}
errorResponseMessage={errorResponseMessage}
/>
...
🔥 코드 감소
코드 라인 | 이전 방식 | 현재 방식 | 개선 효과 |
---|---|---|---|
총 라인 | 172 | 120 | 30% 감소 |
상태 | useState 15개 | useForm 1개 | 93% 감소 |
핸들러 함수 | 'handle-' 8개 | onSubmit 1개 | 87% 감소 |
유효성 검사 | 60 | 외부 스키마 | 100% 감소 (분리) |
🔥 성능 개선
성능 지표 | 이전 방식 | 현재 방식 | 개선 효과 |
---|---|---|---|
리렌더링 횟수 | 타이핑 할 때마다 | useForm 내부 최적화 | 80% 감소 |
유효성 검사 | onChange가 감지할 때마다 | 필요한 시점에만 | 70% 감소 |
DOM 조작 | 상태가 변경될 때마다 | 최적화된 조작 | 60% 감소 |
코드잇 FE SI 9기
중간 발표 이후 타 스프린터들의 평가를 받게 되었습니다.
평가 문항 3개, 각 문항당 1~5점
- 웹 사이트의 기능 및 사용성에 대해 어떻게 생각하셨나요? → 평균 약 4.148점
- 디자인이 얼마나 직관적이었나요? → 평균 약 4.370점
- 프로젝트의 전반적인 만족도는 어떠셨나요? → 평균 약 4.259점
총 평균 약 4.173점
전체 7개 팀 중 몇 위인지는 모릅니다만, 스프린터분들께서 주신 피드백은 매우 건설적이었습니다.
소중한 유저라고 생각하며 유저의 목소리를 적극 반영하고자 저희 팀은 바로 UI/UX 개선에 들어갔습니다.
이에 제한적인 API 제공으로 인한 기술적 한계를 제외하고는 거의 90%
는 수용하여 개선을 하였습니다.
Lighthouse
기준 이미지에 의한 LCP 유발로 성능 저하가 발생하여, 이미지 최적화를 진행했고 최종적으로 다음과 같은 결과를 얻었습니다.
페이지 | 이전 성능 | 현재 성능 | 향상률(%) |
---|---|---|---|
랜딩 | 85 | 90 | 5.88 |
모임 찾기 | 80 | 84 | 5 |
모임 상세 | 80 | 85 | 6.25 |
찜한 모임 | 83 | 87 | 4.82 |
모든 리뷰 | 85 | 88 | 3.53 |
로그인 | 81 | 86 | 6.17 |
회원 가입 | 81 | 86 | 6.17 |
프로필 수정 | 88 | 89 | 1.17 |
마이페이지 | 82 | 85 | 3.66 |
평균 86.7점
아쉬운 점은 좀 더 높은 점수를 얻고 싶었는데 LCP에서 레거시 javascript 폴리필 이슈와, CSS 파일이 렌더링을 차단하는 문제를 해결하지 못했다는 점입니다. 이 문제도 해결할 수 있는 방법을 좀 더 강구해야 할 거 같습니다.
- Feature Based Architecture 도입으로 협업 관점에서 SPEEDY & STABLE한 D 77EE X 확보
- Async Surf 패턴을 창조하여 엄격하고 체계적인 비동기 흐름 관리 및 제어 구축
- React Hook Form과 Zod를 활용한 효율적인 폼 컴포넌트 렌더링 제어
- 유저의 시각적 관심을 유발하는 요소를 프로덕트 전반에 주입
- 스프린터들의 피드백을 95% 이상 반영한 UI/UX 개선
- 중간 평가에서 평균 4.17점이라는 높은 점수 달성 (5점 만점)
- 문의를 남길 수 있도록 구글폼 링크도 포함하여 소통 채널 개설
배운 점
- 시간 효율적 & 비용 효율적인 개발에 필요한 아키텍쳐의 중요성을 알 수 있었습니다.
- 사용자 피드백을 통한 실질적인 서비스 개선 경험이라는 값진 경험을 했습니다.
- 새로운 기술 스택을 활용하면서 기존에 사용했던 개발 방식보다 더 좋은 성과를 낼 수 있었습니다.
- 스크럼과 문서화 기반 협업 관리 방식이 팀의 코드 일관성과 개발 속도에 기여함을 알 수 있었습니다.
아쉬운 점 & 향후 계획
- 고정된 백엔드 API를 기반으로 진행하는 방식이라 한계로 인해 반영하지 못한 유저 피드백이 아쉽습니다.
- 지금 코드보다 더 좋은 코드 구조를 생각하지 못해서 아쉽습니다.
- LCP 점수를 감소시킨 CSS CRP 차단 문제와 폴리필 이슈를 해결하지 못해사 아쉽습니다.
기술적 완성도뿐만 아니라, 실제 사용자의 니즈를 파악하고 반영하는 과정의 중요성을 배웠습니다. 앞으로도 더 나은 DX를 위한 방법론에 대해 고민하며 공부하고, 유저친화적 UX를 제공하는 서비스를 만들어 나갈 것입니다.
묵묵히 따라와준 팀원 상훈님
기술적으로 조언을 해주신 멘토님
배포 과정에서 솔루션을 주신 강사님
© 2025 Meet Meet All rights reserved
Scripted By window-ook