Skip to content

[feat] #40 - 구글 캘린더 연동/해제#44

Open
Jy000n wants to merge 30 commits into
developfrom
feat/#40-google-calendar
Open

[feat] #40 - 구글 캘린더 연동/해제#44
Jy000n wants to merge 30 commits into
developfrom
feat/#40-google-calendar

Conversation

@Jy000n

@Jy000n Jy000n commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator

관련 이슈 🛠

작업 내용 요약 ✏️

구글 OAuth 동의 완료 후 발급된 authorizationCode로 구글 토큰을 교환하여 캘린더를 연동하고, 연동 정보를 삭제 및 구글 토큰을 revoke하는 연동 해제 기능을 구현했습니다.

주요 변경 사항 🛠️

  • [CalendarController]: POST /api/v1/users/calendar(연동), DELETE /api/v1/users/calendar(해제) API 엔드포인트 추가
  • [CalendarService]: 연동/해제 비즈니스 로직 구현 (이미 연동된 경우 예외 처리, 가입 이메일과 구글 인증 이메일 일치 검증, revoke 처리)
  • [CalendarConnection]: calendar_connections 테이블 매핑 엔티티 추가 (토큰 평문 저장)
  • [CalendarConnectionRepository]: findByUserId, existsByUserId 조회 메서드 추가
  • [CalendarConnectRequest / CalendarConnectResponse / CalendarDisconnectResponse]: 연동/해제 API Request, Response DTO 추가
  • [CalendarConnectResponse]: connectedAt 응답 포맷을 yyyy-MM-dd HH:mm:ss로 통일 (@JsonFormat, @Schema 적용)
  • [GoogleOAuthClient]: 구글 토큰 교환(exchangeToken), 사용자 정보 조회(fetchUserInfo), 토큰 revoke(revokeToken) 로직 분리, 기존 spring.security.oauth2.client.registration.google 설정 재사용
  • [GoogleTokenResponse / GoogleUserInfoResponse]: 구글 API 응답 매핑 DTO 추가
  • [CalendarResponseFactory]: AuthResponseFactory 패턴에 맞춰 응답 조립 책임 분리
  • [CalendarControllerDocs]: Swagger 문서화를 위한 인터페이스 추가

트러블 슈팅 ⚽️

  • 유저가 로그인 계정과 다른 구글 계정으로 연동을 시도할 수 있어, 구글 userinfo API로 실제 인증된 이메일을 조회해 가입 이메일과 일치하는지 검증하는 로직 추가 (다른 계정으로는 연동 불가 정책)
  • 프론트-백엔드 연결 전이라 OAuth Playground를 임시 redirect_uri로 등록해 authorizationCode를 발급받아 테스트 진행함

구글 캘린더 연동 시 fetchUserInfo 단계에서 401 에러 발생

  • POST /api/v1/users/calendar 호출 시 CALENDAR_401(구글 캘린더 인증에 실패했습니다) 응답이 반복적으로 발생
  • 예외를 단순히 CustomException으로 감싸 던지던 catch 블록에 로그를 추가하여 원인을 단계별로 추적
    1. 1차 원인: GoogleTokenResponse@JsonProperty가 없어, 구글이 스네이크 케이스(access_token, refresh_token, expires_in)로 응답하는 필드가 전부 null로 파싱됨. 이로 인해 fetchUserInfo() 호출 시 accessTokennull로 전달되어 Authorization: Bearer null 헤더가 생성되고, 구글이 401(UNAUTHENTICATED)로 거부하는 문제였음. 각 필드에 @JsonProperty를 명시하여 해결
    2. 2차 원인: 필드 매핑을 고친 후에도 동일한 401이 재현됨. 원인은 OAuth 인가 코드 발급 시 calendar.readonly 스코프만 요청하고, 사용자 정보 조회에 필요한 email(userinfo) 스코프를 함께 요청하지 않았기 때문. 캘린더 접근 권한만 있는 토큰으로는 userinfo 엔드포인트 호출이 거부됨을 확인. 인가 요청 시 calendar.readonlyemail 스코프를 함께 요청하도록 테스트 절차를 수정하여 해결
  • (참고) 디버깅 과정에서 OAuth 인가 코드가 1회용이며 재사용/만료 시 invalid_grant로 거부된다는 점도 함께 확인. 테스트 시 매번 새 인가 코드를 발급받아야 함

