[FEAT/#119] 담당 페이지 API 연동#120
Conversation
|
Thanks for the contribution! Please review the labels and make any necessary changes. |
There was a problem hiding this comment.
Code Review
This pull request integrates real API endpoints across student, partner, and admin dashboards, replacing previous mock implementations. Key changes include integrating report creation, review management, admin dashboard statistics, partnership suggestions, partner weekly rankings, and dynamic KakaoMap store markers. The review feedback focuses on adding defensive programming checks across various API adapters and hooks to prevent runtime crashes from missing or null response fields, as well as optimizing the KakaoMap component to avoid redundant marker updates when the array reference changes but the data remains identical.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| const appKey = process.env.EXPO_PUBLIC_KAKAO_JS_KEY ?? ""; | ||
| const webViewRef = useRef<WebView>(null); | ||
| const [isMapReady, setIsMapReady] = useState(false); |
There was a problem hiding this comment.
KakaoMap 컴포넌트가 렌더링될 때마다 markers 배열의 참조가 변경되어 불필요한 injectJavaScript 호출이 발생할 수 있습니다. 이전 마커 상태를 기억하기 위해 prevMarkersRef를 추가합니다.
| const appKey = process.env.EXPO_PUBLIC_KAKAO_JS_KEY ?? ""; | |
| const webViewRef = useRef<WebView>(null); | |
| const [isMapReady, setIsMapReady] = useState(false); | |
| const appKey = process.env.EXPO_PUBLIC_KAKAO_JS_KEY ?? ""; | |
| const webViewRef = useRef<WebView>(null); | |
| const prevMarkersRef = useRef<string>(""); | |
| const [isMapReady, setIsMapReady] = useState(false); |
| useEffect(() => { | ||
| if (!isMapReady) return; | ||
| const serializedMarkers = JSON.stringify(markers).replace( | ||
| /</g, | ||
| "\\u003c", | ||
| ); | ||
| webViewRef.current?.injectJavaScript( | ||
| `window.updateStoreMarkers(${serializedMarkers}); true;`, | ||
| ); | ||
| }, [isMapReady, markers]); |
There was a problem hiding this comment.
markers 배열의 참조가 변경되더라도 실제 데이터 내용이 동일하다면 WebView에 마커를 다시 그리는 무거운 작업을 방지해야 합니다. prevMarkersRef를 사용하여 문자열화된 마커 데이터를 비교하고, 변경된 경우에만 injectJavaScript를 호출하도록 최적화합니다.
useEffect(() => {
if (!isMapReady) return;
const serialized = JSON.stringify(markers).replace(/</g, "\\u003c");
if (prevMarkersRef.current === serialized) return;
prevMarkersRef.current = serialized;
webViewRef.current?.injectJavaScript(
`window.updateStoreMarkers(${serialized}); true;`,
);
}, [isMapReady, markers]);
| export function toAdminDashboardData({ | ||
| auth, | ||
| newCount, | ||
| todayUsage, | ||
| topUsage, | ||
| usageList, | ||
| }: { | ||
| auth: CountAdminAuthResponseDto; | ||
| newCount: NewCountAdminResponseDto; | ||
| todayUsage: CountUsagePersonResponseDto; | ||
| topUsage: CountUsageResponseDto; | ||
| usageList: CountUsageListResponseDto; | ||
| }): DashboardData { | ||
| const monthlyUsage: MonthlyUsage[] = usageList.items | ||
| .slice(0, 6) | ||
| .map((item) => ({ | ||
| month: item.storeName, | ||
| count: item.usageCount, | ||
| })); | ||
|
|
||
| return { | ||
| adminName: auth.adminName, | ||
| stats: { | ||
| appCertified: auth.studentCount, | ||
| newJoined: newCount.newStudentCount, | ||
| affiliateUsers: todayUsage.usagePersonCount, | ||
| }, | ||
| monthlyUsage, | ||
| insight: { | ||
| topStoreName: topUsage.storeName, | ||
| expectedCount: topUsage.usageCount, | ||
| }, | ||
| }; | ||
| } |
There was a problem hiding this comment.
API 응답 데이터가 비어있거나(null 또는 undefined) 특정 필드가 누락된 경우를 대비해 방어적 코드(Defensive Programming)를 적용하는 것이 안전합니다. 특히 topUsage가 null이거나 usageList.items가 없을 때 발생할 수 있는 런타임 에러를 방지합니다.
export function toAdminDashboardData({
auth,
newCount,
todayUsage,
topUsage,
usageList,
}: {
auth: CountAdminAuthResponseDto;
newCount: NewCountAdminResponseDto;
todayUsage: CountUsagePersonResponseDto;
topUsage: CountUsageResponseDto | null;
usageList: CountUsageListResponseDto;
}): DashboardData {
const monthlyUsage: MonthlyUsage[] = (usageList?.items ?? [])
.slice(0, 6)
.map((item) => ({
month: item.storeName,
count: item.usageCount,
}));
return {
adminName: auth?.adminName ?? "",
stats: {
appCertified: auth?.studentCount ?? 0,
newJoined: newCount?.newStudentCount ?? 0,
affiliateUsers: todayUsage?.usagePersonCount ?? 0,
},
monthlyUsage,
insight: {
topStoreName: topUsage?.storeName ?? null,
expectedCount: topUsage?.usageCount ?? 0,
},
};
}| function toReadableEnum(value: string): string { | ||
| return value | ||
| .split("_") | ||
| .map((word) => word.slice(0, 1) + word.slice(1).toLowerCase()) | ||
| .join(" "); | ||
| } | ||
|
|
||
| export function toSuggestion(dto: GetSuggestionResponseDto): Suggestion { | ||
| return { | ||
| id: String(dto.suggestionId), | ||
| storeName: dto.storeName, | ||
| department: | ||
| MAJOR_LABELS[dto.studentMajor] ?? toReadableEnum(dto.studentMajor), | ||
| studentStatus: STATUS_LABELS[dto.enrollmentStatus] ?? dto.enrollmentStatus, | ||
| content: dto.content, | ||
| createdAt: new Date(dto.createdAt), | ||
| }; | ||
| } |
There was a problem hiding this comment.
dto.studentMajor나 dto.enrollmentStatus 등 API 응답 필드가 누락되거나 null인 경우 toReadableEnum 내부에서 split 호출 시 런타임 에러가 발생할 수 있습니다. 안전한 기본값 제공 및 방어적 코드를 적용합니다.
| function toReadableEnum(value: string): string { | |
| return value | |
| .split("_") | |
| .map((word) => word.slice(0, 1) + word.slice(1).toLowerCase()) | |
| .join(" "); | |
| } | |
| export function toSuggestion(dto: GetSuggestionResponseDto): Suggestion { | |
| return { | |
| id: String(dto.suggestionId), | |
| storeName: dto.storeName, | |
| department: | |
| MAJOR_LABELS[dto.studentMajor] ?? toReadableEnum(dto.studentMajor), | |
| studentStatus: STATUS_LABELS[dto.enrollmentStatus] ?? dto.enrollmentStatus, | |
| content: dto.content, | |
| createdAt: new Date(dto.createdAt), | |
| }; | |
| } | |
| function toReadableEnum(value: string): string { | |
| if (!value) return ""; | |
| return value | |
| .split("_") | |
| .map((word) => word.slice(0, 1) + word.slice(1).toLowerCase()) | |
| .join(" "); | |
| } | |
| export function toSuggestion(dto: GetSuggestionResponseDto): Suggestion { | |
| return { | |
| id: String(dto.suggestionId), | |
| storeName: dto.storeName ?? "", | |
| department: | |
| MAJOR_LABELS[dto.studentMajor] ?? toReadableEnum(dto.studentMajor ?? ""), | |
| studentStatus: STATUS_LABELS[dto.enrollmentStatus] ?? dto.enrollmentStatus ?? "", | |
| content: dto.content ?? "", | |
| createdAt: dto.createdAt ? new Date(dto.createdAt) : new Date(), | |
| }; | |
| } |
| return res.data.result.map((item, index) => ({ | ||
| weekLabel: `${index + 1}주차`, | ||
| rank: item.rank, | ||
| usageCount: item.usageCount, | ||
| })); |
There was a problem hiding this comment.
서버로부터 주간 순위 데이터(res.data.result)가 없거나 null로 반환될 경우 .map() 호출 시 런타임 에러가 발생할 수 있습니다. 안전하게 빈 배열(?? [])을 기본값으로 사용하도록 수정합니다.
| return res.data.result.map((item, index) => ({ | |
| weekLabel: `${index + 1}주차`, | |
| rank: item.rank, | |
| usageCount: item.usageCount, | |
| })); | |
| return (res.data.result ?? []).map((item, index) => ({ | |
| weekLabel: `${index + 1}주차`, | |
| rank: item.rank, | |
| usageCount: item.usageCount, | |
| })); |
| return { | ||
| reviews: page.content.map(toReview), | ||
| totalElements: page.totalElements, | ||
| totalPages: page.totalPages, | ||
| page: page.number, | ||
| size: page.size, | ||
| isLast: page.last, | ||
| }; |
There was a problem hiding this comment.
API 응답의 result 또는 content가 누락되었을 때 발생할 수 있는 런타임 에러를 방지하기 위해 옵셔널 체이닝과 기본값을 적용하여 방어적으로 처리합니다.
| return { | |
| reviews: page.content.map(toReview), | |
| totalElements: page.totalElements, | |
| totalPages: page.totalPages, | |
| page: page.number, | |
| size: page.size, | |
| isLast: page.last, | |
| }; | |
| return { | |
| reviews: (page?.content ?? []).map(toReview), | |
| totalElements: page?.totalElements ?? 0, | |
| totalPages: page?.totalPages ?? 0, | |
| page: page?.number ?? 0, | |
| size: page?.size ?? 0, | |
| isLast: page?.last ?? true, | |
| }; |
| const stores = | ||
| storeSearchResult.status === "fulfilled" | ||
| ? pickList(storeSearchResult.value.data.result) | ||
| .map(toSearchResultStore) | ||
| .filter((store): store is SearchResultStore => store !== null) | ||
| : []; | ||
| const places = | ||
| placeSearchResult.status === "fulfilled" | ||
| ? (Array.isArray(placeSearchResult.value.data.result) | ||
| ? placeSearchResult.value.data.result | ||
| : [] | ||
| ).map(toPlaceSearchResult) | ||
| : []; |
There was a problem hiding this comment.
API 요청이 성공하더라도 value.data가 비어있을 경우 value.data.result 접근 시 에러가 발생할 수 있으므로 옵셔널 체이닝(data?.result)을 적용하는 것이 안전합니다.
| const stores = | |
| storeSearchResult.status === "fulfilled" | |
| ? pickList(storeSearchResult.value.data.result) | |
| .map(toSearchResultStore) | |
| .filter((store): store is SearchResultStore => store !== null) | |
| : []; | |
| const places = | |
| placeSearchResult.status === "fulfilled" | |
| ? (Array.isArray(placeSearchResult.value.data.result) | |
| ? placeSearchResult.value.data.result | |
| : [] | |
| ).map(toPlaceSearchResult) | |
| : []; | |
| const stores = | |
| storeSearchResult.status === "fulfilled" | |
| ? pickList(storeSearchResult.value.data?.result) | |
| .map(toSearchResultStore) | |
| .filter((store): store is SearchResultStore => store !== null) | |
| : []; | |
| const places = | |
| placeSearchResult.status === "fulfilled" | |
| ? (Array.isArray(placeSearchResult.value.data?.result) | |
| ? placeSearchResult.value.data.result | |
| : [] | |
| ).map(toPlaceSearchResult) | |
| : []; |
📝 요약
작업 내용
🔗 관련 이슈
체크리스트
💬 리뷰어에게
EXPO_PUBLIC_API_BASE_URL이 비어 있고assu.shopSwagger 접속이 timeout되어 실제 서버 응답 검증은 정확히 하지 못했습니다.__DEV__조건부 요청/응답 로그를 남겨두었습니다.