Skip to content

[ON-66](Fix) backend integration#17

Open
Byunjihun wants to merge 1 commit intodevelopfrom
fix/ON-66_backendIntegration
Open

[ON-66](Fix) backend integration#17
Byunjihun wants to merge 1 commit intodevelopfrom
fix/ON-66_backendIntegration

Conversation

@Byunjihun
Copy link

@Byunjihun Byunjihun commented Jan 18, 2026

OneBitArticle (OBA) - Frontend (App)

OneBitArticle은 바쁜 IT 취업준비생들을 위해 매일 아침 AI가 요약한 IT 뉴스와 퀴즈를 제공하는 모바일 애플리케이션입니다. React Native (Expo) 환경에서 개발되었으며, 사용자 친화적인 UI와 학습 동기 부여를 위한 게이미피케이션 요소(Streak, 피자 메뉴 등)를 포함하고 있습니다.


Key Features (Update)

1. 홈 대시보드 & 기사 탐색

  • 학습 현황 (Streak): 로그인 시 연속 학습일수와 요일별 학습 로그를 시각적으로 보여줍니다.

  • 게스트 모드: 비로그인 상태에서도 '오늘의 기사'를 자유롭게 열람할 수 있으며, 로그인 유도 UI가 자연스럽게 노출됩니다.

  • 오늘의 기사 슬라이더: 매일 업데이트되는 기사를 카드 형태로 스와이프하여 탐색할 수 있습니다.

2. 기사 상세 & AI 학습 도구

  • 탭(Tab) 기반 뷰: 기사 본문 / 핵심 키워드 / 퀴즈 탭을 통해 체계적인 학습이 가능합니다.

  • AI 요약: 긴 기사도 3줄 요약 기능을 통해 빠르게 핵심을 파악할 수 있습니다.

  • 키워드 사전: 기사에 등장하는 어려운 IT 용어에 대한 AI 해설을 제공합니다.

3. 인터랙티브 퀴즈 시스템

  • 실시간 채점: 4지선다 퀴즈를 풀고 즉시 정답 여부와 해설을 확인할 수 있습니다.

  • 결과 저장: 채점 완료 시 백엔드 서버로 결과가 전송되어 학습 이력이 저장됩니다.

  • 오답 노트: 틀린 문제만 모아 언제든 다시 복습할 수 있는 전용 화면을 제공합니다.

4. 보안 및 인증

  • 소셜 로그인: Google OAuth2 연동을 통해 간편하게 가입/로그인할 수 있습니다.

  • 토큰 관리: JWT(Access/Refresh Token)를 SecureStore에 안전하게 저장하고, Axios Interceptor를 통해 만료 시 자동 로그아웃 처리합니다.


Directory Structure

Plaintext
oba_frontend
├── app
│   ├── (auth)          # 로그인 관련 화면 (login)
│   ├── (tabs)          # 메인 탭 화면 (홈, 오답노트, 리포트, 마이)
│   ├── article         # 기사 상세 및 퀴즈 관련 페이지 ([id].tsx)
│   └── _layout.tsx     # 앱 전역 레이아웃 및 설정
├── src
│   ├── api             # Axios 클라이언트 및 인터셉터 설정 (apiClient.ts)
│   ├── components      # 재사용 가능한 UI 컴포넌트 (PizzaMenu, AppBackground 등)
│   └── hooks           # 커스텀 훅
└── assets              # 이미지 및 폰트 리소스

Getting Started

1. Prerequisites

  • Node.js (LTS 버전 권장)

  • Expo CLI (npm install -g expo-cli)

  • iOS Simulator (Mac) 또는 Android Emulator

2. Installation

Bash
# 1. 레포지토리 클론
git clone https://github.com/OneBiteArticle/oba_frontend.git
cd oba_frontend

# 2. 패키지 설치
npm install

3. Configuration

src/api/apiClient.ts 파일에서 백엔드 서버 주소를 본인의 환경에 맞게 설정해야 합니다.

TypeScript
// src/api/apiClient.ts
const BASE_URL = "http://YOUR_SERVER_IP:9000"; // 로컬 테스트 시 본인 PC IP 입력

4. Run Application

Bash
# 개발 서버 실행 (캐시 초기화 옵션 권장)
npx expo start -c

# iOS 시뮬레이터 실행: 'i' 입력
# Android 에뮬레이터 실행: 'a' 입력


Release Notes (v1.1.0 - Backend Integration)

Branch: fix/ON-66_backendIntegration

이번 업데이트에서는 프론트엔드와 Spring Boot 백엔드 간의 완전한 데이터 연동이 이루어졌습니다.

New Features & Improvements

  • [Network] 백엔드 API 연동 완료: apiClient를 통해 유저 정보, 기사 목록, 상세 조회, 퀴즈 결과 전송 API를 연결했습니다.

  • [Quiz] 퀴즈 로직 고도화:

    • 퀴즈 정답/오답 판별 로직(인덱스 및 텍스트 매칭) 구현

    • 채점 버튼 클릭 시 서버로 결과(true/false 리스트) 자동 전송

  • [UI/UX] 탭 바(Tab Bar) 제어 개선:

    • 전체 화면 몰입감을 위해 _layout.tsx에서 하단 탭 바를 전역적으로 숨김 처리 (display: 'none').

    • 커스텀 네비게이션 요소(PizzaMenu)만 노출되도록 변경.

  • [UI/UX] 게스트 모드 UI 개선:

    • 로그인하지 않은 상태에서도 앱 메인 진입 가능.

    • 홈 화면에서 사용자 이름 대신 "로그인이 필요해요" 문구 및 로그인 버튼 노출.

    • 학습 연속일(Streak) 및 주간 로그 숨김 처리로 깔끔한 UI 제공.

  • [Fix] 디렉토리 구조 정리: 중복되거나 잘못된 위치에 있던 app/index.tsx 파일을 제거하고 (tabs) 구조로 일원화했습니다.