self-invocation으로 인해 @transactional이 실제로 적용되지 않던 문제

  • CalendarService.connect(), disconnect()에서 같은 클래스 내부의 saveConnection(), deleteConnection()this로 직접 호출하고 있었는데, 이 경우 Spring AOP 프록시를 우회하여 @Transactional이 사실상 무시된다는 점을 코드 리뷰(CodeRabbit)를 통해 확인
  • Spring의 @Transactional은 프록시 기반으로 동작하는데, 외부에서 빈을 호출할 때만 프록시가 트랜잭션 시작/커밋 로직을 가로챌 수 있고, 같은 클래스 내부에서 자기 자신의 메서드를 호출하는 경우(self-invocation)에는 프록시를 거치지 않아 트랜잭션이 적용되지 않음
  • 트랜잭션이 필요한 쓰기 로직(saveConnection, deleteConnection)을 별도의 CalendarConnectionCommandService로 분리하고, CalendarService가 이를 주입받아 호출하도록 수정 -> 다른 빈을 통해 호출되므로 프록시가 정상적으로 트랜잭션을 적용함

revoke 실패를 조용히 무시하지 않고 log.warn으로 남긴 이유

  • disconnect()에서 DB 삭제를 먼저 확정한 뒤 revokeToken을 best-effort로 실행하도록 바꾸면서, 이 호출이 실패했을 때 catch {}로 완전히 무시할지 로그를 남길지 검토
  • catch {}로 비워두면 코드는 단순해지지만, **"조용한 실패(silent failure)"**가 되어 다음과 같은 문제가 생길 수 있음을 확인
    • DB 상 연동은 정상적으로 해제됐지만 구글 쪽에는 여전히 우리 앱이 해당 사용자의 캘린더에 접근 가능한 토큰이 살아있는 상태로 남을 수 있음
    • 이 상태에서 향후 "연동 해제했는데 구글 계정 설정에 앱 권한이 남아있다"는 문의가 들어와도 로그에 아무 흔적이 없어 원인 추적이 불가능함
    • 반대로 revoke 실패가 반복적으로 발생하는 경우(구글 API 자체 장애, 코드 버그 등)를 운영 중 모니터링으로 감지할 수 있는 신호도 사라짐
  • 이에 error가 아닌 warn 레벨로 로그를 남기기로 결정
    • error로 하지 않은 이유는 이 실패가 사용자 경험에는 즉각적인 영향을 주지 않는 부차적 실패이기 때문임 (DB 삭제는 이미 성공했고 우리 서비스는 더 이상 해당 토큰을 사용하지 않으므로 기능적으로는 정상 동작)
  • -> 기능은 정상 동작하되 나중에 추적 가능한 흔적은 남긴다"는 목적으로 log.warn(userId 포함)을 선택함

테스트 결과 📄

  • 정상 케이스: 캘린더 연동 성공, calendarConnected: truecalendarEmail 반환 확인
  • authorizationCode 누락 시 400 에러 반환 확인
  • 유효하지 않은 authorizationCode인 경우 401(CALENDAR_401) 에러 반환 확인
  • 가입 이메일과 다른 구글 계정으로 연동 시도 시 401(이메일 불일치) 에러 반환 확인
  • 이미 연동된 상태에서 재연동 시도 시 409 에러 반환 확인
  • 토큰 없이 요청 시 401 에러 반환 확인
  • 정상 케이스: 캘린더 연동 해제 성공, calendarConnected: false 반환 확인
  • 연동되지 않은 상태에서 해제 시도 시 404 에러 반환 확인
  • 해제 후 DB에서 calendar_connections row 삭제 확인

