Skip to content

Conversation

@Whatdoyumin
Copy link
Contributor

@Whatdoyumin Whatdoyumin commented Aug 19, 2025

✔️ 체크 리스트

  • Merge 하려는 브랜치가 올바른가? (main branch에 실수로 PR 생성 금지)
  • Merge 하려는 PR 및 Commit들을 로컬에서 실행했을 때 에러가 발생하지 않았는가?
  • 라벨을 등록했는가?
  • 리뷰어를 지정했는가?

Summary by CodeRabbit

  • New Features

    • 홈 상세: 드래그식 이미지 슬라이더, 전체화면 뷰, 썸네일·인디케이터, 이미지 카운터, 즐겨찾기 토글
    • 계약/채팅: 법령 안내 카드, 글로벌 로그인·확인 모달, 모바일 미지원 안내 화면, 실시간 알림 버튼·토스트
  • Improvements

    • 웹소켓 전송 신뢰성·재시도 및 대기 구독 처리 개선
    • 채팅 UI 대대적 개편(카카오 스타일, 반응형 두-팬 레이아웃, 라우터 연동)
    • 계약 흐름: 서명 카운트다운, 다운로드 동시성 제어, 상태 폴링 및 PDF 처리 개선
    • 메인 히어로 비디오 및 접근성(감소된 모션) 적용
  • Bug Fixes

    • CHAT/CONTRACT 네비게이션 쿼리 키 및 라벨 수정 (roomId, contract-chat 등)

@Whatdoyumin Whatdoyumin self-assigned this Aug 19, 2025
@Whatdoyumin Whatdoyumin added the ♻️ refactor 리팩토링 작업 label Aug 19, 2025
@coderabbitai
Copy link

coderabbitai bot commented Aug 19, 2025

Walkthrough

웹소켓 연결/전송 준비성과 pending 구독 처리를 재작성하고 send 계열을 async로 변경했습니다. 채팅 레이아웃·룸 쿼리 동기화, 카카오 스타일 UI, 이미지 갤러리(드래그·전체화면), 계약 서명/다운로드 폴링, 전역 모달·알림·FCM 및 라우터 가드·에러 페이지가 추가/수정되었습니다.

Changes

Cohort / File(s) Summary
WebSocket Core & Contract Export
src/apis/websocket.js
env 기반 wsUrl 선택, readiness 체크 변경, retryCount 30→5, 자동 connect, 1s 폴링 재시도, pendingSubscriptions 큐/flush/중복 방지, publish body JSON.stringify, 여러 send* 메서드 async화.
WebSocket Callers / Hooks
src/hooks/chat/useWebSocket.js, src/hooks/chat/useChatRoom.js
send 계열 래퍼 및 useChatRoom.sendMessage를 async로 변경(시그니처 변경).
Chat Routing & Layout
src/pages/chat/ChatPage.vue, src/components/chat/layout/ChatLayout.vue, src/components/chat/chatList/ChatList.vue, src/components/alarm/AlarmDropdown.vue
반응형 두-핀 레이아웃, roomId 쿼리 동기화(토글/푸시), 높이 관리 및 알림 클릭 경로 변경(/chat?roomId=...).
Chat Room UI / Message Handling
src/components/chat/chatRoom/ChatRoom.vue, src/components/chat/chatRoom/RoomNav.vue, src/components/chat/chatRoom/ChatInput.vue
카카오 스타일 메시지·버블·읽음 표시, CONTRACT_REQUEST 버튼 수직 배치·게이팅, URL 버튼 게이팅, back 이벤트 emit, WS 수신시 백업 알림 CustomEvent 발행.
Chat List / Item
src/components/chat/chatList/ChatList.vue, src/components/chat/chatList/ChatItem.vue
헤더/탭 교체, 라우터 통합, 카카오 스타일 리스트·선택·언리드 로직 개선, 시간/미리보기 보완.
Contract Chat & AI Flow
src/components/contract/chat/ContractChat.vue, src/components/contract/chat/ContractChatInput.vue, src/composables/chat/useChatAiButtons.js
LawTipMessage 추가, owner-edit-request/failed 이벤트 추가, 로딩 오버레이 분리, 서명 카운트다운·타이머, 낙관적 메시지 UI, currentStep 처리에 unref 적용.
Contract Completion & Download
src/pages/contract/ContractCompletePage.vue, src/components/contract/complete/ContractDownloadPanel.vue, src/components/contract/complete/DownloadItem.vue
서명 WS 우선·HTTP 폴백, 상태 폴링 강화, final PDF blob 다운로드, isDownloading으로 중복 차단, DownloadItem 이벤트/prop 변경(clickactivate, disabled).
APIs: Contract / Mypage / ExportStatus
src/apis/contractChatApi.js, src/apis/contractApi.js, src/apis/mypage.js
getExportStatus 실패시 null 반환, contractApi.moveContractChat 추가, mypage.deleteProperty 파라미터명·엔드포인트 변경(homeId 대상).
Homes: Pages & Image Gallery
src/pages/homes/**, src/components/homes/homedetails/ImageGallery.vue, .../ListingBasicInfo.vue, .../ListingCard.vue
갤러리 슬라이더·드래그·전체화면·썸네일·전환, 즐겨찾기(toggleHomeLike), 로딩/빈 상태 UI, images prop validator 추가, 레이블·인증 배지 표기.
Property Image / Item Normalization
src/components/common/PropertyImage.vue, src/components/common/PropertyItem.vue
PropertyImage src 타입 String→[String,Array], processedSrc 도입(S3/AWS/Unsplash 정규화), PropertyItem processedImageUrl computed 추가.
MyPage / Contracts / Properties
src/pages/mypage/**, src/components/mypage/contracts/ContractsCard.vue, src/pages/mypage/MyPageContracts.vue, src/pages/mypage/MyPageProperties.vue, src/stores/mypage.js
contracts 항목 형식 변경(contractId/chatRoomId/fileUrl 추가), handleView/handleDownload async·예외처리 강화, 삭제 UX(스피너·상세 에러), stores.deleteProperty에 로깅·응답 반환 추가.
Pre-Contract & Forms / Modals
src/components/pre-contract/**, src/components/pre-contract/owner/**, src/components/contract/modals/step3/FinalClauseSelectModal.vue
contractChatId로 homeId 조회 후 router.replace, 일부 boolean 초기값 false→null, FinalClauseSelectModal onSelect async화 및 finally에서 onClose 보장, 레이블/헬퍼 텍스트 수정.
Risk-check / Analysis UIs
src/components/risk-check/**, src/pages/risk-check/**
이미지 필드명 정합(imageUrl→image), 디버그 로그 제거, 이미지 소스 우선순위 확장, 일부 computed 제거/정리.
Global Modals / Auth / Notification / FCM
src/App.vue, src/components/common/LoginModal.vue, src/components/common/ConfirmModal.vue, src/composables/useLoginModal.js, src/composables/useConfirmModal.js, src/fcm/**, src/components/layouts/menu/NotificationButton.vue, src/main.js
전역 Login/Confirm 모달 및 composable 추가, FCM env 기반 구성·토큰 전송 개선, NotificationButton·알림 토스트 도입, DEV용 전역 테스트 헬퍼 등록.
Routing / Guards / Error Pages
src/router/index.js, src/router/guards.js, src/pages/error/**
authGuard 및 check 유틸 추가, 401/404/500 에러 페이지 라우트 추가, 전역 beforeEach 연결 및 특정 라우트 추가 검사.
Various UI/Text & Minor Behavior
여러 파일 (UserVerification, HomeCreatePage, HomeDetailsPage, HomeListPage, ChatItem 등)
문구 변경(인증/등록 텍스트), 모바일 게이트(Desktop-only 컴포넌트), 레이아웃·스타일·접근성 개선, 소규모 안전성/로그 정리.
Build / Env / Config
.env.example, Dockerfile, index.html, vite.config.js
VITE_* 예시 추가, Dockerfile에 Firebase ARG/ENV 추가, index.html global polyfill 추가, vite define global 및 proxy ws/api 설정 조정.

Sequence Diagram(s)

sequenceDiagram
  autonumber
  participant UI as Component
  participant WS as WebSocketService
  participant STOMP as STOMP Broker
  Note over UI,WS: 메시지 전송 시 연결 준비성 확인 및 재시도(최대 5회, 1s 폴링)
  UI->>WS: sendChatMessage(chatRoomId, senderId, receiverId, content)
  alt stompClient 없음 또는 미연결
    WS->>WS: await connect()
    WS->>STOMP: CONNECT
    STOMP-->>WS: CONNECTED
    WS->>WS: flush pendingSubscriptions → subscribe(topic)*
  end
  loop 최대 5회 (1s 간격)
    WS->>WS: ready? (stompClient?.connected && isConnected.value)
  end
  alt ready
    WS->>STOMP: PUBLISH destination, body=JSON.stringify(message)
    STOMP-->>WS: ACK
    WS-->>UI: return true
  else 실패
    WS-->>UI: return false (재시도 초과)
  end
Loading
sequenceDiagram
  autonumber
  participant User as User
  participant CCP as ContractCompletePage
  participant WS as WebSocketService
  participant API as HTTP API
  participant Poll as Poller
  Note over CCP: 서명 처리 — WS 우선 전송, HTTP 폴백 및 폴링으로 상태 확정
  User->>CCP: proceedToPassword(password)
  CCP->>WS: sendContractExportSignature(contractId, signatureData)
  alt WS 실패
    CCP->>API: save signature via HTTP
  else WS 성공
    CCP->>API: also update via HTTP
  end
  CCP->>Poll: startStatusPolling()
  loop 반복
    Poll->>API: getExportStatus(contractId)
    API-->>Poll: status (bothSigned?, finalPdfUrl?)
    alt bothSigned && finalPdfUrl
      Poll->>CCP: loadFinalPdf()
      CCP->>Poll: stopStatusPolling()
      CCP-->>User: 완료 알림 / 화면 전환
    end
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~70 minutes

Possibly related PRs

Suggested reviewers

  • leeedongjaee

Poem

(•ᴗ•)ʳᵃᵇᵇᶦᵗ
소켓 깡총 다섯 번 기다렸다 전송하고,
계약 두 번 찍으면 PDF 뽁—저장 완료.
방번호는 쿼리에 착착, 사진은 좌우로 슝—
찜 반짝, 알림은 쿵쾅, PR에 당근 한 입! 🥕

Tip

🔌 Remote MCP (Model Context Protocol) integration is now available!

Pro plan users can now connect to remote MCP servers from the Integrations page. Connect with popular remote MCPs such as Notion and Linear to add more context to your reviews and chats.

✨ Finishing Touches
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch refactor/all-ui

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.

❤️ Share
🪧 Tips

Chat

There are 3 ways to chat with CodeRabbit:

  • Review comments: Directly reply to a review comment made by CodeRabbit. Example:
    • I pushed a fix in commit <commit_id>, please review it.
    • Open a follow-up GitHub issue for this discussion.
  • Files and specific lines of code (under the "Files changed" tab): Tag @coderabbitai in a new review comment at the desired location with your query.
  • PR comments: Tag @coderabbitai in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
    • @coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.
    • @coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.

Support

Need help? Create a ticket on our support page for assistance with any issues or questions.

CodeRabbit Commands (Invoked using PR/Issue comments)

Type @coderabbitai help to get the list of available commands.

Other keywords and placeholders

  • Add @coderabbitai ignore anywhere in the PR description to prevent this PR from being reviewed.
  • Add @coderabbitai summary to generate the high-level summary at a specific location in the PR description.
  • Add @coderabbitai anywhere in the PR title to generate the title automatically.

CodeRabbit Configuration File (.coderabbit.yaml)

  • You can programmatically configure CodeRabbit by adding a .coderabbit.yaml file to the root of your repository.
  • Please see the configuration documentation for more information.
  • If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json

Status, Documentation and Community

  • Visit our Status Page to check the current availability of CodeRabbit.
  • Visit our Documentation for detailed information on how to use CodeRabbit.
  • Join our Discord Community to get help, request features, and share feedback.
  • Follow us on X/Twitter for updates and announcements.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

🔭 Outside diff range comments (3)
src/apis/websocket.js (2)

138-146: await 키워드 누락

sendChatMessage 함수에서 sendMessage 호출 시 await가 누락되었습니다. async 함수이므로 await를 추가해야 합니다.

  async sendChatMessage(chatRoomId, senderId, receiverId, content, type = 'TEXT', fileUrl = null) {
-    const success = this.sendMessage('/app/chat/send', {
+    const success = await this.sendMessage('/app/chat/send', {
      chatRoomId,
      senderId,
      receiverId,
      content,
      type,
      fileUrl,
    })
    return success
  }

170-178: await 키워드 누락

sendContractChatMessage 함수에서도 동일하게 await가 누락되었습니다.

  async sendContractChatMessage(contractChatId, senderId, receiverId, content, type = 'TEXT') {
-    const success = this.sendMessage('/app/contract/chat/send', {
+    const success = await this.sendMessage('/app/contract/chat/send', {
      contractChatId,
      senderId,
      receiverId,
      content,
      type,
    })
    return success
  }
src/components/chat/chatRoom/ChatRoom.vue (1)

608-615: sendChatMessage 비동기 호출에 await 추가 필요
src/apis/websocket.js에서 sendChatMessage(chatRoomId, senderId, receiverId, content, type, fileUrl)는 async 함수이며 Promise을 반환합니다. 따라서 ChatRoom.vue에서 호출 시 await 를 붙여야 합니다.

수정 예시 (src/components/chat/chatRoom/ChatRoom.vue:608–615):

- const success = websocketService.sendChatMessage(
+ const success = await websocketService.sendChatMessage(
    chatRoomId.value,
    currentUserId.value,
    receiverId,
    content,
    'TEXT',
    null,
)
  • 호출부가 속한 함수(또는 <script setup> 블록)가 async인지 확인하고, 필요 시 async 키워드를 추가해주세요.
🧹 Nitpick comments (16)
src/pages/homes/HomeCreatePage.vue (1)

141-143: 콘솔 로그 민감 정보 노출 최소화

주소/이미지 등 PII가 포함될 수 있어, 프로덕션에서는 로그를 출력하지 않도록 환경가드 적용을 권장합니다.

예시:

if (import.meta.env.DEV) {
  console.log('📦 최종 제출 데이터 (payload):', payload)
  console.log('🖼️ 업로드할 이미지 파일:', rawForm.images)
}
src/pages/homes/HomeUserVerification.vue (3)

1-1: 주석 오탈자 수정 제안: "HomeUserVerification"

파일 주석의 컴포넌트명이 "HomeUserVerifcation"으로 오탈자입니다. 검색성/일관성을 위해 수정 권장합니다.

-<!-- HomeUserVerifcation -->
+<!-- HomeUserVerification -->

4-7: 검증 UI 토글 로직 연결 누락 — v-if로 보여짐 상태와 연동

showVerificationfalse로 바꾸고 있지만 템플릿에서 해당 상태로 렌더링을 제어하지 않아 실제로는 숨겨지지 않습니다. UX 요건(인증 완료 시 UI 숨김)에 맞추어 v-if로 연결하고, 초기값을 true로 두는 방식을 제안합니다.

아래 변경으로 요구사항을 충족합니다:

-      <UserVerification @verified="handleVerified" mode="home" :home-id="homeId" />
+      <UserVerification v-if="showVerification" @verified="handleVerified" mode="home" :home-id="homeId" />
-const showVerification = ref(false)
+const showVerification = ref(true)
 
 function handleVerified() {
-  showVerification.value = false
+  showVerification.value = false
   alert('매물 등록이 완료되었습니다.')
   router.push('/homes')
 }

Also applies to: 18-24


24-26: 네비게이션 히스토리 관리: replace 사용 고려

인증 완료 후 목록으로 돌아갈 때 뒤로 가기 시 다시 인증 페이지로 진입되는 것을 방지하려면 push 대신 replace가 더 적합합니다.

-  router.push('/homes')
+  router.replace('/homes')
src/composables/chat/useChatAiButtons.js (1)

6-6: 0 또는 NaN이 기본 3으로 강제되는 문제 — fallback 로직 보완 제안

Number(unref(currentStep)) || 30도 거짓으로 처리되어 의도치 않게 3으로 대체될 수 있습니다. 또한 NaN도 동일하게 3으로 대체됩니다. 유효 범위 체크를 통해 명시적으로 기본값을 적용하는 편이 안전합니다.

현재 유효한 step 범위가 1~3이 맞는지 확인 부탁드립니다. 아래처럼 범위 클램핑을 권장합니다.

-  const stepNum = computed(() => Number(unref(currentStep)) || 3)
+  const stepNum = computed(() => {
+    const n = Number(unref(currentStep))
+    // step의 유효 범위를 알고 있다면 명시적으로 제한 (예: 1~3)
+    return Number.isFinite(n) && n >= 1 && n <= 3 ? n : 3
+  })
src/components/alarm/AlarmDropdown.vue (1)

188-205: 채팅 이동 쿼리 키(roomId) 변경은 적절합니다. push는 객체 기반으로 안전하게 구성 권장

문자열로 URL을 조립하면 인코딩/타입 변환 실수가 생길 수 있습니다. router.push({ path, query }) 형태를 사용하면 안전하고 가독성이 좋아집니다. 또한 숫자 ID를 문자열로 명시 변환하는 것이 라우터 일관성에 유리합니다.

Chat 라우트가 roomId 쿼리를 수신하도록 구성되어 있는지 최종 확인 부탁드립니다.

-    let targetUrl = null
+    let targetUrl = null
+    let targetQuery = undefined
@@
-    if (notification.type === 'CHAT' && notification.relatedId) {
-      // 채팅 알림 - 채팅방으로 이동
-      targetUrl = `/chat?roomId=${notification.relatedId}`
+    if (notification.type === 'CHAT' && notification.relatedId) {
+      // 채팅 알림 - 채팅방으로 이동
+      targetUrl = '/chat'
+      targetQuery = { roomId: String(notification.relatedId) }
     } else if (notification.type.includes('CONTRACT') && notification.relatedId) {
       // 계약 관련 알림 - 계약 페이지로 이동
       targetUrl = `/contract/${notification.relatedId}`
@@
-    if (targetUrl) {
-      await router.push(targetUrl)
-    }
+    if (targetUrl) {
+      if (targetQuery) {
+        await router.push({ path: targetUrl, query: targetQuery })
+      } else {
+        await router.push(targetUrl)
+      }
+    }
src/components/contract/modals/step3/FinalClauseSelectModal.vue (1)

68-74: 모달 닫기 보장을 위해 finally 사용: 중복 클릭 방지와 실패 시 UX 보완 제안

현재 실패해도 무조건 닫히므로 에러 노출 기회가 사라질 수 있고, 중복 클릭 시 중복 전송 위험이 있습니다. 전송 중 버튼 비활성화 플래그를 추가하면 안전합니다.

-const onSelect = async () => {
-  try {
-    await props.onSelect?.()
-  } finally {
-    props.onClose?.()
-  }
-}
+const onSelect = async () => {
+  submitting.value = true
+  try {
+    await props.onSelect?.()
+    // 성공 시 닫기 (실패 시에는 유지하여 사용자에게 재시도/오류 안내 기회 제공 고려)
+    props.onClose?.()
+  } catch (e) {
+    console.error('확인 처리 실패:', e)
+    // TODO: 토스트/알럿 등으로 사용자에게 실패 사유 안내를 고려
+  } finally {
+    submitting.value = false
+  }
+}

추가로 적용해야 하는 코드(선택):

// <script setup> 상단 어딘가
const submitting = ref(false)
<!-- template의 버튼에 비활성화/로딩 상태 바인딩 -->
<BaseButton
  variant="yellow"
  class="mt-6 w-full"
  @click="onSelect"
  :disabled="submitting"
>
  {{ submitting ? '처리 중...' : '확인' }}
</BaseButton>
src/components/homes/homedetails/ListingBasicInfo.vue (1)

20-20: maintenaceFee 오탈자 가능성 확인 요청

listing.maintenaceFeemaintenanceFee의 오탈자로 보입니다. API/스키마가 실제로 maintenaceFee를 사용한다면 유지하되, 아니라면 속성명 불일치로 런타임에서 undefined가 표시될 수 있습니다.

필드명이 확정되기 전까지는 안전하게 다음과 같이 폴백을 두는 방법도 있습니다(선택):

// 안전한 표시용 헬퍼
const fee = (l) => l?.maintenanceFee ?? l?.maintenaceFee ?? 0
// 템플릿
관리비 {{ formatNumber(fee(listing)) }}
src/components/pre-contract/owner/step3/SubStep2RestorationForm.vue (1)

95-105: null 값이 API에 그대로 전송될 가능성 — payload에서 불명확한 값은 제외 권장

백엔드가 null을 허용하지 않거나 의미가 다른 경우가 있습니다. 불리언 필드는 typeof === 'boolean'일 때만 포함하도록 안전하게 구성하는 것을 권장합니다. 전세가 아닐 때는 allowJeonseRightRegistration을 아예 제외하는 것도 명확합니다.

백엔드 스펙이 null 허용/비허용인지 확인 부탁드립니다.

 const patchContractStep2 = async () => {
   try {
-    await OwnerPreContractAPI.updateContractStep2(contractChatId, {
-      allowJeonseRightRegistration: allowJeonseRight.value,
-      hasAutoPriceAdjustment: hasAutoPriceAdjustment.value,
-      hasConditionLog: hasConditionLog.value,
-      hasPenalty: hasPenalty.value,
-      hasPriorityForExtension: hasPriorityExtension.value,
-      restoreCategories: restoreCategory.value,
-    })
+    const payload = {
+      restoreCategories: restoreCategory.value,
+    }
+    if (typeof hasConditionLog.value === 'boolean') payload.hasConditionLog = hasConditionLog.value
+    if (typeof hasPenalty.value === 'boolean') payload.hasPenalty = hasPenalty.value
+    if (typeof hasPriorityExtension.value === 'boolean') payload.hasPriorityForExtension = hasPriorityExtension.value
+    if (typeof hasAutoPriceAdjustment.value === 'boolean') payload.hasAutoPriceAdjustment = hasAutoPriceAdjustment.value
+    // 전세일 때만 포함
+    if (rentType.value === 'JEONSE' && typeof allowJeonseRight.value === 'boolean') {
+      payload.allowJeonseRightRegistration = allowJeonseRight.value
+    }
+    await OwnerPreContractAPI.updateContractStep2(contractChatId, payload)
   } catch (error) {
     console.log('계약 조건 Step2 업데이트 실패', error)
   }
 }
src/components/homes/homedetails/ImageGallery.vue (2)

89-91: 불필요한 props 검증 중복

props.images 존재 여부를 확인하는데, 이미 props validator에서 필수 배열이며 비어있지 않음을 보장하므로 이 체크는 불필요합니다.

 const nextImage = () => {
-  if (!props.images || currentIndex.value >= props.images.length - 1) return
+  if (currentIndex.value >= props.images.length - 1) return
   transitionName.value = 'slide-left'
   currentIndex.value += 1
 }

95-97: 불필요한 props 검증 중복

prevImage 함수에서도 동일하게 불필요한 props 검증이 있습니다.

 const prevImage = () => {
-  if (!props.images || currentIndex.value <= 0) return
+  if (currentIndex.value <= 0) return
   transitionName.value = 'slide-right'
   currentIndex.value -= 1
 }
src/pages/homes/HomeDetailsPage.vue (2)

77-77: 사용되지 않는 변수 제거

isCreatingChat 변수가 선언되었지만 사용되지 않습니다.

-const isCreatingChat = ref(false)

84-84: isCreatingChat 상태 관리 수정

isCreatingChat 변수를 제거하지 않으려면, 채팅방 생성 중 버튼 비활성화를 위해 활용할 수 있습니다.

       <BaseButton class="w-full" variant="primary" size="lg" @click="goToChat">
-        연락하기
+        {{ isCreatingChat ? '채팅방 생성 중...' : '연락하기' }}
       </BaseButton>

그리고 BaseButton에 disabled 속성도 추가하세요:

-      <BaseButton class="w-full" variant="primary" size="lg" @click="goToChat">
+      <BaseButton class="w-full" variant="primary" size="lg" @click="goToChat" :disabled="isCreatingChat">

Also applies to: 96-96

src/components/chat/layout/ChatLayout.vue (3)

10-19: 우측 패널에서 ChatRoom/플레이스홀더 높이 일관성 확보 제안

현재 우측 컨테이너는 flex flex-col min-h-0 overflow-hidden이지만, ChatRoom는 래퍼 없이 바로 렌더되고, 플레이스홀더는 h-full로 높이를 채웁니다. 컴포넌트별로 높이/스크롤 처리의 일관성이 깨질 수 있어, 두 경우 모두 flex-1 min-h-0 래퍼를 사용해 동일하게 영역을 점유하도록 권장합니다.

아래처럼 래퍼를 추가/수정하면 ChatRoom/플레이스홀더가 동일한 레이아웃 규칙을 따릅니다.

   <div class="flex-1 min-h-0 overflow-hidden flex flex-col">
     <!--  선택된 채팅방이 있을 때만 렌더링 -->
-    <ChatRoom
-      v-if="selectedRoom"
-      :room="selectedRoom"
-      :key="`room-${selectedRoom.chatRoomId}`"
-      @room-closed="handleRoomClosed"
-    />
-    <div v-else class="flex items-center justify-center h-full text-gray-500">
+    <div v-if="selectedRoom" class="flex-1 min-h-0">
+      <ChatRoom
+        :room="selectedRoom"
+        :key="`room-${selectedRoom.chatRoomId}`"
+        @room-closed="handleRoomClosed"
+      />
+    </div>
+    <div v-else class="flex flex-1 min-h-0 items-center justify-center text-gray-500">

참고:

  • ChatRoom 내부에서 자체 스크롤을 관리한다면, 부모는 overflow-hidden으로 충분합니다.
  • ChatRoom가 높이 가이드가 없다면, ChatRoom 루트에도 class="flex flex-col min-h-0 h-full"을 부여하는 쪽이 안전합니다.

156-160: console.log는 개발 환경에서만 출력하거나 로거로 위임 권장

배포 번들에 불필요한 로그가 남지 않도록 게이트하세요. Vite 환경이면 import.meta.env.DEV로 분기 가능합니다.

-  console.log('채팅방 선택 변경:', {
-    이전: selectedRoom.value?.chatRoomId,
-    현재: room?.chatRoomId,
-  })
+  if (import.meta.env.DEV) {
+    console.log('채팅방 선택 변경:', {
+      이전: selectedRoom.value?.chatRoomId,
+      현재: room?.chatRoomId,
+    })
+  }
-  console.log('채팅방이 닫혔습니다')
+  if (import.meta.env.DEV) {
+    console.log('채팅방이 닫혔습니다')
+  }

Also applies to: 166-167


169-173: 자식 메서드 직접 호출 대신 라우터/이벤트 기반으로 초기화 권장

chatListRef.value.setCurrentChatRoom(null)는 부모→자식의 명령형 의존을 만들고 캡슐화를 약화시킵니다. 요약에 따르면 ChatList가 라우터 쿼리(roomId)와 동기화되므로, URL 쿼리를 비워서(또는 제거해서) 반응적으로 초기화하는 편이 더 일관적입니다.

라인 범위 내 대체 예시:

-  // ChatList의 현재 채팅방 ID도 초기화
-  if (chatListRef.value && chatListRef.value.setCurrentChatRoom) {
-    chatListRef.value.setCurrentChatRoom(null)
-  }
+  // URL의 roomId 쿼리를 제거하면 ChatList가 반응적으로 초기화됩니다.
+  router
+    .replace({ query: { ...route.query, roomId: undefined } })
+    .catch(() => {})

위 변경에는 추가로 다음 준비가 필요합니다(선택 라인 외부 코드):

// 상단 import
import { useRouter, useRoute } from 'vue-router'

// <script setup> 상단 초기화
const router = useRouter()
const route = useRoute()

비고:

  • 만약 ChatList가 아직 defineExpose({ setCurrentChatRoom })로만 초기화 가능하다면, 현재 구현도 동작상 문제는 없습니다. 다만 장기적으로는 라우터/상태 기반 단방향 데이터 흐름을 권장합니다.
📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 8d8eabc and 29f27c7.

📒 Files selected for processing (19)
  • src/apis/websocket.js (3 hunks)
  • src/components/alarm/AlarmDropdown.vue (1 hunks)
  • src/components/chat/chatList/ChatList.vue (7 hunks)
  • src/components/chat/chatRoom/ChatRoom.vue (2 hunks)
  • src/components/chat/layout/ChatLayout.vue (1 hunks)
  • src/components/common/UserVerification.vue (0 hunks)
  • src/components/contract/chat/ContractChat.vue (14 hunks)
  • src/components/contract/chat/ContractChatInput.vue (5 hunks)
  • src/components/contract/modals/step3/FinalClauseSelectModal.vue (1 hunks)
  • src/components/homes/homedetails/ImageGallery.vue (2 hunks)
  • src/components/homes/homedetails/ListingBasicInfo.vue (1 hunks)
  • src/components/pre-contract/common/step1HomeInfo/Step1BasicInfo.vue (2 hunks)
  • src/components/pre-contract/owner/step3/SubStep2RestorationForm.vue (2 hunks)
  • src/composables/chat/useChatAiButtons.js (1 hunks)
  • src/pages/chat/ChatPage.vue (1 hunks)
  • src/pages/homes/HomeCreatePage.vue (1 hunks)
  • src/pages/homes/HomeDetailsPage.vue (4 hunks)
  • src/pages/homes/HomeListPage.vue (10 hunks)
  • src/pages/homes/HomeUserVerification.vue (2 hunks)
💤 Files with no reviewable changes (1)
  • src/components/common/UserVerification.vue
🧰 Additional context used
🧠 Learnings (2)
📚 Learning: 2025-08-11T06:26:31.790Z
Learnt from: Whatdoyumin
PR: ITZEEP/frontend#100
File: src/pages/homes/HomeUserVerification.vue:4-4
Timestamp: 2025-08-11T06:26:31.790Z
Learning: In src/pages/homes/HomeUserVerification.vue, the home-id="9" is temporarily hardcoded and will be replaced with the actual homeId from property registration once the integration is completed by another team member. This is a known temporary state awaiting dependency completion.

Applied to files:

  • src/pages/homes/HomeCreatePage.vue
  • src/pages/homes/HomeUserVerification.vue
  • src/components/pre-contract/common/step1HomeInfo/Step1BasicInfo.vue
📚 Learning: 2025-08-11T06:37:05.017Z
Learnt from: Whatdoyumin
PR: ITZEEP/frontend#100
File: src/router/homes.js:17-21
Timestamp: 2025-08-11T06:37:05.017Z
Learning: In src/router/homes.js, the /homes/create/verification route currently doesn't accept homeId as a parameter. This is known and planned to be fixed in a future PR to properly pass homeId from HomeCreatePage to HomeUserVerification page.

Applied to files:

  • src/pages/homes/HomeCreatePage.vue
🧬 Code Graph Analysis (1)
src/apis/websocket.js (1)
src/router/index.js (1)
  • contractChatId (45-45)
🔇 Additional comments (25)
src/pages/homes/HomeCreatePage.vue (1)

150-150: 카피 변경 LGTM

성공 알럿 문구를 절차적으로 더 명확히 안내하도록 업데이트된 점 좋습니다.

src/components/homes/homedetails/ListingBasicInfo.vue (1)

9-15: 비전세 케이스에 ‘보증금’ 라벨 추가 — 가독성 향상 좋습니다

월세형 상품에서 금액 레이블이 명확해져 사용자 이해도가 개선됩니다.

src/components/pre-contract/owner/step3/SubStep2RestorationForm.vue (2)

88-93: 초기값을 null로 전환하여 명시적 사용자 선택을 유도한 점 좋습니다

watch의 typeof === 'boolean' 검증과도 일관되어 초기 로드/미응답 상태에서 진행 불가를 명확히 표현합니다.


139-147: API 응답 파싱 기본값을 null로 설정한 부분은 OK — 문자열 불리언 대응 여부 확인

일부 API가 "true"/"false" 문자열을 반환하는 경우가 있어 그대로 null 병합하면 문자열이 남아 타입 검증에 걸릴 수 있습니다. 필요 시 불리언 보정 로직을 추가해 주세요.

예시:

const toBool = (v) => (v === true || v === 'true' ? true : v === false || v === 'false' ? false : null)

hasConditionLog.value = toBool(data.hasConditionLog)
hasPenalty.value = toBool(data.hasPenalty)
hasPriorityExtension.value = toBool(data.hasPriorityForExtension)
hasAutoPriceAdjustment.value = toBool(data.hasAutoPriceAdjustment)
allowJeonseRight.value = toBool(data.allowJeonseRightRegistration)
src/components/contract/chat/ContractChatInput.vue (3)

194-196: 새로운 이벤트 emit 추가 확인

owner-edit-requestowner-edit-failed 이벤트가 추가되었습니다. 이 이벤트들은 부모 컴포넌트에서 올바르게 처리되고 있는지 확인이 필요합니다.


388-400: 오버레이 상태 관리 개선

owner-edit 요청 시 즉시 부모 컴포넌트에 알려서 오버레이를 표시하는 패턴이 좋습니다. 실패 시에도 적절히 처리하고 있습니다.


347-347: 메시지 입력 초기화 로직 개선

result.success !== false 조건으로 변경되어 더 명확해졌습니다. result가 없거나 명시적으로 실패가 아닌 경우에만 입력을 초기화합니다.

src/components/chat/chatList/ChatList.vue (3)

13-13: UI 텍스트 개선

탭 라벨이 "임대인/임차인"에서 "내가 파는 매물/내가 사는 매물"로 변경되어 사용자 친화적으로 개선되었습니다.

Also applies to: 24-24


515-517: 화살표 함수 스타일 일관성

find 함수의 화살표 함수가 여러 줄로 변경되었으나 기능상 변화는 없습니다. 코드 가독성을 위한 포맷팅 변경으로 보입니다.

Also applies to: 524-526


178-181: URL 동기화 구현 확인 및 뒤로가기 버튼 호환성 테스트

ChatList.vue에서 roomId 쿼리가 router.push로 업데이트되고 있으며, ChatPage.vue에서는 route.query.roomId를 기반으로 초기 방을 설정합니다.
다음 위치를 참고하여 브라우저 뒤로가기 시 쿼리 변경이 의도대로 작동하는지 꼭 확인해 주세요.
필요하다면 router.push 대신 router.replace를 사용해 히스토리 엔트리 생성을 최소화할 수 있습니다.

• src/components/chat/chatList/ChatList.vue: 라인 178–181, 207–210
• src/pages/chat/ChatPage.vue: 라인 32–39

src/components/pre-contract/common/step1HomeInfo/Step1BasicInfo.vue (3)

60-90: homeId 쿼리 파라미터 자동 주입 로직

homeId가 없을 때 contractChatId로 계약 정보를 조회해서 homeId를 가져오고 URL에 추가하는 로직이 잘 구현되었습니다. 에러 처리도 적절합니다.


75-80: 중복 라우터 업데이트 방지

현재 쿼리의 homeId와 새로운 homeId를 비교하여 중복 업데이트를 방지하는 로직이 좋습니다.


57-57: let 변수 선언 확인

homeIdconst에서 let으로 변경되었습니다. 재할당이 필요한 경우이므로 적절한 변경입니다.

src/pages/homes/HomeListPage.vue (4)

40-46: 로딩 스켈레톤 UI 추가

로딩 중 스켈레톤 UI를 표시하여 사용자 경험이 개선되었습니다. 6개의 플레이스홀더 카드를 표시하는 것이 적절합니다.


69-69: 페이지네이션 버튼 비활성화 처리

로딩 중에 페이지네이션 버튼들을 비활성화하여 중복 요청을 방지하는 것이 좋습니다.

Also applies to: 79-79, 89-89


252-252: 안전한 API 응답 처리

response.totalElements || response.data?.length || 0로 totalItems를 설정하여 다양한 응답 형식을 안전하게 처리합니다.


195-195: finally 블록에서 로딩 상태 해제

try-catch-finally 패턴을 사용하여 에러 발생 시에도 로딩 상태가 해제되도록 보장합니다.

Also applies to: 263-265

src/apis/websocket.js (3)

93-100: 연결 실패 시 에러 처리 개선

STOMP 연결 실패 시 적절히 로그를 남기고 false를 반환하여 호출자가 실패를 감지할 수 있도록 개선되었습니다.


102-112: 폴링 기반 연결 대기 로직

재귀 호출 대신 while 루프와 폴링을 사용하여 연결을 대기하는 방식으로 변경되었습니다. 더 명확하고 디버깅하기 쉬운 구현입니다.


137-137: async 함수로 변경

sendChatMessagesendContractChatMessage가 async 함수로 변경되어 호출자가 await를 사용할 수 있게 되었습니다. API 일관성이 개선되었습니다.

Also applies to: 169-169

src/components/contract/chat/ContractChat.vue (1)

386-386: 타이머 정리 누락

signingTimer를 선언했지만 초기값이 없습니다. 명시적으로 null로 초기화하세요.

-let signingTimer = null
+let signingTimer = ref(null)

그리고 타이머 참조도 .value를 사용하도록 수정:

-  if (signingTimer) clearInterval(signingTimer)
+  if (signingTimer.value) clearInterval(signingTimer.value)

Likely an incorrect or invalid review comment.

src/pages/chat/ChatPage.vue (1)

2-7: 레이아웃 구조 개선됨

높이 계산을 명확하게 하고 overflow 처리를 개선한 것이 좋습니다.

src/components/chat/chatRoom/ChatRoom.vue (1)

94-99: UI 일관성 개선됨

CONTRACT_REQUEST 메시지의 버튼 레이아웃을 수직 정렬로 변경하고, 두 버튼 모두 outline 스타일을 적용한 것이 UI 일관성 측면에서 좋습니다.

src/components/chat/layout/ChatLayout.vue (2)

3-3: 좌측 패널 스크롤/높이 처리 적절합니다

min-h-0 + overflow-y-auto로 리스트 영역만 독립 스크롤되며, 부모 flex 컨텍스트에서 자식 높이 수축 문제도 방지됩니다. 변경 방향 좋습니다.


2-2: ChatLayout.vue: h-[calc(100dvh-64px)] 제거 후 h-full 사용

ChatPage에서 이미 상위 컨테이너(h-90dvh)로 높이를 제어하고, <ChatLayout class="h-full min-h-0">로 전달해주고 있으므로, 내부 calc 기반 높이는 중복됩니다. 다음과 같이 수정해주세요:

-  <div class="flex h-[calc(100dvh-64px)] min-h-0">
+  <div class="flex h-full min-h-0">

확인 사항

  • src/pages/chat/ChatPage.vue
    • 상위 <div class="h-90dvh">…<ChatLayout class="h-full min-h-0">
  • tailwind.config.js
    • spacing: { 'layout-x': '64px', … } 외 64px 하드코딩 없음
  • dvh 사용 충돌 없음

Comment on lines +445 to +454
hookMessages.value.push({
id: Date.now(), // 임시 키
_localId: (crypto?.randomUUID && crypto.randomUUID()) || `local-${Date.now()}`,
senderId: currentUserId.value,
receiverId: contractReceiverId.value,
content,
sendTime: new Date().toISOString(),
type: 'TEXT',
isRead: false,
})
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

낙관적 업데이트의 중복 ID 문제

낙관적 업데이트에서 id_localId를 동시에 사용하는데, id: Date.now()는 실제 서버 ID와 충돌할 수 있습니다.

     hookMessages.value.push({
-      id: Date.now(), // 임시 키
       _localId: (crypto?.randomUUID && crypto.randomUUID()) || `local-${Date.now()}`,
       senderId: currentUserId.value,
       receiverId: contractReceiverId.value,
       content,
       sendTime: new Date().toISOString(),
       type: 'TEXT',
       isRead: false,
     })
📝 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.

Suggested change
hookMessages.value.push({
id: Date.now(), // 임시 키
_localId: (crypto?.randomUUID && crypto.randomUUID()) || `local-${Date.now()}`,
senderId: currentUserId.value,
receiverId: contractReceiverId.value,
content,
sendTime: new Date().toISOString(),
type: 'TEXT',
isRead: false,
})
hookMessages.value.push({
_localId: (crypto?.randomUUID && crypto.randomUUID()) || `local-${Date.now()}`,
senderId: currentUserId.value,
receiverId: contractReceiverId.value,
content,
sendTime: new Date().toISOString(),
type: 'TEXT',
isRead: false,
})

const map = new Map()
a.forEach((m, i) => map.set(keyOf(m, i), m))
b.forEach((m, i) => map.set(keyOf(m, i + 10000), m))
b.forEach((m, i) => map.set(keyOf(m, i, 10000), m))
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

키 생성 로직 개선 필요

keyOf 함수에 세 번째 인자 10000을 전달하는 것은 함수 시그니처와 맞지 않으며, 의도가 불명확합니다.


🏁 Script executed:

#!/bin/bash
# keyOf 함수 정의 확인
ast-grep --pattern 'const keyOf = ($$$) => {
  $$$
}'

Length of output: 55


키 생성 로직 검증을 위해 keyOf 함수 정의 위치를 찾아야 합니다. 아래 스크립트를 실행해 주세요.


🏁 Script executed:

#!/bin/bash
# keyOf 함수 정의 검색
rg -nP --type ts --type js --type vue 'keyOf\s*\(' -C3
rg -nP --type ts --type js 'function\s+keyOf' -C3
rg -nP --type ts --type js 'const\s+keyOf\s*=' -C3

Length of output: 821


keyOf 함수 호출 인자 정리 및 로직 개선 필요

keyOf는 아래와 같이 두 개의 인자만 받도록 정의되어 있는데,
529행에서 세 번째 인자 10000을 전달하고 있어 의도와 맞지 않습니다.
키 생성 방식에 맞게 호출부를 수정하거나, 길이 제한이 필요하다면 keyOf를 확장하세요.

• 파일: src/components/contract/chat/ContractChat.vue
– 정의(515행):
js const keyOf = (m, i) => String(m?.id ?? m?.sendTime ?? m?._localId ?? m?.tempId ?? `tmp-${i}`)
– 잘못된 호출(529행):
diff - b.forEach((m, i) => map.set(keyOf(m, i, 10000), m)) + b.forEach((m, i) => map.set(keyOf(m, i), m))

🤖 Prompt for AI Agents
In src/components/contract/chat/ContractChat.vue around line 529, the call
b.forEach((m, i) => map.set(keyOf(m, i, 10000), m)) passes an unexpected third
argument to keyOf which is defined to accept only (m, i); either remove the
third argument so the call becomes keyOf(m, i), or change the keyOf definition
(around line 515) to accept a third parameter (e.g., maxLen) and implement
truncation/length limiting of the generated key before returning it; pick one
approach and apply the corresponding change so the function signature and calls
are consistent.

Comment on lines +615 to +621
onMounted(() => {
// 혹시 남아있던 타이머 정리
if (signingTimer) clearInterval(signingTimer)
})
onUnmounted(() => {
if (signingTimer) clearInterval(signingTimer)
})
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

타이머 정리 로직 개선

onMountedonUnmounted에서 타이머를 정리하는데, ref를 사용하도록 수정이 필요합니다.

 onMounted(() => {
   // 혹시 남아있던 타이머 정리
-  if (signingTimer) clearInterval(signingTimer)
+  if (signingTimer.value) {
+    clearInterval(signingTimer.value)
+    signingTimer.value = null
+  }
 })
 onUnmounted(() => {
-  if (signingTimer) clearInterval(signingTimer)
+  if (signingTimer.value) {
+    clearInterval(signingTimer.value)
+    signingTimer.value = null
+  }
 })
📝 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.

Suggested change
onMounted(() => {
// 혹시 남아있던 타이머 정리
if (signingTimer) clearInterval(signingTimer)
})
onUnmounted(() => {
if (signingTimer) clearInterval(signingTimer)
})
onMounted(() => {
// 혹시 남아있던 타이머 정리
if (signingTimer.value) {
clearInterval(signingTimer.value)
signingTimer.value = null
}
})
onUnmounted(() => {
if (signingTimer.value) {
clearInterval(signingTimer.value)
signingTimer.value = null
}
})
🤖 Prompt for AI Agents
In src/components/contract/chat/ContractChat.vue around lines 615 to 621, the
timer cleanup currently references a plain variable `signingTimer` but should
use a Vue ref; change the `signingTimer` declaration to a ref (e.g., ref<number
| null>(null)), update all accesses to use `signingTimer.value`, and in both
onMounted and onUnmounted call clearInterval(signingTimer.value) only if
signingTimer.value is not null and then set signingTimer.value = null so the
reactive ref is correctly updated and the timer cannot be leaked.

Comment on lines 5 to 22
<transition :name="transitionName" mode="default">
<img
v-if="images.length > 0"
:key="currentIndex"
:src="images[currentIndex]"
alt="매물 이미지"
class="w-full h-80 object-cover absolute top-0 left-0"
@error="handleImageError"
loading="lazy"
/>
<div
v-else
key="no-image"
class="w-full h-80 bg-gray-200 flex items-center justify-center text-gray-500 absolute top-0 left-0"
>
이미지 없음
</div>
</transition>
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

이미지 슬라이드 전환 모드 확인 필요

현재 transition 컴포넌트의 mode="default"는 유효하지 않은 값입니다. Vue 3에서 transition mode는 "out-in", "in-out", 또는 mode 속성 생략만 가능합니다.

-      <transition :name="transitionName" mode="default">
+      <transition :name="transitionName" mode="out-in">
🤖 Prompt for AI Agents
In src/components/homes/homedetails/ImageGallery.vue around lines 5 to 22, the
transition component uses an invalid mode value ("default"); replace
mode="default" with a valid Vue 3 mode ("out-in" or "in-out") or remove the mode
attribute entirely. Update the template to choose the appropriate mode for the
intended animation (use "out-in" to wait for the leave transition before enter,
"in-out" to delay leave until after enter) or simply delete the mode prop if no
specific behavior is required.

opacity: 0.5;
}

/* 왼쪽 → 오른쪽 (이전 이미지) */
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

