Skip to content

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

Merged
Jy000n merged 62 commits into
developfrom
feat/#40-google-calendar
Jul 14, 2026
Merged

Jy000n merged 62 commits into
developfrom
feat/#40-google-calendar

Conversation

@Jy000n

@Jy000n Jy000n commented Jul 8, 2026 •

Copy link
Copy Markdown
Member

관련 이슈 🛠

작업 내용 요약 ✏️

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

주요 변경 사항 🛠️

  • [CalendarController]: POST /api/v1/users/calendar(연동), DELETE /api/v1/users/calendar(해제) API 엔드포인트 추가
  • [CalendarService]: 연동/해제 비즈니스 로직 구현 (이미 연동된 경우 예외 처리, 가입 이메일과 구글 인증 이메일 일치 검증, revoke 처리)
  • [CalendarConnection]: calendar_connections 테이블 매핑 엔티티 추가 (access_token, refresh_token은 AES-GCM으로 암호화하여 저장)
  • [CalendarConnectionCommandService]: self-invocation으로 인한 @transactional 무효화 문제 방지를 위해 saveConnection, deleteConnection 트랜잭션 로직을 별도 서비스로 분리
  • [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 문서화를 위한 인터페이스 추가
  • [AesGcmConverter]: access_token/refresh_token 암호화를 위한 JPA AttributeConverter 추가 (암호화 키는 환경변수로 분리)
  • [CalendarAuthorizeResponse]: authorize API의 JSON 응답 DTO 추가
  • [CalendarConnectRequest]: state 필드 추가 (OAuth CSRF 방어)

트러블 슈팅 ⚽️

  • 유저가 로그인 계정과 다른 구글 계정으로 연동을 시도할 수 있어, 구글 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() 호출 시 accessToken이 null로 전달되어 Authorization: Bearer null 헤더가 생성되고, 구글이 401(UNAUTHENTICATED)로 거부하는 문제였음. 각 필드에 @JsonProperty를 명시하여 해결
    2. 2차 원인: 필드 매핑을 고친 후에도 동일한 401이 재현됨. 원인은 OAuth 인가 코드 발급 시 calendar.readonly 스코프만 요청하고, 사용자 정보 조회에 필요한 email(userinfo) 스코프를 함께 요청하지 않았기 때문. 캘린더 접근 권한만 있는 토큰으로는 userinfo 엔드포인트 호출이 거부됨을 확인. 인가 요청 시 calendar.readonly와 email 스코프를 함께 요청하도록 테스트 절차를 수정하여 해결
  • (참고) 디버깅 과정에서 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 포함)을 선택함

Bearer 토큰 인증 구조에서 /authorize API가 302 리다이렉트로 동작할 수 없는 문제

  • 초기 구현에서는 GET /users/calendar/authorize가 서버에서 직접 302로 구글 인증 화면으로 리다이렉트하는 방식이었으나 코드 리뷰를 통해 헤더 기반(Bearer 토큰) 인증 구조와 이 방식이 근본적으로 충돌한다는 점을 확인
  • axios/fetch로 이 API를 호출하면 Authorization 헤더는 정상적으로 실리지만, 서버의 302 응답은 axios가 백그라운드 요청으로만 따라가고 실제 브라우저 화면은 이동하지 않음
  • 반대로 window.location으로 직접 이 API를 호출하면 브라우저 화면은 정상적으로 이동하지만, 이 경우 axios 인터셉터를 거치지 않아 Authorization 헤더가 실리지 않아 인증에 실패함
  • 서버가 302로 직접 리다이렉트하는 대신 조립된 구글 인증 URL을 JSON 응답(authorizationUrl)으로 반환하고, 프론트가 이 URL을 받아 window.location.assign()으로 직접 이동시키는 방식으로 변경하여 해결

