Skip to content

[Feat] OAuth Spring Security 적용#43

Open
toychip wants to merge 24 commits intomainfrom
feat/#42-spring-security-oauth
Open

[Feat] OAuth Spring Security 적용#43
toychip wants to merge 24 commits intomainfrom
feat/#42-spring-security-oauth

Conversation

@toychip
Copy link
Copy Markdown
Contributor

@toychip toychip commented Mar 25, 2026

관련 이슈


변경 내용

  • Spring Security SecurityFilterChain 기반 인증 구조로 전환
  • 소셜 로그인 지원 (Kakao, Google, Apple)
    • 클라이언트가 OAuth access token 전달 → 서버가 프로바이더 API 호출하여 사용자 정보 검증
  • Member 도메인 모델 OAuth 필드로 변경 (serialNumberproviderType, providerId, email)
  • AuthenticationEntryPoint, AccessDeniedHandler를 통한 인증/인가 에러 처리 통합
  • 트랜잭션 범위 최소화: 외부 OAuth API 호출은 트랜잭션 밖, DB 조작만 @Transactional (AuthServiceMemberService 분리)
  • 기존 서블릿 필터 인증 코드 제거 (JwtAuthenticationFilter, FilterPaths)

체크리스트

  • Ktlint
  • 테스트 통과 여부

Summary by CodeRabbit

  • New Features

    • 카카오·구글·애플 기반 OAuth 소셜 로그인 추가 — 손쉬운 계정 연동 및 로그인 지원.
    • JWT 기반 보안 적용 및 요청 인증 흐름 개선.
  • Changes

    • 로그인 API 요청/응답이 OAuth 흐름으로 변경되고, 응답에 신규 사용자 여부(isNewMember)가 포함됩니다.
    • 일부 공개 엔드포인트 및 보안 경로 설정이 정비되었습니다.

