Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
워크스루스탬프 획득 기능을 구현합니다. 사용자가 스탬프 버튼을 클릭하면 로그인 확인 후 현재 위치를 수집하여 API로 전송하고, 응답 데이터와 함께 비디오 재생 페이지로 이동합니다. 실패 시 에러 팝업을 표시하고, 비디오 재생 페이지는 라벨 기반 위치 검색으로 변경됩니다. 변경 사항
시퀀스 다이어그램sequenceDiagram
participant User
participant PlaceNode as [placeId].tsx
participant LocationAPI as Location API
participant StampAPI as stampAcquire API
participant Router as Router
participant VideoPlay as videoPlay Page
User->>PlaceNode: Click stamp button
alt Not logged in
PlaceNode->>PlaceNode: Show login popup
User->>Router: Close login popup
Router->>Router: Redirect to auth
else Already completed
PlaceNode->>PlaceNode: No action
else Proceed to acquire
PlaceNode->>LocationAPI: Get current location
alt Location success
LocationAPI-->>PlaceNode: latitude, longitude
PlaceNode->>StampAPI: POST with placeId & location
alt Stamp acquired
StampAPI-->>PlaceNode: StampAcquireResponse
PlaceNode->>Router: Navigate to /main/videoPlay
Router->>VideoPlay: Load with postcard data
else Acquisition failed
StampAPI-->>PlaceNode: Error
PlaceNode->>PlaceNode: Show error popup
end
else Location failed
LocationAPI-->>PlaceNode: Error
PlaceNode->>PlaceNode: Log error (no crash)
end
end
예상 코드 리뷰 난이도🎯 3 (보통) | ⏱️ ~20분
관련 PR
제안 라벨
제안 검수자
시
Pre-merge checks and finishing touches❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✨ Finishing touches
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
🏷️ Labeler has automatically applied labels based on your PR title, branch name, or commit message. |
There was a problem hiding this comment.
Actionable comments posted: 7
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
src/pages/main/node/[placeId].tsx (1)
111-125: 뮤테이션 진행 중 버튼을 비활성화하세요.로딩 상태 동안 버튼의 시각적 피드백과 비활성화가 필요합니다.
<button aria-label={isCompleted ? '스탬프 획득 완료' : '스탬프 찍기'} className={cn( 'absolute bottom-0 right-0', isCompleted && 'p-[2.5rem]', + isAcquiring && 'opacity-50 cursor-not-allowed', )} onClick={handleStampClick} + disabled={isAcquiring} > <Icon name={isCompleted ? 'Stamp' : 'PressStamp'} color={isCompleted ? 'pink-400' : 'gray-50'} size={isCompleted ? 100 : 160} aria-hidden='true' /> </button>
🧹 Nitpick comments (2)
src/shared/api/main/node/queries/useStampAcquire.ts (1)
5-9: React Query 뮤테이션 훅이 올바르게 구현되었습니다.타입 정의와 뮤테이션 함수 위임이 적절합니다.
디버깅과 개발자 도구 사용성 향상을 위해
mutationKey를 추가하는 것을 고려하세요:export const useStampAcquire = () => { return useMutation<StampAcquireResponse, Error, { placeId: number; body: StampAcquireRequest }>({ + mutationKey: ['stampAcquire'], mutationFn: ({ placeId, body }) => postStampAcquire(placeId, body), }); };src/pages/main/node/[placeId].tsx (1)
57-57: 프로덕션 코드에서 console.log를 제거하세요.디버깅용 로그는 프로덕션 환경에서 제거되어야 합니다.
const placeIdNum = Number(placeId); - console.log('📍 현재 위치:', body); acquireStamp(
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (7)
src/pages/main/node/[placeId].tsx(4 hunks)src/pages/main/videoPlay/index.tsx(1 hunks)src/shared/api/main/node/api/stampAcquire.ts(1 hunks)src/shared/api/main/node/queries/useStampAcquire.ts(1 hunks)src/shared/api/main/node/types/stampAcquireTypes.ts(1 hunks)src/shared/constants/main/videoLocations.ts(1 hunks)src/shared/types/main/videoLocation.ts(0 hunks)
💤 Files with no reviewable changes (1)
- src/shared/types/main/videoLocation.ts
🧰 Additional context used
🧬 Code graph analysis (4)
src/shared/api/main/node/api/stampAcquire.ts (2)
src/shared/api/main/node/types/stampAcquireTypes.ts (2)
StampAcquireRequest(1-4)StampAcquireResponse(14-26)src/shared/api/instance.ts (1)
apiWithToken(14-17)
src/shared/api/main/node/queries/useStampAcquire.ts (2)
src/shared/api/main/node/types/stampAcquireTypes.ts (2)
StampAcquireResponse(14-26)StampAcquireRequest(1-4)src/shared/api/main/node/api/stampAcquire.ts (1)
postStampAcquire(4-13)
src/pages/main/node/[placeId].tsx (4)
src/shared/hooks/useUserStatus.ts (1)
useUserStatus(7-66)src/shared/api/main/node/queries/useStampAcquire.ts (1)
useStampAcquire(5-9)src/shared/utils/handleGetLocation.ts (1)
getLocation(1-15)src/shared/components/set/PopupSet.tsx (1)
PopupSet(11-35)
src/pages/main/videoPlay/index.tsx (2)
src/shared/constants/main/videoLocations.ts (1)
VIDEO_LOCATIONS(3-44)src/shared/components/main/components/video/VideoPlayer.tsx (1)
VideoPlayer(9-47)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
- GitHub Check: build-and-deploy
🔇 Additional comments (3)
src/shared/api/main/node/types/stampAcquireTypes.ts (1)
1-26: 타입 정의가 명확하고 올바릅니다.API 계약과 일치하는 타입 정의입니다.
video필드가 옵셔널로 처리되어 있어 안전합니다.src/shared/api/main/node/api/stampAcquire.ts (1)
4-13: API 헬퍼 구현이 올바릅니다.
apiWithToken을 사용한 인증된 요청과 타입 안전한 응답 처리가 적절합니다.placeId가number로 타입이 지정되어 있어 안전합니다.src/shared/constants/main/videoLocations.ts (1)
4-44: 백엔드 placeName 값과 라벨의 데이터 정합성을 확인하세요.검증 결과,
loc.label === placeName로 정확한 문자열 매칭을 하고 있습니다. 백엔드 API에서 반환하는postcard.placeName값이VIDEO_LOCATIONS의 라벨과 완벽히 일치하지 않으면 비디오가 재생되지 않습니다.현재 코드는 일치하는 위치를 찾지 못해도 VideoPlayer가 placeholder UI를 표시하므로 크래시는 발생하지 않지만, 사용자는 비디오 대신 "Video play" 텍스트를 보게 됩니다. 다음 사항을 확인하세요:
- 백엔드 API가 반환하는
placeName값이 정확히"부천아트벙커","다솔관","부천자유시장"등과 일치하는지 확인- 공백, 대소문자, 문장 부호의 차이가 없는지 검증
- 필요시 정규화(trim, 소문자 변환) 추가 고려
S3 URL 접근성은 정상 확인됨 (HTTP 200 OK).
| const handleStampClick = () => { | ||
| if (!isLoggedIn) { | ||
| setShowLoginPopup(true); | ||
| return; | ||
| } | ||
|
|
||
| if (isCompleted) return; |
There was a problem hiding this comment.
로그인 체크 전에 완료 상태를 확인하세요.
현재 로직에서는 사용자가 로그인하지 않으면 이미 완료된 스탬프인지 확인할 수 없습니다. UX 관점에서 완료 상태 확인을 먼저 수행하는 것이 더 적절합니다.
다음과 같이 순서를 변경하세요:
const handleStampClick = () => {
+ if (isCompleted) return;
+
if (!isLoggedIn) {
setShowLoginPopup(true);
return;
}
- if (isCompleted) return;
-
// 위치 가져와서 API 호출📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const handleStampClick = () => { | |
| if (!isLoggedIn) { | |
| setShowLoginPopup(true); | |
| return; | |
| } | |
| if (isCompleted) return; | |
| const handleStampClick = () => { | |
| if (isCompleted) return; | |
| if (!isLoggedIn) { | |
| setShowLoginPopup(true); | |
| return; | |
| } | |
| // 위치 가져와서 API 호출 |
🤖 Prompt for AI Agents
In src/pages/main/node/[placeId].tsx around lines 40 to 46, the current
handleStampClick checks login before completion so unauthenticated users can't
see that a stamp is already completed; change the order so you first check if
(isCompleted) and return early, then check if (!isLoggedIn) to show the login
popup — keep the existing returns and side effects otherwise.
| getLocation( | ||
| (pos) => console.log('📍 현재 위치:', pos.coords), | ||
| (err) => console.error('⚠️ 위치 에러:', err.message), | ||
| (pos) => { | ||
| const body = { | ||
| latitude: pos.coords.latitude, | ||
| longitude: pos.coords.longitude, | ||
| }; | ||
| const placeIdNum = Number(placeId); | ||
|
|
||
| console.log('📍 현재 위치:', body); | ||
|
|
||
| acquireStamp( | ||
| { placeId: placeIdNum, body }, | ||
| { | ||
| onSuccess: (res) => { | ||
| console.log('스탬프 획득 성공:', res.data); | ||
|
|
||
| const { postcard } = res.data; | ||
| const { hidden } = postcard; | ||
|
|
||
| // 항상 videoPlay로 이동하되, hidden이 true면 쿼리로 전달 | ||
| router.push({ | ||
| pathname: `/main/videoPlay`, | ||
| query: { | ||
| placeName: postcard.placeName, | ||
| ...(hidden ? { hidden: 'true' } : {}), | ||
| }, | ||
| }); | ||
| }, | ||
| onError: (err) => { | ||
| console.error('스탬프 획득 실패:', err); | ||
| setShowErrorPopup(true); | ||
| }, | ||
| }, | ||
| ); | ||
| }, | ||
| (err) => { | ||
| console.error('위치 정보를 가져올 수 없습니다:', err.message); | ||
| }, | ||
| ); |
There was a problem hiding this comment.
에러 타입별 처리와 로딩 상태를 추가하세요.
현재 구현의 문제점:
- 모든 API 에러에 대해 동일한 메시지("해당 위치를 다시 확인해 주세요")를 표시합니다. 400(위치 오류), 401(인증), 500(서버) 등을 구분해야 합니다.
- 위치 정보 가져오기 실패 시 사용자 피드백이 없습니다.
- 뮤테이션 진행 중 로딩 상태가 표시되지 않아 중복 요청 가능성이 있습니다.
다음 diff를 적용하세요:
+ const [isAcquiring, setIsAcquiring] = useState(false);
+ const [errorMessage, setErrorMessage] = useState('');
const { mutate: acquireStamp } = useStampAcquire();
// ...
const handleStampClick = () => {
if (isCompleted) return;
if (!isLoggedIn) {
setShowLoginPopup(true);
return;
}
+ if (isAcquiring) return; // 중복 요청 방지
+ setIsAcquiring(true);
+
// 위치 가져와서 API 호출
getLocation(
(pos) => {
const body = {
latitude: pos.coords.latitude,
longitude: pos.coords.longitude,
};
const placeIdNum = Number(placeId);
- console.log('📍 현재 위치:', body);
acquireStamp(
{ placeId: placeIdNum, body },
{
onSuccess: (res) => {
- console.log('스탬프 획득 성공:', res.data);
+ setIsAcquiring(false);
const { postcard } = res.data;
const { hidden } = postcard;
router.push({
pathname: `/main/videoPlay`,
query: {
placeName: postcard.placeName,
...(hidden ? { hidden: 'true' } : {}),
},
});
},
onError: (err) => {
- console.error('스탬프 획득 실패:', err);
- setShowErrorPopup(true);
+ setIsAcquiring(false);
+
+ // 에러 타입별 처리
+ if (err instanceof Error) {
+ const axiosError = err as any;
+ if (axiosError.response?.status === 400) {
+ setErrorMessage('해당 위치를 다시 확인해 주세요.');
+ } else if (axiosError.response?.status === 401) {
+ setErrorMessage('인증이 만료되었습니다. 다시 로그인해주세요.');
+ } else {
+ setErrorMessage('스탬프 획득에 실패했습니다. 다시 시도해주세요.');
+ }
+ } else {
+ setErrorMessage('알 수 없는 오류가 발생했습니다.');
+ }
+ setShowErrorPopup(true);
},
},
);
},
(err) => {
- console.error('위치 정보를 가져올 수 없습니다:', err.message);
+ setIsAcquiring(false);
+ setErrorMessage('위치 정보를 가져올 수 없습니다. 위치 권한을 확인해주세요.');
+ setShowErrorPopup(true);
},
);
};그리고 에러 팝업도 수정하세요:
{/* 위치 에러 팝업 */}
{showErrorPopup && (
<PopupSet
- text='해당 위치를 다시 확인해 주세요.'
+ text={errorMessage}
onClose={() => setShowErrorPopup(false)}
/>
)}Committable suggestion skipped: line range outside the PR's diff.
| onSuccess: (res) => { | ||
| console.log('스탬프 획득 성공:', res.data); | ||
|
|
||
| const { postcard } = res.data; | ||
| const { hidden } = postcard; | ||
|
|
||
| // 항상 videoPlay로 이동하되, hidden이 true면 쿼리로 전달 | ||
| router.push({ | ||
| pathname: `/main/videoPlay`, | ||
| query: { | ||
| placeName: postcard.placeName, | ||
| ...(hidden ? { hidden: 'true' } : {}), | ||
| }, | ||
| }); | ||
| }, |
There was a problem hiding this comment.
응답 데이터 구조를 검증하세요.
res.data.postcard 구조를 검증 없이 바로 접근하면 API 응답이 예상과 다를 때 런타임 에러가 발생할 수 있습니다.
다음과 같이 방어적 코딩을 추가하세요:
onSuccess: (res) => {
setIsAcquiring(false);
const { postcard } = res.data;
+
+ if (!postcard || !postcard.placeName) {
+ setErrorMessage('올바르지 않은 응답 형식입니다.');
+ setShowErrorPopup(true);
+ return;
+ }
+
const { hidden } = postcard;
router.push({
pathname: `/main/videoPlay`,
query: {
placeName: postcard.placeName,
...(hidden ? { hidden: 'true' } : {}),
},
});
},Committable suggestion skipped: line range outside the PR's diff.
🤖 Prompt for AI Agents
In src/pages/main/node/[placeId].tsx around lines 62 to 76, the handler accesses
res.data.postcard and postcard.hidden without validating the API response; add
defensive checks before using these fields by verifying res and res.data are
defined and that res.data.postcard is an object (or use optional chaining and a
safe fallback), log a useful error or return early if the structure is missing,
and only call router.push when postcard data (placeName) is present; keep the
existing behavior of adding hidden: 'true' to query only when postcard.hidden is
true.
| const { placeName, hidden } = router.query; | ||
|
|
||
| const handleVideoEnd = () => { | ||
| const isHiddenReward = Math.random() < 0.5; | ||
| const location = VIDEO_LOCATIONS.find( | ||
| (loc) => loc.label === placeName | ||
| ); |
There was a problem hiding this comment.
라우터 쿼리 파라미터의 타입 안전성을 강화하세요.
router.query의 값은 string | string[] | undefined 타입입니다. 현재 코드는 타입 검증 없이 placeName을 직접 사용하여 런타임 에러나 예기치 않은 동작이 발생할 수 있습니다.
다음 diff를 적용하여 타입 가드를 추가하세요:
export default function VideoPlayPage() {
const router = useRouter();
- const { placeName, hidden } = router.query;
+ const placeName = typeof router.query.placeName === 'string' ? router.query.placeName : undefined;
+ const hidden = typeof router.query.hidden === 'string' ? router.query.hidden : undefined;
+ if (!placeName) {
+ return (
+ <div className="flex items-center justify-center h-screen">
+ <p>잘못된 접근입니다.</p>
+ </div>
+ );
+ }
+
const location = VIDEO_LOCATIONS.find(
(loc) => loc.label === placeName
);📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const { placeName, hidden } = router.query; | |
| const handleVideoEnd = () => { | |
| const isHiddenReward = Math.random() < 0.5; | |
| const location = VIDEO_LOCATIONS.find( | |
| (loc) => loc.label === placeName | |
| ); | |
| const placeName = typeof router.query.placeName === 'string' ? router.query.placeName : undefined; | |
| const hidden = typeof router.query.hidden === 'string' ? router.query.hidden : undefined; | |
| if (!placeName) { | |
| return ( | |
| <div className="flex items-center justify-center h-screen"> | |
| <p>잘못된 접근입니다.</p> | |
| </div> | |
| ); | |
| } | |
| const location = VIDEO_LOCATIONS.find( | |
| (loc) => loc.label === placeName | |
| ); |
🤖 Prompt for AI Agents
In src/pages/main/videoPlay/index.tsx around lines 8 to 12,
router.query.placeName is typed as string | string[] | undefined but is used
directly; add a type guard that ensures placeName is a single string before
using it (e.g., check typeof placeName === 'string' and handle the
array/undefined cases), then pass that validated string to VIDEO_LOCATIONS.find;
if placeName is missing or an array, handle gracefully (fallback value, early
return, or error state) so runtime errors are prevented.
| const handleVideoEnd = () => { | ||
| if (hidden === 'true') { | ||
| router.push( | ||
| `/main/HiddenReward?place=${encodeURIComponent(placeName as string)}` | ||
| ); | ||
| } else { | ||
| router.push( | ||
| `/main/PostCard?place=${encodeURIComponent(placeName as string)}` | ||
| ); | ||
| } | ||
| }; |
There was a problem hiding this comment.
🛠️ Refactor suggestion | 🟠 Major
비디오 종료 핸들러에서 타입 캐스팅을 제거하세요.
placeName as string 캐스팅은 이전에 제안한 타입 가드를 적용하면 불필요해집니다. 타입 가드 적용 후 이 코드는 안전하게 동작합니다.
타입 가드 적용 후 다음과 같이 수정하세요:
const handleVideoEnd = () => {
if (hidden === 'true') {
router.push(
- `/main/HiddenReward?place=${encodeURIComponent(placeName as string)}`
+ `/main/HiddenReward?place=${encodeURIComponent(placeName)}`
);
} else {
router.push(
- `/main/PostCard?place=${encodeURIComponent(placeName as string)}`
+ `/main/PostCard?place=${encodeURIComponent(placeName)}`
);
}
};📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const handleVideoEnd = () => { | |
| if (hidden === 'true') { | |
| router.push( | |
| `/main/HiddenReward?place=${encodeURIComponent(placeName as string)}` | |
| ); | |
| } else { | |
| router.push( | |
| `/main/PostCard?place=${encodeURIComponent(placeName as string)}` | |
| ); | |
| } | |
| }; | |
| const handleVideoEnd = () => { | |
| if (hidden === 'true') { | |
| router.push( | |
| `/main/HiddenReward?place=${encodeURIComponent(placeName)}` | |
| ); | |
| } else { | |
| router.push( | |
| `/main/PostCard?place=${encodeURIComponent(placeName)}` | |
| ); | |
| } | |
| }; |
🤖 Prompt for AI Agents
In src/pages/main/videoPlay/index.tsx around lines 14 to 24, remove the
unnecessary "as string" casts in the router.push calls and use the already
type-guarded placeName directly (e.g., encodeURIComponent(placeName)) since the
earlier type guard ensures placeName is a string; update both
`/main/HiddenReward?place=...` and `/main/PostCard?place=...` calls to pass
encodeURIComponent(placeName) without casting and, if helpful for clarity,
compute a single encodedPlace const above the conditional and use it in both
branches.
| <VideoPlayer | ||
| src={location?.videoSrc} | ||
| label={location?.label} | ||
| label={location?.label || (placeName as string)} | ||
| onEnd={handleVideoEnd} | ||
| /> | ||
| </div> |
There was a problem hiding this comment.
location을 찾지 못한 경우 에러 처리를 추가하세요.
VIDEO_LOCATIONS에서 일치하는 label을 찾지 못하면 location이 undefined가 되어 비디오가 재생되지 않습니다. 사용자에게 명확한 피드백을 제공해야 합니다.
다음 diff를 적용하여 에러 상태를 처리하세요:
const location = VIDEO_LOCATIONS.find(
(loc) => loc.label === placeName
);
+
+ if (!location) {
+ return (
+ <div className="flex flex-col items-center justify-center h-screen gap-4">
+ <p className="text-body-lg">해당 장소의 영상을 찾을 수 없습니다.</p>
+ <button
+ onClick={() => router.back()}
+ className="px-4 py-2 bg-mint-500 text-white rounded"
+ >
+ 돌아가기
+ </button>
+ </div>
+ );
+ }
const handleVideoEnd = () => {Committable suggestion skipped: line range outside the PR's diff.
🤖 Prompt for AI Agents
In src/pages/main/videoPlay/index.tsx around lines 36 to 41, handle the case
where location is undefined before rendering VideoPlayer: check whether location
exists and if not set an error state or render a clear fallback UI (e.g., an
error message, a toast, or a redirect) that informs the user the video could not
be found; ensure you do not pass undefined to VideoPlayer by guarding the
component render (or using a conditional early return) and include any relevant
logging for debugging.
| <VideoPlayer | ||
| src={location?.videoSrc} | ||
| label={location?.label} | ||
| label={location?.label || (placeName as string)} |
There was a problem hiding this comment.
🛠️ Refactor suggestion | 🟠 Major
fallback 라벨에서 타입 캐스팅을 제거하세요.
이전에 제안한 타입 가드와 location 검증을 적용하면 이 캐스팅도 불필요합니다.
<VideoPlayer
src={location?.videoSrc}
- label={location?.label || (placeName as string)}
+ label={location.label}
onEnd={handleVideoEnd}
/>Committable suggestion skipped: line range outside the PR's diff.
🤖 Prompt for AI Agents
In src/pages/main/videoPlay/index.tsx around line 38, remove the explicit cast
"(placeName as string)" from the fallback label and instead rely on the earlier
type guard/validation for location/placeName; replace the expression with a safe
fallback such as location?.label ?? placeName (or String(placeName) only if
placeName can be non-string and you intentionally want coercion), and ensure
placeName is narrowed to string earlier (or validated) so no cast is needed.
🔥 작업 내용
Node([placeId].tsx)페이지 수정useGetPlaceDetail로 명소 상세 조회useStampAcquiremutation 연결 (/api/stamps/{placeId}/acquire)useUserStatus) 반영getLocation()으로 실시간 위치 정보 획득 및 API 호출PopupSet모달 표시 ("해당 위치를 다시 확인해 주세요.")hidden값(true/false)에 따라→
/main/videoPlay?placeName=...&hidden=true또는/main/videoPlay?placeName=...으로 분기 이동VideoPlayPage리팩토링placeName쿼리 기반으로VIDEO_LOCATIONS에서 영상 매칭hidden값에 따라→
HiddenReward또는PostCard페이지로 자동 이동VIDEO_LOCATIONS상수 구성label기반 매칭 구조로 단순화jwt-decode미설치) 수정🤔 추후 작업 사항
getLocation()좌표 테스트 및 반경 조건(100m) 세부 검증HiddenReward → PostCard간 데이터 전달 (router.query) 개선🔗 이슈
💬 PR Point (To Reviewer)
PopupSet모달 표시 로직이 자연스러운지 확인 부탁드립니다.VideoPlayPage에서placeName기준 매칭 구조가 정상적으로 작동하는지 검토 부탁드립니다.placeId기반 전환이 필요할지 의견 부탁드립니다.📸 기능 시연
Summary by CodeRabbit
릴리스 노트
새로운 기능
개선사항