주석 오타 수정

"왼쪽 → 오른쪽" 주석이 잘못되었습니다. slide-right는 "오른쪽 → 왼쪽" 방향입니다.

-/* 왼쪽 → 오른쪽 (이전 이미지) */
+/* 오른쪽 → 왼쪽 (이전 이미지) */
📝 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.

Suggested change
/* 왼쪽오른쪽 (이전 이미지) */
/* 오른쪽왼쪽 (이전 이미지) */
🤖 Prompt for AI Agents
In src/components/homes/homedetails/ImageGallery.vue around line 141, the inline
comment "왼쪽 → 오른쪽 (이전 이미지)" is incorrect for the slide-right direction; update
the comment to "오른쪽 → 왼쪽 (이전 이미지)" (or otherwise correct the arrow direction
text) so it accurately describes the slide-right behavior.

Comment on lines +150 to 152
alert('본인 인증 완료 후 등록이 완료됩니다.')
router.push(`/homes/create/${homeId}/verification`)
} catch (e) {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

검증 페이지 라우팅 파라미터 수용 여부 확인 필요

학습된 컨텍스트에 따르면 현재 /homes/create/verification 경로는 homeId 파라미터를 아직 받지 않는 것으로 기록되어 있습니다(이전 PR 계획 사항). 본 PR에서 router.push('/homes/create/${homeId}/verification')를 사용하므로, 해당 라우트가 이번 리팩토링에서 업데이트되었는지 확인이 필요합니다. 미반영 상태라면 네비게이션이 실패합니다.

제가 확인을 자동화할 수 있는 스크립트를 제공합니다. 기대 결과:

  • 경로 정의에 :homeId 세그먼트가 포함되어 있으면 OK.
  • 없다면 쿼리 방식으로 전달하도록 수정 필요.

쿼리 전송으로의 대안(라우터 업데이트가 아직이라면):

-    router.push(`/homes/create/${homeId}/verification`)
+    router.push({ path: '/homes/create/verification', query: { homeId: String(homeId) } })

🏁 Script executed:

#!/bin/bash
# 라우터 정의에서 /homes/create ... verification 경로를 점검
set -euo pipefail

echo "== 라우팅 정의에서 '/homes/create' 관련 경로 검색 =="
# 후보 라우터 파일들을 폭넓게 탐색
fd -a -t f -i "router" | tr '\n' '\0' | xargs -0 -I{} rg -nP -C3 'path\s*:\s*["'\'']/homes/create.*verification' "{}" || true

echo
echo "== 파라미터 세그먼트(:homeId 등) 포함 여부 점검 =="
fd -a -t f -i "router" | tr '\n' '\0' | xargs -0 -I{} rg -nP -C1 'path\s*:\s*["'\'']/homes/create/:\w+/verification' "{}" || true

echo
echo "참고: 위 결과에 파라미터 세그먼트가 없다면, 쿼리로 전달하는 방식을 고려하세요."

Length of output: 556