@toychip toychip linked an issue Mar 25, 2026 that may be closed by this pull request
10 tasks
@toychip toychip changed the title [Feat] OAuth Spring Security 적용 (#42) [Feat] OAuth Spring Security 적용 Mar 25, 2026
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 25, 2026

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

Walkthrough

직렬 번호 기반 인증을 OAuth 소셜 로그인으로 전환했습니다. Member 식별자와 DB 제약이 providerType/providerId 기반으로 바뀌고, Spring Security(JWT) 도입, Kakao/Google/Apple OAuth 어댑터, 관련 DTO·포트·서비스·컨트롤러·테스트가 추가/수정되었습니다.

Changes

Cohort / File(s) Summary
의존성 관리
gradle/libs.versions.toml
spring-boot-starter-security 추가, adaptor-input-http에 security 포함, adaptor-output-http에 jjwt 라이브러리 추가
도메인 / 애플리케이션 서비스
linktrip-application/src/main/kotlin/com/linktrip/application/domain/member/Member.kt, .../ProviderType.kt, .../MemberService.kt, .../AuthService.kt
Member 도메인에서 serialNumber 제거 → email, providerType, providerId 추가. ProviderType enum 및 MemberService(찾기/생성) 추가. AuthService가 serial 인증에서 socialLogin(OAuthPorts, MemberService 사용)으로 변경
포트/인터페이스/DTO (application)
linktrip-application/src/main/kotlin/.../port/output/auth/OAuthPort.kt, .../OAuthInfo.kt, .../port/output/persistence/MemberPort.kt, .../port/input/AuthUseCase.kt
OAuthPort/OAuthInfo 신규 추가. MemberPort 쿼리 변경(findByProviderTypeAndProviderId). AuthUseCase API 변경(authenticateBySerial → socialLogin)
입력 HTTP 계층 (컨트롤러/DTO/필터/인터셉터)
linktrip-input-http/src/main/kotlin/.../controller/AuthController.kt, .../dto/request/OAuthLoginRequest.kt, .../dto/response/OAuthLoginResponse.kt, .../config/FilterConfig.kt, .../ratelimit/RateLimitInterceptor.kt, .../filter/MdcLoggingFilter.kt
AuthRequest 제거, OAuthLoginRequest/Response 추가 및 컨트롤러 호출 경로 변경. JwtAuthenticationFilter 등록 제거. RateLimitInterceptor가 SecurityContext에서 memberId 조회로 변경. MdcLoggingFilter 로컬 LOGGING_SKIP로 변경
Spring Security 구성 및 컴포넌트
linktrip-input-http/src/main/kotlin/.../security/SecurityConfig.kt, .../security/SecurityPaths.kt, .../security/JwtSecurityFilter.kt, .../security/JwtAuthenticationProvider.kt, .../security/JwtTokenProvider.kt, .../security/PreAuthorizationToken.kt, .../security/PostAuthorizationToken.kt, .../security/handler/*
SecurityConfig로 공개/보호 필터체인 정의. JwtSecurityFilter, AuthenticationProvider, TokenProvider(JJWT 기반) 및 Pre/Post 토큰 추가. AuthenticationEntryPoint/AccessDeniedHandler 추가
컨트롤러 인자 리졸버 / 어노테이션
linktrip-input-http/src/main/kotlin/.../security/AuthenticatedMember.kt, .../security/AuthenticatedMemberArgumentResolver.kt, .../config/WebMvcConfig.kt
AuthenticatedMember 패키지 변경 및 AuthenticatedMemberArgumentResolver 추가( SecurityContext에서 principal 추출 ). WebMvcConfig import 경로 업데이트
출력 HTTP - OAuth 어댑터 및 구성
linktrip-output-http/src/main/kotlin/.../oauth/adapter/KakaoOAuthAdapter.kt, .../GoogleOAuthAdapter.kt, .../AppleOAuthAdapter.kt, .../oauth/config/OAuthRestClientConfig.kt
Kakao/Google/Apple OAuthPort 구현 추가. 각 공급자용 RestClient 빈 구성(타임아웃·에러 핸들링)
출력 DTOs (OAuth)
linktrip-output-http/src/main/kotlin/.../oauth/dto/KakaoUserInfoResponse.kt, .../GoogleUserInfoResponse.kt, .../ApplePublicKeyResponse.kt
각 공급자 응답 DTO 추가(Apple 공개키 DTO 포함)
영속성 레이어
linktrip-output-persistence/mysql/src/main/kotlin/.../adapter/MemberAdapter.kt, .../entity/MemberEntity.kt, .../repository/MemberJpaRepository.kt
MemberAdapter 및 JpaRepository가 providerType/providerId 기반 조회로 변경. MemberEntity의 유니크 제약이 serial → (provider_type, provider_id)로 변경. save에서 중복 예외 처리 추가
공통 예외 코드
linktrip-common/src/main/kotlin/com/linktrip/common/exception/ExceptionCode.kt
OAuth 관련 예외 코드 BAD_REQUEST_OAUTH_PROVIDER, BAD_GATEWAY_OAUTH_PROVIDER 추가
테스트 변경
linktrip-application/src/test/.../AuthServiceTest.kt, linktrip-input-http/src/test/.../RateLimitInterceptorTest.kt
AuthServiceTest를 socialLogin 시나리오로 변경(모의 OAuthPort). RateLimitInterceptorTest가 SecurityContextHolder 기반으로 인증 시뮬레이션하도록 변경

Sequence Diagram(s)

sequenceDiagram
    actor Client
    participant AuthController
    participant AuthService
    participant OAuthPort
    participant MemberService
    participant MemberPort
    participant TokenProvider

    Client->>AuthController: POST /login (providerType, accessToken)
    AuthController->>AuthService: socialLogin(providerType, accessToken)
    AuthService->>OAuthPort: requestUserInfo(accessToken)
    OAuthPort-->>AuthService: OAuthInfo(providerId, email, providerType)
    AuthService->>MemberService: findOrCreateByOAuth(providerType, oAuthInfo)
    MemberService->>MemberPort: findByProviderTypeAndProviderId(providerType, providerId)
    alt existing member
        MemberPort-->>MemberService: Member
    else new member
        MemberPort-->>MemberService: null
        MemberService->>MemberPort: save(newMember)
        MemberPort-->>MemberService: savedMember
    end
    MemberService-->>AuthService: MemberLoginResult(member, isNewMember)
    AuthService->>TokenProvider: create(memberId)
    TokenProvider-->>AuthService: JWT
    AuthService-->>AuthController: AuthResult(memberId, accessToken, isNewMember)
    AuthController-->>Client: 200/201 + OAuthLoginResponse
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related issues

Possibly related PRs

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed PR 제목은 변경 사항의 핵심을 명확하게 반영합니다. OAuth와 Spring Security 적용이 주요 목적이며, 이는 광범위한 인증 구조 전환과 일치합니다.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/#42-spring-security-oauth

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@toychip
Copy link
Copy Markdown
Contributor Author

toychip commented Mar 25, 2026

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 25, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@toychip
Copy link
Copy Markdown
Contributor Author

toychip commented Mar 25, 2026

@coderabbitai full review

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 25, 2026

✅ Actions performed

Full review triggered.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 13

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
linktrip-input-http/src/main/kotlin/com/linktrip/input/http/security/JwtTokenProvider.kt (1)

37-50: ⚠️ Potential issue | 🟠 Major

IllegalArgumentException 처리 추가로 예외 처리 명시화 필요

현재 코드는 빈 JWT 토큰 입력 시 JJWT 파서가 던지는 IllegalArgumentException을 명시적으로 처리하지 않고 있습니다. 필터 레벨의 일반 예외 핸들러가 이를 처리하므로 현재 401 응답으로 반환되지만, 예외 처리를 명시화하는 것이 권장됩니다.

🔧 제안 수정안
     override fun validate(token: String): Boolean =
         try {
             Jwts.parser()
                 .verifyWith(secretKey)
                 .build()
                 .parseSignedClaims(token)
             true
+        } catch (e: IllegalArgumentException) {
+            logger.warn { "비어 있거나 잘못된 JWT 토큰: ${e.message}" }
+            false
         } catch (e: ExpiredJwtException) {
             logger.warn { "만료된 JWT 토큰: ${e.message}" }
             false
         } catch (e: JwtException) {
             logger.warn { "유효하지 않은 JWT 토큰: ${e.message}" }
             false
         }

     override fun extractMemberId(token: String): String =
         try {
             Jwts.parser()
                 .verifyWith(secretKey)
                 .build()
                 .parseSignedClaims(token)
                 .payload
                 .subject
+        } catch (e: IllegalArgumentException) {
+            logger.warn { "비어 있거나 잘못된 JWT 토큰으로 memberId 추출 시도: ${e.message}" }
+            throw LinktripException(ExceptionCode.TOKEN_INVALID)
         } catch (e: ExpiredJwtException) {
             logger.warn { "만료된 JWT 토큰으로 memberId 추출 시도: ${e.message}" }
             throw LinktripException(ExceptionCode.TOKEN_EXPIRED)
         } catch (e: JwtException) {
             logger.warn { "유효하지 않은 JWT 토큰으로 memberId 추출 시도: ${e.message}" }
             throw LinktripException(ExceptionCode.TOKEN_INVALID)
         }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@linktrip-input-http/src/main/kotlin/com/linktrip/input/http/security/JwtTokenProvider.kt`
around lines 37 - 50, The validate(token: String) method in JwtTokenProvider
currently catches ExpiredJwtException and JwtException but not
IllegalArgumentException; add a catch block for IllegalArgumentException in
validate to explicitly handle empty/invalid token input: log a warning via
logger.warn including e.message (e.g., "잘못된 JWT 토큰: ${e.message}") and return
false, placing this catch alongside the existing exception handlers in the
validate function.
🧹 Nitpick comments (7)
linktrip-input-http/src/main/kotlin/com/linktrip/input/http/security/handler/JwtAuthenticationEntryPoint.kt (1)

13-37: LGTM - 공통 응답 작성 로직 추출을 고려해볼 수 있습니다.

AuthenticationEntryPoint 구현이 올바릅니다. JwtAccessDeniedHandler와 응답 작성 로직이 거의 동일하므로, 향후 유지보수성을 위해 공통 유틸리티 함수로 추출하는 것도 고려해볼 수 있습니다.

♻️ 공통 응답 작성 로직 추출 예시
// SecurityResponseHelper.kt
object SecurityResponseHelper {
    fun writeErrorResponse(
        response: HttpServletResponse,
        objectMapper: ObjectMapper,
        exceptionCode: ExceptionCode,
    ) {
        response.status = exceptionCode.statusCode
        response.contentType = MediaType.APPLICATION_JSON_VALUE
        response.characterEncoding = Charsets.UTF_8.name()
        response.writer.write(
            objectMapper.writeValueAsString(
                ExceptionResponse(
                    message = exceptionCode.defaultMessage,
                    cause = null,
                    timestamp = System.currentTimeMillis(),
                ),
            ),
        )
    }
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@linktrip-input-http/src/main/kotlin/com/linktrip/input/http/security/handler/JwtAuthenticationEntryPoint.kt`
around lines 13 - 37, The JwtAuthenticationEntryPoint and JwtAccessDeniedHandler
share identical response-writing logic; extract this into a shared helper (e.g.,
SecurityResponseHelper.writeErrorResponse) that accepts HttpServletResponse,
ObjectMapper, and ExceptionCode, then replace the duplicated blocks in
JwtAuthenticationEntryPoint.commence and JwtAccessDeniedHandler.handle with a
call to that helper to centralize status, contentType, encoding, and
ExceptionResponse serialization.
linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/entity/MemberEntity.kt (1)

17-26: 중복 인덱스가 존재합니다.

uk_member_provider unique constraint가 (provider_type, provider_id)에 대해 암시적으로 인덱스를 생성하므로, idx_member_provider 인덱스는 중복입니다. unique constraint만으로 조회 성능이 보장됩니다.

♻️ 중복 인덱스 제거
     indexes = [
-        Index(name = "idx_member_provider", columnList = "provider_type, provider_id"),
         Index(name = "idx_member_email", columnList = "email"),
     ],
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/entity/MemberEntity.kt`
around lines 17 - 26, Remove the redundant index idx_member_provider from the
indexes list in MemberEntity (the unique constraint uk_member_provider on
columns provider_type and provider_id already creates an index), i.e., delete
the Index entry with name "idx_member_provider" (leave the UniqueConstraint
"uk_member_provider" and other indexes like "idx_member_email" intact) and run
schema migration tests to ensure no references depend on the removed index.
linktrip-output-http/src/main/kotlin/com/linktrip/output/http/oauth/dto/AppleUserInfoResponse.kt (1)

3-14: 파일명과 클래스명이 일치하지 않습니다.

파일명은 AppleUserInfoResponse.kt이지만 실제 클래스명은 ApplePublicKeyResponse입니다. Kotlin 관례상 파일명과 public 클래스명을 일치시키는 것이 좋습니다.

♻️ 수정 제안

파일명을 ApplePublicKeyResponse.kt로 변경하세요.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@linktrip-output-http/src/main/kotlin/com/linktrip/output/http/oauth/dto/AppleUserInfoResponse.kt`
around lines 3 - 14, The file name does not match the public data class declared
(file is AppleUserInfoResponse.kt but the class is ApplePublicKeyResponse);
rename the file to ApplePublicKeyResponse.kt so Kotlin file/class convention is
preserved and the public data class ApplePublicKeyResponse (and its nested
AppleKey) are discoverable and consistent.
linktrip-input-http/src/test/kotlin/com/linktrip/input/http/ratelimit/RateLimitInterceptorTest.kt (1)

37-39: 테스트 정리 로직을 @AfterEach로 이동하는 것을 권장합니다.

현재 clearAuthentication()이 assertion 이후에 호출되어, assertion 실패 시 security context가 정리되지 않아 다른 테스트에 영향을 줄 수 있습니다.

♻️ 개선 제안
+import org.junit.jupiter.api.AfterEach
+
 `@ExtendWith`(MockitoExtension::class)
 class RateLimitInterceptorTest {
     `@Mock`
     lateinit var rateLimitBucketStore: RateLimitBucketStore

     `@InjectMocks`
     lateinit var interceptor: RateLimitInterceptor

     private fun setAuthentication(memberId: String) {
         SecurityContextHolder.getContext().authentication = PostAuthorizationToken(memberId)
     }

-    private fun clearAuthentication() {
-        SecurityContextHolder.clearContext()
-    }
+    `@AfterEach`
+    fun tearDown() {
+        SecurityContextHolder.clearContext()
+    }

그런 다음 각 테스트 메서드 끝의 clearAuthentication() 호출을 제거하세요.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@linktrip-input-http/src/test/kotlin/com/linktrip/input/http/ratelimit/RateLimitInterceptorTest.kt`
around lines 37 - 39, Move the manual cleanup into a JUnit `@AfterEach` method so
the SecurityContextHolder is always cleared even if assertions fail: create an
`@AfterEach` annotated function (e.g., fun clearAuthentication()) that calls
SecurityContextHolder.clearContext() and remove all explicit
clearAuthentication() calls at the end of individual tests; ensure the method
name matches existing clearAuthentication to reuse the same logic.
linktrip-input-http/src/main/kotlin/com/linktrip/input/http/security/PostAuthorizationToken.kt (1)

7-7: 향후 역할 기반 접근 제어(RBAC)가 필요할 경우 authorities 확장을 고려하세요.

현재 emptyList()로 authorities를 설정했는데, 이는 현재 요구사항에는 충분하지만 추후 관리자/일반 사용자 구분 등이 필요하면 이 부분의 확장이 필요합니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@linktrip-input-http/src/main/kotlin/com/linktrip/input/http/security/PostAuthorizationToken.kt`
at line 7, 현재 PostAuthorizationToken 클래스가 AbstractAuthenticationToken을
emptyList()로 초기화해 권한 정보를 고정하고 있으므로 향후 RBAC 확장이 어렵습니다; 수정안은
PostAuthorizationToken 생성자에 authorities: Collection<GrantedAuthority> (또는 기본값이
있는 오버로드 생성자)을 추가하고 해당 authorities를 super(authorities)로 전달하도록 변경하거나 내부에
GrantedAuthority 컬렉션 필드를 두고 getAuthorities()가 이를 반환하게 하여 관리자/사용자 등 역할 구분을 쉽게 확장할
수 있도록 하세요; 대상 심볼: PostAuthorizationToken 생성자,
AbstractAuthenticationToken(super(...)), getAuthorities().
linktrip-output-http/src/main/kotlin/com/linktrip/output/http/oauth/adapter/AppleOAuthAdapter.kt (1)

47-52: Apple 공개 키 캐싱을 고려하세요.

현재 매 요청마다 Apple 서버에서 공개 키를 조회합니다. Apple의 키는 자주 변경되지 않으므로, 캐싱을 도입하면 성능을 크게 개선하고 외부 의존성 호출을 줄일 수 있습니다.

캐싱 로직이 포함된 구현을 생성해 드릴까요?

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@linktrip-output-http/src/main/kotlin/com/linktrip/output/http/oauth/adapter/AppleOAuthAdapter.kt`
around lines 47 - 52, fetchApplePublicKeys is currently calling Apple on every
invocation; add caching to AppleOAuthAdapter to avoid repeated network calls by
storing the last ApplePublicKeyResponse along with its expiry (Apple keys rotate
infrequently). Implement a simple thread-safe cache (e.g., volatile field +
synchronized refresh or use a cache library like Caffeine) inside
AppleOAuthAdapter: on fetchApplePublicKeys return cached response if present and
not expired, otherwise call
restClient.get().uri("/auth/keys").retrieve().body<ApplePublicKeyResponse>(),
update the cache and expiry (configurable TTL), and fall back to the previous
cached value on transient failures; ensure exceptions still map to
LinktripException(ExceptionCode.OAUTH_PROVIDER_ERROR) when no valid cached value
exists.
linktrip-output-http/src/main/kotlin/com/linktrip/output/http/oauth/config/OAuthRestClientConfig.kt (1)

16-47: RestClient 생성 로직은 공통 팩토리로 추출하는 편이 유지보수에 유리합니다.

현재 3개 빈이 동일한 패턴을 반복하고 있어, 타임아웃/에러정책 변경 시 누락 위험이 있습니다.

♻️ 제안 diff
 class OAuthRestClientConfig {
     `@Bean`("kakaoOAuthRestClient")
     fun kakaoOAuthRestClient(): RestClient =
-        RestClient.builder()
-            .baseUrl(KAKAO_USER_INFO_URL)
-            .defaultStatusHandler({ it.isError }) { request, response ->
-                logger.error { "Kakao API 호출 실패: status=${response.statusCode}" }
-                throw LinktripException(ExceptionCode.OAUTH_PROVIDER_ERROR)
-            }
-            .requestFactory(clientHttpRequestFactory())
-            .build()
+        buildOAuthRestClient(provider = "Kakao", baseUrl = KAKAO_USER_INFO_URL)
 
     `@Bean`("googleOAuthRestClient")
     fun googleOAuthRestClient(): RestClient =
-        RestClient.builder()
-            .baseUrl(GOOGLE_USER_INFO_URL)
-            .defaultStatusHandler({ it.isError }) { request, response ->
-                logger.error { "Google API 호출 실패: status=${response.statusCode}" }
-                throw LinktripException(ExceptionCode.OAUTH_PROVIDER_ERROR)
-            }
-            .requestFactory(clientHttpRequestFactory())
-            .build()
+        buildOAuthRestClient(provider = "Google", baseUrl = GOOGLE_USER_INFO_URL)
 
     `@Bean`("appleOAuthRestClient")
     fun appleOAuthRestClient(): RestClient =
+        buildOAuthRestClient(provider = "Apple", baseUrl = APPLE_BASE_URL)
+
+    private fun buildOAuthRestClient(provider: String, baseUrl: String): RestClient =
         RestClient.builder()
-            .baseUrl(APPLE_BASE_URL)
-            .defaultStatusHandler({ it.isError }) { request, response ->
-                logger.error { "Apple API 호출 실패: status=${response.statusCode}" }
+            .baseUrl(baseUrl)
+            .defaultStatusHandler({ it.isError }) { _, response ->
+                logger.error { "$provider API 호출 실패: status=${response.statusCode}" }
                 throw LinktripException(ExceptionCode.OAUTH_PROVIDER_ERROR)
             }
             .requestFactory(clientHttpRequestFactory())
             .build()
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@linktrip-output-http/src/main/kotlin/com/linktrip/output/http/oauth/config/OAuthRestClientConfig.kt`
around lines 16 - 47, The three bean builders kakaoOAuthRestClient,
googleOAuthRestClient and appleOAuthRestClient repeat identical RestClient
construction (baseUrl, defaultStatusHandler, requestFactory), so extract a
single private helper like buildOAuthRestClient(baseUrl: String): RestClient
that uses clientHttpRequestFactory() and the shared defaultStatusHandler logic,
then have each `@Bean` method simply call that helper with its respective URL
constant (KAKAO_USER_INFO_URL, GOOGLE_USER_INFO_URL, APPLE_BASE_URL) to
centralize timeouts/error policy changes and avoid duplication.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@linktrip-application/src/main/kotlin/com/linktrip/application/domain/member/AuthService.kt`:
- Line 26: 로그에 민감한 식별자(providerId)를 남기고 있으니 AuthService.kt의 해당 logger.info 호출에서
oAuthInfo.providerId를 제거하거나 마스킹하세요; 즉 AuthService 내 logger.info { "OAuth 사용자 정보
조회 완료: provider=$providerType" }로 바꾸거나 oAuthInfo.providerId를 부분 마스킹(예: 첫/끝 일부만
노출)하여 실제 식별자가 남지 않게 변경하고, 참조되는 심볼은 AuthService, logger.info, providerType,
oAuthInfo.providerId입니다.

In
`@linktrip-application/src/main/kotlin/com/linktrip/application/domain/member/MemberService.kt`:
- Around line 20-30: The current find → save flow in MemberService
(memberPort.findByProviderTypeAndProviderId + memberPort.save with
Member.create) can produce duplicate members under concurrent requests; add DB
unique constraint on (providerType, providerId) and wrap the save in error
recovery: attempt to save the new Member, and if the save throws a
duplicate-key/DataIntegrityViolation (or the persistence-specific duplicate
exception), catch it, re-query memberPort.findByProviderTypeAndProviderId and
return MemberLoginResult with the existing member (isNewMember=false); keep the
normal path returning the newly saved member as
MemberLoginResult(isNewMember=true). Ensure the recovery handles the same
identifying keys and uses the same symbols: memberPort.save,
memberPort.findByProviderTypeAndProviderId, Member.create, and
MemberLoginResult.

In
`@linktrip-application/src/main/kotlin/com/linktrip/application/port/output/persistence/MemberPort.kt`:
- Line 12: The MemberPort interface exposes an unused method findByEmail; remove
it or document its intended future use—either delete the findByEmail declaration
from MemberPort if not needed, or add a clear comment above findByEmail stating
it is reserved for future account/email-based lookups (e.g., account linking)
and why current flows (MemberService.findOrCreateByOAuth using
findByProviderTypeAndProviderId and save) do not call it; ensure references to
findByEmail are absent elsewhere and run tests/compilation after the change.

In
`@linktrip-input-http/src/main/kotlin/com/linktrip/input/http/config/WebMvcConfig.kt`:
- Around line 3-4: 현재 WebMvcConfig.kt의 import 순서가 알파벳 순서와 일치하지 않아 ktlint가 실패합니다;
파일에서 import com.linktrip.input.http.security.AuthenticatedMemberArgumentResolver
및 import com.linktrip.input.http.ratelimit.RateLimitInterceptor를 찾은 뒤
알파벳(패키지+클래스 기준) 순서로 정렬하여 RateLimitInterceptor(import
com.linktrip.input.http.ratelimit) 가 AuthenticatedMemberArgumentResolver(import
com.linktrip.input.http.security)보다 먼저 오도록 수정하십시오.

In
`@linktrip-input-http/src/main/kotlin/com/linktrip/input/http/controller/dto/request/AuthRequest.kt`:
- Around line 1-12: The filename currently (AuthRequest.kt) does not match the
Kotlin data class name OAuthLoginRequest and ktlint is failing; rename the file
from AuthRequest.kt to OAuthLoginRequest.kt so it matches the data class
OAuthLoginRequest, then update any imports/usages referencing the old filename
if necessary (search for AuthRequest and replace with OAuthLoginRequest) to
ensure compilation and linting pass.

In
`@linktrip-input-http/src/main/kotlin/com/linktrip/input/http/controller/dto/response/AuthResponse.kt`:
- Around line 1-7: The filename currently (AuthResponse.kt) does not match the
Kotlin data class name OAuthLoginResponse which fails ktlint; rename the source
file from AuthResponse.kt to OAuthLoginResponse.kt so the file name matches the
data class OAuthLoginResponse (ensure VCS commit includes only the rename and
run ktlint/build to verify).

In
`@linktrip-input-http/src/main/kotlin/com/linktrip/input/http/filter/MdcLoggingFilter.kt`:
- Around line 85-90: The LOGGING_SKIP list in the companion object currently
contains "/api/health/" which doesn't match the actual HealthCheckController
mappings; update LOGGING_SKIP (in MdcLoggingFilter's companion object) to
include the correct health endpoints (e.g. "/health", or the specific paths
"/health/api" and "/health/db" or a prefix pattern that matches all health
endpoints) so requests to HealthCheckController (mapped with
`@RequestMapping`("/health")) are skipped by the logging filter.

In
`@linktrip-input-http/src/main/kotlin/com/linktrip/input/http/security/AuthenticatedMemberArgumentResolver.kt`:
- Around line 25-26: The ktlint error is caused by breaking the expression
before the Elvis operator; update the formatting in
AuthenticatedMemberArgumentResolver so the Elvis operator is placed at the end
of the preceding line (or keep the entire assignment on one line): ensure the
expression "val authentication =
SecurityContextHolder.getContext().authentication ?: throw
LinktripException(ExceptionCode.AUTHENTICATION_FAILED)" does not start the
multiline with the operator and conforms to ktlint's rule.

In
`@linktrip-input-http/src/main/kotlin/com/linktrip/input/http/security/JwtSecurityFilter.kt`:
- Around line 29-44: The try/catch currently wraps filterChain.doFilter causing
non-auth errors to be handled as authentication failures; change
JwtSecurityFilter so only the authentication attempt (creation of
PreAuthorizationToken and call to authenticationManager.authenticate) is inside
the try block, then set SecurityContextHolder.getContext().authentication =
postAuthToken and call filterChain.doFilter(request, response) outside that try.
Keep the AuthenticationException catch to clear the context and call
authenticationEntryPoint.commence, but do not catch/generalize other exceptions
(let them propagate) so downstream errors aren't converted to 401.

In
`@linktrip-input-http/src/main/kotlin/com/linktrip/input/http/security/SecurityConfig.kt`:
- Line 29: SecurityConfig currently applies
securityMatcher(*SecurityPaths.PUBLIC_ENDPOINTS) too broadly; narrow the public
matcher to only truly public routes by removing dev/ops-sensitive paths from
SecurityPaths.PUBLIC_ENDPOINTS, create a separate SecurityPaths.DEV_PUBLIC (or
similar) for Swagger/Actuator/Test, and conditionally register that dev-only
matcher in SecurityConfig based on the active Spring profile (e.g., only add the
Swagger/Actuator/Test matcher when an "dev" or "local" profile is active) so
production never exposes those endpoints.
- Around line 26-54: The two SecurityFilterChain beans (publicFilterChain and
protectedFilterChain) need explicit ordering so the public securityMatcher
(SecurityPaths.PUBLIC_ENDPOINTS) is evaluated before the protected anyRequest()
chain; add `@Order` annotations (import org.springframework.core.annotation.Order)
to set publicFilterChain to a higher precedence (lower order value, e.g.
`@Order`(1)) and protectedFilterChain to a lower precedence (e.g. `@Order`(2)) so
the SecurityPaths.PUBLIC_ENDPOINTS matcher is applied before
JwtSecurityFilter/anyRequest() in protectedFilterChain.

In
`@linktrip-input-http/src/main/kotlin/com/linktrip/input/http/security/SecurityPaths.kt`:
- Around line 11-12: 현재 SecurityPaths에 들어있는 공개 경로 "/test/**"와 "/actuator/**"는
과도하므로 보안을 위해 축소해야 합니다: SecurityPaths 클래스(또는 관련 상수/배열)를 찾아서 "/actuator/**"를
"/actuator/health/**" 등 필요한 헬스 체크 엔드포인트로 제한하고 "/test/**"는 아예 제거하거나 로컬/테스트
프로파일에서만 허용되도록 조건부로 분리(예: 프로파일 기반 필터 또는 환경 검사)하세요; 또한 변경 후 접근 제어 로직(예:
WebSecurityConfigurerAdapter 설정)에서 해당 경로명이 올바르게 반영되는지 확인하십시오.

In
`@linktrip-output-http/src/main/kotlin/com/linktrip/output/http/oauth/adapter/AppleOAuthAdapter.kt`:
- Around line 72-76: The token parsing currently only verifies the signature
(Jwts.parser().verifyWith(publicKey).build().parseSignedClaims(idToken).payload)
but does not validate the issuer (iss) and audience (aud); update the
AppleOAuthAdapter token validation to extract claims after
parseSignedClaims(idToken) and explicitly check that claims.get("iss") equals
"https://appleid.apple.com" and that claims.get("aud") matches the expected app
identifier (the Bundle ID/Service ID provided to your adapter, e.g., an
expectedAudience or clientId field); if either check fails, throw a clear
authentication/invalid-token exception so tokens issued for other apps are
rejected.

---

Outside diff comments:
In
`@linktrip-input-http/src/main/kotlin/com/linktrip/input/http/security/JwtTokenProvider.kt`:
- Around line 37-50: The validate(token: String) method in JwtTokenProvider
currently catches ExpiredJwtException and JwtException but not
IllegalArgumentException; add a catch block for IllegalArgumentException in
validate to explicitly handle empty/invalid token input: log a warning via
logger.warn including e.message (e.g., "잘못된 JWT 토큰: ${e.message}") and return
false, placing this catch alongside the existing exception handlers in the
validate function.

---

Nitpick comments:
In
`@linktrip-input-http/src/main/kotlin/com/linktrip/input/http/security/handler/JwtAuthenticationEntryPoint.kt`:
- Around line 13-37: The JwtAuthenticationEntryPoint and JwtAccessDeniedHandler
share identical response-writing logic; extract this into a shared helper (e.g.,
SecurityResponseHelper.writeErrorResponse) that accepts HttpServletResponse,
ObjectMapper, and ExceptionCode, then replace the duplicated blocks in
JwtAuthenticationEntryPoint.commence and JwtAccessDeniedHandler.handle with a
call to that helper to centralize status, contentType, encoding, and
ExceptionResponse serialization.

In
`@linktrip-input-http/src/main/kotlin/com/linktrip/input/http/security/PostAuthorizationToken.kt`:
- Line 7: 현재 PostAuthorizationToken 클래스가 AbstractAuthenticationToken을
emptyList()로 초기화해 권한 정보를 고정하고 있으므로 향후 RBAC 확장이 어렵습니다; 수정안은
PostAuthorizationToken 생성자에 authorities: Collection<GrantedAuthority> (또는 기본값이
있는 오버로드 생성자)을 추가하고 해당 authorities를 super(authorities)로 전달하도록 변경하거나 내부에
GrantedAuthority 컬렉션 필드를 두고 getAuthorities()가 이를 반환하게 하여 관리자/사용자 등 역할 구분을 쉽게 확장할
수 있도록 하세요; 대상 심볼: PostAuthorizationToken 생성자,
AbstractAuthenticationToken(super(...)), getAuthorities().

In
`@linktrip-input-http/src/test/kotlin/com/linktrip/input/http/ratelimit/RateLimitInterceptorTest.kt`:
- Around line 37-39: Move the manual cleanup into a JUnit `@AfterEach` method so
the SecurityContextHolder is always cleared even if assertions fail: create an
`@AfterEach` annotated function (e.g., fun clearAuthentication()) that calls
SecurityContextHolder.clearContext() and remove all explicit
clearAuthentication() calls at the end of individual tests; ensure the method
name matches existing clearAuthentication to reuse the same logic.

In
`@linktrip-output-http/src/main/kotlin/com/linktrip/output/http/oauth/adapter/AppleOAuthAdapter.kt`:
- Around line 47-52: fetchApplePublicKeys is currently calling Apple on every
invocation; add caching to AppleOAuthAdapter to avoid repeated network calls by
storing the last ApplePublicKeyResponse along with its expiry (Apple keys rotate
infrequently). Implement a simple thread-safe cache (e.g., volatile field +
synchronized refresh or use a cache library like Caffeine) inside
AppleOAuthAdapter: on fetchApplePublicKeys return cached response if present and
not expired, otherwise call
restClient.get().uri("/auth/keys").retrieve().body<ApplePublicKeyResponse>(),
update the cache and expiry (configurable TTL), and fall back to the previous
cached value on transient failures; ensure exceptions still map to
LinktripException(ExceptionCode.OAUTH_PROVIDER_ERROR) when no valid cached value
exists.

In
`@linktrip-output-http/src/main/kotlin/com/linktrip/output/http/oauth/config/OAuthRestClientConfig.kt`:
- Around line 16-47: The three bean builders kakaoOAuthRestClient,
googleOAuthRestClient and appleOAuthRestClient repeat identical RestClient
construction (baseUrl, defaultStatusHandler, requestFactory), so extract a
single private helper like buildOAuthRestClient(baseUrl: String): RestClient
that uses clientHttpRequestFactory() and the shared defaultStatusHandler logic,
then have each `@Bean` method simply call that helper with its respective URL
constant (KAKAO_USER_INFO_URL, GOOGLE_USER_INFO_URL, APPLE_BASE_URL) to
centralize timeouts/error policy changes and avoid duplication.

In
`@linktrip-output-http/src/main/kotlin/com/linktrip/output/http/oauth/dto/AppleUserInfoResponse.kt`:
- Around line 3-14: The file name does not match the public data class declared
(file is AppleUserInfoResponse.kt but the class is ApplePublicKeyResponse);
rename the file to ApplePublicKeyResponse.kt so Kotlin file/class convention is
preserved and the public data class ApplePublicKeyResponse (and its nested
AppleKey) are discoverable and consistent.

In
`@linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/entity/MemberEntity.kt`:
- Around line 17-26: Remove the redundant index idx_member_provider from the
indexes list in MemberEntity (the unique constraint uk_member_provider on
columns provider_type and provider_id already creates an index), i.e., delete
the Index entry with name "idx_member_provider" (leave the UniqueConstraint
"uk_member_provider" and other indexes like "idx_member_email" intact) and run
schema migration tests to ensure no references depend on the removed index.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 6dc6c163-f73c-4922-8745-fa00fcf5fe45

📥 Commits

Reviewing files that changed from the base of the PR and between 373d563 and e6b85da.

📒 Files selected for processing (42)
  • gradle/libs.versions.toml
  • linktrip-application/src/main/kotlin/com/linktrip/application/domain/member/AuthService.kt
  • linktrip-application/src/main/kotlin/com/linktrip/application/domain/member/Member.kt
  • linktrip-application/src/main/kotlin/com/linktrip/application/domain/member/MemberService.kt
  • linktrip-application/src/main/kotlin/com/linktrip/application/domain/member/ProviderType.kt
  • linktrip-application/src/main/kotlin/com/linktrip/application/port/input/AuthUseCase.kt
  • linktrip-application/src/main/kotlin/com/linktrip/application/port/output/auth/OAuthInfo.kt
  • linktrip-application/src/main/kotlin/com/linktrip/application/port/output/auth/OAuthPort.kt
  • linktrip-application/src/main/kotlin/com/linktrip/application/port/output/persistence/MemberPort.kt
  • linktrip-application/src/test/kotlin/com/linktrip/application/domain/member/AuthServiceTest.kt
  • linktrip-common/src/main/kotlin/com/linktrip/common/exception/ExceptionCode.kt
  • linktrip-input-http/src/main/kotlin/com/linktrip/input/http/config/FilterConfig.kt
  • linktrip-input-http/src/main/kotlin/com/linktrip/input/http/config/WebMvcConfig.kt
  • linktrip-input-http/src/main/kotlin/com/linktrip/input/http/controller/AuthController.kt
  • linktrip-input-http/src/main/kotlin/com/linktrip/input/http/controller/dto/request/AuthRequest.kt
  • linktrip-input-http/src/main/kotlin/com/linktrip/input/http/controller/dto/response/AuthResponse.kt
  • linktrip-input-http/src/main/kotlin/com/linktrip/input/http/filter/FilterPaths.kt
  • linktrip-input-http/src/main/kotlin/com/linktrip/input/http/filter/JwtAuthenticationFilter.kt
  • linktrip-input-http/src/main/kotlin/com/linktrip/input/http/filter/MdcLoggingFilter.kt
  • linktrip-input-http/src/main/kotlin/com/linktrip/input/http/ratelimit/RateLimitInterceptor.kt
  • linktrip-input-http/src/main/kotlin/com/linktrip/input/http/security/AuthenticatedMember.kt
  • linktrip-input-http/src/main/kotlin/com/linktrip/input/http/security/AuthenticatedMemberArgumentResolver.kt
  • linktrip-input-http/src/main/kotlin/com/linktrip/input/http/security/JwtAuthenticationProvider.kt
  • linktrip-input-http/src/main/kotlin/com/linktrip/input/http/security/JwtSecurityFilter.kt
  • linktrip-input-http/src/main/kotlin/com/linktrip/input/http/security/JwtTokenProvider.kt
  • linktrip-input-http/src/main/kotlin/com/linktrip/input/http/security/PostAuthorizationToken.kt
  • linktrip-input-http/src/main/kotlin/com/linktrip/input/http/security/PreAuthorizationToken.kt
  • linktrip-input-http/src/main/kotlin/com/linktrip/input/http/security/SecurityConfig.kt
  • linktrip-input-http/src/main/kotlin/com/linktrip/input/http/security/SecurityPaths.kt
  • linktrip-input-http/src/main/kotlin/com/linktrip/input/http/security/handler/JwtAccessDeniedHandler.kt
  • linktrip-input-http/src/main/kotlin/com/linktrip/input/http/security/handler/JwtAuthenticationEntryPoint.kt
  • linktrip-input-http/src/test/kotlin/com/linktrip/input/http/ratelimit/RateLimitInterceptorTest.kt
  • linktrip-output-http/src/main/kotlin/com/linktrip/output/http/oauth/adapter/AppleOAuthAdapter.kt
  • linktrip-output-http/src/main/kotlin/com/linktrip/output/http/oauth/adapter/GoogleOAuthAdapter.kt
  • linktrip-output-http/src/main/kotlin/com/linktrip/output/http/oauth/adapter/KakaoOAuthAdapter.kt
  • linktrip-output-http/src/main/kotlin/com/linktrip/output/http/oauth/config/OAuthRestClientConfig.kt
  • linktrip-output-http/src/main/kotlin/com/linktrip/output/http/oauth/dto/AppleUserInfoResponse.kt
  • linktrip-output-http/src/main/kotlin/com/linktrip/output/http/oauth/dto/GoogleUserInfoResponse.kt
  • linktrip-output-http/src/main/kotlin/com/linktrip/output/http/oauth/dto/KakaoUserInfoResponse.kt
  • linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/adapter/MemberAdapter.kt
  • linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/entity/MemberEntity.kt
  • linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/repository/MemberJpaRepository.kt
💤 Files with no reviewable changes (3)
  • linktrip-input-http/src/main/kotlin/com/linktrip/input/http/filter/FilterPaths.kt
  • linktrip-input-http/src/main/kotlin/com/linktrip/input/http/config/FilterConfig.kt
  • linktrip-input-http/src/main/kotlin/com/linktrip/input/http/filter/JwtAuthenticationFilter.kt
📜 Review details
🧰 Additional context used
🧠 Learnings (1)
📚 Learning: 2026-03-18T01:07:53.575Z
Learnt from: toychip
Repo: Link-Trip/BackEnd PR: 24
File: linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/adapter/YouTubeVideoPersistenceAdapter.kt:16-28
Timestamp: 2026-03-18T01:07:53.575Z
Learning: Apply the same deferred concurrent-write policy to all Kotlin persistence adapters in the MySQL adapter package: ensure writes are coordinated on a single server with ShedLock considered for the replication phase. For YouTubeVideoPersistenceAdapter.kt (and other adapters in this directory), verify that concurrent writes are serialized or properly guarded, document the policy in code comments, and ensure CI checks or deployment gating will catch any regression where multiple instances might attempt a conflicting write during replication.

Applied to files:

  • linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/adapter/MemberAdapter.kt
🪛 GitHub Actions: CI - Pull request
linktrip-input-http/src/main/kotlin/com/linktrip/input/http/config/WebMvcConfig.kt

[error] 3-3: ktlintCheck (ktlintMainSourceSetCheck) failed: Imports must be ordered in lexicographic order without any empty lines in-between with "java", "javax", "kotlin" and aliases in the end.

linktrip-input-http/src/main/kotlin/com/linktrip/input/http/controller/dto/response/AuthResponse.kt

[error] 1-1: ktlintCheck (ktlintMainSourceSetCheck) failed: File 'AuthResponse.kt' contains a single class and should be named same after that class 'OAuthLoginResponse.kt' (cannot be auto-corrected).

linktrip-input-http/src/main/kotlin/com/linktrip/input/http/controller/dto/request/AuthRequest.kt

[error] 1-1: ktlintCheck (ktlintMainSourceSetCheck) failed: File 'AuthRequest.kt' contains a single class and should be named same after that class 'OAuthLoginRequest.kt' (cannot be auto-corrected).

linktrip-input-http/src/main/kotlin/com/linktrip/input/http/security/AuthenticatedMemberArgumentResolver.kt

[error] 25-25: ktlintCheck (ktlintMainSourceSetCheck) failed: A multiline expression should start on a new line (line 25, column 30).

🔇 Additional comments (28)
linktrip-input-http/src/main/kotlin/com/linktrip/input/http/filter/MdcLoggingFilter.kt (1)

28-28: 리팩토링 방향 적절함

삭제된 FilterPaths 클래스에서 로컬 companion object로 LOGGING_SKIP 상수를 이동한 것은 적절합니다. 이 필터에서만 사용되는 상수이므로 응집도가 높아졌습니다.

linktrip-application/src/main/kotlin/com/linktrip/application/domain/member/ProviderType.kt (1)

3-7: Provider enum 정의가 현재 OAuth 범위와 일관됩니다.

Line 3-7 구조가 단순하고 명확하며, 영속 계층의 EnumType.STRING 저장 방식과도 잘 맞습니다.

linktrip-input-http/src/main/kotlin/com/linktrip/input/http/security/JwtTokenProvider.kt (1)

25-35: 토큰 발급 로직은 간결하고 안전한 흐름입니다.

Line 25-35에서 iat/exp와 서명 처리 순서가 명확합니다.

linktrip-common/src/main/kotlin/com/linktrip/common/exception/ExceptionCode.kt (1)

11-11: OAuth 예외 코드 추가 방향이 적절합니다.

Line 11과 Line 41이 입력 오류(400)와 외부 제공자 장애(502)를 분리해 운영 관점에서도 해석이 좋습니다.

Also applies to: 41-41

linktrip-application/src/main/kotlin/com/linktrip/application/port/output/auth/OAuthInfo.kt (1)

5-9: OAuth 사용자 식별 DTO 구조가 깔끔합니다.

Line 5-9는 provider 식별자와 nullable 이메일을 분리해 provider별 응답 편차를 잘 수용합니다.

linktrip-application/src/main/kotlin/com/linktrip/application/port/output/auth/OAuthPort.kt (1)

5-9: 포트 계약이 목적에 맞게 최소화되어 있습니다.

Line 5-9 인터페이스는 provider 라우팅과 사용자 정보 조회 책임을 분리해 확장하기 좋습니다.

gradle/libs.versions.toml (1)

32-32: 보안 리팩터링에 필요한 의존성 연결이 잘 반영되었습니다.

Line 32, Line 92, Line 97 변경으로 input/output HTTP 모듈의 Security + JWT 요구사항이 일관되게 맞춰졌습니다.

Also applies to: 92-92, 97-97

linktrip-output-http/src/main/kotlin/com/linktrip/output/http/oauth/dto/KakaoUserInfoResponse.kt (1)

5-13: Kakao 응답 DTO 매핑이 어댑터 사용 방식과 일치합니다.

Line 5-13의 id: Longkakao_account 매핑이 KakaoOAuthAdapter의 변환 로직과 정확히 맞습니다.

linktrip-input-http/src/main/kotlin/com/linktrip/input/http/security/handler/JwtAccessDeniedHandler.kt (1)

13-37: LGTM!

Spring Security의 AccessDeniedHandler를 올바르게 구현하였습니다. JSON 응답 형식, Content-Type 설정, 문자 인코딩 처리가 적절합니다.

linktrip-application/src/main/kotlin/com/linktrip/application/domain/member/Member.kt (1)

6-26: LGTM!

OAuth 기반 인증을 위한 도메인 모델 변경이 적절합니다. email을 nullable로 처리한 것은 일부 OAuth 제공자가 이메일을 제공하지 않을 수 있으므로 올바른 설계입니다. providerTypeproviderId 조합으로 사용자를 고유하게 식별하는 구조가 잘 반영되었습니다.

linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/entity/MemberEntity.kt (1)

28-48: LGTM!

Entity 구현이 적절합니다. EnumType.STRING 사용, nullable email 처리, toDomain() 매핑이 도메인 모델과 일관성 있게 구현되었습니다.

linktrip-input-http/src/main/kotlin/com/linktrip/input/http/security/AuthenticatedMember.kt (1)

1-5: LGTM!

보안 관련 컴포넌트를 security 패키지로 통합하는 리팩토링에 맞춰 패키지가 적절히 변경되었습니다.

linktrip-output-http/src/main/kotlin/com/linktrip/output/http/oauth/dto/GoogleUserInfoResponse.kt (1)

3-6: LGTM!

Google OAuth userinfo 응답을 위한 DTO가 적절하게 정의되었습니다. email을 nullable로 처리한 것은 OAuth scope에 따라 이메일이 제공되지 않을 수 있으므로 올바른 설계입니다.

linktrip-input-http/src/main/kotlin/com/linktrip/input/http/controller/dto/request/AuthRequest.kt (1)

10-11: OAuthLoginRequest.kt 파일명 수정 필요 (linktrip-input-http/src/main/kotlin/com/linktrip/input/http/controller/dto/request/OAuthLoginRequest.kt)

ProviderType enum이 KAKAO, GOOGLE, APPLE 같은 대문자 상수를 사용하고 있으며, Jackson 커스텀 설정이 없는 상태입니다. 클라이언트가 소문자("kakao")로 전송하면 역직렬화가 실패합니다. 클라이언트 API 문서에서 대문자 사용을 명시하거나, 대소문자 무관하게 처리하려면 enum에 @JsonCreator를 추가하는 것을 고려하세요.

			> Likely an incorrect or invalid review comment.
linktrip-application/src/main/kotlin/com/linktrip/application/port/input/AuthUseCase.kt (1)

3-9: LGTM!

OAuth 기반 소셜 로그인으로의 전환이 인터페이스에 잘 반영되었습니다. ProviderTypeaccessToken을 매개변수로 받는 설계가 명확하고, 헥사고날 아키텍처의 입력 포트로서 적절합니다.

linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/repository/MemberJpaRepository.kt (1)

7-14: LGTM!

OAuth 기반 멤버 조회를 위한 리포지토리 메서드가 Spring Data JPA 명명 규칙에 맞게 잘 정의되었습니다. AI 요약에 따르면 MemberEntityproviderType + providerId 복합 인덱스와 email 인덱스가 설정되어 있어 쿼리 성능도 적절히 고려된 것으로 보입니다.

linktrip-application/src/main/kotlin/com/linktrip/application/port/output/persistence/MemberPort.kt (1)

7-12: LGTM!

OAuth 기반 회원 조회를 위한 포트 인터페이스가 적절히 설계되었습니다.

linktrip-input-http/src/main/kotlin/com/linktrip/input/http/security/PreAuthorizationToken.kt (1)

5-15: LGTM!

Spring Security의 AbstractAuthenticationToken을 올바르게 확장하여 인증 전 토큰을 구현했습니다. JWT를 credentials로 저장하고, isAuthenticated = false로 초기화하는 패턴이 적절합니다.

linktrip-input-http/src/main/kotlin/com/linktrip/input/http/security/PostAuthorizationToken.kt (1)

5-15: LGTM!

인증 완료 후 토큰 구현이 적절합니다. memberId를 principal로 저장하여 다운스트림 컴포넌트에서 사용자를 식별할 수 있습니다.

linktrip-input-http/src/main/kotlin/com/linktrip/input/http/ratelimit/RateLimitInterceptor.kt (1)

21-23: LGTM!

SecurityContextHolder에서 memberId를 가져오도록 변경한 것은 새로운 JWT 보안 파이프라인과 잘 맞습니다. 인증되지 않은 요청에 대해 return true로 처리하여 rate limit을 적용하지 않는 것은 공개 엔드포인트를 위한 적절한 설계입니다.

linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/adapter/MemberAdapter.kt (1)

14-21: LGTM!

MemberPort 인터페이스를 올바르게 구현했습니다. findByProviderTypeAndProviderIdfindByEmail 메서드가 JPA 리포지토리에 적절히 위임하고 toDomain()을 통해 매핑합니다. 학습된 내용에 따른 단일 서버 쓰기 정책도 유지되고 있습니다.

linktrip-application/src/test/kotlin/com/linktrip/application/domain/member/AuthServiceTest.kt (3)

33-38: LGTM!

테스트 설정이 프로덕션 코드의 생성자 시그니처와 정확히 일치합니다. MemberService를 실제로 생성하고 MemberPort만 모킹하는 전략은 OAuth 로그인 플로우의 통합 동작을 효과적으로 검증합니다.


40-65: LGTM!

기존 회원 로그인 테스트가 적절합니다. memberPort.save가 호출되지 않았음을 검증하고 isNewMemberfalse임을 확인하여 기존 회원 플로우를 정확히 테스트합니다.


67-93: LGTM!

신규 회원 등록 테스트가 memberPort.save 호출과 isNewMember == true 검증을 통해 신규 가입 플로우를 정확히 커버합니다.

linktrip-input-http/src/main/kotlin/com/linktrip/input/http/controller/AuthController.kt (1)

18-40: LGTM!

OAuth 소셜 로그인으로의 전환이 깔끔하게 구현되었습니다. isNewMember 플래그에 따라 201 Created / 200 OK를 반환하는 것은 RESTful 설계 원칙에 부합합니다. @Valid 어노테이션으로 입력 검증도 적절히 처리됩니다.

linktrip-output-http/src/main/kotlin/com/linktrip/output/http/oauth/adapter/GoogleOAuthAdapter.kt (1)

23-39: LGTM!

Google OAuth 어댑터 구현이 적절합니다. RestClient 주입, 에러 처리, 그리고 OAuthInfo 매핑이 다른 어댑터들과 일관성 있게 구현되어 있습니다.

linktrip-output-http/src/main/kotlin/com/linktrip/output/http/oauth/adapter/KakaoOAuthAdapter.kt (1)

23-39: LGTM!

Kakao OAuth 어댑터가 잘 구현되었습니다. response.id.toString()으로 Long을 String으로 변환하여 providerId의 일관된 타입을 유지하고, kakaoAccount?.email로 선택적 이메일을 안전하게 처리합니다.

linktrip-input-http/src/main/kotlin/com/linktrip/input/http/security/JwtAuthenticationProvider.kt (1)

13-25: JWT 인증 흐름(검증 → memberId 추출 → 인증 토큰 반환)이 명확하고 일관적입니다.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

♻️ Duplicate comments (1)
linktrip-output-http/src/main/kotlin/com/linktrip/output/http/oauth/adapter/AppleOAuthAdapter.kt (1)

74-78: ⚠️ Potential issue | 🟠 Major

Apple 토큰의 iss/aud 클레임 검증이 여전히 빠져 있습니다.

Line 74~78은 서명만 확인하고 있어, 다른 클라이언트 대상으로 발급된 토큰 재사용을 차단하지 못합니다. iss=https://appleid.apple.com와 기대 aud(Service ID/Bundle ID) 검증을 추가해 주세요. (이 이슈는 이전 리뷰와 동일한 미해결 항목입니다.)

🔒 제안 수정
+    `@org.springframework.beans.factory.annotation.Value`("\${oauth.apple.audience}")
+    private lateinit var expectedAudience: String
@@
-        return Jwts.parser()
+        val claims = Jwts.parser()
             .verifyWith(publicKey)
             .build()
             .parseSignedClaims(idToken)
             .payload
+
+        if (claims.issuer != "https://appleid.apple.com") {
+            throw LinktripException(ExceptionCode.TOKEN_INVALID)
+        }
+
+        val aud = claims["aud"]
+        val audienceMatched =
+            when (aud) {
+                is String -> aud == expectedAudience
+                is Collection<*> -> aud.any { it == expectedAudience }
+                else -> false
+            }
+        if (!audienceMatched) {
+            throw LinktripException(ExceptionCode.TOKEN_INVALID)
+        }
+        return claims
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@linktrip-output-http/src/main/kotlin/com/linktrip/output/http/oauth/adapter/AppleOAuthAdapter.kt`
around lines 74 - 78, The current token parsing in AppleOAuthAdapter (using
Jwts.parser().verifyWith(publicKey).parseSignedClaims(idToken)) only verifies
signature; add explicit checks that the parsed payload's "iss" equals
"https://appleid.apple.com" and that "aud" matches the expected client/service
id (e.g., your configured Apple clientId/serviceId value), and reject/throw if
either check fails; update the verification flow around
parseSignedClaims(idToken) to retrieve the payload, compare payload.get("iss")
and payload.get("aud") against the expected constants/config, and return only
when both match.
🧹 Nitpick comments (1)
linktrip-output-http/src/main/kotlin/com/linktrip/output/http/oauth/adapter/AppleOAuthAdapter.kt (1)

47-52: Apple 공개키 호출은 짧은 TTL 캐시를 두는 것을 권장합니다.

매 로그인마다 /auth/keys를 호출하면 지연/외부장애 전파 리스크가 커집니다. in-memory 캐시(예: 5~15분) + 실패 시 마지막 성공값 fallback을 두면 인증 가용성이 좋아집니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@linktrip-output-http/src/main/kotlin/com/linktrip/output/http/oauth/adapter/AppleOAuthAdapter.kt`
around lines 47 - 52, The fetchApplePublicKeys function currently calls
restClient.get().uri("/auth/keys") on every login; change it to use an in-memory
TTL cache (e.g., 5–15 minutes) holding the last successful
ApplePublicKeyResponse and a timestamp, returning the cached value while fresh;
on cache miss, attempt the network call, update the cache on success, and on
network failure return the last successful cached ApplePublicKeyResponse as a
fallback and only throw LinktripException if there is no cached value. Modify
fetchApplePublicKeys (and related private state: cached ApplePublicKeyResponse
and cached timestamp) to be thread-safe (synchronized or atomic) so concurrent
requests use the cache correctly.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@linktrip-output-http/src/main/kotlin/com/linktrip/output/http/oauth/adapter/AppleOAuthAdapter.kt`:
- Around line 58-67: Wrap the Base64 decoding and Jackson parsing around
headerPart/headerJson/headerMap (where you call Base64.getUrlDecoder().decode
and objectMapper.readValue and extract kid) in a try/catch that converts any
thrown exception into throw LinktripException(ExceptionCode.TOKEN_INVALID); do
the same for the subsequent JWT parsing block (the code that validates/parses
the JWT after extracting kid) so any parsing/validation errors are consistently
rethrown as LinktripException(ExceptionCode.TOKEN_INVALID). Ensure you catch
generic Exception (or relevant parsing exceptions) and rethrow the
LinktripException at points interacting with idToken, headerPart, headerJson,
headerMap, and kid.
- Around line 35-43: claims.subject (assigned to providerId) can be null or
blank for invalid tokens; update the AppleOAuthAdapter code that builds
OAuthInfo to validate providerId (from claims.subject) and explicitly handle
null/blank by returning/throwing the TOKEN_INVALID error path instead of using
the value directly. Locate the block that reads val providerId = claims.subject
and before constructing OAuthInfo, add a check for providerId.isNullOrBlank()
and route to the existing TOKEN_INVALID handling (e.g., throw the same exception
or return the same error result used elsewhere), keeping the rest of OAuthInfo
construction (providerType = ProviderType.APPLE, email, etc.) only for valid
providerId.

---

Duplicate comments:
In
`@linktrip-output-http/src/main/kotlin/com/linktrip/output/http/oauth/adapter/AppleOAuthAdapter.kt`:
- Around line 74-78: The current token parsing in AppleOAuthAdapter (using
Jwts.parser().verifyWith(publicKey).parseSignedClaims(idToken)) only verifies
signature; add explicit checks that the parsed payload's "iss" equals
"https://appleid.apple.com" and that "aud" matches the expected client/service
id (e.g., your configured Apple clientId/serviceId value), and reject/throw if
either check fails; update the verification flow around
parseSignedClaims(idToken) to retrieve the payload, compare payload.get("iss")
and payload.get("aud") against the expected constants/config, and return only
when both match.

---

Nitpick comments:
In
`@linktrip-output-http/src/main/kotlin/com/linktrip/output/http/oauth/adapter/AppleOAuthAdapter.kt`:
- Around line 47-52: The fetchApplePublicKeys function currently calls
restClient.get().uri("/auth/keys") on every login; change it to use an in-memory
TTL cache (e.g., 5–15 minutes) holding the last successful
ApplePublicKeyResponse and a timestamp, returning the cached value while fresh;
on cache miss, attempt the network call, update the cache on success, and on
network failure return the last successful cached ApplePublicKeyResponse as a
fallback and only throw LinktripException if there is no cached value. Modify
fetchApplePublicKeys (and related private state: cached ApplePublicKeyResponse
and cached timestamp) to be thread-safe (synchronized or atomic) so concurrent
requests use the cache correctly.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 770e5454-a29a-4c07-b1d3-a17be53ed2d3

📥 Commits

Reviewing files that changed from the base of the PR and between e6b85da and 262ad57.

📒 Files selected for processing (6)
  • linktrip-input-http/src/main/kotlin/com/linktrip/input/http/config/WebMvcConfig.kt
  • linktrip-input-http/src/main/kotlin/com/linktrip/input/http/controller/dto/request/OAuthLoginRequest.kt
  • linktrip-input-http/src/main/kotlin/com/linktrip/input/http/controller/dto/response/OAuthLoginResponse.kt
  • linktrip-input-http/src/main/kotlin/com/linktrip/input/http/security/AuthenticatedMemberArgumentResolver.kt
  • linktrip-output-http/src/main/kotlin/com/linktrip/output/http/oauth/adapter/AppleOAuthAdapter.kt
  • linktrip-output-http/src/main/kotlin/com/linktrip/output/http/oauth/dto/ApplePublicKeyResponse.kt
✅ Files skipped from review due to trivial changes (3)
  • linktrip-input-http/src/main/kotlin/com/linktrip/input/http/config/WebMvcConfig.kt
  • linktrip-output-http/src/main/kotlin/com/linktrip/output/http/oauth/dto/ApplePublicKeyResponse.kt
  • linktrip-input-http/src/main/kotlin/com/linktrip/input/http/controller/dto/request/OAuthLoginRequest.kt
🚧 Files skipped from review as they are similar to previous changes (1)
  • linktrip-input-http/src/main/kotlin/com/linktrip/input/http/security/AuthenticatedMemberArgumentResolver.kt
📜 Review details
🔇 Additional comments (2)
linktrip-input-http/src/main/kotlin/com/linktrip/input/http/controller/dto/response/OAuthLoginResponse.kt (1)

3-7: LGTM!

DTO 구조가 AuthUseCase.AuthResult 인터페이스 계약과 정확히 일치하며, isNewMember 플래그를 통해 컨트롤러에서 신규 회원(201 Created)과 기존 회원(200 OK) 응답을 구분할 수 있습니다. OAuth 로그인 플로우에 적합한 깔끔한 구현입니다.

linktrip-output-http/src/main/kotlin/com/linktrip/output/http/oauth/adapter/AppleOAuthAdapter.kt (1)

81-87: RSA 공개키 생성 로직은 명확하고 구현 방향이 좋습니다.

n/eRSAPublicKeySpec으로 변환하는 흐름이 직관적이고 유지보수성이 좋습니다.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
linktrip-input-http/src/main/kotlin/com/linktrip/input/http/security/JwtTokenProvider.kt (1)

37-72: 선택적 개선: JWT 이중 파싱 고려

validate()extractMemberId()가 각각 JWT를 파싱하여 유효한 토큰의 경우 이중 파싱이 발생합니다. 현재 구조에서 큰 문제는 아니지만, 향후 파싱 결과를 캐싱하거나 단일 메서드로 검증과 추출을 동시에 수행하는 방식을 고려할 수 있습니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@linktrip-input-http/src/main/kotlin/com/linktrip/input/http/security/JwtTokenProvider.kt`
around lines 37 - 72, validate() and extractMemberId() each parse the JWT
separately causing duplicate parsing; create a single private helper (e.g.,
parseSignedClaims(token): SignedJwtClaims or similar) that wraps
Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token) and
centralizes the try/catch for ExpiredJwtException, JwtException, and
IllegalArgumentException; then have validate() call the helper and return
true/false based on success, and have extractMemberId() call the same helper and
read .payload.subject (rethrowing LinktripException where appropriate) so
parsing logic is only implemented once (reference functions: validate,
extractMemberId and
Jwts.parser().verifyWith(secretKey).build().parseSignedClaims).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@linktrip-output-http/src/main/kotlin/com/linktrip/output/http/oauth/adapter/AppleOAuthAdapter.kt`:
- Around line 52-57: fetchApplePublicKeys currently only maps a null body to
LinktripException(ExceptionCode.OAUTH_PROVIDER_ERROR) but lets
RestClientException from restClient.get()/retrieve() bubble up; wrap the REST
call in a try/catch that catches RestClientException (and any transport-related
exceptions you use) and rethrow a LinktripException with
ExceptionCode.OAUTH_PROVIDER_ERROR, keeping the existing null-body check for
ApplePublicKeyResponse; update the method around fetchApplePublicKeys /
restClient.get() / retrieve() to ensure all external API failures are converted
to the same domain exception (LinktripException with
ExceptionCode.OAUTH_PROVIDER_ERROR).

---

Nitpick comments:
In
`@linktrip-input-http/src/main/kotlin/com/linktrip/input/http/security/JwtTokenProvider.kt`:
- Around line 37-72: validate() and extractMemberId() each parse the JWT
separately causing duplicate parsing; create a single private helper (e.g.,
parseSignedClaims(token): SignedJwtClaims or similar) that wraps
Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token) and
centralizes the try/catch for ExpiredJwtException, JwtException, and
IllegalArgumentException; then have validate() call the helper and return
true/false based on success, and have extractMemberId() call the same helper and
read .payload.subject (rethrowing LinktripException where appropriate) so
parsing logic is only implemented once (reference functions: validate,
extractMemberId and
Jwts.parser().verifyWith(secretKey).build().parseSignedClaims).

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: bdf3639a-1f78-4a3f-84cd-e96efe2a38b7

📥 Commits

Reviewing files that changed from the base of the PR and between 262ad57 and a6a2d43.

📒 Files selected for processing (12)
  • linktrip-application/src/main/kotlin/com/linktrip/application/domain/member/AuthService.kt
  • linktrip-application/src/main/kotlin/com/linktrip/application/port/output/persistence/MemberPort.kt
  • linktrip-bootstrap/src/main/resources/application.yml
  • linktrip-input-http/src/main/kotlin/com/linktrip/input/http/security/JwtAuthenticationProvider.kt
  • linktrip-input-http/src/main/kotlin/com/linktrip/input/http/security/JwtSecurityFilter.kt
  • linktrip-input-http/src/main/kotlin/com/linktrip/input/http/security/JwtTokenProvider.kt
  • linktrip-input-http/src/main/kotlin/com/linktrip/input/http/security/SecurityConfig.kt
  • linktrip-input-http/src/main/kotlin/com/linktrip/input/http/security/SecurityPaths.kt
  • linktrip-output-http/src/main/kotlin/com/linktrip/output/http/oauth/adapter/AppleOAuthAdapter.kt
  • linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/adapter/MemberAdapter.kt
  • linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/entity/MemberEntity.kt
  • linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/repository/MemberJpaRepository.kt
✅ Files skipped from review due to trivial changes (2)
  • linktrip-bootstrap/src/main/resources/application.yml
  • linktrip-input-http/src/main/kotlin/com/linktrip/input/http/security/SecurityPaths.kt
🚧 Files skipped from review as they are similar to previous changes (5)
  • linktrip-application/src/main/kotlin/com/linktrip/application/port/output/persistence/MemberPort.kt
  • linktrip-input-http/src/main/kotlin/com/linktrip/input/http/security/JwtSecurityFilter.kt
  • linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/adapter/MemberAdapter.kt
  • linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/entity/MemberEntity.kt
  • linktrip-application/src/main/kotlin/com/linktrip/application/domain/member/AuthService.kt
📜 Review details
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: 자동 검증 (ktlint + test)
🧰 Additional context used
🪛 detekt (1.23.8)
linktrip-output-http/src/main/kotlin/com/linktrip/output/http/oauth/adapter/AppleOAuthAdapter.kt

[warning] 88-88: The caught exception is swallowed. The original exception could be lost.

(detekt.exceptions.SwallowedException)

🔇 Additional comments (9)
linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/repository/MemberJpaRepository.kt (2)

3-3: 승인: ProviderType import 반영이 적절합니다.

회원 식별 모델 전환 방향과 일치하는 변경입니다.


8-11: 승인: 저장소 조회 시그니처가 신규 식별 체계와 정확히 맞습니다.

findByProviderTypeAndProviderId(...) 도입으로 provider 기반 회원 조회 의도가 명확해졌습니다.

linktrip-input-http/src/main/kotlin/com/linktrip/input/http/security/SecurityConfig.kt (3)

31-31: PUBLIC_ENDPOINTS 범위 검증 필요 (기존 리뷰 참조)

SecurityPaths.PUBLIC_ENDPOINTS에 포함된 경로가 운영 환경에서 적절한지 확인이 필요합니다. Swagger, Actuator, 테스트 전용 경로가 포함되어 있다면 프로파일별로 분리하는 것이 권장됩니다.


27-37: @Order 적용 확인 - 이전 리뷰 이슈 해결됨

publicFilterChain@Order(1), protectedFilterChain@Order(2)가 적용되어 공개 엔드포인트 매처가 먼저 평가됩니다. 이전 리뷰에서 지적된 필터 체인 순서 문제가 해결되었습니다.


39-59: 보호된 필터 체인 설정 LGTM

적절한 구성입니다:

  • exceptionHandlingauthenticationEntryPointaccessDeniedHandler가 올바르게 연결되었습니다.
  • JwtSecurityFilterUsernamePasswordAuthenticationFilter 앞에 추가되어 JWT 인증이 먼저 수행됩니다.
  • Stateless 세션 관리, CSRF 비활성화 등 JWT 기반 API에 적합한 설정입니다.
linktrip-input-http/src/main/kotlin/com/linktrip/input/http/security/JwtTokenProvider.kt (2)

50-53: IllegalArgumentException 처리 추가 LGTM

jjwt 라이브러리가 빈 문자열이나 null 토큰에 대해 IllegalArgumentException을 던지므로, 이를 명시적으로 처리하는 것은 적절합니다. 상위 레이어(JwtAuthenticationProvider)에서 BadCredentialsException으로 래핑되어 JwtSecurityFilter에서 올바르게 처리됩니다.


69-72: 예외 처리 일관성 확인

extractMemberId()에서 IllegalArgumentException 처리가 기존 JwtException 처리와 일관되게 구현되었습니다. LinktripException(TOKEN_INVALID)JwtAuthenticationProvider의 generic exception handler에서 BadCredentialsException으로 래핑되어 적절히 처리됩니다.

linktrip-input-http/src/main/kotlin/com/linktrip/input/http/security/JwtAuthenticationProvider.kt (1)

9-32: Spring Security AuthenticationProvider 구현 LGTM

구현이 적절합니다:

  • supports()PreAuthorizationToken만 처리하도록 제한하고, PreAuthorizationToken.getCredentials()String을 반환하므로 Line 14의 캐스트는 타입 안전합니다.
  • 예외 처리가 방어적으로 구현되어 BadCredentialsException은 그대로 전파하고, 다른 예외는 래핑합니다.
  • JwtSecurityFilterAuthenticationException을 적절히 처리하여 전체 인증 흐름이 올바르게 동작합니다.
linktrip-output-http/src/main/kotlin/com/linktrip/output/http/oauth/adapter/AppleOAuthAdapter.kt (1)

37-40: providerId(sub) null/blank 방어가 깔끔합니다.

Line 37-40에서 claims.subject를 검증하고 TOKEN_INVALID로 실패 처리한 점은 인증 안정성 측면에서 좋습니다.

@toychip toychip force-pushed the feat/#42-spring-security-oauth branch from a6a2d43 to 4091f77 Compare March 30, 2026 03:43
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 4

♻️ Duplicate comments (1)
linktrip-output-http/src/main/kotlin/com/linktrip/output/http/oauth/adapter/AppleOAuthAdapter.kt (1)

52-57: ⚠️ Potential issue | 🟠 Major

Apple 공개키 조회 시 예외 처리가 누락되었습니다.

restClient.get() 호출 중 발생하는 RestClientException이 도메인 예외로 변환되지 않아 500 에러로 노출될 수 있습니다.

🛡️ 제안 수정
-    private fun fetchApplePublicKeys(): ApplePublicKeyResponse =
-        restClient.get()
-            .uri("/auth/keys")
-            .retrieve()
-            .body<ApplePublicKeyResponse>()
-            ?: throw LinktripException(ExceptionCode.OAUTH_PROVIDER_ERROR)
+    private fun fetchApplePublicKeys(): ApplePublicKeyResponse =
+        try {
+            restClient.get()
+                .uri("/auth/keys")
+                .retrieve()
+                .body<ApplePublicKeyResponse>()
+                ?: throw LinktripException(ExceptionCode.OAUTH_PROVIDER_ERROR)
+        } catch (e: LinktripException) {
+            throw e
+        } catch (e: Exception) {
+            logger.warn(e) { "Apple 공개키 조회 실패" }
+            throw LinktripException(ExceptionCode.OAUTH_PROVIDER_ERROR)
+        }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@linktrip-output-http/src/main/kotlin/com/linktrip/output/http/oauth/adapter/AppleOAuthAdapter.kt`
around lines 52 - 57, The fetchApplePublicKeys method in AppleOAuthAdapter calls
restClient.get() but doesn't catch RestClientException; wrap the REST call in a
try-catch inside fetchApplePublicKeys, catch RestClientException (and optionally
other transport exceptions), and rethrow a LinktripException with
ExceptionCode.OAUTH_PROVIDER_ERROR (include the caught exception as the cause or
message) so transport errors are translated to your domain error; keep the
original body<ApplePublicKeyResponse>() null check and throw as before if
response is null.
🧹 Nitpick comments (3)
linktrip-input-http/src/main/kotlin/com/linktrip/input/http/security/JwtAuthenticationProvider.kt (1)

13-27: credentials 캐스팅 시 안전한 처리를 권장합니다.

Line 14에서 authentication.credentials as String 캐스팅이 실패할 경우 ClassCastException이 발생할 수 있습니다. supports() 메서드로 타입이 제한되어 있지만, 방어적 코딩을 권장합니다.

♻️ 제안 수정
     override fun authenticate(authentication: Authentication): Authentication {
-        val jwt = authentication.credentials as String
+        val jwt = authentication.credentials as? String
+            ?: throw BadCredentialsException("JWT 토큰이 제공되지 않았습니다.")
 
         try {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@linktrip-input-http/src/main/kotlin/com/linktrip/input/http/security/JwtAuthenticationProvider.kt`
around lines 13 - 27, The authenticate method in JwtAuthenticationProvider
unsafely casts authentication.credentials to String which can throw
ClassCastException; update JwtAuthenticationProvider.authenticate to defensively
handle credentials by checking type (or using a safe cast) and throwing a
BadCredentialsException if credentials is null or not a String before calling
tokenProvider.validate/extractMemberId, then continue to return
PostAuthorizationToken(memberId) as before and retain the existing exception
handling for other errors.
linktrip-input-http/src/main/kotlin/com/linktrip/input/http/security/JwtTokenProvider.kt (1)

23-23: toByteArray() 호출 시 명시적 charset 지정을 권장합니다.

toByteArray()는 플랫폼 기본 charset을 사용합니다. 서로 다른 환경(로컬/서버)에서 charset이 다를 경우 키가 달라져 토큰 검증이 실패할 수 있습니다.

♻️ 제안 diff
-    private val secretKey: SecretKey = Keys.hmacShaKeyFor(secretKeyString.toByteArray())
+    private val secretKey: SecretKey = Keys.hmacShaKeyFor(secretKeyString.toByteArray(Charsets.UTF_8))
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@linktrip-input-http/src/main/kotlin/com/linktrip/input/http/security/JwtTokenProvider.kt`
at line 23, The secretKey initialization in JwtTokenProvider currently calls
secretKeyString.toByteArray() which uses the platform default charset; change
this to use an explicit charset (e.g., StandardCharsets.UTF_8) when converting
secretKeyString to bytes so the key is consistent across environments—update the
expression that constructs secretKey (the line assigning private val secretKey:
SecretKey) to use the explicit charset.
linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/adapter/MemberAdapter.kt (1)

23-30: 동시쓰기 가정(단일 서버/추후 분산락)을 코드에 짧게 명시해 두는 것을 권장합니다.

현재 구현은 중복 저장을 DB 제약 + 재조회로 흡수하고 있어 실용적입니다. 다만 운영 가정이 코드에 드러나지 않아, 다중 인스턴스 전환 시 회귀 위험이 있습니다. 간단한 주석(TODO)로 ShedLock 도입 시점을 남겨두면 좋겠습니다.

주석 추가 예시 diff
     override fun save(member: Member): Member =
         try {
             memberJpaRepository.save(MemberEntity.from(member)).toDomain()
         } catch (ex: DuplicateKeyException) {
+            // NOTE: 현재는 단일 서버 운영 가정에서 DB 유니크 제약 + 재조회로 동시 저장을 완화.
+            // 멀티 인스턴스/복제 환경 전환 시 ShedLock 등 분산 락 도입 필요.
             logger.warn { "동시 요청으로 인한 중복 회원 저장 시도 감지: provider=${member.providerType}" }
             memberJpaRepository.findByProviderTypeAndProviderId(member.providerType, member.providerId)
                 ?.toDomain()
                 ?: throw ex
         } catch (ex: DataIntegrityViolationException) {

Based on learnings: In linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/adapter/YouTubeVideoPersistenceAdapter.kt, concurrent-write safety is currently deferred under a single-server assumption, and ShedLock is planned when replication is introduced.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/adapter/MemberAdapter.kt`
around lines 23 - 30, Add a short TODO comment in MemberAdapter.save (the
try/catch that handles DataIntegrityViolationException and re-queries via
memberJpaRepository.findByProviderTypeAndProviderId) stating the current
single-server concurrency assumption and that a distributed lock (e.g.,
ShedLock) should be introduced when moving to multi-instance/distributed
deployments; reference the DataIntegrityViolationException handling and the
requery fallback so future maintainers know why this approach exists and where
to plug in the distributed-lock solution.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@linktrip-application/src/main/kotlin/com/linktrip/application/domain/member/AuthService.kt`:
- Around line 38-40: Replace the non-existent ExceptionCode value in
AuthService#getOAuthPort: change the thrown LinktripException to use
ExceptionCode.BAD_REQUEST_OAUTH_PROVIDER instead of
ExceptionCode.UNSUPPORTED_OAUTH_PROVIDER so the code compiles and unsupported
OAuth providers map to the correct enum constant.

In
`@linktrip-output-http/src/main/kotlin/com/linktrip/output/http/oauth/adapter/GoogleOAuthAdapter.kt`:
- Line 32: Remove the Personally Identifiable Information from the debug log in
GoogleOAuthAdapter: the logger.debug call that includes response.id should be
changed to avoid logging the raw user identifier (response.id). Edit the
logger.debug in GoogleOAuthAdapter to either log a generic success message
(e.g., "Google user info retrieval succeeded") or log non-identifying metadata
(no full IDs or use a truncated/hashed value if absolutely required), and ensure
any reference to response.id is removed from logs.
- Around line 23-39: Wrap the external call in
GoogleOAuthAdapter.requestUserInfo (the restClient.get() call) in a try-catch
and convert client/network errors into the domain LinktripException with
ExceptionCode.OAUTH_PROVIDER_ERROR (same behavior as AppleOAuthAdapter);
specifically catch RestClientException / WebClientResponseException (or a broad
Exception if those types are not available) around the
restClient.get().retrieve().body<GoogleUserInfoResponse>() call and rethrow
LinktripException(ExceptionCode.OAUTH_PROVIDER_ERROR) so network/HTTP errors
don’t bubble as raw 500s.

In
`@linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/adapter/MemberAdapter.kt`:
- Around line 26-29: The catch in MemberAdapter currently swallows all
DataIntegrityViolationException and uses `!!` on
memberJpaRepository.findByProviderTypeAndProviderId, which can mask
non-duplicate integrity errors and cause NPEs; update the save flow in
MemberAdapter to detect a duplicate-key violation only (inspect the exception
cause e.g. ConstraintViolationException or SQLState/error code and/or constraint
name "uk_member_provider"), return the existing member via
memberJpaRepository.findByProviderTypeAndProviderId(member.providerType,
member.providerId) if present, and for non-duplicate integrity violations
rethrow the original exception; also handle the null case from
findByProviderTypeAndProviderId safely (throw a clear IllegalStateException or
Domain-specific exception instead of using `!!`).

---

Duplicate comments:
In
`@linktrip-output-http/src/main/kotlin/com/linktrip/output/http/oauth/adapter/AppleOAuthAdapter.kt`:
- Around line 52-57: The fetchApplePublicKeys method in AppleOAuthAdapter calls
restClient.get() but doesn't catch RestClientException; wrap the REST call in a
try-catch inside fetchApplePublicKeys, catch RestClientException (and optionally
other transport exceptions), and rethrow a LinktripException with
ExceptionCode.OAUTH_PROVIDER_ERROR (include the caught exception as the cause or
message) so transport errors are translated to your domain error; keep the
original body<ApplePublicKeyResponse>() null check and throw as before if
response is null.

---

Nitpick comments:
In
`@linktrip-input-http/src/main/kotlin/com/linktrip/input/http/security/JwtAuthenticationProvider.kt`:
- Around line 13-27: The authenticate method in JwtAuthenticationProvider
unsafely casts authentication.credentials to String which can throw
ClassCastException; update JwtAuthenticationProvider.authenticate to defensively
handle credentials by checking type (or using a safe cast) and throwing a
BadCredentialsException if credentials is null or not a String before calling
tokenProvider.validate/extractMemberId, then continue to return
PostAuthorizationToken(memberId) as before and retain the existing exception
handling for other errors.

In
`@linktrip-input-http/src/main/kotlin/com/linktrip/input/http/security/JwtTokenProvider.kt`:
- Line 23: The secretKey initialization in JwtTokenProvider currently calls
secretKeyString.toByteArray() which uses the platform default charset; change
this to use an explicit charset (e.g., StandardCharsets.UTF_8) when converting
secretKeyString to bytes so the key is consistent across environments—update the
expression that constructs secretKey (the line assigning private val secretKey:
SecretKey) to use the explicit charset.

In
`@linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/adapter/MemberAdapter.kt`:
- Around line 23-30: Add a short TODO comment in MemberAdapter.save (the
try/catch that handles DataIntegrityViolationException and re-queries via
memberJpaRepository.findByProviderTypeAndProviderId) stating the current
single-server concurrency assumption and that a distributed lock (e.g.,
ShedLock) should be introduced when moving to multi-instance/distributed
deployments; reference the DataIntegrityViolationException handling and the
requery fallback so future maintainers know why this approach exists and where
to plug in the distributed-lock solution.
🪄 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: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 7bf7fdfc-25fa-4db2-a4b3-33a96e098042

📥 Commits

Reviewing files that changed from the base of the PR and between a6a2d43 and 4091f77.

📒 Files selected for processing (43)
  • gradle/libs.versions.toml
  • linktrip-application/src/main/kotlin/com/linktrip/application/domain/member/AuthService.kt
  • linktrip-application/src/main/kotlin/com/linktrip/application/domain/member/Member.kt
  • linktrip-application/src/main/kotlin/com/linktrip/application/domain/member/MemberService.kt
  • linktrip-application/src/main/kotlin/com/linktrip/application/domain/member/ProviderType.kt
  • linktrip-application/src/main/kotlin/com/linktrip/application/port/input/AuthUseCase.kt
  • linktrip-application/src/main/kotlin/com/linktrip/application/port/output/auth/OAuthInfo.kt
  • linktrip-application/src/main/kotlin/com/linktrip/application/port/output/auth/OAuthPort.kt
  • linktrip-application/src/main/kotlin/com/linktrip/application/port/output/persistence/MemberPort.kt
  • linktrip-application/src/test/kotlin/com/linktrip/application/domain/member/AuthServiceTest.kt
  • linktrip-bootstrap/src/main/resources/application.yml
  • linktrip-common/src/main/kotlin/com/linktrip/common/exception/ExceptionCode.kt
  • linktrip-input-http/src/main/kotlin/com/linktrip/input/http/config/FilterConfig.kt
  • linktrip-input-http/src/main/kotlin/com/linktrip/input/http/config/WebMvcConfig.kt
  • linktrip-input-http/src/main/kotlin/com/linktrip/input/http/controller/AuthController.kt
  • linktrip-input-http/src/main/kotlin/com/linktrip/input/http/controller/dto/request/AuthRequest.kt
  • linktrip-input-http/src/main/kotlin/com/linktrip/input/http/controller/dto/request/OAuthLoginRequest.kt
  • linktrip-input-http/src/main/kotlin/com/linktrip/input/http/controller/dto/response/OAuthLoginResponse.kt
  • linktrip-input-http/src/main/kotlin/com/linktrip/input/http/filter/FilterPaths.kt
  • linktrip-input-http/src/main/kotlin/com/linktrip/input/http/filter/MdcLoggingFilter.kt
  • linktrip-input-http/src/main/kotlin/com/linktrip/input/http/ratelimit/RateLimitInterceptor.kt
  • linktrip-input-http/src/main/kotlin/com/linktrip/input/http/security/AuthenticatedMember.kt
  • linktrip-input-http/src/main/kotlin/com/linktrip/input/http/security/AuthenticatedMemberArgumentResolver.kt
  • linktrip-input-http/src/main/kotlin/com/linktrip/input/http/security/JwtAuthenticationProvider.kt
  • linktrip-input-http/src/main/kotlin/com/linktrip/input/http/security/JwtSecurityFilter.kt
  • linktrip-input-http/src/main/kotlin/com/linktrip/input/http/security/JwtTokenProvider.kt
  • linktrip-input-http/src/main/kotlin/com/linktrip/input/http/security/PostAuthorizationToken.kt
  • linktrip-input-http/src/main/kotlin/com/linktrip/input/http/security/PreAuthorizationToken.kt
  • linktrip-input-http/src/main/kotlin/com/linktrip/input/http/security/SecurityConfig.kt
  • linktrip-input-http/src/main/kotlin/com/linktrip/input/http/security/SecurityPaths.kt
  • linktrip-input-http/src/main/kotlin/com/linktrip/input/http/security/handler/JwtAccessDeniedHandler.kt
  • linktrip-input-http/src/main/kotlin/com/linktrip/input/http/security/handler/JwtAuthenticationEntryPoint.kt
  • linktrip-input-http/src/test/kotlin/com/linktrip/input/http/ratelimit/RateLimitInterceptorTest.kt
  • linktrip-output-http/src/main/kotlin/com/linktrip/output/http/oauth/adapter/AppleOAuthAdapter.kt
  • linktrip-output-http/src/main/kotlin/com/linktrip/output/http/oauth/adapter/GoogleOAuthAdapter.kt
  • linktrip-output-http/src/main/kotlin/com/linktrip/output/http/oauth/adapter/KakaoOAuthAdapter.kt
  • linktrip-output-http/src/main/kotlin/com/linktrip/output/http/oauth/config/OAuthRestClientConfig.kt
  • linktrip-output-http/src/main/kotlin/com/linktrip/output/http/oauth/dto/ApplePublicKeyResponse.kt
  • linktrip-output-http/src/main/kotlin/com/linktrip/output/http/oauth/dto/GoogleUserInfoResponse.kt
  • linktrip-output-http/src/main/kotlin/com/linktrip/output/http/oauth/dto/KakaoUserInfoResponse.kt
  • linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/adapter/MemberAdapter.kt
  • linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/entity/MemberEntity.kt
  • linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/repository/MemberJpaRepository.kt
💤 Files with no reviewable changes (3)
  • linktrip-input-http/src/main/kotlin/com/linktrip/input/http/filter/FilterPaths.kt
  • linktrip-input-http/src/main/kotlin/com/linktrip/input/http/controller/dto/request/AuthRequest.kt
  • linktrip-input-http/src/main/kotlin/com/linktrip/input/http/config/FilterConfig.kt
✅ Files skipped from review due to trivial changes (11)
  • linktrip-bootstrap/src/main/resources/application.yml
  • linktrip-application/src/main/kotlin/com/linktrip/application/domain/member/ProviderType.kt
  • linktrip-application/src/main/kotlin/com/linktrip/application/port/output/auth/OAuthPort.kt
  • linktrip-application/src/main/kotlin/com/linktrip/application/port/output/auth/OAuthInfo.kt
  • linktrip-input-http/src/test/kotlin/com/linktrip/input/http/ratelimit/RateLimitInterceptorTest.kt
  • linktrip-input-http/src/main/kotlin/com/linktrip/input/http/security/SecurityPaths.kt
  • linktrip-output-http/src/main/kotlin/com/linktrip/output/http/oauth/dto/GoogleUserInfoResponse.kt
  • linktrip-input-http/src/main/kotlin/com/linktrip/input/http/security/handler/JwtAccessDeniedHandler.kt
  • linktrip-input-http/src/main/kotlin/com/linktrip/input/http/security/PostAuthorizationToken.kt
  • linktrip-output-http/src/main/kotlin/com/linktrip/output/http/oauth/dto/ApplePublicKeyResponse.kt
  • linktrip-output-http/src/main/kotlin/com/linktrip/output/http/oauth/dto/KakaoUserInfoResponse.kt
🚧 Files skipped from review as they are similar to previous changes (16)
  • linktrip-input-http/src/main/kotlin/com/linktrip/input/http/filter/MdcLoggingFilter.kt
  • linktrip-input-http/src/main/kotlin/com/linktrip/input/http/security/AuthenticatedMember.kt
  • linktrip-input-http/src/main/kotlin/com/linktrip/input/http/security/PreAuthorizationToken.kt
  • linktrip-application/src/main/kotlin/com/linktrip/application/port/input/AuthUseCase.kt
  • linktrip-input-http/src/main/kotlin/com/linktrip/input/http/controller/dto/response/OAuthLoginResponse.kt
  • linktrip-common/src/main/kotlin/com/linktrip/common/exception/ExceptionCode.kt
  • linktrip-input-http/src/main/kotlin/com/linktrip/input/http/config/WebMvcConfig.kt
  • linktrip-input-http/src/main/kotlin/com/linktrip/input/http/controller/dto/request/OAuthLoginRequest.kt
  • linktrip-application/src/main/kotlin/com/linktrip/application/domain/member/Member.kt
  • linktrip-output-http/src/main/kotlin/com/linktrip/output/http/oauth/adapter/KakaoOAuthAdapter.kt
  • linktrip-output-http/src/main/kotlin/com/linktrip/output/http/oauth/config/OAuthRestClientConfig.kt
  • linktrip-application/src/main/kotlin/com/linktrip/application/domain/member/MemberService.kt
  • linktrip-input-http/src/main/kotlin/com/linktrip/input/http/ratelimit/RateLimitInterceptor.kt
  • linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/entity/MemberEntity.kt
  • linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/repository/MemberJpaRepository.kt
  • linktrip-application/src/test/kotlin/com/linktrip/application/domain/member/AuthServiceTest.kt
📜 Review details
🧰 Additional context used
🧠 Learnings (2)
📚 Learning: 2026-03-18T01:07:53.575Z
Learnt from: toychip
Repo: Link-Trip/BackEnd PR: 24
File: linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/adapter/YouTubeVideoPersistenceAdapter.kt:16-28
Timestamp: 2026-03-18T01:07:53.575Z
Learning: Apply the same deferred concurrent-write policy to all Kotlin persistence adapters in the MySQL adapter package: ensure writes are coordinated on a single server with ShedLock considered for the replication phase. For YouTubeVideoPersistenceAdapter.kt (and other adapters in this directory), verify that concurrent writes are serialized or properly guarded, document the policy in code comments, and ensure CI checks or deployment gating will catch any regression where multiple instances might attempt a conflicting write during replication.

Applied to files:

  • linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/adapter/MemberAdapter.kt
📚 Learning: 2026-03-18T01:08:05.661Z
Learnt from: toychip
Repo: Link-Trip/BackEnd PR: 24
File: linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/adapter/YouTubeVideoPersistenceAdapter.kt:16-28
Timestamp: 2026-03-18T01:08:05.661Z
Learning: In `linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/adapter/YouTubeVideoPersistenceAdapter.kt`, the concurrent-write safety (duplicate videoId in batch, race condition on uk_youtube_video_video_id) is intentionally deferred. The maintainer (toychip) confirmed the system is currently single-server, so this is not a concern yet. When replication is introduced in the future, ShedLock will be used for distributed locking to address this. At that point, in-batch videoId deduplication should also be applied.

Applied to files:

  • linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/adapter/MemberAdapter.kt
🪛 detekt (1.23.8)
linktrip-output-http/src/main/kotlin/com/linktrip/output/http/oauth/adapter/AppleOAuthAdapter.kt

[warning] 88-88: The caught exception is swallowed. The original exception could be lost.

(detekt.exceptions.SwallowedException)

🪛 GitHub Actions: CI - Pull request
gradle/libs.versions.toml

[warning] 1-1: Kapt warning: Language version 2.0+ is not supported; falling back to 1.9.


[warning] 1-1: Deprecated Gradle features were used, making the build incompatible with Gradle 9.0.

linktrip-application/src/main/kotlin/com/linktrip/application/domain/member/AuthService.kt

[error] 40-40: Kotlin compilation failed: Unresolved reference 'UNSUPPORTED_OAUTH_PROVIDER' at AuthService.kt:40:54.

🔇 Additional comments (15)
gradle/libs.versions.toml (1)

32-32: LGTM!

의존성 추가가 적절합니다. spring-boot-starter-security와 JWT 라이브러리들이 올바른 번들에 포함되었습니다.

Also applies to: 92-92, 97-97

linktrip-input-http/src/main/kotlin/com/linktrip/input/http/security/handler/JwtAuthenticationEntryPoint.kt (1)

17-36: LGTM!

AuthenticationEntryPoint 구현이 적절합니다. JSON 응답 형식과 문자 인코딩이 올바르게 설정되어 있습니다.

linktrip-application/src/main/kotlin/com/linktrip/application/domain/member/AuthService.kt (1)

20-36: OAuth 로그인 로직이 적절하게 구현되었습니다.

OAuth 포트 선택, 사용자 정보 조회, 회원 조회/생성, 토큰 발급의 흐름이 명확합니다. 외부 API 호출이 트랜잭션 밖에서 수행되는 설계가 좋습니다.

linktrip-input-http/src/main/kotlin/com/linktrip/input/http/controller/AuthController.kt (1)

18-40: LGTM!

OAuth 로그인 엔드포인트가 적절하게 구현되었습니다. 신규 회원 시 201 Created, 기존 회원 시 200 OK 응답을 반환하는 RESTful 패턴이 좋습니다.

linktrip-input-http/src/main/kotlin/com/linktrip/input/http/security/AuthenticatedMemberArgumentResolver.kt (1)

14-31: LGTM!

HandlerMethodArgumentResolver 구현이 적절합니다. SecurityContextHolder에서 인증 정보를 안전하게 추출하고 있습니다.

linktrip-input-http/src/main/kotlin/com/linktrip/input/http/security/JwtAuthenticationProvider.kt (1)

30-31: LGTM!

supports() 메서드가 PreAuthorizationToken 타입만 처리하도록 올바르게 제한하고 있습니다.

linktrip-output-http/src/main/kotlin/com/linktrip/output/http/oauth/adapter/AppleOAuthAdapter.kt (1)

86-90: 예외 원인을 보존하되, LinktripException의 실제 생성자 시그니처에 맞춰 수정하세요.

LinktripExceptioncause 파라미터를 지원하지 않습니다. 대신 원본 예외를 손실하지 않으려면 다음과 같이 처리할 수 있습니다:

  1. 첫 번째 생성자를 사용하여 예외 정보를 상세 메시지에 포함
  2. 예외를 로깅한 후 새 예외 발생
  3. LinktripExceptioncause 파라미터 지원 추가 (선택사항)
♻️ 제안 수정
         } catch (e: LinktripException) {
             throw e
         } catch (e: Exception) {
+            logger.debug(e) { "Apple ID 토큰 검증 실패" }
-            throw LinktripException(ExceptionCode.TOKEN_INVALID)
+            throw LinktripException(ExceptionCode.TOKEN_INVALID, "Token validation failed: ${e.message}")
         }
			> Likely an incorrect or invalid review comment.
linktrip-input-http/src/main/kotlin/com/linktrip/input/http/security/SecurityConfig.kt (3)

31-31: 운영 환경에서 PUBLIC_ENDPOINTS 범위 검토가 필요합니다.

이전 리뷰에서 언급된 것처럼, SecurityPaths.PUBLIC_ENDPOINTS에 Swagger/Actuator/Test 경로가 포함되어 있다면 프로파일별로 분리하는 것이 안전합니다.


27-37: @Order 애노테이션이 올바르게 적용되었습니다.

이전 리뷰에서 지적된 다중 SecurityFilterChain 우선순위 문제가 해결되었습니다. publicFilterChain@Order(1)로, protectedFilterChain@Order(2)로 설정되어 공개 엔드포인트가 먼저 평가됩니다.


39-59: 보호된 필터 체인 설정이 적절합니다.

  • Stateless 세션 관리, CSRF 비활성화가 JWT 기반 API에 적합합니다.
  • JwtSecurityFilterUsernamePasswordAuthenticationFilter 앞에 올바르게 배치되었습니다.
  • 예외 처리 핸들러(authenticationEntryPoint, accessDeniedHandler)가 적절히 구성되었습니다.
linktrip-input-http/src/main/kotlin/com/linktrip/input/http/security/JwtSecurityFilter.kt (1)

12-54: LGTM! 인증 예외 처리 구조가 올바르게 구현되었습니다.

이전 리뷰에서 지적된 문제가 해결되었습니다:

  • 인증 시도만 try 블록 내에서 수행되고, filterChain.doFilter는 외부에서 호출됩니다.
  • AuthenticationException만 처리하고 다른 예외는 정상적으로 전파됩니다.
  • 토큰이 없는 경우 인증 없이 필터 체인을 계속 진행합니다.
linktrip-input-http/src/main/kotlin/com/linktrip/input/http/security/JwtTokenProvider.kt (2)

55-72: 예외 처리 및 로깅 구현이 적절합니다.

만료된 토큰과 유효하지 않은 토큰을 구분하여 다른 예외 코드(TOKEN_EXPIRED, TOKEN_INVALID)로 처리하고, 디버깅을 위한 경고 로그도 적절히 남기고 있습니다.


29-42: jjwt 버전 호환성이 확인되었습니다.

프로젝트에서 사용 중인 jjwt 버전은 0.12.6으로, 코드에서 사용하는 subject(), expiration(), verifyWith(), parseSignedClaims() 등의 메서드들이 모두 이 버전에서 지원하는 올바른 fluent API입니다. 필요한 모든 의존성(jjwt-api, jjwt-impl, jjwt-jackson)도 proper하게 구성되어 있어 추가 작업이 필요 없습니다.

linktrip-application/src/main/kotlin/com/linktrip/application/port/output/persistence/MemberPort.kt (1)

7-10: OAuth 식별자 기반 포트 시그니처가 도메인 변경과 잘 정렬되었습니다.

providerType + providerId 조회 계약이 명확하고 현재 PR 목적과 일관됩니다.

linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/adapter/MemberAdapter.kt (1)

18-21: ProviderType/ProviderId 조회 위임 구현은 깔끔합니다.

리포지토리 조회 후 도메인 매핑 흐름이 단순하고 의도에 맞습니다.

Comment on lines +23 to +39
override fun requestUserInfo(accessToken: String): OAuthInfo {
val response =
restClient.get()
.uri("/oauth2/v1/userinfo")
.header("Authorization", "Bearer $accessToken")
.retrieve()
.body<GoogleUserInfoResponse>()
?: throw LinktripException(ExceptionCode.OAUTH_PROVIDER_ERROR)

logger.debug { "Google 사용자 정보 조회 성공: id=${response.id}" }

return OAuthInfo(
providerType = ProviderType.GOOGLE,
providerId = response.id,
email = response.email,
)
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

외부 API 호출 시 예외 처리가 누락되었습니다.

restClient.get() 호출 중 네트워크 오류나 RestClientException이 발생하면 전역 핸들러로 전파되어 500 에러로 처리됩니다. AppleOAuthAdapter와 동일하게 도메인 예외로 변환해야 합니다.

🛡️ 제안 수정
     override fun requestUserInfo(accessToken: String): OAuthInfo {
-        val response =
-            restClient.get()
-                .uri("/oauth2/v1/userinfo")
-                .header("Authorization", "Bearer $accessToken")
-                .retrieve()
-                .body<GoogleUserInfoResponse>()
-                ?: throw LinktripException(ExceptionCode.OAUTH_PROVIDER_ERROR)
+        val response =
+            try {
+                restClient.get()
+                    .uri("/oauth2/v1/userinfo")
+                    .header("Authorization", "Bearer $accessToken")
+                    .retrieve()
+                    .body<GoogleUserInfoResponse>()
+                    ?: throw LinktripException(ExceptionCode.OAUTH_PROVIDER_ERROR)
+            } catch (e: LinktripException) {
+                throw e
+            } catch (e: Exception) {
+                logger.warn(e) { "Google 사용자 정보 조회 실패" }
+                throw LinktripException(ExceptionCode.OAUTH_PROVIDER_ERROR)
+            }
 
-        logger.debug { "Google 사용자 정보 조회 성공: id=${response.id}" }
+        logger.debug { "Google 사용자 정보 조회 성공" }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@linktrip-output-http/src/main/kotlin/com/linktrip/output/http/oauth/adapter/GoogleOAuthAdapter.kt`
around lines 23 - 39, Wrap the external call in
GoogleOAuthAdapter.requestUserInfo (the restClient.get() call) in a try-catch
and convert client/network errors into the domain LinktripException with
ExceptionCode.OAUTH_PROVIDER_ERROR (same behavior as AppleOAuthAdapter);
specifically catch RestClientException / WebClientResponseException (or a broad
Exception if those types are not available) around the
restClient.get().retrieve().body<GoogleUserInfoResponse>() call and rethrow
LinktripException(ExceptionCode.OAUTH_PROVIDER_ERROR) so network/HTTP errors
don’t bubble as raw 500s.

.body<GoogleUserInfoResponse>()
?: throw LinktripException(ExceptionCode.OAUTH_PROVIDER_ERROR)

logger.debug { "Google 사용자 정보 조회 성공: id=${response.id}" }
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

디버그 로그에서 사용자 식별자를 제거하세요.

response.id는 사용자 식별 정보로, 로그 최소수집 원칙에 어긋납니다.

🔧 제안 수정
-        logger.debug { "Google 사용자 정보 조회 성공: id=${response.id}" }
+        logger.debug { "Google 사용자 정보 조회 성공" }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
logger.debug { "Google 사용자 정보 조회 성공: id=${response.id}" }
logger.debug { "Google 사용자 정보 조회 성공" }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@linktrip-output-http/src/main/kotlin/com/linktrip/output/http/oauth/adapter/GoogleOAuthAdapter.kt`
at line 32, Remove the Personally Identifiable Information from the debug log in
GoogleOAuthAdapter: the logger.debug call that includes response.id should be
changed to avoid logging the raw user identifier (response.id). Edit the
logger.debug in GoogleOAuthAdapter to either log a generic success message
(e.g., "Google user info retrieval succeeded") or log non-identifying metadata
(no full IDs or use a truncated/hashed value if absolutely required), and ensure
any reference to response.id is removed from logs.

Comment on lines +26 to +29
} catch (_: DataIntegrityViolationException) {
logger.warn { "동시 요청으로 인한 중복 회원 저장 시도 감지: provider=${member.providerType}" }
memberJpaRepository.findByProviderTypeAndProviderId(member.providerType, member.providerId)!!
.toDomain()
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# 목적: Member 관련 제약조건과 예외 처리 범위가 "중복키 복구" 의도와 일치하는지 정적 점검
# 기대결과:
# 1) MemberEntity/DDL에 (provider_type, provider_id) 유니크 제약이 존재
# 2) 어댑터에서 중복키 예외와 일반 무결성 예외를 구분 처리하도록 개선 가능한 근거 확보

echo "=== MemberAdapter ==="
fd -i "MemberAdapter.kt" --exec nl -ba {} \;

echo "=== MemberEntity ==="
fd -i "MemberEntity.kt" --exec nl -ba {} \;

echo "=== Kotlin: 무결성/중복키 예외 사용처 ==="
rg -n --type kotlin -C2 'DataIntegrityViolationException|DuplicateKeyException'

echo "=== SQL/마이그레이션: member 유니크 제약 탐색 ==="
rg -n -C2 --iglob '*sql' --iglob '*migration*' --iglob '*ddl*' 'member|provider_type|provider_id|unique|constraint'

Repository: Link-Trip/BackEnd

Length of output: 133


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== Finding MemberAdapter.kt ==="
fd -type f "MemberAdapter.kt" 2>/dev/null | head -20

echo "=== Finding MemberEntity.kt ==="
fd -type f "MemberEntity.kt" 2>/dev/null | head -20

echo "=== Finding migration/DDL files ==="
fd -type f -e "sql" -e "ddl" 2>/dev/null | grep -i member | head -20

Repository: Link-Trip/BackEnd

Length of output: 93


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Search for MemberAdapter.kt content
echo "=== MemberAdapter Content ==="
find . -name "MemberAdapter.kt" -type f -exec cat -n {} \;

echo ""
echo "=== MemberEntity Content ==="
find . -name "MemberEntity.kt" -type f -exec cat -n {} \;

Repository: Link-Trip/BackEnd

Length of output: 3191


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Search for DataIntegrityViolationException and DuplicateKeyException usage
echo "=== Exception handling patterns in Kotlin files ==="
rg -n -A3 -B3 "DataIntegrityViolationException|DuplicateKeyException" --type kotlin

echo ""
echo "=== Unique constraints in SQL/migration files ==="
rg -n -i -A2 -B2 "unique.*constraint|constraint.*unique|uk_.*member|provider_type" --type sql | head -50

Repository: Link-Trip/BackEnd

Length of output: 2689


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== YouTubeVideoPersistenceAdapter (from learnings) ==="
find . -name "YouTubeVideoPersistenceAdapter.kt" -type f -exec cat -n {} \;

echo ""
echo "=== Search for DuplicateKeyException usage ==="
rg -n "DuplicateKeyException" --type kotlin

echo ""
echo "=== All exception handling in adapter classes ==="
rg -n "catch.*Exception" --type kotlin | grep -i adapter

Repository: Link-Trip/BackEnd

Length of output: 3423


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Check Spring dependencies to see if DuplicateKeyException is available
echo "=== Spring dependencies in build files ==="
find . -name "build.gradle*" -o -name "pom.xml" -o -name "build.gradle.kts" | xargs cat 2>/dev/null | grep -i spring -A2 -B2 | head -60

Repository: Link-Trip/BackEnd

Length of output: 1132


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Search for Spring's DAO exception hierarchy documentation in code
echo "=== Checking Spring Data/DAO exception imports ==="
rg -n "import org.springframework.dao" --type kotlin | head -20

echo ""
echo "=== All catch blocks in adapters ==="
rg -n "catch" --type kotlin linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/adapter/ 

echo ""
echo "=== Check if there are any database migration files ==="
find . -type f \( -name "*.sql" -o -name "*migration*" \) | head -20

Repository: Link-Trip/BackEnd

Length of output: 717


🌐 Web query:

Spring Framework DuplicateKeyException org.springframework.dao availability

💡 Result:

Yes, Spring Framework provides org.springframework.dao.DuplicateKeyException. It is a subclass of DataIntegrityViolationException, thrown when an insert or update violates a primary key or unique constraint. This class is available in the spring-tx module and documented in official Spring API docs up to version 7.0.6 (current as of 2026). Source code confirms its presence in the main branch. Note that in some cases (e.g., certain JDBC drivers like MySQL), a more general DataIntegrityViolationException may be thrown instead, but DuplicateKeyException exists and is part of the DAO exception hierarchy.

Citations:


DataIntegrityViolationException 전체 캐치는 실제 무결성 오류를 숨길 수 있습니다.

Line 26에서 예외 범위를 넓게 잡고, Line 28에서 !!를 사용해 fallback이 비면 NPE로 바뀔 수 있습니다. MemberEntity에 정의된 uk_member_provider 유니크 제약(provider_type, provider_id)은 중복 저장 시나리오를 유발하지만, 현재 코드는 중복키뿐만 아니라 다른 모든 무결성 위반을 같은 방식으로 처리하므로 실제 오류를 은폐할 위험이 있습니다. 중복키 충돌만 복구하고, 그 외 무결성 예외는 원본 예외를 유지해 재던지도록 분리하며, null 케이스도 안전하게 처리하는 편이 필요합니다.

수정 제안 diff
 import mu.KotlinLogging
+import org.springframework.dao.DuplicateKeyException
 import org.springframework.dao.DataIntegrityViolationException
 import org.springframework.stereotype.Component
@@
     override fun save(member: Member): Member =
         try {
             memberJpaRepository.save(MemberEntity.from(member)).toDomain()
-        } catch (_: DataIntegrityViolationException) {
+        } catch (ex: DuplicateKeyException) {
             logger.warn { "동시 요청으로 인한 중복 회원 저장 시도 감지: provider=${member.providerType}" }
-            memberJpaRepository.findByProviderTypeAndProviderId(member.providerType, member.providerId)!!
-                .toDomain()
+            memberJpaRepository.findByProviderTypeAndProviderId(member.providerType, member.providerId)
+                ?.toDomain()
+                ?: throw ex
+        } catch (ex: DataIntegrityViolationException) {
+            throw ex
         }
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@linktrip-output-persistence/mysql/src/main/kotlin/com/linktrip/output/persistence/mysql/adapter/MemberAdapter.kt`
around lines 26 - 29, The catch in MemberAdapter currently swallows all
DataIntegrityViolationException and uses `!!` on
memberJpaRepository.findByProviderTypeAndProviderId, which can mask
non-duplicate integrity errors and cause NPEs; update the save flow in
MemberAdapter to detect a duplicate-key violation only (inspect the exception
cause e.g. ConstraintViolationException or SQLState/error code and/or constraint
name "uk_member_provider"), return the existing member via
memberJpaRepository.findByProviderTypeAndProviderId(member.providerType,
member.providerId) if present, and for non-duplicate integrity violations
rethrow the original exception; also handle the null case from
findByProviderTypeAndProviderId safely (throw a clear IllegalStateException or
Domain-specific exception instead of using `!!`).

@toychip toychip force-pushed the feat/#42-spring-security-oauth branch from 599bf85 to a37f999 Compare April 1, 2026 07:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feat] OAuth Spring Security 적용

1 participant