diff --git a/buildSrc/src/main/kotlin/Dependencies.kt b/buildSrc/src/main/kotlin/Dependencies.kt index 6ee49ddd..931036be 100644 --- a/buildSrc/src/main/kotlin/Dependencies.kt +++ b/buildSrc/src/main/kotlin/Dependencies.kt @@ -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}" } diff --git a/buildSrc/src/main/kotlin/DependencyVersions.kt b/buildSrc/src/main/kotlin/DependencyVersions.kt index 0218932e..854d2785 100644 --- a/buildSrc/src/main/kotlin/DependencyVersions.kt +++ b/buildSrc/src/main/kotlin/DependencyVersions.kt @@ -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" } diff --git a/casper-application-infrastructure/build.gradle.kts b/casper-application-infrastructure/build.gradle.kts index cb3351d0..1365feb9 100644 --- a/casper-application-infrastructure/build.gradle.kts +++ b/casper-application-infrastructure/build.gradle.kts @@ -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 { diff --git a/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/domain/examcode/usecase/GrantExamCodesUseCase.kt b/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/domain/examcode/usecase/GrantExamCodesUseCase.kt index 50b35033..0b8cadc0 100644 --- a/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/domain/examcode/usecase/GrantExamCodesUseCase.kt +++ b/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/domain/examcode/usecase/GrantExamCodesUseCase.kt @@ -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 { @@ -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) @@ -46,16 +59,26 @@ class GrantExamCodesUseCase( saveExamCodes(examCodeInfos) } + /** + * 학생들의 주소를 위경도로 변환하고, 학교와의 거리를 계산합니다. + * + * @param applications 1차 전형에 합격한 학생 리스트 + * @return 학생들의 접수 코드, 전형 유형, 학교까지의 거리를 담은 리스트 + * @throws ExamCodeException.failedGeocodeConversion 주소 변환에 실패했을 경우 + */ private suspend fun collectDistanceInfo(applications: List): List = 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, @@ -66,6 +89,12 @@ class GrantExamCodesUseCase( }.map { it.await() } } + /** + * 학생들을 학교까지의 거리를 기준으로 그룹화하고, 그룹 내에서 수험번호를 부여합니다. + * + * @param examCodeInfos 학생들의 정보 리스트 + * @param applicationType 전형 유형 (일반, 특별) + */ private fun assignExamCodes(examCodeInfos: List, applicationType: String) { val sortedByDistance = examCodeInfos.sortedByDescending { it.distance } @@ -76,6 +105,13 @@ class GrantExamCodesUseCase( } } + /** + * 학생들을 학교까지의 거리가 같은 그룹으로 묶습니다. + * + * @param sortedInfos 거리를 기준으로 내림차순 정렬된 학생 정보 리스트 + * @param applicationType 전형 유형 + * @return 거리가 같은 학생들끼리 묶인 그룹 리스트 + */ private fun createDistanceGroups(sortedInfos: List, applicationType: String): List { val groups = mutableListOf() val uniqueDistances = sortedInfos.map { it.distance }.distinct() @@ -88,6 +124,11 @@ class GrantExamCodesUseCase( } + /** + * 같은 거리 그룹 내의 학생들에게 수험번호를 부여합니다. + * + * @param distanceGroup 거리가 같은 학생 그룹 + */ private fun assignNumbersInGroup(distanceGroup: DistanceGroup) { distanceGroup.examCodeInfoList.forEach { examCodeInfo -> val receiptCode = String.format("%03d", examCodeInfo.receiptCode) @@ -96,7 +137,11 @@ class GrantExamCodesUseCase( } } - + /** + * 부여된 수험번호를 저장합니다. + * + * @param examCodeInfos 수험번호가 부여된 학생 정보 리스트 + */ private suspend fun saveExamCodes(examCodeInfos: List) { examCodeInfos.forEach { info -> info.examCode?.let { examCode -> diff --git a/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/config/ResilienceConfig.kt b/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/config/ResilienceConfig.kt new file mode 100644 index 00000000..2278394f --- /dev/null +++ b/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/config/ResilienceConfig.kt @@ -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") + } +} diff --git a/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/extension/ResilienceGrpcExtensions.kt b/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/extension/ResilienceGrpcExtensions.kt new file mode 100644 index 00000000..c4cf817b --- /dev/null +++ b/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/extension/ResilienceGrpcExtensions.kt @@ -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 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() + } \ No newline at end of file diff --git a/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/grpc/client/schedule/ScheduleGrpcClient.kt b/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/grpc/client/schedule/ScheduleGrpcClient.kt index 027c147f..02db8b6a 100644 --- a/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/grpc/client/schedule/ScheduleGrpcClient.kt +++ b/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/grpc/client/schedule/ScheduleGrpcClient.kt @@ -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 @@ -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 @@ -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 { - override fun onNext(value: ScheduleServiceProto.GetScheduleResponse) { - continuation.resume(value) - } + val response = + suspendCancellableCoroutine { continuation -> + scheduleStub.getScheduleByType( + request, + object : StreamObserver { + 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) + } } /** diff --git a/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/grpc/client/status/StatusGrpcClient.kt b/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/grpc/client/status/StatusGrpcClient.kt index 473f3ebd..d90370c1 100644 --- a/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/grpc/client/status/StatusGrpcClient.kt +++ b/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/grpc/client/status/StatusGrpcClient.kt @@ -1,15 +1,19 @@ package hs.kr.entrydsm.application.global.grpc.client.status import com.google.protobuf.Empty +import hs.kr.entrydsm.application.global.extension.executeGrpcCallWithResilience import hs.kr.entrydsm.application.global.grpc.dto.status.ApplicationStatus import hs.kr.entrydsm.application.global.grpc.dto.status.InternalStatusListResponse import hs.kr.entrydsm.application.global.grpc.dto.status.InternalStatusResponse import hs.kr.entrydsm.casper.status.proto.StatusServiceGrpc import hs.kr.entrydsm.casper.status.proto.StatusServiceProto +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 kotlin.coroutines.resume import kotlin.coroutines.resumeWithException @@ -20,7 +24,10 @@ import kotlin.coroutines.resumeWithException * @property channel gRPC 통신을 위한 채널 (status-service로 자동 주입됨) */ @Component -class StatusGrpcClient { +class StatusGrpcClient( + @Qualifier("statusGrpcRetry") private val retry: Retry, + @Qualifier("statusGrpcCircuitBreaker") private val circuitBreaker: CircuitBreaker +) { @GrpcClient("status-service") lateinit var channel: Channel @@ -33,6 +40,15 @@ class StatusGrpcClient { * @throws java.util.concurrent.CancellationException 코루틴이 취소된 경우 */ suspend fun getStatusList(): InternalStatusListResponse { + return executeGrpcCallWithResilience( + retry = retry, + circuitBreaker = circuitBreaker, + fallback = { + InternalStatusListResponse(statusList = emptyList()) + } + ) { + + val statusStub = StatusServiceGrpc.newStub(channel) val request = Empty.getDefaultInstance() @@ -55,7 +71,7 @@ class StatusGrpcClient { ) } - return InternalStatusListResponse( + InternalStatusListResponse( statusList = response.statusListList.map { statusElement -> InternalStatusResponse( @@ -69,6 +85,7 @@ class StatusGrpcClient { }, ) } +} /** * 접수번호로 특정 상태를 비동기적으로 조회합니다. @@ -80,15 +97,27 @@ class StatusGrpcClient { * @throws java.util.concurrent.CancellationException 코루틴이 취소된 경우 */ suspend fun getStatusByReceiptCode(receiptCode: Long): InternalStatusResponse { - val statusStub = StatusServiceGrpc.newStub(channel) - - val request = - StatusServiceProto.GetStatusByReceiptCodeRequest.newBuilder() + return executeGrpcCallWithResilience( + retry = retry, + circuitBreaker = circuitBreaker, + fallback = { + // Fallback: 기본 상태 반환 + InternalStatusResponse( + id = 0L, + applicationStatus = ApplicationStatus.NOT_APPLIED, + examCode = null, + isFirstRoundPass = false, + isSecondRoundPass = false, + receiptCode = receiptCode + ) + } + ) { + val statusStub = StatusServiceGrpc.newStub(channel) + val request = StatusServiceProto.GetStatusByReceiptCodeRequest.newBuilder() .setReceiptCode(receiptCode) .build() - val response = - suspendCancellableCoroutine { continuation -> + val response = suspendCancellableCoroutine { continuation -> statusStub.getStatusByReceiptCode( request, object : StreamObserver { @@ -101,17 +130,19 @@ class StatusGrpcClient { } override fun onCompleted() {} - }, + } ) } - return InternalStatusResponse( - id = response.status.id, - applicationStatus = mapProtoApplicationStatus(response.status.applicationStatus), - examCode = response.status.examCode.takeIf { it.isNotBlank() }, - isFirstRoundPass = response.status.isFirstRoundPass, - isSecondRoundPass = response.status.isSecondRoundPass, - receiptCode = response.status.receiptCode, - ) + + InternalStatusResponse( + id = response.status.id, + applicationStatus = mapProtoApplicationStatus(response.status.applicationStatus), + examCode = response.status.examCode.takeIf { it.isNotBlank() }, + isFirstRoundPass = response.status.isFirstRoundPass, + isSecondRoundPass = response.status.isSecondRoundPass, + receiptCode = response.status.receiptCode + ) + } } /** @@ -123,33 +154,37 @@ class StatusGrpcClient { * @throws io.grpc.StatusRuntimeException gRPC 서버에서 오류가 발생한 경우 * @throws java.util.concurrent.CancellationException 코루틴이 취소된 경우 */ - suspend fun updateExamCode( - receiptCode: Long, - examCode: String, - ) { - val statusStub = StatusServiceGrpc.newStub(channel) - - val request = - StatusServiceProto.GetExamCodeRequest.newBuilder() + suspend fun updateExamCode(receiptCode: Long, examCode: String) { + return executeGrpcCallWithResilience( + retry = retry, + circuitBreaker = circuitBreaker, + fallback = { + // Fallback: 로깅만 하고 조용히 실패 + println("Failed to update exam code for receiptCode: $receiptCode") + } + ) { + val statusStub = StatusServiceGrpc.newStub(channel) + val request = StatusServiceProto.GetExamCodeRequest.newBuilder() .setReceiptCode(receiptCode) .setExamCode(examCode) .build() - suspendCancellableCoroutine { continuation -> - statusStub.updateExamCode( - request, - object : StreamObserver { - override fun onNext(value: Empty) { - continuation.resume(Unit) - } + suspendCancellableCoroutine { continuation -> + statusStub.updateExamCode( + request, + object : StreamObserver { + override fun onNext(value: Empty) { + continuation.resume(Unit) + } - override fun onError(t: Throwable) { - continuation.resumeWithException(t) - } + override fun onError(t: Throwable) { + continuation.resumeWithException(t) + } - override fun onCompleted() {} - }, - ) + override fun onCompleted() {} + } + ) + } } } diff --git a/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/grpc/client/user/UserGrpcClient.kt b/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/grpc/client/user/UserGrpcClient.kt index 339476e1..72b75b5b 100644 --- a/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/grpc/client/user/UserGrpcClient.kt +++ b/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/grpc/client/user/UserGrpcClient.kt @@ -1,13 +1,17 @@ package hs.kr.entrydsm.application.global.grpc.client.user +import hs.kr.entrydsm.application.global.extension.executeGrpcCallWithResilience import hs.kr.entrydsm.application.global.grpc.dto.user.InternalUserResponse import hs.kr.entrydsm.application.global.grpc.dto.user.UserRole import hs.kr.entrydsm.casper.user.proto.UserServiceGrpc import hs.kr.entrydsm.casper.user.proto.UserServiceProto +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.util.UUID import kotlin.coroutines.resume @@ -19,7 +23,10 @@ import kotlin.coroutines.resumeWithException * @property channel gRPC 통신을 위한 채널 (user-service로 자동 주입됨) */ @Component -class UserGrpcClient { +class UserGrpcClient( + @Qualifier("userGrpcRetry") private val retry: Retry, + @Qualifier("userGrpcCircuitBreaker") private val circuitBreaker: CircuitBreaker +) { @GrpcClient("user-service") lateinit var channel: Channel @@ -33,38 +40,52 @@ class UserGrpcClient { * @throws java.util.concurrent.CancellationException 코루틴이 취소된 경우 */ suspend fun getUserInfoByUserId(userId: UUID): InternalUserResponse { - val userStub = UserServiceGrpc.newStub(channel) + return executeGrpcCallWithResilience( + retry = retry, + circuitBreaker = circuitBreaker, + fallback = { + InternalUserResponse( + id = userId, + phoneNumber = "N/A", + name = "Unknown User", + isParent = false, + role = UserRole.USER + ) + } + ) { + val userStub = UserServiceGrpc.newStub(channel) - val request = - UserServiceProto.GetUserInfoRequest.newBuilder() - .setUserId(userId.toString()) - .build() + val request = + UserServiceProto.GetUserInfoRequest.newBuilder() + .setUserId(userId.toString()) + .build() - val response = - suspendCancellableCoroutine { continuation -> - userStub.getUserInfoByUserId( - request, - object : StreamObserver { - override fun onNext(value: UserServiceProto.GetUserInfoResponse) { - continuation.resume(value) - } + val response = + suspendCancellableCoroutine { continuation -> + userStub.getUserInfoByUserId( + request, + object : StreamObserver { + override fun onNext(value: UserServiceProto.GetUserInfoResponse) { + 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() {} + }, + ) + } - return InternalUserResponse( - id = UUID.fromString(response.id), - phoneNumber = response.phoneNumber, - name = response.name, - isParent = response.isParent, - role = mapProtoUserRole(response.role), - ) + InternalUserResponse( + id = UUID.fromString(response.id), + phoneNumber = response.phoneNumber, + name = response.name, + isParent = response.isParent, + role = mapProtoUserRole(response.role), + ) + } } /**