diff --git a/buildSrc/src/main/kotlin/Dependencies.kt b/buildSrc/src/main/kotlin/Dependencies.kt index 931036be..fee91cc0 100644 --- a/buildSrc/src/main/kotlin/Dependencies.kt +++ b/buildSrc/src/main/kotlin/Dependencies.kt @@ -107,4 +107,7 @@ object Dependencies { // Netty const val NETTY = "io.netty:netty-resolver-dns-native-macos:${DependencyVersions.NETTY}" + + //kafka + const val KAFKA = "org.springframework.kafka:spring-kafka" } diff --git a/casper-application-domain/src/main/kotlin/hs/kr/entrydsm/domain/application/interfaces/ApplicationConsumeEventContract.kt b/casper-application-domain/src/main/kotlin/hs/kr/entrydsm/domain/application/interfaces/ApplicationConsumeEventContract.kt new file mode 100644 index 00000000..d468d092 --- /dev/null +++ b/casper-application-domain/src/main/kotlin/hs/kr/entrydsm/domain/application/interfaces/ApplicationConsumeEventContract.kt @@ -0,0 +1,5 @@ +package hs.kr.entrydsm.domain.application.interfaces + +interface ApplicationConsumeEventContract { + fun deleteByReceiptCode(receiptCode: Long) +} \ No newline at end of file diff --git a/casper-application-domain/src/main/kotlin/hs/kr/entrydsm/domain/application/interfaces/ApplicationCreateEventContract.kt b/casper-application-domain/src/main/kotlin/hs/kr/entrydsm/domain/application/interfaces/ApplicationCreateEventContract.kt new file mode 100644 index 00000000..55577bfe --- /dev/null +++ b/casper-application-domain/src/main/kotlin/hs/kr/entrydsm/domain/application/interfaces/ApplicationCreateEventContract.kt @@ -0,0 +1,7 @@ +package hs.kr.entrydsm.domain.application.interfaces + +import java.util.UUID + +interface ApplicationCreateEventContract { + fun publishCreateApplication(receiptCode: Long, userId: UUID) +} \ No newline at end of file diff --git a/casper-application-domain/src/main/kotlin/hs/kr/entrydsm/domain/status/aggregates/Status.kt b/casper-application-domain/src/main/kotlin/hs/kr/entrydsm/domain/status/aggregates/Status.kt index 4b402407..1a276d74 100644 --- a/casper-application-domain/src/main/kotlin/hs/kr/entrydsm/domain/status/aggregates/Status.kt +++ b/casper-application-domain/src/main/kotlin/hs/kr/entrydsm/domain/status/aggregates/Status.kt @@ -3,6 +3,19 @@ package hs.kr.entrydsm.domain.status.aggregates import hs.kr.entrydsm.domain.status.values.ApplicationStatus import hs.kr.entrydsm.global.annotation.aggregates.Aggregate +/** + * 원서 상태 정보를 나타내는 도메인 애그리게이트입니다. + * + * 지원자의 전형 상태, 시험 코드, 합격 여부 등 원서 처리 과정에서 + * 필요한 상태 정보를 관리합니다. + * + * @property id 상태 정보의 고유 식별자 (nullable, 기본값 0) + * @property examCode 시험 코드 (nullable, 시험 코드가 배정되지 않은 경우 null) + * @property applicationStatus 현재 원서의 전형 상태 + * @property isFirstRoundPass 1차 전형 합격 여부 (기본값 false) + * @property isSecondRoundPass 2차 전형 합격 여부 (기본값 false) + * @property receiptCode 해당 상태와 연결된 접수번호 + */ @Aggregate(context = "status") data class Status( val id: Long? = 0, @@ -11,4 +24,4 @@ data class Status( val isFirstRoundPass: Boolean = false, val isSecondRoundPass: Boolean = false, val receiptCode: Long, -) \ No newline at end of file +) diff --git a/casper-application-domain/src/main/kotlin/hs/kr/entrydsm/domain/status/aggregates/StatusCache.kt b/casper-application-domain/src/main/kotlin/hs/kr/entrydsm/domain/status/aggregates/StatusCache.kt index 09a8c22e..4ac78a42 100644 --- a/casper-application-domain/src/main/kotlin/hs/kr/entrydsm/domain/status/aggregates/StatusCache.kt +++ b/casper-application-domain/src/main/kotlin/hs/kr/entrydsm/domain/status/aggregates/StatusCache.kt @@ -2,6 +2,19 @@ package hs.kr.entrydsm.domain.status.aggregates import hs.kr.entrydsm.domain.status.values.ApplicationStatus +/** + * 원서 상태 정보의 캐시를 나타내는 도메인 모델입니다. + * + * 자주 조회되는 상태 정보를 Redis 등의 캐시 스토리지에 저장하여 + * 조회 성능을 향상시키기 위해 사용됩니다. + * + * @property receiptCode 접수번호 + * @property examCode 시험 코드 (nullable) + * @property applicationStatus 현재 원서의 전형 상태 + * @property isFirstRoundPass 1차 전형 합격 여부 + * @property isSecondRoundPass 2차 전형 합격 여부 + * @property ttl 캐시 만료 시간 (Time To Live, 초 단위) + */ data class StatusCache( val receiptCode: Long, val examCode: String?, @@ -9,4 +22,4 @@ data class StatusCache( val isFirstRoundPass: Boolean, val isSecondRoundPass: Boolean, val ttl: Long -) \ No newline at end of file +) diff --git a/casper-application-domain/src/main/kotlin/hs/kr/entrydsm/domain/status/interfaces/ApplicationCommandStatusContract.kt b/casper-application-domain/src/main/kotlin/hs/kr/entrydsm/domain/status/interfaces/ApplicationCommandStatusContract.kt index 66a5ad9e..49899710 100644 --- a/casper-application-domain/src/main/kotlin/hs/kr/entrydsm/domain/status/interfaces/ApplicationCommandStatusContract.kt +++ b/casper-application-domain/src/main/kotlin/hs/kr/entrydsm/domain/status/interfaces/ApplicationCommandStatusContract.kt @@ -1,5 +1,20 @@ package hs.kr.entrydsm.domain.status.interfaces +/** + * 원서 상태 변경을 위한 계약 인터페이스입니다. + * + * 접수번호를 기반으로 원서 상태 정보를 업데이트하는 기능을 정의합니다. + */ interface ApplicationCommandStatusContract { + + /** + * 지정된 접수번호의 시험 코드를 업데이트합니다. + * + * 시험 코드 배정 시 외부 Status 서비스에 해당 접수번호의 + * 시험 코드 정보를 업데이트 요청합니다. + * + * @param receiptCode 시험 코드를 업데이트할 접수번호 + * @param examCode 새로 배정된 시험 코드 + */ fun updateExamCode(receiptCode: Long, examCode: String) -} \ No newline at end of file +} diff --git a/casper-application-domain/src/main/kotlin/hs/kr/entrydsm/domain/status/interfaces/ApplicationQueryStatusContract.kt b/casper-application-domain/src/main/kotlin/hs/kr/entrydsm/domain/status/interfaces/ApplicationQueryStatusContract.kt index 078fd1df..e9ef04c3 100644 --- a/casper-application-domain/src/main/kotlin/hs/kr/entrydsm/domain/status/interfaces/ApplicationQueryStatusContract.kt +++ b/casper-application-domain/src/main/kotlin/hs/kr/entrydsm/domain/status/interfaces/ApplicationQueryStatusContract.kt @@ -3,7 +3,32 @@ package hs.kr.entrydsm.domain.status.interfaces import hs.kr.entrydsm.domain.status.aggregates.Status import hs.kr.entrydsm.domain.status.aggregates.StatusCache +/** + * 원서 상태 조회를 위한 계약 인터페이스입니다. + * + * 접수번호를 기반으로 원서 상태 정보를 조회하는 기능을 정의하며, + * 일반 조회와 캐시 기반 조회를 모두 지원합니다. + */ interface ApplicationQueryStatusContract { + + /** + * 접수번호로 원서 상태를 조회합니다. + * + * 외부 Status 서비스에서 해당 접수번호의 상태 정보를 조회합니다. + * + * @param receiptCode 조회할 접수번호 + * @return 조회된 상태 정보, 존재하지 않는 경우 null + */ fun queryStatusByReceiptCode(receiptCode: Long): Status? + + /** + * 캐시에서 접수번호로 원서 상태를 조회합니다. + * + * Redis 등의 캐시 스토리지에서 해당 접수번호의 상태 정보를 조회하여 + * 빠른 응답을 제공합니다. + * + * @param receiptCode 조회할 접수번호 + * @return 캐시된 상태 정보, 존재하지 않는 경우 null + */ fun queryStatusByReceiptCodeInCache(receiptCode: Long): StatusCache? -} \ No newline at end of file +} diff --git a/casper-application-domain/src/main/kotlin/hs/kr/entrydsm/domain/status/interfaces/SaveExamCodeContract.kt b/casper-application-domain/src/main/kotlin/hs/kr/entrydsm/domain/status/interfaces/SaveExamCodeContract.kt index c5e1e3c7..fbab42f3 100644 --- a/casper-application-domain/src/main/kotlin/hs/kr/entrydsm/domain/status/interfaces/SaveExamCodeContract.kt +++ b/casper-application-domain/src/main/kotlin/hs/kr/entrydsm/domain/status/interfaces/SaveExamCodeContract.kt @@ -1,5 +1,20 @@ package hs.kr.entrydsm.domain.status.interfaces +/** + * 시험 코드 저장을 위한 계약 인터페이스입니다. + * + * 비동기적으로 시험 코드를 외부 서비스에 저장하는 기능을 정의합니다. + */ interface SaveExamCodeContract { + + /** + * 지정된 접수번호의 시험 코드를 비동기적으로 업데이트합니다. + * + * Coroutine을 사용하여 비블로킹 방식으로 외부 서비스에 + * 시험 코드 업데이트를 요청합니다. + * + * @param receiptCode 시험 코드를 업데이트할 접수번호 + * @param examCode 새로 배정된 시험 코드 + */ suspend fun updateExamCode(receiptCode: Long, examCode: String) -} \ No newline at end of file +} diff --git a/casper-application-domain/src/main/kotlin/hs/kr/entrydsm/domain/status/interfaces/StatusContract.kt b/casper-application-domain/src/main/kotlin/hs/kr/entrydsm/domain/status/interfaces/StatusContract.kt index 2998192b..c2220f30 100644 --- a/casper-application-domain/src/main/kotlin/hs/kr/entrydsm/domain/status/interfaces/StatusContract.kt +++ b/casper-application-domain/src/main/kotlin/hs/kr/entrydsm/domain/status/interfaces/StatusContract.kt @@ -1,3 +1,9 @@ package hs.kr.entrydsm.domain.status.interfaces -interface StatusContract : ApplicationQueryStatusContract, ApplicationCommandStatusContract \ No newline at end of file +/** + * 상태 관련 모든 계약을 통합하는 메인 계약 인터페이스입니다. + * + * 상태 조회(Query)와 상태 변경(Command) 계약을 모두 상속받아 + * 상태 관련 모든 기능에 대한 단일 진입점을 제공합니다. + */ +interface StatusContract : ApplicationQueryStatusContract, ApplicationCommandStatusContract diff --git a/casper-application-domain/src/main/kotlin/hs/kr/entrydsm/domain/user/aggregates/User.kt b/casper-application-domain/src/main/kotlin/hs/kr/entrydsm/domain/user/aggregates/User.kt index 3b979853..9fa1b716 100644 --- a/casper-application-domain/src/main/kotlin/hs/kr/entrydsm/domain/user/aggregates/User.kt +++ b/casper-application-domain/src/main/kotlin/hs/kr/entrydsm/domain/user/aggregates/User.kt @@ -3,10 +3,32 @@ package hs.kr.entrydsm.domain.user.aggregates import hs.kr.entrydsm.global.annotation.aggregates.Aggregate import java.util.UUID +/** + * 사용자 정보를 나타내는 도메인 애그리게이트입니다. + * + * 사용자의 기본 정보와 부모 여부를 관리하며, + * 다른 마이크로서비스로부터 받아온 사용자 데이터를 도메인 내에서 활용하기 위한 모델입니다. + */ @Aggregate(context = "user") data class User( + /** + * 사용자의 고유 식별자 + */ val id: UUID, + + /** + * 사용자의 전화번호 + */ val phoneNumber: String, + + /** + * 사용자의 이름 + */ val name: String, + + /** + * 부모 여부를 나타내는 플래그 + * true인 경우 부모, false인 경우 학생 + */ val isParent: Boolean ) diff --git a/casper-application-domain/src/main/kotlin/hs/kr/entrydsm/domain/user/interfaces/ApplicationQueryUserContract.kt b/casper-application-domain/src/main/kotlin/hs/kr/entrydsm/domain/user/interfaces/ApplicationQueryUserContract.kt index 81b6bfd7..bd170f49 100644 --- a/casper-application-domain/src/main/kotlin/hs/kr/entrydsm/domain/user/interfaces/ApplicationQueryUserContract.kt +++ b/casper-application-domain/src/main/kotlin/hs/kr/entrydsm/domain/user/interfaces/ApplicationQueryUserContract.kt @@ -3,6 +3,17 @@ package hs.kr.entrydsm.domain.user.interfaces import hs.kr.entrydsm.domain.user.aggregates.User import java.util.UUID +/** + * 원서 조회 시 필요한 사용자 정보 조회를 위한 계약 인터페이스입니다. + * + * 원서와 연관된 사용자 정보를 외부 서비스로부터 조회하는 기능을 정의합니다. + */ interface ApplicationQueryUserContract { + /** + * 사용자 ID로 사용자 정보를 조회합니다. + * + * @param userId 조회할 사용자의 고유 식별자 + * @return 조회된 사용자 정보 + */ fun queryUserByUserId(userId: UUID): User -} \ No newline at end of file +} diff --git a/casper-application-domain/src/main/kotlin/hs/kr/entrydsm/domain/user/interfaces/UserContract.kt b/casper-application-domain/src/main/kotlin/hs/kr/entrydsm/domain/user/interfaces/UserContract.kt index cdd4b742..b9c4bad5 100644 --- a/casper-application-domain/src/main/kotlin/hs/kr/entrydsm/domain/user/interfaces/UserContract.kt +++ b/casper-application-domain/src/main/kotlin/hs/kr/entrydsm/domain/user/interfaces/UserContract.kt @@ -1,4 +1,10 @@ package hs.kr.entrydsm.domain.user.interfaces +/** + * 사용자 관련 외부 통신을 위한 계약 인터페이스입니다. + * + * 현재는 ApplicationQueryUserContract를 상속받아 + * 원서 조회 시 필요한 사용자 정보 조회 기능을 제공합니다. + */ interface UserContract : ApplicationQueryUserContract { -} \ No newline at end of file +} diff --git a/casper-application-infrastructure/build.gradle.kts b/casper-application-infrastructure/build.gradle.kts index 1365feb9..57b031be 100644 --- a/casper-application-infrastructure/build.gradle.kts +++ b/casper-application-infrastructure/build.gradle.kts @@ -110,6 +110,9 @@ dependencies { implementation(Dependencies.RESILIENCE4J_RETRY) implementation(Dependencies.RESILIENCE4J_SPRING_BOOT) implementation(Dependencies.RESILIENCE4J_KOTLIN) + + //kafka + implementation(Dependencies.KAFKA) } sourceSets { diff --git a/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/domain/application/event/consumer/DeleteApplicationConsumer.kt b/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/domain/application/event/consumer/DeleteApplicationConsumer.kt new file mode 100644 index 00000000..f7e750f5 --- /dev/null +++ b/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/domain/application/event/consumer/DeleteApplicationConsumer.kt @@ -0,0 +1,49 @@ +package hs.kr.entrydsm.application.domain.application.event.consumer + +import com.fasterxml.jackson.databind.ObjectMapper +import hs.kr.entrydsm.application.global.kafka.configuration.KafkaTopics +import hs.kr.entrydsm.domain.application.interfaces.ApplicationConsumeEventContract +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Component +import org.springframework.kafka.annotation.KafkaListener + +/** + * 사용자 삭제 이벤트를 수신하여 연관된 원서를 삭제하는 Consumer 클래스입니다. + * + * 사용자 서비스에서 사용자가 삭제되었을 때 해당 사용자의 원서도 함께 삭제하여 + * 데이터 일관성을 유지하는 역할을 담당합니다. + * + * @property mapper JSON 역직렬화를 위한 ObjectMapper + * @property applicationConsumeContract 원서 삭제 로직을 처리하는 계약 인터페이스 + */ +@Component +class DeleteApplicationConsumer( + private val mapper: ObjectMapper, + private val applicationConsumeContract: ApplicationConsumeEventContract +) { + private val logger = LoggerFactory.getLogger(DeleteApplicationConsumer::class.java) + + /** + * 사용자 삭제 이벤트를 수신하여 연관된 원서를 삭제합니다. + * + * DELETE_USER 토픽에서 접수번호를 수신하고, 해당 접수번호에 해당하는 + * 원서를 삭제합니다. 처리 과정에서 발생하는 오류는 로그로 기록됩니다. + * + * @param message 사용자 삭제 이벤트 메시지 (접수번호가 JSON 형태로 포함) + */ + @KafkaListener( + topics = [KafkaTopics.DELETE_USER], + groupId = "delete-application-consumer", + containerFactory = "kafkaListenerContainerFactory" + ) + fun deleteApplication(message: String) { + try{ + val receiptCode = mapper.readValue(message, Long::class.java) + logger.info("사용자 삭제로 인한 원서 삭제: receiptCode=$receiptCode") + applicationConsumeContract.deleteByReceiptCode(receiptCode) + logger.info("원서 삭제 완료: receiptCode=$receiptCode") + } catch (e: Exception) { + logger.error("원서 삭제 처리 실패: $message", e) + } + } +} diff --git a/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/domain/application/event/consumer/UserUpdateResultConsumer.kt b/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/domain/application/event/consumer/UserUpdateResultConsumer.kt new file mode 100644 index 00000000..45735434 --- /dev/null +++ b/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/domain/application/event/consumer/UserUpdateResultConsumer.kt @@ -0,0 +1,82 @@ +package hs.kr.entrydsm.application.domain.application.event.consumer + +import com.fasterxml.jackson.databind.ObjectMapper +import hs.kr.entrydsm.application.global.kafka.configuration.KafkaTopics +import hs.kr.entrydsm.application.domain.application.event.dto.UserReceiptCodeUpdateCompletedEvent +import hs.kr.entrydsm.application.domain.application.event.dto.UserReceiptCodeUpdateFailedEvent +import hs.kr.entrydsm.domain.application.interfaces.ApplicationConsumeEventContract +import org.slf4j.LoggerFactory +import org.springframework.kafka.annotation.KafkaListener +import org.springframework.stereotype.Component +import org.springframework.transaction.annotation.Transactional + +/** + * 사용자 접수번호 업데이트 결과 이벤트를 처리하는 Consumer 클래스입니다. + * + * 사용자 서비스에서 접수번호 업데이트 성공/실패 결과를 수신하여 + * Choreography 패턴 기반의 분산 트랜잭션을 처리합니다. + * 실패 시에는 보상 트랜잭션으로 원서를 삭제합니다. + * + * @property mapper JSON 역직렬화를 위한 ObjectMapper + * @property applicationConsumeContract 원서 삭제 로직을 처리하는 계약 인터페이스 + */ +@Component +class UserUpdateResultConsumer( + private val mapper: ObjectMapper, + private val applicationConsumeContract: ApplicationConsumeEventContract, +) { + private val logger = LoggerFactory.getLogger(UserUpdateResultConsumer::class.java) + + /** + * 사용자 접수번호 업데이트 성공 이벤트를 처리합니다. + * + * 현재는 성공 로그만 기록하며, 향후 추가적인 후속 처리가 필요한 경우 + * 이 메서드에서 처리할 수 있습니다. + * + * @param message 접수번호 업데이트 완료 이벤트 메시지 + */ + @KafkaListener( + topics = [KafkaTopics.USER_RECEIPT_CODE_UPDATE_COMPLETED], + groupId = "user-update-result-consumer", + containerFactory = "kafkaListenerContainerFactory" + ) + fun handleUserUpdateCompleted(message: String) { + try { + val event = mapper.readValue(message, UserReceiptCodeUpdateCompletedEvent::class.java) + logger.info("사용자 receiptCode 업데이트 성공: receiptCode=${event.receiptCode}, userId=${event.userId}") + // Choreography: 추가 처리가 필요하면 여기서 + // 현재는 단순히 로깅만 (원서 생성 완료) + } catch (e: Exception) { + logger.error("User 업데이트 성공 이벤트 처리 실패: $message", e) + } + } + + /** + * 사용자 접수번호 업데이트 실패 이벤트를 처리하고 보상 트랜잭션을 수행합니다. + * + * 사용자 서비스에서 접수번호 업데이트가 실패했을 때 데이터 일관성을 유지하기 위해 + * 해당 접수번호의 원서를 삭제하는 보상 트랜잭션을 수행합니다. + * + * @param message 접수번호 업데이트 실패 이벤트 메시지 + */ + @KafkaListener( + topics = [KafkaTopics.USER_RECEIPT_CODE_UPDATE_FAILED], + groupId = "user-update-result-consumer", + containerFactory = "kafkaListenerContainerFactory" + ) + @Transactional + fun handleUserUpdateFailed(message: String) { + try { + val event = mapper.readValue(message, UserReceiptCodeUpdateFailedEvent::class.java) + logger.info("사용자 receiptCode 업데이트 실패: receiptCode=${event.receiptCode}, reson=${event.reason}") + + logger.info("보상 트랜잭션 시작: 원서 삭제 receiptCode=${event.receiptCode}") + applicationConsumeContract.deleteByReceiptCode(event.receiptCode) + logger.info("보상 트랜잭션 완료: 원서 삭제됨 receiptCode=${event.receiptCode}") + + } catch (e: Exception) { + logger.error("USER 업데이트 실패 이벤트 처리 실패 : $message", e) + + } + } +} diff --git a/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/domain/application/event/dto/CreateApplicationEvent.kt b/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/domain/application/event/dto/CreateApplicationEvent.kt new file mode 100644 index 00000000..77038776 --- /dev/null +++ b/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/domain/application/event/dto/CreateApplicationEvent.kt @@ -0,0 +1,17 @@ +package hs.kr.entrydsm.application.domain.application.event.dto + +import java.util.UUID + +/** + * 원서 생성 이벤트 데이터를 담는 DTO 클래스입니다. + * + * 원서가 성공적으로 생성되었을 때 사용자 서비스에 접수번호 업데이트를 + * 요청하기 위해 Kafka를 통해 전송되는 이벤트 데이터를 정의합니다. + * + * @property receiptCode 생성된 원서의 접수번호 + * @property userId 원서를 생성한 사용자의 고유 식별자 + */ +data class CreateApplicationEvent( + val receiptCode: Long, + val userId: UUID +) diff --git a/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/domain/application/event/dto/UserReceiptCodeUpdateCompletedEvent.kt b/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/domain/application/event/dto/UserReceiptCodeUpdateCompletedEvent.kt new file mode 100644 index 00000000..9b663013 --- /dev/null +++ b/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/domain/application/event/dto/UserReceiptCodeUpdateCompletedEvent.kt @@ -0,0 +1,17 @@ +package hs.kr.entrydsm.application.domain.application.event.dto + +import java.util.UUID + +/** + * 사용자 접수번호 업데이트 완료 이벤트 데이터를 담는 DTO 클래스입니다. + * + * 사용자 서비스에서 접수번호 업데이트가 성공적으로 완료되었음을 알리는 + * 이벤트 데이터를 정의합니다. + * + * @property receiptCode 업데이트된 접수번호 + * @property userId 접수번호가 업데이트된 사용자의 고유 식별자 + */ +data class UserReceiptCodeUpdateCompletedEvent( + val receiptCode: Long, + val userId: UUID +) diff --git a/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/domain/application/event/dto/UserReceiptCodeUpdateFailedEvent.kt b/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/domain/application/event/dto/UserReceiptCodeUpdateFailedEvent.kt new file mode 100644 index 00000000..5bd3c845 --- /dev/null +++ b/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/domain/application/event/dto/UserReceiptCodeUpdateFailedEvent.kt @@ -0,0 +1,19 @@ +package hs.kr.entrydsm.application.domain.application.event.dto + +import java.util.UUID + +/** + * 사용자 접수번호 업데이트 실패 이벤트 데이터를 담는 DTO 클래스입니다. + * + * 사용자 서비스에서 접수번호 업데이트가 실패했음을 알리는 이벤트 데이터를 정의하며, + * 이 이벤트를 수신한 원서 서비스는 보상 트랜잭션을 통해 해당 원서를 삭제합니다. + * + * @property receiptCode 업데이트 실패한 접수번호 + * @property userId 접수번호 업데이트가 실패한 사용자의 고유 식별자 + * @property reason 업데이트 실패 사유 + */ +data class UserReceiptCodeUpdateFailedEvent( + val receiptCode: Long, + val userId: UUID, + val reason: String +) diff --git a/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/domain/application/event/producer/ApplicationEventProducer.kt b/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/domain/application/event/producer/ApplicationEventProducer.kt new file mode 100644 index 00000000..40ecd8f9 --- /dev/null +++ b/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/domain/application/event/producer/ApplicationEventProducer.kt @@ -0,0 +1,42 @@ +package hs.kr.entrydsm.application.domain.application.event.producer + +import com.fasterxml.jackson.databind.ObjectMapper +import hs.kr.entrydsm.application.domain.application.event.dto.CreateApplicationEvent +import hs.kr.entrydsm.application.global.kafka.configuration.KafkaTopics +import hs.kr.entrydsm.domain.application.interfaces.ApplicationCreateEventContract +import org.springframework.kafka.core.KafkaTemplate +import org.springframework.stereotype.Component +import java.util.UUID + +/** + * 원서 생성 이벤트를 발행하는 Producer 클래스입니다. + * + * 원서가 생성되었을 때 사용자 서비스에 접수번호 업데이트를 요청하기 위한 + * 이벤트를 Kafka로 발행하는 역할을 담당합니다. + * + * @property mapper JSON 직렬화를 위한 ObjectMapper + * @property createApplicationTemplate 원서 생성 이벤트 발행용 KafkaTemplate + */ +@Component +class ApplicationEventProducer( + private val mapper: ObjectMapper, + private val createApplicationTemplate: KafkaTemplate +): ApplicationCreateEventContract { + + /** + * 원서 생성 이벤트를 발행합니다. + * + * 원서가 성공적으로 생성된 후 사용자 서비스에서 해당 사용자의 접수번호를 + * 업데이트하도록 이벤트를 발행합니다. + * + * @param receiptCode 생성된 원서의 접수번호 + * @param userId 원서를 생성한 사용자의 ID + */ + override fun publishCreateApplication(receiptCode: Long, userId: UUID) { + val createApplicationEvent = CreateApplicationEvent(receiptCode, userId) + createApplicationTemplate.send( + KafkaTopics.CREATE_APPLICATION, + mapper.writeValueAsString(createApplicationEvent) + ) + } +} diff --git a/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/domain/status/domain/StatusPersistenceAdapter.kt b/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/domain/status/domain/StatusPersistenceAdapter.kt index 6e962ad1..7ee8c1e1 100644 --- a/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/domain/status/domain/StatusPersistenceAdapter.kt +++ b/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/domain/status/domain/StatusPersistenceAdapter.kt @@ -8,12 +8,30 @@ import hs.kr.entrydsm.domain.status.interfaces.StatusContract import kotlinx.coroutines.runBlocking import org.springframework.stereotype.Component +/** + * 상태 정보 조회 및 변경을 위한 퍼시스턴스 어댑터입니다. + * + * gRPC를 통한 외부 Status 서비스와의 통신과 Redis 캐시를 통한 + * 빠른 상태 조회를 모두 지원합니다. + * + * @property statusGrpcClient Status 서비스와의 gRPC 통신 클라이언트 + * @property statusCacheRepository Redis 기반 상태 캐시 저장소 + */ @Component class StatusPersistenceAdapter( private val statusGrpcClient: StatusGrpcClient, private val statusCacheRepository: StatusCacheRepository ) : StatusContract { + /** + * 접수번호로 원서 상태를 조회합니다. + * + * gRPC를 통해 Status 서비스에서 상태 정보를 조회하고, + * 응답 데이터를 도메인 모델로 변환하여 반환합니다. + * + * @param receiptCode 조회할 접수번호 + * @return 조회된 상태 정보, 존재하지 않는 경우 null + */ override fun queryStatusByReceiptCode(receiptCode: Long): Status? = runBlocking { statusGrpcClient.getStatusByReceiptCode(receiptCode)?.let { Status( @@ -27,6 +45,15 @@ class StatusPersistenceAdapter( } } + /** + * 캐시에서 접수번호로 원서 상태를 조회합니다. + * + * Redis 캐시 저장소에서 해당 접수번호의 상태 정보를 조회하여 + * 빠른 응답을 제공합니다. + * + * @param receiptCode 조회할 접수번호 + * @return 캐시된 상태 정보, 존재하지 않는 경우 null + */ override fun queryStatusByReceiptCodeInCache(receiptCode: Long): StatusCache? { return statusCacheRepository.findById(receiptCode) .map { @@ -41,7 +68,15 @@ class StatusPersistenceAdapter( }.orElse(null) } + /** + * 지정된 접수번호의 시험 코드를 업데이트합니다. + * + * gRPC를 통해 Status 서비스에 시험 코드 업데이트를 요청합니다. + * + * @param receiptCode 시험 코드를 업데이트할 접수번호 + * @param examCode 새로 배정된 시험 코드 + */ override fun updateExamCode(receiptCode: Long, examCode: String) = runBlocking { statusGrpcClient.updateExamCode(receiptCode, examCode) } -} \ No newline at end of file +} diff --git a/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/domain/status/domain/entity/StatusCacheRedisEntity.kt b/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/domain/status/domain/entity/StatusCacheRedisEntity.kt index 612e2894..69153249 100644 --- a/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/domain/status/domain/entity/StatusCacheRedisEntity.kt +++ b/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/domain/status/domain/entity/StatusCacheRedisEntity.kt @@ -5,6 +5,19 @@ import org.springframework.data.annotation.Id import org.springframework.data.redis.core.RedisHash import org.springframework.data.redis.core.TimeToLive +/** + * Redis에 저장되는 상태 캐시 엔티티 클래스입니다. + * + * 자주 조회되는 원서 상태 정보를 Redis에 캐싱하여 조회 성능을 향상시킵니다. + * TTL(Time To Live) 설정을 통해 캐시 데이터의 자동 만료를 지원합니다. + * + * @property receiptCode 접수번호 (Primary Key) + * @property examCode 시험 코드 (nullable) + * @property applicationStatus 현재 원서의 전형 상태 + * @property isFirstRoundPass 1차 전형 합격 여부 + * @property isSecondRoundPass 2차 전형 합격 여부 + * @property ttl 캐시 만료 시간 (초 단위) + */ @RedisHash("status_cache") class StatusCacheRedisEntity( @Id @@ -15,4 +28,4 @@ class StatusCacheRedisEntity( val isSecondRoundPass: Boolean, @TimeToLive val ttl: Long -) \ No newline at end of file +) diff --git a/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/domain/status/domain/repository/StatusCacheRepository.kt b/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/domain/status/domain/repository/StatusCacheRepository.kt index 446301d8..c01ab691 100644 --- a/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/domain/status/domain/repository/StatusCacheRepository.kt +++ b/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/domain/status/domain/repository/StatusCacheRepository.kt @@ -3,5 +3,12 @@ package hs.kr.entrydsm.application.domain.status.domain.repository import hs.kr.entrydsm.application.domain.status.domain.entity.StatusCacheRedisEntity import org.springframework.data.repository.CrudRepository +/** + * 상태 캐시를 위한 Redis 저장소 인터페이스입니다. + * + * Spring Data Redis를 사용하여 StatusCacheRedisEntity에 대한 + * 기본적인 CRUD 연산을 제공합니다. + * 접수번호(Long)를 Primary Key로 사용합니다. + */ interface StatusCacheRepository : CrudRepository{ -} \ No newline at end of file +} diff --git a/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/domain/user/domain/UserPersistenceAdapter.kt b/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/domain/user/domain/UserPersistenceAdapter.kt index bc452caf..5204df77 100644 --- a/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/domain/user/domain/UserPersistenceAdapter.kt +++ b/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/domain/user/domain/UserPersistenceAdapter.kt @@ -7,11 +7,26 @@ import kotlinx.coroutines.runBlocking import org.springframework.stereotype.Component import java.util.UUID +/** + * 사용자 정보 조회를 위한 퍼시스턴스 어댑터입니다. + * + * gRPC를 통해 외부 User 서비스와 통신하여 사용자 정보를 조회하고, + * 도메인 모델로 변환하는 역할을 담당합니다. + */ @Component class UserPersistenceAdapter( private val userGrpcClient: UserGrpcClient ) : UserContract { + /** + * 사용자 ID로 사용자 정보를 조회합니다. + * + * gRPC 클라이언트를 통해 User 서비스에서 사용자 정보를 조회하고, + * 응답 데이터를 도메인 모델로 변환하여 반환합니다. + * + * @param userId 조회할 사용자의 고유 식별자 + * @return 조회된 사용자 정보가 담긴 User 도메인 모델 + */ override fun queryUserByUserId(userId: UUID): User = runBlocking { userGrpcClient.getUserInfoByUserId(userId).run { User( @@ -23,4 +38,4 @@ class UserPersistenceAdapter( } } -} \ No newline at end of file +} diff --git a/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/document/pdf/config/ConverterPropertiesCreator.kt b/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/document/pdf/config/ConverterPropertiesCreator.kt index 953d5cbf..718b9c15 100644 --- a/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/document/pdf/config/ConverterPropertiesCreator.kt +++ b/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/document/pdf/config/ConverterPropertiesCreator.kt @@ -9,8 +9,10 @@ import java.io.IOException /** * PDF 변환을 위한 변환 속성을 생성하는 클래스입니다. * - * iText PDF 라이브러리에서 HTML을 PDF로 변환할 때 필요한 설정을 관리합니다. + * iText PDF 라이브러리에서 HTML을 PDF로 변환할 때 필요한 설정을 관리하며, * 특히 한글 폰트 설정을 담당하여 PDF에서 한글이 정상적으로 표시되도록 합니다. + * + * @property fontPath 폰트 파일이 위치한 경로 */ @Component class ConverterPropertiesCreator { @@ -18,9 +20,11 @@ class ConverterPropertiesCreator { /** * PDF 변환을 위한 ConverterProperties를 생성합니다. - * 한글 폰트 설정을 포함하여 PDF 생성 시 필요한 모든 속성을 구성합니다. + * + * DefaultFontProvider를 설정하고 한글 폰트들을 등록하여 + * PDF 생성 시 한글 텍스트가 올바르게 렌더링되도록 합니다. * - * @return 설정된 ConverterProperties 객체 + * @return 폰트 설정이 완료된 ConverterProperties 객체 * @throws IllegalStateException 폰트 파일을 찾을 수 없는 경우 */ fun createConverterProperties(): ConverterProperties { diff --git a/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/document/pdf/config/Font.kt b/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/document/pdf/config/Font.kt index 3362a036..72a27474 100644 --- a/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/document/pdf/config/Font.kt +++ b/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/document/pdf/config/Font.kt @@ -1,6 +1,18 @@ package hs.kr.entrydsm.application.global.document.pdf.config +/** + * PDF 생성 시 사용할 폰트 설정을 관리하는 객체입니다. + * + * 한글과 영문을 모두 지원하는 폰트들의 목록을 정의하며, + * PDF 문서에서 텍스트가 올바르게 렌더링되도록 보장합니다. + */ object Font { + /** + * PDF에서 사용할 폰트 파일 목록입니다. + * + * 한글 지원을 위한 KoPubWorld Dotum 폰트 패밀리와 + * 영문 지원을 위한 DejaVuSans 폰트를 포함합니다. + */ val fonts = listOf( "KoPubWorld Dotum Light.ttf", diff --git a/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/document/pdf/config/PdfConfig.kt b/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/document/pdf/config/PdfConfig.kt index 0b64ea99..189d1724 100644 --- a/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/document/pdf/config/PdfConfig.kt +++ b/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/document/pdf/config/PdfConfig.kt @@ -6,8 +6,23 @@ import org.thymeleaf.TemplateEngine import org.thymeleaf.templatemode.TemplateMode import org.thymeleaf.templateresolver.ClassLoaderTemplateResolver +/** + * PDF 생성을 위한 Thymeleaf 템플릿 엔진 설정을 담당하는 Configuration 클래스입니다. + * + * HTML 템플릿을 사용하여 PDF 문서를 생성하기 위한 Thymeleaf 템플릿 엔진을 + * Bean으로 등록하고 설정합니다. + */ @Configuration class PdfConfig { + + /** + * PDF 생성용 Thymeleaf 템플릿 엔진을 생성합니다. + * + * classpath의 templates 디렉토리에서 HTML 템플릿을 로드하도록 설정하며, + * PDF로 변환될 HTML 문서 생성에 사용됩니다. + * + * @return 설정된 TemplateEngine 인스턴스 + */ @Bean fun pdfTemplateEngine(): TemplateEngine { val templateResolver = ClassLoaderTemplateResolver().apply { diff --git a/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/document/pdf/data/PdfData.kt b/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/document/pdf/data/PdfData.kt index baec24fb..af8e1dc9 100644 --- a/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/document/pdf/data/PdfData.kt +++ b/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/document/pdf/data/PdfData.kt @@ -11,10 +11,27 @@ package hs.kr.entrydsm.application.global.document.pdf.data data class PdfData( private val values: MutableMap, ) { + /** + * 데이터 맵을 반환합니다. + * + * @return 내부 데이터 맵 + */ fun toMap(): MutableMap = values + /** + * 지정된 키의 값을 조회합니다. + * + * @param key 조회할 키 + * @return 해당 키의 값, 존재하지 않으면 null + */ fun getValue(key: String): Any? = values[key] + /** + * 지정된 키에 값을 설정합니다. + * + * @param key 설정할 키 + * @param value 설정할 값 + */ fun setValue( key: String, value: Any, diff --git a/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/document/pdf/data/PdfDataConverter.kt b/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/document/pdf/data/PdfDataConverter.kt index b88bb78d..d5caa1c5 100644 --- a/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/document/pdf/data/PdfDataConverter.kt +++ b/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/document/pdf/data/PdfDataConverter.kt @@ -61,6 +61,12 @@ class PdfDataConverter( return PdfData(values) } + /** + * 지원서의 접수번호를 설정합니다. + * + * @param application 지원서 정보 + * @param values 템플릿 데이터 맵 + */ private fun setReceiptCode( application: Application, values: MutableMap, @@ -68,11 +74,22 @@ class PdfDataConverter( values["receiptCode"] = application.receiptCode.toString() } + /** + * 입학년도를 설정합니다. (현재 년도 + 1) + * + * @param values 템플릿 데이터 맵 + */ private fun setEntranceYear(values: MutableMap) { val entranceYear: Int = LocalDate.now().plusYears(1).year values["entranceYear"] = entranceYear.toString() } + /** + * 보훈번호 정보를 설정합니다. + * + * @param application 지원서 정보 + * @param values 템플릿 데이터 맵 + */ private fun setVeteransNumber( application: Application, values: MutableMap, @@ -80,6 +97,13 @@ class PdfDataConverter( values["veteransNumber"] = application.veteransNumber?.toString() ?: "" } + /** + * 지원자의 개인정보(이름, 성별, 주소, 생년월일 등)를 설정합니다. + * 일부 정보는 도메인에 없어서 더미값을 사용합니다. + * + * @param application 지원서 정보 + * @param values 템플릿 데이터 맵 + */ private fun setPersonalInfo( application: Application, values: MutableMap, @@ -98,6 +122,13 @@ class PdfDataConverter( values["applicationRemark"] = "해당없음" } + /** + * 출석 및 봉사활동 정보를 설정합니다. + * 현재 관련 도메인이 없어서 더미값을 사용합니다. + * + * @param application 지원서 정보 + * @param values 템플릿 데이터 맵 + */ private fun setAttendanceAndVolunteer( application: Application, values: MutableMap, @@ -329,6 +360,12 @@ class PdfDataConverter( values["schoolName"] = "더미중학교" } + /** + * 전화번호를 하이픈 포함 형태로 포맷팅합니다. + * + * @param phoneNumber 포맷팅할 전화번호 + * @return 하이픈으로 구분된 전화번호 문자열 + */ private fun toFormattedPhoneNumber(phoneNumber: String?): String { if (phoneNumber.isNullOrBlank()) { return "" @@ -339,14 +376,32 @@ class PdfDataConverter( return phoneNumber.replace("(\\d{2,3})(\\d{3,4})(\\d{4})".toRegex(), "$1-$2-$3") } + /** + * null 값을 빈 문자열로 변환합니다. + * + * @param input 변환할 문자열 + * @return 입력값이 null이면 빈 문자열, 그렇지 않으면 원래 값 + */ private fun setBlankIfNull(input: String?): String { return input ?: "" } + /** + * boolean 값을 체크박스 문자(☑/☐)로 변환합니다. + * + * @param isTrue 변환할 boolean 값 + * @return true이면 "☑", false이면 "☐" + */ private fun toBallotBox(isTrue: Boolean): String { return if (isTrue) "☑" else "☐" } + /** + * boolean 값을 O/X 문자로 변환합니다. + * + * @param isTrue 변환할 boolean 값 + * @return true이면 "O", false이면 "X" + */ private fun toCircleBallotbox(isTrue: Boolean): String { return if (isTrue) "O" else "X" } diff --git a/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/document/pdf/generator/PdfProcessor.kt b/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/document/pdf/generator/PdfProcessor.kt index a45aea38..d532d1fb 100644 --- a/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/document/pdf/generator/PdfProcessor.kt +++ b/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/document/pdf/generator/PdfProcessor.kt @@ -17,6 +17,9 @@ class PdfProcessor( ) { /** * HTML 문자열을 PDF로 변환합니다. + * + * iText HTML2PDF를 사용하여 HTML을 PDF로 변환하며, + * 한글 폰트 설정과 기타 변환 옵션이 적용됩니다. * * @param html 변환할 HTML 문자열 * @return PDF 데이터가 포함된 ByteArrayOutputStream diff --git a/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/excel/generator/PrintAdmissionTicketGenerator.kt b/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/excel/generator/PrintAdmissionTicketGenerator.kt index f4657ed1..85578f2c 100644 --- a/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/excel/generator/PrintAdmissionTicketGenerator.kt +++ b/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/excel/generator/PrintAdmissionTicketGenerator.kt @@ -65,6 +65,18 @@ class PrintAdmissionTicketGenerator { } } + /** + * 수험표 Excel 파일을 생성합니다. + * + * 템플릿 파일을 기반으로 여러 지원자의 수험표를 하나의 Excel 파일로 생성하며, + * 각 수험표는 20행씩 차지합니다. + * + * @param applications 지원서 목록 + * @param users 사용자 정보 목록 + * @param schools 학교 정보 목록 + * @param statuses 전형 상태 목록 + * @return 생성된 Excel 워크북 객체 + */ fun generate( applications: List, users: List, @@ -103,11 +115,29 @@ class PrintAdmissionTicketGenerator { return targetWorkbook } + /** + * 소스 워크북 템플릿 파일을 로드합니다. + * + * 클래스패스에서 Excel 템플릿 파일을 읽어와서 + * 수험표 생성의 기본 템플릿으로 사용합니다. + * + * @return 로드된 소스 워크북 + */ fun loadSourceWorkbook(): Workbook { val resource = ClassPathResource(EXCEL_PATH) return resource.inputStream.use { XSSFWorkbook(it) } } + /** + * 소스 워크북의 스타일을 타겟 워크북으로 복사하기 위한 스타일 매핑을 생성합니다. + * + * 템플릿의 모든 셀 스타일을 새로운 워크북으로 복제하여 + * 원본과 동일한 서식을 유지할 수 있도록 합니다. + * + * @param sourceWorkbook 소스 워크북 (템플릿) + * @param targetWorkbook 타겟 워크북 (생성될 파일) + * @return 소스 스타일과 타겟 스타일의 매핑 맵 + */ fun createStyleMap( sourceWorkbook: Workbook, targetWorkbook: Workbook, @@ -125,6 +155,19 @@ class PrintAdmissionTicketGenerator { } } + /** + * 소스 시트의 특정 행 범위를 타겟 시트로 복사합니다. + * + * 지정된 행 범위의 모든 셀과 서식을 새로운 위치로 복사하며, + * 병합된 셀 영역도 함께 복사합니다. + * + * @param sourceSheet 소스 시트 + * @param targetSheet 타겟 시트 + * @param sourceStartRow 복사할 시작 행 번호 + * @param sourceEndRow 복사할 끝 행 번호 + * @param targetStartRow 복사될 위치의 시작 행 번호 + * @param styleMap 스타일 매핑 맵 + */ fun copyRows( sourceSheet: Sheet, targetSheet: Sheet, @@ -142,6 +185,18 @@ class PrintAdmissionTicketGenerator { } } + /** + * 단일 행을 복사합니다. + * + * 행의 높이, 모든 셀 데이터와 서식을 복사하며, + * 병합된 셀 영역도 함께 복사합니다. + * + * @param sourceSheet 소스 시트 + * @param targetSheet 타겟 시트 + * @param sourceRow 복사할 소스 행 + * @param targetRow 복사될 타겟 행 + * @param styleMap 스타일 매핑 맵 + */ fun copyRow( sourceSheet: Sheet, targetSheet: Sheet, @@ -177,6 +232,16 @@ class PrintAdmissionTicketGenerator { } } + /** + * 단일 셀을 복사합니다. + * + * 셀의 값, 데이터 타입, 스타일을 모두 복사하여 + * 원본과 동일한 셀을 생성합니다. + * + * @param oldCell 복사할 소스 셀 + * @param newCell 복사될 타겟 셀 + * @param styleMap 스타일 매핑 맵 + */ fun copyCell( oldCell: Cell, newCell: Cell, @@ -198,6 +263,20 @@ class PrintAdmissionTicketGenerator { } } + /** + * 지원서 데이터를 템플릿의 특정 위치에 채웁니다. + * + * 수험번호, 이름, 학교명, 지역, 전형유형, 접수번호 등의 + * 지원자 정보를 수험표의 지정된 셀에 입력합니다. + * + * @param sheet 데이터를 입력할 시트 + * @param startRowIndex 시작 행 인덱스 + * @param application 지원서 정보 + * @param user 사용자 정보 (nullable) + * @param school 학교 정보 (nullable) + * @param status 전형 상태 정보 (nullable) + * @param workbook 워크북 객체 + */ fun fillApplicationData( sheet: Sheet, startRowIndex: Int, @@ -215,6 +294,17 @@ class PrintAdmissionTicketGenerator { setValue(sheet, "E14", application.receiptCode.toString()) } + /** + * 지원자의 사진을 수험표에 복사합니다. + * + * 지원서에 등록된 사진 경로에서 이미지를 로드하여 + * 수험표의 지정된 위치에 삽입합니다. + * 사진이 없는 경우 더미 이미지를 사용합니다. + * + * @param application 지원서 정보 + * @param targetSheet 타겟 시트 + * @param targetRowIndex 타겟 행 인덱스 + */ fun copyApplicationImage( application: Application, targetSheet: Sheet, @@ -229,6 +319,14 @@ class PrintAdmissionTicketGenerator { } } + /** + * 전형 타입명을 한글로 변환합니다. + * + * 영문 전형 타입 코드를 사용자에게 친숙한 한글명으로 변환합니다. + * + * @param applicationType 영문 전형 타입 코드 + * @return 한글 전형명 + */ private fun translateApplicationType(applicationType: String?): String { return when (applicationType) { "COMMON" -> "일반전형" @@ -238,6 +336,15 @@ class PrintAdmissionTicketGenerator { } } + /** + * 더미 이미지를 수험표에 삽입합니다. + * + * 실제 지원자 사진이 없는 경우 사용되며, + * 빈 바이트 배열로 구성된 더미 이미지를 생성하여 삽입합니다. + * + * @param targetSheet 타겟 시트 + * @param targetRowIndex 이미지가 삽입될 행 인덱스 + */ fun copyDummyImage( targetSheet: Sheet, targetRowIndex: Int, @@ -261,6 +368,16 @@ class PrintAdmissionTicketGenerator { } } + /** + * 지정된 셀 위치에 값을 설정합니다. + * + * Excel 셀 참조 형식(예: "A1", "B2")을 사용하여 + * 해당 위치의 셀에 문자열 값을 설정합니다. + * + * @param sheet 대상 시트 + * @param position Excel 셀 참조 형식의 위치 (예: "A1") + * @param value 설정할 값 + */ fun setValue( sheet: Sheet, position: String, @@ -274,6 +391,14 @@ class PrintAdmissionTicketGenerator { } } + /** + * HTTP 응답 헤더를 설정합니다. + * + * Excel 파일 다운로드를 위한 Content-Type과 파일명을 설정하며, + * 파일명에는 현재 시간이 포함됩니다. + * + * @param response HTTP 응답 객체 + */ fun setResponseHeaders(response: HttpServletResponse) { response.contentType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" val formatFilename = "attachment;filename=\"수험표" diff --git a/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/excel/generator/PrintApplicantCodesGenerator.kt b/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/excel/generator/PrintApplicantCodesGenerator.kt index c13e4989..2a1e78c5 100644 --- a/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/excel/generator/PrintApplicantCodesGenerator.kt +++ b/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/excel/generator/PrintApplicantCodesGenerator.kt @@ -55,6 +55,9 @@ class PrintApplicantCodesGenerator { /** * Excel Row에 지원자 정보를 삽입합니다. + * + * 수험번호, 접수번호, 성명을 각각 첫 번째, 두 번째, 세 번째 셀에 입력합니다. + * 수험번호가 없는 경우 "미발급"으로 표시됩니다. * * @param row Excel의 Row 객체 * @param application 지원서 정보 diff --git a/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/excel/generator/PrintApplicationCheckListGenerator.kt b/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/excel/generator/PrintApplicationCheckListGenerator.kt index 87ac6163..7fc06c6e 100644 --- a/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/excel/generator/PrintApplicationCheckListGenerator.kt +++ b/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/excel/generator/PrintApplicationCheckListGenerator.kt @@ -93,6 +93,17 @@ class PrintApplicationCheckListGenerator { } } + /** + * 더미 지원서 데이터를 생성합니다. + * + * 테스트나 샘플 데이터 생성을 위한 임시 메서드로, + * 실제 지원서 정보가 없을 때 사용됩니다. + * + * @param receiptCode 접수번호 + * @param name 지원자 이름 + * @param schoolName 학교명 + * @return 더미 지원서 데이터 맵 + */ private fun createDummyApplication( receiptCode: Long, name: String, @@ -117,6 +128,14 @@ class PrintApplicationCheckListGenerator { ) } + /** + * 지정된 행 오프셋에 대해 시트 포맷을 설정합니다. + * + * 셀 병합, 테두리 스타일, 셀 값을 설정하여 + * 점검표의 기본 레이아웃을 생성합니다. + * + * @param dh 행 오프셋 (각 지원자마다 20행씩 차지) + */ private fun formatSheet(dh: Int) { sheet.apply { mergeRegions(dh) @@ -125,6 +144,14 @@ class PrintApplicationCheckListGenerator { } } + /** + * 지정된 행 오프셋에 대해 셀 병합을 수행합니다. + * + * 점검표의 각 섹션에서 필요한 셀들을 병합하여 + * 시각적으로 구조화된 레이아웃을 만듭니다. + * + * @param rowOffset 행 오프셋 + */ private fun Sheet.mergeRegions(rowOffset: Int) { val mergedRegions = arrayOf( @@ -143,6 +170,12 @@ class PrintApplicationCheckListGenerator { } } + /** + * 지정된 영역이 이미 병합되어 있는지 확인합니다. + * + * @param region 확인할 셀 영역 + * @return 이미 병합되어 있으면 true, 그렇지 않으면 false + */ private fun Sheet.isRegionMerged(region: CellRangeAddress): Boolean { return mergedRegions.any { it.firstRow == region.firstRow && @@ -152,6 +185,14 @@ class PrintApplicationCheckListGenerator { } } + /** + * 지정된 행 오프셋에 대해 테두리 스타일을 적용합니다. + * + * 점선, 실선, 굵은 선 등 다양한 테두리 스타일을 + * 각 영역에 적절히 적용하여 시각적 구분을 제공합니다. + * + * @param dh 행 오프셋 + */ private fun Sheet.applyBorderStyles(dh: Int) { val borderRegionsDashedBottom = arrayOf( @@ -225,6 +266,14 @@ class PrintApplicationCheckListGenerator { setBorderStyle(borderRegionsThinRight, BorderStyle.THIN, Direction.RIGHT) } + /** + * 지정된 행 오프셋에 대해 고정 텍스트 값들을 설정합니다. + * + * 헤더와 라벨 등 변하지 않는 텍스트들을 + * 점검표의 지정된 위치에 설정합니다. + * + * @param dh 행 오프셋 + */ private fun Sheet.setCellValues(dh: Int) { val cellValues = mapOf( @@ -264,6 +313,13 @@ class PrintApplicationCheckListGenerator { } } + /** + * 지정된 영역에 테두리 스타일을 설정합니다. + * + * @param regions 테두리를 설정할 영역들의 배열 + * @param borderStyle 적용할 테두리 스타일 + * @param direction 테두리를 적용할 방향 + */ private fun setBorderStyle( regions: Array, borderStyle: BorderStyle, @@ -286,6 +342,13 @@ class PrintApplicationCheckListGenerator { } } + /** + * 지정된 행과 열 위치의 셀을 가져오거나 생성합니다. + * + * @param rowNum 행 번호 + * @param cellNum 열 번호 + * @return 해당 위치의 셀 객체 + */ private fun getCell( rowNum: Int, cellNum: Int, @@ -294,6 +357,12 @@ class PrintApplicationCheckListGenerator { return row.getCell(cellNum) ?: row.createCell(cellNum) } + /** + * 지정된 행의 높이를 설정합니다. + * + * @param rowIndex 행 인덱스 + * @param height 설정할 높이 (포인트 단위) + */ private fun setRowHeight( rowIndex: Int, height: Int, @@ -302,6 +371,18 @@ class PrintApplicationCheckListGenerator { row.heightInPoints = height.toFloat() } + /** + * 지원서 데이터를 시트의 해당 위치에 삽입합니다. + * + * 개인정보, 성적, 출석 정보 등을 점검표의 지정된 셀에 입력하며, + * 도메인에 없는 데이터는 더미값으로 대체합니다. + * + * @param application 지원서 정보 + * @param user 사용자 정보 (nullable) + * @param school 학교 정보 (nullable) + * @param status 전형 상태 정보 (nullable) + * @param dh 행 오프셋 + */ private fun insertDataIntoSheet( application: Application, user: User?, @@ -364,6 +445,12 @@ class PrintApplicationCheckListGenerator { setRowHeight(dh + 0, 71) } + /** + * 전형 타입명을 한글로 변환합니다. + * + * @param applicationType 영문 전형 타입 코드 + * @return 한글 전형명 + */ private fun translateApplicationType(applicationType: String?): String { return when (applicationType) { "COMMON" -> "일반전형" @@ -373,6 +460,12 @@ class PrintApplicationCheckListGenerator { } } + /** + * 전화번호를 하이픈이 포함된 형태로 포맷팅합니다. + * + * @param phoneNumber 포맷팅할 전화번호 + * @return 하이픈으로 구분된 전화번호 문자열 + */ private fun formatPhoneNumber(phoneNumber: String?): String { if (phoneNumber.isNullOrBlank()) return "" if (phoneNumber.length == 8) { @@ -381,6 +474,9 @@ class PrintApplicationCheckListGenerator { return phoneNumber.replace("(\\d{2,3})(\\d{3,4})(\\d{4})".toRegex(), "$1-$2-$3") } + /** + * 테두리 적용 방향을 정의하는 열거형입니다. + */ enum class Direction { TOP, BOTTOM, diff --git a/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/excel/generator/PrintApplicationInfoGenerator.kt b/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/excel/generator/PrintApplicationInfoGenerator.kt index 660d0f11..327a6eac 100644 --- a/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/excel/generator/PrintApplicationInfoGenerator.kt +++ b/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/excel/generator/PrintApplicationInfoGenerator.kt @@ -73,13 +73,16 @@ class PrintApplicationInfoGenerator { /** * Excel Row에 지원자의 상세 전형 정보를 삽입합니다. - * 60개 컬럼에 개인정보부터 성적, 점수까지 모든 정보를 기록합니다. + * + * 60개 컬럼에 개인정보부터 학기별 성적, 출석 정보, 봉사활동, + * 가산점, 종합 점수까지 모든 정보를 기록합니다. + * 도메인에 없는 데이터는 더미값으로 대체됩니다. * * @param row Excel의 Row 객체 * @param application 지원서 정보 - * @param user 사용자 정보 - * @param school 학교 정보 - * @param status 전형 상태 정보 + * @param user 사용자 정보 (nullable) + * @param school 학교 정보 (nullable) + * @param status 전형 상태 정보 (nullable) */ private fun insertCode( row: Row, @@ -121,10 +124,13 @@ class PrintApplicationInfoGenerator { } /** - * 지원유형을 한국어로 변환합니다. + * 전형 타입 코드를 한글명으로 변환합니다. + * + * 영문 코드를 사용자에게 친숙한 한글 전형명으로 변환하며, + * 알 수 없는 타입인 경우 기본값으로 "일반전형"을 반환합니다. * - * @param applicationType 지원유형 코드 - * @return 변환된 한국어 지원유형명 + * @param applicationType 영문 전형 타입 코드 + * @return 한글 전형명 */ private fun translateApplicationType(applicationType: String?): String { return when (applicationType) { diff --git a/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/excel/model/ApplicantCode.kt b/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/excel/model/ApplicantCode.kt index d9bbcd19..2630000a 100644 --- a/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/excel/model/ApplicantCode.kt +++ b/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/excel/model/ApplicantCode.kt @@ -15,14 +15,29 @@ class ApplicantCode { private val workbook: Workbook = XSSFWorkbook() private val sheet: Sheet = workbook.createSheet("지원자 목록") + /** + * 설정된 Workbook 객체를 반환합니다. + * + * @return Excel 워크북 객체 + */ fun getWorkbook(): Workbook { return workbook } + /** + * 지원자 목록 시트 객체를 반환합니다. + * + * @return Excel 시트 객체 + */ fun getSheet(): Sheet { return sheet } + /** + * 지원자 목록 Excel 시트의 헤더를 설정합니다. + * + * 수험번호, 접수번호, 성명 3개 컬럼의 헤더를 첫 번째 행에 생성합니다. + */ fun format() { val row: Row = sheet.createRow(0) row.createCell(0).setCellValue("수험번호") diff --git a/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/excel/model/ApplicationInfo.kt b/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/excel/model/ApplicationInfo.kt index ca56a3f3..992e94f7 100644 --- a/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/excel/model/ApplicationInfo.kt +++ b/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/excel/model/ApplicationInfo.kt @@ -16,14 +16,30 @@ class ApplicationInfo { private val workbook: Workbook = XSSFWorkbook() private val sheet: Sheet = workbook.createSheet("전형자료") + /** + * 설정된 Workbook 객체를 반환합니다. + * + * @return Excel 워크북 객체 + */ fun getWorkbook(): Workbook { return workbook } + /** + * 전형자료 시트 객체를 반환합니다. + * + * @return Excel 시트 객체 + */ fun getSheet(): Sheet { return sheet } + /** + * 전형자료 Excel 시트의 헤더를 설정합니다. + * + * 60개 컬럼에 해당하는 모든 헤더를 순차적으로 생성하며, + * 개인정보, 학기별 성적, 출석 정보, 가산점 등을 포함합니다. + */ fun format() { val row: Row = sheet.createRow(0) row.createCell(0).setCellValue("접수번호") diff --git a/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/excel/presentation/ExcelTestController.kt b/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/excel/presentation/ExcelTestController.kt index 03dfa7dd..20916dac 100644 --- a/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/excel/presentation/ExcelTestController.kt +++ b/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/excel/presentation/ExcelTestController.kt @@ -31,6 +31,14 @@ class ExcelTestController( private val printAdmissionTicketGenerator: PrintAdmissionTicketGenerator, private val printApplicationCheckListGenerator: PrintApplicationCheckListGenerator, ) { + /** + * 지원자번호목록 Excel 파일을 다운로드합니다. + * + * 테스트용 더미 데이터를 사용하여 지원자번호목록을 생성하고 + * HTTP 응답으로 Excel 파일을 전송합니다. + * + * @param response HTTP 응답 객체 + */ @GetMapping("/applicant-codes") fun downloadApplicantCodes(response: HttpServletResponse) { // TODO: 실제 Application, Status 조회 로직 필요 @@ -40,6 +48,14 @@ class ExcelTestController( printApplicantCodesGenerator.execute(response, dummyApplications, dummyStatuses) } + /** + * 전형자료 Excel 파일을 다운로드합니다. + * + * 테스트용 더미 데이터를 사용하여 상세한 전형자료를 생성하고 + * HTTP 응답으로 Excel 파일을 전송합니다. + * + * @param response HTTP 응답 객체 + */ @GetMapping("/application-info") fun downloadApplicationInfo(response: HttpServletResponse) { // TODO: 실제 Application, User, School, Status 조회 로직 필요 @@ -51,6 +67,14 @@ class ExcelTestController( printApplicationInfoGenerator.execute(response, dummyApplications, dummyUsers, dummySchools, dummyStatuses) } + /** + * 수험표 Excel 파일을 다운로드합니다. + * + * 테스트용 더미 데이터를 사용하여 수험표를 생성하고 + * HTTP 응답으로 Excel 파일을 전송합니다. + * + * @param response HTTP 응답 객체 + */ @GetMapping("/admission-ticket") fun downloadAdmissionTicket(response: HttpServletResponse) { // TODO: 실제 Application, User, School, Status 조회 로직 필요 @@ -62,6 +86,14 @@ class ExcelTestController( printAdmissionTicketGenerator.execute(response, dummyApplications, dummyUsers, dummySchools, dummyStatuses) } + /** + * 점검표 Excel 파일을 다운로드합니다. + * + * 테스트용 더미 데이터를 사용하여 지원서 점검표를 생성하고 + * HTTP 응답으로 Excel 파일을 전송합니다. + * + * @param response HTTP 응답 객체 + */ @GetMapping("/check-list") fun downloadCheckList(response: HttpServletResponse) { // TODO: 실제 Application, User, School, Status 조회 로직 필요 @@ -75,6 +107,14 @@ class ExcelTestController( ) } + /** + * 테스트용 더미 지원서 데이터를 생성합니다. + * + * 일반전형과 마이스터전형 각각 하나씩의 샘플 지원서를 생성하여 + * Excel 생성기 테스트에 사용합니다. + * + * @return 더미 지원서 목록 + */ private fun createDummyApplications(): List { return listOf( Application( @@ -118,6 +158,14 @@ class ExcelTestController( ) } + /** + * 테스트용 더미 사용자 데이터를 생성합니다. + * + * 지원서와 연결될 사용자 정보를 생성하여 + * Excel에서 사용자 관련 정보를 표시할 수 있도록 합니다. + * + * @return 더미 사용자 목록 + */ private fun createDummyUsers(): List { return listOf( User( @@ -135,6 +183,14 @@ class ExcelTestController( ) } + /** + * 테스트용 더미 학교 데이터를 생성합니다. + * + * 지역별(대전, 서울) 학교 정보를 생성하여 + * Excel에서 출신학교 정보를 표시할 수 있도록 합니다. + * + * @return 더미 학교 목록 + */ private fun createDummySchools(): List { return listOf( School( @@ -156,6 +212,14 @@ class ExcelTestController( ) } + /** + * 테스트용 더미 상태 데이터를 생성합니다. + * + * 각 지원서와 연결될 전형 상태 정보를 생성하여 + * Excel에서 수험번호와 전형 상태를 표시할 수 있도록 합니다. + * + * @return 더미 상태 목록 + */ private fun createDummyStatuses(): List { return listOf( Status( 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 7bd4eeb9..446f2d16 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 @@ -20,22 +20,33 @@ import kotlin.coroutines.resumeWithException /** * 상태 서비스와의 gRPC 통신을 담당하는 클라이언트 클래스입니다. - * - * @property channel gRPC 통신을 위한 채널 (status-service로 자동 주입됨) + * + * Resilience4j의 Circuit Breaker와 Retry 패턴을 적용하여 + * 장애 상황에서도 안정적인 서비스 통신을 보장합니다. + * + * @property retry gRPC 호출 실패 시 재시도를 위한 Retry 인스턴스 + * @property circuitBreaker gRPC 호출 실패율이 높을 때 회로 차단을 위한 Circuit Breaker */ @Component class StatusGrpcClient( @Qualifier("statusGrpcRetry") private val retry: Retry, @Qualifier("statusGrpcCircuitBreaker") private val circuitBreaker: CircuitBreaker ) { + /** + * gRPC 통신을 위한 채널 + * status-service로 자동 주입됨 + */ @GrpcClient("status-service") lateinit var channel: Channel /** * 모든 상태 리스트를 비동기적으로 조회합니다. - * gRPC 비동기 스트리밍을 사용하여 상태 서비스로부터 전체 상태 정보를 가져옵니다. + * + * gRPC 비동기 스트리밍을 사용하여 상태 서비스로부터 전체 상태 정보를 가져오며, + * Circuit Breaker와 Retry 패턴을 통해 장애 상황에 대비합니다. + * 장애 발생 시 빈 리스트를 fallback으로 반환합니다. * - * @return 조회된 상태 정보 리스트를 담은 [InternalStatusListResponse] 객체 + * @return 조회된 상태 정보 리스트를 담은 InternalStatusListResponse 객체 * @throws io.grpc.StatusRuntimeException gRPC 서버에서 오류가 발생한 경우 * @throws java.util.concurrent.CancellationException 코루틴이 취소된 경우 */ @@ -89,14 +100,17 @@ class StatusGrpcClient( /** * 접수번호로 특정 상태를 비동기적으로 조회합니다. - * gRPC 비동기 스트리밍을 사용하여 상태 서비스로부터 해당 접수번호의 상태 정보를 가져옵니다. + * + * gRPC 비동기 스트리밍을 사용하여 상태 서비스로부터 해당 접수번호의 상태 정보를 가져오며, + * Circuit Breaker와 Retry 패턴을 통해 장애 상황에 대비합니다. + * 장애 발생 시 기본 상태를 fallback으로 반환합니다. * * @param receiptCode 조회할 접수번호 - * @return 조회된 상태 정보를 담은 [InternalStatusResponse] 객체 + * @return 조회된 상태 정보를 담은 InternalStatusResponse 객체, 존재하지 않는 경우 기본 상태 * @throws io.grpc.StatusRuntimeException gRPC 서버에서 오류가 발생한 경우 * @throws java.util.concurrent.CancellationException 코루틴이 취소된 경우 */ - suspend fun getStatusByReceiptCode(receiptCode: Long): InternalStatusResponse { + suspend fun getStatusByReceiptCode(receiptCode: Long): InternalStatusResponse? { return executeGrpcCallWithResilience( retry = retry, circuitBreaker = circuitBreaker, @@ -146,11 +160,14 @@ class StatusGrpcClient( } /** - * 수험번호를 비동기적으로 업데이트합니다. - * gRPC 비동기 스트리밍을 사용하여 상태 서비스에 수험번호 업데이트를 요청합니다. + * 시험 코드를 비동기적으로 업데이트합니다. + * + * gRPC 비동기 스트리밍을 사용하여 상태 서비스에 시험 코드 업데이트를 요청하며, + * Circuit Breaker와 Retry 패턴을 통해 장애 상황에 대비합니다. + * 장애 발생 시 조용히 실패하고 로그를 남깁니다. * - * @param receiptCode 접수번호 - * @param examCode 새로운 수험번호 + * @param receiptCode 시험 코드를 업데이트할 접수번호 + * @param examCode 새로 배정된 시험 코드 * @throws io.grpc.StatusRuntimeException gRPC 서버에서 오류가 발생한 경우 * @throws java.util.concurrent.CancellationException 코루틴이 취소된 경우 */ @@ -190,6 +207,9 @@ class StatusGrpcClient( /** * gRPC 프로토콜 지원 상태를 도메인 지원 상태로 변환합니다. + * + * Protocol Buffer의 ApplicationStatus enum을 도메인 계층의 ApplicationStatus enum으로 매핑하며, + * 예상치 못한 값이 들어올 경우 기본값으로 NOT_APPLIED를 반환합니다. * * @param protoApplicationStatus 변환할 gRPC 프로토콜 지원 상태 * @return 도메인 지원 상태 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 7d95a7c5..0a1cebb7 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 @@ -19,23 +19,34 @@ import kotlin.coroutines.resumeWithException /** * 사용자 서비스와의 gRPC 통신을 담당하는 클라이언트 클래스입니다. - * - * @property channel gRPC 통신을 위한 채널 (user-service로 자동 주입됨) + * + * Resilience4j의 Circuit Breaker와 Retry 패턴을 적용하여 + * 장애 상황에서도 안정적인 서비스 통신을 보장합니다. + * + * @property retry gRPC 호출 실패 시 재시도를 위한 Retry 인스턴스 + * @property circuitBreaker gRPC 호출 실패율이 높을 때 회로 차단을 위한 Circuit Breaker */ @Component class UserGrpcClient( @Qualifier("userGrpcRetry") private val retry: Retry, - @Qualifier("userGrpcCircuitBreaker") private val circuitBreaker: CircuitBreaker + @Qualifier("userGrpcCircuitBreaker") private val circuitBreaker ) { + /** + * gRPC 통신을 위한 채널 + * user-service로 자동 주입됨 + */ @GrpcClient("user-service") lateinit var channel: Channel /** * 사용자 ID를 기반으로 사용자 정보를 비동기적으로 조회합니다. - * gRPC 비동기 스트리밍을 사용하여 사용자 서비스로부터 정보를 가져옵니다. + * + * gRPC 비동기 스트리밍을 사용하여 사용자 서비스로부터 정보를 가져오며, + * Circuit Breaker와 Retry 패턴을 통해 장애 상황에 대비합니다. + * 장애 발생 시 기본 사용자 정보를 fallback으로 반환합니다. * - * @param userId 조회할 사용자의 고유 식별자(UUID) - * @return 조회된 사용자 정보를 담은 [InternalUserResponse] 객체 + * @param userId 조회할 사용자의 고유 식별자 + * @return 조회된 사용자 정보를 담은 InternalUserResponse 객체 * @throws io.grpc.StatusRuntimeException gRPC 서버에서 오류가 발생한 경우 * @throws java.util.concurrent.CancellationException 코루틴이 취소된 경우 */ @@ -90,6 +101,9 @@ class UserGrpcClient( /** * gRPC 프로토콜 사용자 역할을 도메인 사용자 역할로 변환합니다. + * + * Protocol Buffer의 UserRole enum을 도메인 계층의 UserRole enum으로 매핑하며, + * 예상치 못한 값이 들어올 경우 기본값으로 USER를 반환합니다. * * @param protoUserRole 변환할 gRPC 프로토콜 사용자 역할 * @return 도메인 사용자 역할 diff --git a/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/kafka/configuration/KafkaConsumerConfig.kt b/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/kafka/configuration/KafkaConsumerConfig.kt new file mode 100644 index 00000000..53fb0ef7 --- /dev/null +++ b/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/kafka/configuration/KafkaConsumerConfig.kt @@ -0,0 +1,68 @@ +package hs.kr.entrydsm.application.global.kafka.configuration + +import org.apache.kafka.clients.consumer.ConsumerConfig +import org.apache.kafka.common.serialization.StringDeserializer +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.kafka.annotation.EnableKafka +import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory +import org.springframework.kafka.core.DefaultKafkaConsumerFactory +import org.springframework.kafka.support.serializer.JsonDeserializer + +/** + * Kafka Consumer 설정을 담당하는 Configuration 클래스입니다. + * + * 사용자 삭제 이벤트 및 접수번호 업데이트 결과 이벤트 수신을 위한 + * Consumer 설정을 구성합니다. + * + * @property kafkaProperty Kafka 연결 정보를 담은 프로퍼티 + */ +@EnableKafka +@Configuration +class KafkaConsumerConfig( + private val kafkaProperty: KafkaProperty +) { + /** + * Kafka 리스너 컨테이너 팩토리를 생성합니다. + * + * 동시성 레벨을 2로 설정하여 병렬 메시지 처리를 지원하며, + * 폴링 타임아웃을 500ms로 설정하여 적절한 응답성을 보장합니다. + * + * @return 설정된 ConcurrentKafkaListenerContainerFactory 인스턴스 + */ + @Bean + fun kafkaListenerContainerFactory(): ConcurrentKafkaListenerContainerFactory { + return ConcurrentKafkaListenerContainerFactory().apply { + setConcurrency(2) + consumerFactory = DefaultKafkaConsumerFactory(consumerFactoryConfig()) + containerProperties.pollTimeout = 500 + } + } + + /** + * Kafka Consumer의 기본 설정을 구성합니다. + * + * Confluent Cloud 연결을 위한 SASL 보안 설정과 역직렬화 설정을 포함하며, + * read_committed 격리 레벨로 트랜잭션 안정성을 보장합니다. + * + * @return Consumer 설정 맵 + */ + private fun consumerFactoryConfig(): Map { + return mapOf( + ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG to kafkaProperty.serverAddress, + ConsumerConfig.ISOLATION_LEVEL_CONFIG to "read_committed", + ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG to StringDeserializer::class.java, + ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG to "false", + ConsumerConfig.AUTO_OFFSET_RESET_CONFIG to "latest", + ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG to JsonDeserializer::class.java, + ConsumerConfig.MAX_POLL_INTERVAL_MS_CONFIG to 5000, + JsonDeserializer.TRUSTED_PACKAGES to "*", + "security.protocol" to "SASL_PLAINTEXT", + "sasl.mechanism" to "SCRAM-SHA-512", + "sasl.jaas.config" to + "org.apache.kafka.common.security.scram.ScramLoginModule required " + + "username=\"${kafkaProperty.confluentApiKey}\" " + + "password=\"${kafkaProperty.confluentApiSecret}\";" + ) + } +} diff --git a/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/kafka/configuration/KafkaProducerConfig.kt b/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/kafka/configuration/KafkaProducerConfig.kt new file mode 100644 index 00000000..f365723c --- /dev/null +++ b/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/kafka/configuration/KafkaProducerConfig.kt @@ -0,0 +1,64 @@ +package hs.kr.entrydsm.application.global.kafka.configuration + +import org.apache.kafka.clients.producer.ProducerConfig +import org.apache.kafka.common.serialization.StringSerializer +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.kafka.core.DefaultKafkaProducerFactory +import org.springframework.kafka.core.KafkaTemplate +import org.springframework.kafka.support.serializer.JsonSerializer + +/** + * Kafka Producer 설정을 담당하는 Configuration 클래스입니다. + * + * 원서 생성 이벤트 발행을 위한 KafkaTemplate과 ProducerFactory를 구성하며, + * Confluent Cloud 연결을 위한 보안 설정을 포함합니다. + * + * @property kafkaProperty Kafka 연결 정보를 담은 프로퍼티 + */ +@Configuration +class KafkaProducerConfig( + private val kafkaProperty: KafkaProperty +) { + + /** + * 원서 생성 이벤트 발행을 위한 KafkaTemplate을 생성합니다. + * + * @return 설정된 KafkaTemplate 인스턴스 + */ + @Bean + fun createApplicationTemplate(): KafkaTemplate { + return KafkaTemplate(createApplicationProducerFactory()) + } + + /** + * 원서 생성 이벤트용 Producer Factory를 생성합니다. + * + * @return 설정된 DefaultKafkaProducerFactory 인스턴스 + */ + @Bean + fun createApplicationProducerFactory(): DefaultKafkaProducerFactory { + return DefaultKafkaProducerFactory(producerConfig()) + } + + /** + * Kafka Producer의 기본 설정을 구성합니다. + * + * Confluent Cloud 연결을 위한 SASL 보안 설정과 직렬화 설정을 포함합니다. + * + * @return Producer 설정 맵 + */ + private fun producerConfig(): Map { + return mapOf( + ProducerConfig.BOOTSTRAP_SERVERS_CONFIG to kafkaProperty.serverAddress, + ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG to StringSerializer::class.java, + ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG to JsonSerializer::class.java, + "security.protocol" to "SASL_PLAINTEXT", + "sasl.mechanism" to "SCRAM-SHA-512", + "sasl.jaas.config" to + "org.apache.kafka.common.security.scram.ScramLoginModule required " + + "username=\"${kafkaProperty.confluentApiKey}\" " + + "password=\"${kafkaProperty.confluentApiSecret}\";" + ) + } +} diff --git a/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/kafka/configuration/KafkaProperty.kt b/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/kafka/configuration/KafkaProperty.kt new file mode 100644 index 00000000..96175964 --- /dev/null +++ b/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/kafka/configuration/KafkaProperty.kt @@ -0,0 +1,20 @@ +package hs.kr.entrydsm.application.global.kafka.configuration + +import org.springframework.boot.context.properties.ConfigurationProperties + +/** + * Kafka 연결을 위한 설정 프로퍼티 클래스입니다. + * + * application.yml의 kafka 섹션에서 설정값을 바인딩하여 + * Confluent Cloud Kafka 클러스터 연결에 필요한 정보를 관리합니다. + * + * @property serverAddress Kafka 브로커 서버 주소 + * @property confluentApiKey Confluent Cloud 접근을 위한 API 키 + * @property confluentApiSecret Confluent Cloud 접근을 위한 API 시크릿 + */ +@ConfigurationProperties("kafka") +class KafkaProperty( + val serverAddress: String, + val confluentApiKey: String, + val confluentApiSecret: String +) diff --git a/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/kafka/configuration/KafkaTopics.kt b/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/kafka/configuration/KafkaTopics.kt new file mode 100644 index 00000000..44fc1a2b --- /dev/null +++ b/casper-application-infrastructure/src/main/kotlin/hs/kr/entrydsm/application/global/kafka/configuration/KafkaTopics.kt @@ -0,0 +1,34 @@ +package hs.kr.entrydsm.application.global.kafka.configuration + +/** + * Kafka 토픽명을 관리하는 상수 객체입니다. + * + * 마이크로서비스 간 이벤트 통신에 사용되는 토픽명들을 중앙에서 관리하여 + * 토픽명 변경 시 일관성을 보장합니다. + */ +object KafkaTopics { + /** + * 원서 생성 이벤트 토픽 + * 원서가 생성되었을 때 사용자 서비스에 접수번호 업데이트를 요청하기 위해 사용 + */ + const val CREATE_APPLICATION = "create-application" + + /** + * 사용자 삭제 이벤트 토픽 + * 사용자가 삭제되었을 때 연관된 원서도 함께 삭제하기 위해 사용 + */ + const val DELETE_USER = "delete-user" + + /** + * 사용자 접수번호 업데이트 완료 이벤트 토픽 + * 사용자 서비스에서 접수번호 업데이트가 성공적으로 완료되었음을 알리는 이벤트 + */ + const val USER_RECEIPT_CODE_UPDATE_COMPLETED = "user-receipt-code-update-completed" + + /** + * 사용자 접수번호 업데이트 실패 이벤트 토픽 + * 사용자 서비스에서 접수번호 업데이트가 실패했음을 알리는 이벤트 + * 이 이벤트 수신 시 보상 트랜잭션으로 원서를 삭제함 + */ + const val USER_RECEIPT_CODE_UPDATE_FAILED = "user-receipt-code-update-failed" +}