OAuth 흐름에서 CSRF 공격 방어 수단 부재

  • 기존 구현은 authorizationCode가 실제로 현재 로그인한 사용자가 시작한 연동 흐름에서 발급된 것인지 검증할 방법이 없었고 가입 이메일 일치 검증만으로는 일부 계정 연결 공격을 완전히 방어하기 어렵다는 점을 코드 리뷰로 확인
  • OAuth 표준 방어 방식인 state 파라미터를 도입: authorize 호출 시 Redis에 state(UUID) → userId를 5분 TTL로 저장하고 POST /users/calendar 요청 시 이 state를 함께 전달받아 검증 후 즉시 삭제하도록 구현

access_token/refresh_token 평문 저장 및 연동 해제 시 토큰 유실 가능성

  • calendar_connections 테이블에 구글 access_token/refresh_token이 평문으로 저장되고 있어 DB 접근 권한이나 백업 데이터 유출 시 연동된 사용자의 Google Calendar에 접근 가능한 보안 위험을 코드 리뷰로 확인
  • JPA AttributeConverter(AES-GCM)를 도입하여 저장 시 자동 암호화, 조회 시 자동 복호화되도록 수정. 암호화 키는 환경변수로 분리하여 코드/설정 파일에 노출되지 않도록 처리
  • 또한 연동 해제(disconnect) 시 DB 삭제를 먼저 수행하고 Google 토큰 revoke 실패는 로그만 남기던 기존 방식이 revoke 실패 시 토큰 정보가 이미 삭제되어 재시도가 불가능한 문제가 있음을 확인 -> revoke 성공 시에만 DB 삭제가 진행되도록 순서를 되돌리고 access token이 만료됐을 가능성을 고려해 refresh token을 우선 revoke하도록 수정
  • 토큰 전달 방식도 쿼리스트링에서 form-urlencoded body로 변경하여 프록시/APM 로그에 토큰이 노출될 가능성을 제거

동시 요청 시 존재 여부 사전 검사와 실제 저장 사이의 race condition

  • existsByUserId()로 사전 검사 후 저장하는 구조에서, 동시에 두 요청이 들어오면 둘 다 사전 검사를 통과할 수 있고 DB 유니크 제약으로 나중 요청은 저장이 실패하지만 이 경우 의도한 409가 아니라 500(unique violation)으로 응답될 수 있는 문제를 확인
  • save()를 saveAndFlush()로 변경하여 저장 시점에 즉시 유니크 제약 위반을 확인하고 DataIntegrityViolationException 발생 시 CALENDAR_409_ALREADY_CONNECTED로 변환하도록 수정하여 동시 요청 상황에서도 일관되게 409가 보장되도록 처리

테스트 결과 📄

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

스크린샷 📷

캘린더 연동 성공, calendarConnected: true 및 calendarEmail 반환 확인
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)를 발급받을 방법 자체가 없어 캘린더 연동 흐름을 시작할 수 없는 상태였습니다.
    • 최초에는 서버가 구글 인증 URL로 302 리다이렉트하는 방식으로 구현했으나, Bearer 토큰 기반 인증 구조에서는 axios가 302를 백그라운드로만 따라가 실제 화면이 이동하지 않는 문제가 있어(반대로 window.location으로 직접 이동하면 Authorization 헤더가 실리지 않음), 서버가 client_id, scope(calendar.readonly + email), redirect_uri, state를 포함한 구글 인증 URL을 조립하여 JSON(authorizationUrl)으로 응답하는 방식으로 변경했습니다.
    • 프론트는 이 URL을 받아 window.location.assign() 등으로 직접 이동시켜야 합니다.
    • 이에 따라 캘린더 연동 API 명세서에도 이 API가 신규 추가되었으니 참고해주시면 감사하겠습니다.
  2. 기존 POST /api/v1/users/calendar(토큰 교환), DELETE /api/v1/users/calendar(연동 해제) API는 로직 변경 없이 그대로 유지됩니다.

  3. 회원 탈퇴 시 calendar_connections에 고아 데이터가 남지 않도록, CalendarConnection.user 필드의 @JoinColumn에 foreignKeyDefinition을 추가하여 ON DELETE CASCADE 제약이 걸리도록 했습니다.

    • prod가 ddl-auto: update로 동작함을 확인하여, 별도의 수동 마이그레이션 없이 이번 배포 시 테이블 생성과 함께 FK 제약(CASCADE)도 자동으로 반영됩니다.
    • 이 제약과 별개로, 구글 토큰 revoke는 DB가 대신할 수 없어 AuthService.withdraw()에 애플리케이션 레벨로 추가했습니다.