홈 생성 검증 페이지 라우팅 경로에 homeId 파라미터 미반영
지금 router.push(\/homes/create/${homeId}/verification`)를 호출하지만, 라우터 정의에서 :homeId` 세그먼트를 찾을 수 없어 네비게이션이 실패합니다. 아래 중 하나를 선택해 수정이 필요합니다.

• 라우터 정의 업데이트 (권장)
src/router/homes.js에서 경로를 다음과 같이 변경해 주세요.

 {
-    path: '/homes/create/verification',
+    path: '/homes/create/:homeId/verification',
   name: 'HomeUserVerification',
   component: HomeUserVerification,
+    props: true,
 }

• 쿼리 방식 임시 대체
라우터 정의를 당장 변경하기 어렵다면, push 시 쿼리로 homeId를 전달하도록 수정할 수 있습니다.

-    router.push(`/homes/create/${homeId}/verification`)
+    router.push({ path: '/homes/create/verification', query: { homeId: String(homeId) } })

위 중 하나를 적용해 네비게이션 오류를 해결해 주세요.

📝 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.

Suggested change
alert('본인 인증 완료 후 등록이 완료됩니다.')
router.push(`/homes/create/${homeId}/verification`)
} catch (e) {
alert('본인 인증 완료 후 등록이 완료됩니다.')
router.push({ path: '/homes/create/verification', query: { homeId: String(homeId) } })
} catch (e) {
🤖 Prompt for AI Agents
In src/pages/homes/HomeCreatePage.vue around lines 150 to 152, the router.push
call uses a path with a homeId segment but the router does not define a :homeId
param so navigation fails; fix by either (recommended) updating
src/router/homes.js to include the dynamic segment (e.g., add or change the
route to /homes/create/:homeId/verification) so
router.push(`/homes/create/${homeId}/verification`) matches, or if you cannot
change routes now, modify this push to pass homeId as a query parameter (e.g.,
router.push({ path: '/homes/create/verification', query: { homeId } })) so the
ID is transmitted without changing route definitions.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🔭 Outside diff range comments (4)
src/apis/websocket.js (1)

170-179: sendContractChatMessage의 await 누락

sendContractChatMessage가 async로 선언되었지만 sendMessage 호출 시 await를 사용하지 않습니다.

 async sendContractChatMessage(contractChatId, senderId, receiverId, content, type = 'TEXT') {
-  const success = this.sendMessage('/app/contract/chat/send', {
+  const success = await this.sendMessage('/app/contract/chat/send', {
     contractChatId,
     senderId,
     receiverId,
     content,
     type,
   })
   return success
 }
src/components/chat/chatRoom/ChatRoom.vue (3)

747-777: 계약 수락 로직에 busy 상태 관리 누락

handleAcceptContract 함수에서 acceptBusy 상태를 설정하지 않아 중복 클릭이 가능합니다.

+const acceptBusy = ref(false)
+const rejectBusy = ref(false)
+
 const handleAcceptContract = async () => {
   if (!props.room?.chatRoomId) {
     console.error('채팅방 ID가 없습니다.')
     return
   }
+  
+  if (acceptBusy.value) return
+  acceptBusy.value = true

   try {
     const response = await acceptContract(props.room.chatRoomId)
     // ... existing code ...
   } catch (error) {
     console.error('계약 수락 중 오류 발생:', error)
     alert('계약 수락 중 오류가 발생했습니다.')
+  } finally {
+    acceptBusy.value = false
   }
 }

또한 handleRejectContract 함수가 정의되지 않았습니다:

const handleRejectContract = async () => {
  if (!props.room?.chatRoomId) {
    console.error('채팅방 ID가 없습니다.')
    return
  }
  
  if (rejectBusy.value) return
  rejectBusy.value = true

  try {
    // TODO: rejectContract API 호출 구현
    alert('계약이 거절되었습니다.')
  } catch (error) {
    console.error('계약 거절 중 오류 발생:', error)
    alert('계약 거절 중 오류가 발생했습니다.')
  } finally {
    rejectBusy.value = false
  }
}

260-268: ref 변수 선언 누락

템플릿에서 사용되는 acceptBusyrejectBusy ref 변수가 스크립트에 선언되지 않았습니다.

Line 311 근처에 다음 변수들을 추가하세요:

 // 계약 수락 시 채팅방 성공
 const isSuccessBuildContract = ref(false)
 const contractRoomId = ref('')
+const acceptBusy = ref(false)
+const rejectBusy = ref(false)

634-641: sendChatMessage 호출 시 await 누락

websocketService.sendChatMessage가 이제 async 함수인데 await 없이 호출되고 있습니다.

 try {
   isSendingMessage.value = true
-  const success = websocketService.sendChatMessage(
+  const success = await websocketService.sendChatMessage(
     chatRoomId.value,
     currentUserId.value,
     receiverId,
     content,
     'TEXT',
     null,
   )
🧹 Nitpick comments (9)
src/pages/HomePage.vue (4)

4-4: 모바일 주소창 변동으로 인한 VH 점프 방지 — svh 보완 클래스 추가 권장

모바일 환경(iOS/안드로이드)에서 vh만 사용 시 스크롤/주소창 표시 변화에 따라 히어로 높이가 튀는 현상이 있습니다. svh를 함께 선언해 최신 브라우저에서 안정적인 높이를 적용하고, 구형 브라우저는 vh가 폴백되도록 하세요.

-    <section class="relative overflow-hidden h-[70vh] md:h-[80vh]">
+    <section class="relative overflow-hidden h-[70vh] md:h-[80vh] h-[70svh] md:h-[80svh]">

31-34: 히어로 본문 텍스트 대비(접근성) 확인 필요

비디오 위에 text-gray-800/90는 배경 장면에 따라 대비가 충분치 않을 수 있습니다. 시인성 강화를 위해 text-white/90 등 더 높은 대비를 고려하세요. 최소 WCAG AA 대비를 충족하는지 검증 부탁드립니다.

-            <p class="text-lg md:text-xl text-gray-800/90 mb-8 ml-auto drop-shadow">
+            <p class="text-lg md:text-xl text-white/90 mb-8 ml-auto drop-shadow">

검증 팁:

  • 다양한 히어로 프레임(밝은/어두운 장면)에서 스크린샷 후 대비 체크.
  • 필요 시 backdrop-brightness/bg-black/40 오버레이 강도 조정.

47-79: AI 요약과 불일치: Trust Indicators 블록이 여전히 존재합니다

AI 요약에는 “Trust Indicators 아이콘 제거”로 기재되어 있으나, 본 코드에는 해당 블록과 아이콘이 유지되어 있습니다. 문서/릴리즈 노트 정합성 위해 요약 또는 코드 중 하나를 정리하세요. 또한 아이콘은 장식 목적이므로 스크린리더 제외를 위해 aria-hidden="true"를 부여하는 것이 안전합니다. 중복 구조는 컴포넌트화(BaseIcon 등)로 간결화도 고려하세요.

접근성 보완 제안(diff):

-                <svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
+                <svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
@@
-                <svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
+                <svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
@@
-                <svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
+                <svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">

구조 리팩터링(선택):

  • 목록 의미 전달을 위해 <ul><li>로 교체.
  • 반복되는 아이콘/레이블을 배열로 맵 렌더하거나 공용 컴포넌트로 추출.

280-287: prefers-reduced-motion 처리 방식 보완 필요 — 실제 비디오 재생은 멈추지 않습니다

animation: none은 CSS 애니메이션만 제거할 뿐, <video>의 재생을 멈추지 않습니다. reduced-motion 사용 시 비디오를 숨기는 접근이 더 적절합니다. 위에서 제안한 hero-video 클래스를 활용해 display를 끄도록 변경하세요.

-@media (prefers-reduced-motion: reduce) {
-  video {
-    animation: none !important;
-  }
-}
+@media (prefers-reduced-motion: reduce) {
+  .hero-video {
+    display: none !important;
+  }
+}

참고:

  • 네트워크 절약을 위해 preload="metadata"(위 diff 적용)도 병행.
  • 필요 시 reduced-motion 환경에서 정적 배경(poster 또는 배경 이미지)만 노출.
src/apis/websocket.js (4)

94-101: 연결 실패 시 에러 처리 개선 필요

connect() 호출이 실패했을 때 단순히 false를 반환하는 것보다 에러를 상위로 전파하거나 재시도 로직을 추가하는 것이 좋습니다.

 if (!this.stompClient || !this.stompClient.connected) {
   try {
     await this.connect()
   } catch (e) {
     console.error('STOMP 연결 실패:', e)
-    return false
+    throw new Error(`STOMP 연결 실패: ${e.message}`)
   }
 }

103-113: 폴링 로직의 타임아웃 개선 필요

현재 폴링 로직은 30회 * 200ms = 6초의 고정 타임아웃을 가지고 있습니다. 이는 네트워크 상황에 따라 너무 짧을 수 있습니다.

설정 가능한 타임아웃과 지수 백오프를 고려해보세요:

-async sendMessage(destination, message, retryCount = 30) {
+async sendMessage(destination, message, retryCount = 30, retryDelay = 200) {
   // ... existing code ...
   
   let attempts = retryCount
+  let currentDelay = retryDelay
   while ((!this.stompClient || !this.stompClient.connected) && attempts > 0) {
     console.warn(`STOMP 연결 대기... (남은 시도: ${attempts})`)
-    await new Promise((r) => setTimeout(r, 200))
+    await new Promise((r) => setTimeout(r, currentDelay))
+    currentDelay = Math.min(currentDelay * 1.5, 2000) // 지수 백오프, 최대 2초
     attempts--
   }

138-148: async/await 패턴 일관성 개선

sendChatMessage가 async로 변경되었지만 단순히 boolean을 반환합니다. 에러 처리를 위해 더 풍부한 응답을 반환하는 것이 좋습니다.

 async sendChatMessage(chatRoomId, senderId, receiverId, content, type = 'TEXT', fileUrl = null) {
-  const success = await this.sendMessage('/app/chat/send', {
-    chatRoomId,
-    senderId,
-    receiverId,
-    content,
-    type,
-    fileUrl,
-  })
-  return success
+  try {
+    const success = await this.sendMessage('/app/chat/send', {
+      chatRoomId,
+      senderId,
+      receiverId,
+      content,
+      type,
+      fileUrl,
+    })
+    return { success, error: null }
+  } catch (error) {
+    return { success: false, error: error.message }
+  }
 }

233-240: 구독 로직의 중복 검사 개선

subscribeToTopic에서 pending subscriptions 중복 체크 로직이 반복됩니다. 헬퍼 메서드로 추출하면 좋습니다.

+  addPendingSubscription(topic, handler) {
+    if (!this.pendingSubscriptions.find((p) => p.topic === topic)) {
+      this.pendingSubscriptions.push({ topic, handler })
+    }
+  }
+
   subscribeToTopic(topic, handler) {
     if (!this.stompClient || !this.stompClient.connected || !this.isConnected.value) {
       console.warn('구독 불가 - STOMP 연결 상태 불안정')
-      if (!this.pendingSubscriptions.find((p) => p.topic === topic)) {
-        this.pendingSubscriptions.push({ topic, handler })
-      }
+      this.addPendingSubscription(topic, handler)
       return
     }
src/components/chat/chatRoom/ChatRoom.vue (1)

530-563: 과도한 스크롤 처리 로직

forceScrollToBottom 함수에서 여러 번의 setTimeout과 requestAnimationFrame 호출이 과도합니다. 이는 성능 문제를 일으킬 수 있습니다.

 function forceScrollToBottom() {
   if (!messagesContainer.value) return

   const container = messagesContainer.value
-  
-  // 즉시 스크롤
-  container.scrollTop = container.scrollHeight
-  
-  // Vue의 반응성 업데이트 후 스크롤
-  nextTick(() => {
-    container.scrollTop = container.scrollHeight
-    
-    setTimeout(() => {
-      container.scrollTop = container.scrollHeight
-    }, 10)
-    
-    setTimeout(() => {
-      container.scrollTop = container.scrollHeight
-    }, 50)
-    
-    setTimeout(() => {
-      container.scrollTop = container.scrollHeight
-    }, 100)
-  })
-  
-  // 애니메이션 프레임 사용으로 렌더링 완료 후 스크롤
-  requestAnimationFrame(() => {
-    container.scrollTop = container.scrollHeight
-    
-    requestAnimationFrame(() => {
-      container.scrollTop = container.scrollHeight
-    })
-  })
+  
+  // 즉시 스크롤 시도
+  container.scrollTop = container.scrollHeight
+  
+  // DOM 업데이트 후 한 번 더 시도
+  nextTick(() => {
+    container.scrollTop = container.scrollHeight
+  })
+  
+  // 최종 보장을 위한 단일 지연 실행
+  requestAnimationFrame(() => {
+    container.scrollTop = container.scrollHeight
+  })
 }
📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 29f27c7 and dc666f1.

⛔ Files ignored due to path filters (1)
  • public/panda&lion.mp4 is excluded by !**/*.mp4
📒 Files selected for processing (3)
  • src/apis/websocket.js (4 hunks)
  • src/components/chat/chatRoom/ChatRoom.vue (3 hunks)
  • src/pages/HomePage.vue (6 hunks)
🔇 Additional comments (5)
src/pages/HomePage.vue (2)

97-99: 카피 업데이트 방향성 적절합니다

간결하고 명확한 메시지로 개선되었고, 톤/포지셔닝이 일관적입니다. 별도 이슈 없음.

Also applies to: 146-148


261-269: CTA 버튼 마크업 정리 LGTM

버튼 속성/크기/반응형 폭 처리가 일관적입니다. 접근성 면에서 버튼 텍스트가 충분히 구체적이라 별도 aria-label 없이도 괜찮습니다.

src/apis/websocket.js (1)

11-11: 핸들러 맵 추가 승인

토픽별 핸들러를 추적하는 handlersByTopic Map 추가는 구독 관리를 더 명확하게 만듭니다.

src/components/chat/chatRoom/ChatRoom.vue (2)

94-112: 계약 요청 UI 개선 승인

CONTRACT_REQUEST 메시지의 수직 버튼 레이아웃과 비활성화 로직 추가가 UX를 개선합니다.


116-117: 읽음 표시 색상 변경 승인

사용자가 보낸 메시지의 읽음 표시를 흰색으로 변경한 것이 노란색 배경과 더 잘 어울립니다.

Comment on lines +6 to +13
<video
class="pointer-events-none absolute left-0 top-0 h-full w-full object-cover xl:w-[60vw] 2xl:w-[60vw]"
src="/panda&lion.mp4"
autoplay
muted
loop
playsinline
></video>
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

영상 src의 '&' 문자로 인해 자산 로드/번들 실패 가능 — 파일명 변경 또는 URL 인코딩 필요

HTML 속성에서 &는 쿼리 파라미터 구분자로 간주될 수 있어 브라우저/번들러(Vite)에서 예기치 않은 파싱이 발생할 수 있습니다. 실제 배포 시 비디오가 로드되지 않거나 HMR 에셋 재작성 시 깨질 수 있습니다. 파일명을 변경하거나 %26으로 인코딩하세요. 또한 접근성/성능 보완을 위해 poster, preload="metadata", aria-hidden을 추가하고, 이후 reduced-motion 대응을 위해 전용 클래스도 부여합니다.

적용 제안(diff):

-      <video
-        class="pointer-events-none absolute left-0 top-0 h-full w-full object-cover xl:w-[60vw] 2xl:w-[60vw]"
-        src="/panda&lion.mp4"
-        autoplay
-        muted
-        loop
-        playsinline
-      ></video>
+      <video
+        class="hero-video pointer-events-none absolute left-0 top-0 h-full w-full object-cover xl:w-[60vw] 2xl:w-[60vw]"
+        src="/panda-lion.mp4"
+        poster="/hero-fallback.jpg"
+        preload="metadata"
+        autoplay
+        muted
+        loop
+        playsinline
+        aria-hidden="true"
+      ></video>

추가 확인:

  • 실제 파일명을 panda-lion.mp4(또는 panda%26lion.mp4)로 정리했는지 확인 필요.
  • poster 자산 존재 여부 확인 필요. 없으면 제거하거나 올바른 경로로 교체하세요.
📝 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.

Suggested change
<video
class="pointer-events-none absolute left-0 top-0 h-full w-full object-cover xl:w-[60vw] 2xl:w-[60vw]"
src="/panda&lion.mp4"
autoplay
muted
loop
playsinline
></video>
<video
class="hero-video pointer-events-none absolute left-0 top-0 h-full w-full object-cover xl:w-[60vw] 2xl:w-[60vw]"
src="/panda-lion.mp4"
poster="/hero-fallback.jpg"
preload="metadata"
autoplay
muted
loop
playsinline
aria-hidden="true"
></video>
🤖 Prompt for AI Agents
In src/pages/HomePage.vue around lines 6 to 13, the video src uses an ampersand
which can break asset loading/bundling; rename the file to remove '&' (e.g.
panda-lion.mp4) or URL-encode it to panda%26lion.mp4 and update the src
accordingly, and add accessibility/performance attributes: include poster (or
remove if not available), set preload="metadata", add aria-hidden="true" if
purely decorative, and add a CSS class for reduced-motion handling; finally
verify the actual asset filename/path in the repo matches the updated src.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (2)
src/hooks/chat/useChatRoom.js (1)

83-119: try/catch 내 비동기 호출에 await 누락 — 예외 포착되지 않음(선택 개선)

현재 return으로 Promise를 그대로 전달하므로 catch 블록에서 비동기 예외를 잡지 못합니다. 서비스가 현재는 true/false로 resolve만 하지만, 향후 reject가 생길 때를 대비해 await를 권장합니다.

-    try {
-      return websocketService.sendChatMessage(
+    try {
+      const ok = await websocketService.sendChatMessage(
         chatRoomId.value,
         currentUserId.value,
         receiverId,
         content,
         type,
         fileUrl,
-      )
+      )
+      return ok
     } catch (error) {
       console.error('메시지 전송 중 오류:', error)
       return false
     }
src/hooks/chat/useWebSocket.js (1)

24-26: 불필요한 async 키워드(선택 사항)

websocketService.sendMessage가 이미 Promise를 반환하므로, 여기서 async는 필수는 아닙니다. 유지해도 문제는 없으나 간결성을 위해 제거 가능.

📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between dc666f1 and d712614.

📒 Files selected for processing (3)
  • src/apis/websocket.js (4 hunks)
  • src/hooks/chat/useChatRoom.js (1 hunks)
  • src/hooks/chat/useWebSocket.js (1 hunks)
🧰 Additional context used
🧬 Code Graph Analysis (2)
src/hooks/chat/useChatRoom.js (1)
src/hooks/chat/useWebSocket.js (1)
  • sendMessage (24-26)
src/hooks/chat/useWebSocket.js (2)
src/hooks/chat/useChatRoom.js (1)
  • sendMessage (84-119)
src/apis/websocket.js (2)
  • websocketService (299-299)
  • websocketService (299-299)
🔇 Additional comments (3)
src/apis/websocket.js (2)

139-149: 채팅 전송 API async 승격과 반환값 일관성 확보는 적절합니다

await 기반 상위 호출부와의 합치가 좋아졌고, 성공 여부를 boolean으로 반환하는 계약이 명확합니다.


171-180: 계약 채팅 전송 async 승격 LGTM

동일한 패턴으로 일관성 유지되어 있습니다.

src/hooks/chat/useWebSocket.js (1)

33-47: 계약 채팅 래퍼 async 승격 OK

서비스 시그니처와 일치하며, 반환 Promise를 그대로 전달하는 단순 래퍼로 적절합니다.

Comment on lines 10 to 11
this.messageHandlers = new Map() // topic -> subscription
this.handlersByTopic = new Map() // topic -> handler ✅ 추가
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

⚠️ Potential issue

handlersByTopic 미사용 · 재연결 후 구독 유실 가능성(단일 구독/핸들러 설계 한계)

현재는 topic당 하나의 STOMP subscription만 저장(messageHandlers)하고, 실제 핸들러는 보존하지 않습니다. 이 상태에서 연결이 끊겼다가 재연결되면, 기존 subscription 객체는 무효화되지만 onConnect에서 재구독을 수행하지 않아 해당 topic이 더 이상 메시지를 받지 못할 수 있습니다. 새로 도입한 handlersByTopic(Map)은 사용되지 않아 이 문제를 막지 못합니다.

권장:

  • topic -> Set 구조로 handlersByTopic를 실제 사용해 다중 핸들러와 재구독을 지원
  • onConnect 시 handlersByTopic의 모든 topic을 순회하며 재구독(새 subscription 생성) 수행
  • offMessage는 (topic, handler?) 형태로 설계하여 특정 핸들러만 제거하거나, 마지막 핸들러가 제거될 때에만 unsubscribe

예시(파일 밖 전역 변경이므로 참고용 코드):

// subscribeToTopic 내
if (!this.handlersByTopic.has(topic)) this.handlersByTopic.set(topic, new Set());
this.handlersByTopic.get(topic).add(handler);

if (!this.messageHandlers.has(topic)) {
  const subscription = this.stompClient.subscribe(topic, (message) => {
    let data;
    try { data = JSON.parse(message.body) } catch (e) { console.error('파싱 실패:', e, message.body); return; }
    const hs = this.handlersByTopic.get(topic);
    if (!hs) return;
    for (const h of hs) {
      try { h(data) } catch (e) { console.error('핸들러 오류:', e); }
    }
  });
  this.messageHandlers.set(topic, subscription);
}

// onConnect 내 (pendingSubscriptions 처리 후)
for (const topic of this.handlersByTopic.keys()) {
  if (!this.messageHandlers.get(topic)) {
    this.subscribeToTopic(topic, () => {}); // 실제 핸들러는 handlersByTopic의 Set에서 호출
  }
}

// offMessage 개선
offMessage(topic, handler) {
  const set = this.handlersByTopic.get(topic);
  if (set && handler) {
    set.delete(handler);
    if (set.size === 0) {
      const sub = this.messageHandlers.get(topic);
      sub?.unsubscribe();
      this.messageHandlers.delete(topic);
      this.handlersByTopic.delete(topic);
    }
    return;
  }
  // handler 미지정: 전체 해제
  const sub = this.messageHandlers.get(topic);
  sub?.unsubscribe();
  this.messageHandlers.delete(topic);
  this.handlersByTopic.delete(topic);
}
🤖 Prompt for AI Agents
In src/apis/websocket.js around lines 10-11, handlersByTopic is created but
never used which causes subscriptions to be lost after reconnect because
messageHandlers stores only the subscription object and handlers aren't
preserved; change handlersByTopic to map topic -> Set<handler>, store handlers
there on subscribe, make the actual STOMP subscription callback dispatch
incoming messages to every handler in the Set, and onConnect iterate
handlersByTopic to recreate missing subscriptions into messageHandlers; also
modify offMessage to accept (topic, handler?) so removing a single handler only
deletes the subscription when the Set becomes empty (otherwise just remove the
handler).

Comment on lines +28 to 30
const sendChatMessage = async (chatRoomId, senderId, receiverId, content, type = 'TEXT') => {
return websocketService.sendChatMessage(chatRoomId, senderId, receiverId, content, type)
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

fileUrl 인자 누락으로 첨부 메시지 전송 불가

WebSocketService.sendChatMessage는 fileUrl을 지원하지만, 이 래퍼는 fileUrl을 받지도 전달하지도 않아 첨부 전송이 누락됩니다.

-  const sendChatMessage = async (chatRoomId, senderId, receiverId, content, type = 'TEXT') => {
-    return websocketService.sendChatMessage(chatRoomId, senderId, receiverId, content, type)
+  const sendChatMessage = async (chatRoomId, senderId, receiverId, content, type = 'TEXT', fileUrl = null) => {
+    return websocketService.sendChatMessage(chatRoomId, senderId, receiverId, content, type, fileUrl)
   }

첨부 전송 경로를 사용하는 호출부가 있는지 확인하려면 다음 스크립트를 실행해 주세요. fileUrl을 전달하는 호출이 있는 경우, 현재는 누락되어 동작하지 않을 수 있습니다.


🏁 Script executed:

#!/bin/bash
# useWebSocket 래퍼 사용처 중 sendChatMessage 인자 개수 검사

echo "sendChatMessage 호출부:"
rg -nP -C2 --type=ts --type=tsx --type=js --type=vue '\bsendChatMessage\s*\('

echo
echo "fileUrl 또는 6번째 인자를 전달하는 패턴(근사치):"
rg -nP -C2 --type=ts --type=tsx --type=js --type=vue '\bsendChatMessage\s*\([^)]*,\s*[^)]*,\s*[^)]*,\s*[^)]*,\s*[^)]*,\s*[^)]*\)'

Length of output: 401


안전한 검증을 위해 타입 필터를 제거하고 전역 검색으로 호출부 및 정의를 다시 확인해 주세요.


🏁 Script executed:

#!/bin/bash
echo "=== sendChatMessage 호출부 전체 목록 ==="
rg -n -C2 '\bsendChatMessage\s*\('

echo
echo "=== sendChatMessage 호출 시 6개 이상 인자 전달 패턴(근사치) ==="
rg -n -C2 '\bsendChatMessage\s*\(([^,]*,){5}[^,)]*'

echo
echo "=== WebSocketService.sendChatMessage 정의 위치 확인 ==="
# WebSocketService 클래스 선언 포함 파일 검색
rg -l 'class\s+WebSocketService' 
# 해당 파일에서 메서드 정의 검색
rg -nP 'sendChatMessage\s*\([\s\S]*?\)\s*{' $(rg -l 'class\s+WebSocketService')

Length of output: 2335


sendChatMessage에 fileUrl 파라미터 누락 및 호출부 수정 필요

WebSocketService.sendChatMessage는 fileUrl을 지원하지만,
– useWebSocket.js의 wrapper에서 fileUrl을 받거나 전달하지 않고
– ChatRoom.vue, useChatRoom.js 같은 호출부에서도 fileUrl을 전달하지 않아 첨부 메시지가 전송되지 않습니다.

다음 위치를 수정해 주세요:

  • src/hooks/chat/useWebSocket.js
    sendChatMessage에 fileUrl 파라미터 추가 및 전달
  • src/components/chat/chatRoom/ChatRoom.vue (line 634)
    websocketService.sendChatMessage 호출 시 fileUrl 추가 또는 wrapper 사용
  • src/hooks/chat/useChatRoom.js (line 107)
    동일하게 fileUrl 전달

예시 Diff:

--- src/hooks/chat/useWebSocket.js
- const sendChatMessage = async (chatRoomId, senderId, receiverId, content, type = 'TEXT') => {
-   return websocketService.sendChatMessage(chatRoomId, senderId, receiverId, content, type)
+ const sendChatMessage = async (
+   chatRoomId,
+   senderId,
+   receiverId,
+   content,
+   type = 'TEXT',
+   fileUrl = null
+ ) => {
+   return websocketService.sendChatMessage(chatRoomId, senderId, receiverId, content, type, fileUrl)
}
--- src/components/chat/chatRoom/ChatRoom.vue
- const success = websocketService.sendChatMessage(
-   chatRoomId.value,
-   currentUserId.value,
-   receiverId.value,
-   content.value,
-   type
- )
+ const success = websocketService.sendChatMessage(
+   chatRoomId.value,
+   currentUserId.value,
+   receiverId.value,
+   content.value,
+   type,
+   fileUrl  // 첨부 URL
+ )

또는 wrapper를 사용하도록 변경:

- await websocketService.sendChatMessage(...)
+ await sendChatMessage(..., fileUrl)

이렇게 수정하면 fileUrl이 정상 전달되어 첨부 메시지 전송이 가능해집니다.

📝 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.

Suggested change
const sendChatMessage = async (chatRoomId, senderId, receiverId, content, type = 'TEXT') => {
return websocketService.sendChatMessage(chatRoomId, senderId, receiverId, content, type)
}
// src/hooks/chat/useWebSocket.js
const sendChatMessage = async (
chatRoomId,
senderId,
receiverId,
content,
type = 'TEXT',
fileUrl = null
) => {
return websocketService.sendChatMessage(
chatRoomId,
senderId,
receiverId,
content,
type,
fileUrl
)
}
🤖 Prompt for AI Agents
In src/hooks/chat/useWebSocket.js around lines 28-30, the sendChatMessage
wrapper omits the fileUrl parameter so attached files never get forwarded;
update the wrapper signature to accept fileUrl (default null) and pass it
through to websocketService.sendChatMessage. Also update call sites: in
src/components/chat/chatRoom/ChatRoom.vue (around line 634) and
src/hooks/chat/useChatRoom.js (around line 107) ensure they pass the fileUrl
when invoking the wrapper or call websocketService.sendChatMessage with the
fileUrl argument so attachments are transmitted correctly.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (4)
src/apis/websocket.js (4)

230-246: 토픽당 단일 핸들러만 유지 → 다중 핸들러 미지원 및 재연결 시 구독 유실

현재 messageHandlers에는 구독 객체만 저장되고, 실제 콜백은 보존되지 않습니다. 동일 토픽에 두 번째 핸들러를 등록하면 existingSubscription 때문에 무시되며, 재연결 시에도 다시 구독/디스패치할 수단이 없습니다. topic → Set 구조를 도입하고, 구독 콜백에서 Set 내 모든 핸들러로 디스패치하세요. onConnect에서 해당 토픽들에 대해 구독을 재생성해야 합니다.

구독 콜백 내 디스패치 개선(부분 패치):

       const subscription = this.stompClient.subscribe(topic, (message) => {
         try {
           const data = JSON.parse(message.body)
-          handler(data)
+          const set = this.handlersByTopic?.get(topic)
+          if (!set || set.size === 0) return
+          for (const h of set) {
+            try {
+              h(data)
+            } catch (e) {
+              console.error('핸들러 실행 오류:', e)
+            }
+          }
         } catch (e) {
           console.error('파싱 실패:', e)
           console.error('Raw body:', message.body)
           console.error('Body 타입:', typeof message.body)
         }
       })

파일 밖(참고용) 추가/수정 코드:

// constructor
this.handlersByTopic = new Map() // Map<string, Set<Function>>

// onMessage(topic, handler)
if (!this.handlersByTopic.has(topic)) this.handlersByTopic.set(topic, new Set())
this.handlersByTopic.get(topic).add(handler)
// 연결 안 되었으면 토픽만 큐에 저장(중복 방지)
if (!this.isConnected.value || !this.stompClient?.connected) {
  if (!this.pendingSubscriptions.find((p) => p.topic === topic)) {
    this.pendingSubscriptions.push({ topic }) // handler 불필요
  }
  return
}
this.subscribeToTopic(topic)

// subscribeToTopic(topic)
const existingSubscription = this.messageHandlers.get(topic)
if (existingSubscription) return existingSubscription
// (위의 디스패치 개선 콜백 사용)

// onConnect
// 대기 구독 처리 후, handlersByTopic에 있는 모든 토픽을 보장 재구독
for (const topic of this.handlersByTopic.keys()) {
  if (!this.messageHandlers.get(topic)) {
    this.subscribeToTopic(topic)
  }
}
this.pendingSubscriptions = []

// offMessage(topic, handler?)
offMessage(topic, handler) {
  const set = this.handlersByTopic.get(topic)
  if (set && handler) {
    set.delete(handler)
    if (set.size > 0) return
  }
  const sub = this.messageHandlers.get(topic)
  sub?.unsubscribe()
  this.messageHandlers.delete(topic)
  this.handlersByTopic.delete(topic)
}

197-213: 대기 구독 큐(pendingSubscriptions)가 핸들러 중복을 제거하면서(토픽 기준) 핸들러를 소실시킬 수 있음

현재 find((p) => p.topic === topic)으로 토픽 단위 중복 제거를 하므로, 동일 토픽에 여러 핸들러 등록 시 뒤에 등록된 핸들러가 무시됩니다. 앞선 코멘트의 handlersByTopic(Set) 채택 시 토픽-핸들러 분리를 통해 문제 해소됩니다.


85-104: sendMessage: 비준비 상태에서 connect 미트리거 + connect() reject 미처리 → 영구 대기/Unhandled Rejection 가능

현재는 this.stompClient가 존재하지만 연결이 끊긴 경우(isReady=false) connect()를 재호출하지 않습니다. 또한 await this.connect()가 실패(reject)하면 sendMessage에서 예외가 전파되어 호출부가 false를 받지 못할 수 있습니다. 재시도 루프 진입 전에 안전하게 connect를 트리거하고, 실패는 루프에서 처리하도록 수정이 필요합니다.

권장 패치:

-  async sendMessage(destination, message, retryCount = 5) {
-    // STOMP 클라이언트가 없으면 연결 시도
-    if (!this.stompClient) {
-      await this.connect()
-    }
-
-    // 연결 상태 확인
-    const isReady = this.stompClient?.connected && this.isConnected.value
-
-    if (!isReady) {
-      if (retryCount > 0) {
-        await new Promise((resolve) => setTimeout(resolve, 1000))
-        return this.sendMessage(destination, message, retryCount - 1)
-      } else {
-        console.error('STOMP 연결 실패 - 재시도 횟수 초과')
-        return false
-      }
-    }
+  async sendMessage(destination, message, retryCount = 5) {
+    // 현재 준비 상태 확인
+    let isReady = this.stompClient?.connected && this.isConnected.value
+    if (!isReady) {
+      // 미준비면 연결 시도(중복 연결 방지)
+      if (!this.isConnecting.value) {
+        try {
+          await this.connect()
+        } catch (e) {
+          // 실패는 재시도 루프에서 처리
+          console.warn('connect() 실패, 재시도 대기:', e?.message || e)
+        }
+      }
+      // 재확인 후 미준비 상태면 재시도 루프
+      isReady = this.stompClient?.connected && this.isConnected.value
+      if (!isReady) {
+        if (retryCount > 0) {
+          await new Promise((resolve) => setTimeout(resolve, 1000))
+          return this.sendMessage(destination, message, retryCount - 1)
+        } else {
+          console.error('STOMP 연결 실패 - 재시도 횟수 초과')
+          return false
+        }
+      }
+    }

85-104: isReady가 false일 때도 connect() 호출 누락
현재 this.stompClient는 존재하지만 연결이 끊어진 상태(connected === false 또는 isConnected.value === false)인 경우에도 connect()를 호출하지 않아, 재귀로 즉시 재시도만 하게 됩니다.
아래 위치에서 isReady가 false일 때도 await this.connect()를 수행하도록 로직을 수정하세요.

  • 파일: src/apis/websocket.js
  • 메서드: async sendMessage(destination, message, retryCount = 5)
  • 범위: 85–104행

제안하는 수정 예시:

 async sendMessage(destination, message, retryCount = 5) {
-  // STOMP 클라이언트가 없으면 연결 시도
-  if (!this.stompClient) {
-    await this.connect()
-  }
+  // STOMP 클라이언트가 없거나 연결이 끊어졌으면 재연결 시도
+  if (!this.stompClient || !this.stompClient.connected) {
+    await this.connect()
+  }

   // 연결 상태 확인
   const isReady = this.stompClient?.connected && this.isConnected.value
   if (!isReady) {
+    // 재연결 후에도 준비되지 않았으면 재시도
+    await this.connect()
     if (retryCount > 0) {
       await new Promise((resolve) => setTimeout(resolve, 1000))
       return this.sendMessage(destination, message, retryCount - 1)
🧹 Nitpick comments (4)
src/apis/contractChatApi.js (2)

247-247: 오타: 로그 메시지 함수명 불일치(endContractMessageHttp → sendContractMessageHttp)

디버깅 시 혼선을 줄이기 위해 로그 태그를 실제 함수명과 일치시키세요.

수정 예:

console.error('sendContractMessageHttp 실패:', error)

43-75: 에러 상세 로깅에 PII 포함 가능성 — 운영 환경에서의 로그 축소/마스킹 권장

error.response.data 전체를 로그로 남기면 개인/민감정보가 출력될 수 있습니다. 운영 환경에서는 헤더/본문을 부분 마스킹하거나, 요약 메시지만 남기는 방식을 권장합니다.

가이드:

  • NODE_ENV !== 'production' 일 때만 상세 출력
  • data는 길이 제한 및 키 기반 마스킹(예: email, name, phone 등)
src/apis/websocket.js (2)

85-85: 재시도 횟수 5는 네트워크 환경에 비해 짧을 수 있음 — 구성 가능 값으로 노출 권장

모바일/약한 네트워크에서 5초(5회 x 1초)는 짧습니다. 환경변수 또는 인자 기반으로 재시도 횟수/간격을 조정 가능하게 하세요.

예: retryCount 기본값을 10~30으로 상향하거나, 옵션 객체로 (retryCount, retryDelayMs) 받기.


44-44: STOMP debug 훅 주석 처리 대신 환경 기반 토글 권장

개발 환경에서만 디버그 로그를 활성화하면 이슈 분석에 유용하고, 운영 로그 오염도 막을 수 있습니다.

예시 패치:

-        // debug: (str) => console.log('[STOMP DEBUG]', str),
+        debug: import.meta.env.MODE !== 'production'
+          ? (str) => console.log('[STOMP DEBUG]', str)
+          : undefined,
📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between d712614 and 685b3bc.

📒 Files selected for processing (2)
  • src/apis/contractChatApi.js (1 hunks)
  • src/apis/websocket.js (6 hunks)
🧰 Additional context used
🧬 Code Graph Analysis (1)
src/apis/websocket.js (2)
src/hooks/chat/useContractChat.js (1)
  • isReady (8-8)
src/hooks/chat/useChatRoom.js (1)
  • isReady (7-7)

Comment on lines +464 to 472
if (response.data && response.data.success) {
return response.data.data
} else {
return null
}
} catch (error) {
console.error('내보내기 상태 조회 실패:', error)
throw error
return null
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

getExportStatus: 에러 시 null 반환으로 변경 — 호출부 영향 점검 및 반환 형태 문서화/표준화 권장

이번 변경으로 성공이 아니면 null을 반환하게 되었습니다. 폴링 기반 처리에는 합리적이지만, 기존 호출부가 객체 형태(예: { success, data })를 기대했다면 런타임 오류를 유발할 수 있습니다. 최소한 호출부에서 null 체크가 모두 되어있는지 확인 부탁드립니다. 또한 이 파일 내 다른 API들이 반환하는 형태가 제각각이라(일부는 result, 일부는 result.data, 일부는 null), 일관된 Result 형태로 표준화하는 것을 권장합니다.

아래 스크립트로 getExportStatus 호출부에서 null 체크가 누락된 곳을 빠르게 살펴볼 수 있습니다.

선택지:

  • 유지(현재 설계): null 반환 유지 + JSDoc로 계약을 명확히
  • 표준화(권장): Result 형태로 통일

옵션 B(표준화) 최소 패치 예시:

-    if (response.data && response.data.success) {
-      return response.data.data
-    } else {
-      return null
-    }
+    if (response.data?.success) {
+      return { success: true, data: response.data.data }
+    }
+    return { success: false, data: null }
@@
-    return null
+    return { success: false, data: null }

🏁 Script executed:

#!/bin/bash
# getExportStatus 사용처와 주변 null 체크 여부 확인
rg -nP -C3 --glob '!node_modules/**' '\bgetExportStatus\s*\('

echo
echo "잠재적 null 미체크 패턴 (단순 휴리스틱):"
rg -nP -C2 --glob '!node_modules/**' '\bgetExportStatus\s*\([^)]+\)\s*(;|\))\s*$' | sed 's/^/  /'

Length of output: 1254


🏁 Script executed:

#!/bin/bash
echo "=== Context around initial getExportStatus call ==="
sed -n '525,545p' src/pages/contract/ContractCompletePage.vue
echo
echo "=== Context around polling getExportStatus call ==="
sed -n '930,960p' src/pages/contract/ContractCompletePage.vue

Length of output: 1686


getExportStatus 폴링 로직에 null 처리 누락으로 인한 런타임 오류 가능 – 즉시 수정 필요

폴링 루프에서 getExportStatusnull을 반환할 경우, 아래 코드에서 status.isCompleted 등을 읽으면서 예외가 발생합니다. 초기 호출부에는 null 체크(if (initialStatus))가 되어 있으나, 폴링 구간에는 빠져 있습니다.

  • 위치: src/pages/contract/ContractCompletePage.vue (약 937–940행)
  • 현상:
    const status = await getExportStatus(contractId.value)
    // → status가 null이면 다음 줄에서 Cannot read properties of null 에러
    exportStatus.value = status
    if ((status.isCompleted || status.completed) && status.finalPdfUrl) {  }

수정 방안 (택1):

  1. null 반환 시 건너뛰기
    const status = await getExportStatus(contractId.value)
  • if (!status) {
  • console.warn('getExportStatus returned null, skip polling iteration')
  • return
  • }
    exportStatus.value = status
    if ((status.isCompleted || status.completed) && status.finalPdfUrl) { … }
    
    
  1. 반환 타입을 항상 객체 형태로 표준화 (예: { success, data })
    - if (response.data?.success) {
    -   return { ... }
    - }
    + return { success: !!response.data?.success, data: response.data?.data ?? null }
    이후 호출부에서도 status.success로 검사하도록 일관화.

또한, getExportStatus 함수에 JSDoc/타입 정의로 null 반환 가능 여부를 명확히 문서화해 주세요.

🤖 Prompt for AI Agents
In src/apis/contractChatApi.js around lines 464–472 the getExportStatus function
can return null but callers in src/pages/contract/ContractCompletePage.vue
(~lines 937–940) assume an object, causing runtime errors; fix by either (A)
making the polling loop defensively skip when status is null (e.g., if (!status)
continue/return) before reading status.isCompleted/status.finalPdfUrl, or (B)
change getExportStatus to always return a standardized object shape (never null)
and update all callers to check a success flag (e.g., status.success) instead;
additionally add JSDoc/type annotation to getExportStatus documenting nullable
return or the new standardized shape so callers know which to handle.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

♻️ Duplicate comments (2)
src/components/chat/chatRoom/ChatRoom.vue (2)

94-112: acceptBusy/rejectBusy/handleRejectContract 미정의 — 클릭 시 ReferenceError 발생

템플릿에서 acceptBusy, rejectBusy, handleRejectContract를 참조하지만 스크립트에 정의가 없어 런타임 오류가 납니다. 기존 리뷰에서 지적된 사항이 아직 반영되지 않았습니다.

권장 수정:

  1. busy 상태 ref 추가(상태 선언부 인근)
const acceptBusy = ref(false)
const rejectBusy = ref(false)
  1. 수락 핸들러에 busy 가드/토글 추가
 const handleAcceptContract = async () => {
+  if (acceptBusy.value) return
+  acceptBusy.value = true
   if (!props.room?.chatRoomId) {
     console.error('채팅방 ID가 없습니다.')
-    return
+    acceptBusy.value = false
+    return
   }
   try {
     const response = await acceptContract(props.room.chatRoomId)
     // ... 기존 처리
   } catch (error) {
     console.error('계약 수락 중 오류 발생:', error)
     alert('계약 수락 중 오류가 발생했습니다.')
   } finally {
+    acceptBusy.value = false
   }
 }
  1. 거절 핸들러 추가(프로젝트 API/프로토콜에 맞게 치환)
// import { rejectContract } from '@/apis/chatApi' // 실제 API가 있다면 사용

const handleRejectContract = async () => {
  if (rejectBusy.value) return
  if (!props.room?.chatRoomId) {
    console.error('채팅방 ID가 없습니다.')
    return
  }
  const confirmed = window.confirm('계약 요청을 거절하시겠어요?')
  if (!confirmed) return

  try {
    rejectBusy.value = true
    // TODO: 실제 거절 동작(예: API 호출 또는 WS 전송)
    // await rejectContract(props.room.chatRoomId)
    // websocketService.sendMessage('/app/chat/contract/reject', { chatRoomId: props.room.chatRoomId, userId: currentUserId.value })
    alert('계약 요청을 거절했습니다.')
  } catch (e) {
    console.error('계약 거절 중 오류:', e)
    alert('계약 거절 중 오류가 발생했습니다.')
  } finally {
    rejectBusy.value = false
  }
}

필요하시면 실제 API에 맞춘 구현을 함께 제안드리겠습니다.


156-174: 동일 이슈 반복: acceptBusy/rejectBusy/handleRejectContract 미정의

위 API 메시지 블록(라인 94-112)과 동일한 런타임 오류가 WS 메시지 블록에도 발생합니다. 한 번의 스크립트 수정으로 두 곳 모두 해결됩니다.

🧹 Nitpick comments (10)
src/components/chat/chatRoom/ChatRoom.vue (4)

18-18: 메시지 컨테이너에 overflow-y-auto/min-h-0 적용은 적절 — 커스텀 스크롤 스타일 클래스 누락

style 섹션에 .chat-messages-container용 커스텀 스크롤 스타일이 존재하지만, 현재 컨테이너에서 해당 클래스가 빠져 실제로 적용되지 않습니다. 두 가지 중 하나로 정리해 주세요:

  • 컨테이너에 .chat-messages-container를 추가하여 기존 스타일을 활용
  • 불필요해진 .chat-messages-container 스타일을 제거하고 .overflow-y-auto 확장 스타일만 사용

아래처럼 컨테이너에 클래스를 추가하는 것을 권장합니다.

-    <div class="flex-1 min-h-0 overflow-y-auto p-4 bg-gray-50" ref="messagesContainer">
+    <div class="flex-1 min-h-0 overflow-y-auto p-4 bg-gray-50 chat-messages-container" ref="messagesContainer">

47-49: URL 버튼 클릭 조건 개선 — WS 경로에도 동일 로직 적용 권장

API 메시지 블록은 isClickableUrlButton으로 예외(계약 채팅 URL) 허용을 잘 처리합니다. WebSocket 메시지 블록(라인 146-154)은 여전히 isMyMessage만으로 차단하고 있어 UX가 불일치합니다. 아래와 같이 WS 블록도 통일해 주세요.

-              <BaseButton
-                :disabled="isMyMessage(message)"
-                @click="!isMyMessage(message) && handleUrlLinkClick(message)"
+              <BaseButton
+                :disabled="!isClickableUrlButton(message)"
+                @click="isClickableUrlButton(message) && handleUrlLinkClick(message)"
                 class="disabled:opacity-50 disabled:cursor-not-allowed"
               >

120-123: 주석 처리된 버튼 블록은 목적이 불명확 — 정리 권장

임시 디버깅/회귀 대비 목적이 아니라면 주석 코드는 제거하여 가독성을 개선하는 것이 좋습니다. 향후 재도입 가능성이 있다면 feature flag나 조건부 렌더링으로 관리하는 방법을 권장합니다.


861-871: 계약 채팅 URL 판별/클릭 허용 헬퍼 추가 — 좋습니다; 상수화로 중복 문자열 제거 제안

플레이스홀더 문자열('계약 채팅방 URL')이 여러 함수에 반복됩니다. 오타/변경 시 리스크를 줄이기 위해 상수로 추출하는 것을 권장합니다.

예시:

// 스크립트 상단 상수
const CONTRACT_CHAT_PLACEHOLDER = '계약 채팅방 URL'

해당 함수들에 적용:

 function isContractChatUrl(message) {
-  const c = String(message?.content || '')
-  return c === '계약 채팅방 URL' || /\/contract-?chat/i.test(c)
+  const c = String(message?.content || '')
+  return c === CONTRACT_CHAT_PLACEHOLDER || /\/contract-?chat/i.test(c)
 }

 function isClickableUrlButton(message) {
   // 내가 보낸 것이더라도 계약 채팅방 링크면 클릭 허용
   return !isMyMessage(message) || isContractChatUrl(message)
 }

참고: 같은 상수를 preContractButtonLabel/handleUrlLinkClick의 비교에도 재사용해 주세요.

src/components/pre-contract/owner/step3/SubStep1ContractForm.vue (6)

74-74: 여백 일관성(nit): mt-4 위치 조정 제안

ToggleRadio에 직접 mt-4를 부여하면 섹션 gap 리듬과 혼용되어 간격이 들쭉날쭉해질 수 있습니다. 안내 문구(p)에 상단 마진을 주거나, 섹션 컨테이너의 gap만으로 간격을 맞추는 편이 일관됩니다.

권장 변경(여백을 안내 문구로 이동):

-      <ToggleRadio
+      <ToggleRadio
         label="선순위 확정일자가 존재하나요?"
         v-model="hasPriorFixedDate"
         :options="[
           { label: '있음', value: true },
           { label: '없음', value: false },
         ]"
-        class="mt-4"
       />
-      <p class="text-[12px] text-gray-500">
+      <p class="mt-2 text-[12px] text-gray-500">
         *없음을 선택할 경우 최종 계약서에 해당 항목에 대한 서명이 필요합니다.
       </p>

55-57: 동일 안내 문구 중복 — i18n/공통화 및 접근성 보강 제안

  • 동일 안내 문구가 두 곳에 반복됩니다. i18n 키 또는 상수로 추출해 단일 소스로 관리하면 유지보수와 문구 변경 리스크가 줄어듭니다.
  • ToggleRadio가 보조설명 prop/slot을 지원한다면(예: description/helpText), 컴포넌트 내부로 넣어 스크린리더 읽힘을 보장하세요. 지원하지 않는다면 p에 id를 부여하고 aria-describedby로 연결하는 방식을 고려해 주세요.

예시(간단 공통화):

<script setup>
const signNote = '*없음을 선택할 경우 최종 계약서에 해당 항목에 대한 서명이 필요합니다.'
</script>

<!-- 사용부 -->
<p class="text-[12px] text-gray-500">{{ signNote }}</p>
...
<p class="text-[12px] text-gray-500">{{ signNote }}</p>

Also applies to: 76-78


62-62: 금액 입력: 음수 방지 및 스텝 제약/비활성화(nit)

금액 필드에 최소값·스텝·입력모드 제약을 걸고, 토글이 true가 아닐 때는 비활성화하는 것을 권장합니다.

-          <BaseInput v-model="taxArrearsAmount" type="number" class="w-32" />
+          <BaseInput
+            v-model="taxArrearsAmount"
+            type="number"
+            :min="0"
+            :step="1"
+            inputmode="numeric"
+            :disabled="hasTaxArrears !== true"
+            class="w-32"
+          />

59-65: hasTaxArrears가 false로 변경될 때 금액 초기화 및 전송 차단

사용자가 ‘없음’으로 바꿨을 때 기존 금액이 그대로 남아 전송될 수 있습니다. 토글이 false이면 금액을 0으로 초기화하거나 패치 페이로드에서 제외하세요.

예시(값 초기화):

<script setup>
watch(hasTaxArrears, (v) => {
  if (v === false) taxArrearsAmount.value = 0
})
</script>

95-102: 불린 초기값을 null로 통일해 3-state 표현 일관성 확보

Step2에서 관련 플래그들이 null 초기값으로 리팩토링된 것(요약 참고)과 맞춰, 본 파일의 불린 항목들도 null로 통일하면 “미선택/미정” 상태 표현이 명확하고 타입 일관성이 좋아집니다.

-const isMortgaged = ref('')
+const isMortgaged = ref(null)
...
-const hasTaxArrears = ref('')
+const hasTaxArrears = ref(null)
...
-const hasPriorFixedDate = ref('')
+const hasPriorFixedDate = ref(null)

참고: 진행 가능 여부 계산부에서 isMortgaged는 이미 null 체크로 되어 있어 추가 변경은 필요 없습니다.


122-133: 전송 payload 정규화: hasTaxArrears=false일 때 금액 0 또는 필드 제외

서버 측 기대에 따라 ‘없음’이면 0으로 고정하거나 필드를 생략하는 것이 안전합니다. 현재는 토글이 false여도 이전 값이 그대로 전송될 수 있습니다.

간단히 0 고정:

-    await OwnerPreContractAPI.updateContractStep1(contractChatId, {
+    await OwnerPreContractAPI.updateContractStep1(contractChatId, {
       mortgaged: isMortgaged.value,
       contractDuration: contractDuration.value,
       renewalIntent: renewalIntent.value,
       responseRepairingFixtures: repairingFixtures.value,
       hasTaxArrears: hasTaxArrears.value,
-      taxArrearsAmount: taxArrearsAmount.value,
+      taxArrearsAmount: hasTaxArrears.value === true ? taxArrearsAmount.value : 0,
       hasPriorFixedDate: hasPriorFixedDate.value,
     })

또는 payload를 변수로 만들고 조건에 따라 키를 제거(omit)하는 방식도 고려해 주세요.

📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between cf46c1a and a9fb14d.

📒 Files selected for processing (3)
  • src/components/chat/chatRoom/ChatRoom.vue (8 hunks)
  • src/components/pre-contract/owner/step3/SubStep1ContractForm.vue (2 hunks)
  • src/components/pre-contract/owner/step5/Step5UploadTerms.vue (1 hunks)
✅ Files skipped from review due to trivial changes (1)
  • src/components/pre-contract/owner/step5/Step5UploadTerms.vue
🔇 Additional comments (5)
src/components/chat/chatRoom/ChatRoom.vue (3)

13-13: min-h-0 추가로 Flex 컨테이너 스크롤 수축 이슈 방지 — 좋습니다

Flex 자식 컨테이너에서 min-h-0 지정으로 overflow 컨테이너가 의도대로 축소/스크롤됩니다.


217-217: 읽음 텍스트 색상(text-white) 변경 — 배경 대비 확인 필요

내 메시지 버블 배경(bg-yellow-primary) 위 흰색 텍스트의 가독성/대비가 낮을 수 있습니다. 디자인 토큰의 실제 색상값에 따라 text-white/80, text-gray-100 등으로 조정이 필요할 수 있습니다. 확인 부탁드립니다.


232-237: ChatInput의 sticky footer 스타일링(shrink-0, border-t, bg-white) — 적절합니다

입력창 영역이 스크롤에 의해 줄어들지 않도록 안정적으로 고정됩니다.

src/components/pre-contract/owner/step3/SubStep1ContractForm.vue (2)

138-145: 진행 가능 여부 기준 필드 재검토 요청

현재 canProceed는 isMortgaged/contractDuration/renewalIntent/repairingFixtures만 고려합니다. hasTaxArrears/hasPriorFixedDate가 Step1 완료의 필수 항목이 아니라는 제품 요구사항이 확실한지 확인 부탁드립니다.


71-73: hasPriorFixedDate boolean 매핑 변경 — 백엔드/기존 데이터 호환성 확인 필요

UI 토글 옵션이 이제 { 있음: true, 없음: false }로 정상화되었습니다.
하지만 백엔드/API나 DB에 과거 역매핑(없음: true, 있음: false)이 남아있다면:

  • 조회(fetch) 시 잘못된 초기 표시
  • 저장(patch) 시 반전된 값이 DB에 저장

확인 위치:

  • src/components/pre-contract/owner/step3/SubStep1ContractForm.vue
    fetchContractStep1hasPriorFixedDate.value = data.hasPriorFixedDate
    patchContractStep1payload.hasPriorFixedDate = hasPriorFixedDate.value
  • src/components/pre-contract/owner/step6/Step6Confirm.vue
    summary.contractStep1?.hasPriorFixedDate ? '예' : '아니오'
  • src/pages/contract/ContractCompletePage.vue
    • Checkbox v-model="hasPriorFixedDate" 및 전송 DTO(hasPriorFixedDate.value)
  • src/apis/preContractOwner.js
    getContractStep1, updateContractStep1 정의부

임시 역매핑 예시(서버가 과거 의미일 경우에만):

-    hasPriorFixedDate.value = data.hasPriorFixedDate
+    hasPriorFixedDate.value = data.hasPriorFixedDate === null ? null : !data.hasPriorFixedDate

-      hasPriorFixedDate: hasPriorFixedDate.value,
+      hasPriorFixedDate: hasPriorFixedDate.value === null ? null : !hasPriorFixedDate.value,

장기적으로는 API 계층(DTO 변환)에서 일관된 매핑 처리 권장

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/components/contract/chat/ContractChat.vue (1)

301-325: 한글 금액 파서가 ‘천만/백만/십만’ 등 복합 단위를 잘못 해석합니다

현재 구현은 ‘천만’을 10,000으로 해석하는 등(정확히는 1×만) 큰 오차가 발생합니다. ‘억/만’ 앞의 서브 단위(천/백/십)를 함께 파싱해야 합니다.

적용 제안(간단·정확 파서):

-function parseKoreanAmount(raw = '') {
-  const s = String(raw).replace(/\s+/g, '').replace(/,/g, '')
-  if (!s) return null
-  const UNIT = { 억: 100_000_000, 만: 10_000, 천: 1_000, 백: 100, 십: 10 }
-  const t = s.replace(/원$/, '')
-  if (/^\d+$/.test(t)) return Number(t)
-  let total = 0
-  let rest = t
-  for (const big of ['억', '만']) {
-    const m = rest.match(new RegExp(`(\\d+)?${big}`))
-    if (m) {
-      total += (m[1] ? Number(m[1]) : 1) * UNIT[big]
-      rest = rest.replace(m[0], '')
-    }
-  }
-  for (const small of ['천', '백', '십']) {
-    const m = rest.match(new RegExp(`(\\d+)?${small}`))
-    if (m) {
-      total += (m[1] ? Number(m[1]) : 1) * UNIT[small]
-      rest = rest.replace(m[0], '')
-    }
-  }
-  if (/^\d+$/.test(rest)) total += Number(rest)
-  return Number.isFinite(total) ? total : null
-}
+function parseKoreanAmount(raw = '') {
+  const s = String(raw).replace(/\s+/g, '').replace(/,/g, '').replace(/원$/, '')
+  if (!s) return null
+  if (/^\d+$/.test(s)) return Number(s)
+
+  const UNIT_BIG = { '억': 100_000_000, '만': 10_000 }
+  const UNIT_SMALL = { '천': 1000, '백': 100, '십': 10 }
+
+  const parseChunk = (chunk = '') => {
+    if (!chunk) return 0
+    if (/^\d+$/.test(chunk)) return Number(chunk)
+    let n = 0
+    let rest = chunk
+    for (const u of Object.keys(UNIT_SMALL)) {
+      const re = new RegExp(`(\\d+)?${u}`)
+      const m = rest.match(re)
+      if (m) {
+        const mul = m[1] ? Number(m[1]) : 1
+        n += mul * UNIT_SMALL[u]
+        rest = rest.replace(m[0], '')
+      }
+    }
+    if (/^\d+$/.test(rest)) n += Number(rest)
+    return n
+  }
+
+  let total = 0
+  let rest = s
+  // 억, 만 순서대로 소거하며 앞부분 청크를 파싱
+  for (const big of Object.keys(UNIT_BIG)) {
+    const re = new RegExp(`(.+?)${big}`)
+    const m = rest.match(re)
+    if (m) {
+      total += parseChunk(m[1]) * UNIT_BIG[big]
+      rest = rest.replace(m[0], '')
+    }
+  }
+  total += parseChunk(rest)
+  return Number.isFinite(total) ? total : null
+}

간단 검증: ‘천만’=10,000,000 / ‘2억3천5백만’=235,000,000 / ‘1억2천3백45만6천7백89’=123,456,789

♻️ Duplicate comments (2)
src/components/contract/chat/ContractChat.vue (2)

403-405: 타이머 ref화 및 정리 일관성 개선(기존 코멘트와 동일 이슈)

타이머를 ref로 관리하면 클로저/정리 누락 위험을 줄일 수 있습니다. onUnmounted에서 syncTimer도 함께 정리하세요.

적용 diff 1/3 (ref로 선언 변경):

-const signingCountdown = ref(0)
-let signingTimer = null
+const signingCountdown = ref(0)
+const signingTimer = ref<number | null>(null)

적용 diff 2/3 (사용부 갱신):

-const startSigningCountdown = (sec = 3) => {
-  if (signingTimer) clearInterval(signingTimer)
+const startSigningCountdown = (sec = 3) => {
+  if (signingTimer.value) clearInterval(signingTimer.value)
   signingCountdown.value = sec
-  signingTimer = setInterval(() => {
+  signingTimer.value = setInterval(() => {
     signingCountdown.value -= 1
     if (signingCountdown.value <= 0) {
-      clearInterval(signingTimer)
-      signingTimer = null
+      clearInterval(signingTimer.value!)
+      signingTimer.value = null
       router.push(`/contract/complete/${String(actualContractChatId.value)}`)
     }
   }, 1000)
 }

적용 diff 3/3 (마운트/언마운트 정리 + syncTimer 정리 추가):

 onMounted(() => {
   // 혹시 남아있던 타이머 정리
-  if (signingTimer) clearInterval(signingTimer)
+  if (signingTimer.value) {
+    clearInterval(signingTimer.value)
+    signingTimer.value = null
+  }
 })
 onUnmounted(() => {
-  if (signingTimer) clearInterval(signingTimer)
+  if (signingTimer.value) {
+    clearInterval(signingTimer.value)
+    signingTimer.value = null
+  }
+  if (syncTimer) {
+    clearTimeout(syncTimer)
+    syncTimer = null
+  }
 })

Also applies to: 409-421, 633-639


462-473: 낙관적 업데이트 ID 충돌 가능성 및 keyOf 시그니처 불일치(기존 코멘트와 동일 이슈)

  • hookMessages에 id: Date.now()를 부여하면 API 메시지의 실제 id/시간과 충돌해 Map dedup에서 유실될 수 있습니다.
  • keyOf는 (m, i) 시그니처인데 호출부에서 3번째 인자를 전달하고 있습니다.

조치:

  • 낙관적 메시지에서 id를 부여하지 말고 _localId 기반으로 식별
  • keyOf는 _localId → tempId → id → sendTime 순으로 우선하여 충돌 최소화
  • 잘못된 3번째 인자 제거

적용 diff 1/3 (낙관적 메시지에서 id 제거):

   hookMessages.value.push({
-      id: Date.now(), // 임시 키
       _localId: (crypto?.randomUUID && crypto.randomUUID()) || `local-${Date.now()}`,
       senderId: currentUserId.value,
       receiverId: contractReceiverId.value,
       content,
       sendTime: new Date().toISOString(),
       type: 'TEXT',
       isRead: false,
   })

적용 diff 2/3 (keyOf 우선순위 조정):

-  const keyOf = (m, i) => String(m?.id ?? m?.sendTime ?? m?._localId ?? m?.tempId ?? `tmp-${i}`)
+  const keyOf = (m, i) =>
+    String(m?._localId ?? m?.tempId ?? m?.id ?? m?.sendTime ?? `tmp-${i}`)

적용 diff 3/3 (호출부 시그니처 정정):

-  b.forEach((m, i) => map.set(keyOf(m, i, 10000), m))
+  b.forEach((m, i) => map.set(keyOf(m, i), m))

Also applies to: 533-533, 547-547

🧹 Nitpick comments (9)
src/config/chat/aiUiRegistry.js (4)

105-107: BUTTON 기본 매핑을 빈 배열로 변경 — 의도 확인 및 회귀 영향 점검 필요

step 3에서 senderId 기본 매핑 중 BUTTON을 빈 배열로 두면, 콘텐츠 규칙/메타 규칙에 걸리지 않는 경우 버튼이 전혀 노출되지 않습니다. 현재로서는 contentRules나 meta.action이 항상 세팅된다는 전제하에 동작해야 하므로, 메시지 프로듀서가 해당 조건을 일관되게 충족하는지 확인이 필요합니다. 특히 카피가 변경되거나 meta.action 누락 시 버튼 소실 리스크가 있습니다.


23-27: PR 요약과 불일치: ‘초안이 생성되었습니다’ 규칙에 ‘특약 검토’ 버튼이 여전히 노출됩니다

PR 요약(및 파일 요약)에서는 해당 버튼을 제거한 것으로 보이지만, 실제 규칙은 계속 버튼을 반환합니다. 의도적으로 유지한 것인지 확인 부탁드립니다. 제거가 목적이라면 아래처럼 비워두거나(권장) 메시지에 meta.action('terms.review')만 의존하도록 정리하는 편이 안전합니다. 또한 파일 내 다른 규칙과 동일하게 문자열 처리 스타일도 통일했습니다.

     {
-      when: (message) =>
-        typeof message?.content === 'string' && message.content.includes('초안이 생성되었습니다'),
-      buttons: [{ label: '특약 검토', action: 'step3.openTermsReview' }],
+      when: (message) => {
+        const t = String(message?.content || '')
+        return t.includes('초안이 생성되었습니다')
+      },
+      buttons: [],
     },

112-115: ‘특약 검토’ 비활성화가 목적이라면 metaActionButtons에서도 제거 필요

meta.action 우선 규칙에 여전히 terms.review가 남아 있어(‘특약 검토’ 버튼 노출) 위 규칙 제거와 상충될 수 있습니다. 완전 비활성화 의도라면 아래 라인도 함께 제거해야 일관됩니다.

 export const metaActionButtons = {
-  'terms.review': [{ label: '특약 검토', action: 'step3.openTermsReview' }],
   'terms.final': [{ label: '특약 수정 과정 확인하기', action: 'step3.openFinalClause' }],
 }

118-121: step 파싱 안전성 가드 제안

step이 비정상 값으로 들어올 경우 Number(step)가 NaN이 되어 이후 인덱싱이 모두 실패합니다. 방어적으로 빈 배열을 빠르게 반환하면 예외 상황에서의 안전성이 올라갑니다.

 export function getAiButtonsForMessage(step, message) {
   const stepNum = Number(step)
+  if (!Number.isFinite(stepNum)) return []
   const sid = String(message?.senderId ?? '')
src/components/contract/chat/ContractChat.vue (5)

87-96: 서명 카운트다운 오버레이 A11y 보강 제안

접근성 개선을 위해 다음을 고려해 주세요:

  • 오버레이 컨테이너에 role="alertdialog"와 aria-live="assertive" 추가
  • 포커스 트랩 또는 최초 포커스 이동 처리(필수는 아님)

적용 예:

-<div class="fixed inset-0 bg-black/40 flex items-center justify-center z-[9999]">
+<div class="fixed inset-0 bg-black/40 flex items-center justify-center z-[9999]" role="alertdialog" aria-live="assertive" aria-modal="true">

254-266: 사용자 알림 UX: alert → UI 토스트/모달로 전환 고려

동기 alert은 UX를 막습니다. 앱 전반의 알림 컴포넌트(토스트/모달)로 통일하면 UX 일관성과 제어성이 좋아집니다.


374-383: 정규식 상수화 LGTM + 패턴 중앙집중 관리 제안

상수 분리는 좋습니다. 동일 패턴을 다른 파일에서도 사용할 가능성이 크면 별도 유틸(예: src/utils/contractChatPatterns.ts)로 추출을 고려하세요.


664-673: STEP2 트리거 엄격 일치 비교 완화 제안

현재 t === STEP2_TRIGGER는 사소한 텍스트 변화(공백/구두점)로 쉽게 빗나갑니다. 포함(match) 기반으로 완화하세요.

-      if (t === STEP2_TRIGGER) {
+      if (t.includes(STEP2_TRIGGER)) {
         needGetPrice.value = true
         triggerGetPrice()
       }

554-560: isNewerThanApi: API 메시지 부재 시 동기화 기회 상실

API가 아직 비어있는 경우에도 최신 동기화를 허용하는 편이 안전합니다.

 const isNewerThanApi = (live) => {
   if (!live || String(live.senderId) !== '9999') return false
   const liveTs = new Date(live?.sendTime || live?.createdAt || 0).getTime()
   const apiLast = apiMessages.value.at(-1)
+  if (!apiLast) return true
   const apiTs = new Date(apiLast?.sendTime || apiLast?.createdAt || 0).getTime()
   return liveTs > apiTs
 }
📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between a9fb14d and abbd48c.

📒 Files selected for processing (2)
  • src/components/contract/chat/ContractChat.vue (15 hunks)
  • src/config/chat/aiUiRegistry.js (1 hunks)
🔇 Additional comments (10)
src/components/contract/chat/ContractChat.vue (10)

53-53: StepContainer 이벤트 연결 LGTM

owner-edit-request 이벤트 배선이 명확합니다. 하위 컴포넌트와의 계약이 일관되면 문제 없습니다.


67-69: 입력 컴포넌트 이벤트 배선 LGTM

ContractChatInput의 owner-edit-request / owner-edit-failed 연결이 의도에 부합합니다.


76-85: 역할별 로딩 오버레이 분기 LGTM

amOwner 여부에 따른 2-way 오버레이 분리가 명확합니다. 메시지/서브메시지도 역할에 맞습니다.


145-146: stepFromUrl 도입 LGTM

URL 쿼리와 props를 안전하게 폴백하며, Number 변환으로 일관된 타입을 보장합니다.


184-184: useChatAiButtons의 stepFromUrl 의존 전환 LGTM

라우트 기반 단계 동기화 의도에 부합합니다.


562-562: 주석 갱신 LGTM

의도가 분명해졌습니다.


648-655: 로딩 해제 트리거 선처리 + AI 메시지 필터링 LGTM

불필요한 분기 실행을 줄이고 UX 깜빡임을 예방합니다.


686-694: 라운드 완료/특약 수락 감지 로직 LGTM

오버레이 해제와 버전 증가 타이밍이 자연스럽습니다.


706-715: 최종 수락 → 카운트다운 시작 LGTM

중복 실행 방지 가드가 있어 안정적입니다.


798-803: 라운드 전환 시 오버레이 리셋 LGTM

상태 불일치 방지에 유효합니다.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
src/components/common/PropertyImage.vue (1)

28-39: 동적 processedSrc 변경 시 이미지 상태 초기화 로직 추가 필요

src/components/common/PropertyImage.vue 에서 processedSrc 가 바뀔 때 imageLoading/imageError 가 자동으로 리셋되지 않아, 이미지가 교체되는 목록·갤러리(슬라이더, 무한 스크롤 등)에서 스켈레톤이 재등장하지 않습니다. 아래와 같이 필수 리팩터링을 적용하세요.

파일: src/components/common/PropertyImage.vue

  • 상단 import 구문에 watch 추가
    - import { ref, computed } from 'vue'
    + import { ref, computed, watch } from 'vue'
  • imageError·imageLoading 선언부 바로 아래에 watch 로직 삽입
    const imageError = ref(false)
    const imageLoading = ref(true)
    
    + watch(
    +   () => processedSrc.value,
    +   (next, prev) => {
    +     if (next !== prev) {
    +       imageError.value = false
    +       imageLoading.value = !!next
    +     }
    +   }
    + )
  • 변경 후 동적 이미지 교체 흐름에서 스켈레톤이 정상적으로 재등장하는지 확인 바랍니다.
src/pages/risk-check/RiskCheckResult.vue (1)

371-374: ReferenceError: categorizedAnalysisDetails가 정의되지 않았습니다

상단에서 categorizedAnalysisDetails를 더 이상 정의하지 않는 반면, 템플릿에서 여전히 바인딩하고 있어 런타임 에러가 발생합니다.

가장 작은 수정은 해당 프로퍼티 전달을 제거하는 것입니다(컴포넌트가 detail-groups만으로 렌더가 가능하다는 전제):

-          <DetailedAnalysis
-            :categorized-details="categorizedAnalysisDetails"
-            :detail-groups="currentAnalysis?.detailGroups"
-          />
+          <DetailedAnalysis
+            :detail-groups="currentAnalysis?.detailGroups"
+          />

만약 DetailedAnalysis가 categorized-details를 필수로 요구한다면, 대안으로 빈 값이라도 정의해 일단 참조 에러를 막을 수 있습니다(권장도 낮음):

// 상단 computed 근처
const categorizedAnalysisDetails = computed(() => null)

컴포넌트 요구사항에 맞춰 둘 중 하나로 정리 부탁드립니다.

🧹 Nitpick comments (29)
src/components/alarm/AlarmDropdown.vue (3)

188-191: 채팅 이동: roomId 쿼리로의 변경은 일관성 OK — URL 문자열 조합 대신 location 객체/인코딩 권장

ChatList/ChatPage의 쿼리 기반 라우팅과 맞춰 roomId로 바꾼 점은 좋습니다. 다만 문자열로 직접 URL을 만들면(1) 쿼리 인코딩 누락, (2) 기존 쿼리 병합 미지원, (3) 베이스 경로/해시 모드 등 라우터 설정 변화에 덜 탄탄합니다. 최소한 인코딩, 가능하면 라우터 location 객체로 전환하는 것을 권장합니다.

최소 패치(인코딩 추가):

-      targetUrl = `/chat?roomId=${notification.relatedId}`
+      targetUrl = `/chat?roomId=${encodeURIComponent(String(notification.relatedId))}`

구조적 개선(참고 코드 — 라우터 location 객체 사용):

// 변경 아이디어: 문자열 대신 location 객체 사용
const location = { path: '/chat', query: { roomId: String(notification.relatedId) } }
await router.push(location)

188-194: relatedId가 0일 때 분기 누락 가능 — truthy 체크 대신 null/undefined 체크 권장

relatedId0(또는 빈 문자열)일 경우 현재 조건에서 false로 평가되어 탐색이 누락될 수 있습니다. 안전하게 != null로 체크하는 것이 좋습니다. 또한 type이 비문자열일 때를 대비해 optional chaining을 권장합니다.

-    if (notification.type === 'CHAT' && notification.relatedId) {
+    if (notification.type === 'CHAT' && notification.relatedId != null) {

-    } else if (notification.type.includes('CONTRACT') && notification.relatedId) {
+    } else if (notification.type?.includes('CONTRACT') && notification.relatedId != null) {

199-200: 로그는 개발 환경에서만 출력하도록 제한 권장

사용자 환경에서 콘솔 로그 노출을 피하려면 개발 모드에서만 출력하거나 제거하는 것을 권장합니다.

-    console.log(`알림 클릭: ${notification.title} → ${targetUrl}`)
+    if (import.meta?.env?.DEV) {
+      console.debug(`알림 클릭: ${notification.title} → ${targetUrl}`)
+    }
src/stores/useContractTermStore.js (6)

18-21: 로컬스토리지 파싱 시 NaN 방지 및 radix 명시

parseInt에 기수를 명시하고, 잘못된 값이 저장된 경우 NaN으로 흐르지 않도록 방어 코드를 권장합니다. 기본값을 1로 가져가려는 의도라면, NaN일 때 1로 폴백하는 편이 안전합니다.

아래와 같이 변경을 제안합니다:

-    const savedOrder = localStorage.getItem('currentOrder')
-    if (savedOrder !== null) {
-      currentOrder.value = parseInt(savedOrder)
-    }
+    const savedOrder = localStorage.getItem('currentOrder')
+    if (savedOrder !== null) {
+      const parsed = Number.parseInt(savedOrder, 10)
+      currentOrder.value = Number.isFinite(parsed) ? parsed : 1
+    }

27-31: SSR 안전성: watch 내부의 localStorage 접근 가드 및 값 유효성 점검

스토어가 SSR 컨텍스트에서 초기화될 가능성이 있다면, watch 콜백 내부에서도 typeof window 가드가 필요합니다. 또한 null일 때는 키 삭제, 숫자가 아닌 경우 기록 회피를 권장합니다.

-  watch(currentOrder, (val) => {
-    if (val !== null) {
-      localStorage.setItem('currentOrder', String(val))
-    }
-  })
+  watch(currentOrder, (val) => {
+    if (typeof window === 'undefined') return
+    if (val !== null && Number.isFinite(val)) {
+      localStorage.setItem('currentOrder', String(val))
+    } else {
+      localStorage.removeItem('currentOrder')
+    }
+  })

33-35: SSR 안전성: allCompleted watcher도 클라이언트 가드 필요

동일한 이유로 allCompleted 워처에서도 window 가드를 권장합니다.

-  watch(allCompleted, (v) => {
-    localStorage.setItem('specialContract_allCompleted', v ? 'true' : 'false')
-  })
+  watch(allCompleted, (v) => {
+    if (typeof window === 'undefined') return
+    localStorage.setItem('specialContract_allCompleted', v ? 'true' : 'false')
+  })

38-55: 콘솔 로깅은 개발 환경에서만 출력하도록 제한

프로덕션 빌드에서 불필요한 콘솔 로깅 노이즈를 줄이는 것을 권장합니다. Vite/Vue 환경이라면 import.meta.env.DEV 가드가 간단합니다.

-    console.log('[store.setOrder] 설정된 order:', order)
+    if (import.meta.env.DEV) console.log('[store.setOrder] 설정된 order:', order)
-    console.log('[store.moveToNextOrder] 현재 currentOrder:', currentOrder.value)
-    console.log('[store.moveToNextOrder] 전체 orders:', contractOrders.value)
+    if (import.meta.env.DEV) {
+      console.log('[store.moveToNextOrder] 현재 currentOrder:', currentOrder.value)
+      console.log('[store.moveToNextOrder] 전체 orders:', contractOrders.value)
+    }
@@
-    console.log('[store.moveToNextOrder] index:', index)
+    if (import.meta.env.DEV) console.log('[store.moveToNextOrder] index:', index)

53-73: 다음 인덱스 계산을 명시화하고 외부 API 실패를 처리

index === -1일 때 index + 1이 0으로 떨어지는 트릭은 의도가 불분명합니다. 가독성을 위해 명시적으로 처리하는 편이 좋습니다. 또한 postAiMessage/putRecentData 실패 시 상태가 중간에 멈추지 않도록 예외 처리가 필요합니다.

-    const index = contractOrders.value.findIndex((c) => c.order === currentOrder.value)
-    console.log('[store.moveToNextOrder] index:', index)
-
-    const next = contractOrders.value[index + 1]
-
-    if (next) {
-      const nextOrder = next.order
-
-      await postAiMessage(chatId, nextOrder)
-
-      await putRecentData(chatId, nextOrder)
-
-      // const res = await getContractInfo(chatId)
-      // if (res.success && res.data?.role === '임차인') {
-      //   await setStartPoint(chatId)
-      // }
-
-      setOrder(nextOrder)
-    } else {
-      clearOrder()
-    }
+    const idx = contractOrders.value.findIndex((c) => c.order === currentOrder.value)
+    if (import.meta.env.DEV) console.log('[store.moveToNextOrder] index:', idx)
+
+    const nextIdx = idx >= 0 ? idx + 1 : 0
+    const next = contractOrders.value[nextIdx]
+
+    if (next) {
+      const nextOrder = next.order
+      try {
+        await postAiMessage(chatId, nextOrder)
+        await putRecentData(chatId, nextOrder)
+        setOrder(nextOrder)
+      } catch (e) {
+        console.error('[store.moveToNextOrder] 다음 단계 전송 실패:', e)
+        // TODO: 사용자 알림/재시도 트리거 등 정책 확정 시 처리
+        throw e
+      }
+    } else {
+      clearOrder()
+    }

91-93: 빈 목록에서 true를 반환하는 every 함정 방지

every는 빈 배열에서 true를 반환합니다. contractOrders가 아직 주입되지 않았거나 비어있을 때 “검토 완료”로 잘못 표시될 수 있으니, 길이 체크를 추가하는 편이 안전합니다.

-  const isAllReviewCompleted = computed(() => {
-    return contractOrders.value.every((c) => orderStatusMap.value[c.order] === 'SUCCESS')
-  })
+  const isAllReviewCompleted = computed(() => {
+    const list = contractOrders.value
+    return list.length > 0 && list.every((c) => orderStatusMap.value[c.order] === 'SUCCESS')
+  })
src/apis/contractApi.js (1)

8-17: 에러 처리 일관성 및 파라미터 인코딩 권장

  • 현재 함수는 catch에서 rethrow 하지만, 같은 파일의 다른 API들(예: getContractBasic)은 error.response?.data를 반환합니다. 호출부 UX를 위해 에러 처리 방식을 통일하는 것이 좋습니다.
  • chatRoomId를 URL path에 그대로 삽입 중입니다. 슬래시/공백 등의 특수문자 안전성을 위해 encodeURIComponent 처리 권장합니다.

아래처럼 수정 제안드립니다.

   moveContractChat: async (chatRoomId) => {
     try {
-      const response = await api.get(`${CONTRACT_CHAT_BASE_URL}/${chatRoomId}/moveContractChat`)
+      const response = await api.get(
+        `${CONTRACT_CHAT_BASE_URL}/${encodeURIComponent(String(chatRoomId))}/moveContractChat`
+      )
       return response.data
     } catch (error) {
       console.error('계약 채팅방 이동 실패: ', error)
-      throw error
+      if (error.response?.data) {
+        return error.response.data
+      }
+      throw error
     }
   },

에러 반환 형태 변경이 호출부 기대값과 맞는지(예: MyPageContracts.vue에서의 처리) 한 번 더 확인 부탁드립니다.

src/components/risk-check/result/DetailedAnalysis.vue (2)

66-85: 중복 마크업 축소 제안(아이콘 블록)

아이콘 컴포넌트/클래스 바인딩이 세 블록에서 반복됩니다. 소규모 서브컴포넌트 or 렌더 헬퍼로 추출하면 유지보수성이 좋아집니다.

예: RiskIcon.vue로 분리하거나, 동일 파일 내에 함수형 컴포넌트로 래핑.

Also applies to: 109-114, 141-146


46-46: SAFE 위험도 색상 일관성 점검 필요

현재 SAFE(riskLevel==='SAFE')에 사용된 green 계열 토큰이 컴포넌트마다 다르게 적용되어 있습니다. 디자인 시스템에서 정의된 단일 토큰으로 통일해주세요.

  • src/components/risk-check/result/DetailedAnalysis.vue (라인 46): text-green-400
  • src/components/pre-contract/buyer/step2/Step2RiskResult.vue (라인 21): text-green-600
  • src/components/risk-check/result/OverallRiskSection.vue (라인 24): text-green-500
  • src/components/pre-contract/buyer/step6/Step6Confirm.vue (라인 48): text-green-600
  • src/components/contract/modals/step3/TermsReviewCard.vue (라인 19): text-green-400

위치별로 동일한 green 토큰(e.g. text-green-500)으로 변경 후, 전체 코드베이스에 일관되게 반영되었는지 최종 확인 부탁드립니다.

src/components/common/PropertyImage.vue (3)

58-83: processedSrc 보강 제안 — 프로토콜 상대 URL/Blob/Data URL 처리 및 공백 Trim

몇 가지 추가 케이스를 처리하면 안정성이 높아집니다.

  • '//'로 시작하는 프로토콜 상대 URL → https 보간.
  • 'blob:' / 'data:' 스키마는 그대로 허용.
  • 앞뒤 공백 제거.
 const processedSrc = computed(() => {
-  if (!props.src) return ''
+  if (!props.src) return ''
   
   // 배열인 경우 첫 번째 이미지 사용
   let srcUrl = props.src
   if (Array.isArray(props.src)) {
     if (props.src.length === 0) return ''
     srcUrl = props.src[0]
   }
   
-  if (!srcUrl || typeof srcUrl !== 'string') return ''
+  if (!srcUrl) return ''
+  if (typeof srcUrl !== 'string') {
+    try {
+      srcUrl = String(srcUrl)
+    } catch {
+      return ''
+    }
+  }
+  srcUrl = srcUrl.trim()
+  if (srcUrl === '') return ''
+
+  // blob:, data: 스키마는 그대로 사용
+  if (srcUrl.startsWith('blob:') || srcUrl.startsWith('data:')) {
+    return srcUrl
+  }
+
+  // 프로토콜 상대 URL 처리
+  if (srcUrl.startsWith('//')) {
+    return `https:${srcUrl}`
+  }
   
   // S3 URL 패턴 확인
   if (srcUrl.includes('.s3.') || srcUrl.includes('amazonaws.com')) {
     // 이미 전체 URL인 경우 그대로 반환
     if (srcUrl.startsWith('http://') || srcUrl.startsWith('https://')) {
       return srcUrl
     }
     // 프로토콜이 없는 경우 https 추가
     return `https://${srcUrl}
   }
   
   return srcUrl
 })

43-56: isInvalidImage 기준 보강 및 예외 도메인 하드코딩 최소화

  • 현재 example.com 포함 여부로 invalid 처리합니다. 의도된 플레이스홀더 도메인이라면 상수 리스트로 관리하거나 제거하는 것이 안전합니다.
  • blob:/data: 및 프로토콜 상대 URL도 유효 케이스로 허용하면 미리보기/로컬 업로드 시 시나리오를 커버할 수 있습니다.
 const isInvalidImage = computed(() => {
   const srcToCheck = processedSrc.value
   
   // S3 URL이나 unsplash 이미지는 유효한 것으로 처리
-  if (srcToCheck && (srcToCheck.includes('.s3.') || 
-                     srcToCheck.includes('amazonaws.com') || 
-                     srcToCheck.includes('unsplash.com'))) {
+  if (
+    srcToCheck &&
+    (
+      srcToCheck.includes('.s3.') ||
+      srcToCheck.includes('amazonaws.com') ||
+      srcToCheck.includes('unsplash.com') ||
+      srcToCheck.startsWith('blob:') ||
+      srcToCheck.startsWith('data:') ||
+      srcToCheck.startsWith('http://') ||
+      srcToCheck.startsWith('https://') ||
+      srcToCheck.startsWith('//')
+    )
+  ) {
     return imageError.value // 에러가 발생한 경우에만 invalid로 처리
   }
   
-  return !srcToCheck || 
-         srcToCheck === '/property-placeholder.jpg' || 
-         srcToCheck.includes('example.com') ||
-         imageError.value
+  return (
+    !srcToCheck ||
+    srcToCheck === '/property-placeholder.jpg' ||
+    imageError.value
+  )
 })

example.com 체크가 실제 데이터에서 사용 중인지 확인 부탁드립니다. 플레이스홀더 도메인이라면 상수로 분리하여 관리하면 오탐을 줄일 수 있습니다.


153-162: 이미지 로딩 UX/성능 개선: lazy/decoding 및 key 바인딩

  • 새로운 src로 변경 시 이미지 로딩 이벤트를 안정적으로 트리거하고, 초기 페인트를 지연해 성능을 개선합니다.
-    <img
-      v-if="processedSrc && !isInvalidImage"
-      :src="processedSrc"
+    <img
+      v-if="processedSrc && !isInvalidImage"
+      :key="processedSrc"
+      :src="processedSrc"
       :alt="alt"
       :class="imageClasses"
       @error="handleImageError"
       @load="handleImageLoad"
-      :style="{ display: imageLoading ? 'none' : 'block' }"
+      loading="lazy"
+      decoding="async"
+      :style="{ display: imageLoading ? 'none' : 'block' }"
     />
src/components/mypage/contracts/ContractsCard.vue (2)

25-40: '보기' 버튼의 동작이 UX와 불일치합니다 (완료 상태에서 다운로드 실행).

완료 상태(COMPLETED)에서 버튼 라벨은 '계약서 보기'이지만, 실제로는 handleView가 다운로드를 실행하고 있습니다. '보기'는 완료 페이지로 이동하고, 별도의 '다운로드' 버튼만 파일을 받도록 정렬하는 편이 UX 상 더 자연스럽습니다. 아래 handleView 정리 제안과 함께 적용을 고려해 주세요.


95-106: 다운로드 처리 안정화: 파일명 폴백 및 target 제거 제안.

  • cross-origin URL에서는 download 속성이 무시될 수 있으며, target="_blank"는 새 탭을 열어 UX를 해칠 수 있습니다. 다운로드만 원한다면 target은 제거하는 것이 안전합니다.
  • contract.contractId가 없을 경우 파일명이 'undefined'가 될 수 있습니다. id로 폴백하세요.
 const handleDownload = (contract) => {
   if (!contract.fileUrl) return

   // PDF 다운로드
   const link = document.createElement('a')
   link.href = contract.fileUrl
-  link.download = `계약서_${contract.contractId}.pdf`
-  link.target = '_blank'
+  const idForName = contract.contractId || contract.id || 'unknown'
+  link.download = `계약서_${idForName}.pdf`
   document.body.appendChild(link)
   link.click()
   document.body.removeChild(link)
 }

파일 다운로드가 동일 출처(same-origin)인지, 혹은 프록시/서버를 통해 서빙되는지 확인 부탁드립니다. cross-origin이라면 서버 측에서 Content-Disposition 헤더 설정을 권장합니다.

src/pages/mypage/MyPageContracts.vue (4)

52-64: 완료 상태 비교 일관화(대문자/소문자 모두 처리).

데이터 매핑에서 상태 대소문자가 섞일 수 있습니다. 템플릿 조건도 소문자 'completed'를 함께 허용하거나, 상태를 상단에서 대문자로 정규화하세요.

-              <i v-else-if="contract.status === 'COMPLETED'" class="fas fa-file-pdf"></i>
+              <i v-else-if="(contract.status || '').toUpperCase() === 'COMPLETED'" class="fas fa-file-pdf"></i>
@@
-                contract.status === 'COMPLETED' ? '계약서 보기' : '채팅방 이동' 
+                ((contract.status || '').toUpperCase() === 'COMPLETED') ? '계약서 보기' : '채팅방 이동' 

172-181: 완료 상태 판정 정규화 제안.

상태 비교 시 매번 문자열 비교 대신, 상단에서 한 번 정규화해 가독성과 안전성을 높일 수 있습니다.

-  // 완료된 계약의 경우 다운로드
-  if (contract.status === 'COMPLETED' && contract.fileUrl) {
+  // 완료된 계약의 경우 다운로드
+  const status = (contract.status || '').toUpperCase()
+  if (status === 'COMPLETED' && contract.fileUrl) {

225-254: 다운로드 파일명 안전화 및 target 제거(크로스 오리진 호환성).

  • createdAt이 비어있거나 Invalid Date인 경우 NaN이 포함된 파일명이 생성될 수 있습니다.
  • cross-origin에서 download 속성이 무시될 수 있고, target="_blank"는 새 탭을 열어 흐름을 방해할 수 있습니다.
-    const date = new Date(contract.createdAt)
-    const dateStr = `${date.getFullYear()}${String(date.getMonth() + 1).padStart(2, '0')}${String(date.getDate()).padStart(2, '0')}`
-    const fileName = `계약서_${dateStr}_${contract.id}.pdf`
+    const d = contract.createdAt ? new Date(contract.createdAt) : null
+    const isValid = d && !Number.isNaN(d.getTime())
+    const dateStr = isValid
+      ? `${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, '0')}${String(d.getDate()).padStart(2, '0')}`
+      : 'undated'
+    const safeId = contract.id || contract.contractId || 'unknown'
+    const fileName = `계약서_${dateStr}_${safeId}.pdf`
@@
-    link.download = fileName
-    link.target = '_blank' // 새 탭에서 열기
+    link.download = fileName

파일이 cross-origin이라면 서버에서 Content-Disposition 헤더를 설정하여 파일명을 보장하는 방식을 권장합니다. 현재 파일 URL이 동일 출처인지 확인 부탁드립니다.


281-297: 데이터 매핑 안정성: 상태 대문자 정규화 및 chatRoomId 폴백 검토.

  • 상태를 대문자로 정규화하면 템플릿/스타일 분기 일관성이 높아집니다.
  • chatRoomId를 contractId로 폴백하면 존재하지 않는 채팅방으로 라우팅될 수 있습니다(에러 처리 있음). 폴백이 실제 요구사항인지 확인해 주세요.
-        console.log('계약서 목록 API 응답:', response)
+        // console.log('계약서 목록 API 응답:', response)
@@
-        console.log('원본 계약 데이터:', contract)
+        // console.log('원본 계약 데이터:', contract)
@@
-          status: contract.status || 'STEP0',
+          status: (contract.status || 'STEP0').toUpperCase(),
@@
-        console.log('매핑된 계약 데이터:', mappedContract)
+        // console.log('매핑된 계약 데이터:', mappedContract)

chatRoomId 폴백을 contractId로 두는 것이 의도인지 확인 부탁드립니다. 의도라면 라우터에서 해당 폴백 케이스를 처리하는지(예: 계약 채팅 생성/리다이렉션)도 함께 점검이 필요합니다.

src/components/common/PropertyItem.vue (3)

52-69: S3·프로토콜 처리 로직 개선 제안(공백/스킴리스/데이터 URL 대응)

현재 로직은 S3 도메인·프로토콜 유무를 잘 다루고 있으나, 다음 케이스에서 안전하지 않을 수 있습니다:

  • 공백이 포함된 값
  • 스킴리스 URL(//bucket.s3...)
  • data:/blob: 스킴(이미지 인라인/Blob) — 현 로직에서 불필요하게 https://를 붙일 위험

간단 보완안 제안드립니다.

아래처럼 보완하면 방어적입니다:

 const processedImageUrl = computed(() => {
   if (!props.property?.image) return ''
-  
-  const url = props.property.image
-  
-  // S3 URL 패턴 확인
-  if (url.includes('.s3.') || url.includes('amazonaws.com')) {
-    // 이미 전체 URL인 경우 그대로 반환
-    if (url.startsWith('http://') || url.startsWith('https://')) {
-      return url
-    }
-    // 프로토콜이 없는 경우 https 추가
-    return `https://${url}`
-  }
-  
-  return url
+
+  const url = String(props.property.image).trim()
+
+  // 스킴리스 URL: //example.com
+  if (url.startsWith('//')) {
+    return `https:${url}`
+  }
+  // data:, blob: 등은 그대로 사용
+  if (url.startsWith('data:') || url.startsWith('blob:')) {
+    return url
+  }
+  // 이미 절대 URL이면 그대로
+  if (url.startsWith('http://') || url.startsWith('https://')) {
+    return url
+  }
+  // S3 URL 패턴 확인
+  if (url.includes('.s3.') || url.includes('amazonaws.com')) {
+    return `https://${url}`
+  }
+  // 그 외는 원본 유지 (상대경로 등)
+  return url
 })

69-70: 이미지 URL 변경 시 로딩/에러 상태 초기화 필요

새 URL로 바뀌어도 이전 에러 상태가 유지되어 이미지가 다시 시도되지 않을 수 있습니다. URL 변경 시 상태를 초기화하세요. (watch 추가 필요)

아래 코드 추가(그리고 watch import 추가):

 })
 
+// 이미지 URL이 바뀌면 상태 초기화
+watch(processedImageUrl, () => {
+  imageError.value = false
+  imageLoading.value = true
+})

그리고 import 수정:

-import { ref, computed } from 'vue'
+import { ref, computed, watch } from 'vue'

114-116: 렌더 조건 일관성(스피너/이미지/플레이스홀더) 정렬 권장

이미지 태그와 플레이스홀더는 processedImageUrl를 기준으로 조건을 걸고 있으나, 로딩 스피너는 여전히 property.image를 사용합니다. 처리 기준을 모두 processedImageUrl로 통일하면 예외(예: 프로토콜 보정 후에만 표시되는 URL)에서 의도치 않은 깜빡임/미표시를 줄일 수 있습니다.

스피너 v-if도 processedImageUrl 기준으로 바꾸는 것을 권장합니다:

<!-- 현 상태: v-if="imageLoading && property.image && !imageError" -->
<!-- 제안: v-if="imageLoading && processedImageUrl && !imageError" -->

Also applies to: 126-127

src/pages/risk-check/RiskCheckResult.vue (1)

248-257: history.state 직접 접근은 브라우저/런타임별 가변성 있음(가드 권장)

router.options.history.state.back 접근은 환경에 따라 undefined일 수 있습니다. 안전 가드 추가를 권장합니다.

-  const previousRoute = router.options.history.state.back
+  const previousRoute = router.options?.history?.state?.back

또는 문자열 여부도 함께 검사:

if (typeof previousRoute === 'string' && previousRoute.includes('/risk-check/confirm')) { ... }
src/pages/mypage/MyPageProperties.vue (2)

42-42: PropertyImage가 배열 src를 지원한다면, 전체 배열 전달 권장

첫 번째 이미지만 전달하면 실패 시 대체 후보로 넘어갈 수 없습니다. 해당 컴포넌트가 배열 기반 폴백을 지원한다면 전체 배열을 넘기세요.

-            :src="property.images && property.images.length > 0 ? property.images[0] : ''"
+            :src="property.images"

165-165: 삭제 흐름 개선 전반 LGTM + 401 가드 제안

  • 확인 모달 → API 호출 → 로컬 목록 제거 → 알림 순서가 자연스럽습니다.
  • 에러 메시지 분기(404/403/400)도 좋습니다.
  • 추가로 401(세션 만료) 시 로그인 화면으로 유도하면 UX가 좋아집니다.
       if (error.response) {
         if (error.response.status === 404) {
           errorMessage = '삭제하려는 매물을 찾을 수 없습니다.'
         } else if (error.response.status === 403) {
           errorMessage = '이 매물을 삭제할 권한이 없습니다.'
         } else if (error.response.status === 400) {
           errorMessage = '이미 계약이 진행 중인 매물은 삭제할 수 없습니다.'
+        } else if (error.response.status === 401) {
+          errorMessage = '로그인이 만료되었습니다. 다시 로그인해 주세요.'
+          router.push('/auth/signin')
         }
       }

Also applies to: 230-261

src/pages/mypage/MyPageHome.vue (2)

490-503: 디버그 로그 정리 제안

console.log는 개발 중에는 유용하지만, 배포 시 노이즈가 됩니다. console.error는 유지하되 일반 로그는 제거를 권장합니다.

-  console.log('삭제할 매물:', property)
...
-      console.log('삭제 요청 ID:', propertyId)

561-568: 중복 식별자 정리 (id vs contractId 통합)
ContractsCard.vue에서 contract.id는 라우팅에, contract.contractId는 다운로드 파일명에 각각 참조되고 있습니다. MyPageHome.vue와 MyPageContracts.vue 맵핑부에서 두 키를 동일값으로 중복 설정하고 있으니, 하나의 식별자로 통일하고 나머지 참조를 해당 키로 변경하세요.

  • MyPageHome.vue (557–560행)

    contracts.value = data.content.map((contract) => ({
      id: contract.contractId,
      contractId: contract.contractId,
      /* … */
    }))

    contractId 하나만 남기거나, id 하나만 남기고 나머지 참조를 일괄 교체

  • MyPageContracts.vue (283–290행)
    마찬가지로 맵핑부에서 idcontractId 중 하나만 유지

  • ContractsCard.vue

    • router.push에 사용하는 ${contract.id}
    • link.download = \계약서_${contract.contractId}.pdf``
      중복 키를 삭제한 뒤에도 위 기능이 정상 동작하도록, 참조 키를 통일
📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between abbd48c and d3b770b.

📒 Files selected for processing (16)
  • src/apis/contractApi.js (1 hunks)
  • src/apis/mypage.js (1 hunks)
  • src/components/alarm/AlarmDropdown.vue (1 hunks)
  • src/components/common/PropertyImage.vue (4 hunks)
  • src/components/common/PropertyItem.vue (4 hunks)
  • src/components/mypage/contracts/ContractsCard.vue (4 hunks)
  • src/components/risk-check/PropertyCard.vue (1 hunks)
  • src/components/risk-check/RiskCheckHistoryModal.vue (1 hunks)
  • src/components/risk-check/confirm/BuildingRegistryForm.vue (1 hunks)
  • src/components/risk-check/result/DetailedAnalysis.vue (8 hunks)
  • src/pages/mypage/MyPageContracts.vue (6 hunks)
  • src/pages/mypage/MyPageHome.vue (2 hunks)
  • src/pages/mypage/MyPageProperties.vue (6 hunks)
  • src/pages/risk-check/RiskCheckResult.vue (4 hunks)
  • src/stores/mypage.js (1 hunks)
  • src/stores/useContractTermStore.js (1 hunks)
🧰 Additional context used
🧬 Code Graph Analysis (3)
src/apis/contractApi.js (1)
src/apis/index.js (1)
  • api (4-9)
src/apis/mypage.js (1)
src/apis/index.js (1)
  • api (4-9)
src/stores/mypage.js (1)
src/apis/mypage.js (16)
  • response (9-9)
  • response (23-28)
  • response (39-39)
  • response (50-50)
  • response (61-63)
  • response (74-76)
  • response (87-89)
  • response (102-102)
  • response (113-113)
  • response (124-129)
  • response (140-140)
  • response (151-151)
  • response (162-162)
  • response (173-173)
  • response (184-186)
  • response (197-199)
🔇 Additional comments (19)
src/components/alarm/AlarmDropdown.vue (1)

192-193: 라우터 설정 직접 확인 필요
스크립트 검색 결과 /contract-chat 경로 및 ContractChat 네임드 라우트를 찾을 수 없습니다.
라우터 설정 파일(src/router/index.ts 등)을 열어 아래가 정의되어 있는지 수동으로 검증해주세요:

  • path: /contract-chat/:id
  • name: 'ContractChat'

그리고 문자열 조합 대신 네임드 라우트+params 사용을 권장하며, 최소한 ID 인코딩은 추가하세요:

- targetUrl = `/contract-chat/${notification.relatedId}`
+ targetUrl = `/contract-chat/${encodeURIComponent(String(notification.relatedId))}`
await router.push({
  name: 'ContractChat',
  params: { id: String(notification.relatedId) }
})
src/stores/useContractTermStore.js (2)

76-81: clear 후 초기값 일관성 확인: null로 초기화 vs 1로 재설정

이번 변경으로 “초기(새 세션) = 1”이지만, clearOrder()currentOrdernull로 되돌립니다. “초기화”의 의미를 “처음 단계(1)로 이동”으로 보고 싶다면 1로 재설정하는 것이 일관되고, “완전히 비움(미시작 상태)”이라면 현재 구현이 맞습니다. 사용하는 화면에서 어떤 의미를 기대하는지 확인 부탁드립니다.


6-6: currentOrder 기본값 변경 영향 없음 확인됨

저장소 전반에서 currentOrder === null 또는 undefined 비교, clearOrder() 호출 후 currentOrder = null 대입, 또는 truthy/falsy 분기 처리를 찾을 수 없었습니다.
따라서 currentOrder 기본값을 1로 설정해도 기존 “미시작(null)” 상태를 가정하는 로직은 존재하지 않아, 변경에 따른 부작용은 없습니다.
이대로 머지 가능합니다.

src/apis/contractApi.js (1)

4-4: 상수 분리 👍 — 계약 채팅 전용 BASE_URL 도입은 가독성과 유지보수에 유리합니다.

CONTRACT_CHAT_BASE_URL 분리는 엔드포인트 관리에 도움이 됩니다. 기존 CONTRACT_BASE_URL과의 패턴 일관성도 잘 맞습니다.

src/apis/mypage.js (1)

160-163: 삭제 엔드포인트 및 리소스 경로 검토 요청

  • 삭제 API(/api/homes/${homeId}) 호출부는 모든 곳에서 정상 반영되었습니다.
  • 목록 조회는 여전히 /api/mypage/properties를 사용 중인데, 백엔드 권한/스코프 관점에서 서로 다른 리소스 경로가 혼용된 것이 의도된 설계인지 확인 부탁드립니다.
  • homeId에 URI 인코딩을 적용해 안전성을 높이는 것을 권장드립니다.
- const response = await api.delete(`/api/homes/${homeId}`)
+ const response = await api.delete(
+   `/api/homes/${encodeURIComponent(String(homeId))}`
+ )
src/components/risk-check/confirm/BuildingRegistryForm.vue (1)

98-98: 포맷팅 정리 LGTM

발급일 필드를 단일 라인으로 정리한 변경은 의미 동일하며 가독성만 개선되었습니다.

src/components/common/PropertyImage.vue (1)

7-7: src prop 다형성 수용 좋습니다

String|Array 모두를 수용하도록 변경한 점 유연하고 실제 데이터 다양성에 대응하기 좋습니다.

src/components/mypage/contracts/ContractsCard.vue (1)

213-213: 버튼 아이콘 크기와 간격 조정 LGTM.

시인성이 좋아졌고, 버튼 간격/아이콘 크기가 일관적으로 정리되었습니다.

Also applies to: 220-223, 224-230

src/pages/mypage/MyPageContracts.vue (1)

256-260: 기존 시그니처 유지하며 위임하는 구조 LGTM.

외부 호출부 변경 없이 보안 안내를 포함한 다운로드 로직으로 위임되어 일관성이 좋아졌습니다.

src/components/risk-check/PropertyCard.vue (1)

436-437: PropertyItem에 맞춘 image 키 변경 LGTM.

PropertyItem이 image 필드를 기대하도록 정리된 변경과 일관됩니다. 상위 fetch 로직에서도 imageUrl -> image 매핑이 적용되어 있어 문제없습니다.

src/components/risk-check/RiskCheckHistoryModal.vue (1)

55-55: imageUrl -> image 매핑 변경 LGTM.

PropertyItem의 인터페이스 정리에 맞춘 변경으로, RiskCheck 히스토리에서도 일관된 렌더링이 보장됩니다.

src/pages/risk-check/RiskCheckResult.vue (4)

175-182: 이미지 소스 다중 후보·폴백 도입 좋습니다

여러 백엔드 필드에 대응하고 마지막에 플레이스홀더로 폴백하는 전략이 견고합니다. 상위(카드/이미지 컴포넌트) 변경 사항과도 정합성이 좋습니다.


235-241: analysisResult의 이미지 후보 확장 LGTM

currentProperty 우선 후 백엔드 필드 다중 후보 → 플레이스홀더 순으로 일관된 폴백 체인이 잘 구성되어 있습니다.


301-303: 문구/헤더 블록화 리팩터링 무해

시맨틱/접근성 측면 영향 없고 가독성만 개선되었습니다.


384-388: RecommendedServices 이벤트 바인딩 정리 LGTM

@analyze-another 이벤트를 위로 끌어올려 재분석 흐름을 명확히 했습니다.

src/pages/mypage/MyPageProperties.vue (3)

79-87: 삭제 진행 중 버튼 비활성화/스피너 UX 좋습니다

중복 클릭/중복 요청 방지와 진행 상태 피드백이 명확합니다.


275-280: 백엔드 응답 매핑 합리적입니다

  • type/status/likeCount 다중 소스 매핑이 안정적입니다.
  • imageUrl → 배열 변환으로 이미지 컴포넌트와의 정합성 확보도 좋습니다.

609-621: hover 비활성화 및 disabled 스타일 분리 적용 좋습니다

의도된 상태 전환만 허용되어 UX가 깔끔해집니다.

src/pages/mypage/MyPageHome.vue (1)

485-486: 수정 라우팅에서 propertyId 우선 사용 합리적

데이터 소스에 따라 id/ propertyId가 혼재할 수 있는데, 우선순위 처리로 안전성을 확보했습니다.

Comment on lines +68 to +93
const handleView = (contract) => {
// 완료된 계약서는 완료 페이지로
if (contract.status === 'COMPLETED' || contract.status === 'completed') {
if (contract.fileUrl) {
// PDF 다운로드
handleDownload(contract)
}
} else if (contract.chatRoomId || contract.contractId) {
// 채팅방으로 이동
if (contract.status == 'STEP0') {
router.push(
`/pre-contract/${contract.id}/${contract.userType === 'BUYER' ? 'buyer' : 'owner'}`,
)
} else if (contract.status == 'STEP1' || contract.status == 'STEP2') {
router.push(`/contract/${contract.id}/step1`)
} else if (['ROUND0', 'ROUND1', 'ROUND2', 'ROUND3'].includes(contract.status)) {
router.push(`/contract/${contract.id}/round`)
} else if (contract.status == 'COMPLETED') {
router.push(`/contract/${contract.id}/completed`)
} else if (contract.status == 'STEP4') {
router.push(`/contract/${contract.id}`)
} else if (contract.status == 'COMPLETED') {
router.push(`/contract/completed/${contract.id}`)
}
}
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

중복·도달 불가능 분기와 느슨한 비교 연산자 사용 정리 필요.

  • COMPLETED 분기가 두 번 등장하며 두 번째는 도달 불가입니다(라인 85–91, 89–91).
  • 상태 비교에 '=='가 혼재되어 있습니다. '==='로 통일하세요.
  • 완료 상태이지만 fileUrl이 없는 경우 아무 동작도 하지 않습니다. 완료 페이지로 이동하도록 보완해 주세요.
  • 라우트 경로가 두 가지로 섞여 있습니다. 한 가지로 일관화하세요(예: /contract/completed/{id}).
  • id와 contractId 혼용으로 파일명/라우팅 불일치 가능성이 있습니다. 안전한 폴백을 두세요.

아래 최소 수정안 제안드립니다.

 const handleView = (contract) => {
-  // 완료된 계약서는 완료 페이지로
-  if (contract.status === 'COMPLETED' || contract.status === 'completed') {
-    if (contract.fileUrl) {
-      // PDF 다운로드
-      handleDownload(contract)
-    }
-  } else if (contract.chatRoomId || contract.contractId) {
+  // 완료된 계약서
+  const isCompleted = (contract.status || '').toUpperCase() === 'COMPLETED'
+  if (isCompleted) {
+    if (contract.fileUrl) {
+      // PDF 다운로드
+      handleDownload(contract)
+    } else {
+      // 파일이 없으면 완료 페이지로 이동
+      router.push(`/contract/completed/${contract.id || contract.contractId}`)
+    }
+  } else if (contract.chatRoomId || contract.contractId) {
     // 채팅방으로 이동
-    if (contract.status == 'STEP0') {
+    if (contract.status === 'STEP0') {
       router.push(
         `/pre-contract/${contract.id}/${contract.userType === 'BUYER' ? 'buyer' : 'owner'}`,
       )
-    } else if (contract.status == 'STEP1' || contract.status == 'STEP2') {
+    } else if (contract.status === 'STEP1' || contract.status === 'STEP2') {
       router.push(`/contract/${contract.id}/step1`)
     } else if (['ROUND0', 'ROUND1', 'ROUND2', 'ROUND3'].includes(contract.status)) {
       router.push(`/contract/${contract.id}/round`)
-    } else if (contract.status == 'COMPLETED') {
-      router.push(`/contract/${contract.id}/completed`)
-    } else if (contract.status == 'STEP4') {
+    } else if (contract.status === 'STEP4') {
       router.push(`/contract/${contract.id}`)
-    } else if (contract.status == 'COMPLETED') {
-      router.push(`/contract/completed/${contract.id}`)
     }
   }
 }

완료 상세 페이지의 실제 라우트 규칙이 /contract/${id}/completed 인지 /contract/completed/${id} 인지 확인이 필요합니다. 둘 중 하나로 통일해 주세요. 필요하면 관련 라우터 설정 파일을 공유해 주시면 정밀하게 맞춰드리겠습니다.


🏁 Script executed:

#!/bin/bash
# 라우터 설정 파일 위치 검색
echo "=== 라우터 인스턴스 생성 지점 ==="
rg -l "createRouter" -g "src/**/*.{js,ts}" || true
rg -l "new VueRouter" -g "src/**/*.{js,ts}" || true

# /contract/:id/completed 경로 정의 확인
echo "=== path '/contract/:id/completed' 검색 ==="
rg -n "path\s*:\s*['\"]/contract/:id/completed['\"]" -g "src/**/*.{js,ts}" || true

# /contract/completed/:id 경로 정의 확인
echo "=== path '/contract/completed/:id' 검색 ==="
rg -n "path\s*:\s*['\"]/contract/completed/:id['\"]" -g "src/**/*.{js,ts}" || true

Length of output: 544


🏁 Script executed:

#!/bin/bash
# src/router/index.js 내 'contract' 경로 정의 확인
echo "=== src/router/index.js에서 'contract' 관련 경로 검색 ==="
rg -n "contract" -g "src/router/index.js" -C3

Length of output: 1916


🏁 Script executed:

#!/bin/bash
# 라우터 설정 디렉토리 확인
echo "=== src/router 디렉토리 파일 리스트 ==="
ls src/router

# contractRoutes 파일에서 path 정의 검색
echo "=== src/router/contract.* 에서 path 정의 검색 ==="
rg -n "path\s*:" -g "src/router/contract.*"

Length of output: 724


중복 분기 제거 및 비교·라우팅 로직 일관화 필요

  • COMPLETED 분기가 두 번 선언되어 두 번째 분기는 절대 도달하지 않습니다 (src/components/mypage/contracts/ContractsCard.vue:68–93).
  • == 연산자가 혼용되어 있으니 모두 ===로 변경하세요.
  • 완료 상태인데 fileUrl이 없으면 아무 동작도 하지 않습니다. PDF가 없을 땐 완료 페이지(/contract/:id/complete)로 이동하도록 보완해야 합니다.
  • 라우터 설정(src/router/contract.js)을 보면 완료 페이지 경로는 /contract/:id/complete입니다. 코드에서 이 경로로 통일하세요.
  • idcontractId가 혼용되어 있으므로 contract.id || contract.contractId 형태로 안전한 폴백을 추가하세요.

아래 최소 수정안 예시입니다:

const handleView = (contract) => {
-  // 완료된 계약서는 완료 페이지로
-  if (contract.status === 'COMPLETED' || contract.status === 'completed') {
-    if (contract.fileUrl) {
-      // PDF 다운로드
-      handleDownload(contract)
-    }
-  } else if (contract.chatRoomId || contract.contractId) {
+  // 완료 상태 판단
+  const status = (contract.status || '').toUpperCase()
+  const isCompleted = status === 'COMPLETED'
+  if (isCompleted) {
+    if (contract.fileUrl) {
+      // PDF 다운로드
+      handleDownload(contract)
+    } else {
+      // 파일 없으면 완료 페이지로 이동
+      const cid = contract.id || contract.contractId
+      router.push(`/contract/${cid}/complete`)
+    }
+  } else if (contract.chatRoomId || contract.contractId) {
     // 채팅방 또는 사전 계약 단계로 이동
-    if (contract.status == 'STEP0') {
+    if (contract.status === 'STEP0') {
       router.push(
         `/pre-contract/${contract.id}/${contract.userType === 'BUYER' ? 'buyer' : 'owner'}`,
       )
-    } else if (contract.status == 'STEP1' || contract.status == 'STEP2') {
+    } else if (contract.status === 'STEP1' || contract.status === 'STEP2') {
       router.push(`/contract/${contract.id}/step1`)
     } else if (['ROUND0', 'ROUND1', 'ROUND2', 'ROUND3'].includes(contract.status)) {
       router.push(`/contract/${contract.id}/round`)
-    } else if (contract.status == 'COMPLETED') {
-      router.push(`/contract/${contract.id}/completed`)
-    } else if (contract.status == 'STEP4') {
+    } else if (contract.status === 'STEP4') {
       router.push(`/contract/${contract.id}`)
-    } else if (contract.status == 'COMPLETED') {
-      router.push(`/contract/completed/${contract.id}`)
     }
   }
 }
📝 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.

Suggested change
const handleView = (contract) => {
// 완료된 계약서는 완료 페이지로
if (contract.status === 'COMPLETED' || contract.status === 'completed') {
if (contract.fileUrl) {
// PDF 다운로드
handleDownload(contract)
}
} else if (contract.chatRoomId || contract.contractId) {
// 채팅방으로 이동
if (contract.status == 'STEP0') {
router.push(
`/pre-contract/${contract.id}/${contract.userType === 'BUYER' ? 'buyer' : 'owner'}`,
)
} else if (contract.status == 'STEP1' || contract.status == 'STEP2') {
router.push(`/contract/${contract.id}/step1`)
} else if (['ROUND0', 'ROUND1', 'ROUND2', 'ROUND3'].includes(contract.status)) {
router.push(`/contract/${contract.id}/round`)
} else if (contract.status == 'COMPLETED') {
router.push(`/contract/${contract.id}/completed`)
} else if (contract.status == 'STEP4') {
router.push(`/contract/${contract.id}`)
} else if (contract.status == 'COMPLETED') {
router.push(`/contract/completed/${contract.id}`)
}
}
}
const handleView = (contract) => {
// 완료 상태 판단
const status = (contract.status || '').toUpperCase()
const isCompleted = status === 'COMPLETED'
if (isCompleted) {
if (contract.fileUrl) {
// PDF 다운로드
handleDownload(contract)
} else {
// 파일 없으면 완료 페이지로 이동
const cid = contract.id || contract.contractId
router.push(`/contract/${cid}/complete`)
}
} else if (contract.chatRoomId || contract.contractId) {
// 채팅방 또는 사전 계약 단계로 이동
if (contract.status === 'STEP0') {
router.push(
`/pre-contract/${contract.id}/${contract.userType === 'BUYER' ? 'buyer' : 'owner'}`,
)
} else if (contract.status === 'STEP1' || contract.status === 'STEP2') {
router.push(`/contract/${contract.id}/step1`)
} else if (['ROUND0', 'ROUND1', 'ROUND2', 'ROUND3'].includes(contract.status)) {
router.push(`/contract/${contract.id}/round`)
} else if (contract.status === 'STEP4') {
router.push(`/contract/${contract.id}`)
}
}
}
🤖 Prompt for AI Agents
In src/components/mypage/contracts/ContractsCard.vue around lines 68–93: remove
the duplicate COMPLETED branch and consolidate routing logic so each status is
handled once; replace all == with === for strict comparisons; when
contract.status indicates completion but contract.fileUrl is missing, route to
the completed page at /contract/:id/complete (use
router.push(`/contract/${id}/complete`)); normalize id usage by deriving const
id = contract.id || contract.contractId and use that value for all pushes;
ensure other status branches use === and the desired paths
(/pre-contract/:id/:role, /contract/:id/step1, /contract/:id/round,
/contract/:id for STEP4) so routing is consistent with src/router/contract.js.

Comment on lines +139 to 150
console.log('Store: 매물 삭제 요청 - propertyId:', propertyId)
const response = await mypageAPI.deleteProperty(propertyId)
if (response.success) {
// 목록에서 제거
properties.value = properties.value.filter((p) => p.id !== propertyId)
// 목록에서 제거 (propertyId 또는 id로 필터링)
properties.value = properties.value.filter((p) =>
(p.propertyId !== propertyId) && (p.id !== propertyId)
)
summary.value.propertyCount--
console.log('Store: 매물 삭제 성공')
}
return response
} catch (error) {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

삭제 API 응답 처리 신뢰성 개선 필요(204/빈 본문 대응).

apis/mypage.js의 deleteProperty는 response.data를 그대로 반환합니다. 서버가 204 No Content를 반환하면 response가 undefined여서 현재의 if (response.success) 분기가 스킵되어 로컬 목록/카운트가 갱신되지 않습니다. 예외가 없으면 성공으로 간주하는 방식을 권장합니다(또는 success 플래그가 명시적으로 false일 때만 실패 처리).

-      const response = await mypageAPI.deleteProperty(propertyId)
-      if (response.success) {
+      const response = await mypageAPI.deleteProperty(propertyId)
+      // success 플래그가 명시적으로 false가 아닌 경우를 성공으로 간주 (204/빈 본문 대응)
+      const succeeded = response?.success !== false
+      if (succeeded) {
         // 목록에서 제거 (propertyId 또는 id로 필터링)
         properties.value = properties.value.filter((p) => 
           (p.propertyId !== propertyId) && (p.id !== propertyId)
         )
-        summary.value.propertyCount--
-        console.log('Store: 매물 삭제 성공')
+        summary.value.propertyCount = Math.max(0, summary.value.propertyCount - 1)
+        // console.log('Store: 매물 삭제 성공')
       }
       return response

아래 스크립트로 deleteProperty 응답 형태를 확인해 주세요. success 플래그 유무/형태에 따라 상기 분기 조건을 확정할 수 있습니다.


🏁 Script executed:

#!/bin/bash
# deleteProperty 구현과 호출부에서 'success' 사용 여부 조사
rg -n -C2 "deleteProperty\s*\(" src/apis src/stores | sed -n '1,200p'
echo "----"
rg -n -C2 "deleteProperty:" src/apis | sed -n '1,200p'

Length of output: 715


아래 스크립트로 src/apis/mypage.js에서 deleteProperty 함수의 전체 구현부와 반환 로직을 확인해 주세요.


🏁 Script executed:

#!/bin/bash
# deleteProperty 구현부(150~260라인) 및 반환(return) 로직 확인
sed -n '150,260p' src/apis/mypage.js

Length of output: 1402


삭제 API 응답 처리 신뢰성 개선 필요 (204 No Content/빈 본문 대응)
deletePropertyresponse.data만 반환하므로, 서버에서 204 No Content를 응답할 경우 responseundefined가 되어 if (response.success) 분기가 스킵되고 로컬 목록 삭제 및 카운트 갱신이 이루어지지 않습니다. 에러가 발생하지 않는 한(즉, success 플래그가 명시적으로 false인 경우만) 실패로 간주하도록 분기 로직을 수정해주세요.

대상 파일/위치:

  • src/stores/mypage.js: 약 140~150행

제안 변경 내용:

-      const response = await mypageAPI.deleteProperty(propertyId)
-      if (response.success) {
+      const response = await mypageAPI.deleteProperty(propertyId)
+      // success 플래그가 명시적으로 false가 아닌 경우 성공으로 간주 (204/빈 본문 대응)
+      const succeeded = response?.success !== false
+      if (succeeded) {
         // 목록에서 제거 (propertyId 또는 id로 필터링)
         properties.value = properties.value.filter((p) =>
           (p.propertyId !== propertyId) && (p.id !== propertyId)
         )
-        summary.value.propertyCount--
-        console.log('Store: 매물 삭제 성공')
+        summary.value.propertyCount = Math.max(0, summary.value.propertyCount - 1)
+        // console.log('Store: 매물 삭제 성공')
       }
       return response
📝 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.

Suggested change
console.log('Store: 매물 삭제 요청 - propertyId:', propertyId)
const response = await mypageAPI.deleteProperty(propertyId)
if (response.success) {
// 목록에서 제거
properties.value = properties.value.filter((p) => p.id !== propertyId)
// 목록에서 제거 (propertyId 또는 id로 필터링)
properties.value = properties.value.filter((p) =>
(p.propertyId !== propertyId) && (p.id !== propertyId)
)
summary.value.propertyCount--
console.log('Store: 매물 삭제 성공')
}
return response
} catch (error) {
console.log('Store: 매물 삭제 요청 - propertyId:', propertyId)
const response = await mypageAPI.deleteProperty(propertyId)
// success 플래그가 명시적으로 false가 아닌 경우 성공으로 간주 (204/빈 본문 대응)
const succeeded = response?.success !== false
if (succeeded) {
// 목록에서 제거 (propertyId 또는 id로 필터링)
properties.value = properties.value.filter((p) =>
(p.propertyId !== propertyId) && (p.id !== propertyId)
)
summary.value.propertyCount = Math.max(0, summary.value.propertyCount - 1)
// console.log('Store: 매물 삭제 성공')
}
return response
🤖 Prompt for AI Agents
In src/stores/mypage.js around lines 139-150, the delete branch currently checks
if (response.success) which fails when the API returns 204 No Content (response
undefined) and prevents local removal; change the logic to treat absence of an
explicit failure as success — i.e., consider the delete successful unless
response exists and response.success === false — then perform the properties
filtering and summary decrement; also if the API returns no body,
synthesize/return a success object (or return a normalized { success: true }) so
callers get a consistent response shape.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

♻️ Duplicate comments (1)
src/pages/HomePage.vue (1)

6-13: 영상 src의 '&'로 인한 번들/배포 이슈 — 파일명 정리 또는 URL 인코딩, 그리고 a11y/성능 속성 추가 제안

현재 /panda&lion.mp4는 HTML 파서/번들러에서 문제를 유발할 수 있습니다. 파일명을 바꾸거나 %26으로 인코딩하세요. 배경 장식용 비디오라면 poster, preload, aria-hidden도 함께 권장합니다.

아래처럼 수정을 제안합니다(파일명을 실제 자산과 일치시키세요):

-      <video
-        class="pointer-events-none absolute left-0 top-0 h-full w-full object-cover xl:w-[60vw] 2xl:w-[60vw]"
-        src="/panda&lion.mp4"
-        autoplay
-        muted
-        loop
-        playsinline
-      ></video>
+      <video
+        class="hero-video pointer-events-none absolute left-0 top-0 h-full w-full object-cover xl:w-[60vw] 2xl:w-[60vw]"
+        src="/panda-lion.mp4"
+        poster="/hero-fallback.jpg"
+        preload="metadata"
+        autoplay
+        muted
+        loop
+        playsinline
+        aria-hidden="true"
+      ></video>

추가 확인:

  • 정적 자산이 실제로 panda-lion.mp4(또는 panda%26lion.mp4)로 존재하는지 확인.
  • poster 경로가 유효한지 확인(없으면 속성 제거 또는 올바른 경로로 교체).
🧹 Nitpick comments (5)
src/pages/HomePage.vue (5)

16-18: 오른쪽 정렬 텍스트 대비 개선을 위한 그라디언트 방향 조정 제안

텍스트가 우측에 위치하므로, 좌→우 그라디언트는 우측 대비가 낮아질 수 있습니다. 우측을 더 어둡게(왼쪽 방향 그라디언트) 하여 가독성을 높이는 것을 권장합니다.

-      <div
-        class="absolute inset-0 bg-gradient-to-r from-black/30 via-black/10 to-transparent pointer-events-none"
-      ></div>
+      <div
+        class="absolute inset-0 bg-gradient-to-l from-black/40 via-black/20 to-transparent pointer-events-none"
+      ></div>

31-34: 히어로 문단 텍스트 대비 개선(밝은 배경 대비 어두운 텍스트 충돌 가능)

비디오 배경 위에서 text-gray-800/90는 상황에 따라 대비가 낮을 수 있습니다. text-white/90 등으로 상향하는 것을 권장합니다.

-            <p class="text-lg md:text-xl text-gray-800/90 mb-8 ml-auto drop-shadow">
+            <p class="text-lg md:text-xl text-white/90 mb-8 ml-auto drop-shadow">
               AI 기술로 사기 위험을 분석하고,
               <br />스마트 계약서 작성부터 안전한 거래까지 모든 과정을 지원합니다.
             </p>

50-57: 장식용 아이콘의 접근성 개선(스크린리더 노이즈 제거)

장식용 SVG는 스크린리더에서 건너뛰도록 aria-hidden="true"를 추가하는 것이 좋습니다.

-                <svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
+                <svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
...
-                <svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
+                <svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">
...
-                <svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
+                <svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true">

Also applies to: 60-67, 70-77


47-79: 중복된 아이콘 마크업(DRY 위반) — 재사용 컴포넌트로 추출 제안

동일한 체크 아이콘 SVG가 반복됩니다. 소형 Icon 컴포넌트(예: Heroicons 사용 또는 BaseIconCheck 컴포넌트)로 추출하면 유지보수가 쉬워집니다.


280-287: prefers-reduced-motion 대응: CSS만으로는 재생 중지 불가 — 비디오 일시정지/대체 처리 제안

CSS의 animation 비활성화만으로는 영상 재생(모션)을 멈출 수 없습니다. 사용자 설정을 감지해 autoplay 해제 및 pause 처리, 또는 poster로 대체를 권장합니다.

추가 변경(파일 밖 영역) 예시:

템플릿의

-      <video
+      <video
+        ref="heroVideo"
         class="hero-video pointer-events-none absolute left-0 top-0 h-full w-full object-cover xl:w-[60vw] 2xl:w-[60vw]"
         ...
      ></video>
<script setup> 보강: ```ts import { onMounted, ref } from 'vue' const heroVideo = ref(null) onMounted(() => { const mql = window.matchMedia?.('(prefers-reduced-motion: reduce)') if (mql?.matches && heroVideo.value) { try { heroVideo.value.pause() heroVideo.value.removeAttribute('autoplay') } catch {} } }) ```
📜 Review details **Configuration used: CodeRabbit UI** **Review profile: CHILL** **Plan: Pro** **💡 Knowledge Base configuration:** - MCP integration is disabled by default for public repositories - Jira integration is disabled by default for public repositories - Linear integration is disabled by default for public repositories You can enable these sources in your CodeRabbit configuration.
📥 Commits Reviewing files that changed from the base of the PR and between 7601267 and 489c35f.
📒 Files selected for processing (1) * `src/pages/HomePage.vue` (6 hunks)
🔇 Additional comments (3)
src/pages/HomePage.vue (3) `97-99`: **카피 간결화 반영 좋습니다** 섹션 메시지가 더 간결·직관적으로 개선되었습니다. --- `146-148`: **설명 문구 업데이트 적절합니다** ‘AI와 함께 진행’으로 차별화 포인트가 명확해졌습니다. --- `260-268`: **라우트 경로 정의 확인 완료 & 접근 제어 설정 검토 요청** - `/auth/signin` 경로는 `src/router/auth.js`에 정의되어 있습니다. - `/risk-check` 경로는 `src/router/risk-check.js`에 정의되어 있습니다. - `/homes` 경로는 `src/router/homes.js`에 정의되어 있습니다. 경로 정의 자체는 정상 확인됐으나, 각 라우트에 로그인 필요 여부(meta.requiresAuth)나 네비게이션 가드가 의도대로 적용됐는지 추가로 검토해 주세요.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (5)
src/components/chat/chatList/ChatList.vue (2)

512-521: SSR/테스트 환경에서 window 참조로 인한 ReferenceError 위험

if (window)는 SSR이나 Node 기반 테스트에서 ReferenceError를 야기합니다. typeof window !== 'undefined' 가드로 교체하세요.

-function setupFallbackMethod() {
-  if (window) {
+function setupFallbackMethod() {
+  if (typeof window !== 'undefined') {
     window.updateChatRoomList = (chatRoomId, message, timestamp, senderId, unreadCount) => {
       updateRoomLastMessage(chatRoomId, message, timestamp, senderId, unreadCount)
     }
 
     window.markChatRoomAsRead = (chatRoomId) => {
       return markRoomAsRead(chatRoomId)
     }
   }
 }

579-587: 언마운트 시에도 window 가드 필요

마찬가지로 SSR/테스트 환경에서 안전하게 동작하도록 가드를 추가하세요.

-onUnmounted(() => {
-  if (window) {
+onUnmounted(() => {
+  if (typeof window !== 'undefined') {
     if (window.updateChatRoomList) {
       delete window.updateChatRoomList
     }
     if (window.markChatRoomAsRead) {
       delete window.markChatRoomAsRead
     }
   }
 })
src/components/chat/chatList/ChatItem.vue (1)

13-21: 프로필 이미지 실패 시 대체 이니셜이 표시되지 않는 문제

현재는 <img>만 숨기고 v-else 블록은 렌더링되지 않아 빈 원형만 남습니다. 로컬 상태로 에러 여부를 트래킹하여 v-if 조건에 반영하세요.

아래와 같이 수정 제안:

-        <img
-          v-if="room?.otherUserProfileUrl"
+        <img
+          v-if="room?.otherUserProfileUrl && !imgError"
           :src="room.otherUserProfileUrl"
           :alt="room.otherUserNickname || '사용자'"
           class="w-full h-full object-cover"
-          @error="onImageError"
+          @error="onImageError"
         />
         <div
           v-else
           class="w-full h-full bg-gray-400 flex items-center justify-center text-white font-medium"
         >
           {{ getInitial(room?.otherUserNickname) }}
         </div>
-import { computed, watch, inject } from 'vue'
+import { computed, watch, inject, ref } from 'vue'
+// 이미지 로드 에러 상태
+const imgError = ref(false)
-function onImageError(event) {
-  console.warn('프로필 이미지 로드 실패:', props.room?.otherUserProfileUrl)
-  // 이미지 로드 실패 시 숨기고 이니셜 표시
-  event.target.style.display = 'none'
-}
+function onImageError() {
+  imgError.value = true
+}

Also applies to: 171-176, 57-58

src/components/chat/chatRoom/ChatInput.vue (1)

199-205: 업로드 진행률 타이머(clearInterval) 누락으로 인한 누수/과도한 CPU 사용 가능성

에러 케이스에서 progressInterval이 해제되지 않습니다. finally에서 항상 해제되도록 옮겨주세요.

-    // 파일 업로드 API 호출
-    const result = await uploadChatFile(file, props.chatRoomId, props.receiverId)
-
-    clearInterval(progressInterval)
-    uploadProgress.value = 100
+    // 파일 업로드 API 호출
+    const result = await uploadChatFile(file, props.chatRoomId, props.receiverId)
+    uploadProgress.value = 100
@@
   } catch (error) {
     console.error('파일 업로드 실패:', error)
     isUploading.value = false
     uploadingFileName.value = ''
     uploadProgress.value = 0
@@
-  } finally {
-    // 파일 입력 초기화
-    event.target.value = ''
-  }
+  } finally {
+    // 진행률 타이머 정리 및 파일 입력 초기화
+    if (progressInterval) clearInterval(progressInterval)
+    event.target.value = ''
+  }

Also applies to: 217-219, 229-239

src/components/chat/chatRoom/ChatRoom.vue (1)

754-784: handleAcceptContract 중복 클릭/중복 요청 방지 가드 추가 필요

busy 가드/토글이 없어 빠른 연타로 다중 요청이 전송될 수 있습니다. 아래처럼 가드 및 finally 토글을 추가해 주세요.

적용 diff:

 const handleAcceptContract = async () => {
-  if (!props.room?.chatRoomId) {
+  if (acceptBusy.value) return
+  if (!props.room?.chatRoomId) {
     console.error('채팅방 ID가 없습니다.')
     return
   }
 
   try {
+    acceptBusy.value = true
     const response = await acceptContract(props.room.chatRoomId)
@@
-  } catch (error) {
+  } catch (error) {
     console.error('계약 수락 중 오류 발생:', error)
     alert('계약 수락 중 오류가 발생했습니다.')
-  }
+  } finally {
+    acceptBusy.value = false
+  }
 }

참고: 위 diff가 컴파일되려면 상단에 const acceptBusy = ref(false) 선언이 있어야 합니다.

♻️ Duplicate comments (2)
src/components/chat/chatRoom/ChatRoom.vue (2)

95-113: acceptBusy/rejectBusy, handleRejectContract 미정의로 런타임 에러 발생

템플릿에서 acceptBusy/rejectBusy 및 handleRejectContract를 참조하지만, 스크립트에 정의가 없습니다. 클릭 시 ReferenceError가 납니다. 상태(ref) 추가와 거절 핸들러 구현이 필요합니다.

아래 코드를 스크립트 상태 선언부 인근(예: isSuccessBuildContract/contractRoomId 다음)과 handleAcceptContract 인근에 추가/수정해 주세요.

추가(상태 및 거절 핸들러):

// 추가: 계약 수락/거절 진행 상태
const acceptBusy = ref(false)
const rejectBusy = ref(false)

// 추가: 계약 거절 핸들러 (실제 API/토픽은 프로젝트 스펙에 맞춰 교체 요망)
const handleRejectContract = async () => {
  if (rejectBusy.value) return
  if (!props.room?.chatRoomId) {
    console.error('채팅방 ID가 없습니다.')
    return
  }
  const confirmed = window.confirm('계약 요청을 거절하시겠어요?')
  if (!confirmed) return

  try {
    rejectBusy.value = true
    // TODO: 실제 API/WS 경로로 교체
    // 예시: websocketService.sendMessage('/app/chat/contract/reject', {
    //   chatRoomId: props.room.chatRoomId,
    //   userId: currentUserId.value,
    // })
    alert('계약 요청을 거절했습니다.')
  } catch (e) {
    console.error('계약 거절 중 오류:', e)
    alert('계약 거절 중 오류가 발생했습니다.')
  } finally {
    rejectBusy.value = false
  }
}

handleAcceptContract에 busy 가드/토글은 아래 별도 코멘트(라인 754-784) diff를 적용해 주세요.

필요하시면 실제 API 시그니처에 맞춰 reject 핸들러까지 제가 반영한 패치를 드리겠습니다.


157-175: acceptBusy/rejectBusy, handleRejectContract 미정의 — WS 경로도 동일 이슈

위(API 경로)와 같은 런타임 에러가 발생합니다. 동일한 수정이 필요합니다.

🧹 Nitpick comments (11)
src/components/chat/chatRoom/RoomNav.vue (4)

93-95: 구매자 판별 시 타입 불일치 가능성 (Number vs String)

currentUserId는 Number로 정의되어 있고, room.buyerId는 백엔드/라우팅에 따라 String일 수 있습니다. 느슨한 비교 대신 양측을 동일 타입으로 변환해 비교하세요. 실제 버튼 표시 조건에 영향이 있어 사용자 경험에 직접적인 영향이 있을 수 있습니다.

아래와 같이 안전 비교를 권장합니다.

-const isBuyer = computed(() => {
-  return props.currentUserId === props.room?.buyerId
-})
+const isBuyer = computed(() => {
+  const a = props.currentUserId
+  const b = props.room?.buyerId
+  return a != null && b != null && String(a) === String(b)
+})

102-107: 계약서 요청 요청/오류 처리 및 중복 클릭 방지

요청 실패 시 사용자에게 피드백이 없고, 중복 클릭에 대한 방어가 없습니다. 최소한 try/catch와 로딩 가드를 추가해 주세요.

예시:

-// 계약서 작성하러 가기
-const handleClickGoToContract = () => {
-  if (!props.room?.chatRoomId) return
-
-  requestContract(props.room?.chatRoomId)
-}
+// 계약서 작성하러 가기
+const contractLoading = ref(false)
+const handleClickGoToContract = async () => {
+  if (!props.room?.chatRoomId || contractLoading.value) return
+  try {
+    contractLoading.value = true
+    await requestContract(props.room.chatRoomId)
+    // TODO: 성공 토스트/스낵바
+  } catch (e) {
+    console.error('계약 요청 실패:', e)
+    // TODO: 실패 토스트/스낵바
+  } finally {
+    contractLoading.value = false
+  }
+}

151-153: 이미지 에러 핸들러에서 무한 에러 루프 및 빈 컨테이너 방지

src='' 설정만으로는 브라우저별로 에러가 반복될 수 있고, 시각적으로 빈 틀만 남습니다. onerror를 제거하고 명시적으로 숨기거나 플레이스홀더로 대체하세요.

-const handleImageError = (event) => {
-  event.target.src = ''
-}
+const handleImageError = (event) => {
+  const img = event.target
+  // 재호출 방지
+  img.onerror = null
+  // 필요 시 플레이스홀더로 대체 가능
+  img.src = ''
+  // 또는 단순히 숨김
+  img.style.visibility = 'hidden'
+}

134-138: 디버그 로그는 개발 환경에서만 출력하도록 제한

프로덕션 콘솔 노이즈를 줄이기 위해 환경 가드 추가를 권장합니다.

-    console.log('채팅방 변경 감지:', {
+    if (import.meta.env.DEV) console.log('채팅방 변경 감지:', {
       old: oldRoom?.chatRoomId,
       new: newRoom?.chatRoomId,
-    })
+    })
src/components/chat/chatList/ChatList.vue (2)

65-72: ChatItem에 중복 클래스 전달

자식 컴포넌트(ChatItem) 자체 루트에 이미 kakao-chat-item 클래스를 부여하고 있습니다. 부모에서 동일 클래스를 다시 전달하면 중복입니다. 기능상 문제는 없지만 불필요하므로 제거를 권장합니다.

-      <ChatItem
+      <ChatItem
         v-for="room in filteredRooms"
         :key="`room-${room.chatRoomId}-${room._lastUpdated || 0}`"
         :room="room"
         @click="selectRoom(room)"
-        class="kakao-chat-item"
       />

480-509: WebSocket 구독 해제(클린업) 누락

사용자 토픽 구독을 설정하지만 컴포넌트 언마운트 시 해제하지 않으면 메모리 누수/중복 핸들러 호출이 발생할 수 있습니다. 구독 토픽을 외부 변수로 보관하거나 setupWebSocketSubscriptions에서 언구독 함수를 반환해 onUnmounted에서 호출하도록 구조화하세요.

예: let userTopicRef = ''를 정의해 토픽 보관 후 onUnmounted에서 websocketService.offMessage(userTopicRef) 호출.

src/components/chat/chatList/ChatItem.vue (1)

75-81: 디버그 로그는 개발 환경에서만

지속적인 로그 출력은 성능/노이즈 이슈가 됩니다. 개발 환경에서만 출력하거나 제거하세요.

-watch(
+watch(
   () => props.room?.lastMessage,
   (newVal, oldVal) => {
-    console.log('lastMessage 변경 감지:', { oldVal, newVal })
+    if (import.meta.env.DEV) {
+      console.log('lastMessage 변경 감지:', { oldVal, newVal })
+    }
   },
   { immediate: true },
 )
src/components/chat/chatRoom/ChatRoom.vue (4)

18-18: 메시지 컨테이너 클래스 전환 — CSS 중복 정리 제안

overflow-y-auto + kakao-chat-bg로 전환은 타당합니다. 다만 style 블록에 남아있는 .chat-messages-container 관련 스타일(1090-1136 라인 부근)은 더 이상 사용되지 않는 것으로 보입니다. 후속 PR에서 제거해 빌드 사이즈/유지보수성을 개선하는 것을 권장합니다.


48-50: URLLINK 클릭 가드: API/WS 경로 간 정책 일관화 제안

API 경로는 isClickableUrlButton(message)로 “내 메시지라도 계약 채팅 URL은 클릭 허용” 로직을 적용했지만, WS 경로는 여전히 isMyMessage 기반으로 차단하고 있습니다. UX 및 정책 일관성을 위해 WS 경로도 동일 가드를 쓰는 것을 권장합니다.

적용 예(WS 영역 149-151 라인 교체):

-                :disabled="isMyMessage(message)"
-                @click="!isMyMessage(message) && handleUrlLinkClick(message)"
+                :disabled="!isClickableUrlButton(message)"
+                @click="isClickableUrlButton(message) && handleUrlLinkClick(message)"

1078-1081: 노란 버블(#facc15) + 흰색 텍스트 대비 저하 — 가독성 개선 제안

흰색 텍스트는 노란 배경과 대비가 낮아 가독성/접근성에 불리할 수 있습니다. 진한 텍스트로 변경을 권장합니다.

적용 diff:

 .kakao-message-mine {
   background-color: #facc15; /* yellow-primary */
-  color: #ffffff;
+  color: #1f2937; /* gray-800: 대비 개선 */
 }

1173-1190: 주석과 실제 사용 불일치(overflow-y-auto)

주석은 “기존 overflow-y-auto 클래스는 제거”라고 되어 있으나, 템플릿에서 여전히 overflow-y-auto를 사용 중이며 해당 클래스 커스텀 스크롤 스타일도 유지됩니다. 주석을 정정하거나, 불필요하다면 관련 스타일/주석을 정리해 주세요.

📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 489c35f and 6d6279d.

📒 Files selected for processing (7)
  • src/components/chat/chatList/ChatItem.vue (4 hunks)
  • src/components/chat/chatList/ChatList.vue (11 hunks)
  • src/components/chat/chatRoom/ChatInput.vue (4 hunks)
  • src/components/chat/chatRoom/ChatRoom.vue (9 hunks)
  • src/components/chat/chatRoom/RoomNav.vue (3 hunks)
  • src/components/chat/layout/ChatLayout.vue (4 hunks)
  • src/components/risk-check/result/RecommendedServices.vue (5 hunks)
✅ Files skipped from review due to trivial changes (1)
  • src/components/risk-check/result/RecommendedServices.vue
🔇 Additional comments (22)
src/components/chat/chatRoom/RoomNav.vue (2)

7-14: 모바일 뒤로가기 버튼/이벤트 흐름 명확 — LGTM

터치 타겟, 접근성(호버/포커스) 상태, emit 연결까지 깔끔합니다. 상위로 'back' 이벤트 전파도 직관적입니다.


86-87: 퍼블릭 이벤트 선언 적절 — LGTM

defineEmits(['back'])로 컴포넌트 API가 명확해졌습니다.

src/components/chat/chatList/ChatList.vue (5)

2-35: 상단 탭 헤더 리뉴얼 — LGTM

Sticky 헤더/활성 탭 강조 등 사용성 향상이 명확합니다.


54-61: 빈 상태(UI Empty State) 개선 — LGTM

아이콘/문구 조합이 명확하고 시멘틱하게 구성되어 있습니다.


195-199: 현재 방 재클릭 시 roomId 쿼리 제거 처리 — LGTM

undefined로 키 제거하는 패턴이 Vue Router 4에서 의도대로 동작합니다(키 삭제). 경로/기존 쿼리 보존도 적절합니다.


224-227: 새 방 선택 시 URL 싱크 — LGTM

선택 상태를 URL 쿼리와 동기화해 새로고침/공유 시 일관성을 보장합니다.

Also applies to: 234-237


241-258: 채팅방별 구독 정리 — LGTM

토픽별 offMessage 호출로 리소스 누수 방지 처리가 잘 되어 있습니다.

src/components/chat/chatList/ChatItem.vue (2)

2-8: 아이템 루트 및 선택 하이라이트 — LGTM

Kakao 스타일과 선택 상태 반영이 직관적입니다.


138-169: 상대 시간 포맷팅 — LGTM

오류/무효 날짜 방어 및 한글 상대 시각 처리(분/시간/일, 1주 초과 시 날짜표기) 모두 적절합니다.

src/components/chat/chatRoom/ChatInput.vue (1)

2-4: 입력/버튼 레이아웃 및 포커스 링 색상 조정 — LGTM

모바일에서의 공간 활용과 포커스 가시성이 개선되었습니다.

Also applies to: 55-55, 63-63

src/components/chat/layout/ChatLayout.vue (3)

2-31: 양측 패널 레이아웃/반응형 동작 — LGTM

100dvh 활용, 목록/채팅 패널 토글이 직관적입니다. 모바일에서 선택 시 목록 숨김도 자연스럽습니다.


198-211: 리사이즈 핸들러 등록/해제 — LGTM

마운트/언마운트 시 리스너 관리가 적절합니다. handleResize 내용도 단순 명료합니다.


187-196: 채팅방 닫힘 시 ChatList 상태 초기화 — LGTM

상위-하위 간 선택 상태 동기화가 잘 맞물립니다.

src/components/chat/chatRoom/ChatRoom.vue (9)

13-13: min-h-0 적용으로 스크롤 컨테이너 잘림 이슈 예방 — 적절합니다

플렉스 컨테이너 하위 스크롤 영역에서 발생하던 overflow 잘림을 예방하는 올바른 선택입니다.


15-15: RoomNav @back → handleBackClick 배선 OK

RoomNav가 back 이벤트를 emit하고, 상위로 room-closed를 올려주는 흐름이 단순·명확합니다.


30-39: 메시지 아이템 정렬/버블 레이아웃 변경 — OK

좌우 정렬 및 카카오톡 스타일 버블 클래싱이 명확하고, max-width 반응형 제약도 적절합니다.


115-119: 시간/읽음 표기 Kakao 스타일 분리 — 적절합니다

작은 폰트와 보조 라인 분리는 가독성에 유리합니다.


131-140: WS 메시지 레이아웃 동기화 — OK

API/WS 모두 동일한 버블/정렬 체계를 사용하여 UI 일관성이 확보되었습니다.


216-220: 시간/읽음 표기 WS 경로 — OK

API 경로와 동일한 렌더링으로 통일되었습니다.


234-239: ChatInput 스타일/prop 전달 — OK

shrink-0로 입력창 고정, border-t/bg-white 적용이 레이아웃 안정성에 도움이 됩니다.


863-873: 계약 채팅 URL 판별/클릭 허용 헬퍼 — 합리적입니다

플레이스홀더와 정규식 둘 다 커버하여 서버 메시지 포맷 변화에 유연합니다.


874-878: 뒤로가기 핸들러: 상위 room-closed emit — OK

모바일 UX에서 기대되는 동작입니다.

Comment on lines +378 to 389
// 직접 속성 변경으로 반응성 보장 - _lastUpdated는 변경하지 않음 (순서 유지)
const ownerRoom = ownerRooms.value.find((room) => room.chatRoomId === chatRoomId)
if (ownerRoom && ownerRoom.unreadMessageCount > 0) {
ownerRoom.unreadMessageCount = 0
ownerRoom._lastUpdated = currentTime
wasMarked = true
}

const buyerRoom = buyerRooms.value.find((room) => room.chatRoomId === chatRoomId)
if (buyerRoom && buyerRoom.unreadMessageCount > 0) {
buyerRoom.unreadMessageCount = 0
buyerRoom._lastUpdated = currentTime
wasMarked = true
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

읽음 처리에서 ID 타입 불일치로 인한 반응 실패 가능성

markRoomAsRead===로 비교해 String/Number 타입 차이 시 매칭에 실패할 수 있습니다. 실제로 updateRoomLastMessage는 String으로 캐스팅하여 비교하므로, 일관되게 캐스팅하도록 수정이 필요합니다. 그렇지 않으면 뱃지가 사라지지 않는 등 사용자에게 즉각적인 오동작이 노출됩니다.

아래처럼 안전 비교로 교체하세요.

-  const ownerRoom = ownerRooms.value.find((room) => room.chatRoomId === chatRoomId)
+  const ownerRoom = ownerRooms.value.find(
+    (room) => String(room.chatRoomId) === String(chatRoomId),
+  )
   if (ownerRoom && ownerRoom.unreadMessageCount > 0) {
     ownerRoom.unreadMessageCount = 0
     wasMarked = true
   }

-  const buyerRoom = buyerRooms.value.find((room) => room.chatRoomId === chatRoomId)
+  const buyerRoom = buyerRooms.value.find(
+    (room) => String(room.chatRoomId) === String(chatRoomId),
+  )
   if (buyerRoom && buyerRoom.unreadMessageCount > 0) {
     buyerRoom.unreadMessageCount = 0
     wasMarked = true
   }
📝 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.

Suggested change
// 직접 속성 변경으로 반응성 보장 - _lastUpdated는 변경하지 않음 (순서 유지)
const ownerRoom = ownerRooms.value.find((room) => room.chatRoomId === chatRoomId)
if (ownerRoom && ownerRoom.unreadMessageCount > 0) {
ownerRoom.unreadMessageCount = 0
ownerRoom._lastUpdated = currentTime
wasMarked = true
}
const buyerRoom = buyerRooms.value.find((room) => room.chatRoomId === chatRoomId)
if (buyerRoom && buyerRoom.unreadMessageCount > 0) {
buyerRoom.unreadMessageCount = 0
buyerRoom._lastUpdated = currentTime
wasMarked = true
}
// 직접 속성 변경으로 반응성 보장 - _lastUpdated는 변경하지 않음 (순서 유지)
const ownerRoom = ownerRooms.value.find(
(room) => String(room.chatRoomId) === String(chatRoomId),
)
if (ownerRoom && ownerRoom.unreadMessageCount > 0) {
ownerRoom.unreadMessageCount = 0
wasMarked = true
}
const buyerRoom = buyerRooms.value.find(
(room) => String(room.chatRoomId) === String(chatRoomId),
)
if (buyerRoom && buyerRoom.unreadMessageCount > 0) {
buyerRoom.unreadMessageCount = 0
wasMarked = true
}
🤖 Prompt for AI Agents
In src/components/chat/chatList/ChatList.vue around lines 378 to 389, the room
lookup uses strict === which can fail if chatRoomId and room.chatRoomId are
different types (string vs number); change the find predicates to compare
normalized types (e.g., cast both sides to String) so both
ownerRooms.value.find(...) and buyerRooms.value.find(...) use
String(room.chatRoomId) === String(chatRoomId) (or equivalent safe comparison)
before mutating unreadMessageCount to ensure the badge clears reliably.

Comment on lines +175 to 176
const isMobile = ref(window.innerWidth < 768)

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

SSR/테스트 환경에서의 window 직접 참조 방지

모듈 초기화 시 window를 직접 참조하면 SSR에서 ReferenceError가 발생합니다. 안전 가드로 초기화하세요.

-const isMobile = ref(window.innerWidth < 768)
+const isMobile = ref(typeof window !== 'undefined' ? window.innerWidth < 768 : false)
📝 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.

Suggested change
const isMobile = ref(window.innerWidth < 768)
const isMobile = ref(typeof window !== 'undefined' ? window.innerWidth < 768 : false)
🤖 Prompt for AI Agents
In src/components/chat/layout/ChatLayout.vue around lines 175-176, avoid
referencing window during module init; replace direct ref(window.innerWidth <
768) with a safe initialization (e.g., const isMobile = ref(false) or typeof
window !== 'undefined' && window.innerWidth < 768) and then set the correct
value inside a lifecycle hook (onMounted) or after checking typeof window !==
'undefined'; also add a resize listener to update isMobile if needed and remove
the listener on unmounted.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

🧹 Nitpick comments (6)
src/components/layouts/FooterLayout.vue (6)

6-9: 브랜드 로고 접근성 개선: 중복 낭독 방지 및 홈 링크 추가

텍스트 “잇집”이 바로 옆에 있으므로 로고 이미지 alt를 비워 중복 낭독을 방지하는 것이 좋습니다. 또한 브랜드 영역 전체를 홈 링크로 감싸면 사용성이 향상됩니다.

적용 제안 diff:

-        <div class="flex items-center gap-2">
-          <img :src="logo" alt="잇집 로고" class="h-6" />
-          <span class="text-lg font-bold">잇집</span>
-        </div>
+        <RouterLink to="/" class="flex items-center gap-2" aria-label="잇집 홈">
+          <img :src="logo" alt="" class="h-6" aria-hidden="true" />
+          <span class="text-lg font-bold">잇집</span>
+        </RouterLink>

10-13: 문장 분리로 시맨틱/접근성 개선 및 i18n 고려

<br /> 대신 문단을 분리하면 시맨틱이 명확해지고 스크린리더 낭독 흐름이 좋아집니다. 또한 정적 문구는 i18n으로 분리해 향후 다국어를 대비하는 것을 권장합니다.

문단 분리 최소 변경 diff:

-        <p class="text-sm text-gray-300 leading-relaxed">
-          내집 탐색부터 계약까지 AI와 함께,<br />
-          안전한 직거래 부동산 플랫폼 잇집에서 내집 마련 어떤가요?
-        </p>
+        <p class="text-sm text-gray-300 leading-relaxed">
+          내집 탐색부터 계약까지 AI와 함께,
+        </p>
+        <p class="text-sm text-gray-300 leading-relaxed">
+          안전한 직거래 부동산 플랫폼 잇집에서 내집 마련 어떤가요?
+        </p>

참고: i18n 도입 시에는 텍스트를 $t('footer.descriptionLine1'), $t('footer.descriptionLine2') 등으로 치환하는 방식을 추천합니다.


16-21: 개발자 명단: 시맨틱 리스트와 데이터 분리로 유지보수성 향상

이름들을 하나의 <p>로 묶기보다 <ul>/<li> 리스트와 v-for를 사용하면 접근성과 유지보수성이 좋아집니다. 추후 인원 변경 시 템플릿 수정보다 배열만 수정하면 됩니다. 구분점(·)은 템플릿에서 마지막 항목 제외 조건으로 처리할 수 있습니다.

템플릿 변경 diff:

-      <!-- 개발자 명단 -->
-      <div class="flex flex-col gap-1 text-sm">
-        <h2 class="font-semibold text-white">개발자</h2>
-        <p class="text-gray-300">김규석 · 이동재 · 이유민 · 이은미 · 정성엽 · 황선주</p>
-        <p class="mt-2 font-bold text-yellow-300">We are one!</p>
-      </div>
+      <!-- 개발자 명단 -->
+      <div class="flex flex-col gap-1 text-sm">
+        <h2 class="font-semibold text-white">개발자</h2>
+        <ul class="flex flex-wrap items-center gap-x-1 text-gray-300">
+          <li v-for="(dev, idx) in developers" :key="dev">
+            {{ dev }}<span v-if="idx < developers.length - 1"> ·</span>
+          </li>
+        </ul>
+        <p class="mt-2 font-bold text-yellow-300">We are one!</p>
+      </div>

스크립트에 배열 추가(참고 코드):

<script setup>
import logo from '@/assets/logo.png'

const developers = ['김규석', '이동재', '이유민', '이은미', '정성엽', '황선주']
</script>

24-31: 주석 처리된 소셜 아이콘: 정리 또는 접근성 개선된 구현으로 대체 권장

주석 블록은 장기적으로 부채가 되기 쉽습니다. 사용하지 않는다면 삭제하고, 사용할 계획이라면 <nav aria-label="소셜 링크">와 시각적 숨김 텍스트/aria-label을 통해 접근성을 확보하세요.

불필요 주석 제거 diff:

-    <!-- 소셜 아이콘 -->
-    <!-- <div class="flex gap-4 text-gray-400 text-xl mt-4">
-      <i class="fab fa-facebook-f"></i>
-      <i class="fab fa-twitter"></i>
-      <i class="fab fa-instagram"></i>
-      <i class="fab fa-linkedin-in"></i>
-    </div> -->
+

접근성 고려한 예시(참고 코드):

<nav aria-label="소셜 링크" class="mt-4">
  <ul class="flex gap-4 text-gray-400 text-xl">
    <li>
      <a href="https://facebook.com/..." target="_blank" rel="noopener noreferrer" aria-label="Facebook">
        <i class="fab fa-facebook-f" aria-hidden="true"></i>
      </a>
    </li>
    <!-- ... 다른 아이콘들 동일 패턴 ... -->
  </ul>
</nav>

33-35: 저작권 연도 하드코딩 대신 동적 계산 권장

연도를 하드코딩하면 매년 수정이 필요합니다. 런타임에서 계산해 유지보수 비용을 줄이세요.

적용 제안 diff:

-      © 2025 KB IT's Your Life, JEJUGO. All rights reserved.
+      © {{ year }} KB IT's Your Life, JEJUGO. All rights reserved.

스크립트에 연도 추가(참고 코드):

<script setup>
import logo from '@/assets/logo.png'

const year = new Date().getFullYear()
</script>

3-3: w-screen-xl 유틸리티 대체 권장 및 bg-gray-warm-700 정의 확인 완료

Tailwind 기본 및 커스텀 설정에 w-screen-xl이 정의되어 있지 않아 스타일이 적용되지 않으므로, 반응형 안전성을 위해 max-w-screen-xl w-full로 교체를 권장합니다.
배경색 토큰 bg-gray-warm-700tailwind.config.js에 정의되어 있어 정상 적용됩니다.

위치:

  • src/components/layouts/FooterLayout.vue:3

제안 diff:

-    <div class="w-screen-xl mx-auto flex flex-col gap-8">
+    <div class="max-w-screen-xl w-full mx-auto flex flex-col gap-8">
📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 6d6279d and 9c8c1d1.

📒 Files selected for processing (2)
  • src/components/layouts/FooterLayout.vue (1 hunks)
  • src/config/chat/aiUiRegistry.js (2 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/config/chat/aiUiRegistry.js

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 17

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (7)
src/apis/chatApi.js (2)

17-35: 버그: apiRequest가 options.data를 무시하여 POST 페이로드가 전송되지 않음

apiPost(){ method: 'POST', data }를 넘기지만, apiRequest()options.data를 읽지 않고 options.body만 처리합니다. 이로 인해 의도한 본문이 누락되어 빈 {}가 전송될 수 있습니다.

아래처럼 options.data를 우선 처리하고, options.body는 문자열/객체 모두 허용하도록 보완해 주세요.

   const config = {
     method,
     url: fullUrl,
     headers,
   }

-  if (options.body) {
-    config.data = JSON.parse(options.body)
-  } else if (options.method?.toLowerCase() === 'post' && !options.body) {
-    // POST 요청인데 body가 없으면 빈 객체 전송
-    config.data = {}
-  }
+  // 우선순위: data > body
+  if ('data' in options) {
+    config.data = options.data
+  } else if ('body' in options) {
+    config.data = typeof options.body === 'string' ? JSON.parse(options.body) : options.body
+  } else if (method === 'post') {
+    // POST인데 명시적 본문이 없다면 빈 객체
+    config.data = {}
+  }

Also applies to: 44-45


124-136: 오류 흐름 불명확: 토큰이 있는데도 getCurrentUser가 undefined 반환 가능

catch 블록에서 토큰이 없으면 오류를 던지지만, 토큰이 있는 경우에는 아무것도 반환하지 않아 undefined가 리턴됩니다. 호출부에서 userInfo.data 접근 시 런타임 에러를 유발할 수 있습니다.

명확히 예외를 재전파하거나 실패 응답을 일관되게 반환해 주세요. 예:

 export async function getCurrentUser() {
   try {
     const result = await apiRequest('/user')
     return result
   } catch (error) {
     console.error('서버 API 실패:', error)

     const token = getAuthToken()
     if (!token) {
       throw new Error('인증 토큰이 없습니다.')
     }
-  }
+    // 토큰이 있어도 실패한 경우, 예외를 재전파하여 호출부가 분기 처리하게 함
+    throw error
+  }
 }
src/pages/homes/HomeUpdatePage.vue (1)

187-197: 변경 감지에서 이미지 추가/삭제 상태가 누락됩니다

현재 hasChanges는 listing vs originalListing만 비교합니다. newImages/deletedImageIds만 변경된 경우(텍스트/폼은 그대로) 경고 없이 이탈할 수 있어 작업 손실 위험이 있습니다.

아래처럼 이미지 관련 변경을 포함해 취소 확인을 수행하도록 수정 제안합니다.

-const cancelUpdate = async () => {
-  const hasChanges = JSON.stringify(listing.value) !== JSON.stringify(originalListing.value)
+const cancelUpdate = async () => {
+  const hasListingChanges =
+    JSON.stringify(listing.value) !== JSON.stringify(originalListing.value)
+  const hasImageChanges =
+    (newImages.value?.length ?? 0) > 0 || (deletedImageIds.value?.length ?? 0) > 0
+  const hasChanges = hasListingChanges || hasImageChanges
   if (hasChanges) {
     const confirmed = await openConfirmModal({
       title: '수정 취소',
       message: '작업 중인 내용이 있습니다. 정말 취소하시겠습니까?'
     })
     if (!confirmed) return
   }
   router.push('/homes')
 }
src/components/contract/form/Step3Terms.vue (2)

226-234: API 오류 내성 강화: fetchClauses try/catch 추가 제안

네트워크/서버 오류 시 화면이 갱신되지 않거나 상태가 꼬일 수 있습니다. 예외를 캐치하고 기본 상태를 적용해주세요.

 const fetchClauses = async (id) => {
   if (!id) return
-  const res = await getSpecialContractForUser(id)
-  const list = res?.clauses || []
-  clauses.value = list
-  openDetails.value = {}
-  list.forEach((c) => (openDetails.value[c.id] = false))
+  try {
+    const res = await getSpecialContractForUser(id)
+    const list = res?.clauses || []
+    clauses.value = list
+    openDetails.value = {}
+    list.forEach((c) => (openDetails.value[c.id] = false))
+  } catch (e) {
+    console.error('[Step3Terms] 특약 목록 조회 실패:', e)
+    clauses.value = []
+    openDetails.value = {}
+  }
 }

235-244: API 오류 내성 강화: fetchFinalClauses try/catch 추가 제안

최종 특약 조회도 동일하게 예외 처리해 주세요.

 const fetchFinalClauses = async (id) => {
   if (!id) return
-  const res = await getFinalContract(id)
-  const list =
-    res?.data?.finalClauses || res?.finalClauses || res?.clauses || res?.data?.clauses || []
-  finalClauses.value = Array.isArray(list)
-    ? [...list].sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
-    : []
+  try {
+    const res = await getFinalContract(id)
+    const list =
+      res?.data?.finalClauses || res?.finalClauses || res?.clauses || res?.data?.clauses || []
+    finalClauses.value = Array.isArray(list)
+      ? [...list].sort((a, b) => (a.order ?? a.id ?? a.clauseId ?? 0) - (b.order ?? b.id ?? b.clauseId ?? 0))
+      : []
+  } catch (e) {
+    console.error('[Step3Terms] 최종 특약 조회 실패:', e)
+    finalClauses.value = []
+  }
 }
src/pages/contract/ContractCompletePage.vue (1)

634-655: 운영 코드에 남은 SockJS 로컬 테스트 연결 — 제거 또는 DEV 가드 필요

http://localhost:8080/ws로 직접 연결 테스트를 항상 시도합니다. 운영/스테이징에서 불필요한 오류와 과도한 연결 시도를 유발합니다.

-// WebSocket 간단 테스트
-const testWebSocketConnection = async () => {
+// WebSocket 간단 테스트 (개발 환경에서만)
+const testWebSocketConnection = async () => {
   try {
-    console.log('=== WebSocket 연결 테스트 시작 ===')
-    const testSocket = new SockJS('http://localhost:8080/ws')
+    if (!import.meta.env.DEV) return
+    console.debug('=== WebSocket 연결 테스트 시작 ===')
+    const testSocket = new SockJS('/ws')
 ...
-      console.log('✅ SockJS 직접 연결 성공!')
+      console.debug('✅ SockJS 직접 연결 성공!')
 ...
-      console.error('❌ SockJS 직접 연결 실패:', error)
+      console.debug('❌ SockJS 직접 연결 실패:', error)
 ...
-      console.log('🔒 SockJS 연결 종료:', event.code, event.reason)
+      console.debug('🔒 SockJS 연결 종료:', event.code, event.reason)
   } catch (error) {
-    console.error('WebSocket 테스트 실패:', error)
+    if (import.meta.env.DEV) console.debug('WebSocket 테스트 실패:', error)
   }
 }
src/apis/websocket.js (1)

202-219: 토픽당 단일 subscription만 관리 — 재연결 후 구독 유실/다중 핸들러 미지원

현재는 topic -> subscription만 저장하고 handler는 보존하지 않아, 재연결 시 기존 구독이 유실되면 핸들러 재등록이 불가능합니다. topic -> Set 구조로 보강하고, onConnect에서 재구독을 수행하세요. offMessage도 (topic, handler?) 형태로 개선 필요.

예시 패치(핵심 아이디어):

 class WebSocketService {
   constructor() {
     this.stompClient = null
     this.isConnected = ref(false)
     this.isConnecting = ref(false)
-    this.messageHandlers = new Map()
+    this.messageHandlers = new Map() // topic -> subscription
+    this.handlersByTopic = new Map() // topic -> Set<handler>
     this.connectionHandlers = []
     this.pendingSubscriptions = [] // 대기 중인 구독들
   }
   onMessage(topic, handler) {
-    if (!this.stompClient) { ... }
+    if (!this.stompClient) { ... }
     // 연결 상태를 더 정확히 체크
     if (!this.isConnected.value || !this.stompClient.connected) {
-      if (!this.pendingSubscriptions.find((p) => p.topic === topic)) {
-        this.pendingSubscriptions.push({ topic, handler })
-      }
+      this.pendingSubscriptions.push({ topic, handler })
       return
     }
-    this.subscribeToTopic(topic, handler)
+    this.subscribeToTopic(topic, handler)
   }
   subscribeToTopic(topic, handler) {
-    // 이미 구독된 토픽인지 확인 (빠른 리턴)
-    const existingSubscription = this.messageHandlers.get(topic)
-    if (existingSubscription) {
-      return existingSubscription
-    }
+    // 핸들러 등록
+    if (!this.handlersByTopic.has(topic)) this.handlersByTopic.set(topic, new Set())
+    this.handlersByTopic.get(topic).add(handler)
+
+    // 구독이 없으면 구독 생성
+    let subscription = this.messageHandlers.get(topic)
+    if (subscription) return subscription
     try {
-      const subscription = this.stompClient.subscribe(topic, (message) => {
+      subscription = this.stompClient.subscribe(topic, (message) => {
         try {
           const data = JSON.parse(message.body)
-          handler(data)
+          const set = this.handlersByTopic.get(topic)
+          if (!set) return
+          for (const h of set) {
+            try { h(data) } catch (e) { console.error('핸들러 오류:', e) }
+          }
         } catch (e) {
           console.error('파싱 실패:', e)
           console.error('Raw body:', message.body)
           console.error('Body 타입:', typeof message.body)
         }
       })
       this.messageHandlers.set(topic, subscription)
       return subscription
-  offMessage(topic) {
-    const subscription = this.messageHandlers.get(topic)
-    if (subscription) {
-      subscription.unsubscribe()
-      this.messageHandlers.delete(topic)
-    }
-  }
+  offMessage(topic, handler) {
+    const set = this.handlersByTopic.get(topic)
+    if (set && handler) {
+      set.delete(handler)
+      if (set.size > 0) return
+    }
+    const subscription = this.messageHandlers.get(topic)
+    subscription?.unsubscribe()
+    this.messageHandlers.delete(topic)
+    this.handlersByTopic.delete(topic)
+  }
         onConnect: (frame) => {
           ...
-          // 대기 중인 구독들을 실행
-          this.pendingSubscriptions.forEach(({ topic, handler }) => {
-            this.subscribeToTopic(topic, handler)
-          })
-          this.pendingSubscriptions = []
+          // 대기 중인 구독 핸들러 등록
+          this.pendingSubscriptions.forEach(({ topic, handler }) => {
+            this.subscribeToTopic(topic, handler)
+          })
+          this.pendingSubscriptions = []
+
+          // 기존 등록된 토픽에 대해 구독이 없으면 재구독
+          for (const topic of this.handlersByTopic.keys()) {
+            if (!this.messageHandlers.get(topic)) {
+              this.subscribeToTopic(topic, () => {})
+            }
+          }

Also applies to: 220-253, 255-261

♻️ Duplicate comments (10)
src/pages/HomePage.vue (1)

6-13: 영상 src의 '&' 문자로 인한 번들/로딩 실패 가능성 — 파일명 정리 또는 URL 인코딩 필요

여전히 src="/panda&lion.mp4"를 사용 중입니다. Vite/브라우저에서 &는 쿼리 구분자로 해석되어 에셋 경로나 HMR 재작성 시 깨질 수 있습니다. 파일명을 바꾸거나 %26으로 인코딩하세요. 장식용 배경 영상이면 접근성/성능 속성도 함께 권장합니다.

적용 제안(diff):

-      <video
-        class="pointer-events-none absolute left-0 top-0 h-full w-full object-cover xl:w-[60vw] 2xl:w-[60vw]"
-        src="/panda&lion.mp4"
-        autoplay
-        muted
-        loop
-        playsinline
-      ></video>
+      <video
+        class="pointer-events-none absolute left-0 top-0 h-full w-full object-cover xl:w-[60vw] 2xl:w-[60vw]"
+        src="/panda-lion.mp4"
+        poster="/hero-fallback.jpg"
+        preload="metadata"
+        autoplay
+        muted
+        loop
+        playsinline
+        aria-hidden="true"
+      ></video>

추가 확인:

  • 실제 파일명이 public/panda-lion.mp4(또는 panda%26lion.mp4)로 반영되어 있는지 확인 필요
  • poster 자산 존재 여부 확인 필요
src/components/homes/homedetails/ImageGallery.vue (2)

408-433: 주석 방향 표기 오류

slide-right 트랜지션은 새 슬라이드가 좌측(-100%)에서 들어오고, 기존 슬라이드는 우측(+100%)으로 나갑니다. 주석을 실제 동작에 맞게 정정해주세요.

적용 제안(diff):

-/* 왼쪽 → 오른쪽 (이전 이미지) */
+/* 오른쪽 → 왼쪽으로 이동하는 느낌(이전 이미지로 이동) */

25-43: Vue 3에서 transition mode="default"는 유효하지 않습니다

mode"out-in" 또는 "in-out"만 허용됩니다. 목적이 없으면 mode 속성 제거도 가능합니다.

적용 제안(diff):

-        <transition :name="transitionName" mode="default">
+        <transition :name="transitionName" mode="out-in">

그리고 전체화면 전환부도 동일:

-              <transition :name="transitionName" mode="default">
+              <transition :name="transitionName" mode="out-in">

Also applies to: 159-169

src/pages/contract/ContractCompletePage.vue (2)

941-951: 서명 Base64를 WebSocket으로 전송 및 전체 로그 출력 — 민감정보 유출 위험

이미 HTTP(FormData)로 서명 이미지를 업로드하고 있으므로, WS 메시지에는 원본 이미지를 포함하지 않는 것이 안전합니다. 또한 signatureMessage 전체(JSON.stringify) 로그는 제거/DEV 가드 필요합니다.

-  const signatureMessage = {
-    userRole: userRole.value,
-    signature1: signatures.value.signature1?.dataUrl || '', // base64 encoded signature
-    signature2: signatures.value.signature2?.dataUrl || '',
-    signature3: signatures.value.signature3?.dataUrl || '',
-    hasTaxArrears: hasTaxArrears.value,
-    hasPriorFixedDate: hasPriorFixedDate.value,
-    mediationAgree: true, // 항상 true로 설정
-    submittedAt: Date.now(),
-  }
+  const signatureMessage = {
+    userRole: userRole.value,
+    hasTaxArrears: hasTaxArrears.value,
+    hasPriorFixedDate: hasPriorFixedDate.value,
+    mediationAgree: true,
+    submittedAt: Date.now(),
+    // 이미지 업로드는 HTTP로 수행. WS에는 식별자/플래그만 포함.
+    signatureSaved: true,
+  }
-  console.log('서명 전송 시작, contractId:', contractId.value)
-  console.log('서명 메시지:', JSON.stringify(signatureMessage, null, 2))
+  if (import.meta.env.DEV) {
+    console.debug('서명 전송 시작, contractId:', contractId.value)
+    console.debug('서명 메시지(요약):', {
+      userRole: signatureMessage.userRole,
+      hasTaxArrears: signatureMessage.hasTaxArrears,
+      hasPriorFixedDate: signatureMessage.hasPriorFixedDate,
+      signatureSaved: signatureMessage.signatureSaved,
+    })
+  }

Also applies to: 962-966, 959-960


548-555: WebSocket 상태/이전 상태 전체 로그 — DEV 한정 요약 로그로 축소

이 구간은 이전 리뷰에서 이미 지적된 내용과 동일합니다. 운영에서 payload 전체/이전 상태 전체를 로그로 남기지 마세요.

-  console.log('=== WebSocket 상태 업데이트 수신 ===')
-  console.log('받은 데이터:', JSON.stringify(data, null, 2))
-  console.log('현재 userRole:', userRole.value)
+  if (import.meta.env.DEV) {
+    console.debug('[WS][export/status] 수신', {
+      step: data?.currentStep,
+      ownerSigned: !!data?.ownerSignatureCompleted,
+      buyerSigned: !!data?.buyerSignatureCompleted,
+      hasFinalPdf: !!data?.finalPdfUrl,
+      userRole: userRole.value,
+    })
+  }
 ...
-  console.log('이전 상태:', prevStatus)
+  if (import.meta.env.DEV && prevStatus) {
+    console.debug('[WS] 이전 상태 요약', {
+      step: prevStatus.currentStep,
+      ownerSigned: !!prevStatus.ownerSignatureCompleted,
+      buyerSigned: !!prevStatus.buyerSignatureCompleted,
+    })
+  }
src/apis/websocket.js (1)

90-107: isReady=false 시 재연결 트리거 누락 — 영구 실패 가능성

과거 리뷰에서 제안된 내용과 유사합니다. stompClient 인스턴스는 있지만 연결이 끊긴 경우 connect()를 재시도하지 않아 재시도 횟수 소진 후 실패할 수 있습니다. isConnecting 가드와 함께 재연결을 시도하세요.

-    if (!isReady) {
+    if (!isReady) {
+      if (!this.isConnecting.value) {
+        try { await this.connect() } catch (_) {}
+      }
       if (retryCount > 0) {
         await new Promise((resolve) => setTimeout(resolve, 1000))
         return this.sendMessage(destination, message, retryCount - 1)
       } else {
         console.error('STOMP 연결 실패 - 재시도 횟수 초과')
         return false
       }
     }
src/components/contract/chat/ContractChat.vue (3)

413-415: 타이머 ref로 전환하여 메모리 누수/정리 타이밍 개선 [이전 코멘트와 동일 사안]

plain 변수로 setInterval 핸들을 관리하면 정리 누락/타이밍 이슈가 발생하기 쉽습니다. ref로 전환하고 onMounted/onUnmounted에서 .value 기반으로 정리해 주세요.

-const signingCountdown = ref(0)
-let signingTimer = null
+const signingCountdown = ref(0)
+const signingTimer = ref(null)
-const startSigningCountdown = (sec = 3) => {
-  if (signingTimer) clearInterval(signingTimer)
-  signingCountdown.value = sec
-  signingTimer = setInterval(() => {
-    signingCountdown.value -= 1
-    if (signingCountdown.value <= 0) {
-      clearInterval(signingTimer)
-      signingTimer = null
-      router.push(`/contract/complete/${String(actualContractChatId.value)}`)
-    }
-  }, 1000)
-}
+const startSigningCountdown = (sec = 3) => {
+  if (signingTimer.value) {
+    clearInterval(signingTimer.value)
+    signingTimer.value = null
+  }
+  signingCountdown.value = sec
+  signingTimer.value = setInterval(() => {
+    signingCountdown.value -= 1
+    if (signingCountdown.value <= 0) {
+      if (signingTimer.value) {
+        clearInterval(signingTimer.value)
+        signingTimer.value = null
+      }
+      router.push(`/contract/complete/${String(actualContractChatId.value)}`)
+    }
+  }, 1000)
+}
-onMounted(() => {
-  // 혹시 남아있던 타이머 정리
-  if (signingTimer) clearInterval(signingTimer)
-})
-onUnmounted(() => {
-  if (signingTimer) clearInterval(signingTimer)
-})
+onMounted(() => {
+  // 혹시 남아있던 타이머 정리
+  if (signingTimer.value) {
+    clearInterval(signingTimer.value)
+    signingTimer.value = null
+  }
+})
+onUnmounted(() => {
+  if (signingTimer.value) {
+    clearInterval(signingTimer.value)
+    signingTimer.value = null
+  }
+})

Also applies to: 420-431, 644-651


474-476: 임시 id(Date.now) 사용은 충돌/정렬 문제를 유발할 수 있습니다

낙관적 항목에 id를 부여하면 서버 id와 혼동되거나 정렬/중복 병합에 악영향을 줍니다. _localId만 사용하세요.

-      id: Date.now(), // 임시 키
       _localId: (crypto?.randomUUID && crypto.randomUUID()) || `local-${Date.now()}`,

556-559: keyOf 호출에 불필요한 세 번째 인자 전달

keyOf는 (m, i)만 받는데 호출부에서 10000을 전달하고 있습니다. 제거하세요. (이전 리뷰와 동일 사안)

-  b.forEach((m, i) => map.set(keyOf(m, i, 10000), m))
+  b.forEach((m, i) => map.set(keyOf(m, i), m))
src/components/chat/chatRoom/ChatRoom.vue (1)

95-113: acceptBusy/rejectBusy, handleRejectContract 미정의 — 클릭 시 ReferenceError 발생 (중요)

템플릿에서 acceptBusy/rejectBusy/handleRejectContract를 참조하지만, 스크립트에 정의가 없습니다. 즉시 런타임 에러가 납니다. 또한 handleAcceptContract에도 busy 가드가 없어 중복 클릭 위험이 있습니다. 아래 패치로 최소 보완해 주세요.

상태 추가(상태 선언 인근):

   const webSocketMessages = ref([])
   const isTyping = ref(false)
   const isSendingMessage = ref(false)
+
+  // 수락/거절 버튼 busy 상태
+  const acceptBusy = ref(false)
+  const rejectBusy = ref(false)

수락 핸들러 busy 가드/토글:

 const handleAcceptContract = async () => {
+  if (acceptBusy.value) return
+  acceptBusy.value = true
   if (!props.room?.chatRoomId) {
     console.error('채팅방 ID가 없습니다.')
-    return
+    acceptBusy.value = false
+    return
   }
   try {
     const response = await acceptContract(props.room.chatRoomId)
     // ...(기존 처리)
   } catch (error) {
     console.error('계약 수락 중 오류 발생:', error)
     alert('계약 수락 중 오류가 발생했습니다.')
   } finally {
+    acceptBusy.value = false
   }
 }

거절 핸들러 스켈레톤 추가:

+const handleRejectContract = async () => {
+  if (rejectBusy.value) return
+  if (!props.room?.chatRoomId) {
+    console.error('채팅방 ID가 없습니다.')
+    return
+  }
+  const confirmed = window.confirm('계약 요청을 거절하시겠어요?')
+  if (!confirmed) return
+  try {
+    rejectBusy.value = true
+    // TODO: 실제 거절 API 호출 또는 서버로 거절 이벤트 전송
+    // 예: await rejectContract(props.room.chatRoomId)
+    alert('계약 요청을 거절했습니다.')
+  } catch (e) {
+    console.error('계약 거절 중 오류:', e)
+    alert('계약 거절 중 오류가 발생했습니다.')
+  } finally {
+    rejectBusy.value = false
+  }
+}

Also applies to: 157-175, 784-814

Comment on lines +470 to +483
// 서버 전송 (실패 시 throw)
await sendContractMessage(content, 'TEXT')

// ✅ 낙관적 업데이트: 즉시 로컬에 메시지 추가
hookMessages.value.push({
id: Date.now(), // 임시 키
_localId: (crypto?.randomUUID && crypto.randomUUID()) || `local-${Date.now()}`,
senderId: currentUserId.value,
receiverId: contractReceiverId.value,
content,
sendTime: new Date().toISOString(),
type: 'TEXT',
isRead: false,
})
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

낙관적 업데이트가 서버 에코와 중복될 수 있습니다 (중요)

현재 흐름은 서버 전송 후 로컬 push를 추가합니다. 서버가 동일 메시지를 에코(또는 훅에서 수신)하면 hookMessages에 2개가 들어가고, mergedMessages에서도 서로 다른 키로 공존해 중복 렌더링이 발생합니다. 또한 “서버 전송 후”에 push하고 있어 “낙관적”도 아닙니다.

즉시 안전 조치(권장): 로컬 push를 제거하고 서버 에코/훅 수신에만 의존하세요.

-    // 서버 전송 (실패 시 throw)
-    await sendContractMessage(content, 'TEXT')
-
-    // ✅ 낙관적 업데이트: 즉시 로컬에 메시지 추가
-    hookMessages.value.push({
-      id: Date.now(), // 임시 키
-      _localId: (crypto?.randomUUID && crypto.randomUUID()) || `local-${Date.now()}`,
-      senderId: currentUserId.value,
-      receiverId: contractReceiverId.value,
-      content,
-      sendTime: new Date().toISOString(),
-      type: 'TEXT',
-      isRead: false,
-    })
+    // 서버 전송 (실패 시 throw)
+    await sendContractMessage(content, 'TEXT')
+    // 중복 렌더링 방지를 위해 서버 에코(훅) 수신으로만 메시지를 추가합니다.

대안(낙관적 유지): 클라이언트 생성 tempId(_localId)를 서버에 함께 전송하고, 서버가 에코 시 그대로 포함하도록 한 뒤, 에코 수신 시 tempId가 일치하면 기존 로컬 항목을 대체/업데이트하는 reconcile 로직을 추가하세요. 필요하시면 해당 경량 구현 패치를 제안드릴 수 있습니다.

📝 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.

Suggested change
// 서버 전송 (실패 시 throw)
await sendContractMessage(content, 'TEXT')
// ✅ 낙관적 업데이트: 즉시 로컬에 메시지 추가
hookMessages.value.push({
id: Date.now(), // 임시 키
_localId: (crypto?.randomUUID && crypto.randomUUID()) || `local-${Date.now()}`,
senderId: currentUserId.value,
receiverId: contractReceiverId.value,
content,
sendTime: new Date().toISOString(),
type: 'TEXT',
isRead: false,
})
// 서버 전송 (실패 시 throw)
await sendContractMessage(content, 'TEXT')
// 중복 렌더링 방지를 위해 서버 에코(훅) 수신으로만 메시지를 추가합니다.
🤖 Prompt for AI Agents
In src/components/contract/chat/ContractChat.vue around lines 470–483 the code
sends the message to the server and then immediately pushes a local message,
causing duplicate messages when the server echoes the same message and also
making this not truly optimistic. Fix by removing the immediate
hookMessages.value.push block and rely on the server echo/hook to insert
messages (safe/fast fix). If you want to keep optimistic updates instead,
generate a client tempId/_localId, include that tempId in the payload sent to
the server, push the optimistic message with that tempId before sending, and add
reconcile logic on server-echo/hook receipt to match tempId and replace/update
the optimistic item rather than inserting a duplicate.

Comment on lines +110 to +117
const { indexNum, title } = (() => {
const line = titleAndIndexLineIdx.value >= 0 ? lines.value[titleAndIndexLineIdx.value] : ''
const m = line.match(/^\s*(\d+)\.\s*(.+)$/)
if (m) {
return { indexNum: Number(m[1]), title: m[2].trim() }
}
return { indexNum: null, title: line || '법령 안내' }
})()
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

reactivity 버그: title/indexNum가 반응형이 아님

현재 indexNumtitle은 즉시실행 함수에서 한 번 계산된 정적 값입니다. props.raw가 변경되어도 값이 갱신되지 않습니다. computed로 전환해야 합니다.

-const { indexNum, title } = (() => {
-  const line = titleAndIndexLineIdx.value >= 0 ? lines.value[titleAndIndexLineIdx.value] : ''
-  const m = line.match(/^\s*(\d+)\.\s*(.+)$/)
-  if (m) {
-    return { indexNum: Number(m[1]), title: m[2].trim() }
-  }
-  return { indexNum: null, title: line || '법령 안내' }
-})()
+const indexNum = computed(() => {
+  const line = titleAndIndexLineIdx.value >= 0 ? lines.value[titleAndIndexLineIdx.value] : ''
+  const m = line.match(/^\s*(\d+)\.\s*(.+)$/)
+  return m ? Number(m[1]) : null
+})
+
+const title = computed(() => {
+  const line = titleAndIndexLineIdx.value >= 0 ? lines.value[titleAndIndexLineIdx.value] : ''
+  const m = line.match(/^\s*(\d+)\.\s*(.+)$/)
+  return m ? m[2].trim() : (line || '법령 안내')
+})
📝 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.

Suggested change
const { indexNum, title } = (() => {
const line = titleAndIndexLineIdx.value >= 0 ? lines.value[titleAndIndexLineIdx.value] : ''
const m = line.match(/^\s*(\d+)\.\s*(.+)$/)
if (m) {
return { indexNum: Number(m[1]), title: m[2].trim() }
}
return { indexNum: null, title: line || '법령 안내' }
})()
const indexNum = computed(() => {
const line = titleAndIndexLineIdx.value >= 0 ? lines.value[titleAndIndexLineIdx.value] : ''
const m = line.match(/^\s*(\d+)\.\s*(.+)$/)
return m ? Number(m[1]) : null
})
const title = computed(() => {
const line = titleAndIndexLineIdx.value >= 0 ? lines.value[titleAndIndexLineIdx.value] : ''
const m = line.match(/^\s*(\d+)\.\s*(.+)$/)
return m ? m[2].trim() : (line || '법령 안내')
})

Comment on lines +203 to +205
// 오늘 날짜 (YYYY-MM-DD 형식)
const today = new Date().toISOString().split('T')[0]

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

toISOString() 기반 today는 로컬 시간대에서 하루 어긋남 가능 — 로컬 기준으로 계산 필요

UTC 기준 자르기 때문에 KST 등에서 자정 이후 오전 시간대에 '오늘'이 어제로 제한됩니다. 아래처럼 로컬 기준 ISO 날짜로 교체를 권장합니다. 또한 동일 로직이 여러 컴포넌트에 반복되므로 공통 유틸/컴포저블로의 추출도 고려해 주세요.

-// 오늘 날짜 (YYYY-MM-DD 형식)
-const today = new Date().toISOString().split('T')[0]
+// 오늘 날짜 (YYYY-MM-DD, 로컬 타임존 기준)
+const today = new Date(Date.now() - new Date().getTimezoneOffset() * 60000)
+  .toISOString()
+  .split('T')[0]

중복 사용 여부 점검용 스크립트입니다. 동일 패턴을 전역에서 찾아 한 번에 교정할 수 있습니다.


🏁 Script executed:

#!/bin/bash
# 레포 전역에서 UTC 기반 YYYY-MM-DD 추출 패턴 사용 위치 검색
rg -nP "toISOString\(\)\.split\('T'\)\[0\]"

Length of output: 506


로컬 타임존 기준 날짜 계산 로직 적용 및 중복 추출 필요

다음 4개 컴포넌트에서 toISOString().split('T')[0] 패턴으로 UTC 기준 날짜만 추출하고 있어, KST 등 로컬 타임존에서는 자정 이후 ‘어제’로 표시될 수 있습니다. 반드시 아래와 같이 로컬 타임존 기준으로 변경하세요.

– 수정 대상 위치
• src/pages/risk-check/RiskCheckConfirm.vue:324
• src/components/homes/homecreate/Step3DetailInfo.vue:204
• src/components/homes/homeupdate/RoomInfoForm.vue:170
• src/components/contract/complete/ContractDownloadPanel.vue:137

- // 오늘 날짜 (YYYY-MM-DD 형식)
- const today = new Date().toISOString().split('T')[0]
+ // 오늘 날짜 (YYYY-MM-DD, 로컬 타임존 기준)
+ const today = new Date(Date.now() - new Date().getTimezoneOffset() * 60000)
+   .toISOString()
+   .split('T')[0]

또한 동일 로직이 여러 곳에서 반복되므로, utils/date.js 또는 Composable로 분리하여 재사용성을 높이고 유지보수를 용이하게 해주시기 바랍니다.

📝 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.

Suggested change
// 오늘 날짜 (YYYY-MM-DD 형식)
const today = new Date().toISOString().split('T')[0]
// 오늘 날짜 (YYYY-MM-DD, 로컬 타임존 기준)
const today = new Date(Date.now() - new Date().getTimezoneOffset() * 60000)
.toISOString()
.split('T')[0]
🤖 Prompt for AI Agents
In src/components/homes/homecreate/Step3DetailInfo.vue around lines 203 to 205,
the code uses new Date().toISOString().split('T')[0] which extracts a UTC date
and can show the previous day in local timezones; replace this with a local-date
calculation (e.g., build YYYY-MM-DD from new Date() using local year/month/date)
and update the other occurrences at
src/pages/risk-check/RiskCheckConfirm.vue:324,
src/components/homes/homeupdate/RoomInfoForm.vue:170, and
src/components/contract/complete/ContractDownloadPanel.vue:137 to the same
local-date logic; factor this logic out into a shared utility (e.g.,
utils/date.js) or a composable that exports a getLocalYYYYMMDD() function and
import/use that in all four places to remove duplication and ensure consistent
local-time behavior.

Comment on lines +7 to +15
@click="openFullscreen"
@mousedown="handleDragStart"
@mousemove="handleDragMove"
@mouseup="handleDragEnd(false)"
@mouseleave="handleDragEnd(false)"
@touchstart="handleDragStart"
@touchmove="handleDragMove"
@touchend="handleDragEnd(false)"
:style="{ cursor: isDragging ? 'grabbing' : 'grab' }"
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

드래그 후 의도치 않게 전체화면이 열릴 수 있음 — 드래그 후 클릭 억제

드래그 스와이프가 끝난 뒤 클릭 이벤트가 발생해 openFullscreen이 실행될 수 있습니다. 드래그 임계치 초과 시 짧은 시간 클릭 억제를 두거나, 최근 드래그 여부를 체크해 전체화면 진입을 막아주세요.

적용 제안(diff):

   :style="{ cursor: isDragging ? 'grabbing' : 'grab' }"
   >
@@
-const isDragging = ref(false)
+const isDragging = ref(false)
+const didDrag = ref(false) // 드래그로 이동 발생 여부
@@
 const handleDragStart = (e, isFullscreenMode = false) => {
   isDragging.value = true
   startX.value = e.type.includes('mouse') ? e.clientX : e.touches[0].clientX
   currentX.value = startX.value
   dragOffset.value = 0
+  didDrag.value = false
 }
@@
 const handleDragMove = (e) => {
   if (!isDragging.value) return
 
   e.preventDefault()
   currentX.value = e.type.includes('mouse') ? e.clientX : e.touches[0].clientX
   dragOffset.value = currentX.value - startX.value
+  if (Math.abs(dragOffset.value) > 3) didDrag.value = true
 }
@@
 const handleDragEnd = (isFullscreenMode = false) => {
   if (!isDragging.value) return
 
   isDragging.value = false
   const threshold = 50 // 최소 드래그 거리
 
   if (Math.abs(dragOffset.value) > threshold) {
     if (dragOffset.value > 0) {
       // 오른쪽으로 드래그 - 이전 이미지
       if (isFullscreenMode) {
         prevFullscreenImage()
       } else {
         prevImage()
       }
     } else {
       // 왼쪽으로 드래그 - 다음 이미지
       if (isFullscreenMode) {
         nextFullscreenImage()
       } else {
         nextImage()
       }
     }
   }
 
   dragOffset.value = 0
 }
@@
-const openFullscreen = () => {
-  isFullscreen.value = true
-  fullscreenIndex.value = currentIndex.value
-  document.body.style.overflow = 'hidden'
-}
+const openFullscreen = () => {
+  // 직전 상호작용이 드래그였다면 전체화면 진입 억제
+  if (didDrag.value) {
+    didDrag.value = false
+    return
+  }
+  isFullscreen.value = true
+  fullscreenIndex.value = currentIndex.value
+  document.body.style.overflow = 'hidden'
+}

Also applies to: 317-360, 280-285

🤖 Prompt for AI Agents
In src/components/homes/homedetails/ImageGallery.vue around lines 7-15 (also
apply same changes near 280-285 and 317-360): clicks trigger openFullscreen even
after a swipe/drag because there's no suppression of click post-drag; add a
lightweight drag suppression mechanism — track pointer movement during
handleDragStart/handleDragMove and set a wasDragging flag when movement exceeds
a small threshold, set a short timeout (e.g., 200-300ms) to clear the flag on
handleDragEnd, and then guard openFullscreen (the @click handler) to return
early if wasDragging is true; ensure touch and mouse handlers share the same
flag and threshold so swipes don't unintentionally open fullscreen.

Comment on lines +29 to +36
<button
@click.prevent.stop="toggleFavorite"
class="absolute top-3 right-3 z-10 w-8 h-8 bg-white/90 backdrop-blur-sm rounded-full flex items-center justify-center transition-all hover:bg-white hover:scale-110 shadow-lg"
>
<svg :class="[isFavorite ? 'text-red-500' : 'text-gray-400']" class="w-5 h-5 transition-colors" :fill="isFavorite ? 'currentColor' : 'none'" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
</svg>
</button>
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

즐겨찾기 토글 중복 클릭 방지 및 카운트 동기화 필요

  • 버튼 연타 시 중복 요청이나 상태 뒤틀림 가능. in-flight 가드(로딩 플래그) 추가 권장.
  • 성공 시 likeCnt도 낙관적 업데이트(+/-1)하고 실패 시 롤백하면 UI/서버 간 불일치 줄일 수 있습니다.

적용 예시:

-        <button 
-          @click.prevent.stop="toggleFavorite"
-          class="absolute top-3 right-3 z-10 w-8 h-8 bg-white/90 backdrop-blur-sm rounded-full flex items-center justify-center transition-all hover:bg-white hover:scale-110 shadow-lg"
-        >
+        <button
+          @click.prevent.stop="toggleFavorite"
+          :disabled="isToggling"
+          :aria-pressed="isFavorite"
+          class="absolute top-3 right-3 z-10 w-8 h-8 bg-white/90 backdrop-blur-sm rounded-full flex items-center justify-center transition-all hover:bg-white hover:scale-110 shadow-lg disabled:opacity-50"
+        >
-const isFavorite = ref(props.listing.isLiked || false)
+const isFavorite = ref(props.listing.isLiked || false)
+const isToggling = ref(false)
-  try {
-    await toggleHomeLike(props.listing.homeId)
-    isFavorite.value = !isFavorite.value
+  try {
+    if (isToggling.value) return
+    isToggling.value = true
+    // 낙관적 업데이트
+    const prev = isFavorite.value
+    isFavorite.value = !prev
+    if (typeof props.listing.likeCnt === 'number') {
+      props.listing.likeCnt += isFavorite.value ? 1 : -1
+    }
+    await toggleHomeLike(props.listing.homeId)
   } catch (error) {
     console.error('찜하기 상태 변경 실패:', error)
+    // 롤백
+    isFavorite.value = !isFavorite.value
+    if (typeof props.listing.likeCnt === 'number') {
+      props.listing.likeCnt += isFavorite.value ? 1 : -1
+    }
+  } finally {
+    isToggling.value = false
   }

Also applies to: 147-153

Comment on lines +5 to +11
apiKey: import.meta.env.VITE_FIREBASE_API_KEY || 'AIzaSyCoYr1H2VwJaLpHA0krtZCOX-dTDGlXZYM',
authDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN || 'itzeep-de0ca.firebaseapp.com',
projectId: import.meta.env.VITE_FIREBASE_PROJECT_ID || 'itzeep-de0ca',
storageBucket: import.meta.env.VITE_FIREBASE_STORAGE_BUCKET || 'itzeep-de0ca.firebasestorage.app',
messagingSenderId: import.meta.env.VITE_FIREBASE_MESSAGING_SENDER_ID || '966020195845',
appId: import.meta.env.VITE_FIREBASE_APP_ID || '1:966020195845:web:9532cf29ef4f7108d55e6c',
measurementId: import.meta.env.VITE_FIREBASE_MEASUREMENT_ID || 'G-4MWDC6FYCE',
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

보안 이슈: 하드코딩된 Firebase 키 fallback 제거 필요 (Gitleaks 탐지됨)

정적 분석이 GCP API Key 노출을 경고했습니다. 코드 내 하드코딩된 기본값은 실수로 운영 빌드에 포함될 수 있으며, 키 순환 및 오남용 리스크가 있습니다. 환경변수만 사용하고, 누락 시 초기화 단계에서 명시적으로 실패하도록 하는 것이 안전합니다.

아래처럼 fallback을 제거하고, 누락 검사를 추가해 주세요.

환경값만 사용:

 const firebaseConfig = {
-  apiKey: import.meta.env.VITE_FIREBASE_API_KEY || 'AIzaSyCoYr1H2VwJaLpHA0krtZCOX-dTDGlXZYM',
-  authDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN || 'itzeep-de0ca.firebaseapp.com',
-  projectId: import.meta.env.VITE_FIREBASE_PROJECT_ID || 'itzeep-de0ca',
-  storageBucket: import.meta.env.VITE_FIREBASE_STORAGE_BUCKET || 'itzeep-de0ca.firebasestorage.app',
-  messagingSenderId: import.meta.env.VITE_FIREBASE_MESSAGING_SENDER_ID || '966020195845',
-  appId: import.meta.env.VITE_FIREBASE_APP_ID || '1:966020195845:web:9532cf29ef4f7108d55e6c',
-  measurementId: import.meta.env.VITE_FIREBASE_MEASUREMENT_ID || 'G-4MWDC6FYCE',
+  apiKey: import.meta.env.VITE_FIREBASE_API_KEY,
+  authDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN,
+  projectId: import.meta.env.VITE_FIREBASE_PROJECT_ID,
+  storageBucket: import.meta.env.VITE_FIREBASE_STORAGE_BUCKET,
+  messagingSenderId: import.meta.env.VITE_FIREBASE_MESSAGING_SENDER_ID,
+  appId: import.meta.env.VITE_FIREBASE_APP_ID,
+  measurementId: import.meta.env.VITE_FIREBASE_MEASUREMENT_ID,
 }

추가로, 아래 검증 코드를 firebaseConfig 선언 직후에 넣어 주세요(선택):

// 필수 키 누락 검증: 개발환경에서는 경고, 운영환경에서는 실패
const required = [
  'VITE_FIREBASE_API_KEY',
  'VITE_FIREBASE_AUTH_DOMAIN',
  'VITE_FIREBASE_PROJECT_ID',
  'VITE_FIREBASE_STORAGE_BUCKET',
  'VITE_FIREBASE_MESSAGING_SENDER_ID',
  'VITE_FIREBASE_APP_ID',
  'VITE_FIREBASE_MEASUREMENT_ID',
]
const missing = required.filter((k) => !import.meta.env[k])
if (missing.length) {
  const msg = `[Firebase] Missing env: ${missing.join(', ')}`
  if (import.meta.env.PROD) throw new Error(msg)
  console.warn(msg)
}

참고: .env.example와 Docker ARG/ENV가 이미 준비되어 있다면, 빌드/배포 파이프라인에서 해당 값들이 항상 주입되도록만 관리하면 됩니다.

🧰 Tools
🪛 Gitleaks (8.27.2)

5-5: Uncovered a GCP API key, which could lead to unauthorized access to Google Cloud services and data breaches.

(gcp-api-key)

Comment on lines 1183 to +1197
const startStatusPolling = () => {
console.log('상태 폴링 시작')

// 즉시 한 번 실행
getExportStatus(contractId.value)
.then((status) => {
if (status) {
console.log('초기 폴링 상태:', status)
exportStatus.value = status
}
})
.catch((err) => {
console.error('초기 상태 조회 실패:', err)
})

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

상태 폴링 중복 시작 방지 가드 추가 필요

startStatusPolling이 여러 번 호출되면 interval 누수가 생길 수 있습니다. 이미 실행 중이면 재시작을 막아 주세요.

 const startStatusPolling = () => {
-  console.log('상태 폴링 시작')
+  if (pollingInterval) {
+    if (import.meta.env.DEV) console.debug('상태 폴링이 이미 실행 중입니다. 재시작 생략')
+    return
+  }
+  if (import.meta.env.DEV) console.debug('상태 폴링 시작')

Also applies to: 1256-1262

🤖 Prompt for AI Agents
In src/pages/contract/ContractCompletePage.vue around lines 1183-1197 (and
similarly at 1256-1262), startStatusPolling currently allows multiple concurrent
timers causing interval leaks; add a guard using a module/component-scoped
variable (e.g., pollingIntervalId or isPolling boolean) checked at the start of
startStatusPolling to return early if already running, set the variable when
creating the interval, and clear/reset it when stopping the poll or on component
unmount to prevent duplicate intervals and ensure proper cleanup.

Comment on lines +2 to +15
const authRequiredRoutes = [
'mypage',
'mypage-profile',
'mypage-properties',
'mypage-favorites',
'mypage-chat-properties',
'mypage-contracts',
'mypage-risk-check-results',
'chat',
'contract-chat',
'contract-step',
'contract-export',
'home-create',
]
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

authRequiredRoutes 라우트 이름 일치 여부 확인 필요

하드코딩된 이름/세그먼트가 실제 라우터 정의와 불일치하면 보호가 누락됩니다(예: 'home-create'의 실제 name 확인). 아래 스크립트로 존재 여부를 점검하세요.


🏁 Script executed:

#!/bin/bash
set -euo pipefail
echo "Checking route names..."
for name in \
  mypage mypage-profile mypage-properties mypage-favorites mypage-chat-properties \
  mypage-contracts mypage-risk-check-results chat contract-chat contract-step \
  contract-export home-create
do
  if rg -nP -C2 "(name\s*:\s*'${name}')" src/router; then
    echo "FOUND: ${name}"
  else
    echo "MISSING: ${name}"
  fi
done

Length of output: 4767


미정의된 라우트 이름 확인 및 수정 필요

아래 authRequiredRoutes에 포함된 라우트 이름이 src/router 정의에서 누락되어 있습니다. 이대로 두면 해당 경로가 보호되지 않습니다.

– mypage-profile
– mypage-favorites
– mypage-chat-properties
– mypage-risk-check-results
– contract-chat
– contract-step
– contract-export
– home-create

위 이름이 실제 route.name과 일치하는지 확인 후,
• 해당 라우터 정의를 추가하거나
authRequiredRoutes 목록을 실제 이름으로 수정해주세요.

🤖 Prompt for AI Agents
In src/router/guards.js around lines 2 to 15, several route names listed in
authRequiredRoutes (mypage-profile, mypage-favorites, mypage-chat-properties,
mypage-risk-check-results, contract-chat, contract-step, contract-export,
home-create) do not match any defined route names in src/router; confirm the
actual route.name values in your route definitions and either add the missing
route definitions (with the exact name fields) or update authRequiredRoutes to
use the exact existing route.name strings so the auth guard correctly protects
those pages.

Comment on lines +27 to +49
export const sendTestNotification = () => {
if (Notification.permission !== 'granted') {
console.error('❌ 알림 권한이 없습니다. 먼저 권한을 요청하세요.')
return false
}

// 일반 브라우저 알림
const notification = new Notification('잇집 알림 테스트', {
body: '알림이 정상적으로 작동합니다! 🎉',
icon: '/favicon.ico',
badge: '/favicon.ico',
tag: 'test-notification',
requireInteraction: false,
})

notification.onclick = () => {
console.log('알림 클릭됨!')
notification.close()
}

console.log('✅ 테스트 알림 전송 완료')
return true
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

일부 브라우저에서 Notification 생성 예외 가능성: try/catch로 보호

iOS Safari 등 일부 환경에서 포그라운드/권한 상태에 따라 new Notification()이 예외를 던질 수 있습니다. 안전성을 위해 감싸주세요.

 export const sendTestNotification = () => {
   if (Notification.permission !== 'granted') {
     console.error('❌ 알림 권한이 없습니다. 먼저 권한을 요청하세요.')
     return false
   }
 
-  // 일반 브라우저 알림
-  const notification = new Notification('잇집 알림 테스트', {
-    body: '알림이 정상적으로 작동합니다! 🎉',
-    icon: '/favicon.ico',
-    badge: '/favicon.ico',
-    tag: 'test-notification',
-    requireInteraction: false,
-  })
-
-  notification.onclick = () => {
-    console.log('알림 클릭됨!')
-    notification.close()
-  }
-
-  console.log('✅ 테스트 알림 전송 완료')
-  return true
+  try {
+    // 일반 브라우저 알림
+    const notification = new Notification('잇집 알림 테스트', {
+      body: '알림이 정상적으로 작동합니다! 🎉',
+      icon: '/favicon.ico',
+      badge: '/favicon.ico',
+      tag: 'test-notification',
+      requireInteraction: false,
+    })
+    notification.onclick = () => {
+      console.log('알림 클릭됨!')
+      notification.close()
+    }
+    console.log('✅ 테스트 알림 전송 완료')
+    return true
+  } catch (e) {
+    console.error('❌ 브라우저 알림 생성 실패:', e)
+    return false
+  }
 }
📝 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.

Suggested change
export const sendTestNotification = () => {
if (Notification.permission !== 'granted') {
console.error('❌ 알림 권한이 없습니다. 먼저 권한을 요청하세요.')
return false
}
// 일반 브라우저 알림
const notification = new Notification('잇집 알림 테스트', {
body: '알림이 정상적으로 작동합니다! 🎉',
icon: '/favicon.ico',
badge: '/favicon.ico',
tag: 'test-notification',
requireInteraction: false,
})
notification.onclick = () => {
console.log('알림 클릭됨!')
notification.close()
}
console.log('✅ 테스트 알림 전송 완료')
return true
}
export const sendTestNotification = () => {
if (Notification.permission !== 'granted') {
console.error('❌ 알림 권한이 없습니다. 먼저 권한을 요청하세요.')
return false
}
try {
// 일반 브라우저 알림
const notification = new Notification('잇집 알림 테스트', {
body: '알림이 정상적으로 작동합니다! 🎉',
icon: '/favicon.ico',
badge: '/favicon.ico',
tag: 'test-notification',
requireInteraction: false,
})
notification.onclick = () => {
console.log('알림 클릭됨!')
notification.close()
}
console.log('✅ 테스트 알림 전송 완료')
return true
} catch (e) {
console.error('❌ 브라우저 알림 생성 실패:', e)
return false
}
}
🤖 Prompt for AI Agents
In src/utils/notificationTest.js around lines 27 to 49, creating a Notification
can throw in some browsers (e.g. iOS Safari) so wrap the new Notification(...)
and subsequent notification setup in a try/catch block; on catch, log the error
(with a clear message), ensure any created notification is closed if needed, and
return false to signal failure, otherwise proceed to set onclick, log success,
and return true.

// 전체 알림 시스템 진단
export async function diagnoseNotificationSystem() {
console.log('🔍 알림 시스템 진단 시작...')
console.log('=' * 50)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

문자열 곱셈 버그: '=' * 50는 JS에서 NaN 반환

JS에서는 문자열 곱셈이 불가합니다. repeat를 사용해야 합니다.

-  console.log('=' * 50)
+  console.log('='.repeat(50))

같은 수정이 하단 라인에도 필요합니다.

-  console.log('=' * 50)
+  console.log('='.repeat(50))

Also applies to: 146-146

🤖 Prompt for AI Agents
In src/utils/testNotification.js around lines 107 and 146, the code uses '=' *
50 which in JavaScript yields NaN because string multiplication isn't supported;
replace those occurrences with '='.repeat(50) (or another appropriate repeat
count) to produce a 50-character separator string and ensure any console.log or
output uses the .repeat result.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

♻️ Duplicate comments (3)
src/pages/contract/ContractCompletePage.vue (3)

517-524: 운영 환경에서 WebSocket 페이로드/이전 상태 전체 로그 출력 — 민감정보/성능 이슈, 개발 모드 가드 필요

운영 환경에서 매 이벤트마다 payload 전체(JSON.stringify)와 이전 상태 전체를 로그로 노출하는 것은 과도하며 민감정보/성능 리스크가 있습니다. 개발 모드에서만 요약 정보만 출력하도록 가드해 주세요.

적용 예시:

-  console.log('=== WebSocket 상태 업데이트 수신 ===')
-  console.log('받은 데이터:', JSON.stringify(data, null, 2))
-  console.log('현재 userRole:', userRole.value)
+  if (import.meta.env.DEV) {
+    console.debug('[WS][export/status] 업데이트 수신', {
+      step: data?.currentStep,
+      ownerSigned: !!data?.ownerSignatureCompleted,
+      buyerSigned: !!data?.buyerSignatureCompleted,
+      hasFinalPdf: !!data?.finalPdfUrl,
+      userRole: userRole.value,
+    })
+  }

-  console.log('이전 상태:', prevStatus)
+  if (import.meta.env.DEV && prevStatus) {
+    console.debug('[WS] 이전 상태 요약', {
+      step: prevStatus.currentStep,
+      ownerSigned: !!prevStatus.ownerSignatureCompleted,
+      buyerSigned: !!prevStatus.buyerSignatureCompleted,
+    })
+  }

916-917: 서명 Base64 전체 로그 유출 — 반드시 제거/마스킹

signatureMessage 전체(JSON.stringify)에는 서명 이미지의 Base64가 그대로 포함됩니다. 콘솔 로그만으로도 서명 원본이 유출됩니다. 개발 모드에서도 마스킹된 요약만 남기고, 운영에서는 출력하지 마세요.

수정 예시:

-    console.log('서명 메시지:', JSON.stringify(signatureMessage, null, 2))
+    if (import.meta.env.DEV) {
+      console.debug('서명 메타 전송', {
+        userRole: signatureMessage.userRole,
+        hasTaxArrears: signatureMessage.hasTaxArrears,
+        hasPriorFixedDate: signatureMessage.hasPriorFixedDate,
+        mediationAgree: signatureMessage.mediationAgree,
+        submittedAt: signatureMessage.submittedAt,
+        sig1: signatures.value.signature1 ? `len=${signatures.value.signature1.dataUrl.length}` : null,
+        sig2: signatures.value.signature2 ? `len=${signatures.value.signature2.dataUrl.length}` : null,
+        sig3: signatures.value.signature3 ? `len=${signatures.value.signature3.dataUrl.length}` : null,
+      })
+    }

추가 권고: 서명 이미지 자체를 WebSocket으로 보내지 않고, HTTP 업로드(또는 presigned URL)로 전송 후 WebSocket은 “저장 완료/레코드 ID”만 전송하는 경량 신호로 바꾸는 것을 권장합니다. 서버 API 계약 확인 필요.


1141-1144: 상태 폴링 중복 시작 방지 가드 추가 (메모리/네트워크 누수 방지)

startStatusPolling가 여러 번 호출되면 interval이 중복 생성될 수 있습니다. 재시작 가드를 넣어 주세요.

-  console.log('상태 폴링 시작')
+  if (pollingInterval) {
+    if (import.meta.env.DEV) console.debug('상태 폴링이 이미 실행 중입니다. 재시작 생략')
+    return
+  }
+  if (import.meta.env.DEV) console.debug('상태 폴링 시작')
🧹 Nitpick comments (7)
src/pages/contract/ContractCompletePage.vue (7)

1186-1189: 양측 서명 완료 후 PDF 미생성 시 'generating' 단계로 명시 전환

UX 명확성을 위해 생성 대기 상태를 단계로 표현하는 것이 좋습니다.

-          console.log('최종 계약서 생성 중...')
-          isLoading.value = true
+          console.log('최종 계약서 생성 중...')
+          isLoading.value = true
+          if (currentStep.value !== 'generating') {
+            currentStep.value = 'generating'
+          }

632-634: 개발용 SockJS 연결 테스트는 운영에서 비활성화 필요

http://localhost:8080/ws로의 테스트 연결은 운영 환경에서 항상 실패 로그를 남깁니다. 개발 모드에서만 실행하도록 가드하세요.

-  // WebSocket 연결 테스트
-  await testWebSocketConnection()
+  // WebSocket 연결 테스트 (개발 모드 전용)
+  if (import.meta.env.DEV) {
+    await testWebSocketConnection()
+  }

458-465: watch(exportStatus) 내 상세 로그는 개발 모드에서만 요약 출력

매 상태 변경마다 상세 로그는 과합니다. 개발 모드에서만 요약을 권장합니다.

-      console.log('WebSocket 상태 업데이트:', newStatus)
-      console.log('현재 단계:', newStatus.currentStep)
-      console.log('현재 사용자 역할:', userRole.value)
-      console.log('Owner 서명:', newStatus.ownerSignatureCompleted)
-      console.log('Buyer 서명:', newStatus.buyerSignatureCompleted)
-      console.log('완료 여부:', newStatus.isCompleted || newStatus.completed)
-      console.log('PDF URL:', newStatus.finalPdfUrl)
+      if (import.meta.env.DEV) {
+        console.debug('[WS] 상태 업데이트 요약', {
+          step: newStatus.currentStep,
+          userRole: userRole.value,
+          ownerSigned: !!newStatus.ownerSignatureCompleted,
+          buyerSigned: !!newStatus.buyerSignatureCompleted,
+          completed: !!(newStatus.isCompleted || newStatus.completed),
+          hasFinalPdf: !!newStatus.finalPdfUrl,
+        })
+      }

704-719: 주민번호 앞 6자리 추출 시 길이/형식 검증 추가 권장

예상치 못한 데이터(길이 < 6, null/undefined, 비문자열)가 올 경우 잘못된 암호 자동입력 가능성이 있습니다. 최소 길이/형식 검사를 추가하세요.

-      if (userRole.value === 'owner' && contractData.ownerSsn) {
-        // 임대인의 경우: ownerSsn의 앞 6자리
-        birthDate.value = contractData.ownerSsn.substring(0, 6)
-      } else if (
-        (userRole.value === 'buyer' || userRole.value === 'tenant') &&
-        contractData.buyerSsn
-      ) {
-        // 임차인의 경우: buyerSsn의 앞 6자리
-        birthDate.value = contractData.buyerSsn.substring(0, 6)
-      }
+      if (userRole.value === 'owner' && typeof contractData.ownerSsn === 'string' && contractData.ownerSsn.length >= 6) {
+        birthDate.value = contractData.ownerSsn.slice(0, 6)
+      } else if ((userRole.value === 'buyer' || userRole.value === 'tenant')
+        && typeof contractData.buyerSsn === 'string' && contractData.buyerSsn.length >= 6) {
+        birthDate.value = contractData.buyerSsn.slice(0, 6)
+      }
 
       // 암호 필드에 자동으로 생년월일 설정
       if (birthDate.value) {

추가로 숫자 6자리 정규식(/^\d{6}$/) 검증까지 고려해 주세요.


739-741: API 응답 전체 로그는 개발 모드에서만 출력

응답 객체에는 민감정보가 포함될 수 있습니다. 운영에서는 제거하거나 요약하세요.

-    } else {
-      console.log('finalContract 레코드 초기화 응답:', response.data)
-    }
+    } else if (import.meta.env.DEV) {
+      console.debug('finalContract 레코드 초기화 응답(요약):', {
+        success: response?.data?.success ?? null,
+        hasData: !!response?.data?.data,
+      })
+    }

1231-1234: 이동은 window.history.back() 대신 router.back() 사용 권장

SPA 라우팅 일관성 및 히스토리 스택 제어를 위해 Vue Router API 사용이 적합합니다.

-    window.history.back()
+    router.back()

192-223: 알림(alert) 다건 사용 — 전역 토스트/모달로 일원화 권장

여러 위치에서 alert를 직접 호출하고 있습니다. 사용자 경험과 일관성을 위해 기존 전역 모달/알림 시스템으로 치환하는 것을 권장합니다. 특히 PDF 생성 완료/대기 안내 문구는 토스트/비차단 알림이 적합합니다.

Also applies to: 1170-1185, 1193-1204

📜 Review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between b67c641 and 6d402a4.

📒 Files selected for processing (4)
  • src/pages/contract/ContractCompletePage.vue (22 hunks)
  • src/pages/contract/ContractGatePage.vue (2 hunks)
  • src/pages/contract/ContractPage.vue (2 hunks)
  • src/pages/contract/TestContractExport.vue (2 hunks)
🚧 Files skipped from review as they are similar to previous changes (3)
  • src/pages/contract/ContractGatePage.vue
  • src/pages/contract/ContractPage.vue
  • src/pages/contract/TestContractExport.vue
🔇 Additional comments (1)
src/pages/contract/ContractCompletePage.vue (1)

816-819: updateContractStep('END') 호출 시점/값 검증 필요

서명 화면 진입 시 contract_step을 'END'로 설정하는 것은 의미적으로 “계약 종료”로 오해될 수 있습니다. 서버가 기대하는 단계 값과 타이밍이 맞는지 확인이 필요합니다. 서명 시작이라면 ‘SIGNING’(또는 유사 단계)로 맞추는 것이 자연스럽습니다.

잠정 수정 예시(서버 계약이 ‘SIGNING’일 경우):

-  updateContractStep('END')
+  updateContractStep('SIGNING')

서버 단계 정의를 확인해 주시면, 정확한 단계명으로 패치 제안 드리겠습니다.

Comment on lines +904 to 906
hasTaxArrears: !hasTaxArrears.value,
hasPriorFixedDate: !hasPriorFixedDate.value,
mediationAgree: true, // 항상 true로 설정
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

WS/HTTP 전송 필드 값 불일치(hasTaxArrears/hasPriorFixedDate) — 즉시 수정 필요

WebSocket 메시지에만 부정(!)을 취해 전송하고, HTTP(dto)에는 원값을 전송합니다. 동일 사건에 대해 서로 반대의 의미가 서버에 저장될 수 있습니다. 두 경로 모두 동일한 의미로 전송하도록 정합성을 맞춰야 합니다.

HTTP와 동일하게 원값을 전송하도록 수정 제안:

-      hasTaxArrears: !hasTaxArrears.value,
-      hasPriorFixedDate: !hasPriorFixedDate.value,
+      hasTaxArrears: hasTaxArrears.value,
+      hasPriorFixedDate: hasPriorFixedDate.value,

참고: 만약 서버가 “체납 없음/확정일자 없음”을 true로 해석하도록 설계되었다면, HTTP(dto) 측도 동일하게 부정 값을 보내도록 통일해야 합니다. 현재 상태는 반드시 하나로 정리되어야 합니다.

📝 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.

Suggested change
hasTaxArrears: !hasTaxArrears.value,
hasPriorFixedDate: !hasPriorFixedDate.value,
mediationAgree: true, // 항상 true로 설정
hasTaxArrears: hasTaxArrears.value,
hasPriorFixedDate: hasPriorFixedDate.value,
mediationAgree: true, // 항상 true로 설정
🤖 Prompt for AI Agents
In src/pages/contract/ContractCompletePage.vue around lines 904-906, the
WebSocket payload inverts hasTaxArrears and hasPriorFixedDate with a negation
while the HTTP DTO sends the original values, causing inconsistent semantics on
the server; change the WebSocket send to use the same boolean values as the HTTP
DTO (remove the leading '!' negation) so both WS and HTTP send identical
meanings, or if the server expects the inverted semantics, instead invert the
values in the HTTP DTO so both channels are consistent — pick one canonical
interpretation and apply it to both send paths.

Comment on lines 926 to 953
if (!sendResult) {
console.warn('WebSocket 메시지 전송 실패. HTTP API 사용...')
try {
// HTTP API를 통한 서명 상태 업데이트
const httpResult = await updateSignatureStatus(contractId.value, signatureMessage)
console.log('HTTP API 서명 상태 업데이트 결과:', httpResult)

// 상태 폴링 시작
startStatusPolling()
} catch (error) {
console.error('HTTP API 서명 상태 업데이트 실패:', error)
}

// 항상 HTTP API로도 전송 (백업)
try {
console.log('HTTP API로 서명 상태 업데이트 시도...')
const httpResult = await updateSignatureStatus(contractId.value, signatureMessage)
console.log('HTTP API 서명 상태 업데이트 결과:', httpResult)

// HTTP API 성공 시 본인 서명 상태를 즉시 업데이트
if (httpResult && httpResult.success) {
if (userRole.value === 'owner') {
exportStatus.value = {
...exportStatus.value,
ownerSignatureCompleted: true,
}
} else {
exportStatus.value = {
...exportStatus.value,
buyerSignatureCompleted: true,
}
}
}
} catch (error) {
handleError(error, '서명 전송에 실패했습니다. 다시 시도해주세요.')
return // 실패 시 대기 화면으로 가지 않음
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

WS 성공 시에도 항상 HTTP 백업 호출 — 중복 처리/중복 저장 위험

WS 전송 성공 여부와 무관하게 HTTP API를 “항상” 호출하고 있어 서버에서 중복 처리될 수 있습니다. WS 실패 시에만 HTTP로 폴백하도록 변경하세요.

-    // WebSocket 실패 시 또는 백업으로 HTTP API 사용
-    if (!sendResult) {
-      console.warn('WebSocket 메시지 전송 실패. HTTP API 사용...')
-    }
-
-    // 항상 HTTP API로도 전송 (백업)
-    try {
-      console.log('HTTP API로 서명 상태 업데이트 시도...')
-      const httpResult = await updateSignatureStatus(contractId.value, signatureMessage)
-      console.log('HTTP API 서명 상태 업데이트 결과:', httpResult)
-
-      // HTTP API 성공 시 본인 서명 상태를 즉시 업데이트
-      if (httpResult && httpResult.success) {
-        if (userRole.value === 'owner') {
-          exportStatus.value = {
-            ...exportStatus.value,
-            ownerSignatureCompleted: true,
-          }
-        } else {
-          exportStatus.value = {
-            ...exportStatus.value,
-            buyerSignatureCompleted: true,
-          }
-        }
-      }
-    } catch (error) {
-      handleError(error, '서명 전송에 실패했습니다. 다시 시도해주세요.')
-      return // 실패 시 대기 화면으로 가지 않음
-    }
+    // WebSocket 실패 시에만 HTTP 폴백
+    if (!sendResult) {
+      console.warn('WebSocket 메시지 전송 실패. HTTP API 사용...')
+      try {
+        console.log('HTTP API로 서명 상태 업데이트 시도...')
+        const httpResult = await updateSignatureStatus(contractId.value, signatureMessage)
+        console.log('HTTP API 서명 상태 업데이트 결과:', httpResult)
+        if (httpResult && httpResult.success) {
+          if (userRole.value === 'owner') {
+            exportStatus.value = { ...exportStatus.value, ownerSignatureCompleted: true }
+          } else {
+            exportStatus.value = { ...exportStatus.value, buyerSignatureCompleted: true }
+          }
+        }
+      } catch (error) {
+        handleError(error, '서명 전송에 실패했습니다. 다시 시도해주세요.')
+        return // 실패 시 대기 화면으로 가지 않음
+      }
+    }
📝 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.

Suggested change
if (!sendResult) {
console.warn('WebSocket 메시지 전송 실패. HTTP API 사용...')
try {
// HTTP API를 통한 서명 상태 업데이트
const httpResult = await updateSignatureStatus(contractId.value, signatureMessage)
console.log('HTTP API 서명 상태 업데이트 결과:', httpResult)
// 상태 폴링 시작
startStatusPolling()
} catch (error) {
console.error('HTTP API 서명 상태 업데이트 실패:', error)
}
// 항상 HTTP API로도 전송 (백업)
try {
console.log('HTTP API로 서명 상태 업데이트 시도...')
const httpResult = await updateSignatureStatus(contractId.value, signatureMessage)
console.log('HTTP API 서명 상태 업데이트 결과:', httpResult)
// HTTP API 성공 시 본인 서명 상태를 즉시 업데이트
if (httpResult && httpResult.success) {
if (userRole.value === 'owner') {
exportStatus.value = {
...exportStatus.value,
ownerSignatureCompleted: true,
}
} else {
exportStatus.value = {
...exportStatus.value,
buyerSignatureCompleted: true,
}
}
}
} catch (error) {
handleError(error, '서명 전송에 실패했습니다. 다시 시도해주세요.')
return // 실패 시 대기 화면으로 가지 않음
}
// WebSocket 실패 시에만 HTTP 폴백
if (!sendResult) {
console.warn('WebSocket 메시지 전송 실패. HTTP API 사용...')
try {
console.log('HTTP API로 서명 상태 업데이트 시도...')
const httpResult = await updateSignatureStatus(contractId.value, signatureMessage)
console.log('HTTP API 서명 상태 업데이트 결과:', httpResult)
if (httpResult && httpResult.success) {
if (userRole.value === 'owner') {
exportStatus.value = { ...exportStatus.value, ownerSignatureCompleted: true }
} else {
exportStatus.value = { ...exportStatus.value, buyerSignatureCompleted: true }
}
}
} catch (error) {
handleError(error, '서명 전송에 실패했습니다. 다시 시도해주세요.')
return // 실패 시 대기 화면으로 가지 않음
}
}
🤖 Prompt for AI Agents
In src/pages/contract/ContractCompletePage.vue around lines 926 to 953, the code
always calls the HTTP backup updateSignatureStatus even when the WebSocket
sendResult succeeded, causing potential duplicate processing; change the logic
so the HTTP API is called only when sendResult is falsy (i.e., inside the if
(!sendResult) branch), move the try/catch and exportStatus update into that
branch so that on WS success you skip the HTTP call entirely, while preserving
the error handling and ensuring exportStatus is updated only when the HTTP call
succeeds.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

♻️ refactor 리팩토링 작업

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants