Skip to content
This repository was archived by the owner on Jul 7, 2025. It is now read-only.

Commit ae29075

Browse files
authored
Merge pull request #124 from ASAP-Lettering/ASAP-424
ASAP-424 feat: Google OAuth 기능 추가 및 Kakao OAuth 코드 리팩토링
2 parents cd1559e + fffce2d commit ae29075

File tree

8 files changed

+368
-48
lines changed

8 files changed

+368
-48
lines changed

.junie/guidelines.md

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
# 프로젝트 가이드라인
2+
3+
## 프로젝트 개요
4+
Lettering-Backend는 모듈화된 아키텍처를 사용하는 Kotlin 기반 백엔드 프로젝트입니다. 이 프로젝트는 다음과 같은 모듈로 구성되어 있습니다:
5+
6+
* **Application-Module**: 비즈니스 로직과 유스케이스를 포함합니다.
7+
* **Bootstrap-Module**: 애플리케이션의 진입점과 API 엔드포인트를 정의합니다.
8+
* **Common-Module**: 공통 유틸리티와 기능을 제공합니다.
9+
* **Domain-Module**: 도메인 엔티티와 비즈니스 규칙을 정의합니다.
10+
* **Infrastructure-Module**: 외부 시스템과의 통합을 담당합니다.
11+
* AWS: AWS 서비스 통합
12+
* Client: 외부 API 클라이언트
13+
* Event: 이벤트 처리
14+
* Persistence: 데이터 저장소 관련 코드
15+
* Security: 인증 및 권한 부여
16+
17+
## 테스트 가이드라인
18+
19+
### 기본 원칙
20+
1. **모킹을 기본으로 슬라이스 테스트를 진행한다.**
21+
* 각 서비스나 컴포넌트는 독립적으로 테스트되어야 합니다.
22+
* 외부 의존성은 mockk를 사용하여 모킹해야 합니다.
23+
* 테스트는 특정 기능 단위(슬라이스)에 집중해야 합니다.
24+
25+
2. **kotest를 활용한 테스트 검증을 진행해야한다.**
26+
* 모든 테스트는 kotest 프레임워크를 사용하여 작성해야 합니다.
27+
* BehaviorSpec 스타일(given/when/then)을 사용하여 테스트를 구조화합니다.
28+
* kotest의 assertion 라이브러리(shouldBe, shouldNotBeNull 등)를 사용하여 결과를 검증합니다.
29+
30+
### 테스트 작성 방법
31+
1. **테스트 클래스 구조**
32+
* 테스트 클래스는 테스트 대상 클래스 이름에 'Test'를 붙여 명명합니다. (예: `SocialLoginServiceTest`)
33+
* BehaviorSpec을 상속받아 테스트를 구현합니다.
34+
35+
2. **모킹 방법**
36+
* mockk 라이브러리를 사용하여 의존성을 모킹합니다.
37+
* `mockk<Interface>()` 형태로 모의 객체를 생성합니다.
38+
* `every { ... } returns ...` 구문을 사용하여 모의 객체의 동작을 정의합니다.
39+
* 필요한 경우 `verify { ... }` 구문을 사용하여 모의 객체의 메서드 호출을 검증합니다.
40+
41+
3. **테스트 시나리오 작성**
42+
* given: 테스트 전제 조건을 설정합니다.
43+
* when: 테스트할 동작을 실행합니다.
44+
* then: 결과를 검증합니다.
45+
* 테스트 설명은 한글로 작성하여 가독성을 높입니다.
46+
47+
4. **예외 테스트**
48+
* `shouldThrow<ExceptionType> { ... }` 구문을 사용하여 예외 발생을 검증합니다.
49+
50+
### 테스트 실행
51+
* 개별 테스트는 독립적으로 실행 가능해야 합니다.
52+
* 테스트 간 의존성이 없어야 합니다.
53+
* 테스트는 빠르게 실행되어야 합니다.
54+
55+
### 코드 품질
56+
* 테스트 코드도 프로덕션 코드와 동일한 품질 기준을 적용합니다.
57+
* 테스트 코드는 명확하고 이해하기 쉽게 작성해야 합니다.
58+
* 중복 코드는 적절한 헬퍼 메서드나 픽스처를 사용하여 제거합니다.

Domain-Module/src/main/kotlin/com/asap/domain/user/enums/SocialLoginProvider.kt

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,16 @@ package com.asap.domain.user.enums
33
import com.asap.common.exception.DefaultException
44

