v1 (Phase 0~5) 기록 아카이브: PROGRESS_V1.md
bot/app/config.py: Issue 리포트 제어용 설정 추가issue_enabledissue_labelsissue_dedup_window_hours
bot/app/services/sanitizer.py신규: Bearer/Authorization/Cookie/email/Google OAuth 토큰 패턴 마스킹 + 길이 제한용sanitize_excerptbot/app/services/issue_builder.py신규:FailureStageenum, dedup key 생성, GitHub Issue title/body/comment payload 조립bot/app/services/github_service.py: GitHub Issue 조회/생성/comment helper 추가find_open_issue_by_keycreate_issueadd_issue_comment
bot/app/pipeline.py:report_failure()도입- Stage 3~9 실패 경로를 공통 함수로 통합
- open issue dedup hit 시 새 Issue 대신 comment 추가
- stage 1~2는 Discord only 유지
- 최상위
except는pipeline_internal_error+ sanitize fallback 사용
bot/app/services/discord_service.py: Discord 전송 경로 sanitize 보강send_error_alert()와send_failure_alert()모두errorType,requestUrl,errorMessage,reason,issue_url,timestamp를 sanitize 후 embed 생성- 리뷰 지적이었던 “Issue는 sanitize되지만 Discord는 원문 노출 가능” 경로 제거
- 테스트 추가/확장
bot/tests/test_sanitizer.py신규bot/tests/test_issue_builder.py신규bot/tests/test_github_service.py확장bot/tests/test_pipeline.py확장bot/tests/test_discord_service.py확장: Discord payload에 민감정보가 남지 않는지 회귀 테스트 추가
cd bot && uv run pytest tests/test_sanitizer.py tests/test_issue_builder.py tests/test_discord_service.py tests/test_github_service.py tests/test_pipeline.py— 43 passedcd bot && uv run pytest tests— 67 passed
a2fd78cdocs: expand phase 26 implementation plancab6f81feat: add issue reporting for bot failures
/api/test-webhook수동 시나리오로 Discord/Issue 동작 확인deploy.yml에ISSUE_ENABLED,ISSUE_LABELS,ISSUE_DEDUP_WINDOW_HOURS를 실제로 노출할지 판단- 필요 시
bot/uv.lock의requires-python >=3.13변경을 별도 커밋 또는 제외 처리
- 리뷰로 확인된 보안 이슈: 초기 구현에서는 Discord embed가
report원문 필드를 사용하고 있어 sanitize 요구사항과 불일치했음. 현재는 Discord/GitHub 모두 sanitize 후 진입하도록 수정 완료 - dedup 범위는
sha256(errorType + errorMessage + stage)[:10]+ open issue 최근 50개 + 24시간 윈도우 - 아직 로컬 수동 검증과 deploy env 반영 여부는 미완료이므로 Phase 26은 BOT-9만 pending 상태
bot/uv.lock은 테스트 실행 중requires-python이>=3.13으로 갱신된 부수 변경이며, 기능 구현과는 별개로 판단 필요
backend/app/auth/google_errors.py신규:GoogleRefreshOutcomeenum + 책임 3분할 헬퍼 (classify_google_refresh_error/disconnect_google_account/build_google_refresh_http_exception).is_google_scope_mismatch_error는auth/service.py에 유지하고 내부적으로 재사용backend/app/auth/dependencies.py: 로컬_disconnect_google_account제거,except RefreshError블록을 공통 헬퍼 호출로 교체. 선제 refresh 조건(credentials.expired and credentials.refresh_token)은 변경 없음backend/app/core/background_sync.py: 로컬_disconnect_google_account제거,is_google_scope_mismatch_errorimport를 공통 헬퍼 import로 교체. worker 종료 정책(return 0)은 호출부에서 유지backend/app/calendar/router.py: 5개 엔드포인트(get_calendars,get_events,get_event_detail,create_new_event,delete_existing_event)에except RefreshError방어선 추가. 중복 최소화를 위해 라우터 내부_raise_refresh_error헬퍼 도입backend/app/mail/routers/gmail.py: 3개 엔드포인트(sync_messages,sync_all_messages,apply_classification_labels)에 동일 패턴 방어선 추가.list_messages,get_message는 DB만 사용하므로 제외backend/tests/routers/test_calendar.py신규:/calendarsRefreshError → 401token_expired, invalid_scope → 401google_reconnect_required+ 계정 disconnect 검증,/events방어선 검증backend/tests/routers/test_gmail.py확장:/syncRefreshError 2가지 시나리오 검증
cd backend && uv run ruff check .— All checks passedcd backend && uv run pytest— 37 passed, 2 failed (Phase 25 이전부터 존재하는test_auth.py의 sliding session 테스트 2개 — 무관)- 기존
tests/services/test_background_sync.py::test_sync_user_gmail_disconnects_on_invalid_scope통과 (동작 동일성 유지) - 기존
tests/test_auth_dependencies.py2개 테스트 통과 (monkeypatch 경로 변경 불필요 — import 심볼이 여전히 dependencies 모듈에 존재)
- 커밋 + PR 생성 (
fix: Google OAuth refresh 에러를 401로 변환하는 방어선 추가) - 머지 후 Phase 26(Bot 실패 리포트 이슈화) 별도 브랜치에서 착수
- 머지 후 후속 이슈 등록: "User.google_token_expiry 컬럼 + 선제 refresh 활성화"
- scope_mismatch 라우터 테스트에서
db_sessionfixture는 identity map 캐시로 commit된 변경을 못 보기 때문에 검증용으로 새TestingSessionLocal세션을 열어서 User 재조회 tests/routers/test_auth.py::test_me_renews_cookie_*2개 실패는git stash로 확인한 결과 Phase 25 이전부터 존재 — 별도 이슈로 처리 대상 아님- 라우터의
_raise_refresh_error헬퍼는 404/401 구분을 위해HttpErrorcatch 앞에 위치.disconnect_google_account는AsyncSession을 받으므로 각 엔드포인트에Depends(get_db)추가
- Google Calendar
/api/calendar/calendars500 에러(RefreshError: Token has been expired or revoked) 근본 원인 조사- 원인:
backend/app/auth/service.py:58build_credentials()가expiry를 설정하지 않아credentials.expired==False로 판정됨.backend/app/auth/dependencies.py:47선제 refresh 분기를 건너뛰고, 실제 API 호출 중 googleapiclient 내부 refresh가RefreshError를 raise. Calendar/Gmail 라우터는HttpError만 catch하므로 500으로 전파.
- 원인:
- 두 차례 외부 리뷰 반영하여 계획서 v3까지 확정
- Task 1: 라우터 방어선 + 공통 모듈(
auth/google_errors.py) 추출. 선제 refresh 강화는 expiry 저장 컬럼 부재로 제외 → 별도 후속 이슈로 분리 - Task 2: PR 생성 실패 시 GitHub Issue 업로드. sanitize 선행 + dedup(24h, label + 최근 50개) + sanitize 실패 시 제목 최소화
- Task 1: 라우터 방어선 + 공통 모듈(
- PLAN.md에 Phase 25, 26 추가
- 브랜치 생성:
fix/google-refresh-error-handling
- BE-1부터 순차 진행 — Phase 25 태스크 8개 (BE-1 → BE-8)
- Phase 25 머지 후 Phase 26 별도 브랜치에서 착수
- Phase 25 머지 후 후속 이슈 등록: "expiry persistence + 선제 refresh 활성화"
- 핵심 결정 사항 재확인
auth/google_errors.py는 책임 3분할:classify_google_refresh_error(분류만, 부수효과 없음) /disconnect_google_account(부수효과) /build_google_refresh_http_exception(HTTP 변환)- Enum
GoogleRefreshOutcome:TOKEN_INVALID_OR_EXPIRED|SCOPE_MISMATCH dependencies.py:47선제 refresh 조건은 변경하지 않음 (expiry 저장소 없어서 매 요청 refresh 유발)- Gmail 라우터 방어선 대상:
sync,sync/full,apply-labels3개 (messages,messages/{mail_id}는 DB만 사용이므로 제외) - 테스트 파일 경로:
backend/tests/routers/test_calendar.py(신규),backend/tests/routers/test_gmail.py(확장)
- worker(
background_sync.py) 동작 동일성: 공통화 후에도 router→401, worker→return 0정책은 호출부에서 유지. 공통 함수는 "분류"와 "disconnect 실행"만 담당 - 기존 테스트(
tests/services/test_background_sync.py,tests/test_auth_dependencies.py)는 동작 유지되어야 함
backend/app/config.py: JWT 만료 시간 24시간(1440분) → 7일(10080분)frontend/src/lib/api.ts: 401 응답 시/로 리다이렉트 (/auth/me요청은 제외하여 무한 리다이렉트 방지)backend/app/auth/router.py:/auth/me엔드포인트에 sliding session 구현 — 토큰 남은 시간이 절반(3.5일) 미만이면 새 토큰으로 쿠키 갱신
uv run ruff check .— All checks passeduv run pytest tests/ -v— 24 passed
- 커밋 + PR 생성
- 쿠키 max_age는 이미
jwt_expire_minutes * 60을 사용하므로 config 변경만으로 자동 반영 - sliding session 덕분에 활발히 사용 중이면 사실상 세션 만료 없음
- 루트
README.md신규 작성: 프로젝트 소개, 주요 기능, 기술 스택 배지, 시스템 아키텍처 다이어그램, 프로젝트 구조, 로컬 실행 방법, 배포 안내, 프로젝트 규모 frontend/README.md삭제 (Next.js 기본 템플릿 → 루트 README로 대체)- PLAN.md에 Phase 23 추가
- 없음 (프로젝트 마무리 완료)
- PLAN.md, PROGRESS.md, PLAN_V1.md, PROGRESS_V1.md, CLAUDE.md는 개발 과정 증빙으로 그대로 유지
22-1. 인프라 보안
docker-compose.yml: error-bot 볼륨 마운트.:/workspace/source:ro→./backend/app,./frontend/src만 마운트Caddyfile: 보안 헤더 추가 (HSTS, X-Content-Type-Options, X-Frame-Options, Referrer-Policy, Permissions-Policy)
22-2. JWT 세션 인증
backend/pyproject.toml:PyJWT>=2.8,cryptography>=43.0의존성 추가backend/app/config.py:secret_key,jwt_expire_minutes설정 추가backend/app/core/security.py: 신규 — JWT create/verify + Fernet encrypt/decrypt 유틸리티backend/app/core/dependencies.py:get_current_userCookie JWT 인증으로 전면 변경 (Query user_id → Cookie session_token)backend/app/auth/router.py: OAuth callback에서 JWT 쿠키 설정 +POST /auth/logout엔드포인트 추가 +/auth/me에서 user_id Query 제거frontend/src/lib/api.ts:credentials: "include"추가 (쿠키 자동 전송)frontend/src/features/auth/hooks/useAuth.ts: 전면 재작성 — localStorage/URL params 제거, useSyncExternalStore 기반 쿠키 인증,/auth/me성공 여부로 로그인 판단frontend/src/features/*/hooks/*.ts: 10개 파일에서?user_id=${userId}제거 (~35곳)- useNaverConnect, useTodo, useSubtasks, useCalendar, useBookmarks, useMessages, useCategoryCounts, useFeedbackStats, useDragAndDrop, useMailActions
frontend/src/app/page.tsx: userId prop 전달 전면 제거, 인증 상태를 isLoggedIn/loading으로 변경- CalendarPage, TodoPage, BookmarkPage, TodoContext, BookmarkContext: userId prop 제거
.github/workflows/deploy.yml: SECRET_KEY 시크릿 추가backend/.env.example: SECRET_KEY 추가
22-3. DB 토큰 암호화
backend/app/auth/router.py: OAuth callback에서 토큰 암호화 저장backend/app/auth/dependencies.py: get_google_user에서 토큰 복호화 + 갱신 시 재암호화backend/app/mail/routers/naver.py: naver_app_password 암호화 저장 + 복호화 사용backend/app/core/background_sync.py: Gmail/Naver 동기화 시 토큰 복호화 + 갱신 시 재암호화backend/app/core/migrate_encrypt.py: 신규 — 기존 평문 토큰 일괄 암호화 마이그레이션 스크립트
테스트 업데이트
backend/tests/conftest.py:SECRET_KEY환경변수 설정 +auth_cookie()헬퍼 함수 추가backend/tests/routers/test_*.py: 5개 파일 —?user_id=→headers=auth_cookie()방식으로 전면 변경
uv run ruff check .— All checks passeduv run pytest tests/ -v— 24 passedpnpm lint— 통과pnpm build— 빌드 성공
- GitHub Secrets에
SECRET_KEY등록 (32자 이상 랜덤 문자열) - 배포 서버에서
python -m app.core.migrate_encrypt실행 (기존 평문 토큰 암호화) - 수동 테스트: Google 로그인 → 쿠키 설정 → API 호출 → 로그아웃
- JWT InsecureKeyLengthWarning은 테스트 환경의 짧은 키 때문 (프로덕션에서는 32자 이상 SECRET_KEY 사용)
- 기존 사용자는 마이그레이션 스크립트 실행 전까지 API 호출 시 토큰 복호화 실패 가능 → 배포 직후 마이그레이션 필수
- background_sync는 내부 DB 직접 조회이므로 JWT 불필요 (변경 없음)
- CORS
allow_credentials=True는 이미 설정되어 있어 추가 변경 불필요
bot/app/services/ai_provider.py: 모델gpt-4o-mini→gpt-4o,max_tokens4096 → 16384bot/app/services/ai_service.py: 스키마/프롬프트 전면 개편- RESPONSE_SCHEMA:
files→changes(original/modified 쌍),should_fix/skip_reason추가 - SYSTEM_PROMPT: diff 기반 규칙 추가 (파일 전체 재작성 금지, 불필요한 수정 방지)
- USER_PROMPT_TEMPLATE: should_fix 지시 + 최소 범위 변경 지시
validate_ai_result: files → changes 필드 검증으로 변경
- RESPONSE_SCHEMA:
bot/app/pipeline.py: 검증 강화apply_changes(): original→modified 치환 함수 추가 (원본에서 매칭 후 교체)validate_changes(): 과도한 삭제 차단 (삭제 > 추가×3, 원본 50% 이상 삭제)should_fix=false시 PR 생성 건너뛰기 (Discord에 분석 결과만 알림)- 적용된 파일(applied)을 GitHub에 전달하도록 변경
bot/app/services/pr_builder.py: changes 형식 지원 (build_diff에서 original→modified 적용 후 diff 계산)bot/tests/test_ai_service.py: 새 스키마 반영 + should_fix=false 테스트 추가bot/tests/test_pipeline.py: 전면 재작성 — apply_changes, validate_changes, should_fix 테스트 추가bot/tests/conftest.py: 새 테스트 display names 추가
uv run ruff check .— All checks passeduv run pytest tests/ -v— 52 passed
- 커밋 + PR 생성
- 배포 후 통합 테스트 (test-webhook → PR 생성 확인)
- gpt-4o는 gpt-4o-mini 대비 비용 증가하지만 코드 추론 품질 대폭 개선
- diff 기반 적용으로 AI가 파일 전체를 재작성하는 문제 해결
- validate_changes로 대규모 코드 삭제 차단 (PR #14 재발 방지)
deploy.yml: GitHub Secrets → .env 파일 자동 생성 (appleboy/ssh-actionenvs파라미터 활용).env.production(DOMAIN)backend/.env(OPENAI_API_KEY, GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET)bot/.env(OPENAI_API_KEY, GITHUB_TOKEN, GITHUB_REPO 등)
.env.production.example: 필요한 GitHub Secrets 목록 문서화PLAN.md: Phase 20 추가
- GitHub Settings > Secrets에 7개 시크릿 등록 (DOMAIN, OPENAI_API_KEY, GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, BOT_GITHUB_TOKEN, BOT_GITHUB_REPO, DISCORD_WEBHOOK_URL)
- 서버의 기존 수동 .env 파일은 첫 배포 성공 후 삭제 가능
- bot/.env의 GITHUB_BASE_BRANCH, PROJECT_ROOT, IMPORT_DEPTH, LOCAL_SOURCE_PATH는 변경 빈도가 낮아 deploy.yml에 하드코딩
- 기존 서버 .env 파일이 있어도 deploy 시 덮어쓰므로 충돌 없음
19-1. error-bot 코드 이식 (bot/ 디렉토리)
bot/전체 디렉토리: 500-pr-bot에서 복사 후 불필요 파일 제거 (static/, admin.py, errors.py, test_runner.py)bot/app/main.py: 대시보드, static mount, admin/errors 라우터 제거 → health + webhook만 유지bot/app/utils/stack_trace_parser.py: Java traceback → Python traceback 파서로 전면 재작성bot/app/services/ai_service.py: SYSTEM_PROMPT "Spring Boot" → "FastAPI + Next.js", 코드 블록 언어 태그 동적 결정bot/app/config.py:base_package→project_root, 기본 포트 8001bot/app/pipeline.py: 파서 호출부settings.project_root사용bot/app/routers/webhook.py: test-webhook 샘플을 Python traceback으로 변경bot/.env.example: G-Tool 환경변수로 업데이트
19-2. G-Tool 백엔드 — 500 에러 리포터 미들웨어
backend/app/core/error_reporter.py: 신규 — ErrorReporterMiddleware (500 에러 시 error-bot POST)backend/app/config.py:error_bot_url: str = ""추가backend/app/main.py: error_bot_url 설정 시 ErrorReporterMiddleware 등록
19-3. Docker Compose
docker-compose.yml: error-bot 서비스 추가 (포트 8001, .:/workspace/source:ro 볼륨)- backend 서비스에
ERROR_BOT_URL=http://error-bot:8001+depends_on: error-bot
19-4. 테스트 + 코드 정리
- 테스트 전면 교체: Python traceback 기반 46개 통과
event_store.py+ pipelineemit()호출 제거 (대시보드 없으므로 불필요)- GitHub API 코드 조회 로직 제거 → 로컬 전용으로 단순화
import_depth기본값 1 → 2 (AI 분석 컨텍스트 확장)
cd bot && python -m pytest tests/ -v— 46 passedcd backend && uv run ruff check .— All checks passed
- 배포 서버에서
bot/.env설정 (OPENAI_API_KEY, GITHUB_TOKEN, DISCORD_WEBHOOK_URL 등) docker compose up --build통합 테스트curl http://localhost:8001/health헬스체크curl -X POST http://localhost:8001/api/test-webhook테스트 에러 전송 검증
- error-bot은 Docker 내부 전용 (포트 8001), 외부 노출 없음
ERROR_BOT_URL빈 문자열이면 미들웨어 비활성 (로컬 개발 시 영향 없음)- 로컬 볼륨 마운트로 소스코드 접근 (GitHub API 호출 불필요)
1단계: 토큰 최적화 + JSON 안정성
backend/app/mail/services/classifier.py:- OpenAI Structured Outputs 적용 (
response_formatjson_schema) — JSON 파싱 실패율 0% _extract_json()함수 제거 (불필요)SYSTEM_PROMPT마지막 줄 JSON 지시 제거 (~20토큰 절감)SINGLE_TEMPLATE,BATCH_TEMPLATE에서 JSON 형식 지시 제거_truncate_bodydefault 500→300자 (~50토큰/메일 절감)chunk_size10→15 (API 호출 33% 감소)
- OpenAI Structured Outputs 적용 (
2단계: 병렬 처리
backend/app/mail/services/classifier.py:_process_chunk()함수 분리 (청크별 독립 처리)asyncio.as_completed()+asyncio.Semaphore(3)로 병렬 처리on_progress콜백 파라미터 추가
3단계: SSE 실시간 진행 피드백
backend/app/mail/routers/classify.py:POST /api/classify/mails→ SSEStreamingResponse변경- progress/done/error 이벤트 형식
frontend/src/features/mail/hooks/useMailActions.ts:handleClassifyfetch streaming으로 변경 (native ReadableStream)classifyProgress상태 추가 ({processed, total} | null)
frontend/src/components/AppHeader.tsx:classifyProgressprop 추가- 분류 버튼에 "분류 중 15/45" 텍스트 + 하단 프로그레스 바 표시
frontend/src/app/page.tsx:classifyProgressprop 전달
uv run ruff check .— All checks passedpnpm lint— 통과pnpm build— 빌드 성공
커밋 + PR 생성→ PR #10 완료- 수동 테스트: 분류 버튼 클릭 → 진행률 표시 → 분류 완료 확인
- PR 리뷰 후 main 병합
- Structured Outputs의 json_schema는 최상위가 object여야 하므로 batch 응답을
{"results": [...]}형태로 감쌈 - SSE 이벤트는 classify_batch 완료 후 일괄 전송 (on_progress 콜백이 동기이므로 큐에 모아둠)
- 외부 라이브러리 추가 없음 (asyncio.Semaphore, native fetch ReadableStream)
- 코드:
backend/pyproject.toml,backend/app/main.py,backend/app/core/exceptions.py,backend/app/config.py(DB명gtool.db),docker-compose.yml - 프론트엔드:
layout.tsx,AppHeader.tsx,LoginScreen.tsx— 타이틀/설명 변경 - 테스트:
smoke.test.tsx,LoginScreen.test.tsx— "G-Tool"로 변경 - 에이전트:
.claude/agents/5개 파일 — "G-Tool 프로젝트"로 변경 - 문서:
CLAUDE.md,DEPLOY.md,references/guide-google-oauth-setup.md,references/guide-oracle-cloud-free-deploy.md - CI/CD:
.github/workflows/deploy.yml—cd ~/g-tool - 메모리:
MEMORY.md— "G-Tool"로 변경
uv run ruff check .— All checks passedpnpm lint— 통과pnpm build— 빌드 성공pnpm test— 35/35 통과grep "Mail Organizer"잔여 — 없음 (PROGRESS.md 과거 기록 제외)
- 커밋 + PR → main 병합
- main 병합 후:
gh repo rename g-tool - 배포 서버:
mv ~/-mail-organizer ~/g-tool,mv mail_organizer.db gtool.db
- PROGRESS.md 과거 기록은 수정하지 않음 (히스토리 보존)
- GitHub repo rename 시 기존 URL은 자동 redirect됨
backend/app/calendar/service.py:delete_event함수 추가 (기존_build_calendar+asyncio.to_thread패턴)backend/app/calendar/router.py:DELETE /api/calendar/events/{event_id}?calendar_id=...엔드포인트 추가,delete_eventimportfrontend/src/features/calendar/hooks/useCalendar.ts:deleteEvent함수 추가 (DELETE 호출 + loadEvents 갱신), return에 포함frontend/src/features/calendar/components/CalendarEventDetail.tsx:onDeleteprop 추가, 헤더에 Trash2 삭제 버튼 배치 (deleting 시 Loader2 spinner + disabled)frontend/src/features/calendar/CalendarPage.tsx:deleteEventdestructure,handleDeleteEvent핸들러 (삭제 → toast → selectedEvent null),onDeleteprop 전달
uv run ruff check .— All checks passed- ESLint — 통과
next build— 빌드 성공
- 커밋 + PR 생성
- 없음
frontend/src/features/calendar/components/CalendarSidebar.tsx:onRefresh,refreshingprops 추가, "오늘" 버튼 옆에 RefreshCw 동기화 버튼 배치, refreshing 시 animate-spin + disabledfrontend/src/features/calendar/CalendarPage.tsx:refreshingstate +handleRefresh함수 추가 (loadCalendars → loadEvents → toast 알림), CalendarSidebar에 props 전달
- ESLint — 통과
next build— 빌드 성공
- 커밋 + PR 생성
.next/dev/types/routes.d.ts캐시 파일 빌드 에러 →.next삭제 후 클린 빌드로 해결
백엔드 (14-1)
backend/app/bookmark/__init__.py: 패키지 초기화backend/app/bookmark/models.py: BookmarkCategory, Bookmark SQLAlchemy 모델 (Mapped[] 스타일, Index, SET NULL on category delete)backend/app/bookmark/schemas.py: Pydantic 스키마 (CategoryCreate, CategoryResponse, BookmarkCreate, BookmarkUpdate, BookmarkResponse, URL 자동 https:// 보정)backend/app/bookmark/service.py: async CRUD (소유권 검증, favicon 자동 생성, position 자동 계산, bookmark_count 서브쿼리)backend/app/bookmark/router.py:/api/bookmark라우터 (7개 엔드포인트: 카테고리 3 + 북마크 4)backend/app/main.py: bookmark_router 등록
프론트엔드 (14-2)
frontend/src/features/bookmark/types.ts: Bookmark, BookmarkCategory 인터페이스 + 색상 상수frontend/src/features/bookmark/hooks/useBookmarks.ts: 카테고리+북마크 CRUD 훅 (낙관적 업데이트)frontend/src/features/bookmark/BookmarkContext.tsx: Context Provider (prop drilling 방지, 모달 상태 관리)frontend/src/features/bookmark/BookmarkPage.tsx: 사이드바 + 그리드 레이아웃frontend/src/features/bookmark/components/BookmarkCard.tsx: 카드 (favicon, 제목, URL, 카테고리 뱃지, 수정/삭제)frontend/src/features/bookmark/components/BookmarkGrid.tsx: 반응형 카드 그리드 (grid-cols-1 sm:2 lg:3 xl:4)frontend/src/features/bookmark/components/BookmarkCategorySidebar.tsx: 카테고리 사이드바 (전체/미분류 필터, 카테고리 목록)frontend/src/features/bookmark/components/AddBookmarkModal.tsx: 북마크 생성/수정 Dialog (key 패턴으로 setState-in-effect 해결)frontend/src/features/bookmark/components/AddCategoryModal.tsx: 카테고리 생성 Dialog (색상 선택)frontend/src/components/AppHeader.tsx: 북마크 탭 추가 (Bookmark 아이콘)frontend/src/app/page.tsx: activePage 타입에 "bookmark" 추가, BookmarkPage 렌더링
검증
uv run ruff check .— All checks passed- ESLint — 통과
next build— 빌드 성공
- 커밋 + PR 생성
- 수동 테스트: 카테고리 생성 → 북마크 등록 → 카테고리 필터 → 북마크 수정 → 삭제
- ESLint
react-hooks/set-state-in-effect룰 → AddBookmarkModal에서 FormFields 별도 컴포넌트 + key 패턴으로 해결 - 카테고리 삭제 시 북마크는 DB의
ondelete="SET NULL"로 미분류로 전환 - favicon은 Google Favicon API로 자동 생성 (
https://www.google.com/s2/favicons?domain={domain}&sz=32)
백엔드 (6가지 개선)
schemas.py: status/priority를Literal타입으로 제한 (잘못된 값 DB 저장 방지)schemas.py:ReorderRequest.items를list[dict]→list[ReorderItem]타입 안전화schemas.py:due_date파싱을field_validator로 schema 레벨로 이동schemas.py:TaskResponse,SubtaskResponsePydantic response model 추가service.py:reorder_tasksN+1 쿼리 →Task.id.in_(ids)일괄 조회로 개선service.py:update_tasksubtask 이중 로드 제거 (_get_task_or_404에load_subtasks파라미터 추가)service.py:_task_to_dict/_subtask_to_dict→_task_to_response/_subtask_to_response(Pydantic 모델 반환)service.py:func.max()대신order_by().limit(1)패턴으로 position 조회
프론트엔드 (6가지 개선)
useTodo.ts: CUD 낙관적 업데이트 (createTask → 로컬 추가, updateTask → 로컬 교체, deleteTask → 로컬 제거 후 서버 호출)useTodo.ts/useSubtasks.ts: 에러 시setTasks([])/setSubtasks([])제거 → 기존 데이터 보존useSubtasks.ts: toggleSubtask 낙관적 업데이트 (즉시 UI 반영 → 서버 응답으로 확정)KanbanBoard.tsx: handleDragEnd 로직 단순화 (이중 필터링/중복 제거 제거)KanbanCard.tsx: descDraft 동기화 →DescriptionEditor별도 컴포넌트 분리 + key 패턴KanbanCard.tsx:dispatchEvent(contextmenu)→DropdownMenu컴포넌트로 안정적 교체 (⋯ 클릭 → DropdownMenu, 우클릭 → ContextMenu)TodoContext.tsx신규: prop drilling 해소 (TodoPage → KanbanBoard → KanbanCard 간 11개 prop → Context)TodoPage.tsx: 핸들러 함수들 Context로 이동, 코드 95줄 → 15줄로 축소dropdown-menu.tsx:DropdownMenuSub,DropdownMenuSubTrigger,DropdownMenuSubContent추가
검증
uv run ruff check .— All checks passedpnpm lint— 통과pnpm build— 빌드 성공
- 커밋 + PR 업데이트
- 수동 테스트: 낙관적 업데이트 동작, 드래그앤드롭, 컨텍스트 메뉴/드롭다운 메뉴
- ESLint
react-hooks/set-state-in-effect룰로 useEffect 내 setState 불가 → DescriptionEditor 분리 + key 패턴으로 해결 - ESLint
react-hooks/refs룰로 render 중 ref 업데이트 불가 → 동일 방법으로 우회
백엔드
backend/app/todo/models.py: Project 클래스 삭제, Task에 user_id(FK→users) 추가, status 값todo/in_progress/on_hold, 인덱스(user_id, status, position)backend/app/todo/schemas.py: ProjectCreate/ProjectUpdate 삭제, TaskCreate/TaskUpdate에서 project_id 제거backend/app/todo/service.py: Project 함수 5개 + _get_project_or_404 삭제, Task/Subtask에서 project join 제거 → user_id 직접 검증, reorder_tasks에 status 변경 지원backend/app/todo/router.py: Project 엔드포인트 5개 삭제,GET /projects/{id}/tasks→GET /tasks
프론트엔드
frontend/src/features/todo/types.ts: Project 타입 삭제, Task.status 타입 변경, STATUS_LABELS 추가frontend/src/features/todo/hooks/useTodo.ts: Project 관련 state/함수 제거, loadTasks → GET /tasks 플랫 조회, reorderTasks 함수 추가frontend/src/features/todo/components/KanbanBoard.tsx: @dnd-kit 기반 3열 칸반 보드 (todo/in_progress/on_hold), 드래그앤드롭 컬럼 이동, 퀵 추가 입력frontend/src/features/todo/components/KanbanCard.tsx: useSortable 드래그 가능 카드, 인라인 확장 (설명 편집 + 서브태스크 관리)frontend/src/features/todo/TodoPage.tsx: ProjectSidebar/TaskDetailView/ResizablePanelGroup 제거, KanbanBoard만 렌더링- 삭제: ProjectSidebar.tsx, TaskListView.tsx, TaskDetailView.tsx
- @dnd-kit/core, @dnd-kit/sortable, @dnd-kit/utilities 설치
검증
uv run ruff check .— All checks passedpnpm lint— 통과pnpm build— 빌드 성공
- DB 파일 삭제 후 재시작 (tasks 테이블 스키마 변경)
- 수동 테스트: 칸반 보드 드래그앤드롭, 태스크 생성/삭제, 서브태스크 관리
- SQLite 컬럼 변경 불가 → 개발 DB 파일 삭제 후 재생성 필요
- projects 테이블은 DB에 남아있어도 무해 (코드에서 참조하지 않음)
백엔드 (11-1)
backend/app/todo/__init__.py: 패키지 초기화backend/app/todo/models.py: Project, Task, Subtask SQLAlchemy 모델 (Mapped[] 스타일, 인덱스, CASCADE)backend/app/todo/schemas.py: Pydantic 요청 모델 (Create, Update, Reorder)backend/app/todo/service.py: async CRUD 비즈니스 로직 (소유권 검증, position 자동 계산)backend/app/todo/router.py:/api/todo라우터 (15개 엔드포인트)backend/app/main.py: todo_router 등록
프론트엔드 (11-2)
frontend/src/features/todo/types.ts: Project, Task, Subtask 인터페이스 + 요청/응답 타입frontend/src/features/todo/hooks/useTodo.ts: 프로젝트/태스크 CRUD 훅frontend/src/features/todo/hooks/useSubtasks.ts: 서브태스크 CRUD + 토글 훅frontend/src/features/todo/components/ProjectSidebar.tsx: 프로젝트 목록, 색상 도트, 생성/삭제frontend/src/features/todo/components/TaskListView.tsx: 퀵 추가, 체크박스, 우선순위 도트, 마감일frontend/src/features/todo/components/TaskDetailView.tsx: 설명 편집, 서브태스크 목록, 상태/우선순위 토글frontend/src/features/todo/TodoPage.tsx: 3-panel 레이아웃 (프로젝트 사이드바 | 태스크 목록 | 태스크 상세)frontend/src/components/ui/checkbox.tsx: shadcn/ui Checkbox 컴포넌트 추가 (@radix-ui/react-checkbox)frontend/src/components/ui/textarea.tsx: shadcn/ui Textarea 컴포넌트 추가frontend/src/app/page.tsx: activePage 타입에 "todo" 추가, TodoPage 조건부 렌더링frontend/src/components/AppHeader.tsx: 할일 탭 추가 (CheckSquare 아이콘)
검증
uv run ruff check .— All checks passedpnpm lint— 통과pnpm build— 빌드 성공
- PR 생성 후 main에 병합
- Phase 11-3 (UI 폴리시): 드래그앤드롭 재정렬, 우선순위 선택기, 마감일 date-picker, 상태 필터 탭, 프로젝트 색상 선택기
- shadcn/ui checkbox, textarea 컴포넌트가 없어서 직접 추가함
- SQLite create_all()은 기존 테이블에 영향 없이 새 테이블만 생성
- 기존 DB 재생성 불필요 (새 테이블 추가만)
백엔드 — DDD 패키지 구조
app/core/: database.py, exceptions.py, dependencies.py, background_sync.pyapp/auth/: router.py, service.py, dependencies.pyapp/calendar/: router.py, service.py, schemas.pyapp/mail/: models.py (4파일 통합), routers/ (gmail, naver, inbox, classify), services/ (gmail, naver, classifier, feedback, helpers)- 기존 flat 구조 (models/, routers/, services/, dependencies.py, exceptions.py) 완전 삭제
- main.py, conftest.py, test_classify.py import 경로 업데이트
- ruff clean, pytest 24/24 통과
프론트엔드 — feature 폴더 구조
features/mail/: components/ (7개), hooks/ (5개), types.ts, constants.tsfeatures/calendar/: CalendarPage.tsx, components/ (4개), hooks/ (1개), types.tsfeatures/auth/: components/ (2개), hooks/ (2개), types.ts (UserInfo 분리)- 공통 유지: components/AppHeader.tsx, Pagination.tsx, ui/
- page.tsx, AppHeader.tsx, 테스트 5개 파일 import 경로 업데이트
- 기존 hooks/, types/, constants/ 디렉토리 삭제
- pnpm lint + pnpm build 통과
- PR 생성 후 main에 병합
- page.tsx → thin shell + MailPage 추출 (선택적 추가 리팩토링)
- 주간/일간 캘린더 뷰 (Phase 9-3 pending)
- CalendarView → CalendarPage로 rename (feature 폴더 관례에 맞춤)
- UserInfo 타입은 features/auth/types.ts로 분리, useMailActions에서 cross-feature import
- MailPage 추출은 AppHeader와의 상태 공유 복잡성으로 인해 추후 진행
백엔드
backend/app/services/google_auth.py: OAuth 스코프calendar.readonly→calendar.events로 변경,OAUTHLIB_RELAX_TOKEN_SCOPE=1설정 (Google 스코프 순서 불일치 해결)backend/app/services/calendar_service.py:create_event()함수 추가 (종일/시간 이벤트 분기 처리)backend/app/routers/calendar.py:POST /events엔드포인트 +CreateEventRequestPydantic 모델
프론트엔드
frontend/src/types/calendar.ts:CreateEventRequest타입 추가frontend/src/hooks/useCalendar.ts:createEvent함수 추가 (POST + 자동 새로고침)frontend/src/components/EventCreateModal.tsx: 이벤트 생성 모달 (제목, 종일, 날짜/시간, 장소, 설명, 캘린더 선택)frontend/src/components/CalendarView.tsx: 생성 모달 연동 + toast 알림
버그 수정
- Dialog
aria-describedby경고 해결 (sr-only DialogDescription 추가) - 날짜 클릭 시 모달에 해당 날짜가 기본값으로 설정되도록 수정 (useEffect로 open 시 상태 리셋)
- 주간/일간 캘린더 뷰 (Phase 9-3 pending)
- 이벤트 수정/삭제 기능 (추후)
OAUTHLIB_RELAX_TOKEN_SCOPE=1: Google OAuthinclude_granted_scopes로 인해 이전 스코프가 포함되어 순서/내용 불일치 발생 → 이 환경변수로 해결- 스코프 변경 후 기존 사용자 재인증 필요
frontend/src/types/calendar.ts: CreateEventRequest 타입 추가frontend/src/hooks/useCalendar.ts: createEvent 함수 추가- POST /api/calendar/events 호출
- 생성 후 이벤트 목록 자동 새로고침
frontend/src/components/EventCreateModal.tsx: 이벤트 생성 모달 컴포넌트 신규 생성- Dialog 기반 모달 UI
- 제목, 종일 여부, 시작/종료 날짜&시간, 장소, 설명, 캘린더 선택
- 종일 이벤트 vs 시간 이벤트 조건부 입력
- 유효성 검증 (제목 필수)
frontend/src/components/CalendarView.tsx: 이벤트 생성 기능 연동- 사이드바에 "일정 추가" 버튼 추가
- 날짜 클릭 시 해당 날짜로 생성 모달 오픈 (onSelectDate 핸들러 연결)
- toast 알림 (성공/실패)
PLAN.md: Phase 9-3에 "이벤트 생성 기능" 태스크 추가, Phase 9-4 백엔드 쓰기 권한 확장 계획 추가
- 백엔드 Calendar API에 POST /events 엔드포인트 구현 필요 (Phase 9-4)
- OAuth 스코프에
calendar(쓰기 권한) 추가 - calendar_service.py에 create_event, update_event, delete_event 추가
- routers/calendar.py에 POST /events 라우터 추가
- OAuth 스코프에
- 프론트엔드에서 이벤트 생성 테스트
- 현재 백엔드는
calendar.readonly스코프만 있어 POST 요청이 실패함 (403 Forbidden 예상) - 백엔드 9-4 완료 후 재인증 필요 (기존 사용자 로그아웃 → 재로그인으로 새 스코프 동의)
백엔드 (9-1)
backend/app/services/google_auth.py: OAuth 스코프에calendar.readonly추가backend/app/services/calendar_service.py: Calendar Service 신규 생성 — list_calendars, list_events, get_event, _parse_eventbackend/app/routers/calendar.py: Calendar API 라우터 — /calendars, /events, /events/{id}backend/app/main.py: calendar 라우터 등록
프론트엔드 구조 변경 (9-2)
frontend/src/components/AppHeader.tsx: 메일/캘린더 페이지 전환 네비게이션 추가frontend/src/app/page.tsx: activePage 상태, 조건부 렌더링
프론트엔드 캘린더 UI (9-3)
frontend/src/types/calendar.ts: CalendarInfo, CalendarEvent 타입frontend/src/hooks/useCalendar.ts: 캘린더/이벤트 로드, 월 이동, 필터링 훅frontend/src/components/CalendarMonthView.tsx: 월간 그리드 뷰frontend/src/components/CalendarEventDetail.tsx: 이벤트 상세 패널frontend/src/components/CalendarSidebar.tsx: 캘린더 목록/월 네비게이션frontend/src/components/CalendarView.tsx: 통합 3-panel 레이아웃
- Google Cloud Console에서 Calendar API 활성화 필요
- 기존 사용자 로그아웃 → 재로그인 (calendar.readonly 스코프 동의 필요)
- 배포 후 통합 테스트
- 주간/일간 뷰는 추후 확장
- 기존 OAuth 토큰은 calendar.readonly 스코프 없음 → 재인증 필수
- prompt="consent"가 이미 설정되어 있어 재로그인 시 자동으로 새 스코프 동의 화면 표시
- DB 모델 변경 없음 (캘린더 데이터는 실시간 API 호출)
frontend/src/components/CalendarMonthView.tsx: 월간 캘린더 뷰 구현- 월별 그리드 레이아웃 (일~토, 6주)
- 이벤트를 날짜별로 그룹핑하여 표시
- 종일 이벤트 vs 시간 이벤트 구분 표시
- 일요일(빨강), 토요일(파랑) 색상 구분
- 오늘 날짜 하이라이트
- 이벤트 클릭 → 상세 패널 오픈
- 최대 3개 이벤트 표시, 초과 시 "+N개 더" 표시
frontend/src/components/CalendarEventDetail.tsx: 이벤트 상세 패널- 이벤트 제목, 시간, 장소, 참석자, 설명 표시
- 종일 이벤트 vs 시간 이벤트에 따른 시간 포맷 처리
- 참석자 응답 상태 표시 (수락/거절/미정)
- Google Calendar 링크
frontend/src/components/CalendarSidebar.tsx: 캘린더 사이드바- 월 네비게이션 (이전/다음/오늘)
- 캘린더 목록 체크박스 필터링
- 캘린더별 색상 표시
- 기본 캘린더 표시
frontend/src/components/CalendarView.tsx: 통합 캘린더 뷰- 사이드바 + 월간뷰 + 이벤트 상세 3-panel 레이아웃
- ResizablePanel로 패널 크기 조절 가능
- 이벤트 선택 시 상세 패널 동적 표시
frontend/src/app/page.tsx: CalendarView import 및 연동- activePage === "calendar" 조건부 렌더링
- 백엔드 Calendar API 라우터 완료 후 통합 테스트
- 주간/일간 캘린더 뷰 (CalendarWeekView.tsx) 추후 구현 (optional)
- 백엔드 API 라우터
/api/calendar/calendars,/api/calendar/events구현 대기 중 - useCalendar 훅의 try-catch로 백엔드 미준비 상태에서도 에러 없이 빈 화면 표시
frontend/src/types/calendar.ts: Calendar 타입 정의 파일 생성 (CalendarInfo, CalendarEvent, CalendarsResponse, EventsResponse)frontend/src/components/AppHeader.tsx: 페이지 네비게이션 추가- props에
activePage,onPageChange추가 - 로고 옆에 메일/캘린더 전환 탭 추가 (lucide-react Mail, Calendar 아이콘 사용)
- 소스 필터 탭과 메일 액션 버튼은
activePage === "mail"조건부 렌더링
- props에
frontend/src/app/page.tsx: activePage 상태 추가 및 조건부 렌더링useState<"mail" | "calendar">("mail")추가- AppHeader에 activePage, onPageChange props 전달
- 메일 UI (Sheet, NaverConnectModal, 3-panel layout)는
activePage === "mail"조건 내 렌더링 - 캘린더 페이지는 placeholder div 표시 (캘린더 뷰 구현 대기)
frontend/src/hooks/useCalendar.ts: 캘린더 훅 구현- 캘린더 목록 로드, 이벤트 로드 (월 기준)
- 캘린더 선택/필터링, 월 이동 네비게이션 로직
- enabled 플래그로 캘린더 페이지 활성화 시에만 API 호출
- 백엔드 Calendar API 라우터 완료 후 프론트엔드 캘린더 뷰 컴포넌트 구현
- CalendarMonthView.tsx (월간 그리드)
- CalendarWeekView.tsx (주간/일간 타임라인)
- CalendarEventDetail.tsx (이벤트 상세)
- CalendarListSidebar.tsx (캘린더 목록 필터)
- 백엔드 API 라우터
/api/calendar/calendars,/api/calendar/events구현 필요 - useCalendar 훅은 백엔드 API 준비 전까지 에러를 조용히 처리 (try-catch)
backend/app/models/mail.py:body_html컬럼 추가 (Text, nullable)backend/app/services/gmail_service.py:_extract_body()→ text+html dict 반환,_parse_message()및 sync 함수에서 body_html 저장backend/app/services/naver_service.py: 동일하게 text+html 동시 추출backend/app/services/helpers.py: API 응답에body_html포함frontend/src/components/HtmlEmailRenderer.tsx: DOMPurify whitelist 기반 안전한 HTML 렌더링 컴포넌트 신규 생성frontend/src/components/MailDetailView.tsx: body_html 유무에 따른 조건부 렌더링frontend/src/types/mail.ts:body_html필드 추가frontend/src/app/globals.css:@tailwindcss/typography플러그인 추가sync_gmail_messages()에서body_html저장 누락 버그 수정- PR #2 생성: #2
- PR #2 머지 후 배포 확인
- Gmail/Naver HTML 이메일 수동 렌더링 테스트
- AI 분류 재실행 (DB 초기화로 기존 분류 소실됨)
- SQLite
create_all()은 기존 테이블에 컬럼 추가 불가 — DB 재생성 필요했음 - body_html 테스트 위해 mails 전체 삭제 → Classification CASCADE 삭제됨, 재분류 필요
.github/workflows/ci.yml: CI 파이프라인 (backend-lint, backend-test, frontend-lint, frontend-test, frontend-build).github/workflows/deploy.yml: CD 파이프라인 (CI 성공 + main push → SSH로 Oracle VM 자동 배포)- GitHub Secrets 등록:
ORACLE_SSH_KEY,ORACLE_HOST,ORACLE_USERNAME frontend/pnpm-workspace.yaml:packages필드 추가 (CI 호환)CategoryBadge.test.tsx: small 모드 테스트 수정 (실제 동작에 맞게)- gh-aw (ci-doctor, pr-fix) 시도 → Copilot Pro 미구독으로 제거
- CI 전체 통과 확인
- CD 자동 배포 검증 (main 머지 → Oracle VM 반영 확인)
- Copilot Pro 구독 시 gh-aw 재도입 검토
- deploy.yml: host/username은 secrets로 관리 (하드코딩 방지)
- pnpm/action-setup@v4에
version: 9명시 (packageManager 필드 미설정 대응) - gh-aw는 Copilot Pro 이상 구독 필요 (학생/Free 불가)
- Oracle VM 프로비저닝 성공 (A1.Flex, 춘천 리전)
- Reserved Public IP 적용:
138.2.116.61 - DNS 설정:
gtool.kro.kr→138.2.116.61 - Docker + Docker Compose 설치
- OS 방화벽 (iptables) + OCI Security List에 80/443 포트 오픈
git clone→ 환경변수 설정 →docker compose up -d --build- Caddy Let's Encrypt HTTPS 인증서 자동 발급 완료
https://gtool.kro.kr접속 확인
- Google OAuth redirect URI 추가 (
https://gtool.kro.kr/api/auth/callback) - CI/CD 파이프라인 구축 (GitHub Actions)
- OCI Security List에 80/443 Ingress Rule 누락으로 ACME challenge 실패 → 추가 후 해결
- Keep-alive crontab 설정 권장 (OCI idle VM 회수 방지)
- OCI API Key 생성 +
~/.oci/config설정 완료 - Terraform 설정 파일 작성 (
infra/main.tf) — A1.Flex, 2 OCPU, 12GB terraform init+terraform plan성공 확인- SSH 키 생성 (
~/.ssh/oracle_cloud) - 도메인 확보:
gtool.kro.kr - DEPLOY.md 배포 가이드 작성
- OCI Compartment
g-tool생성, VCN + Security List 설정 완료
terraform apply재시도 (춘천 리전 ARM capacity 부족으로 blocked)- 새벽 시간대(KST 2~6시) 시도 권장
- 명령어:
cd infra && terraform apply -auto-approve
- VM 생성 성공 후 → DEPLOY.md Step 3(Docker 설치)부터 진행
- DNS A 레코드에 VM Public IP 연결 (
gtool.kro.kr) - Google OAuth redirect URI 추가:
https://gtool.kro.kr/api/auth/callback
- OCI 춘천 리전 ARM(A1.Flex) capacity 부족 — "Out of host capacity" 에러
- Terraform, OCI API 인증은 모두 정상 동작 확인됨
infra/디렉토리는 .gitignore 처리 (OCID 등 민감정보 포함)
backend/app/config.py:frontend_url설정 추가backend/app/main.py: CORS origins를settings.frontend_url에서 읽도록 변경backend/app/routers/auth.py: OAuth 콜백 리다이렉트 URL을settings.frontend_url사용frontend/next.config.ts:output: "standalone"추가backend/Dockerfile: Python 3.13-slim + uv 기반frontend/Dockerfile: Multi-stage build (deps → build → production)docker-compose.yml: backend + frontend + Caddy (리버스 프록시 + 자동 HTTPS)Caddyfile:/api/*→ backend,/*→ frontend.dockerignore(backend, frontend 각각)backend/.env.example업데이트 (FRONTEND_URL 추가).env.production.example생성 (DOMAIN 설정).gitignore에.env.production추가
- Oracle Cloud VM 프로비저닝 (ARM, Free Tier)
- 서버에서
docker compose up -d로 배포 - DNS 설정 후 HTTPS 자동 발급 확인
- SQLite 유지 (1인 사용, 볼륨 마운트로 영속)
- 로컬 개발 플로우 변경 없음 (
uv run fastapi dev,pnpm dev그대로) - Caddy가
/apiprefix를 strip하므로 프론트에서 API 호출 시/api/...경로 사용
- v1 아카이브: PLAN_V1.md, PROGRESS_V1.md 생성
- PLAN.md, PROGRESS.md를 v2 시작점으로 초기화
- references/ 정리 — 구현 완료된 조사 자료 5개 삭제, 2개 유지
- CLAUDE.md 업데이트 — 로드맵 완료 표시, 프로젝트 구조 현행화
- v2 기획 및 태스크 정의
- v1 전체 40개 태스크 (Phase 0~5) 완료
- 스킬/에이전트는 모두 범용적이므로 전부 유지