Summary by CodeRabbit

릴리스 노트

  • 새로운 기능

    • OAuth 로그인 기능 추가 (Google, Kakao, Naver)
    • 홈 화면에 사용자 대시보드 추가: 프로필, 현재 날짜, 연속 학습 스트릭, 주간 활동 추적
    • 오답 노트 페이지 개선: 타임라인 뷰 및 카테고리 필터링
  • 개선 사항

    • 기사 페이지 재설계 및 AI 요약 토글 기능
    • 퀴즈 결과 표시 및 채점 로직 개선
    • 탭 기반 네비게이션 구조 최적화
    • 보안 강화: 토큰 보안 저장

✏️ Tip: You can customize this high-level summary in your review settings.

@coderabbitai
Copy link

coderabbitai bot commented Jan 18, 2026

Walkthrough

OAuth 인증 흐름을 추가하고 홈페이지 대시보드 UI를 구현했으며, 오답노트와 마이페이지를 재설계했고, 기사 상세 페이지의 퀴즈 흐름을 개선하고 API 클라이언트에 토큰 기반 인증 인터셉터를 통합했습니다.

Changes

Cohort / File(s) 요약
OAuth 인증 및 보안 저장소 통합
app/(auth)/_layout.tsx, app/(auth)/login.tsx, src/api/apiClient.ts
OAuth 세션 처리, 심화된 링크 파싱, SecureStore를 통한 토큰 저장소 추가; API 클라이언트에 Bearer 토큰 인터셉터 및 401 처리 로직 구현
홈페이지 대시보드 및 사용자 정보 표시
app/(tabs)/index.tsx
UserSummary 타입 추가, /api/users/me에서 사용자 데이터 페칭, 대시보드 섹션(날짜, 연속 로그인 일수, 주간 활동 추적, 프로필 이미지) 추가; useFocusEffect 기반 데이터 갱신
오답노트 페이지 완전 재설계
app/(tabs)/wrongArticles/index.tsx
WrongArticle 모델 도입, /api/quiz/wrong 엔드포인트 연동, 타임라인 기반 UI 변경, 카테고리 필터링 추가, 로그인 상태 확인 로직 구현
마이페이지 재구성
app/(tabs)/my/index.tsx
사용자 프로필 데이터 페칭 로직 재작성, 로그인/로그아웃 액션 추가, SecureStore 토큰 삭제 처리, useFocusEffect 기반 데이터 갱신
기사 상세 페이지 및 퀴즈 흐름 개선
app/article/[id].tsx, app/article/components/ArticleTab.tsx, app/article/components/KeywordTab.tsx, app/article/components/QuizTab.tsx, app/article/components/TabBar.tsx
API 엔드포인트 변경(/api/articles/{id}), 퀴즈 결과 제출 로직 추가, SecureStore 기반 로그인 확인, 탭 UI 간소화("요약" 탭 제거), 컴포넌트 Props 타입 정의, 퀴즈 UI/스타일 재설계
앱 레이아웃 재구조화
app/_layout.tsx, app/(tabs)/_layout.tsx
SafeAreaProvider/ThemeProvider 기반 레이아웃에서 Tabs 기반 레이아웃으로 변경, 네 개 탭 화면 정의, RootLayout에서 TabLayout으로 내보내기 이름 변경
설정 및 의존성 업데이트
app.json, package.json
플랫폼 배열 재포맷, expo-web-browser 및 expo-secure-store 플러그인 추가, @react-native-async-storage/async-storage, expo-secure-store 의존성 추가, expo-linking과 expo-web-browser 버전 업데이트
유틸리티 및 주석 정리
app/components/PizzaMenu/usePizzaAnimation.ts, app/(tabs)/report/index.tsx
파일 경로 참조 주석 추가 및 정리

Sequence Diagram(s)

sequenceDiagram
    actor User
    participant LoginScreen as Login Screen
    participant WebBrowser as WebBrowser
    participant OAuthProvider as OAuth Provider
    participant APIClient as API Client
    participant SecureStore as SecureStore
    participant Router as Router

    User->>LoginScreen: 로그인 버튼 클릭 (Google/Kakao/Naver)
    LoginScreen->>WebBrowser: openAuthSessionAsync() 호출
    WebBrowser->>OAuthProvider: OAuth 인증 요청
    OAuthProvider->>User: 로그인 페이지 표시
    User->>OAuthProvider: 자격증명 입력
    OAuthProvider->>WebBrowser: 리디렉트 URL (토큰 포함)
    WebBrowser->>LoginScreen: 콜백 결과 반환
    LoginScreen->>LoginScreen: 리디렉트 URL에서 토큰 추출
    LoginScreen->>SecureStore: 액세스/리프레시 토큰 저장
    LoginScreen->>Router: 메인 탭 화면으로 라우팅
    Router->>User: 대시보드 표시
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~70 minutes

Possibly related PRs

Suggested reviewers

  • Thedduro
  • hwi-min
  • sweetpotatolove

Poem