📎 참고 자료 (선택)

Summary by CodeRabbit

Summary by CodeRabbit

  • 새로운 기능
    • Google 캘린더 연동(인증 URL 발급, 연동 연결/해제) API 추가 및 연동 결과(연동 여부/캘린더 이메일/연결 시각) 제공
  • 버그 수정
    • OAuth 상태 검증 강화, 중복 연동 방지, 이메일 불일치/연동 해제 시나리오 처리 기준 개선
    • 계정 삭제 시 캘린더 토큰 리보크를 자동 시도
  • 설정/보안
    • 캘린더 토큰 암호화 키 및 리다이렉트 URL 설정 분리
    • CORS 허용 메서드에 PATCH 추가
  • 문서화
    • OpenAPI 스키마의 필수(required) 표기 일관화 및 기본 JSON 미디어 타입 지정
  • 기타
    • 온보딩 응답에 약관 동의 여부 반영, 잘못된 시간 오류 코드 조정

@coderabbitai

coderabbitai Bot commented Jul 8, 2026 •

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

구글 OAuth 기반 캘린더 연결·해제 기능과 토큰 암호화, 탈퇴 시 토큰 폐기, 관련 API 응답 및 설정 변경이 추가되었습니다. 여러 응답 DTO의 OpenAPI 필수 필드 표기와 온보딩 약관 동의 상태도 갱신되었습니다.

Changes

구글 캘린더 연동/해제