55
enum class SocialLoginProvider {
6-
KAKAO;
6+
KAKAO,
7+
GOOGLE,
8+
NAVER,
9+
;
710

8-
companion object{
9-
fun parse(value: String): SocialLoginProvider {
10-
return when (value) {
11+
companion object {
12+
fun parse(value: String): SocialLoginProvider =
13+
when (value) {
1114
entries.firstOrNull { it.name == value }?.name -> valueOf(value)
1215
else -> throw DefaultException.InvalidArgumentException("유효하지 않은 소셜 로그인 제공자입니다.")
1316
}
14-
}
1517
}
16-
17-
18-
}
18+
}

Infrastructure-Module/Client/src/main/kotlin/com/asap/client/oauth/OAuthWebClientConfig.kt

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,21 @@ import org.springframework.web.reactive.function.client.WebClient
88

99
@Configuration
1010
class OAuthWebClientConfig {
11-
1211
@Bean
1312
@Qualifier("kakaoWebClient")
14-
fun kakaoWebClient(): WebClient {
15-
return WebClient.builder()
13+
fun kakaoWebClient(): WebClient =
14+
WebClient
15+
.builder()
1616
.baseUrl("https://kapi.kakao.com")
1717
.defaultHeader("Content-Type", MediaType.APPLICATION_JSON_VALUE)
1818
.build()
19-
}
20-
}
19+
20+
@Bean
21+
@Qualifier("googleWebClient")
22+
fun googleWebClient(): WebClient =
23+
WebClient
24+
.builder()
25+
.baseUrl("https://oauth2.googleapis.com")
26+
.defaultHeader("Content-Type", MediaType.APPLICATION_JSON_VALUE)
27+
.build()
28+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
package com.asap.client.oauth.platform
2+
3+
import com.asap.client.oauth.OAuthRetrieveHandler
4+
import com.asap.client.oauth.exception.OAuthException
5+
import org.springframework.beans.factory.annotation.Qualifier
6+
import org.springframework.stereotype.Component
7+
import org.springframework.web.reactive.function.client.WebClient
8+
9+
@Component
10+
class GoogleOAuthRetrieveHandler(
11+
@Qualifier("googleWebClient") private val googleWebClient: WebClient,
12+
) : OAuthRetrieveHandler {
13+
override fun getOAuthInfo(request: OAuthRetrieveHandler.OAuthRequest): OAuthRetrieveHandler.OAuthResponse {
14+
val googleUserInfo =
15+
googleWebClient
16+
.get()
17+
.uri("/userinfo/v2/me")
18+
.header("Authorization", "Bearer ${request.accessToken}")
19+
.retrieve()
20+
.onStatus({ it.isError }, {
21+
throw OAuthException.OAuthRetrieveFailedException("Google 사용자 정보를 가져오는데 실패했습니다.")
22+
})
23+
.bodyToMono(GoogleUserInfo::class.java)
24+
.block()
25+
26+
if (googleUserInfo == null) {
27+
throw OAuthException.OAuthRetrieveFailedException("Google 사용자 정보를 가져오는데 실패했습니다.")
28+
}
29+
30+
return OAuthRetrieveHandler.OAuthResponse(
31+
username = googleUserInfo.name,
32+
socialId = googleUserInfo.id,
33+
email = googleUserInfo.email,
34+
profileImage = googleUserInfo.picture,
35+
)
36+
}
37+
38+
data class GoogleUserInfo(
39+
val email: String,
40+
val name: String,
41+
val id: String,
42+
val picture: String,
43+
)
44+
}

Infrastructure-Module/Client/src/main/kotlin/com/asap/client/oauth/platform/KakaoOAuthRetrieveHandler.kt

Lines changed: 24 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -11,25 +11,30 @@ import org.springframework.web.reactive.function.client.WebClient
1111
class KakaoOAuthRetrieveHandler(
1212
@Qualifier("kakaoWebClient") private val kakaoWebClient: WebClient,
1313
) : OAuthRetrieveHandler {
14-
override fun getOAuthInfo(request: OAuthRetrieveHandler.OAuthRequest): OAuthRetrieveHandler.OAuthResponse =
15-
kakaoWebClient
16-
.get()
17-
.uri("/v2/user/me")
18-
.header("Authorization", "Bearer ${request.accessToken}")
19-
.retrieve()
20-
.onStatus({ it.isError }, {
21-
throw OAuthException.OAuthRetrieveFailedException("카카오 사용자 정보를 가져오는데 실패했습니다.")
22-
})
23-
.bodyToMono(KakaoUserInfo::class.java)
24-
.block()
25-
?.let {
26-
OAuthRetrieveHandler.OAuthResponse(
27-
username = it.properties.nickname,
28-
socialId = it.id,
29-
profileImage = it.properties.profileImage,
30-
email = it.kakaoAccount.email,
31-
)
32-
} ?: throw OAuthException.OAuthRetrieveFailedException("카카오 사용자 정보를 가져오는데 실패했습니다.")
14+
override fun getOAuthInfo(request: OAuthRetrieveHandler.OAuthRequest): OAuthRetrieveHandler.OAuthResponse {
15+
val kakaoUserInfo =
16+
kakaoWebClient
17+
.get()
18+
.uri("/v2/user/me")
19+
.header("Authorization", "Bearer ${request.accessToken}")
20+
.retrieve()
21+
.onStatus({ it.isError }, {
22+
throw OAuthException.OAuthRetrieveFailedException("Kakao 사용자 정보를 가져오는데 실패했습니다.")
23+
})
24+
.bodyToMono(KakaoUserInfo::class.java)
25+
.block()
26+
27+
if (kakaoUserInfo == null) {
28+
throw OAuthException.OAuthRetrieveFailedException("Kakao 사용자 정보를 가져오는데 실패했습니다.")
29+
}
30+
31+
return OAuthRetrieveHandler.OAuthResponse(
32+
username = kakaoUserInfo.properties.nickname,
33+
socialId = kakaoUserInfo.id,
34+
profileImage = kakaoUserInfo.properties.profileImage,
35+
email = kakaoUserInfo.kakaoAccount.email,
36+
)
37+
}
3338

3439
data class KakaoUserInfo(
3540
val id: String,
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
package com.asap.client.oauth.platform
2+
3+
import com.asap.client.oauth.OAuthRetrieveHandler
4+
import com.asap.client.oauth.exception.OAuthException
5+
import io.kotest.assertions.throwables.shouldThrow
6+
import io.kotest.core.spec.style.BehaviorSpec
7+
import io.kotest.matchers.shouldBe
8+
import okhttp3.mockwebserver.MockResponse
9+
import okhttp3.mockwebserver.MockWebServer
10+
import org.springframework.http.MediaType
11+
import org.springframework.web.reactive.function.client.WebClient
12+
13+
class GoogleOAuthRetrieveHandlerTest :
14+
BehaviorSpec({
15+
var mockWebServer: MockWebServer = MockWebServer()
16+
var googleWebClient: WebClient =
17+
WebClient
18+
.builder()
19+
.baseUrl(mockWebServer.url("/").toString())
20+
.defaultHeader("Content-Type", MediaType.APPLICATION_JSON_VALUE)
21+
.build()
22+
var googleOAuthRetrieveHandler: GoogleOAuthRetrieveHandler = GoogleOAuthRetrieveHandler(googleWebClient)
23+
24+
given("OAuth 요청이 성공적으로 처리되었을 때") {
25+
val accessToken = "test-access-token"
26+
val request = OAuthRetrieveHandler.OAuthRequest(accessToken)
27+
28+
val responseBody =
29+
"""
30+
{
31+
"email": "test@example.com",
32+
"name": "Test User",
33+
"id": "12345",
34+
"picture": "https://example.com/profile.jpg"
35+
}
36+
""".trimIndent()
37+
38+
mockWebServer.enqueue(
39+
MockResponse()
40+
.setResponseCode(200)
41+
.setHeader("Content-Type", "application/json")
42+
.setBody(responseBody),
43+
)
44+
45+
`when`("getOAuthInfo 메소드를 호출하면") {
46+
val response = googleOAuthRetrieveHandler.getOAuthInfo(request)
47+
48+
then("올바른 OAuthResponse를 반환해야 한다") {
49+
response.username shouldBe "Test User"
50+
response.socialId shouldBe "12345"
51+
response.email shouldBe "test@example.com"
52+
response.profileImage shouldBe "https://example.com/profile.jpg"
53+
54+
// 요청 검증
55+
val recordedRequest = mockWebServer.takeRequest()
56+
recordedRequest.path shouldBe "/userinfo/v2/me"
57+
recordedRequest.getHeader("Authorization") shouldBe "Bearer test-access-token"
58+
}
59+
}
60+
}
61+
62+
given("API가 오류를 반환할 때") {
63+
val accessToken = "test-access-token"
64+
val request = OAuthRetrieveHandler.OAuthRequest(accessToken)
65+
66+
mockWebServer.enqueue(
67+
MockResponse()
68+
.setResponseCode(401)
69+
.setHeader("Content-Type", "application/json")
70+
.setBody("{\"error\": \"invalid_token\"}"),
71+
)
72+
73+
`when`("getOAuthInfo 메소드를 호출하면") {
74+
then("OAuthRetrieveFailedException이 발생해야 한다") {
75+
val exception =
76+
shouldThrow<OAuthException.OAuthRetrieveFailedException> {
77+
googleOAuthRetrieveHandler.getOAuthInfo(request)
78+
}
79+
}
80+
}
81+
}
82+
83+
given("응답이 null일 때") {
84+
val accessToken = "test-access-token"
85+
val request = OAuthRetrieveHandler.OAuthRequest(accessToken)
86+
87+
mockWebServer.enqueue(
88+
MockResponse()
89+
.setResponseCode(200)
90+
.setHeader("Content-Type", "application/json")
91+
.setBody("null"),
92+
)
93+
94+
`when`("getOAuthInfo 메소드를 호출하면") {
95+
then("OAuthRetrieveFailedException이 발생해야 한다") {
96+
shouldThrow<OAuthException.OAuthRetrieveFailedException> {
97+
googleOAuthRetrieveHandler.getOAuthInfo(request)
98+
}
99+
}
100+
}
101+
}
102+
})

0 commit comments

Comments
 (0)