🐰 토큰을 품은 마법의 저장소,
로그인 흐름 춤을 추고,
대시보드 빛나고, 오답노트 정렬하고,
프로필 페이지 새것으로 변신하네!
차근차근 기사 읽고 퀴즈 맞춰 가는,
우리들의 학습 여정이 시작되었답니다! 🌟

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed PR 제목 'ON-66 backend integration'은 주요 변경사항인 백엔드 통합을 명확하게 반영하고 있으며, PR 목표와 일치합니다.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings

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

Comment @coderabbitai help to get the list of available commands and usage tips.

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: 9

🤖 Fix all issues with AI agents
In `@app/`(auth)/login.tsx:
- Around line 15-16: BACKEND_URL and REDIRECT_URI are hardcoded; move them to
environment/config and use a runtime-generated redirect URI: replace the
constant BACKEND_URL with a value read from env/config (e.g.,
process.env.BACKEND_URL or app config) and replace REDIRECT_URI with a call to
Linking.createURL(...) (or equivalent environment-aware helper) so the redirect
is generated per environment; update any usages of BACKEND_URL and REDIRECT_URI
in this module to read from the new env/config values and remove the hardcoded
IP.
- Around line 50-52: The code path in app/(auth)/login.tsx that handles
result.type === "success" falls back to only console.log when no token is
present (console.log("⚠️ 토큰이 없습니다. 파라미터:", queryParams)), leaving users without
feedback; update the success-but-no-token branch to show a clear user-facing
notification (e.g., via existing toast/error UI or an error banner) and
optionally log the diagnostic info for debugging, referencing the same check
that inspects result.type and queryParams in the login handler so users see a
message like "로그인에 실패했습니다: 토큰을 받지 못했습니다" and developers still get the
console/error details.

In `@app/article/`[id].tsx:
- Around line 85-89: getCorrectIndex currently parses quiz.answer and can
produce NaN (and then -1) when the string contains no digits; update
getCorrectIndex to defensively handle that by checking quiz.answerIndex first,
then extracting digits from quiz.answer, using parseInt result only if it's a
valid finite number, otherwise fall back to a safe default (e.g., 0) and ensure
you subtract 1 only when parseInt succeeded; reference the function
getCorrectIndex and properties quiz.answerIndex and quiz.answer and ensure the
returned index is a non-negative integer (clamped/validated) so grading logic
won't receive -1 or NaN.

In `@app/article/components/ArticleTab.tsx`:
- Line 45: The Text element in ArticleTab.tsx currently falls back to a
hardcoded string "2026.01.14" when article.servingDate is missing; replace that
fallback by either omitting the date portion when article.servingDate is falsy
or computing and formatting the current date dynamically; update the render
logic around the Text using styles.dateInfo and article.servingDate so it
conditionally shows "한입경제 · {formattedDate}" only when a real date exists or
uses a formatted new Date() (e.g., YYYY.MM.DD) as the fallback instead of the
hardcoded literal.

In `@app/article/components/QuizTab.tsx`:
- Around line 112-114: Rename the boolean identifier isPreviouslySolved to
wasPreviouslyCorrect to reflect that it denotes whether the user was previously
correct; update the JSX usage in QuizTab (the Text element using
styles.resultText and the color/label logic), rename the prop/variable
declaration and all references inside QuizTab (and any parent components or prop
interfaces that pass it) so behavior remains identical but the name now clearly
indicates "previously correct".

In `@app/components/PizzaMenu/usePizzaAnimation.ts`:
- Around line 1-2: Update the filename casing in the header/comment and any
imports so the file name matches the actual file `usePizzaAnimation.ts`; change
occurrences of `UsePizzaAnimation.ts` to `usePizzaAnimation.ts` (and verify the
exported hook name `usePizzaAnimation` and any import sites reference the exact
lowercase filename) and remove this unrelated filename-case change from the PR
if it isn't part of the backend work.

In `@src/api/apiClient.ts`:
- Around line 14-21: 요청 인터셉터(apiClient.interceptors.request.use)에서
SecureStore.getItemAsync 호출 실패 시 처리가 빠져 있으니 getItemAsync를 try/catch로 감싸고 실패하면
에러를 로깅하거나 안전한 기본 동작(토큰 없이 요청 진행 또는 인증 흐름 트리거)을 하도록 처리하세요; catch 블록에서는
config.headers.Authorization을 설정하지 않도록 하고 필요시 user-facing 재시도/로그 로직을 호출해 문제를
노출시키되 요청을 차단하지 않도록 구현하세요.
- Around line 23-33: Intercepted 401 handling can cause a redirect loop when a
request from the login page triggers another 401; update the
apiClient.interceptors.response.use handler to skip logout/redirect for requests
marked to avoid auth-redirects (e.g., check error.config?.skipAuthRedirect or an
_retry flag) and also check the current route before calling
router.replace("/(auth)/login"); specifically, modify the response interceptor
(apiClient.interceptors.response.use) to: 1) detect and ignore requests with a
skipAuthRedirect flag on error.config, 2) set/inspect an _retry boolean on
error.config to avoid repeated handling of the same request, and 3) ensure you
only call SecureStore.deleteItemAsync("accessToken") and router.replace(...)
when not already on the login route to prevent the 401 → login → 401 loop.
- Around line 6-7: Replace the hardcoded BASE_URL constant with a value read
from an environment/config variable so different devs and environments can
override it; update the BASE_URL declaration in apiClient.ts to read from
process.env (or your config loader) like REACT_APP_API_BASE_URL or API_BASE_URL
and provide a sensible default or fail-fast error if missing, and ensure any
tests or usage of BASE_URL (the constant) still reference the new env-backed
value.
🧹 Nitpick comments (18)
app/_layout.tsx (1)