스크린샷 📷

캘린더 연동 성공, calendarConnected: truecalendarEmail 반환 확인
image
authorizationCode 누락 시 400 에러 반환 확인
image
유효하지 않은 authorizationCode인 경우 401(CALENDAR_401) 에러 반환 확인
image
가입 이메일과 다른 구글 계정으로 연동 시도 시 401(이메일 불일치) 에러 반환 확인
image
이미 연동된 상태에서 재연동 시도 시 409 에러 반환 확인
image
캘린더 연동 해제 성공, calendarConnected: false 반환 확인
image
연동되지 않은 상태에서 해제 시도 시 404 에러 반환 확인
image
해제 후 DB에서 calendar_connections row 삭제 확인
image

리뷰 요구사항 📢

변경 사항

  1. 구글 캘린더 전용 리다이렉트 주소를 yml에 로그인 리다이렉트 주소와 별도로 분리했습니다 (local, prod 모두 반영)
app:
  calendar:
    redirect-uri: ${FRONTEND_URL}/oauth/calendar/callback
  1. 캘린더 연동을 시작하는 GET /api/v1/users/calendar/authorize API를 새로 추가했습니다.

    • 기존에는 프론트가 구글 인가 코드(authorizationCode)를 발급받을 방법 자체가 없어 캘린더 연동 흐름을 시작할 수 없는 상태였습니다.
    • 추가한 API가 client_id, scope(calendar.readonly + email), redirect_uri를 포함한 구글 인증 URL을 서버가 직접 구성해 302 리다이렉트합니다.
    • 이에 따라 캘린더 연동 API 명세서에도 이 API가 신규 추가되었으니 참고해주시면 감사하겠습니다.
  2. 기존 POST /api/v1/users/calendar(토큰 교환), DELETE /api/v1/users/calendar(연동 해제) API는 로직 변경 없이 그대로 유지됩니다.

📎 참고 자료 (선택)

Summary by CodeRabbit

Summary by CodeRabbit

  • 새로운 기능
    • Google OAuth 기반 캘린더 연동 시작/완료 기능을 추가했습니다(인증 URL 리다이렉트, 코드로 연결).
    • 연결/해제 결과로 연동 여부, 이메일, 연결 시각을 응답으로 제공합니다.
  • 버그 수정
    • 중복 연결, 미연결 해제, 사용자 이메일 불일치, 토큰 처리 오류에 대해 상황별 명확한 메시지로 안내합니다.
    • 인증 코드의 필수값 검증을 강화했습니다.
  • 설정
    • 로컬/프로덕션용 캘린더 OAuth 콜백 리디렉션 주소를 설정으로 분리했습니다.

@coderabbitai

coderabbitai Bot commented Jul 8, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@Jy000n, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 32 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 33d6fe02-0f61-4658-ab63-e5e4d21b0c65

📥 Commits

Reviewing files that changed from the base of the PR and between fcf4bc5 and ae4582b.