Layer / File(s) Summary
캘린더 계약과 저장 모델
.../calendar/dto/..., .../calendar/exception/*, .../calendar/entity/CalendarConnection.java, .../calendar/repository/*
캘린더 요청·응답, Google OAuth DTO, 성공·오류 코드, 연결 엔티티와 사용자별 조회 리포지토리를 정의합니다.
캘린더 토큰 암호화
.../global/crypto/*, .../application-*.yml
캘린더 토큰을 AES/GCM 및 Base64로 저장·복호화하고 환경별 암호화 키 설정을 추가합니다.
Google OAuth 클라이언트
.../calendar/client/GoogleOAuthClient.java
토큰 교환, 사용자 정보 조회, 토큰 폐기와 타임아웃·예외 매핑을 구현합니다.
연결·해제 서비스 흐름
.../calendar/service/*
OAuth state를 Redis에서 관리하고 사용자·이메일·중복 연결을 검증한 뒤 연결 저장 또는 토큰 폐기와 삭제를 수행합니다.
캘린더 API와 탈퇴 연계
.../calendar/controller/*, .../calendar/factory/*, .../global/auth/service/AuthService.java
인증 URL 발급과 연결·해제 API를 서비스에 연결하고, 탈퇴 전에 저장된 캘린더 토큰을 폐기하도록 구성합니다.
API 문서 및 사용자 상태 갱신
.../dto/response/*, .../domain/user/*, .../global/config/SecurityConfig.java, .../application.yml
응답 필드의 OpenAPI required 표기를 확대하고, 약관 동의 상태·태그 처리·CORS PATCH·JSON 미디어 타입 설정을 갱신합니다.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant CalendarController
  participant CalendarService
  participant GoogleOAuthClient
  participant GoogleOAuth
  participant CalendarConnectionRepository

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

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

Possibly related PRs

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

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Out of Scope Changes check ⚠️ Warning 캘린더 기능 외에 다수의 비관련 Swagger 스키마 수정과 SecurityConfig, onboarding/user/auth 변경이 포함되어 범위를 벗어납니다. 캘린더 연동/해제에 직접 필요한 변경만 남기고, 비관련 DTO 스키마·보안·온보딩 변경은 별도 PR로 분리하세요.
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 (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed 제목이 구글 캘린더 연동/해제 기능이라는 핵심 변경을 정확하고 간결하게 요약합니다.
Linked Issues check ✅ Passed 연동/해제 API, OAuth state 검증, 토큰 교환·조회·revoke, 연결 저장/삭제가 모두 구현되어 이슈 목표를 충족합니다.
✨ 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.

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

궁금한 점들이 있어 코멘트 남겨두었습니다. 같이 확인하고 머지합시다.

import org.springframework.stereotype.Component;

@Component
public class CalendarResponseFactory {

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.

따로 factory로 분리한 이유가 있나요?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

분리할 필요 없습니다..!! 저번에 Auth 쪽 응답 조립 로직을 Factory로 분리하면서 그 패턴을 그대로 따라가려고 Calendar에도 같이 만들었던 것 같습니다. Auth의 경우 쿠키 헤더 조립 등 복잡한 로직이 있어 분리할 가치가 있었지만, Calendar는 단순 ResponseEntity 생성 수준이라 분리 실익이 없어 Controller로 다시 합쳤습니다.

Comment on lines 79 to 80
@Column(name = "terms_agreed", nullable = false)
private boolean termsAgreed;

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
Member Author

Choose a reason for hiding this comment

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

이용약관 동의 여부를 저장하는 필드입니다. 회원가입(구글 로그인) 시점에는 false로 시작하고, 온보딩 완료 API(POST /users/onboarding) 호출 시점에 자동으로 true로 변경됩니다.

별도의 약관 동의 체크박스나 API는 없고 "온보딩을 완료했다 = 이용약관에 동의했다"는 정책으로 처리하고 있다고 하여 DB에 일단 같이 저장했는데 필요가 없을까요..??

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.

그때 기획측에서 db에 남았으면 좋겠다고 말씀하셨던 것 같네요!

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
Member Author

Choose a reason for hiding this comment

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

Google Calendar access_token, refresh_token을 DB에 평문으로 저장하고 있던 것을 코드 리뷰를 받고 추가한 암호화 컨버터입니다.

  • JPA AttributeConverter를 구현해서 CalendarConnection 엔티티의 accessToken/refreshToken 필드에 @convert(converter = AesGcmConverter.class)만 붙이면 저장 시 자동 암호화, 조회 시 자동 복호화되도록 만들었습니다.
  • 암호화 알고리즘은 AES-GCM을 사용했습니다. 저장 시 매번 랜덤 초기화벡터 IV(암호화할 때 매번 다르게 넣어주는 랜덤 값)를 생성해서 암호문 앞에 붙여 저장하고(IV는 매번 달라야 안전) 복호화 시 그 IV를 다시 꺼내서 사용합니다.
  • 암호화 키(secretKey)는 정적 필드로 두고 @value로 환경변수 (security.crypto.calendar-token-key)를 주입받도록 했습니다. AttributeConverter는 JPA가 자체적으로 인스턴스화하는 경우가 많아 일반적인 생성자 주입이 어려워서, static 필드 + setter 주입 방식으로 우회했습니다.
  • 키 자체는 32바이트(256비트) 랜덤 값을 Base64로 인코딩해서 사용하며, 코드나 yml에 하드코딩하지 않고 환경변수로만 관리합니다.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

(정리하자면)

  • setSecretKey(String base64Key)
    : 암호화에 쓸 "비밀 열쇠" 세팅 (application.yml에 숨겨둔 비밀키를 가져와서 컴퓨터가 암호화할 때 직접 사용할 수 있는 정식 비밀 열쇠(secretKey) 형태로 만들어서 보관
  • convertToDatabaseColumn(String plainText)
    : 원본 데이터가 DB에 들어가기 직전에 자동으로 실행
    1. 매번 다른 무작위 일련번호(IV) 하나 생성
    2. 비밀 열쇠와 IV를 섞어 원본 데이터를 해커가 못 알아보는 암호문으로 바꿈
    3. 나중에 풀 수 있도록 [일련번호 + 암호문]을 한 세트로 합쳐서 DB에 저장
  • convertToEntityAttribute(String encoded)
    : DB에서 데이터를 꺼내 우리 눈에 보이는 자바 변수에 담기 직전에 자동으로 실행됨


security:
crypto:
calendar-token-key: ${CALENDAR_TOKEN_ENCRYPTION_KEY}

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.

이거 .env파일에 들어가야하는 값인가요?

@Jy000n Jy000n Jul 13, 2026 •

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

넵 맞습니다. CALENDAR_TOKEN_ENCRYPTION_KEY는 .env(로컬) 및 배포 환경변수(.env.prod 등)에 값을 넣어주셔야 하는 항목입니다.

이 키는 구글 캘린더 API를 호출할 수 있는 구글의 OAUTH 토큰을 복호화할 수 있는 유일한 값이라, 이 키만 있으면 DB에 저장된 모든 사용자의 Google Calendar 토큰을 복호화해서 실제로 그 사람의 캘린더에 접근할 수 있게 됩니다. 즉 암호화를 적용한 의미 자체가 이 키의 보안에 달려 있습니다.

@aneykrap aneykrap 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.

끝까지 홧팅!

} catch (Exception e) {
log.warn("회원 탈퇴 시 캘린더 토큰 revoke 실패. userId={}", userId, e);
}
});

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) 지금 withdraw()는 Google revoke가 실패해도 회원 삭제를 그대로 진행하는데 이때 ON DELETE CASCADE 때문에 calendar_connections row도 같이 사라지는걸로 이해했습니다. 그러면 서버는 어떤 토큰 revoke가 실패했는지조차 다시 알 수 없어서 나중에 재시도할 방법이 없어지지 않을까용 결과적으로 우리 서비스에서는 탈퇴가 끝났지만 Google 쪽 권한은 남아 있는 상태가 생길 수 있을 것 같아요!

특히 이번 PR에서 disconnect()는 revoke 실패 후 토큰을 잃어버리지 않도록 신경 쓴 만큼 withdraw()도 같은 기준으로 맞춰 두는 건 어떨까요? 외부 Google revoke 호출은 DB처럼 롤백할 수 있는 작업이 아니어서 사용자 삭제 트랜잭션과 분리하고 실패 시 다시 시도할 수 있게 남겨 두는 구조가 더 적절하지 않으까 생각해용

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

오홍 그러네요 감사합니당
말씀하신대로 withraw()가 disconnect()와 다른 기준으로 되어 있어 토큰을 잃어버리는 문제가 있었습니다..!!

revokeCalendarConnectionIfExists에서 예외를 삼키던 catch를 제거하여 revoke 실패 시 예외가 그대로 전파되고 @transactional에 의해 회원 탈퇴 자체가 콜백되도록 수정하였습니다! disconnect()와 동일하게 'revoke 성공 시에만 다음 단계 진행' 기준으로 변경하였습니다.

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

코멘트 확인 부탁드립니다!!

) {

if (!termsAgreed) {
throw new CustomException(UserErrorCode.TERMS_AGREEMENT_REQUIRED);

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) 해당 코드가 userErrorCode에 존재하지 않는 것 같은데 확인한번 부탁드립니다

@Jy000n Jy000n Jul 13, 2026 •

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

헉 누락이네요.. 다시 추가했습니다

@Jy000n Jy000n Jul 13, 2026 •

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

아래를 코드리뷰를 반영하며 수정하다 이전의 PR 코드를 봤는데 머지과정에서 잘못 들어가있는 애인 것 같아서 삭제했습니다.

온보딩 완료 = 약관 동의 흐름이기 때문에 검증 로직 필요 없이 completeOnboarding 내에서 자동으로 true 처리 하도록 하였습니다.

Comment on lines 38 to 40
data:
redis:
host: ${REDIS_HOST:redis}

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) 현재 data가 security 안에 있는 것 같습니다. 들여쓰기 주의해서 security 작성해주세요!

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

넵 확인했습니다 알겠습니당!!

@Column(name = "calendar_email")
private String calendarEmail;

@Column(name = "terms_agreed", nullable = false)

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.

여기 termsAgreed를 true로 변환하는 로직이 있나요?

@Jy000n Jy000n Jul 13, 2026 •

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

원래는 completeOnboarding() 메서드에 this.termsAgreed = true; 해당 코드가 들어있었는데 충돌 해결 과정에서 사라진 것 같습니다. 다시 추가했습니다..

private final CalendarConnectionRepository calendarConnectionRepository;

@Transactional
public CalendarConnectResponse saveConnection(

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)
새로운 연동 정보는 CalendarConnectionCommandService 에만 저장합니다. 하지만 [UserProfileResponse)는 User.calendarConnected와 User.calendarEmail을 읽습니다. 이 필드들은 연동 시 갱신되지 않아 계속 false/null입니다.
캘린더 연동 상태의 원천이 CalendarConnection과 User 두 곳으로 나뉘어 불일치가 발생합니다. 하나를 단일 원천으로 정하고, 프로필 응답도 CalendarConnection 존재 여부에서 계산하거나 연동/해제 시 User 필드를 함께 갱신해야 합니다.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

생각치 못했던 부분이네용,,

두 곳에 상태가 나뉘어 있어 불일치가 발생하여, User의 calendarConnected, calendarEmail 필드를 제거하고, 프로필 응답 시 CalendarConnectionRepository에서 존재 여부를 조회해서 계산하도록 단일 원천으로 통일했습니당

Comment on lines +63 to +71
public void validateState(Long userId, String state) {
String key = "calendar:oauth:state:" + state;
String savedUserId = redisTemplate.opsForValue().get(key);

if (savedUserId == null || !savedUserId.equals(String.valueOf(userId))) {
throw new CustomException(CalendarErrorCode.CALENDAR_STATE_MISMATCH);
}
redisTemplate.delete(key);
}

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) state는 한 번만 소비되어야 하므로 GET과 DELETE를 분리하기보다 Redis GETDEL 또는 Lua script를 사용해 원자적으로 검증·삭제해야할 것 같습니다.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

넵 반영하도록 하겠습니다!!


try {
restClient.post()
.uri("https://oauth2.googleapis.com/revoke?token=" + token)

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) form body를 추가했지만 토큰이 쿼리 문자열에도 남아 있어 프록시/APM 로그 노출 위험이 그대로입니다. URI에서는 query parameter를 제거하고 form-urlencoded body로만 전달해야할 것 같습니다.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

맞습니다. URI에서 쿼리파라미터를 안전히 제거하고 fom-urlencoded body로만 전달하도록 수정하였습니다 감사합니다:)

@aneykrap aneykrap 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.

리뷰내용 겹치는것도 있고 궁금한 점들도 잇어서 남겨두었습니다. 확인부탁드려용!
홧팅입니다


try {
restClient.post()
.uri("https://oauth2.googleapis.com/revoke?token=" + token)

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.

윤아님이랑 같은 의견입니당
. body의 token만 보내고 URI 쪽 query parameter는 제거해야 할 것 같아요!

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

넵넵 반영하였습니당 감사합니당

private final CalendarConnectionRepository calendarConnectionRepository;

@Transactional
public CalendarConnectResponse saveConnection(

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) 캘린더 연동 정보의 저장 원천이 CalendarConnection으로 바뀌었는데 UserProfileResponse는 여전히 User.calendarConnected와 User.calendarEmail을 읽고 있는 것 같아요! 이 상태면 연동 API는 성공했는데 프로필 응답은 계속 false/null을 반환하는 불일치가 생길것 같습니다.

연동 여부를 판단하는 기준을 하나로 통일해야 할것 같아요

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

넵넵 위와 같은 답글이기는 한데,

두 곳에 상태가 나뉘어 있어 불일치가 발생하여 User의 calendarConnected, calendarEmail 필드를 제거하고, 프로필 응답 시 CalendarConnectionRepository에서 존재 여부를 조회해서 계산하도록 단일 원천으로 통일했습니당

Comment on lines +9 to +16
public record CalendarConnectResponse(
boolean calendarConnected,
String calendarEmail,

@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
@Schema(example = "2026-07-06 17:51:50", type = "string")
LocalDateTime connectedAt
) {}

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.

p4) connectedAt이 응답 DTO로 그대로 클라이언트에 내려가는 값 같은데 현재 포맷이 yyyy-MM-dd HH:mm:ss라 타임존 정보는 포함되지 않는 것 같아서용 의도적으로 로컬 시간 문자열로 내리는 걸까용?

@Jy000n Jy000n Jul 13, 2026 •

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

아니요.. 타임존으로 내려야 합니다..

@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss'Z'", timezone = "UTC")를 추가해서 프론트에서 UTC 기준이라는 설명을 알 수 있도록 하였습니다..!!

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

흠 여쭤보고 싶은 것이 있는데요, 그러면 다른 response에도 다 UTC를 명시적으로 추가해줘야하는 걸까요??

@Jy000n

Jy000n commented Jul 13, 2026

Copy link
Copy Markdown
Member Author
image 혹시 보셨셨을까용,,

@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 106 to +112
@Transactional
public void withdraw(String accessToken, Long userId, String sessionId) {

User user = userRepository.findById(userId)
.orElseThrow(() -> new CustomException(UserErrorCode.USER_NOT_FOUND));

revokeCalendarConnectionIfExists(userId);

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) 외부 Google API 호출을 DB 트랜잭션 내부에서 수행하고 있어 Google 장애 시 회원 탈퇴 자체가 실패하고, revoke 이후 DB/Redis 작업 실패 시 롤백할 수 없는 외부 상태와 DB 상태가 불일치할 수 있습니다. 트랜잭션에서는 사용자 삭제와 revocation 작업을 함께 저장하고, 커밋 이후 outbox/비동기 작업으로 revoke를 재시도하는 구조가 안전해 보입니다.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

말씀주신대로 외부 API 호출이 트랜잭션 안에 있어서 생기는 리스크를 확인했습니다.

Google API 호출을 트랜잭션에서 완전히 분리하고, revoke 대상 토큰을 CalendarRevocationOutbox 테이블에 기록하는 방식으로수정하였습니다.
-> 회원 탈퇴 transaction은 DB 작업만으로 끝나서 Google 장애와 무관하게 항상 성공하고, 별도 스케줄러가 1분 주기로 outbox를 읽어 revoke를 재시도하도록 구현하였습니다.

? calendarConnection.getRefreshToken()
: calendarConnection.getAccessToken();

googleOAuthClient.revokeToken(tokenToRevoke);

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) Google revoke와 DB 삭제는 하나의 트랜잭션으로 묶을 수 없으므로 중간 실패 상태를 고려해야 합니다. DISCONNECT_PENDING 상태 또는 revocation outbox를 저장한 후 비동기로 revoke하고 최종 삭제하는 방식이 안전합니다.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

위와 동일한 이유로 CalendarService.disconnect()도 revoke를 즉시 호출하지 않고 CalendarRevocationOutbox에 기록만 하도록 변경했습니다. DB 삭제와 outbox 기록은 같은 트랜잭션 안에서 원자적으로 처리되고, 실제 Google revoke는 별도 스케줄러가 비동기로 처리하며 실패 시 최대 5회까지 재시도합니다. 반영했습니다!

this.accessToken = accessToken;
this.refreshToken = refreshToken;
this.tokenExpiresAt = tokenExpiresAt;
this.connectedAt = LocalDateTime.now();

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) 현재 시간대 정보가 잘못 표출 될 수 있는 위험이 있을 것 같습니다. 본 서비스의 경우 timezone을 받고 있으니까 zoneId를사용해보는게 어떨까요?

@Jy000n Jy000n Jul 14, 2026 •

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

(대면으로 말씀드린 이야기) 일단 이렇게 가고 zoneId로 변경해서 response 내려주는 건 따로 이슈->PR로 전체적으로 다 수정하겠습니다!!

Comment on lines +53 to +55
return ResponseEntity.ok(
BaseResponse.onSuccess(CalendarSuccessCode.CALENDAR_CONNECTED, response)
);

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) 연동 성공 응답이 실제 HTTP 200이지만 본문과 Swagger에는 201로 정의되어 있습니다. 생성 API로 201을 사용할 예정이라면 ResponseEntity.status(HttpStatus.CREATED)로 반환하고, 200을 사용할 예정이라면 success code와 문서를 함께 수정해야 합니다.

@Jy000n Jy000n Jul 14, 2026 •

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

API 명세서 내에서도 success code가 상충되어 있었습니다.. 반영해서 201로 통일하도록 하겠습니다 (API 명세서도 수정)

@aneykrap aneykrap 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 +53 to +56
return ResponseEntity.ok(
BaseResponse.onSuccess(CalendarSuccessCode.CALENDAR_CONNECTED, response)
);
}

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) Swagger 문서에는 캘린더 연동 성공 응답이 201로 되어 있는데 실제 구현은 ResponseEntity.ok(...)라 200이 내려가고 있는것 같습니다. 나중에 혼선이 생길 수 있어서 문서와 구현 중 하나로 맞추는게 좋아보여요

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

맞아용 201로 수정했씀다

security = @SecurityRequirement(name = "bearerAuth")
)
@ApiResponses({
@ApiResponse(responseCode = "201", description = "연동 성공", useReturnTypeSchema = true),

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.

실제 구현은 ResponseEntity.ok(...)라 200이 내려가고 있어요! 한번 확인 부탁드려요

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

201로 통일했씀당

boolean calendarConnected,
String calendarEmail,

@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss'Z'", timezone = "UTC")

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) CalendarConnection에서 connectedAt을 LocalDateTime.now()로 저장하고 있어서 현재 값 자체는 서버 로컬 시간 기준으로 들어가는데 CalendarConnectResponse에서는 @jsonformat(pattern = "yyyy-MM-dd'T'HH:mm:ss'Z'", timezone = "UTC")로 직렬화하고 있어서 클라이언트에는 UTC 시각처럼 내려가고 있는 것 같아요.

실제로는 시간대 정보가 없는 로컬 시각인데 응답 포맷만 UTC처럼 보이면 클라이언트가 절대시각으로 오해할 수 있어서 진짜 UTC로 내려줄 의도라면 Instant/OffsetDateTime처럼 시간대 포함 타입으로 맞추고 아니라면 Z/UTC 표시는 제거하는 게 좋지 않을까요

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

시간 response 관련해서는 전체적으로 다 zoneId를 적용하여 변경을 해야될 것 같습니다. 이슈 파서 다시 PR 올릴 때 한 번에 수정해보도록 하겠습니다 ..

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

머지를 해봅시다..... 리뷰 반영하느라 수고했어용

@aneykrap aneykrap 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.

확인했습니다! 고생햇어융 짜윤이

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

Labels

✨ feat slack-approval-notified Slack 승인 완료 알림 중복 방지용 라벨 🌸 자윤

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

3 participants