6-7: 경로 힌트 주석 제거 고려

개발 중 도움이 되는 주석이지만, 프로덕션 코드에서는 제거하는 것이 좋습니다. 만약 경로 문제가 자주 발생한다면 프로젝트 구조를 명확히 문서화하거나 path alias를 설정하는 것을 권장합니다.

app/(auth)/_layout.tsx (1)

1-4: 개발용 주석 정리 권장

파일 경로와 import 경로 설명 주석들은 개발 시 유용하지만, 코드가 안정화되면 제거하는 것이 깔끔합니다.

package.json (1)

15-15: 버전 명시 방식 불일치

@react-native-async-storage/async-storage는 정확한 버전(2.2.0)을 사용하고 있지만, 다른 expo 패키지들은 tilde(~)를 사용합니다. 일관성을 위해 "~2.2.0" 또는 "^2.2.0"으로 변경하는 것을 권장합니다.

제안된 수정
-    "@react-native-async-storage/async-storage": "2.2.0",
+    "@react-native-async-storage/async-storage": "~2.2.0",
src/api/apiClient.ts (1)

9-12: 요청 타임아웃 설정 권장

네트워크 요청에 타임아웃이 설정되어 있지 않아, 서버 응답이 없을 경우 무한 대기 상태가 될 수 있습니다.

타임아웃 설정 추가
 export const apiClient = axios.create({
   baseURL: BASE_URL,
   headers: { "Content-Type": "application/json" },
+  timeout: 10000, // 10초
 });
app/article/components/ArticleTab.tsx (2)

5-8: any 타입 대신 구체적인 인터페이스 정의 권장

article: any는 타입 안전성을 잃게 합니다. 백엔드 응답 구조에 맞는 인터페이스를 정의하면 런타임 에러를 사전에 방지할 수 있습니다.

타입 정의 예시
interface GptResult {
  keywords?: Array<string | { keyword: string }>;
  summary?: string;
}

interface Article {
  title: string;
  servingDate?: string;
  content?: string[];
  gpt_result?: GptResult;
  gptResult?: GptResult;
  keywords?: Array<string | { keyword: string }>;
  summary?: string;
  summaryBullets?: string[];
}

interface ArticleTabProps {
  article: Article;
  onMoveToQuiz: () => void;
}

71-93: 이미지 감지 로직 개선 및 접근성 고려 필요

문자열 prefix "<img>"로 이미지를 감지하는 방식은 취약합니다. 가능하다면 백엔드에서 구조화된 데이터({ type: 'image', url: '...' })를 제공받는 것이 좋습니다. 또한 이미지에 accessibilityLabel을 추가하여 접근성을 개선하세요.

접근성 개선 예시
               <Image
                 key={index}
                 source={{ uri: imageUrl }}
+                accessibilityLabel="기사 이미지"
                 style={{
                   width: width - 40,
                   height: 220,
                   borderRadius: 12,
                   marginBottom: 15,
                   resizeMode: "cover"
                 }}
               />

백엔드 API 응답 구조를 확인하여, 이미지와 텍스트를 구조화된 형태로 제공하는 것이 가능한지 검토해 주세요.

app/(tabs)/my/index.tsx (2)

17-18: loading 상태가 선언되었지만 UI에서 사용되지 않습니다.

loading 상태가 설정되지만 로딩 중에 사용자에게 로딩 인디케이터나 스켈레톤 UI를 표시하는 데 활용되지 않습니다. 사용하지 않을 경우 제거하거나, 데이터 로딩 중 적절한 피드백을 제공하는 것이 좋습니다.