⛔ Files ignored due to path filters (1)
  • src/main/java/com/Timo/Timo/domain/calendar/docs/CalendarControllerDocs.java is excluded by !**/docs/**
📒 Files selected for processing (3)
  • src/main/java/com/Timo/Timo/domain/calendar/service/CalendarConnectionCommandService.java
  • src/main/java/com/Timo/Timo/domain/calendar/service/CalendarService.java
  • src/main/resources/application-local.yml

Walkthrough

구글 OAuth 기반 캘린더 연동·해제 기능이 추가되었습니다. OAuth 클라이언트, 서비스 로직, API 컨트롤러, 연결 저장 모델, DTO, 오류·성공 코드와 환경별 redirect URI 설정이 포함됩니다.

Changes

구글 캘린더 연동/해제

Layer / File(s) Summary
에러 코드와 데이터 계약 정의
.../exception/Calendar*.java, .../dto/request/*, .../dto/response/*
캘린더 오류·성공 코드와 연결 요청, 연결·해제 응답, Google OAuth 응답 DTO를 정의합니다.
캘린더 연결 저장 모델 구성
.../entity/CalendarConnection.java, .../repository/CalendarConnectionRepository.java
사용자별 캘린더 이메일과 OAuth 토큰 정보를 저장하는 엔티티 및 조회 리포지토리를 추가합니다.
Google OAuth 클라이언트 구현
.../client/GoogleOAuthClient.java
토큰 교환, 사용자 정보 조회, 토큰 리보크와 타임아웃·인증 실패 예외 매핑을 구현합니다.
CalendarService 연동·해제 로직
.../service/CalendarService.java
사용자·중복 연결·이메일을 검증하고 연결 저장 또는 토큰 리보크 후 연결 삭제를 수행합니다.
OAuth 진입점과 API 응답 연결
.../controller/CalendarController.java, .../factory/CalendarResponseFactory.java, .../application-*.yml
OAuth 인증 URL 리다이렉트와 POST·DELETE 요청을 서비스에 연결하고 표준 성공 응답 및 redirect URI 설정을 추가합니다.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant CalendarController
  participant CalendarService
  participant GoogleOAuthClient
  participant GoogleOAuth
  participant CalendarConnectionRepository

  CalendarController->>CalendarService: connect(userId, authorizationCode)
  CalendarService->>GoogleOAuthClient: exchangeToken(authorizationCode)
  GoogleOAuthClient->>GoogleOAuth: token request
  GoogleOAuth-->>GoogleOAuthClient: GoogleTokenResponse
  CalendarService->>GoogleOAuthClient: fetchUserInfo(accessToken)
  GoogleOAuth-->>GoogleOAuthClient: GoogleUserInfoResponse
  CalendarService->>CalendarConnectionRepository: save(CalendarConnection)
  CalendarService-->>CalendarController: CalendarConnectResponse

  CalendarController->>CalendarService: disconnect(userId)
  CalendarService->>CalendarConnectionRepository: findByUserId(userId)
  CalendarService->>GoogleOAuthClient: revokeToken(accessToken)
  CalendarService->>CalendarConnectionRepository: delete(connection)
  CalendarService-->>CalendarController: CalendarDisconnectResponse
Loading

Suggested labels: 🍀 윤아, slack-approval-notified

Suggested reviewers: laura-jung, aneykrap

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Linked Issues check ✅ Passed 연동용 authorization code 교환, 캘린더 정보 저장, 연동 해제 API가 모두 구현되어 이슈 요구사항을 충족합니다.
Out of Scope Changes check ✅ Passed 추가된 authorize 리다이렉트, DTO/엔티티, 예외·응답 코드, 문서화는 연동/해제 기능을 위한 지원 변경으로 보입니다.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed 구글 캘린더 연동/해제라는 핵심 변경을 간결하게 잘 요약하고 있습니다.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/#40-google-calendar

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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (3)
src/main/java/com/Timo/Timo/domain/calendar/client/GoogleOAuthClient.java (1)

82-93: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

revokeToken에서 토큰을 URL 인코딩 없이 쿼리 스트링에 연결하고 있습니다.

Google 토큰은 일반적으로 URL-safe 문자로 구성되지만, &= 등의 특수문자가 포함될 경우 URL이 깨질 수 있습니다. URLEncoder.encode를 적용하는 것이 안전합니다.

🔒 제안: 토큰 URL 인코딩 적용
 public void revokeToken(String token) {
   try {
     restClient.post()
-        .uri("https://oauth2.googleapis.com/revoke?token=" + token)
+        .uri("https://oauth2.googleapis.com/revoke?token=" + URLEncoder.encode(token, StandardCharsets.UTF_8))
         .retrieve()
         .toBodilessEntity();
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/java/com/Timo/Timo/domain/calendar/client/GoogleOAuthClient.java`
around lines 82 - 93, Update GoogleOAuthClient.revokeToken to URL-encode the
token before appending it to the revoke endpoint query string, using UTF-8
encoding and preserving the existing exception handling behavior.
src/main/java/com/Timo/Timo/domain/calendar/controller/CalendarController.java (1)

46-57: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

OAuth authorize URL에 state 파라미터가 누락되어 있습니다.

Google OAuth 권한 부여 요청에 state 파라미터를 포함하면 CSRF 공격을 방지할 수 있습니다. 현재 서비스의 이메일 일치 검증(validateSameAccount)으로 완화되지만, OAuth 보안 모범 사례로 state 파라미터 추가를 권장합니다. As per path instructions, "인증/인가가 필요한 API에서 보안 처리가 누락되지 않았는지 확인해 주세요".

🔒 제안: state 파라미터 추가
 `@GetMapping`("/authorize")
 public void authorize(HttpServletResponse response) throws IOException {
+  String state = UUID.randomUUID().toString();
+  // state를 세션 또는 캐시에 저장하여 callback에서 검증
+
   String url = GOOGLE_AUTH_URL
       + "?client_id=" + URLEncoder.encode(clientId, StandardCharsets.UTF_8)
       + "&redirect_uri=" + URLEncoder.encode(redirectUri, StandardCharsets.UTF_8)
       + "&response_type=code"
+      + "&state=" + URLEncoder.encode(state, StandardCharsets.UTF_8)
       + "&scope=" + URLEncoder.encode(CALENDAR_SCOPE, StandardCharsets.UTF_8)
       + "&access_type=offline"
       + "&prompt=consent";

   response.sendRedirect(url);
 }

참고: state 값은 세션, 쿠키, 또는 Redis 등에 저장한 후 callback 처리 시 검증해야 합니다. 프론트엔드에서 callback을 처리하므로, 쿠키에 state를 저장하고 프론트엔드에서 검증하는 방식도 고려할 수 있습니다.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/main/java/com/Timo/Timo/domain/calendar/controller/CalendarController.java`
around lines 46 - 57, Update the authorize flow in CalendarController.authorize
to generate a cryptographically secure OAuth state value, persist it for the
initiating session or request context, append it as a URL-encoded state
parameter, and validate and consume the same value during the OAuth callback
before accepting the authorization response.

Source: Path instructions

src/main/java/com/Timo/Timo/domain/calendar/service/CalendarService.java (1)

79-84: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

토큰 revoke 시 access token 대신 refresh token을 사용하는 것을 권장합니다.

Google의 revoke 엔드포인트는 access token과 refresh token 모두를 받지만, refresh token을 revoke하면 파생된 모든 access token도 함께 무효화됩니다. 현재 access token만 revoke하면 refresh token이 유효한 상태로 남아 새 access token을 발급받을 수 있습니다. DB에서 연결 정보가 삭제된 후에도 refresh token이 orphaned 상태로 남는 보안 위험이 있습니다.

🔒 제안: refresh token으로 revoke 수행
 public CalendarDisconnectResponse disconnect(Long userId) {
   CalendarConnection calendarConnection = calendarConnectionRepository.findByUserId(userId)
       .orElseThrow(() -> new CustomException(CalendarErrorCode.CALENDAR_404_NOT_CONNECTED));

-  String accessToken = calendarConnection.getAccessToken();
+  String refreshToken = calendarConnection.getRefreshToken();

   deleteConnection(calendarConnection);

   try {
-    googleOAuthClient.revokeToken(accessToken);
+    googleOAuthClient.revokeToken(refreshToken);
   } catch (Exception e) {
     log.warn("Google token revoke failed after disconnect. userId={}", userId, e);
   }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/java/com/Timo/Timo/domain/calendar/service/CalendarService.java`
around lines 79 - 84, Update the token captured before deleteConnection in the
CalendarService revoke flow to use the stored refresh token instead of the
access token, then pass that refresh token to googleOAuthClient.revokeToken so
all derived access tokens are invalidated.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/main/java/com/Timo/Timo/domain/calendar/service/CalendarService.java`:
- Line 43: CalendarService의 connect()와 disconnect()가 같은 클래스의 saveConnection() 및
deleteConnection()을 직접 호출해 `@Transactional` 프록시를 우회합니다. 트랜잭션 메서드인
saveConnection()과 deleteConnection()을 별도 Spring 서비스로 분리하고 CalendarService에 주입한
뒤, 두 흐름에서 해당 서비스의 메서드를 호출하도록 변경해 각 쓰기 작업이 프록시를 통해 실행되게 하세요.

In `@src/main/resources/application-local.yml`:
- Around line 33-34: Update the local profile configuration for
app.calendar.redirect-uri to provide a default FRONTEND_URL value, such as
http://localhost:5173, when the environment variable is unset. Keep the existing
calendar callback path unchanged so local startup succeeds without requiring
external configuration.

---

Nitpick comments:
In `@src/main/java/com/Timo/Timo/domain/calendar/client/GoogleOAuthClient.java`:
- Around line 82-93: Update GoogleOAuthClient.revokeToken to URL-encode the
token before appending it to the revoke endpoint query string, using UTF-8
encoding and preserving the existing exception handling behavior.

In
`@src/main/java/com/Timo/Timo/domain/calendar/controller/CalendarController.java`:
- Around line 46-57: Update the authorize flow in CalendarController.authorize
to generate a cryptographically secure OAuth state value, persist it for the
initiating session or request context, append it as a URL-encoded state
parameter, and validate and consume the same value during the OAuth callback
before accepting the authorization response.

In `@src/main/java/com/Timo/Timo/domain/calendar/service/CalendarService.java`:
- Around line 79-84: Update the token captured before deleteConnection in the
CalendarService revoke flow to use the stored refresh token instead of the
access token, then pass that refresh token to googleOAuthClient.revokeToken so
all derived access tokens are invalidated.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: e15738cf-b4a3-4fcc-bb39-554a11e2e476

📥 Commits

Reviewing files that changed from the base of the PR and between 3316973 and fcf4bc5.

📒 Files selected for processing (6)
  • src/main/java/com/Timo/Timo/domain/calendar/client/GoogleOAuthClient.java
  • src/main/java/com/Timo/Timo/domain/calendar/controller/CalendarController.java
  • src/main/java/com/Timo/Timo/domain/calendar/exception/CalendarErrorCode.java
  • src/main/java/com/Timo/Timo/domain/calendar/service/CalendarService.java
  • src/main/resources/application-local.yml
  • src/main/resources/application-prod.yml
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/main/java/com/Timo/Timo/domain/calendar/exception/CalendarErrorCode.java

Comment thread src/main/java/com/Timo/Timo/domain/calendar/service/CalendarService.java Outdated
Comment thread src/main/resources/application-local.yml Outdated

@laura-jung laura-jung left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

일단 수정해주세요

Comment on lines +34 to +35
private static final String GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
private static final String CALENDAR_SCOPE = "https://www.googleapis.com/auth/calendar.readonly email";

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

이거 하드코딩해도 되는 값들 맞지요? 아니라면 환경변수처리해주세용

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

GOOGLE_AUTH_URL은 구글의 고정 엔드포인트이고 CALENDAR_SCOPE
요청할 권한 값으로 둘 다 고정된 정책값이라 환경변수로 처리하기보다는 상수로
그대로 유지할까 합니다..!!

Comment on lines 12 to +17
CALENDAR_401_AUTH_FAILED(HttpStatus.UNAUTHORIZED, "CALENDAR_401", "구글 캘린더 인증에 실패했습니다."),
CALENDAR_401_EMAIL_MISMATCH(HttpStatus.UNAUTHORIZED, "CALENDAR_401", "가입 시 사용한 구글 계정으로만 연동할 수 있습니다."),
CALENDAR_404_NOT_CONNECTED(HttpStatus.NOT_FOUND, "CALENDAR_404", "연동된 캘린더가 없습니다."),
CALENDAR_409_ALREADY_CONNECTED(HttpStatus.CONFLICT, "CALENDAR_409", "이미 캘린더가 연동되어 있습니다."),
CALENDAR_500_REVOKE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "CALENDAR_500", "구글 토큰 해제에 실패했습니다.");
CALENDAR_500_REVOKE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "CALENDAR_500", "구글 토큰 해제에 실패했습니다."),
CALENDAR_503_TIMEOUT(HttpStatus.SERVICE_UNAVAILABLE, "CALENDAR_503", "구글 서버 응답이 지연되고 있습니다. 잠시 후 다시 시도해주세요.");

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

p3) 에러코드 네이밍 안에 숫자 넣지 말아주세요

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

넵넵 반영해서 수정했습니당

@Value("${app.calendar.redirect-uri}")
private String redirectUri;

@GetMapping("/authorize")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

p1)

현재 /authorize는 인증이 필요한 API이므로 클라이언트가 Axios/fetch를 통해 Authorization 헤더를 붙여 호출할 것으로 보입니다.

다만 Axios/fetch가 서버의 302 응답을 따라가더라도 이는 백그라운드 HTTP 요청일 뿐, 브라우저의 현재 화면이 Google OAuth 동의 화면으로 이동하지 않습니다. 반대로 window.location.assign("/api/v1/users/calendar/authorize")으로 직접 이동하면 Axios 인터셉터가 적용되지 않아 Bearer 토큰을 전달할 수 없습니다.

따라서 현재 헤더 기반 인증 구조에서는 이 API가 직접 302 리다이렉트하기보다 Google 인증 URL을 응답으로 반환하고, 프론트에서 해당 URL로 이동시키는 방식이 적절해 보입니다.

const { data } = await axios.get("/api/v1/users/calendar/authorize");
window.location.assign(data.authorizationUrl);

추가로 인증 URL을 생성할 때 사용자와 OAuth 응답을 연결할 수 있도록 일회성 state 값을 함께 발급하고, 콜백 시 검증하는 것도 필요해 보입니다.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

움 정리해보자면, 현재 Bearer 토큰 기반 인증 구조에서 axios로 이 API를 호출하면 axios는 302 응답을 백그라운드로만 따라가서 실제 브라우저 화면은 이동하지 않고, 반대로 window.location으로 직접 이동시키면 Authorization 헤더가 실리지 않아 401이 나는 문제가 생기는 거군요..!! 말씀해주신대로 서버가 302로 직접 리다이렉트 하는 대신 구글 인증 URL을 JSON으로 응답하도록 변경하여 프론트쪽에서 해당 주소로 리다이렉트 시키는 방식으로 변경했습니다..!!

state는 아래 코드리뷰 방식에 따라서 추가하여 반영햇습니다..!!


@GetMapping("/authorize")
public void authorize(HttpServletResponse response) throws IOException {
String url = GOOGLE_AUTH_URL

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

p2) OAuth 인증 URL에 state가 포함되어 있지 않아, Google에서 돌아온 authorization code가 현재 로그인한 사용자가 시작한 연동 흐름에서 발급된 것인지 검증할 수 없습니다.
현재 이메일 일치 검증이 일부 계정 연결 공격을 차단하지만, OAuth CSRF 방어를 위해 사용자별로 예측 불가능한 일회성 state를 발급하고 콜백에서 검증하는 것이 필요해 보입니다. 예를 들어 Redis에 state → userId를 짧은 TTL로 저장하고, 연동 완료 요청에서 검증 후 즉시 삭제할 수 있습니다.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

오호 그러네용 지금까지는 authorizationCode가 실제로 현재 로그인한 사용자가 시작한 연동 흐름에서 나온 것인지 검증할 방법이 없어 이메일 일치 검증에만 의존하고 있었습니다!

말씀 주신대로 Redis에 state(UUID) -> userId를 5분 TTL로 저장하고, 연동 완료 요청(connect) 시 state를 함께 받아 검증 후 즉시 삭제하도록 구현하였습니다..!!

Comment on lines +36 to +40
@Column(name = "access_token", nullable = false, length = 2048)
private String accessToken;

@Column(name = "refresh_token", length = 2048)
private String refreshToken;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

p1) accessToken과 특히 장기간 유효할 수 있는 refreshToken이 DB에 평문으로 저장되고 있습니다. DB 조회 권한이나 백업 데이터가 유출될 경우 연결된 사용자의 Google Calendar에 접근할 수 있어 보안상 위험이 큽니다.
캘린더 API 호출을 위해 토큰을 복원해야 하므로 단방향 해싱보다는 애플리케이션 레벨의 양방향 암호화가 필요합니다. 최소한 AES-GCM 등으로 암호화하고, 암호화 키는 환경변수 또는 Secret Manager/KMS처럼 DB와 분리된 저장소에서 관리하는 방향을 권장합니다. 토큰이 로그나 예외 메시지에 포함되지 않는지도 함께 확인하면 좋겠습니다.

Comment on lines +57 to +69
String accessToken = calendarConnection.getAccessToken();

deleteConnection(calendarConnection);

try {
googleOAuthClient.revokeToken(accessToken);
} catch (Exception e) {
log.warn("Google token revoke failed after disconnect. userId={}", userId, e);
}

return CalendarDisconnectResponse.builder()
.calendarConnected(false)
.build();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

p2) 연동 해제 시 장기간 유효한 refresh token은 사용하지 않고 access token만 revoke하고 있습니다. Access token이 이미 만료된 경우를 고려해 refresh token을 우선 revoke하고, 없을 때만 access token을 사용하는 것이 안전해 보입니다.
또한 현재는 DB 정보를 먼저 삭제한 뒤 revoke 실패를 무시하기 때문에, Google 요청 실패 시 토큰을 잃어버려 재시도할 수 없습니다. revoke 성공 후 삭제하거나, 해제 대기 상태/별도 작업으로 저장해 재시도할 수 있는 구조가 필요해 보입니다.
토큰을 쿼리 문자열에 직접 넣으면 프록시나 APM 로그에 노출될 수 있으므로 form-urlencoded body로 전달하는 것도 권장합니다.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

넵 3가지 모두 반영하였습니다..!!

  1. refresh token 우선 revoke
  2. revoke 실패 시 재시도 가능한 구조
  3. forrm-urlencoded body 전달

private final GoogleOAuthClient googleOAuthClient;
private final CalendarConnectionCommandService commandService;

public CalendarConnectResponse connect(Long userId, CalendarConnectRequest request) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

p3) existsByUserId() 확인과 실제 저장이 원자적으로 처리되지 않아 동시 요청 두 개가 모두 사전 검사를 통과할 수 있습니다. DB의 user_id unique 제약조건 덕분에 중복 저장은 방지되지만, 나중에 저장하는 요청은 CALENDAR_ALREADY_CONNECTED가 아니라 unique 위반에 의한 500으로 응답할 가능성이 있습니다.
unique 제약조건은 유지하되 저장 시 발생하는 해당 제약조건 위반을 409로 변환하거나, 사용자별 연동 요청을 선점/멱등 처리하는 방식이 필요해 보입니다. existsByUserId()는 빠른 사전 검사로는 사용할 수 있지만 동시성 보장 수단으로는 충분하지 않습니다.

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[feat] 구글 캘린더 연동/해제

2 participants