[feat] #40 - 구글 캘린더 연동/해제 - #44
Conversation
…to feat/#40-google-calendar
|
Note Reviews pausedIt 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 Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
Walkthrough구글 OAuth 기반 캘린더 연결·해제 기능과 토큰 암호화, 탈퇴 시 토큰 폐기, 관련 API 응답 및 설정 변경이 추가되었습니다. 여러 응답 DTO의 OpenAPI 필수 필드 표기와 온보딩 약관 동의 상태도 갱신되었습니다. Changes구글 캘린더 연동/해제
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)
Possibly related PRs
Suggested labels: 🚥 Pre-merge checks | ✅ 3 | ❌ 2❌ Failed checks (2 warnings)
✅ Passed checks (3 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
laura-jung
left a comment
There was a problem hiding this comment.
궁금한 점들이 있어 코멘트 남겨두었습니다. 같이 확인하고 머지합시다.
| import org.springframework.stereotype.Component; | ||
|
|
||
| @Component | ||
| public class CalendarResponseFactory { |
There was a problem hiding this comment.
따로 factory로 분리한 이유가 있나요?
There was a problem hiding this comment.
분리할 필요 없습니다..!! 저번에 Auth 쪽 응답 조립 로직을 Factory로 분리하면서 그 패턴을 그대로 따라가려고 Calendar에도 같이 만들었던 것 같습니다. Auth의 경우 쿠키 헤더 조립 등 복잡한 로직이 있어 분리할 가치가 있었지만, Calendar는 단순 ResponseEntity 생성 수준이라 분리 실익이 없어 Controller로 다시 합쳤습니다.
| @Column(name = "terms_agreed", nullable = false) | ||
| private boolean termsAgreed; |
There was a problem hiding this comment.
이용약관 동의 여부를 저장하는 필드입니다. 회원가입(구글 로그인) 시점에는 false로 시작하고, 온보딩 완료 API(POST /users/onboarding) 호출 시점에 자동으로 true로 변경됩니다.
별도의 약관 동의 체크박스나 API는 없고 "온보딩을 완료했다 = 이용약관에 동의했다"는 정책으로 처리하고 있다고 하여 DB에 일단 같이 저장했는데 필요가 없을까요..??
There was a problem hiding this comment.
그때 기획측에서 db에 남았으면 좋겠다고 말씀하셨던 것 같네요!
There was a problem hiding this comment.
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에 하드코딩하지 않고 환경변수로만 관리합니다.
There was a problem hiding this comment.
(정리하자면)
- setSecretKey(String base64Key)
: 암호화에 쓸 "비밀 열쇠" 세팅 (application.yml에 숨겨둔 비밀키를 가져와서 컴퓨터가 암호화할 때 직접 사용할 수 있는 정식 비밀 열쇠(secretKey) 형태로 만들어서 보관 - convertToDatabaseColumn(String plainText)
: 원본 데이터가 DB에 들어가기 직전에 자동으로 실행- 매번 다른 무작위 일련번호(IV) 하나 생성
- 비밀 열쇠와 IV를 섞어 원본 데이터를 해커가 못 알아보는 암호문으로 바꿈
- 나중에 풀 수 있도록 [일련번호 + 암호문]을 한 세트로 합쳐서 DB에 저장
- convertToEntityAttribute(String encoded)
: DB에서 데이터를 꺼내 우리 눈에 보이는 자바 변수에 담기 직전에 자동으로 실행됨
|
|
||
| security: | ||
| crypto: | ||
| calendar-token-key: ${CALENDAR_TOKEN_ENCRYPTION_KEY} |
There was a problem hiding this comment.
이거 .env파일에 들어가야하는 값인가요?
There was a problem hiding this comment.
넵 맞습니다. CALENDAR_TOKEN_ENCRYPTION_KEY는 .env(로컬) 및 배포 환경변수(.env.prod 등)에 값을 넣어주셔야 하는 항목입니다.
이 키는 구글 캘린더 API를 호출할 수 있는 구글의 OAUTH 토큰을 복호화할 수 있는 유일한 값이라, 이 키만 있으면 DB에 저장된 모든 사용자의 Google Calendar 토큰을 복호화해서 실제로 그 사람의 캘린더에 접근할 수 있게 됩니다. 즉 암호화를 적용한 의미 자체가 이 키의 보안에 달려 있습니다.
| } catch (Exception e) { | ||
| log.warn("회원 탈퇴 시 캘린더 토큰 revoke 실패. userId={}", userId, e); | ||
| } | ||
| }); |
There was a problem hiding this comment.
p3) 지금 withdraw()는 Google revoke가 실패해도 회원 삭제를 그대로 진행하는데 이때 ON DELETE CASCADE 때문에 calendar_connections row도 같이 사라지는걸로 이해했습니다. 그러면 서버는 어떤 토큰 revoke가 실패했는지조차 다시 알 수 없어서 나중에 재시도할 방법이 없어지지 않을까용 결과적으로 우리 서비스에서는 탈퇴가 끝났지만 Google 쪽 권한은 남아 있는 상태가 생길 수 있을 것 같아요!
특히 이번 PR에서 disconnect()는 revoke 실패 후 토큰을 잃어버리지 않도록 신경 쓴 만큼 withdraw()도 같은 기준으로 맞춰 두는 건 어떨까요? 외부 Google revoke 호출은 DB처럼 롤백할 수 있는 작업이 아니어서 사용자 삭제 트랜잭션과 분리하고 실패 시 다시 시도할 수 있게 남겨 두는 구조가 더 적절하지 않으까 생각해용
There was a problem hiding this comment.
오홍 그러네요 감사합니당
말씀하신대로 withraw()가 disconnect()와 다른 기준으로 되어 있어 토큰을 잃어버리는 문제가 있었습니다..!!
revokeCalendarConnectionIfExists에서 예외를 삼키던 catch를 제거하여 revoke 실패 시 예외가 그대로 전파되고 @transactional에 의해 회원 탈퇴 자체가 콜백되도록 수정하였습니다! disconnect()와 동일하게 'revoke 성공 시에만 다음 단계 진행' 기준으로 변경하였습니다.
| ) { | ||
|
|
||
| if (!termsAgreed) { | ||
| throw new CustomException(UserErrorCode.TERMS_AGREEMENT_REQUIRED); |
There was a problem hiding this comment.
p1) 해당 코드가 userErrorCode에 존재하지 않는 것 같은데 확인한번 부탁드립니다
There was a problem hiding this comment.
아래를 코드리뷰를 반영하며 수정하다 이전의 PR 코드를 봤는데 머지과정에서 잘못 들어가있는 애인 것 같아서 삭제했습니다.
온보딩 완료 = 약관 동의 흐름이기 때문에 검증 로직 필요 없이 completeOnboarding 내에서 자동으로 true 처리 하도록 하였습니다.
| data: | ||
| redis: | ||
| host: ${REDIS_HOST:redis} |
There was a problem hiding this comment.
p1) 현재 data가 security 안에 있는 것 같습니다. 들여쓰기 주의해서 security 작성해주세요!
| @Column(name = "calendar_email") | ||
| private String calendarEmail; | ||
|
|
||
| @Column(name = "terms_agreed", nullable = false) |
There was a problem hiding this comment.
여기 termsAgreed를 true로 변환하는 로직이 있나요?
There was a problem hiding this comment.
원래는 completeOnboarding() 메서드에 this.termsAgreed = true; 해당 코드가 들어있었는데 충돌 해결 과정에서 사라진 것 같습니다. 다시 추가했습니다..
| private final CalendarConnectionRepository calendarConnectionRepository; | ||
|
|
||
| @Transactional | ||
| public CalendarConnectResponse saveConnection( |
There was a problem hiding this comment.
p2)
새로운 연동 정보는 CalendarConnectionCommandService 에만 저장합니다. 하지만 [UserProfileResponse)는 User.calendarConnected와 User.calendarEmail을 읽습니다. 이 필드들은 연동 시 갱신되지 않아 계속 false/null입니다.
캘린더 연동 상태의 원천이 CalendarConnection과 User 두 곳으로 나뉘어 불일치가 발생합니다. 하나를 단일 원천으로 정하고, 프로필 응답도 CalendarConnection 존재 여부에서 계산하거나 연동/해제 시 User 필드를 함께 갱신해야 합니다.
There was a problem hiding this comment.
생각치 못했던 부분이네용,,
두 곳에 상태가 나뉘어 있어 불일치가 발생하여, User의 calendarConnected, calendarEmail 필드를 제거하고, 프로필 응답 시 CalendarConnectionRepository에서 존재 여부를 조회해서 계산하도록 단일 원천으로 통일했습니당
| 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); | ||
| } |
There was a problem hiding this comment.
p2) state는 한 번만 소비되어야 하므로 GET과 DELETE를 분리하기보다 Redis GETDEL 또는 Lua script를 사용해 원자적으로 검증·삭제해야할 것 같습니다.
|
|
||
| try { | ||
| restClient.post() | ||
| .uri("https://oauth2.googleapis.com/revoke?token=" + token) |
There was a problem hiding this comment.
p1) form body를 추가했지만 토큰이 쿼리 문자열에도 남아 있어 프록시/APM 로그 노출 위험이 그대로입니다. URI에서는 query parameter를 제거하고 form-urlencoded body로만 전달해야할 것 같습니다.
There was a problem hiding this comment.
맞습니다. URI에서 쿼리파라미터를 안전히 제거하고 fom-urlencoded body로만 전달하도록 수정하였습니다 감사합니다:)
aneykrap
left a comment
There was a problem hiding this comment.
리뷰내용 겹치는것도 있고 궁금한 점들도 잇어서 남겨두었습니다. 확인부탁드려용!
홧팅입니다
|
|
||
| try { | ||
| restClient.post() | ||
| .uri("https://oauth2.googleapis.com/revoke?token=" + token) |
There was a problem hiding this comment.
윤아님이랑 같은 의견입니당
. body의 token만 보내고 URI 쪽 query parameter는 제거해야 할 것 같아요!
| private final CalendarConnectionRepository calendarConnectionRepository; | ||
|
|
||
| @Transactional | ||
| public CalendarConnectResponse saveConnection( |
There was a problem hiding this comment.
여기도 위 리뷰와 동일해요!
p2) 캘린더 연동 정보의 저장 원천이 CalendarConnection으로 바뀌었는데 UserProfileResponse는 여전히 User.calendarConnected와 User.calendarEmail을 읽고 있는 것 같아요! 이 상태면 연동 API는 성공했는데 프로필 응답은 계속 false/null을 반환하는 불일치가 생길것 같습니다.
연동 여부를 판단하는 기준을 하나로 통일해야 할것 같아요
There was a problem hiding this comment.
넵넵 위와 같은 답글이기는 한데,
두 곳에 상태가 나뉘어 있어 불일치가 발생하여 User의 calendarConnected, calendarEmail 필드를 제거하고, 프로필 응답 시 CalendarConnectionRepository에서 존재 여부를 조회해서 계산하도록 단일 원천으로 통일했습니당
| 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 | ||
| ) {} |
There was a problem hiding this comment.
p4) connectedAt이 응답 DTO로 그대로 클라이언트에 내려가는 값 같은데 현재 포맷이 yyyy-MM-dd HH:mm:ss라 타임존 정보는 포함되지 않는 것 같아서용 의도적으로 로컬 시간 문자열로 내리는 걸까용?
There was a problem hiding this comment.
아니요.. 타임존으로 내려야 합니다..
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss'Z'", timezone = "UTC")를 추가해서 프론트에서 UTC 기준이라는 설명을 알 수 있도록 하였습니다..!!
There was a problem hiding this comment.
흠 여쭤보고 싶은 것이 있는데요, 그러면 다른 response에도 다 UTC를 명시적으로 추가해줘야하는 걸까요??
| @Transactional | ||
| public void withdraw(String accessToken, Long userId, String sessionId) { | ||
|
|
||
| User user = userRepository.findById(userId) | ||
| .orElseThrow(() -> new CustomException(UserErrorCode.USER_NOT_FOUND)); | ||
|
|
||
| revokeCalendarConnectionIfExists(userId); |
There was a problem hiding this comment.
p2) 외부 Google API 호출을 DB 트랜잭션 내부에서 수행하고 있어 Google 장애 시 회원 탈퇴 자체가 실패하고, revoke 이후 DB/Redis 작업 실패 시 롤백할 수 없는 외부 상태와 DB 상태가 불일치할 수 있습니다. 트랜잭션에서는 사용자 삭제와 revocation 작업을 함께 저장하고, 커밋 이후 outbox/비동기 작업으로 revoke를 재시도하는 구조가 안전해 보입니다.
There was a problem hiding this comment.
말씀주신대로 외부 API 호출이 트랜잭션 안에 있어서 생기는 리스크를 확인했습니다.
Google API 호출을 트랜잭션에서 완전히 분리하고, revoke 대상 토큰을 CalendarRevocationOutbox 테이블에 기록하는 방식으로수정하였습니다.
-> 회원 탈퇴 transaction은 DB 작업만으로 끝나서 Google 장애와 무관하게 항상 성공하고, 별도 스케줄러가 1분 주기로 outbox를 읽어 revoke를 재시도하도록 구현하였습니다.
| ? calendarConnection.getRefreshToken() | ||
| : calendarConnection.getAccessToken(); | ||
|
|
||
| googleOAuthClient.revokeToken(tokenToRevoke); |
There was a problem hiding this comment.
p2) Google revoke와 DB 삭제는 하나의 트랜잭션으로 묶을 수 없으므로 중간 실패 상태를 고려해야 합니다. DISCONNECT_PENDING 상태 또는 revocation outbox를 저장한 후 비동기로 revoke하고 최종 삭제하는 방식이 안전합니다.
There was a problem hiding this comment.
위와 동일한 이유로 CalendarService.disconnect()도 revoke를 즉시 호출하지 않고 CalendarRevocationOutbox에 기록만 하도록 변경했습니다. DB 삭제와 outbox 기록은 같은 트랜잭션 안에서 원자적으로 처리되고, 실제 Google revoke는 별도 스케줄러가 비동기로 처리하며 실패 시 최대 5회까지 재시도합니다. 반영했습니다!
| this.accessToken = accessToken; | ||
| this.refreshToken = refreshToken; | ||
| this.tokenExpiresAt = tokenExpiresAt; | ||
| this.connectedAt = LocalDateTime.now(); |
There was a problem hiding this comment.
p3) 현재 시간대 정보가 잘못 표출 될 수 있는 위험이 있을 것 같습니다. 본 서비스의 경우 timezone을 받고 있으니까 zoneId를사용해보는게 어떨까요?
There was a problem hiding this comment.
(대면으로 말씀드린 이야기) 일단 이렇게 가고 zoneId로 변경해서 response 내려주는 건 따로 이슈->PR로 전체적으로 다 수정하겠습니다!!
| return ResponseEntity.ok( | ||
| BaseResponse.onSuccess(CalendarSuccessCode.CALENDAR_CONNECTED, response) | ||
| ); |
There was a problem hiding this comment.
p3) 연동 성공 응답이 실제 HTTP 200이지만 본문과 Swagger에는 201로 정의되어 있습니다. 생성 API로 201을 사용할 예정이라면 ResponseEntity.status(HttpStatus.CREATED)로 반환하고, 200을 사용할 예정이라면 success code와 문서를 함께 수정해야 합니다.
There was a problem hiding this comment.
API 명세서 내에서도 success code가 상충되어 있었습니다.. 반영해서 201로 통일하도록 하겠습니다 (API 명세서도 수정)
| return ResponseEntity.ok( | ||
| BaseResponse.onSuccess(CalendarSuccessCode.CALENDAR_CONNECTED, response) | ||
| ); | ||
| } |
There was a problem hiding this comment.
p3) Swagger 문서에는 캘린더 연동 성공 응답이 201로 되어 있는데 실제 구현은 ResponseEntity.ok(...)라 200이 내려가고 있는것 같습니다. 나중에 혼선이 생길 수 있어서 문서와 구현 중 하나로 맞추는게 좋아보여요
| security = @SecurityRequirement(name = "bearerAuth") | ||
| ) | ||
| @ApiResponses({ | ||
| @ApiResponse(responseCode = "201", description = "연동 성공", useReturnTypeSchema = true), |
There was a problem hiding this comment.
실제 구현은 ResponseEntity.ok(...)라 200이 내려가고 있어요! 한번 확인 부탁드려요
| boolean calendarConnected, | ||
| String calendarEmail, | ||
|
|
||
| @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss'Z'", timezone = "UTC") |
There was a problem hiding this comment.
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 표시는 제거하는 게 좋지 않을까요
There was a problem hiding this comment.
시간 response 관련해서는 전체적으로 다 zoneId를 적용하여 변경을 해야될 것 같습니다. 이슈 파서 다시 PR 올릴 때 한 번에 수정해보도록 하겠습니다 ..
laura-jung
left a comment
There was a problem hiding this comment.
머지를 해봅시다..... 리뷰 반영하느라 수고했어용

관련 이슈 🛠
작업 내용 요약 ✏️
구글 OAuth 동의 완료 후 발급된 authorizationCode로 구글 토큰을 교환하여 캘린더를 연동하고, 연동 정보를 삭제 및 구글 토큰을 revoke하는 연동 해제 기능을 구현했습니다.
주요 변경 사항 🛠️
POST /api/v1/users/calendar(연동),DELETE /api/v1/users/calendar(해제) API 엔드포인트 추가calendar_connections테이블 매핑 엔티티 추가 (access_token, refresh_token은 AES-GCM으로 암호화하여 저장)findByUserId,existsByUserId조회 메서드 추가connectedAt응답 포맷을yyyy-MM-dd HH:mm:ss로 통일 (@JsonFormat,@Schema적용)exchangeToken), 사용자 정보 조회(fetchUserInfo), 토큰 revoke(revokeToken) 로직 분리, 기존spring.security.oauth2.client.registration.google설정 재사용AuthResponseFactory패턴에 맞춰 응답 조립 책임 분리트러블 슈팅 ⚽️
userinfoAPI로 실제 인증된 이메일을 조회해 가입 이메일과 일치하는지 검증하는 로직 추가 (다른 계정으로는 연동 불가 정책)구글 캘린더 연동 시 fetchUserInfo 단계에서 401 에러 발생
POST /api/v1/users/calendar호출 시CALENDAR_401(구글 캘린더 인증에 실패했습니다) 응답이 반복적으로 발생CustomException으로 감싸 던지던 catch 블록에 로그를 추가하여 원인을 단계별로 추적GoogleTokenResponse에@JsonProperty가 없어, 구글이 스네이크 케이스(access_token,refresh_token,expires_in)로 응답하는 필드가 전부null로 파싱됨. 이로 인해fetchUserInfo()호출 시accessToken이null로 전달되어Authorization: Bearer null헤더가 생성되고, 구글이 401(UNAUTHENTICATED)로 거부하는 문제였음. 각 필드에@JsonProperty를 명시하여 해결calendar.readonly스코프만 요청하고, 사용자 정보 조회에 필요한email(userinfo) 스코프를 함께 요청하지 않았기 때문. 캘린더 접근 권한만 있는 토큰으로는userinfo엔드포인트 호출이 거부됨을 확인. 인가 요청 시calendar.readonly와email스코프를 함께 요청하도록 테스트 절차를 수정하여 해결invalid_grant로 거부된다는 점도 함께 확인. 테스트 시 매번 새 인가 코드를 발급받아야 함self-invocation으로 인해 @transactional이 실제로 적용되지 않던 문제
CalendarService.connect(),disconnect()에서 같은 클래스 내부의saveConnection(),deleteConnection()을this로 직접 호출하고 있었는데, 이 경우 Spring AOP 프록시를 우회하여@Transactional이 사실상 무시된다는 점을 코드 리뷰(CodeRabbit)를 통해 확인@Transactional은 프록시 기반으로 동작하는데, 외부에서 빈을 호출할 때만 프록시가 트랜잭션 시작/커밋 로직을 가로챌 수 있고, 같은 클래스 내부에서 자기 자신의 메서드를 호출하는 경우(self-invocation)에는 프록시를 거치지 않아 트랜잭션이 적용되지 않음saveConnection,deleteConnection)을 별도의CalendarConnectionCommandService로 분리하고,CalendarService가 이를 주입받아 호출하도록 수정 -> 다른 빈을 통해 호출되므로 프록시가 정상적으로 트랜잭션을 적용함revoke 실패를 조용히 무시하지 않고 log.warn으로 남긴 이유
disconnect()에서 DB 삭제를 먼저 확정한 뒤revokeToken을 best-effort로 실행하도록 바꾸면서, 이 호출이 실패했을 때catch {}로 완전히 무시할지 로그를 남길지 검토catch {}로 비워두면 코드는 단순해지지만, **"조용한 실패(silent failure)"**가 되어 다음과 같은 문제가 생길 수 있음을 확인error가 아닌warn레벨로 로그를 남기기로 결정error로 하지 않은 이유는 이 실패가 사용자 경험에는 즉각적인 영향을 주지 않는 부차적 실패이기 때문임 (DB 삭제는 이미 성공했고 우리 서비스는 더 이상 해당 토큰을 사용하지 않으므로 기능적으로는 정상 동작)log.warn(userId 포함)을 선택함Bearer 토큰 인증 구조에서
/authorizeAPI가 302 리다이렉트로 동작할 수 없는 문제GET /users/calendar/authorize가 서버에서 직접 302로 구글 인증 화면으로 리다이렉트하는 방식이었으나 코드 리뷰를 통해 헤더 기반(Bearer 토큰) 인증 구조와 이 방식이 근본적으로 충돌한다는 점을 확인window.location으로 직접 이 API를 호출하면 브라우저 화면은 정상적으로 이동하지만, 이 경우 axios 인터셉터를 거치지 않아 Authorization 헤더가 실리지 않아 인증에 실패함authorizationUrl)으로 반환하고, 프론트가 이 URL을 받아window.location.assign()으로 직접 이동시키는 방식으로 변경하여 해결OAuth 흐름에서 CSRF 공격 방어 수단 부재
authorize호출 시 Redis에state(UUID) → userId를 5분 TTL로 저장하고POST /users/calendar요청 시 이 state를 함께 전달받아 검증 후 즉시 삭제하도록 구현access_token/refresh_token 평문 저장 및 연동 해제 시 토큰 유실 가능성
calendar_connections테이블에 구글 access_token/refresh_token이 평문으로 저장되고 있어 DB 접근 권한이나 백업 데이터 유출 시 연동된 사용자의 Google Calendar에 접근 가능한 보안 위험을 코드 리뷰로 확인AttributeConverter(AES-GCM)를 도입하여 저장 시 자동 암호화, 조회 시 자동 복호화되도록 수정. 암호화 키는 환경변수로 분리하여 코드/설정 파일에 노출되지 않도록 처리동시 요청 시 존재 여부 사전 검사와 실제 저장 사이의 race condition
existsByUserId()로 사전 검사 후 저장하는 구조에서, 동시에 두 요청이 들어오면 둘 다 사전 검사를 통과할 수 있고 DB 유니크 제약으로 나중 요청은 저장이 실패하지만 이 경우 의도한 409가 아니라 500(unique violation)으로 응답될 수 있는 문제를 확인save()를saveAndFlush()로 변경하여 저장 시점에 즉시 유니크 제약 위반을 확인하고DataIntegrityViolationException발생 시CALENDAR_409_ALREADY_CONNECTED로 변환하도록 수정하여 동시 요청 상황에서도 일관되게 409가 보장되도록 처리테스트 결과 📄
calendarConnected: true및calendarEmail반환 확인CALENDAR_401) 에러 반환 확인calendarConnected: false반환 확인calendar_connectionsrow 삭제 확인스크린샷 📷
캘린더 연동 성공,