♻️ 로딩 상태 활용 예시
+  if (loading) {
+    return (
+      <View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
+        <ActivityIndicator size="large" color="#FF6B00" />
+      </View>
+    );
+  }
+
   return (
     <View style={{ flex: 1, backgroundColor: "#F8F9FA" }}>

72-74: 뒤로가기 버튼에서 router.replace 사용이 비정상적입니다.

뒤로가기 버튼은 일반적으로 router.back()을 사용하여 네비게이션 스택을 유지해야 합니다. router.replace는 현재 화면을 교체하므로 뒤로가기 히스토리가 손실됩니다.

♻️ 권장 수정
-          <TouchableOpacity onPress={() => router.replace("/(tabs)")} style={styles.backButton}>
+          <TouchableOpacity onPress={() => router.back()} style={styles.backButton}>
app/article/components/KeywordTab.tsx (1)

17-21: 중복된 null 체크가 있습니다.

safeKeywords는 이미 Line 17에서 빈 배열로 기본값이 설정되므로, Line 19의 !safeKeywords 체크는 불필요합니다.

♻️ 간소화된 코드
   const safeKeywords = keywords || [];
 
-  if (!safeKeywords || safeKeywords.length === 0) {
+  if (safeKeywords.length === 0) {
     return null;
   }
app/article/[id].tsx (1)

35-40: JSON 파싱 오류가 조용히 무시됩니다.

catch(e) {}로 파싱 오류를 무시하면 데이터 문제를 디버깅하기 어렵습니다. 최소한 개발 환경에서는 경고 로그를 남기는 것이 좋습니다.

♻️ 로깅 추가
         if (data.gpt_result && typeof data.gpt_result === 'string') {
-            try { data.gpt_result = JSON.parse(data.gpt_result); } catch(e) {}
+            try { data.gpt_result = JSON.parse(data.gpt_result); } catch(e) {
+              console.warn("gpt_result 파싱 실패:", e);
+            }
         }
         if (data.gptResult && typeof data.gptResult === 'string') {
-            try { data.gptResult = JSON.parse(data.gptResult); } catch(e) {}
+            try { data.gptResult = JSON.parse(data.gptResult); } catch(e) {
+              console.warn("gptResult 파싱 실패:", e);
+            }
         }
app/(tabs)/wrongArticles/index.tsx (2)

36-36: 카테고리가 하드코딩되어 있습니다.

카테고리 목록이 하드코딩되어 있어 백엔드에서 반환되는 실제 카테고리와 불일치할 수 있습니다. 백엔드에서 카테고리 목록을 동적으로 가져오거나, 서버 데이터에서 추출하는 방식을 고려해 보세요.


89-91: renderItem 내에서 getFilteredData() 호출은 비효율적입니다.

renderItem은 각 항목 렌더링 시마다 호출되므로, 매번 전체 데이터를 필터링하고 정렬하게 됩니다. useMemo를 사용하여 필터링된 데이터를 캐시하는 것이 좋습니다.

♻️ useMemo 사용 권장
+import React, { useState, useCallback, useMemo } from "react";
...

+  const filteredData = useMemo(() => {
+    const currentArticles = articles || [];
+    let filtered = selectedCategory === "All" 
+      ? currentArticles 
+      : currentArticles.filter(a => a.category === selectedCategory);
+
+    return [...filtered].sort((a, b) => {
+      const dateA = new Date(a.solvedAt).getTime();
+      const dateB = new Date(b.solvedAt).getTime();
+      return sortOrder === "newest" ? dateB - dateA : dateA - dateB;
+    });
+  }, [articles, selectedCategory, sortOrder]);

   const renderItem = ({ item, index }: { item: WrongArticle; index: number }) => {
-    const sortedData = getFilteredData();
-    const isLast = index === sortedData.length - 1;
+    const isLast = index === filteredData.length - 1;
...

       <FlatList
-        data={getFilteredData()}
+        data={filteredData}
app/(auth)/login.tsx (1)

42-45: SecureStore 저장 실패에 대한 에러 처리가 없습니다.

SecureStore.setItemAsync가 실패할 경우(예: 저장소 용량 부족) 에러가 상위 catch로 전파되어 일반적인 오류 메시지만 표시됩니다. 토큰 저장 실패는 별도로 처리하는 것이 좋습니다.

♻️ 에러 처리 개선
+         try {
            await SecureStore.setItemAsync("accessToken", accessStr);
            if (refreshStr) {
              await SecureStore.setItemAsync("refreshToken", refreshStr);
            }
+         } catch (storeError) {
+           console.error("토큰 저장 실패:", storeError);
+           Alert.alert("오류", "로그인 정보 저장에 실패했습니다.");
+           return;
+         }
app/(tabs)/index.tsx (4)

49-54: fetchData 함수가 useCallback 의존성에서 누락되었습니다.

fetchData가 컴포넌트 내부에 정의되어 있어 매 렌더링마다 새로 생성됩니다. 현재는 동작하지만, ESLint exhaustive-deps 규칙을 준수하려면 fetchDatauseCallback으로 감싸거나 의존성 배열에 추가해야 합니다.

♻️ useCallback으로 fetchData 감싸기
+  const fetchData = useCallback(async () => {
+    // ... 기존 fetchData 로직
+  }, []);

   useFocusEffect(
     useCallback(() => {
       fetchData();
-    }, [])
+    }, [fetchData])
   );

-  const fetchData = async () => {
-    // ...
-  };

75-80: as any 타입 캐스팅으로 인해 타입 안전성이 손실됩니다.

서버 응답 필드명이 다양한 형태로 올 수 있다면, 명확한 인터페이스를 정의하거나 유틸리티 함수를 사용하여 정규화하는 것이 좋습니다.

♻️ 서버 응답 타입 정의
// 서버 응답 타입 정의
interface ArticleApiResponse {
  articleId?: string;
  id?: string;
  _id?: string;
  title?: string;
  summaryBullets?: string[];
  summary_bullets?: string[];
  servingDate?: string;
  serving_date?: string;
}

// 매핑 함수
const normalizeArticle = (item: ArticleApiResponse): ArticleSummary => ({
  articleId: item.articleId || item.id || item._id || "",
  title: item.title || "제목 없음",
  summaryBullets: item.summaryBullets || item.summary_bullets || [],
  servingDate: item.servingDate || item.serving_date || "",
});

155-170: weeklyLog 배열이 정확히 7개 요소임을 가정하고 있습니다.

서버에서 반환하는 weeklyLog 배열 길이가 7이 아니거나 undefined인 경우 UI가 예상과 다르게 렌더링될 수 있습니다. 방어적인 코딩을 권장합니다.

♻️ 방어적 코딩 적용
  {user && (
    <View style={styles.weeklyLogContainer}>
-     {['월', '화', '수', '목', '금', '토', '일'].map((day, index) => {
-       const isActive = user.weeklyLog ? user.weeklyLog[index] : false;
+     {['월', '화', '수', '목', '금', '토', '일'].map((day, index) => {
+       const weeklyLog = user.weeklyLog ?? [];
+       const isActive = index < weeklyLog.length ? weeklyLog[index] : false;
        return (
          // ...
        );
      })}
    </View>
  )}

56-88: 중첩된 try-catch 구조가 복잡합니다.

사용자 정보 조회와 기사 목록 조회의 에러 처리가 섞여 있습니다. 각각 분리하면 디버깅과 유지보수가 쉬워집니다.

app/article/components/QuizTab.tsx (1)

5-5: Props에 any 타입 대신 명확한 인터페이스를 정의하세요.

모든 props가 any로 정의되어 타입 안전성이 없습니다. 컴포넌트 사용 시 잘못된 props 전달을 컴파일 타임에 잡을 수 없습니다.

♻️ Props 인터페이스 정의
interface Quiz {
  question: string;
  options: string[];
  answer?: string;
  answerIndex?: number;
  explanation: string;
}

interface QuizTabProps {
  quizList: Quiz[];
  selected: Record<number, number>;
  isGraded: boolean[];
  isOpen: boolean[];
  handleSelect: (qIndex: number, oIndex: number) => void;
  handleGrade: (qIndex: number) => void;
  toggleOpen: (qIndex: number) => void;
  myQuizResults?: boolean[];
}

export default function QuizTab({
  quizList,
  selected,
  isGraded,
  isOpen,
  handleSelect,
  handleGrade,
  toggleOpen,
  myQuizResults,
}: QuizTabProps) {

Comment on lines +15 to +16
const BACKEND_URL = "http://dev.onebitearticle.com:9000";
const REDIRECT_URI = "exp://192.168.219.101:8081/--/oauth/callback";
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major

하드코딩된 URL과 IP 주소는 환경 설정으로 분리해야 합니다.

BACKEND_URLREDIRECT_URI에 하드코딩된 IP 주소(192.168.219.101)는 다른 개발 환경이나 기기에서 작동하지 않습니다. 환경 변수 또는 설정 파일로 분리하는 것을 권장합니다.

🔧 환경 변수 사용 예시
-  const BACKEND_URL = "http://dev.onebitearticle.com:9000";
-  const REDIRECT_URI = "exp://192.168.219.101:8081/--/oauth/callback"; 
+  const BACKEND_URL = process.env.EXPO_PUBLIC_BACKEND_URL || "http://dev.onebitearticle.com:9000";
+  const REDIRECT_URI = Linking.createURL("oauth/callback");

Linking.createURL을 사용하면 현재 환경에 맞는 redirect URI를 자동으로 생성합니다.

📝 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 BACKEND_URL = "http://dev.onebitearticle.com:9000";
const REDIRECT_URI = "exp://192.168.219.101:8081/--/oauth/callback";
const BACKEND_URL = process.env.EXPO_PUBLIC_BACKEND_URL || "http://dev.onebitearticle.com:9000";
const REDIRECT_URI = Linking.createURL("oauth/callback");
🤖 Prompt for AI Agents
In `@app/`(auth)/login.tsx around lines 15 - 16, BACKEND_URL and REDIRECT_URI are
hardcoded; move them to environment/config and use a runtime-generated redirect
URI: replace the constant BACKEND_URL with a value read from env/config (e.g.,
process.env.BACKEND_URL or app config) and replace REDIRECT_URI with a call to
Linking.createURL(...) (or equivalent environment-aware helper) so the redirect
is generated per environment; update any usages of BACKEND_URL and REDIRECT_URI
in this module to read from the new env/config values and remove the hardcoded
IP.

Comment on lines +50 to +52
} else {
console.log("⚠️ 토큰이 없습니다. 파라미터:", queryParams);
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

토큰 획득 실패 시 사용자에게 피드백이 없습니다.

result.type === "success"이지만 토큰이 없는 경우, 콘솔 로그만 남기고 사용자에게 알림이 없어 로그인이 실패한 것처럼 보일 수 있습니다.

🐛 사용자 피드백 추가
        } else {
          console.log("⚠️ 토큰이 없습니다. 파라미터:", queryParams);
+         Alert.alert("로그인 실패", "인증 정보를 받아오지 못했습니다. 다시 시도해주세요.");
        }
🤖 Prompt for AI Agents
In `@app/`(auth)/login.tsx around lines 50 - 52, The code path in
app/(auth)/login.tsx that handles result.type === "success" falls back to only
console.log when no token is present (console.log("⚠️ 토큰이 없습니다. 파라미터:",
queryParams)), leaving users without feedback; update the success-but-no-token
branch to show a clear user-facing notification (e.g., via existing toast/error
UI or an error banner) and optionally log the diagnostic info for debugging,
referencing the same check that inspects result.type and queryParams in the
login handler so users see a message like "로그인에 실패했습니다: 토큰을 받지 못했습니다" and
developers still get the console/error details.

Comment on lines +85 to +89
const getCorrectIndex = (quiz: any) => {
return quiz.answerIndex !== undefined
? Number(quiz.answerIndex)
: (parseInt(quiz.answer?.replace(/[^0-9]/g, "") || "0") - 1);
};
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

getCorrectIndex에서 NaN 가능성이 있습니다.

quiz.answer가 숫자를 포함하지 않는 문자열인 경우, parseIntNaN을 반환하고 -1이 됩니다. 이 경우 채점 로직이 예상대로 동작하지 않을 수 있습니다.

🛠️ 방어적 처리 추가
   const getCorrectIndex = (quiz: any) => {
-    return quiz.answerIndex !== undefined 
-      ? Number(quiz.answerIndex) 
-      : (parseInt(quiz.answer?.replace(/[^0-9]/g, "") || "0") - 1);
+    if (quiz.answerIndex !== undefined) {
+      return Number(quiz.answerIndex);
+    }
+    const parsed = parseInt(quiz.answer?.replace(/[^0-9]/g, "") || "0");
+    return isNaN(parsed) ? 0 : parsed - 1;
   };
🤖 Prompt for AI Agents
In `@app/article/`[id].tsx around lines 85 - 89, getCorrectIndex currently parses
quiz.answer and can produce NaN (and then -1) when the string contains no
digits; update getCorrectIndex to defensively handle that by checking
quiz.answerIndex first, then extracting digits from quiz.answer, using parseInt
result only if it's a valid finite number, otherwise fall back to a safe default
(e.g., 0) and ensure you subtract 1 only when parseInt succeeded; reference the
function getCorrectIndex and properties quiz.answerIndex and quiz.answer and
ensure the returned index is a non-negative integer (clamped/validated) so
grading logic won't receive -1 or NaN.

</TouchableOpacity>
{/* 2. 제목 & 날짜 */}
<Text style={styles.title}>{article.title}</Text>
<Text style={styles.dateInfo}>한입경제 · {article.servingDate || "2026.01.14"}</Text>
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

하드코딩된 날짜 제거 필요

"2026.01.14" 하드코딩된 날짜는 부정확한 정보를 표시할 수 있습니다. 날짜가 없을 경우 표시하지 않거나, 현재 날짜를 사용하세요.

수정 제안
-      <Text style={styles.dateInfo}>한입경제 · {article.servingDate || "2026.01.14"}</Text>
+      <Text style={styles.dateInfo}>한입경제{article.servingDate ? ` · ${article.servingDate}` : ""}</Text>
📝 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
<Text style={styles.dateInfo}>한입경제 · {article.servingDate || "2026.01.14"}</Text>
<Text style={styles.dateInfo}>한입경제{article.servingDate ? ` · ${article.servingDate}` : ""}</Text>
🤖 Prompt for AI Agents
In `@app/article/components/ArticleTab.tsx` at line 45, The Text element in
ArticleTab.tsx currently falls back to a hardcoded string "2026.01.14" when
article.servingDate is missing; replace that fallback by either omitting the
date portion when article.servingDate is falsy or computing and formatting the
current date dynamically; update the render logic around the Text using
styles.dateInfo and article.servingDate so it conditionally shows "한입경제 ·
{formattedDate}" only when a real date exists or uses a formatted new Date()
(e.g., YYYY.MM.DD) as the fallback instead of the hardcoded literal.

Comment on lines +112 to 114
<Text style={[styles.resultText, { color: isCorrect || isPreviouslySolved ? "#2E7D32" : "#C62828" }]}>
{isCorrect || isPreviouslySolved ? "🎉 정답입니다!" : "❌ 오답입니다!"}
</Text>
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

rg -n "myQuizResults" --type ts --type tsx -C 3

Repository: OneBiteArticle/oba_frontend

Length of output: 98


🏁 Script executed:

rg -n "myQuizResults" -C 3

Repository: OneBiteArticle/oba_frontend

Length of output: 2377


🏁 Script executed:

cat -n app/article/components/QuizTab.tsx | sed -n '100,125p'

Repository: OneBiteArticle/oba_frontend

Length of output: 1399


🏁 Script executed:

rg -n "isPreviouslySolved" app/article/components/QuizTab.tsx -B 5

Repository: OneBiteArticle/oba_frontend

Length of output: 2252


변수명 isPreviouslySolvedwasPreviouslyCorrect로 변경하여 의미를 명확히 하세요.

isPreviouslySolved는 이전에 푼 여부가 아닌 이전에 정답을 맞혔는지를 나타냅니다. 코드에서 이 변수가 true일 때 "🎉 정답입니다!"(정답)를 표시하고 초록색을 적용하므로(라인 112-113), 변수명이 실제 의미(이전 정답 여부)를 명확하게 전달하지 못합니다. wasPreviouslyCorrect로 변경하면 의도가 더 명확해집니다.

🤖 Prompt for AI Agents
In `@app/article/components/QuizTab.tsx` around lines 112 - 114, Rename the
boolean identifier isPreviouslySolved to wasPreviouslyCorrect to reflect that it
denotes whether the user was previously correct; update the JSX usage in QuizTab
(the Text element using styles.resultText and the color/label logic), rename the
prop/variable declaration and all references inside QuizTab (and any parent
components or prop interfaces that pass it) so behavior remains identical but
the name now clearly indicates "previously correct".

Comment on lines +1 to +2
// app/components/PizzaMenu/UsePizzaAnimation.ts

Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

파일명 대소문자 불일치를 수정하세요.

주석에 표시된 파일명 UsePizzaAnimation.ts가 실제 파일명 usePizzaAnimation.ts와 대소문자가 일치하지 않습니다. 이는 코드 검색 시 혼란을 야기하거나 케이스 센서티브 파일 시스템에서 문제가 될 수 있습니다.

또한, 이 변경사항은 백엔드 연동이라는 PR 목표와 관련이 없어 보입니다.

📝 파일명 케이스를 수정하는 제안
-// app/components/PizzaMenu/UsePizzaAnimation.ts
+// app/components/PizzaMenu/usePizzaAnimation.ts
🤖 Prompt for AI Agents
In `@app/components/PizzaMenu/usePizzaAnimation.ts` around lines 1 - 2, Update the
filename casing in the header/comment and any imports so the file name matches
the actual file `usePizzaAnimation.ts`; change occurrences of
`UsePizzaAnimation.ts` to `usePizzaAnimation.ts` (and verify the exported hook
name `usePizzaAnimation` and any import sites reference the exact lowercase
filename) and remove this unrelated filename-case change from the PR if it isn't
part of the backend work.

Comment on lines +6 to 7
// 본인 PC의 IP 주소로 수정 필수
const BASE_URL = "http://192.168.219.101:9000";
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major

하드코딩된 IP 주소를 환경 변수로 분리 필요

개발 환경별로 다른 IP 주소가 필요하므로, 환경 변수를 사용하거나 .env 파일로 분리하는 것이 좋습니다. 현재 방식은 다른 개발자가 사용할 때마다 코드를 수정해야 합니다.

환경 변수 사용 예시
+import Constants from 'expo-constants';
+
-// 본인 PC의 IP 주소로 수정 필수
-const BASE_URL = "http://192.168.219.101:9000"; 
+const BASE_URL = Constants.expoConfig?.extra?.apiUrl || "http://localhost:9000";

app.config.js 또는 .env 파일과 함께 사용하세요.

🤖 Prompt for AI Agents
In `@src/api/apiClient.ts` around lines 6 - 7, Replace the hardcoded BASE_URL
constant with a value read from an environment/config variable so different devs
and environments can override it; update the BASE_URL declaration in
apiClient.ts to read from process.env (or your config loader) like
REACT_APP_API_BASE_URL or API_BASE_URL and provide a sensible default or
fail-fast error if missing, and ensure any tests or usage of BASE_URL (the
constant) still reference the new env-backed value.

Comment on lines +14 to +21
// 요청 인터셉터: 토큰 자동 첨부
apiClient.interceptors.request.use(async (config) => {
const token = await SecureStore.getItemAsync("accessToken");
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

SecureStore 접근 실패 시 에러 처리 누락

SecureStore.getItemAsync가 실패할 경우(예: 기기에서 지원하지 않는 경우) 요청이 실패할 수 있습니다.

에러 처리 추가
 apiClient.interceptors.request.use(async (config) => {
-  const token = await SecureStore.getItemAsync("accessToken");
-  if (token) {
-    config.headers.Authorization = `Bearer ${token}`;
+  try {
+    const token = await SecureStore.getItemAsync("accessToken");
+    if (token) {
+      config.headers.Authorization = `Bearer ${token}`;
+    }
+  } catch (e) {
+    console.warn("Failed to retrieve access token:", e);
   }
   return config;
 });
🤖 Prompt for AI Agents
In `@src/api/apiClient.ts` around lines 14 - 21, 요청
인터셉터(apiClient.interceptors.request.use)에서 SecureStore.getItemAsync 호출 실패 시 처리가
빠져 있으니 getItemAsync를 try/catch로 감싸고 실패하면 에러를 로깅하거나 안전한 기본 동작(토큰 없이 요청 진행 또는 인증
흐름 트리거)을 하도록 처리하세요; catch 블록에서는 config.headers.Authorization을 설정하지 않도록 하고 필요시
user-facing 재시도/로그 로직을 호출해 문제를 노출시키되 요청을 차단하지 않도록 구현하세요.

Comment on lines +23 to +33
// 응답 인터셉터: 401 에러 시 로그아웃 처리
apiClient.interceptors.response.use(
(response) => response,
async (error) => {
if (error.response?.status === 401) {
await SecureStore.deleteItemAsync("accessToken");
router.replace("/(auth)/login");
}
return Promise.reject(error);
}
); No newline at end of file
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

401 처리 시 무한 리다이렉트 방지 필요

로그인 페이지에서 인증이 필요한 API를 호출하면 401 → 로그인 → 401의 무한 루프가 발생할 수 있습니다. 특정 요청을 제외하거나 플래그를 사용하여 방지하세요.

무한 루프 방지 예시
 apiClient.interceptors.response.use(
   (response) => response,
   async (error) => {
-    if (error.response?.status === 401) {
+    const originalRequest = error.config;
+    // 로그인 관련 요청은 리다이렉트하지 않음
+    if (error.response?.status === 401 && !originalRequest.url?.includes('/auth/')) {
       await SecureStore.deleteItemAsync("accessToken");
       router.replace("/(auth)/login");
     }
     return Promise.reject(error);
   }
 );
📝 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
// 응답 인터셉터: 401 에러 시 로그아웃 처리
apiClient.interceptors.response.use(
(response) => response,
async (error) => {
if (error.response?.status === 401) {
await SecureStore.deleteItemAsync("accessToken");
router.replace("/(auth)/login");
}
return Promise.reject(error);
}
);
// 응답 인터셉터: 401 에러 시 로그아웃 처리
apiClient.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
// 로그인 관련 요청은 리다이렉트하지 않음
if (error.response?.status === 401 && !originalRequest.url?.includes('/auth/')) {
await SecureStore.deleteItemAsync("accessToken");
router.replace("/(auth)/login");
}
return Promise.reject(error);
}
);
🤖 Prompt for AI Agents
In `@src/api/apiClient.ts` around lines 23 - 33, Intercepted 401 handling can
cause a redirect loop when a request from the login page triggers another 401;
update the apiClient.interceptors.response.use handler to skip logout/redirect
for requests marked to avoid auth-redirects (e.g., check
error.config?.skipAuthRedirect or an _retry flag) and also check the current
route before calling router.replace("/(auth)/login"); specifically, modify the
response interceptor (apiClient.interceptors.response.use) to: 1) detect and
ignore requests with a skipAuthRedirect flag on error.config, 2) set/inspect an
_retry boolean on error.config to avoid repeated handling of the same request,
and 3) ensure you only call SecureStore.deleteItemAsync("accessToken") and
router.replace(...) when not already on the login route to prevent the 401 →
login → 401 loop.

@Yuwolx
Copy link
Contributor

Yuwolx commented Jan 20, 2026

고생하셨습니다

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants