Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
365f32d
feat ( #34 ) : RESILIENCE4J 의존성 추가
qkrwndnjs1075 Aug 29, 2025
2e1470d
feat ( #34 ) : RESILIENCE4J 의존성 추가
qkrwndnjs1075 Aug 31, 2025
10ce71b
feat ( #34 ) : RESILIENCE4J + Retry config 추가
qkrwndnjs1075 Aug 31, 2025
1cf6b8b
feat ( #34 ) : RESILIENCE4J extensions 추가
qkrwndnjs1075 Aug 31, 2025
57ebb8e
feat ( #34 ) : RESILIENCE4J extenstions 적용
qkrwndnjs1075 Aug 31, 2025
2c2efe1
feat ( #34 ) : 서킷 브레이커 테스트 컨트롤러
qkrwndnjs1075 Aug 31, 2025
f387985
chore ( #34 ) : DependencyVersions Netty 추가
coehgns Aug 31, 2025
9f10a49
chore ( #34 ) : Dependencies Netty 추가
coehgns Aug 31, 2025
f15de21
fix ( #34 ) : build.gradle.kts에 netty 설정을 추가하여 netty 관련 에러 해결
coehgns Aug 31, 2025
38ef85d
feat ( #34 ) : ResilienceConfig에 scheduleGrpcCircuitBreaker 추가
coehgns Aug 31, 2025
85c7242
feat ( #34 ) : ResilienceConfig에 scheduleGrpcRetry 추가
coehgns Aug 31, 2025
7d6616f
feat ( #34 ) : ScheduleGrpcClient에 executeGrpcCallWithResilience exte…
coehgns Aug 31, 2025
9115ff2
chore ( #34 ) : GrantExamCodesUseCase에 누락된 kdoc 추가
coehgns Aug 31, 2025
38307ff
refactor ( #34 ) : 코드 구조 수정
coehgns Aug 31, 2025
60e0115
feat ( #34 ) : GrpcResilienceTestController에 Schedule Test 로직 추가
coehgns Aug 31, 2025
f4caf9b
chore ( #34 ) : 클래스 네임 수정
qkrwndnjs1075 Sep 1, 2025
96e90c2
Merge remote-tracking branch 'origin/feature/34-circuit-breaker-for-g…
qkrwndnjs1075 Sep 1, 2025
0a93e10
chore ( #34 ) : test Controller 삭제
qkrwndnjs1075 Sep 1, 2025
e5c32e1
fix ( #34 ) : import * 삭제 후 하나씩 import 하도록 변경
qkrwndnjs1075 Sep 1, 2025
76d94c6
refactor ( #34 ) : Retry 바깥 → CircuitBreaker 안쪽 순서로 수정
qkrwndnjs1075 Sep 1, 2025
889b173
fix ( #34 ) : Netty 의존성 삭제
qkrwndnjs1075 Sep 1, 2025
d8c2bd7
refactor ( #34 ) : ScheduleGrpcClient full back 실패 예외 던지지 않도록 수정
coehgns Sep 2, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions buildSrc/src/main/kotlin/Dependencies.kt
Original file line number Diff line number Diff line change
Expand Up @@ -99,4 +99,12 @@ object Dependencies {
// Cache (스프링 캐시)
const val SPRING_CACHE = "org.springframework.boot:spring-boot-starter-cache"

//Resilience4j
const val RESILIENCE4J_CIRCUITBREAKER = "io.github.resilience4j:resilience4j-circuitbreaker:${DependencyVersions.RESILIENCE4J}"
const val RESILIENCE4J_RETRY = "io.github.resilience4j:resilience4j-retry:${DependencyVersions.RESILIENCE4J}"
const val RESILIENCE4J_SPRING_BOOT = "io.github.resilience4j:resilience4j-spring-boot3:${DependencyVersions.RESILIENCE4J}"
const val RESILIENCE4J_KOTLIN = "io.github.resilience4j:resilience4j-kotlin:${DependencyVersions.RESILIENCE4J}"

// Netty
const val NETTY = "io.netty:netty-resolver-dns-native-macos:${DependencyVersions.NETTY}"
}
6 changes: 6 additions & 0 deletions buildSrc/src/main/kotlin/DependencyVersions.kt
Original file line number Diff line number Diff line change
Expand Up @@ -44,4 +44,10 @@ object DependencyVersions {

// Caffeine
const val CAFFEINE = "3.1.8"

// Resilience4j
const val RESILIENCE4J = "2.0.2"

// Netty
const val NETTY = "4.1.111.Final"
}
6 changes: 6 additions & 0 deletions casper-application-infrastructure/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,12 @@ dependencies {

// MySQL
runtimeOnly(Dependencies.MYSQL_CONNECTOR)

// Resilience4j
implementation(Dependencies.RESILIENCE4J_CIRCUITBREAKER)
implementation(Dependencies.RESILIENCE4J_RETRY)
implementation(Dependencies.RESILIENCE4J_SPRING_BOOT)
implementation(Dependencies.RESILIENCE4J_KOTLIN)
}

sourceSets {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,22 @@ import hs.kr.entrydsm.application.domain.examcode.util.DistanceUtil
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope

/**
* 1차 전형에 합격한 학생들에게 수험번호를 부여하는 유스케이스입니다.
*
* @property applicationContract ApplicationAggregate를 가져옵니다.
* @property statusContract StatusAggregate를 업데이트합니다.
* @property kakaoGeocodeContract 카카오 맵 API와 상호작용합니다.
* @property distanceUtil 두 지점 사이의 거리를 구합니다.
* @property baseLocationContract 기준이 되는 장소의 위경도를 가져옵니다.
*/
@UseCase
class GrantExamCodesUseCase(
private val applicationContract: ApplicationContract,
private val statusContract: StatusContract,
private val kakaoGeocodeContract: KakaoGeocodeContract,
private val distanceUtil: DistanceUtil,
private val baseLocationContract: BaseLocationContract,
private val statusContract: StatusContract,
private val distanceUtil: DistanceUtil,
) : GrantExamCodesContract {

companion object {
Expand All @@ -31,6 +40,10 @@ class GrantExamCodesUseCase(
private const val SPECIAL_EXAM_CODE_PREFIX = "02"
}

/**
* 1차 전형에 합격한 학생들의 주소와 학교까지의 거리를 계산하고,
* 전형별로 그룹화하여 수험번호를 부여합니다.
*/
override suspend fun execute() {
val allFirstRoundPassedApplication = applicationContract.queryAllFirstRoundPassedApplication()
val examCodeInfos = collectDistanceInfo(allFirstRoundPassedApplication)
Expand All @@ -46,16 +59,26 @@ class GrantExamCodesUseCase(
saveExamCodes(examCodeInfos)
}

/**
* 학생들의 주소를 위경도로 변환하고, 학교와의 거리를 계산합니다.
*
* @param applications 1차 전형에 합격한 학생 리스트
* @return 학생들의 접수 코드, 전형 유형, 학교까지의 거리를 담은 리스트
* @throws ExamCodeException.failedGeocodeConversion 주소 변환에 실패했을 경우
*/
private suspend fun collectDistanceInfo(applications: List<Application>): List<ExamCodeInfo> = coroutineScope {
applications.map { application ->
async {
val address = application.streetAddress as String
val coordinate = kakaoGeocodeContract.geocode(address)
?: throw ExamCodeException.failedGeocodeConversion(address)

val baseLat = baseLocationContract.baseLat
val baseLon = baseLocationContract.baseLon

val userLat = coordinate.first
val userLon = coordinate.second

val distance = distanceUtil.haversine(baseLat, baseLon, userLat, userLon)
ExamCodeInfo(
receiptCode = application.receiptCode,
Expand All @@ -66,6 +89,12 @@ class GrantExamCodesUseCase(
}.map { it.await() }
}

/**
* 학생들을 학교까지의 거리를 기준으로 그룹화하고, 그룹 내에서 수험번호를 부여합니다.
*
* @param examCodeInfos 학생들의 정보 리스트
* @param applicationType 전형 유형 (일반, 특별)
*/
private fun assignExamCodes(examCodeInfos: List<ExamCodeInfo>, applicationType: String) {
val sortedByDistance = examCodeInfos.sortedByDescending { it.distance }

Expand All @@ -76,6 +105,13 @@ class GrantExamCodesUseCase(
}
}

/**
* 학생들을 학교까지의 거리가 같은 그룹으로 묶습니다.
*
* @param sortedInfos 거리를 기준으로 내림차순 정렬된 학생 정보 리스트
* @param applicationType 전형 유형
* @return 거리가 같은 학생들끼리 묶인 그룹 리스트
*/
private fun createDistanceGroups(sortedInfos: List<ExamCodeInfo>, applicationType: String): List<DistanceGroup> {
val groups = mutableListOf<DistanceGroup>()
val uniqueDistances = sortedInfos.map { it.distance }.distinct()
Expand All @@ -88,6 +124,11 @@ class GrantExamCodesUseCase(
}


/**
* 같은 거리 그룹 내의 학생들에게 수험번호를 부여합니다.
*
* @param distanceGroup 거리가 같은 학생 그룹
*/
private fun assignNumbersInGroup(distanceGroup: DistanceGroup) {
distanceGroup.examCodeInfoList.forEach { examCodeInfo ->
val receiptCode = String.format("%03d", examCodeInfo.receiptCode)
Expand All @@ -96,7 +137,11 @@ class GrantExamCodesUseCase(
}
}


/**
* 부여된 수험번호를 저장합니다.
*
* @param examCodeInfos 수험번호가 부여된 학생 정보 리스트
*/
private suspend fun saveExamCodes(examCodeInfos: List<ExamCodeInfo>) {
examCodeInfos.forEach { info ->
info.examCode?.let { examCode ->
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package hs.kr.entrydsm.application.global.config

import io.github.resilience4j.circuitbreaker.CircuitBreaker
import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry
import io.github.resilience4j.retry.Retry
import io.github.resilience4j.retry.RetryRegistry
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration

@Configuration
class ResilienceConfig(
private val circuitBreakerRegistry: CircuitBreakerRegistry,
private val retryRegistry: RetryRegistry
) {

@Bean
fun userGrpcCircuitBreaker(): CircuitBreaker {
return circuitBreakerRegistry.circuitBreaker("user-grpc")
}

@Bean
fun statusGrpcCircuitBreaker(): CircuitBreaker {
return circuitBreakerRegistry.circuitBreaker("status-grpc")
}

@Bean
fun scheduleGrpcCircuitBreaker(): CircuitBreaker {
return circuitBreakerRegistry.circuitBreaker("schedule-grpc")
}

@Bean
fun userGrpcRetry(): Retry {
return retryRegistry.retry("user-grpc")
}

@Bean
fun statusGrpcRetry(): Retry {
return retryRegistry.retry("status-grpc")
}

@Bean
fun scheduleGrpcRetry(): Retry {
return retryRegistry.retry("schedule-grpc")
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package hs.kr.entrydsm.application.global.extension

import io.github.resilience4j.circuitbreaker.CircuitBreaker
import io.github.resilience4j.kotlin.circuitbreaker.executeSuspendFunction
import io.github.resilience4j.kotlin.retry.executeSuspendFunction
import io.github.resilience4j.retry.Retry
import kotlinx.coroutines.CancellationException
import org.slf4j.LoggerFactory

private val log = LoggerFactory.getLogger("ResilienceGrpcExtensions")

suspend fun <T> executeGrpcCallWithResilience(
retry: Retry,
circuitBreaker: CircuitBreaker,
fallback: suspend () -> T,
block: suspend () -> T
): T =
try {
retry.executeSuspendFunction {
circuitBreaker.executeSuspendFunction(block)
}
} catch (ce: CancellationException) {
throw ce
} catch (e: Exception) {
log.warn("gRPC 호출 실패, fallback 실행: {}", e.toString())
fallback()
}
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
package hs.kr.entrydsm.application.global.grpc.client.schedule

import hs.kr.entrydsm.application.global.extension.executeGrpcCallWithResilience
import hs.kr.entrydsm.application.global.grpc.dto.schedule.InternalScheduleResponse
import hs.kr.entrydsm.application.global.grpc.dto.schedule.ScheduleType
import hs.kr.entrydsm.casper.schedule.proto.ScheduleServiceGrpc
import hs.kr.entrydsm.casper.schedule.proto.ScheduleServiceProto
import io.github.resilience4j.circuitbreaker.CircuitBreaker
import io.github.resilience4j.retry.Retry
import io.grpc.Channel
import io.grpc.stub.StreamObserver
import kotlinx.coroutines.suspendCancellableCoroutine
import net.devh.boot.grpc.client.inject.GrpcClient
import org.springframework.beans.factory.annotation.Qualifier
import org.springframework.stereotype.Component
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
Expand All @@ -18,7 +22,10 @@ import kotlin.coroutines.resumeWithException
* Schedule Service와 gRPC 통신을 하는 클라이언트입니다.
*/
@Component
class ScheduleGrpcClient {
class ScheduleGrpcClient(
@Qualifier("scheduleGrpcRetry") private val retry: Retry,
@Qualifier("scheduleGrpcCircuitBreaker") private val circuitBreaker: CircuitBreaker
) {
@GrpcClient("schedule-service")
lateinit var channel: Channel

Expand All @@ -28,37 +35,51 @@ class ScheduleGrpcClient {
* @return 일정 정보
*/
suspend fun getScheduleByType(type: String): InternalScheduleResponse {
val scheduleStub = ScheduleServiceGrpc.newStub(channel)
return executeGrpcCallWithResilience(
retry = retry,
circuitBreaker = circuitBreaker,
fallback = {
InternalScheduleResponse(
type = toInternal(
runCatching { ScheduleServiceProto.Type.valueOf(type.uppercase()) }
.getOrDefault(ScheduleServiceProto.Type.START_DATE)
),
date = LocalDateTime.now()
)
}
) {
val scheduleStub = ScheduleServiceGrpc.newStub(channel)

val request =
ScheduleServiceProto.TypeRequest.newBuilder()
.setType(ScheduleServiceProto.Type.valueOf(type.uppercase()))
.build()
val request =
ScheduleServiceProto.TypeRequest.newBuilder()
.setType(ScheduleServiceProto.Type.valueOf(type.uppercase()))
.build()

val response =
suspendCancellableCoroutine { continuation ->
scheduleStub.getScheduleByType(
request,
object : StreamObserver<ScheduleServiceProto.GetScheduleResponse> {
override fun onNext(value: ScheduleServiceProto.GetScheduleResponse) {
continuation.resume(value)
}
val response =
suspendCancellableCoroutine { continuation ->
scheduleStub.getScheduleByType(
request,
object : StreamObserver<ScheduleServiceProto.GetScheduleResponse> {
override fun onNext(value: ScheduleServiceProto.GetScheduleResponse) {
continuation.resume(value)
}

override fun onError(t: Throwable) {
continuation.resumeWithException(t)
}
override fun onError(t: Throwable) {
continuation.resumeWithException(t)
}

override fun onCompleted() {}
},
)
}
override fun onCompleted() {}
},
)
}

val formatter = DateTimeFormatter.ISO_LOCAL_DATE_TIME
val formatter = DateTimeFormatter.ISO_LOCAL_DATE_TIME

val scheduleType = toInternal(response.type)
val date = LocalDateTime.parse(response.date, formatter)
val scheduleType = toInternal(response.type)
val date = LocalDateTime.parse(response.date, formatter)

return InternalScheduleResponse(scheduleType, date)
InternalScheduleResponse(scheduleType, date)
}
}

/**
Expand Down
Loading