calendarConnected: true및calendarEmail반환 확인authorizationCode 누락 시 400 에러 반환 확인
유효하지 않은 authorizationCode인 경우 401(
CALENDAR_401) 에러 반환 확인가입 이메일과 다른 구글 계정으로 연동 시도 시 401(이메일 불일치) 에러 반환 확인
이미 연동된 상태에서 재연동 시도 시 409 에러 반환 확인
캘린더 연동 해제 성공,
calendarConnected: false반환 확인연동되지 않은 상태에서 해제 시도 시 404 에러 반환 확인
해제 후 DB에서
calendar_connectionsrow 삭제 확인리뷰 요구사항 📢
변경 사항
캘린더 연동을 시작하는
GET /api/v1/users/calendar/authorizeAPI를 새로 추가했습니다.authorizationUrl)으로 응답하는 방식으로 변경했습니다.window.location.assign()등으로 직접 이동시켜야 합니다.기존
POST /api/v1/users/calendar(토큰 교환),DELETE /api/v1/users/calendar(연동 해제) API는 로직 변경 없이 그대로 유지됩니다.회원 탈퇴 시
calendar_connections에 고아 데이터가 남지 않도록,CalendarConnection.user필드의@JoinColumn에foreignKeyDefinition을 추가하여ON DELETE CASCADE제약이 걸리도록 했습니다.ddl-auto: update로 동작함을 확인하여, 별도의 수동 마이그레이션 없이 이번 배포 시 테이블 생성과 함께 FK 제약(CASCADE)도 자동으로 반영됩니다.AuthService.withdraw()에 애플리케이션 레벨로 추가했습니다.📎 참고 자료 (선택)
Summary by CodeRabbit
Summary by CodeRabbit