Skip to content
Merged
3 changes: 3 additions & 0 deletions buildSrc/src/main/kotlin/Dependencies.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package hs.kr.entrydsm.domain.application.interfaces

interface ApplicationConsumeEventContract {
fun deleteByReceiptCode(receiptCode: Long)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package hs.kr.entrydsm.domain.application.interfaces

import java.util.UUID

interface ApplicationCreateEventContract {
fun publishCreateApplication(receiptCode: Long, userId: UUID)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -11,4 +24,4 @@ data class Status(
val isFirstRoundPass: Boolean = false,
val isSecondRoundPass: Boolean = false,
val receiptCode: Long,
)
)
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,24 @@ 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?,
val applicationStatus: ApplicationStatus,
val isFirstRoundPass: Boolean,
val isSecondRoundPass: Boolean,
val ttl: Long
)
)
Original file line number Diff line number Diff line change
@@ -1,5 +1,20 @@
package hs.kr.entrydsm.domain.status.interfaces

/**
* 원서 상태 변경을 위한 계약 인터페이스입니다.
*
* 접수번호를 기반으로 원서 상태 정보를 업데이트하는 기능을 정의합니다.
*/
interface ApplicationCommandStatusContract {

/**
* 지정된 접수번호의 시험 코드를 업데이트합니다.
*
* 시험 코드 배정 시 외부 Status 서비스에 해당 접수번호의
* 시험 코드 정보를 업데이트 요청합니다.
*
* @param receiptCode 시험 코드를 업데이트할 접수번호
* @param examCode 새로 배정된 시험 코드
*/
fun updateExamCode(receiptCode: Long, examCode: String)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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?
}
}
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
package hs.kr.entrydsm.domain.status.interfaces

interface StatusContract : ApplicationQueryStatusContract, ApplicationCommandStatusContract
/**
* 상태 관련 모든 계약을 통합하는 메인 계약 인터페이스입니다.
*
* 상태 조회(Query)와 상태 변경(Command) 계약을 모두 상속받아
* 상태 관련 모든 기능에 대한 단일 진입점을 제공합니다.
*/
interface StatusContract : ApplicationQueryStatusContract, ApplicationCommandStatusContract
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
package hs.kr.entrydsm.domain.user.interfaces

/**
* 사용자 관련 외부 통신을 위한 계약 인터페이스입니다.
*
* 현재는 ApplicationQueryUserContract를 상속받아
* 원서 조회 시 필요한 사용자 정보 조회 기능을 제공합니다.
*/
interface UserContract : ApplicationQueryUserContract {
}
}
3 changes: 3 additions & 0 deletions casper-application-infrastructure/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,9 @@ dependencies {
implementation(Dependencies.RESILIENCE4J_RETRY)
implementation(Dependencies.RESILIENCE4J_SPRING_BOOT)
implementation(Dependencies.RESILIENCE4J_KOTLIN)

//kafka
implementation(Dependencies.KAFKA)
}

sourceSets {
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
Original file line number Diff line number Diff line change
@@ -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)

}
}
}
Original file line number Diff line number Diff line change
@@ -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
)
Loading
Loading