From 46d4c6d2e6d0c03a96ba33b7073bd87dd61c05a6 Mon Sep 17 00:00:00 2001 From: goochul-im Date: Sat, 31 Jan 2026 23:03:04 +0900 Subject: [PATCH 01/16] =?UTF-8?q?build:=20=EC=BD=94=EB=A3=A8=ED=8B=B4=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle.kts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/build.gradle.kts b/build.gradle.kts index 6afe828..79d7c7a 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -51,6 +51,8 @@ dependencies { testImplementation("org.awaitility:awaitility-kotlin:4.2.0") // aws s3 (Spring Cloud AWS for Spring Boot 3.x) implementation("io.awspring.cloud:spring-cloud-aws-starter-s3:3.3.1") + //코루틴 + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core") } kotlin { From dcc249c5de4235a40867a0690491b100703da292 Mon Sep 17 00:00:00 2001 From: goochul-im Date: Sun, 1 Feb 2026 00:21:49 +0900 Subject: [PATCH 02/16] =?UTF-8?q?feat(encrypt):=20=EC=95=94=ED=98=B8?= =?UTF-8?q?=ED=99=94/=EB=B3=B5=ED=98=B8=ED=99=94=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AES 알고리즘 사용 --- .../crypto/AesCbcTextEncryptor.kt | 52 +++++++++++++++++++ .../crypto/EncryptionConverter.kt | 17 ++++++ .../crypto/TextEncryptorPort.kt | 8 +++ 3 files changed, 77 insertions(+) create mode 100644 src/main/kotlin/b1a4/harudew/global/infrastructure/crypto/AesCbcTextEncryptor.kt create mode 100644 src/main/kotlin/b1a4/harudew/global/infrastructure/crypto/EncryptionConverter.kt create mode 100644 src/main/kotlin/b1a4/harudew/global/infrastructure/crypto/TextEncryptorPort.kt diff --git a/src/main/kotlin/b1a4/harudew/global/infrastructure/crypto/AesCbcTextEncryptor.kt b/src/main/kotlin/b1a4/harudew/global/infrastructure/crypto/AesCbcTextEncryptor.kt new file mode 100644 index 0000000..2b4df84 --- /dev/null +++ b/src/main/kotlin/b1a4/harudew/global/infrastructure/crypto/AesCbcTextEncryptor.kt @@ -0,0 +1,52 @@ +package b1a4.harudew.global.infrastructure.crypto + +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Component +import java.security.SecureRandom +import javax.crypto.Cipher +import javax.crypto.spec.IvParameterSpec +import javax.crypto.spec.SecretKeySpec + +@Component +class AesCbcTextEncryptor( + @Value("\${encrypt.secret}") private val secretKeyHex: String +) : TextEncryptorPort { + + companion object { + private const val ALGORITHM = "AES/CBC/PKCS5Padding" + private const val IV_LENGTH = 16 + private const val PREFIX = "enc::" + } + + private val keySpec = SecretKeySpec(secretKeyHex.hexToBytes(), "AES") + + override fun encrypt(plainText: String): String { + if (plainText.startsWith(PREFIX)) return plainText + + val iv = ByteArray(IV_LENGTH).also { SecureRandom().nextBytes(it) } + val cipher = Cipher.getInstance(ALGORITHM) + cipher.init(Cipher.ENCRYPT_MODE, keySpec, IvParameterSpec(iv)) + val encrypted = cipher.doFinal(plainText.toByteArray(Charsets.UTF_8)) + + return "${PREFIX}${iv.toHex()}:${encrypted.toHex()}" + } + + override fun decrypt(cipherText: String): String { + if (!cipherText.startsWith(PREFIX)) return cipherText + + return try { + val parts = cipherText.removePrefix(PREFIX).split(":", limit = 2) + val iv = parts[0].hexToBytes() + val data = parts[1].hexToBytes() + + val cipher = Cipher.getInstance(ALGORITHM) + cipher.init(Cipher.DECRYPT_MODE, keySpec, IvParameterSpec(iv)) + String(cipher.doFinal(data), Charsets.UTF_8) + } catch (e: Exception) { + cipherText + } + } + + private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) } + private fun String.hexToBytes(): ByteArray = chunked(2).map { it.toInt(16).toByte() }.toByteArray() +} diff --git a/src/main/kotlin/b1a4/harudew/global/infrastructure/crypto/EncryptionConverter.kt b/src/main/kotlin/b1a4/harudew/global/infrastructure/crypto/EncryptionConverter.kt new file mode 100644 index 0000000..115598b --- /dev/null +++ b/src/main/kotlin/b1a4/harudew/global/infrastructure/crypto/EncryptionConverter.kt @@ -0,0 +1,17 @@ +package b1a4.harudew.global.infrastructure.crypto + +import jakarta.persistence.AttributeConverter +import jakarta.persistence.Converter + +@Converter +class EncryptionConverter( + private val encryptor: TextEncryptorPort +) : AttributeConverter { + + override fun convertToDatabaseColumn(attribute: String): String = + encryptor.encrypt(attribute) + + override fun convertToEntityAttribute(dbData: String): String = + encryptor.decrypt(dbData) + +} diff --git a/src/main/kotlin/b1a4/harudew/global/infrastructure/crypto/TextEncryptorPort.kt b/src/main/kotlin/b1a4/harudew/global/infrastructure/crypto/TextEncryptorPort.kt new file mode 100644 index 0000000..b264ff1 --- /dev/null +++ b/src/main/kotlin/b1a4/harudew/global/infrastructure/crypto/TextEncryptorPort.kt @@ -0,0 +1,8 @@ +package b1a4.harudew.global.infrastructure.crypto + +interface TextEncryptorPort { + + fun encrypt(plainText: String): String + fun decrypt(cipherText: String): String + +} From 0af63b85227e458192d7e9626f340774e8c9d4d0 Mon Sep 17 00:00:00 2001 From: goochul-im Date: Sun, 1 Feb 2026 00:22:16 +0900 Subject: [PATCH 03/16] =?UTF-8?q?test(encrypt):=20=EC=95=94=ED=98=B8?= =?UTF-8?q?=ED=99=94/=EB=B3=B5=ED=98=B8=ED=99=94=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 테스트 통과 완료 --- .../crypto/AesCbcTextEncryptorTest.kt | 113 ++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 src/test/kotlin/b1a4/harudew/global/infrastructure/crypto/AesCbcTextEncryptorTest.kt diff --git a/src/test/kotlin/b1a4/harudew/global/infrastructure/crypto/AesCbcTextEncryptorTest.kt b/src/test/kotlin/b1a4/harudew/global/infrastructure/crypto/AesCbcTextEncryptorTest.kt new file mode 100644 index 0000000..49f1c54 --- /dev/null +++ b/src/test/kotlin/b1a4/harudew/global/infrastructure/crypto/AesCbcTextEncryptorTest.kt @@ -0,0 +1,113 @@ +package b1a4.harudew.global.infrastructure.crypto + +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test + +class AesCbcTextEncryptorTest { + + private val secretKeyHex = "0123456789abcdef0123456789abcdef" + private val encryptor = AesCbcTextEncryptor(secretKeyHex) + + @Test + fun `평문을 암호화하면 enc 접두사가 붙는다`() { + // given + val plainText = "오늘 하루는 좋은 날이었다" + + // when + val encrypted = encryptor.encrypt(plainText) + + // then + assertThat(encrypted).startsWith("enc::") + } + + @Test + fun `암호화된 텍스트를 복호화하면 원본이 복원된다`() { + // given + val plainText = "오늘 하루는 좋은 날이었다" + + // when + val encrypted = encryptor.encrypt(plainText) + val decrypted = encryptor.decrypt(encrypted) + + // then + assertThat(decrypted).isEqualTo(plainText) + } + + @Test + fun `이미 암호화된 텍스트는 이중 암호화되지 않는다`() { + // given + val plainText = "일기 내용" + val encrypted = encryptor.encrypt(plainText) + + // when + val doubleEncrypted = encryptor.encrypt(encrypted) + + // then + assertThat(doubleEncrypted).isEqualTo(encrypted) + } + + @Test + fun `enc 접두사가 없는 텍스트는 복호화 없이 그대로 반환된다`() { + // given + val plainText = "암호화되지 않은 텍스트" + + // when + val result = encryptor.decrypt(plainText) + + // then + assertThat(result).isEqualTo(plainText) + } + + @Test + fun `같은 평문을 두 번 암호화하면 IV가 달라 결과가 다르다`() { + // given + val plainText = "같은 내용" + + // when + val first = encryptor.encrypt(plainText) + val second = encryptor.encrypt(plainText) + + // then + assertThat(first).isNotEqualTo(second) + assertThat(encryptor.decrypt(first)).isEqualTo(encryptor.decrypt(second)) + } + + @Test + fun `빈 문자열도 암호화 및 복호화할 수 있다`() { + // given + val plainText = "" + + // when + val encrypted = encryptor.encrypt(plainText) + val decrypted = encryptor.decrypt(encrypted) + + // then + assertThat(encrypted).startsWith("enc::") + assertThat(decrypted).isEqualTo(plainText) + } + + @Test + fun `긴 텍스트도 암호화 및 복호화할 수 있다`() { + // given + val plainText = "가".repeat(10_000) + + // when + val encrypted = encryptor.encrypt(plainText) + val decrypted = encryptor.decrypt(encrypted) + + // then + assertThat(decrypted).isEqualTo(plainText) + } + + @Test + fun `손상된 암호문은 원본 그대로 반환된다`() { + // given + val corrupted = "enc::invalidhexdata:alsobroken" + + // when + val result = encryptor.decrypt(corrupted) + + // then + assertThat(result).isEqualTo(corrupted) + } +} From a8583a88c6419ef77d3179cb4bace125e854869f Mon Sep 17 00:00:00 2001 From: goochul-im Date: Sun, 1 Feb 2026 00:32:05 +0900 Subject: [PATCH 04/16] =?UTF-8?q?docs(encrypt):=20=EC=A3=BC=EC=84=9D=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/infrastructure/crypto/AesCbcTextEncryptor.kt | 5 +++++ .../global/infrastructure/crypto/EncryptionConverter.kt | 3 +++ 2 files changed, 8 insertions(+) diff --git a/src/main/kotlin/b1a4/harudew/global/infrastructure/crypto/AesCbcTextEncryptor.kt b/src/main/kotlin/b1a4/harudew/global/infrastructure/crypto/AesCbcTextEncryptor.kt index 2b4df84..9956a5a 100644 --- a/src/main/kotlin/b1a4/harudew/global/infrastructure/crypto/AesCbcTextEncryptor.kt +++ b/src/main/kotlin/b1a4/harudew/global/infrastructure/crypto/AesCbcTextEncryptor.kt @@ -7,6 +7,11 @@ import javax.crypto.Cipher import javax.crypto.spec.IvParameterSpec import javax.crypto.spec.SecretKeySpec +/** + * 텍스트를 암호화/복호화하는 클래스입니다 + * enc:: 로 시작하는 암호화된 텍스트는 복호화하고, 암호화되지 않은 텍스트는 복호화를 시도하지 않습니다 + * AES 알고리즘을 채택했습니다 + */ @Component class AesCbcTextEncryptor( @Value("\${encrypt.secret}") private val secretKeyHex: String diff --git a/src/main/kotlin/b1a4/harudew/global/infrastructure/crypto/EncryptionConverter.kt b/src/main/kotlin/b1a4/harudew/global/infrastructure/crypto/EncryptionConverter.kt index 115598b..92576f7 100644 --- a/src/main/kotlin/b1a4/harudew/global/infrastructure/crypto/EncryptionConverter.kt +++ b/src/main/kotlin/b1a4/harudew/global/infrastructure/crypto/EncryptionConverter.kt @@ -3,6 +3,9 @@ package b1a4.harudew.global.infrastructure.crypto import jakarta.persistence.AttributeConverter import jakarta.persistence.Converter +/** + * 엔티티가 저장될 때 자동으로 암호화/복호화를 수행합니다 + */ @Converter class EncryptionConverter( private val encryptor: TextEncryptorPort From 07979f26e765f506a8b0176d1b7b746bb936a66f Mon Sep 17 00:00:00 2001 From: goochul-im Date: Mon, 2 Feb 2026 21:12:03 +0900 Subject: [PATCH 05/16] =?UTF-8?q?feat(global-event):=20=EC=9D=B4=EB=B2=A4?= =?UTF-8?q?=ED=8A=B8=EC=97=90=20=EC=82=AC=EC=9A=A9=EB=90=A0=20=ED=8F=AC?= =?UTF-8?q?=ED=8A=B8,=20=ED=8D=BC=EB=B8=94=EB=A6=AC=EC=85=94=20=EC=9E=91?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../b1a4/harudew/global/event/DomainEvent.kt | 11 +++++++++++ .../global/event/DomainEventPublisherPort.kt | 7 +++++++ .../global/event/SpringEventPublisherAdapter.kt | 15 +++++++++++++++ 3 files changed, 33 insertions(+) create mode 100644 src/main/kotlin/b1a4/harudew/global/event/DomainEvent.kt create mode 100644 src/main/kotlin/b1a4/harudew/global/event/DomainEventPublisherPort.kt create mode 100644 src/main/kotlin/b1a4/harudew/global/event/SpringEventPublisherAdapter.kt diff --git a/src/main/kotlin/b1a4/harudew/global/event/DomainEvent.kt b/src/main/kotlin/b1a4/harudew/global/event/DomainEvent.kt new file mode 100644 index 0000000..621dfe7 --- /dev/null +++ b/src/main/kotlin/b1a4/harudew/global/event/DomainEvent.kt @@ -0,0 +1,11 @@ +package b1a4.harudew.global.event + +import java.time.Instant +import java.util.UUID + +abstract class DomainEvent( + val eventId: String = UUID.randomUUID().toString(), + val createdAt: Instant = Instant.now(), + val aggregateType: String, + val aggregateId: String +) diff --git a/src/main/kotlin/b1a4/harudew/global/event/DomainEventPublisherPort.kt b/src/main/kotlin/b1a4/harudew/global/event/DomainEventPublisherPort.kt new file mode 100644 index 0000000..6d7ece5 --- /dev/null +++ b/src/main/kotlin/b1a4/harudew/global/event/DomainEventPublisherPort.kt @@ -0,0 +1,7 @@ +package b1a4.harudew.global.event + +interface DomainEventPublisherPort { + + fun publish(event: DomainEvent) + +} diff --git a/src/main/kotlin/b1a4/harudew/global/event/SpringEventPublisherAdapter.kt b/src/main/kotlin/b1a4/harudew/global/event/SpringEventPublisherAdapter.kt new file mode 100644 index 0000000..c5c6289 --- /dev/null +++ b/src/main/kotlin/b1a4/harudew/global/event/SpringEventPublisherAdapter.kt @@ -0,0 +1,15 @@ +package b1a4.harudew.global.event + +import org.springframework.context.ApplicationEventPublisher +import org.springframework.stereotype.Component + +@Component +class SpringEventPublisherAdapter( + private val eventPublisher: ApplicationEventPublisher +) : DomainEventPublisherPort { + + override fun publish(event: DomainEvent) { + eventPublisher.publishEvent(event) + } + +} From 36bf71ba53d5150c93d04cf0ba82fe516e3820fa Mon Sep 17 00:00:00 2001 From: goochul-im Date: Tue, 3 Feb 2026 15:36:14 +0900 Subject: [PATCH 06/16] =?UTF-8?q?feat(diary):=20diary=20=EC=83=9D=EC=84=B1?= =?UTF-8?q?=20=EB=A1=9C=EC=A7=81=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 추후 일기 생성에 따른 RAG 전처리 및 키워드 검색 전처리 과정 작성 필요 --- .../adapter/dto/request/CreateDiaryCommand.kt | 4 +- ...ysisResponse.kt => DiaryAnalysisResult.kt} | 2 +- .../adapter/out/ai/DiaryAnalysisAdapter.kt | 3 +- .../adapter/out/chunk/DiaryChunkAdapter.kt | 4 +- .../out/persistence/DiaryRepositoryImpl.kt | 14 ++++ .../port/in/DiaryCommandUseCase.kt | 3 +- .../application/port/in/DiaryQueryUseCase.kt | 4 +- .../application/port/out/DiaryRepository.kt | 9 +++ .../port/out/analysis/DiaryAnalysisPort.kt | 2 +- .../{ChunkResult.kt => ChunkResponse.kt} | 2 +- .../port/out/chunk/DiaryChunkPort.kt | 2 +- .../diary/application/service/DiaryService.kt | 76 +++++++++++++++++-- .../diary/domain/event/DiaryCreateEvent.kt | 12 +++ .../b1a4/harudew/diary/domain/model/Diary.kt | 18 ++--- .../infrastructure/chunk/ChunkClientPort.kt | 4 +- .../chunk/ChunkClientProvider.kt | 6 +- .../member/adapter/infra/MemberRepository.kt | 9 +++ 17 files changed, 142 insertions(+), 32 deletions(-) rename src/main/kotlin/b1a4/harudew/diary/adapter/dto/response/{DiaryAnalysisResponse.kt => DiaryAnalysisResult.kt} (95%) create mode 100644 src/main/kotlin/b1a4/harudew/diary/adapter/out/persistence/DiaryRepositoryImpl.kt create mode 100644 src/main/kotlin/b1a4/harudew/diary/application/port/out/DiaryRepository.kt rename src/main/kotlin/b1a4/harudew/diary/application/port/out/chunk/{ChunkResult.kt => ChunkResponse.kt} (76%) create mode 100644 src/main/kotlin/b1a4/harudew/diary/domain/event/DiaryCreateEvent.kt create mode 100644 src/main/kotlin/b1a4/harudew/member/adapter/infra/MemberRepository.kt diff --git a/src/main/kotlin/b1a4/harudew/diary/adapter/dto/request/CreateDiaryCommand.kt b/src/main/kotlin/b1a4/harudew/diary/adapter/dto/request/CreateDiaryCommand.kt index ec690c0..5157660 100644 --- a/src/main/kotlin/b1a4/harudew/diary/adapter/dto/request/CreateDiaryCommand.kt +++ b/src/main/kotlin/b1a4/harudew/diary/adapter/dto/request/CreateDiaryCommand.kt @@ -10,6 +10,6 @@ data class CreateDiaryCommand( val weather: String? = null, val photoPath: List? = null, val audioPath: List? = null, - val longitude: Number? = null, - val latitude: Number? = null + val longitude: Double? = null, + val latitude: Double? = null ) diff --git a/src/main/kotlin/b1a4/harudew/diary/adapter/dto/response/DiaryAnalysisResponse.kt b/src/main/kotlin/b1a4/harudew/diary/adapter/dto/response/DiaryAnalysisResult.kt similarity index 95% rename from src/main/kotlin/b1a4/harudew/diary/adapter/dto/response/DiaryAnalysisResponse.kt rename to src/main/kotlin/b1a4/harudew/diary/adapter/dto/response/DiaryAnalysisResult.kt index 2d43b83..f34711c 100644 --- a/src/main/kotlin/b1a4/harudew/diary/adapter/dto/response/DiaryAnalysisResponse.kt +++ b/src/main/kotlin/b1a4/harudew/diary/adapter/dto/response/DiaryAnalysisResult.kt @@ -5,7 +5,7 @@ import b1a4.harudew.person.dto.response.PeopleAnalysisResponse import b1a4.harudew.todo.adapter.dto.response.TodoAnalysisResponse import com.fasterxml.jackson.annotation.JsonProperty -data class DiaryAnalysisResponse( +data class DiaryAnalysisResult( val id: Long, val title: String, val photoPath: List?, diff --git a/src/main/kotlin/b1a4/harudew/diary/adapter/out/ai/DiaryAnalysisAdapter.kt b/src/main/kotlin/b1a4/harudew/diary/adapter/out/ai/DiaryAnalysisAdapter.kt index 5380de3..7397e2a 100644 --- a/src/main/kotlin/b1a4/harudew/diary/adapter/out/ai/DiaryAnalysisAdapter.kt +++ b/src/main/kotlin/b1a4/harudew/diary/adapter/out/ai/DiaryAnalysisAdapter.kt @@ -17,7 +17,7 @@ class DiaryAnalysisAdapter( private val prompt: Resource ) : DiaryAnalysisPort { - override fun getAnalysis(content: String) : DiaryAnalysisResponse? { + override fun getAnalysis(content: String) : DiaryAnalysisResponse { val template = PromptTemplate(prompt) val renderedPrompt = template.render(mapOf("content" to content)) @@ -32,6 +32,7 @@ class DiaryAnalysisAdapter( try { return aiClient.fetchEntity(renderedPrompt, request, DiaryAnalysisResponse::class.java) + ?: throw RuntimeException() } catch (e: Exception) { throw DiaryAnalysisFailedException( e, diff --git a/src/main/kotlin/b1a4/harudew/diary/adapter/out/chunk/DiaryChunkAdapter.kt b/src/main/kotlin/b1a4/harudew/diary/adapter/out/chunk/DiaryChunkAdapter.kt index eed6bda..be3b509 100644 --- a/src/main/kotlin/b1a4/harudew/diary/adapter/out/chunk/DiaryChunkAdapter.kt +++ b/src/main/kotlin/b1a4/harudew/diary/adapter/out/chunk/DiaryChunkAdapter.kt @@ -1,6 +1,6 @@ package b1a4.harudew.diary.adapter.out.chunk -import b1a4.harudew.diary.application.port.out.chunk.ChunkResult +import b1a4.harudew.diary.application.port.out.chunk.ChunkResponse import b1a4.harudew.diary.application.port.out.chunk.DiaryChunkPort import b1a4.harudew.global.infrastructure.chunk.ChunkClientPort import org.springframework.stereotype.Component @@ -10,7 +10,7 @@ class DiaryChunkAdapter( private val chunkClient: ChunkClientPort ) : DiaryChunkPort { - override fun chunk(content: String): ChunkResult { + override fun chunk(content: String): ChunkResponse { return chunkClient.chunk(content) } diff --git a/src/main/kotlin/b1a4/harudew/diary/adapter/out/persistence/DiaryRepositoryImpl.kt b/src/main/kotlin/b1a4/harudew/diary/adapter/out/persistence/DiaryRepositoryImpl.kt new file mode 100644 index 0000000..c265b70 --- /dev/null +++ b/src/main/kotlin/b1a4/harudew/diary/adapter/out/persistence/DiaryRepositoryImpl.kt @@ -0,0 +1,14 @@ +package b1a4.harudew.diary.adapter.out.persistence + +import b1a4.harudew.diary.application.port.out.DiaryRepository +import b1a4.harudew.diary.domain.model.Diary +import org.springframework.stereotype.Repository + +@Repository +class DiaryRepositoryImpl() : DiaryRepository { + + override fun save(diary: Diary): Diary { + TODO("Not yet implemented") + } + +} diff --git a/src/main/kotlin/b1a4/harudew/diary/application/port/in/DiaryCommandUseCase.kt b/src/main/kotlin/b1a4/harudew/diary/application/port/in/DiaryCommandUseCase.kt index 26a29c5..64ec897 100644 --- a/src/main/kotlin/b1a4/harudew/diary/application/port/in/DiaryCommandUseCase.kt +++ b/src/main/kotlin/b1a4/harudew/diary/application/port/in/DiaryCommandUseCase.kt @@ -1,10 +1,11 @@ package b1a4.harudew.diary.application.port.`in` import b1a4.harudew.diary.adapter.dto.request.CreateDiaryCommand +import org.springframework.web.multipart.MultipartFile interface DiaryCommandUseCase { - fun create(command : CreateDiaryCommand) + suspend fun create(authorId: String ,command : CreateDiaryCommand, photos: List?) fun delete(diaryId: Long) diff --git a/src/main/kotlin/b1a4/harudew/diary/application/port/in/DiaryQueryUseCase.kt b/src/main/kotlin/b1a4/harudew/diary/application/port/in/DiaryQueryUseCase.kt index bbf373c..f41791b 100644 --- a/src/main/kotlin/b1a4/harudew/diary/application/port/in/DiaryQueryUseCase.kt +++ b/src/main/kotlin/b1a4/harudew/diary/application/port/in/DiaryQueryUseCase.kt @@ -1,6 +1,6 @@ package b1a4.harudew.diary.application.port.`in` -import b1a4.harudew.diary.adapter.dto.response.DiaryAnalysisResponse +import b1a4.harudew.diary.adapter.dto.response.DiaryAnalysisResult import b1a4.harudew.diary.adapter.dto.response.DiaryResponse import b1a4.harudew.diary.adapter.dto.response.DiaryDetailResponse import b1a4.harudew.diary.adapter.dto.response.DiaryPageResponse @@ -10,7 +10,7 @@ import java.time.LocalDate interface DiaryQueryUseCase { - fun findById(diaryId: Long, member: Member): DiaryAnalysisResponse + fun findById(diaryId: Long, member: Member): DiaryAnalysisResult fun findJsonById(diaryId: Long, member: Member): DiaryDetailResponse diff --git a/src/main/kotlin/b1a4/harudew/diary/application/port/out/DiaryRepository.kt b/src/main/kotlin/b1a4/harudew/diary/application/port/out/DiaryRepository.kt new file mode 100644 index 0000000..c2776b1 --- /dev/null +++ b/src/main/kotlin/b1a4/harudew/diary/application/port/out/DiaryRepository.kt @@ -0,0 +1,9 @@ +package b1a4.harudew.diary.application.port.out + +import b1a4.harudew.diary.domain.model.Diary + +interface DiaryRepository { + + fun save(diary: Diary) : Diary + +} diff --git a/src/main/kotlin/b1a4/harudew/diary/application/port/out/analysis/DiaryAnalysisPort.kt b/src/main/kotlin/b1a4/harudew/diary/application/port/out/analysis/DiaryAnalysisPort.kt index dc805d5..03f0200 100644 --- a/src/main/kotlin/b1a4/harudew/diary/application/port/out/analysis/DiaryAnalysisPort.kt +++ b/src/main/kotlin/b1a4/harudew/diary/application/port/out/analysis/DiaryAnalysisPort.kt @@ -2,6 +2,6 @@ package b1a4.harudew.diary.application.port.out.analysis interface DiaryAnalysisPort { - fun getAnalysis(content: String): DiaryAnalysisResponse? + fun getAnalysis(content: String): DiaryAnalysisResponse } diff --git a/src/main/kotlin/b1a4/harudew/diary/application/port/out/chunk/ChunkResult.kt b/src/main/kotlin/b1a4/harudew/diary/application/port/out/chunk/ChunkResponse.kt similarity index 76% rename from src/main/kotlin/b1a4/harudew/diary/application/port/out/chunk/ChunkResult.kt rename to src/main/kotlin/b1a4/harudew/diary/application/port/out/chunk/ChunkResponse.kt index 9b85f42..a294267 100644 --- a/src/main/kotlin/b1a4/harudew/diary/application/port/out/chunk/ChunkResult.kt +++ b/src/main/kotlin/b1a4/harudew/diary/application/port/out/chunk/ChunkResponse.kt @@ -1,5 +1,5 @@ package b1a4.harudew.diary.application.port.out.chunk -data class ChunkResult( +data class ChunkResponse( val chunks: List ) diff --git a/src/main/kotlin/b1a4/harudew/diary/application/port/out/chunk/DiaryChunkPort.kt b/src/main/kotlin/b1a4/harudew/diary/application/port/out/chunk/DiaryChunkPort.kt index 0e64c17..2878ff3 100644 --- a/src/main/kotlin/b1a4/harudew/diary/application/port/out/chunk/DiaryChunkPort.kt +++ b/src/main/kotlin/b1a4/harudew/diary/application/port/out/chunk/DiaryChunkPort.kt @@ -2,6 +2,6 @@ package b1a4.harudew.diary.application.port.out.chunk interface DiaryChunkPort { - fun chunk(content: String): ChunkResult + fun chunk(content: String): ChunkResponse } diff --git a/src/main/kotlin/b1a4/harudew/diary/application/service/DiaryService.kt b/src/main/kotlin/b1a4/harudew/diary/application/service/DiaryService.kt index a3abf5b..7c4ba07 100644 --- a/src/main/kotlin/b1a4/harudew/diary/application/service/DiaryService.kt +++ b/src/main/kotlin/b1a4/harudew/diary/application/service/DiaryService.kt @@ -1,24 +1,67 @@ package b1a4.harudew.diary.application.service import b1a4.harudew.diary.adapter.dto.request.CreateDiaryCommand -import b1a4.harudew.diary.adapter.dto.response.DiaryAnalysisResponse +import b1a4.harudew.diary.adapter.dto.response.DiaryAnalysisResult import b1a4.harudew.diary.adapter.dto.response.DiaryDetailResponse import b1a4.harudew.diary.adapter.dto.response.DiaryPageResponse import b1a4.harudew.diary.adapter.dto.response.DiaryResponse import b1a4.harudew.diary.adapter.dto.response.DiarySearchResponse +import b1a4.harudew.diary.application.port.out.DiaryRepository import b1a4.harudew.diary.application.port.`in`.DiaryCommandUseCase import b1a4.harudew.diary.application.port.`in`.DiaryQueryUseCase +import b1a4.harudew.diary.application.port.out.analysis.DiaryAnalysisPort +import b1a4.harudew.diary.application.port.out.analysis.DiaryAnalysisResponse +import b1a4.harudew.diary.domain.event.DiaryCreateEvent +import b1a4.harudew.diary.domain.model.Diary +import b1a4.harudew.global.event.DomainEventPublisherPort +import b1a4.harudew.global.infrastructure.storage.FileUploadRequest +import b1a4.harudew.global.infrastructure.storage.StorageClientPort +import b1a4.harudew.member.adapter.infra.MemberRepository import b1a4.harudew.member.domain.Member +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.withContext import org.springframework.stereotype.Service +import org.springframework.web.multipart.MultipartFile import java.time.LocalDate @Service class DiaryService( - + private val storageClient: StorageClientPort, + private val diaryAnalysisAdapter: DiaryAnalysisPort, + private val eventPublisher: DomainEventPublisherPort, + private val diaryRepository: DiaryRepository, + private val memberRepository: MemberRepository ) : DiaryCommandUseCase, DiaryQueryUseCase { - override fun create(command: CreateDiaryCommand) { - TODO("Not yet implemented") + override suspend fun create(authorId: String, command: CreateDiaryCommand, photos: List?) { + val (photoUrls, analysisResult) = coroutineScope { + val photos = async { uploadFiles(photos) } + val diaryAnalysisResult = async { analysisDiary(command.content) } + photos.await() to diaryAnalysisResult.await() + } + + val author = memberRepository.findById(authorId) + val result = Diary( + author = author, + writtenDate = LocalDate.now(), + photoPath = photoUrls, + latitude = command.latitude, + longitude = command.longitude, + audioPath = emptyList(), + metaData = analysisResult, + content = command.content, + ) + + val saveDiary = diaryRepository.save(result) + eventPublisher.publish( + DiaryCreateEvent( + saveDiary.id!!, + analysisResult + ) + ) } override fun delete(diaryId: Long) { @@ -32,7 +75,7 @@ class DiaryService( override fun findById( diaryId: Long, member: Member - ): DiaryAnalysisResponse { + ): DiaryAnalysisResult { TODO("Not yet implemented") } @@ -65,4 +108,27 @@ class DiaryService( override fun findBookmark(member: Member): DiaryPageResponse { TODO("Not yet implemented") } + + private suspend fun analysisDiary(content: String): DiaryAnalysisResponse = + withContext(Dispatchers.IO) { + diaryAnalysisAdapter.getAnalysis(content) + } + + /** + * 코루틴을 이용해 여러 파일을 병렬로 업로드 + */ + private suspend fun uploadFiles(files: List?): List = + coroutineScope { + files?.map { file -> + async(Dispatchers.IO) { + storageClient.upload( + FileUploadRequest( + bytes = file.bytes, + contentType = file.contentType ?: "application/octet-stream", + originalFilename = file.originalFilename, + ) + ) + } + }?.awaitAll() ?: emptyList() + } } diff --git a/src/main/kotlin/b1a4/harudew/diary/domain/event/DiaryCreateEvent.kt b/src/main/kotlin/b1a4/harudew/diary/domain/event/DiaryCreateEvent.kt new file mode 100644 index 0000000..f611a89 --- /dev/null +++ b/src/main/kotlin/b1a4/harudew/diary/domain/event/DiaryCreateEvent.kt @@ -0,0 +1,12 @@ +package b1a4.harudew.diary.domain.event + +import b1a4.harudew.diary.application.port.out.analysis.DiaryAnalysisResponse +import b1a4.harudew.global.event.DomainEvent + +data class DiaryCreateEvent( + val diaryId: Long, + val analysisResult : DiaryAnalysisResponse +) : DomainEvent( + aggregateType = "Diary", + aggregateId = diaryId.toString() +) diff --git a/src/main/kotlin/b1a4/harudew/diary/domain/model/Diary.kt b/src/main/kotlin/b1a4/harudew/diary/domain/model/Diary.kt index 3f9a332..0557a11 100644 --- a/src/main/kotlin/b1a4/harudew/diary/domain/model/Diary.kt +++ b/src/main/kotlin/b1a4/harudew/diary/domain/model/Diary.kt @@ -20,18 +20,16 @@ import java.time.LocalDateTime * @param metaData : 이 일기가 분석된 json */ class Diary( - val id: Long, + val id: Long? = null, val author: Member, - val createAt: LocalDateTime, val writtenDate: LocalDate, val content: String, - val title: String, - val weather: String, + val title: String = "diary title", + val weather: String = "NONE", val photoPath: List, val audioPath: List, - val isBookmark: Boolean, - val latitude: Double, - val longitude: Double, - val metaData: String -) { -} + val isBookmark: Boolean = false, + val latitude: Double?, + val longitude: Double?, + val metaData: Any +) diff --git a/src/main/kotlin/b1a4/harudew/global/infrastructure/chunk/ChunkClientPort.kt b/src/main/kotlin/b1a4/harudew/global/infrastructure/chunk/ChunkClientPort.kt index 9399bb7..a79a158 100644 --- a/src/main/kotlin/b1a4/harudew/global/infrastructure/chunk/ChunkClientPort.kt +++ b/src/main/kotlin/b1a4/harudew/global/infrastructure/chunk/ChunkClientPort.kt @@ -1,9 +1,9 @@ package b1a4.harudew.global.infrastructure.chunk -import b1a4.harudew.diary.application.port.out.chunk.ChunkResult +import b1a4.harudew.diary.application.port.out.chunk.ChunkResponse interface ChunkClientPort { - fun chunk(content: String): ChunkResult + fun chunk(content: String): ChunkResponse } diff --git a/src/main/kotlin/b1a4/harudew/global/infrastructure/chunk/ChunkClientProvider.kt b/src/main/kotlin/b1a4/harudew/global/infrastructure/chunk/ChunkClientProvider.kt index de1c8c1..c5393bd 100644 --- a/src/main/kotlin/b1a4/harudew/global/infrastructure/chunk/ChunkClientProvider.kt +++ b/src/main/kotlin/b1a4/harudew/global/infrastructure/chunk/ChunkClientProvider.kt @@ -1,6 +1,6 @@ package b1a4.harudew.global.infrastructure.chunk -import b1a4.harudew.diary.application.port.out.chunk.ChunkResult +import b1a4.harudew.diary.application.port.out.chunk.ChunkResponse import org.springframework.beans.factory.annotation.Value import org.springframework.stereotype.Component import org.springframework.web.client.RestClient @@ -14,7 +14,7 @@ class ChunkClientProvider( private val restClient = restClientBuilder.build() - override fun chunk(content: String): ChunkResult { + override fun chunk(content: String): ChunkResponse { val response = restClient.post() .uri(parserUrl) @@ -22,7 +22,7 @@ class ChunkClientProvider( .retrieve() .body(ParserRawResponse::class.java) - return ChunkResult(response!!.sentences) + return ChunkResponse(response!!.sentences) } private data class ParserRawRequest( diff --git a/src/main/kotlin/b1a4/harudew/member/adapter/infra/MemberRepository.kt b/src/main/kotlin/b1a4/harudew/member/adapter/infra/MemberRepository.kt new file mode 100644 index 0000000..2ced120 --- /dev/null +++ b/src/main/kotlin/b1a4/harudew/member/adapter/infra/MemberRepository.kt @@ -0,0 +1,9 @@ +package b1a4.harudew.member.adapter.infra + +import b1a4.harudew.member.domain.Member + +interface MemberRepository { + + fun findById(id: String) : Member + +} From be92a0ff4da591d2de3007ecc73240b8f358e680 Mon Sep 17 00:00:00 2001 From: goochul-im Date: Tue, 3 Feb 2026 16:28:16 +0900 Subject: [PATCH 07/16] =?UTF-8?q?refactor(Qdrant):=20Qdrant=EB=A5=BC=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=ED=95=98=EB=8A=94=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=EC=B6=94=EA=B0=80=EB=A5=BC=20=EA=B3=A0=EB=A0=A4?= =?UTF-8?q?=ED=95=B4=20=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 비슷한 로직을 각 도메인마다 가질 것으로 예상되므로 QdrantClientAdapter로 하나로 묶어서 사용할수 있도록 설정 --- .../auth/adapter/in/web/AuthController.kt | 2 +- .../harudew/auth/config/SuperUserConfig.kt | 4 +- .../resolver/CurrentMemberArgumentResolver.kt | 2 +- .../oauth2/CustomOAuth2UserService.kt | 4 +- .../out/vector/keyword/KeywordVectorPort.kt | 4 +- ...eywordReqeust.kt => SaveKeywordRequest.kt} | 2 +- ...wordResult.kt => SearchKeywordResponse.kt} | 2 +- .../vector/sentence/SearchSentenceQuery.kt | 7 ++ .../vector/sentence/SearchSentenceResponse.kt | 10 +++ .../out/vector/sentence/SentenceVectorPort.kt | 1 + .../diary/application/service/DiaryService.kt | 2 +- .../qdrant/QdrantClientAdapter.kt | 88 +++++++++++++++++++ .../infrastructure/qdrant/QdrantClientPort.kt | 10 +++ .../qdrant/QdrantPayloadConverter.kt | 44 ++++++++++ .../infrastructure/qdrant/QdrantPointData.kt | 9 ++ .../qdrant/QdrantSearchQuery.kt | 10 +++ .../qdrant/QdrantSearchResult.kt | 25 ++++++ .../infrastructure}/MemberEntity.kt | 2 +- .../infrastructure}/MemberJpaRepository.kt | 2 +- .../out/persistence/MemberRepositoryImpl.kt | 13 +++ .../application/port/MemberQueryUseCase.kt | 7 -- .../application/port/in/MemberQueryUseCase.kt | 7 ++ .../port/out}/MemberRepository.kt | 2 +- .../qdrant/QdrantKeywordVectorAdapterTest.kt | 4 +- .../persistence/qdrant/QdrantTestConfig.kt | 18 +++- .../harudew/member/domain/MemberEntityTest.kt | 2 +- 26 files changed, 256 insertions(+), 27 deletions(-) rename src/main/kotlin/b1a4/harudew/diary/application/port/out/vector/keyword/{SaveKeywordReqeust.kt => SaveKeywordRequest.kt} (88%) rename src/main/kotlin/b1a4/harudew/diary/application/port/out/vector/keyword/{SearchKeywordResult.kt => SearchKeywordResponse.kt} (76%) create mode 100644 src/main/kotlin/b1a4/harudew/diary/application/port/out/vector/sentence/SearchSentenceQuery.kt create mode 100644 src/main/kotlin/b1a4/harudew/diary/application/port/out/vector/sentence/SearchSentenceResponse.kt create mode 100644 src/main/kotlin/b1a4/harudew/global/infrastructure/qdrant/QdrantClientAdapter.kt create mode 100644 src/main/kotlin/b1a4/harudew/global/infrastructure/qdrant/QdrantClientPort.kt create mode 100644 src/main/kotlin/b1a4/harudew/global/infrastructure/qdrant/QdrantPayloadConverter.kt create mode 100644 src/main/kotlin/b1a4/harudew/global/infrastructure/qdrant/QdrantPointData.kt create mode 100644 src/main/kotlin/b1a4/harudew/global/infrastructure/qdrant/QdrantSearchQuery.kt create mode 100644 src/main/kotlin/b1a4/harudew/global/infrastructure/qdrant/QdrantSearchResult.kt rename src/main/kotlin/b1a4/harudew/member/adapter/{infra => out/infrastructure}/MemberEntity.kt (96%) rename src/main/kotlin/b1a4/harudew/member/adapter/{infra => out/infrastructure}/MemberJpaRepository.kt (94%) create mode 100644 src/main/kotlin/b1a4/harudew/member/adapter/out/persistence/MemberRepositoryImpl.kt delete mode 100644 src/main/kotlin/b1a4/harudew/member/application/port/MemberQueryUseCase.kt create mode 100644 src/main/kotlin/b1a4/harudew/member/application/port/in/MemberQueryUseCase.kt rename src/main/kotlin/b1a4/harudew/member/{adapter/infra => application/port/out}/MemberRepository.kt (69%) diff --git a/src/main/kotlin/b1a4/harudew/auth/adapter/in/web/AuthController.kt b/src/main/kotlin/b1a4/harudew/auth/adapter/in/web/AuthController.kt index 20cb449..723ede3 100644 --- a/src/main/kotlin/b1a4/harudew/auth/adapter/in/web/AuthController.kt +++ b/src/main/kotlin/b1a4/harudew/auth/adapter/in/web/AuthController.kt @@ -7,7 +7,7 @@ import b1a4.harudew.auth.dto.TokenResponse import b1a4.harudew.auth.security.jwt.JwtTokenProvider import b1a4.harudew.global.exception.BusinessException import b1a4.harudew.global.exception.ErrorCode -import b1a4.harudew.member.adapter.infra.MemberJpaRepository +import b1a4.harudew.member.adapter.out.infrastructure.MemberJpaRepository import io.github.oshai.kotlinlogging.KotlinLogging import org.springframework.beans.factory.annotation.Value import org.springframework.context.annotation.Profile diff --git a/src/main/kotlin/b1a4/harudew/auth/config/SuperUserConfig.kt b/src/main/kotlin/b1a4/harudew/auth/config/SuperUserConfig.kt index ee3fd77..c68e8f8 100644 --- a/src/main/kotlin/b1a4/harudew/auth/config/SuperUserConfig.kt +++ b/src/main/kotlin/b1a4/harudew/auth/config/SuperUserConfig.kt @@ -1,7 +1,7 @@ package b1a4.harudew.auth.config -import b1a4.harudew.member.adapter.infra.MemberEntity -import b1a4.harudew.member.adapter.infra.MemberJpaRepository +import b1a4.harudew.member.adapter.out.infrastructure.MemberEntity +import b1a4.harudew.member.adapter.out.infrastructure.MemberJpaRepository import b1a4.harudew.member.domain.Member import io.github.oshai.kotlinlogging.KotlinLogging import org.springframework.boot.CommandLineRunner diff --git a/src/main/kotlin/b1a4/harudew/auth/resolver/CurrentMemberArgumentResolver.kt b/src/main/kotlin/b1a4/harudew/auth/resolver/CurrentMemberArgumentResolver.kt index a0ca3c5..a29c8ed 100644 --- a/src/main/kotlin/b1a4/harudew/auth/resolver/CurrentMemberArgumentResolver.kt +++ b/src/main/kotlin/b1a4/harudew/auth/resolver/CurrentMemberArgumentResolver.kt @@ -4,7 +4,7 @@ import b1a4.harudew.auth.annotation.CurrentMember import b1a4.harudew.auth.security.jwt.JwtPrincipal import b1a4.harudew.global.exception.BusinessException import b1a4.harudew.global.exception.ErrorCode -import b1a4.harudew.member.adapter.infra.MemberJpaRepository +import b1a4.harudew.member.adapter.out.infrastructure.MemberJpaRepository import b1a4.harudew.member.domain.Member import org.springframework.core.MethodParameter import org.springframework.security.core.context.SecurityContextHolder diff --git a/src/main/kotlin/b1a4/harudew/auth/security/oauth2/CustomOAuth2UserService.kt b/src/main/kotlin/b1a4/harudew/auth/security/oauth2/CustomOAuth2UserService.kt index 22c9c55..866a5bb 100644 --- a/src/main/kotlin/b1a4/harudew/auth/security/oauth2/CustomOAuth2UserService.kt +++ b/src/main/kotlin/b1a4/harudew/auth/security/oauth2/CustomOAuth2UserService.kt @@ -1,7 +1,7 @@ package b1a4.harudew.auth.security.oauth2 -import b1a4.harudew.member.adapter.infra.MemberEntity -import b1a4.harudew.member.adapter.infra.MemberJpaRepository +import b1a4.harudew.member.adapter.out.infrastructure.MemberEntity +import b1a4.harudew.member.adapter.out.infrastructure.MemberJpaRepository import b1a4.harudew.member.domain.Member import io.github.oshai.kotlinlogging.KotlinLogging import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService diff --git a/src/main/kotlin/b1a4/harudew/diary/application/port/out/vector/keyword/KeywordVectorPort.kt b/src/main/kotlin/b1a4/harudew/diary/application/port/out/vector/keyword/KeywordVectorPort.kt index 5452a67..20be027 100644 --- a/src/main/kotlin/b1a4/harudew/diary/application/port/out/vector/keyword/KeywordVectorPort.kt +++ b/src/main/kotlin/b1a4/harudew/diary/application/port/out/vector/keyword/KeywordVectorPort.kt @@ -2,8 +2,8 @@ package b1a4.harudew.diary.application.port.out.vector.keyword interface KeywordVectorPort { - fun save(request: SaveKeywordReqeust) + fun save(request: SaveKeywordRequest) - fun searchByKeyword(query: SearchKeywordQuery) : List + fun searchByKeyword(query: SearchKeywordQuery) : List } diff --git a/src/main/kotlin/b1a4/harudew/diary/application/port/out/vector/keyword/SaveKeywordReqeust.kt b/src/main/kotlin/b1a4/harudew/diary/application/port/out/vector/keyword/SaveKeywordRequest.kt similarity index 88% rename from src/main/kotlin/b1a4/harudew/diary/application/port/out/vector/keyword/SaveKeywordReqeust.kt rename to src/main/kotlin/b1a4/harudew/diary/application/port/out/vector/keyword/SaveKeywordRequest.kt index dcde24c..4158621 100644 --- a/src/main/kotlin/b1a4/harudew/diary/application/port/out/vector/keyword/SaveKeywordReqeust.kt +++ b/src/main/kotlin/b1a4/harudew/diary/application/port/out/vector/keyword/SaveKeywordRequest.kt @@ -2,7 +2,7 @@ package b1a4.harudew.diary.application.port.out.vector.keyword import b1a4.harudew.diary.application.port.out.vector.ContentVectorWrapper -data class SaveKeywordReqeust( +data class SaveKeywordRequest( val keywords: List, val diaryId: Long, val authorId: Long diff --git a/src/main/kotlin/b1a4/harudew/diary/application/port/out/vector/keyword/SearchKeywordResult.kt b/src/main/kotlin/b1a4/harudew/diary/application/port/out/vector/keyword/SearchKeywordResponse.kt similarity index 76% rename from src/main/kotlin/b1a4/harudew/diary/application/port/out/vector/keyword/SearchKeywordResult.kt rename to src/main/kotlin/b1a4/harudew/diary/application/port/out/vector/keyword/SearchKeywordResponse.kt index 658af4f..31aaccb 100644 --- a/src/main/kotlin/b1a4/harudew/diary/application/port/out/vector/keyword/SearchKeywordResult.kt +++ b/src/main/kotlin/b1a4/harudew/diary/application/port/out/vector/keyword/SearchKeywordResponse.kt @@ -1,6 +1,6 @@ package b1a4.harudew.diary.application.port.out.vector.keyword -data class SearchKeywordResult( +data class SearchKeywordResponse( val keyword: String, val diaryId: Long ) diff --git a/src/main/kotlin/b1a4/harudew/diary/application/port/out/vector/sentence/SearchSentenceQuery.kt b/src/main/kotlin/b1a4/harudew/diary/application/port/out/vector/sentence/SearchSentenceQuery.kt new file mode 100644 index 0000000..93272d6 --- /dev/null +++ b/src/main/kotlin/b1a4/harudew/diary/application/port/out/vector/sentence/SearchSentenceQuery.kt @@ -0,0 +1,7 @@ +package b1a4.harudew.diary.application.port.out.vector.sentence + +data class SearchSentenceQuery( + val vector: List, + val authorId: Long, + val limit: Int = 5 +) diff --git a/src/main/kotlin/b1a4/harudew/diary/application/port/out/vector/sentence/SearchSentenceResponse.kt b/src/main/kotlin/b1a4/harudew/diary/application/port/out/vector/sentence/SearchSentenceResponse.kt new file mode 100644 index 0000000..9b93525 --- /dev/null +++ b/src/main/kotlin/b1a4/harudew/diary/application/port/out/vector/sentence/SearchSentenceResponse.kt @@ -0,0 +1,10 @@ +package b1a4.harudew.diary.application.port.out.vector.sentence + +import java.time.LocalDate + +data class SearchSentenceResponse( + val sentence: String, + val diaryId: Long, + val date: LocalDate, + val score: Float +) diff --git a/src/main/kotlin/b1a4/harudew/diary/application/port/out/vector/sentence/SentenceVectorPort.kt b/src/main/kotlin/b1a4/harudew/diary/application/port/out/vector/sentence/SentenceVectorPort.kt index 09d1c5b..d0f406d 100644 --- a/src/main/kotlin/b1a4/harudew/diary/application/port/out/vector/sentence/SentenceVectorPort.kt +++ b/src/main/kotlin/b1a4/harudew/diary/application/port/out/vector/sentence/SentenceVectorPort.kt @@ -4,4 +4,5 @@ interface SentenceVectorPort { fun save(request: SaveSentenceRequest) + fun search(query: SearchSentenceQuery): List } diff --git a/src/main/kotlin/b1a4/harudew/diary/application/service/DiaryService.kt b/src/main/kotlin/b1a4/harudew/diary/application/service/DiaryService.kt index 7c4ba07..96d5320 100644 --- a/src/main/kotlin/b1a4/harudew/diary/application/service/DiaryService.kt +++ b/src/main/kotlin/b1a4/harudew/diary/application/service/DiaryService.kt @@ -16,7 +16,7 @@ import b1a4.harudew.diary.domain.model.Diary import b1a4.harudew.global.event.DomainEventPublisherPort import b1a4.harudew.global.infrastructure.storage.FileUploadRequest import b1a4.harudew.global.infrastructure.storage.StorageClientPort -import b1a4.harudew.member.adapter.infra.MemberRepository +import b1a4.harudew.member.application.port.out.MemberRepository import b1a4.harudew.member.domain.Member import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async diff --git a/src/main/kotlin/b1a4/harudew/global/infrastructure/qdrant/QdrantClientAdapter.kt b/src/main/kotlin/b1a4/harudew/global/infrastructure/qdrant/QdrantClientAdapter.kt new file mode 100644 index 0000000..c35b83d --- /dev/null +++ b/src/main/kotlin/b1a4/harudew/global/infrastructure/qdrant/QdrantClientAdapter.kt @@ -0,0 +1,88 @@ +package b1a4.harudew.global.infrastructure.qdrant + +import b1a4.harudew.global.util.UuidGenerator +import io.qdrant.client.ConditionFactory.match +import io.qdrant.client.PointIdFactory.id +import io.qdrant.client.QdrantClient +import io.qdrant.client.VectorsFactory.vectors +import io.qdrant.client.WithPayloadSelectorFactory.enable +import io.qdrant.client.grpc.Common +import io.qdrant.client.grpc.Points +import org.springframework.stereotype.Component +import java.util.UUID + +@Component +class QdrantClientAdapter( + private val qdrantClient: QdrantClient, + private val uuidGenerator: UuidGenerator +) : QdrantClientPort { + + override fun upsertAsync(collectionName: String, points: List) { + if (points.isEmpty()) { + return + } + + val qdrantPoints = points.map { point -> + Points.PointStruct.newBuilder() + .setId(id(point.id ?: uuidGenerator.generate())) + .setVectors(vectors(point.vector.map { it.toFloat() })) + .putAllPayload(QdrantPayloadConverter.toQdrantPayload(point.payload)) + .build() + } + + qdrantClient.upsertAsync(collectionName, qdrantPoints) + } + + override fun search(query: QdrantSearchQuery): List { + val searchRequestBuilder = Points.SearchPoints.newBuilder() + .setCollectionName(query.collectionName) + .addAllVector(query.vector.map { it.toFloat() }) + .setWithPayload(enable(query.withPayload)) + .setLimit(query.limit.toLong()) + + if (query.filters.isNotEmpty()) { + val filterBuilder = Common.Filter.newBuilder() + query.filters.forEach { (key, value) -> + when (value) { + is Long -> filterBuilder.addMust(match(key, value)) + is Int -> filterBuilder.addMust(match(key, value.toLong())) + } + } + searchRequestBuilder.setFilter(filterBuilder.build()) + } + + query.scoreThreshold?.let { + searchRequestBuilder.setScoreThreshold(it) + } + + val scoredPoints = qdrantClient.searchAsync(searchRequestBuilder.build()).get() + + return scoredPoints.map { point -> + QdrantSearchResult( + id = extractUuid(point.id), + score = point.score, + payload = QdrantPayloadConverter.fromQdrantPayload(point.payloadMap) + ) + } + } + + override fun deleteByFilter(collectionName: String, filter: Map) { + val filterBuilder = Common.Filter.newBuilder() + filter.forEach { (key, value) -> + when (value) { + is Long -> filterBuilder.addMust(match(key, value)) + is Int -> filterBuilder.addMust(match(key, value.toLong())) + } + } + + qdrantClient.deleteAsync(collectionName, filterBuilder.build()) + } + + private fun extractUuid(pointId: Common.PointId): UUID { + return when { + pointId.uuid.isNotEmpty() -> UUID.fromString(pointId.uuid) + pointId.num > 0 -> UUID(0, pointId.num) + else -> uuidGenerator.generate() + } + } +} diff --git a/src/main/kotlin/b1a4/harudew/global/infrastructure/qdrant/QdrantClientPort.kt b/src/main/kotlin/b1a4/harudew/global/infrastructure/qdrant/QdrantClientPort.kt new file mode 100644 index 0000000..725303e --- /dev/null +++ b/src/main/kotlin/b1a4/harudew/global/infrastructure/qdrant/QdrantClientPort.kt @@ -0,0 +1,10 @@ +package b1a4.harudew.global.infrastructure.qdrant + +interface QdrantClientPort { + + fun upsertAsync(collectionName: String, points: List) + + fun search(query: QdrantSearchQuery): List + + fun deleteByFilter(collectionName: String, filter: Map) +} diff --git a/src/main/kotlin/b1a4/harudew/global/infrastructure/qdrant/QdrantPayloadConverter.kt b/src/main/kotlin/b1a4/harudew/global/infrastructure/qdrant/QdrantPayloadConverter.kt new file mode 100644 index 0000000..67e9778 --- /dev/null +++ b/src/main/kotlin/b1a4/harudew/global/infrastructure/qdrant/QdrantPayloadConverter.kt @@ -0,0 +1,44 @@ +package b1a4.harudew.global.infrastructure.qdrant + +import io.qdrant.client.ValueFactory +import io.qdrant.client.grpc.JsonWithInt.Value +import java.time.LocalDate +import java.time.LocalDateTime + +object QdrantPayloadConverter { + + fun toQdrantPayload(map: Map): Map { + return map.mapValues { (_, value) -> toValue(value) } + } + + private fun toValue(value: Any): Value { + return when (value) { + is String -> ValueFactory.value(value) + is Long -> ValueFactory.value(value) + is Int -> ValueFactory.value(value.toLong()) + is Double -> ValueFactory.value(value) + is Float -> ValueFactory.value(value.toDouble()) + is Boolean -> ValueFactory.value(value) + is LocalDate -> ValueFactory.value(value.toString()) + is LocalDateTime -> ValueFactory.value(value.toString()) + is List<*> -> ValueFactory.list(value.filterNotNull().map { toValue(it) }) + else -> ValueFactory.value(value.toString()) + } + } + + fun fromQdrantPayload(payload: Map): Map { + return payload.mapValues { (_, value) -> fromValue(value) } + } + + private fun fromValue(value: Value): Any? { + return when { + value.hasStringValue() -> value.stringValue + value.hasIntegerValue() -> value.integerValue + value.hasDoubleValue() -> value.doubleValue + value.hasBoolValue() -> value.boolValue + value.hasListValue() -> value.listValue.valuesList.map { fromValue(it) } + value.hasNullValue() -> null + else -> null + } + } +} diff --git a/src/main/kotlin/b1a4/harudew/global/infrastructure/qdrant/QdrantPointData.kt b/src/main/kotlin/b1a4/harudew/global/infrastructure/qdrant/QdrantPointData.kt new file mode 100644 index 0000000..04b282f --- /dev/null +++ b/src/main/kotlin/b1a4/harudew/global/infrastructure/qdrant/QdrantPointData.kt @@ -0,0 +1,9 @@ +package b1a4.harudew.global.infrastructure.qdrant + +import java.util.UUID + +data class QdrantPointData( + val id: UUID? = null, + val vector: List, + val payload: Map +) diff --git a/src/main/kotlin/b1a4/harudew/global/infrastructure/qdrant/QdrantSearchQuery.kt b/src/main/kotlin/b1a4/harudew/global/infrastructure/qdrant/QdrantSearchQuery.kt new file mode 100644 index 0000000..b621432 --- /dev/null +++ b/src/main/kotlin/b1a4/harudew/global/infrastructure/qdrant/QdrantSearchQuery.kt @@ -0,0 +1,10 @@ +package b1a4.harudew.global.infrastructure.qdrant + +data class QdrantSearchQuery( + val collectionName: String, + val vector: List, + val filters: Map = emptyMap(), + val limit: Int = 10, + val withPayload: Boolean = true, + val scoreThreshold: Float? = null +) diff --git a/src/main/kotlin/b1a4/harudew/global/infrastructure/qdrant/QdrantSearchResult.kt b/src/main/kotlin/b1a4/harudew/global/infrastructure/qdrant/QdrantSearchResult.kt new file mode 100644 index 0000000..dc3c78b --- /dev/null +++ b/src/main/kotlin/b1a4/harudew/global/infrastructure/qdrant/QdrantSearchResult.kt @@ -0,0 +1,25 @@ +package b1a4.harudew.global.infrastructure.qdrant + +import java.util.UUID + +data class QdrantSearchResult( + val id: UUID, + val score: Float, + val payload: Map +) { + fun getString(key: String): String? = payload[key] as? String + + fun getLong(key: String): Long? = when (val value = payload[key]) { + is Long -> value + is Int -> value.toLong() + is Number -> value.toLong() + else -> null + } + + fun getDouble(key: String): Double? = when (val value = payload[key]) { + is Double -> value + is Float -> value.toDouble() + is Number -> value.toDouble() + else -> null + } +} diff --git a/src/main/kotlin/b1a4/harudew/member/adapter/infra/MemberEntity.kt b/src/main/kotlin/b1a4/harudew/member/adapter/out/infrastructure/MemberEntity.kt similarity index 96% rename from src/main/kotlin/b1a4/harudew/member/adapter/infra/MemberEntity.kt rename to src/main/kotlin/b1a4/harudew/member/adapter/out/infrastructure/MemberEntity.kt index 00d14fd..4a440e4 100644 --- a/src/main/kotlin/b1a4/harudew/member/adapter/infra/MemberEntity.kt +++ b/src/main/kotlin/b1a4/harudew/member/adapter/out/infrastructure/MemberEntity.kt @@ -1,4 +1,4 @@ -package b1a4.harudew.member.adapter.infra +package b1a4.harudew.member.adapter.out.infrastructure import b1a4.harudew.member.domain.Member import b1a4.harudew.member.domain.SocialType diff --git a/src/main/kotlin/b1a4/harudew/member/adapter/infra/MemberJpaRepository.kt b/src/main/kotlin/b1a4/harudew/member/adapter/out/infrastructure/MemberJpaRepository.kt similarity index 94% rename from src/main/kotlin/b1a4/harudew/member/adapter/infra/MemberJpaRepository.kt rename to src/main/kotlin/b1a4/harudew/member/adapter/out/infrastructure/MemberJpaRepository.kt index c5e276f..9d1afd8 100644 --- a/src/main/kotlin/b1a4/harudew/member/adapter/infra/MemberJpaRepository.kt +++ b/src/main/kotlin/b1a4/harudew/member/adapter/out/infrastructure/MemberJpaRepository.kt @@ -1,4 +1,4 @@ -package b1a4.harudew.member.adapter.infra +package b1a4.harudew.member.adapter.out.infrastructure import b1a4.harudew.member.domain.SocialType import org.springframework.data.jpa.repository.JpaRepository diff --git a/src/main/kotlin/b1a4/harudew/member/adapter/out/persistence/MemberRepositoryImpl.kt b/src/main/kotlin/b1a4/harudew/member/adapter/out/persistence/MemberRepositoryImpl.kt new file mode 100644 index 0000000..f72073e --- /dev/null +++ b/src/main/kotlin/b1a4/harudew/member/adapter/out/persistence/MemberRepositoryImpl.kt @@ -0,0 +1,13 @@ +package b1a4.harudew.member.adapter.out.persistence + +import b1a4.harudew.member.application.port.out.MemberRepository +import b1a4.harudew.member.domain.Member +import org.springframework.stereotype.Component +import org.springframework.stereotype.Repository + +@Repository +class MemberRepositoryImpl : MemberRepository { + override fun findById(id: String): Member { + TODO("Not yet implemented") + } +} diff --git a/src/main/kotlin/b1a4/harudew/member/application/port/MemberQueryUseCase.kt b/src/main/kotlin/b1a4/harudew/member/application/port/MemberQueryUseCase.kt deleted file mode 100644 index 620d0a4..0000000 --- a/src/main/kotlin/b1a4/harudew/member/application/port/MemberQueryUseCase.kt +++ /dev/null @@ -1,7 +0,0 @@ -package b1a4.harudew.member.application.port - -interface MemberQueryUseCase { - - - -} diff --git a/src/main/kotlin/b1a4/harudew/member/application/port/in/MemberQueryUseCase.kt b/src/main/kotlin/b1a4/harudew/member/application/port/in/MemberQueryUseCase.kt new file mode 100644 index 0000000..3e893c3 --- /dev/null +++ b/src/main/kotlin/b1a4/harudew/member/application/port/in/MemberQueryUseCase.kt @@ -0,0 +1,7 @@ +package b1a4.harudew.member.application.port.`in` + +interface MemberQueryUseCase { + + + +} diff --git a/src/main/kotlin/b1a4/harudew/member/adapter/infra/MemberRepository.kt b/src/main/kotlin/b1a4/harudew/member/application/port/out/MemberRepository.kt similarity index 69% rename from src/main/kotlin/b1a4/harudew/member/adapter/infra/MemberRepository.kt rename to src/main/kotlin/b1a4/harudew/member/application/port/out/MemberRepository.kt index 2ced120..eadcf5a 100644 --- a/src/main/kotlin/b1a4/harudew/member/adapter/infra/MemberRepository.kt +++ b/src/main/kotlin/b1a4/harudew/member/application/port/out/MemberRepository.kt @@ -1,4 +1,4 @@ -package b1a4.harudew.member.adapter.infra +package b1a4.harudew.member.application.port.out import b1a4.harudew.member.domain.Member diff --git a/src/test/kotlin/b1a4/harudew/diary/adapter/out/persistence/qdrant/QdrantKeywordVectorAdapterTest.kt b/src/test/kotlin/b1a4/harudew/diary/adapter/out/persistence/qdrant/QdrantKeywordVectorAdapterTest.kt index ee03e41..b5b37f9 100644 --- a/src/test/kotlin/b1a4/harudew/diary/adapter/out/persistence/qdrant/QdrantKeywordVectorAdapterTest.kt +++ b/src/test/kotlin/b1a4/harudew/diary/adapter/out/persistence/qdrant/QdrantKeywordVectorAdapterTest.kt @@ -2,7 +2,7 @@ package b1a4.harudew.diary.adapter.out.persistence.qdrant import b1a4.harudew.annotation.IntegrationTest import b1a4.harudew.diary.application.port.out.vector.ContentVectorWrapper -import b1a4.harudew.diary.application.port.out.vector.keyword.SaveKeywordReqeust +import b1a4.harudew.diary.application.port.out.vector.keyword.SaveKeywordRequest import b1a4.harudew.diary.application.port.out.vector.keyword.SearchKeywordQuery import io.qdrant.client.QdrantClient import io.qdrant.client.grpc.Collections @@ -92,7 +92,7 @@ class QdrantKeywordVectorAdapterTest { println("테스트 authorId: $testAuthorId") println("테스트 keyword: $testKeyword") - val saveRequest = SaveKeywordReqeust( + val saveRequest = SaveKeywordRequest( keywords = listOf( ContentVectorWrapper( content = testKeyword, diff --git a/src/test/kotlin/b1a4/harudew/diary/adapter/out/persistence/qdrant/QdrantTestConfig.kt b/src/test/kotlin/b1a4/harudew/diary/adapter/out/persistence/qdrant/QdrantTestConfig.kt index 54d1a3d..3b3312a 100644 --- a/src/test/kotlin/b1a4/harudew/diary/adapter/out/persistence/qdrant/QdrantTestConfig.kt +++ b/src/test/kotlin/b1a4/harudew/diary/adapter/out/persistence/qdrant/QdrantTestConfig.kt @@ -1,5 +1,7 @@ package b1a4.harudew.diary.adapter.out.persistence.qdrant +import b1a4.harudew.global.infrastructure.qdrant.QdrantClientAdapter +import b1a4.harudew.global.infrastructure.qdrant.QdrantClientPort import b1a4.harudew.global.util.UuidGenerator import io.qdrant.client.QdrantClient import org.springframework.boot.test.context.TestConfiguration @@ -15,13 +17,23 @@ class QdrantTestConfig { @Bean @Primary - fun testKeywordVectorAdapter( + fun testQdrantClientPort( qdrantClient: QdrantClient, uuidGenerator: UuidGenerator + ): QdrantClientPort { + return QdrantClientAdapter( + qdrantClient = qdrantClient, + uuidGenerator = uuidGenerator + ) + } + + @Bean + @Primary + fun testKeywordVectorAdapter( + qdrantClientPort: QdrantClientPort ): QdrantKeywordVectorAdapter { return QdrantKeywordVectorAdapter( - qdrantClient = qdrantClient, - uuidGenerator = uuidGenerator, + qdrantClientPort = qdrantClientPort, collectionName = TEST_COLLECTION_NAME ) } diff --git a/src/test/kotlin/b1a4/harudew/member/domain/MemberEntityTest.kt b/src/test/kotlin/b1a4/harudew/member/domain/MemberEntityTest.kt index 9ef1c8d..ba6d9f2 100644 --- a/src/test/kotlin/b1a4/harudew/member/domain/MemberEntityTest.kt +++ b/src/test/kotlin/b1a4/harudew/member/domain/MemberEntityTest.kt @@ -1,6 +1,6 @@ package b1a4.harudew.member.domain -import b1a4.harudew.member.adapter.infra.MemberEntity +import b1a4.harudew.member.adapter.out.infrastructure.MemberEntity import org.assertj.core.api.Assertions.* import java.time.LocalDate import kotlin.test.Test From c357ca7cabe6f53136b8b49445bde11a1da5e760 Mon Sep 17 00:00:00 2001 From: goochul-im Date: Tue, 3 Feb 2026 16:34:12 +0900 Subject: [PATCH 08/16] =?UTF-8?q?test(Qdrant):=20QdrantClientAdpater=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../QdrantClientAdapterIntegrationTest.kt | 252 ++++++++++++++++++ 1 file changed, 252 insertions(+) create mode 100644 src/test/kotlin/b1a4/harudew/global/infrastructure/qdrant/QdrantClientAdapterIntegrationTest.kt diff --git a/src/test/kotlin/b1a4/harudew/global/infrastructure/qdrant/QdrantClientAdapterIntegrationTest.kt b/src/test/kotlin/b1a4/harudew/global/infrastructure/qdrant/QdrantClientAdapterIntegrationTest.kt new file mode 100644 index 0000000..4692a0e --- /dev/null +++ b/src/test/kotlin/b1a4/harudew/global/infrastructure/qdrant/QdrantClientAdapterIntegrationTest.kt @@ -0,0 +1,252 @@ +package b1a4.harudew.global.infrastructure.qdrant + +import b1a4.harudew.annotation.IntegrationTest +import b1a4.harudew.global.config.QdrantConfig +import b1a4.harudew.global.util.UuidGenerator +import io.qdrant.client.QdrantClient +import io.qdrant.client.grpc.Collections.Distance +import io.qdrant.client.grpc.Collections.VectorParams +import org.assertj.core.api.Assertions.assertThat +import org.awaitility.kotlin.await +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Import +import org.springframework.test.context.TestPropertySource +import java.time.Duration +import java.util.UUID + +@IntegrationTest +@SpringBootTest(classes = [QdrantClientAdapterIntegrationTest.TestConfig::class]) +@Import(QdrantConfig::class) +@TestPropertySource(properties = [ + "qdrant.host=goochul.iptime.org", + "qdrant.port=6334" +]) +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class QdrantClientAdapterIntegrationTest { + + @org.springframework.boot.test.context.TestConfiguration + class TestConfig { + @Bean + fun uuidGenerator(): UuidGenerator = object : UuidGenerator { + override fun generate(): UUID = UUID.randomUUID() + } + + @Bean + fun qdrantClientAdapter( + qdrantClient: QdrantClient, + uuidGenerator: UuidGenerator + ): QdrantClientAdapter = QdrantClientAdapter(qdrantClient, uuidGenerator) + } + + @Autowired + private lateinit var qdrantClient: QdrantClient + + @Autowired + private lateinit var adapter: QdrantClientAdapter + + companion object { + private const val TEST_COLLECTION_NAME = "qdrant_client_adapter_test" + private const val VECTOR_SIZE = 768 + } + + @BeforeAll + fun setup() { + // 테스트 컬렉션 생성 + val collections = qdrantClient.listCollectionsAsync().get() + if (!collections.contains(TEST_COLLECTION_NAME)) { + println("테스트 컬렉션 '$TEST_COLLECTION_NAME' 생성 중...") + val vectorParams = VectorParams.newBuilder() + .setSize(VECTOR_SIZE.toLong()) + .setDistance(Distance.Cosine) + .build() + + qdrantClient.createCollectionAsync(TEST_COLLECTION_NAME, vectorParams).get() + println("테스트 컬렉션 '$TEST_COLLECTION_NAME' 생성 완료") + } + } + + @AfterAll + fun cleanup() { + // 테스트 컬렉션 삭제 + println("테스트 컬렉션 '$TEST_COLLECTION_NAME' 삭제 중...") + try { + qdrantClient.deleteCollectionAsync(TEST_COLLECTION_NAME).get() + println("테스트 컬렉션 '$TEST_COLLECTION_NAME' 삭제 완료") + } catch (e: Exception) { + println("테스트 컬렉션 삭제 실패: ${e.message}") + } + } + + @Test + @DisplayName("포인트를 upsert하고 search로 조회할 수 있다") + fun `upsert and search integration test`() { + // given + val testMemberId = System.currentTimeMillis() + val testDiaryId = 1L + val testContent = "테스트 문장_${UUID.randomUUID()}" + val testVector = generateTestVector(VECTOR_SIZE) + + val points = listOf( + QdrantPointData( + vector = testVector, + payload = mapOf( + "memberId" to testMemberId, + "diaryId" to testDiaryId, + "content" to testContent + ) + ) + ) + + // when - upsert + adapter.upsertAsync(TEST_COLLECTION_NAME, points) + + // then - 비동기 저장 완료 대기 후 search로 검증 + await.atMost(Duration.ofSeconds(5)) + .pollInterval(Duration.ofMillis(500)) + .untilAsserted { + val searchQuery = QdrantSearchQuery( + collectionName = TEST_COLLECTION_NAME, + vector = testVector, + filters = mapOf("memberId" to testMemberId), + limit = 10 + ) + + val results = adapter.search(searchQuery) + + assertThat(results).isNotEmpty + assertThat(results.first().getString("content")).isEqualTo(testContent) + assertThat(results.first().getLong("diaryId")).isEqualTo(testDiaryId) + assertThat(results.first().score).isGreaterThan(0.99f) // 동일 벡터이므로 유사도 높음 + } + + println("upsert 및 search 테스트 성공!") + } + + @Test + @DisplayName("deleteByFilter로 특정 조건의 포인트를 삭제할 수 있다") + fun `deleteByFilter integration test`() { + // given - 먼저 데이터 삽입 + val testMemberId = System.currentTimeMillis() + 1000 // 다른 테스트와 구분 + val testDiaryId = 999L + val testVector = generateTestVector(VECTOR_SIZE) + + val points = listOf( + QdrantPointData( + vector = testVector, + payload = mapOf( + "memberId" to testMemberId, + "diaryId" to testDiaryId, + "content" to "삭제될 데이터" + ) + ) + ) + + adapter.upsertAsync(TEST_COLLECTION_NAME, points) + + // 데이터가 저장될 때까지 대기 + await.atMost(Duration.ofSeconds(5)) + .pollInterval(Duration.ofMillis(500)) + .untilAsserted { + val searchQuery = QdrantSearchQuery( + collectionName = TEST_COLLECTION_NAME, + vector = testVector, + filters = mapOf("memberId" to testMemberId), + limit = 10 + ) + val results = adapter.search(searchQuery) + assertThat(results).isNotEmpty + } + + println("삭제 전 데이터 확인 완료") + + // when - deleteByFilter 실행 + adapter.deleteByFilter(TEST_COLLECTION_NAME, mapOf("memberId" to testMemberId)) + + // then - 삭제 확인 + await.atMost(Duration.ofSeconds(5)) + .pollInterval(Duration.ofMillis(500)) + .untilAsserted { + val searchQuery = QdrantSearchQuery( + collectionName = TEST_COLLECTION_NAME, + vector = testVector, + filters = mapOf("memberId" to testMemberId), + limit = 10 + ) + val results = adapter.search(searchQuery) + assertThat(results).isEmpty() + } + + println("deleteByFilter 테스트 성공 - 데이터가 삭제됨!") + } + + @Test + @DisplayName("빈 포인트 리스트로 upsert해도 에러가 발생하지 않는다") + fun `upsert with empty list does not throw`() { + // when & then - 에러 없이 완료되어야 함 + adapter.upsertAsync(TEST_COLLECTION_NAME, emptyList()) + println("빈 리스트 upsert 테스트 성공!") + } + + @Test + @DisplayName("scoreThreshold를 설정하면 해당 점수 이상의 결과만 반환된다") + fun `search with scoreThreshold filters low score results`() { + // given + val testMemberId = System.currentTimeMillis() + 2000 + val testVector = generateTestVector(VECTOR_SIZE) + val differentVector = generateTestVector(VECTOR_SIZE) // 다른 벡터 + + val points = listOf( + QdrantPointData( + vector = testVector, + payload = mapOf( + "memberId" to testMemberId, + "content" to "원본 벡터 데이터" + ) + ) + ) + + adapter.upsertAsync(TEST_COLLECTION_NAME, points) + + await.atMost(Duration.ofSeconds(5)) + .pollInterval(Duration.ofMillis(500)) + .untilAsserted { + val searchQuery = QdrantSearchQuery( + collectionName = TEST_COLLECTION_NAME, + vector = testVector, + filters = mapOf("memberId" to testMemberId), + limit = 10 + ) + val results = adapter.search(searchQuery) + assertThat(results).isNotEmpty + } + + // when - 높은 scoreThreshold로 다른 벡터 검색 + val searchQueryWithHighThreshold = QdrantSearchQuery( + collectionName = TEST_COLLECTION_NAME, + vector = differentVector, + filters = mapOf("memberId" to testMemberId), + limit = 10, + scoreThreshold = 0.99f // 매우 높은 임계값 + ) + + val results = adapter.search(searchQueryWithHighThreshold) + + // then - 다른 벡터로 검색하면 높은 임계값 때문에 결과 없음 + assertThat(results).isEmpty() + println("scoreThreshold 테스트 성공!") + + // cleanup + adapter.deleteByFilter(TEST_COLLECTION_NAME, mapOf("memberId" to testMemberId)) + } + + private fun generateTestVector(dimension: Int): List { + return (1..dimension).map { Math.random() } + } +} From 14b9c940f36deb9f2ae6a6fa6a31f7f4efc34da9 Mon Sep 17 00:00:00 2001 From: goochul-im Date: Tue, 3 Feb 2026 17:23:06 +0900 Subject: [PATCH 09/16] =?UTF-8?q?chore:=20=ED=95=84=EC=9A=94=EC=97=86?= =?UTF-8?q?=EB=8A=94=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=ED=94=84=EB=A1=9C?= =?UTF-8?q?=ED=8D=BC=ED=8B=B0=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../qdrant/QdrantClientAdapterIntegrationTest.kt | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/test/kotlin/b1a4/harudew/global/infrastructure/qdrant/QdrantClientAdapterIntegrationTest.kt b/src/test/kotlin/b1a4/harudew/global/infrastructure/qdrant/QdrantClientAdapterIntegrationTest.kt index 4692a0e..d740150 100644 --- a/src/test/kotlin/b1a4/harudew/global/infrastructure/qdrant/QdrantClientAdapterIntegrationTest.kt +++ b/src/test/kotlin/b1a4/harudew/global/infrastructure/qdrant/QdrantClientAdapterIntegrationTest.kt @@ -24,10 +24,6 @@ import java.util.UUID @IntegrationTest @SpringBootTest(classes = [QdrantClientAdapterIntegrationTest.TestConfig::class]) @Import(QdrantConfig::class) -@TestPropertySource(properties = [ - "qdrant.host=goochul.iptime.org", - "qdrant.port=6334" -]) @TestInstance(TestInstance.Lifecycle.PER_CLASS) class QdrantClientAdapterIntegrationTest { From e49910c63af358681e49fdd817e650268faab317 Mon Sep 17 00:00:00 2001 From: goochul-im Date: Tue, 3 Feb 2026 17:43:58 +0900 Subject: [PATCH 10/16] =?UTF-8?q?refactor(Qdrant):=20Qdrant=20Adapter=20?= =?UTF-8?q?=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit QdrantClient를 사용하도록 변경 --- .../qdrant/QdrantKeywordVectorAdapter.kt | 85 +++++++------------ .../qdrant/QdrantSentenceAdapter.kt | 54 ++++++++++++ 2 files changed, 87 insertions(+), 52 deletions(-) create mode 100644 src/main/kotlin/b1a4/harudew/diary/adapter/out/persistence/qdrant/QdrantSentenceAdapter.kt diff --git a/src/main/kotlin/b1a4/harudew/diary/adapter/out/persistence/qdrant/QdrantKeywordVectorAdapter.kt b/src/main/kotlin/b1a4/harudew/diary/adapter/out/persistence/qdrant/QdrantKeywordVectorAdapter.kt index 4fdc30f..177eed2 100644 --- a/src/main/kotlin/b1a4/harudew/diary/adapter/out/persistence/qdrant/QdrantKeywordVectorAdapter.kt +++ b/src/main/kotlin/b1a4/harudew/diary/adapter/out/persistence/qdrant/QdrantKeywordVectorAdapter.kt @@ -1,68 +1,49 @@ package b1a4.harudew.diary.adapter.out.persistence.qdrant import b1a4.harudew.diary.application.port.out.vector.keyword.KeywordVectorPort -import b1a4.harudew.diary.application.port.out.vector.keyword.SaveKeywordReqeust +import b1a4.harudew.diary.application.port.out.vector.keyword.SaveKeywordRequest import b1a4.harudew.diary.application.port.out.vector.keyword.SearchKeywordQuery -import b1a4.harudew.diary.application.port.out.vector.keyword.SearchKeywordResult -import b1a4.harudew.global.util.UuidGenerator -import io.qdrant.client.PointIdFactory.id -import io.qdrant.client.QdrantClient -import io.qdrant.client.VectorsFactory.vectors -import io.qdrant.client.grpc.Points +import b1a4.harudew.diary.application.port.out.vector.keyword.SearchKeywordResponse +import b1a4.harudew.global.infrastructure.qdrant.QdrantClientPort +import b1a4.harudew.global.infrastructure.qdrant.QdrantPointData +import b1a4.harudew.global.infrastructure.qdrant.QdrantSearchQuery +import org.springframework.beans.factory.annotation.Value import org.springframework.stereotype.Component -import io.qdrant.client.ValueFactory.value -import io.qdrant.client.VectorFactory.vector -import io.qdrant.client.WithPayloadSelectorFactory.enable -import io.qdrant.client.ConditionFactory.match -import io.qdrant.client.grpc.Common @Component class QdrantKeywordVectorAdapter( - private val qdrantClient: QdrantClient, - private val uuidGenerator: UuidGenerator, - private val collectionName: String = "keyword" + private val qdrantClientPort: QdrantClientPort, + @Value("\${qdrant.collection.keyword_name}") + private val collectionName: String ) : KeywordVectorPort { - override fun save(request: SaveKeywordReqeust) { - val points = request.keywords.map { sentence -> - Points.PointStruct.newBuilder() - .setId(id(uuidGenerator.generate())) // 각 문장마다 새로운 ID - .setVectors(vectors(sentence.vector.map { num -> num.toFloat() })) - .putAllPayload( - mapOf( - "memberId" to value(request.authorId), - "diaryId" to value(request.diaryId), - "keyword" to value(sentence.content) - ) + override fun save(request: SaveKeywordRequest) { + val points = request.keywords.map { keyword -> + QdrantPointData( + vector = keyword.vector, + payload = mapOf( + "memberId" to request.authorId, + "diaryId" to request.diaryId, + "keyword" to keyword.content ) - .build() - } - - if (points.isNotEmpty()) { - qdrantClient.upsertAsync(collectionName, points) + ) } + qdrantClientPort.upsertAsync(collectionName, points) } - override fun searchByKeyword(query: SearchKeywordQuery): List { - - val filterCondition = Common.Filter.newBuilder() - .addMust(match("memberId", query.authorId)) - .build() - - val searchRequest = Points.SearchPoints.newBuilder() - .setCollectionName(collectionName) - .addAllVector(query.vector.map { it.toFloat() }) - .setFilter(filterCondition) - .setWithPayload(enable(true)) - .setLimit(10) - .build() - - val scoredPoints = qdrantClient.searchAsync(searchRequest).get() - - return scoredPoints.map { point -> SearchKeywordResult( - keyword = point.payloadMap["keyword"]?.stringValue ?: "", - diaryId = point.payloadMap["diaryId"]?.integerValue ?: 0L - ) } + override fun searchByKeyword(query: SearchKeywordQuery): List { + val searchQuery = QdrantSearchQuery( + collectionName = collectionName, + vector = query.vector, + filters = mapOf("memberId" to query.authorId), + limit = 10 + ) + + return qdrantClientPort.search(searchQuery).map { result -> + SearchKeywordResponse( + keyword = result.getString("keyword") ?: "", + diaryId = result.getLong("diaryId") ?: 0L + ) + } } - } diff --git a/src/main/kotlin/b1a4/harudew/diary/adapter/out/persistence/qdrant/QdrantSentenceAdapter.kt b/src/main/kotlin/b1a4/harudew/diary/adapter/out/persistence/qdrant/QdrantSentenceAdapter.kt new file mode 100644 index 0000000..a8d68b2 --- /dev/null +++ b/src/main/kotlin/b1a4/harudew/diary/adapter/out/persistence/qdrant/QdrantSentenceAdapter.kt @@ -0,0 +1,54 @@ +package b1a4.harudew.diary.adapter.out.persistence.qdrant + +import b1a4.harudew.diary.application.port.out.vector.sentence.SaveSentenceRequest +import b1a4.harudew.diary.application.port.out.vector.sentence.SearchSentenceQuery +import b1a4.harudew.diary.application.port.out.vector.sentence.SearchSentenceResponse +import b1a4.harudew.diary.application.port.out.vector.sentence.SentenceVectorPort +import b1a4.harudew.global.infrastructure.qdrant.QdrantClientPort +import b1a4.harudew.global.infrastructure.qdrant.QdrantPointData +import b1a4.harudew.global.infrastructure.qdrant.QdrantSearchQuery +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Component +import java.time.LocalDate + +@Component +class QdrantSentenceAdapter( + private val qdrantClientPort: QdrantClientPort, + @Value("\${qdrant.collection.sentence_name}") + private val collectionName: String +) : SentenceVectorPort { + + override fun save(request: SaveSentenceRequest) { + val points = request.sentences.map { sentence -> + QdrantPointData( + vector = sentence.vector, + payload = mapOf( + "memberId" to request.authorId, + "diary_Id" to request.diaryId, + "sentence" to sentence.content, + "date" to request.date + ) + ) + } + qdrantClientPort.upsertAsync(collectionName, points) + } + + override fun search(query: SearchSentenceQuery): List { + val searchQuery = QdrantSearchQuery( + collectionName = collectionName, + vector = query.vector, + filters = mapOf("memberId" to query.authorId), + limit = query.limit + ) + + return qdrantClientPort.search(searchQuery).map { result -> + SearchSentenceResponse( + sentence = result.getString("sentence") ?: "", + diaryId = result.getLong("diary_Id") ?: 0L, + date = result.getString("date")?.let { LocalDate.parse(it) } + ?: throw IllegalStateException("Missing date in sentence vector result"), + score = result.score + ) + } + } +} From 3d9fa5ea5353eaf7b85dd50ea2d3ac2ff372a105 Mon Sep 17 00:00:00 2001 From: goochul-im Date: Tue, 3 Feb 2026 17:45:18 +0900 Subject: [PATCH 11/16] =?UTF-8?q?feat(Diary):=20Rag=20=EA=B2=80=EC=83=89?= =?UTF-8?q?=EC=9D=84=20=EC=9C=84=ED=95=9C=20=EC=A0=84=EC=B2=98=EB=A6=AC=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DiaryEvnetHandler에서 DiaryCreateEvent를 감지하여 전처리를 하도록 작성 --- .../adapter/out/ai/DiaryTaggingAdapter.kt | 8 +++- .../out/embed/SentenceEmbedderAdapter.kt | 20 ++++++++++ .../application/port/in/DiaryRagUseCase.kt | 16 ++++++++ .../port/out/embed/SentenceEmbedderPort.kt | 9 +++++ .../port/out/tagging/DiaryTaggingPort.kt | 2 +- .../vector/sentence/SaveSentenceRequest.kt | 4 +- .../application/service/DiaryRagService.kt | 40 +++++++++++++++++++ .../diary/application/service/DiaryService.kt | 5 ++- .../diary/domain/event/DiaryCreateEvent.kt | 4 ++ .../global/orchestration/DiaryEventHandler.kt | 24 +++++++++++ 10 files changed, 127 insertions(+), 5 deletions(-) create mode 100644 src/main/kotlin/b1a4/harudew/diary/adapter/out/embed/SentenceEmbedderAdapter.kt create mode 100644 src/main/kotlin/b1a4/harudew/diary/application/port/in/DiaryRagUseCase.kt create mode 100644 src/main/kotlin/b1a4/harudew/diary/application/port/out/embed/SentenceEmbedderPort.kt create mode 100644 src/main/kotlin/b1a4/harudew/diary/application/service/DiaryRagService.kt create mode 100644 src/main/kotlin/b1a4/harudew/global/orchestration/DiaryEventHandler.kt diff --git a/src/main/kotlin/b1a4/harudew/diary/adapter/out/ai/DiaryTaggingAdapter.kt b/src/main/kotlin/b1a4/harudew/diary/adapter/out/ai/DiaryTaggingAdapter.kt index 59a8324..63a1a86 100644 --- a/src/main/kotlin/b1a4/harudew/diary/adapter/out/ai/DiaryTaggingAdapter.kt +++ b/src/main/kotlin/b1a4/harudew/diary/adapter/out/ai/DiaryTaggingAdapter.kt @@ -18,7 +18,7 @@ class DiaryTaggingAdapter( private val prompt: Resource ) : DiaryTaggingPort { - override fun tag(content: String): AiDiaryTaggingResponse? { + override fun tag(content: String): AiDiaryTaggingResponse { val template = PromptTemplate(prompt) val renderedPrompt = template.render(mapOf("content" to content)) @@ -31,7 +31,11 @@ class DiaryTaggingAdapter( ) try { - return aiClient.fetchEntity(renderedPrompt, request, AiDiaryTaggingResponse::class.java) + return aiClient.fetchEntity( + renderedPrompt, + request, + AiDiaryTaggingResponse::class.java) + ?: throw RuntimeException("Failed to fetch tag") } catch (e: Exception) { throw DiaryTaggingFailedException( e, diff --git a/src/main/kotlin/b1a4/harudew/diary/adapter/out/embed/SentenceEmbedderAdapter.kt b/src/main/kotlin/b1a4/harudew/diary/adapter/out/embed/SentenceEmbedderAdapter.kt new file mode 100644 index 0000000..52a87a5 --- /dev/null +++ b/src/main/kotlin/b1a4/harudew/diary/adapter/out/embed/SentenceEmbedderAdapter.kt @@ -0,0 +1,20 @@ +package b1a4.harudew.diary.adapter.out.embed + +import b1a4.harudew.diary.application.port.out.embed.SentenceEmbedderPort +import b1a4.harudew.global.infrastructure.embed.dual.DualEmbedClient +import b1a4.harudew.global.infrastructure.embed.dual.DualEmbedClientPort +import org.springframework.stereotype.Component + +@Component +class SentenceEmbedderAdapter( + private val dualEmbedClient: DualEmbedClientPort +) : SentenceEmbedderPort { + + override fun embedPassage(content: String): List { + TODO("Not yet implemented") + } + + override fun embedQuery(content: String): List { + TODO("Not yet implemented") + } +} diff --git a/src/main/kotlin/b1a4/harudew/diary/application/port/in/DiaryRagUseCase.kt b/src/main/kotlin/b1a4/harudew/diary/application/port/in/DiaryRagUseCase.kt new file mode 100644 index 0000000..7a784d0 --- /dev/null +++ b/src/main/kotlin/b1a4/harudew/diary/application/port/in/DiaryRagUseCase.kt @@ -0,0 +1,16 @@ +package b1a4.harudew.diary.application.port.`in` + +import java.time.LocalDate + +interface DiaryRagUseCase { + + fun preprocessing(command: DiaryRagPreprocessingCommand) + +} + +data class DiaryRagPreprocessingCommand( + val content: String, + val diaryId: Long, + val authorId: String, + val writtenDate: LocalDate +) diff --git a/src/main/kotlin/b1a4/harudew/diary/application/port/out/embed/SentenceEmbedderPort.kt b/src/main/kotlin/b1a4/harudew/diary/application/port/out/embed/SentenceEmbedderPort.kt new file mode 100644 index 0000000..c06b476 --- /dev/null +++ b/src/main/kotlin/b1a4/harudew/diary/application/port/out/embed/SentenceEmbedderPort.kt @@ -0,0 +1,9 @@ +package b1a4.harudew.diary.application.port.out.embed + +interface SentenceEmbedderPort { + + fun embedPassage(content: String) : List + + fun embedQuery(content: String) : List + +} diff --git a/src/main/kotlin/b1a4/harudew/diary/application/port/out/tagging/DiaryTaggingPort.kt b/src/main/kotlin/b1a4/harudew/diary/application/port/out/tagging/DiaryTaggingPort.kt index 9cf6ce3..a2fd61a 100644 --- a/src/main/kotlin/b1a4/harudew/diary/application/port/out/tagging/DiaryTaggingPort.kt +++ b/src/main/kotlin/b1a4/harudew/diary/application/port/out/tagging/DiaryTaggingPort.kt @@ -2,6 +2,6 @@ package b1a4.harudew.diary.application.port.out.tagging interface DiaryTaggingPort { - fun tag(content: String): AiDiaryTaggingResponse? + fun tag(content: String): AiDiaryTaggingResponse } diff --git a/src/main/kotlin/b1a4/harudew/diary/application/port/out/vector/sentence/SaveSentenceRequest.kt b/src/main/kotlin/b1a4/harudew/diary/application/port/out/vector/sentence/SaveSentenceRequest.kt index 10ebcd5..0625ad7 100644 --- a/src/main/kotlin/b1a4/harudew/diary/application/port/out/vector/sentence/SaveSentenceRequest.kt +++ b/src/main/kotlin/b1a4/harudew/diary/application/port/out/vector/sentence/SaveSentenceRequest.kt @@ -1,9 +1,11 @@ package b1a4.harudew.diary.application.port.out.vector.sentence import b1a4.harudew.diary.application.port.out.vector.ContentVectorWrapper +import java.time.LocalDate data class SaveSentenceRequest( val sentences: List, val diaryId: Long, - val authorId: Long + val authorId: String, + val date: LocalDate ) diff --git a/src/main/kotlin/b1a4/harudew/diary/application/service/DiaryRagService.kt b/src/main/kotlin/b1a4/harudew/diary/application/service/DiaryRagService.kt new file mode 100644 index 0000000..1218d7c --- /dev/null +++ b/src/main/kotlin/b1a4/harudew/diary/application/service/DiaryRagService.kt @@ -0,0 +1,40 @@ +package b1a4.harudew.diary.application.service + +import b1a4.harudew.diary.application.port.`in`.DiaryRagPreprocessingCommand +import b1a4.harudew.diary.application.port.`in`.DiaryRagUseCase +import b1a4.harudew.diary.application.port.out.analysis.DiaryAnalysisResponse +import b1a4.harudew.diary.application.port.out.chunk.DiaryChunkPort +import b1a4.harudew.diary.application.port.out.embed.SentenceEmbedderPort +import b1a4.harudew.diary.application.port.out.embed.SimpleEmbedderPort +import b1a4.harudew.diary.application.port.out.tagging.DiaryTaggingPort +import b1a4.harudew.diary.application.port.out.vector.ContentVectorWrapper +import b1a4.harudew.diary.application.port.out.vector.sentence.SaveSentenceRequest +import b1a4.harudew.diary.application.port.out.vector.sentence.SentenceVectorPort +import b1a4.harudew.global.infrastructure.embed.dual.DualEmbedClientPort +import org.springframework.stereotype.Component + +@Component +class DiaryRagService( + private val diaryChunkPort: DiaryChunkPort, + private val diaryTaggingPort: DiaryTaggingPort, + private val sentenceEmbedder: SentenceEmbedderPort, + private val sentenceVectorPort: SentenceVectorPort +) : DiaryRagUseCase { + + override fun preprocessing(command: DiaryRagPreprocessingCommand) { + val taggedContent = diaryTaggingPort.tag(command.content.trim()) + val chunkedContent = diaryChunkPort.chunk(taggedContent.result) + + sentenceVectorPort.save(SaveSentenceRequest( + sentences = chunkedContent.chunks.map { chunk -> + ContentVectorWrapper( + chunk, + sentenceEmbedder.embedPassage(chunk) + ) + }, + diaryId = command.diaryId, + authorId = command.authorId, + date = command.writtenDate + )) + } +} diff --git a/src/main/kotlin/b1a4/harudew/diary/application/service/DiaryService.kt b/src/main/kotlin/b1a4/harudew/diary/application/service/DiaryService.kt index 96d5320..d38d3e3 100644 --- a/src/main/kotlin/b1a4/harudew/diary/application/service/DiaryService.kt +++ b/src/main/kotlin/b1a4/harudew/diary/application/service/DiaryService.kt @@ -59,7 +59,10 @@ class DiaryService( eventPublisher.publish( DiaryCreateEvent( saveDiary.id!!, - analysisResult + command.content, + analysisResult = analysisResult, + authorId = authorId, + writtenDate = LocalDate.now(), ) ) } diff --git a/src/main/kotlin/b1a4/harudew/diary/domain/event/DiaryCreateEvent.kt b/src/main/kotlin/b1a4/harudew/diary/domain/event/DiaryCreateEvent.kt index f611a89..5752c80 100644 --- a/src/main/kotlin/b1a4/harudew/diary/domain/event/DiaryCreateEvent.kt +++ b/src/main/kotlin/b1a4/harudew/diary/domain/event/DiaryCreateEvent.kt @@ -2,9 +2,13 @@ package b1a4.harudew.diary.domain.event import b1a4.harudew.diary.application.port.out.analysis.DiaryAnalysisResponse import b1a4.harudew.global.event.DomainEvent +import java.time.LocalDate data class DiaryCreateEvent( val diaryId: Long, + val content: String, + val authorId: String, + val writtenDate: LocalDate, val analysisResult : DiaryAnalysisResponse ) : DomainEvent( aggregateType = "Diary", diff --git a/src/main/kotlin/b1a4/harudew/global/orchestration/DiaryEventHandler.kt b/src/main/kotlin/b1a4/harudew/global/orchestration/DiaryEventHandler.kt new file mode 100644 index 0000000..da1cd6d --- /dev/null +++ b/src/main/kotlin/b1a4/harudew/global/orchestration/DiaryEventHandler.kt @@ -0,0 +1,24 @@ +package b1a4.harudew.global.orchestration + +import b1a4.harudew.diary.application.port.`in`.DiaryRagUseCase +import b1a4.harudew.diary.domain.event.DiaryCreateEvent +import jakarta.transaction.Transactional +import org.springframework.stereotype.Component +import org.springframework.transaction.event.TransactionPhase +import org.springframework.transaction.event.TransactionalEventListener + +/** + * Diary에 관련된 부가 관심사를 이벤트 기반으로 분리해둔 클래스입니다 + * 각 이벤트에 맞게 적절한 도메인을 호출합니다 + */ +@Component +class DiaryEventHandler( + private val diaryRagUseCase: DiaryRagUseCase +) { + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + fun createHandler(event: DiaryCreateEvent) { + diaryRagUseCase.preprocessing(event.content) + } + +} From 69dc2694ee7207bbef073a7a7f54aa3d099a8979 Mon Sep 17 00:00:00 2001 From: goochul-im Date: Tue, 3 Feb 2026 17:46:51 +0900 Subject: [PATCH 12/16] =?UTF-8?q?refactor(DiaryEeventHandler):=20=EC=A0=84?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=20=EC=BB=A4=EB=A7=A8=EB=93=9C=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=EC=97=90=20=EB=94=B0=EB=A5=B8=20=EC=9D=B8=EC=88=98=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../harudew/global/orchestration/DiaryEventHandler.kt | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/b1a4/harudew/global/orchestration/DiaryEventHandler.kt b/src/main/kotlin/b1a4/harudew/global/orchestration/DiaryEventHandler.kt index da1cd6d..c1f4b26 100644 --- a/src/main/kotlin/b1a4/harudew/global/orchestration/DiaryEventHandler.kt +++ b/src/main/kotlin/b1a4/harudew/global/orchestration/DiaryEventHandler.kt @@ -1,5 +1,6 @@ package b1a4.harudew.global.orchestration +import b1a4.harudew.diary.application.port.`in`.DiaryRagPreprocessingCommand import b1a4.harudew.diary.application.port.`in`.DiaryRagUseCase import b1a4.harudew.diary.domain.event.DiaryCreateEvent import jakarta.transaction.Transactional @@ -18,7 +19,12 @@ class DiaryEventHandler( @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) fun createHandler(event: DiaryCreateEvent) { - diaryRagUseCase.preprocessing(event.content) + diaryRagUseCase.preprocessing(DiaryRagPreprocessingCommand( + content = event.content, + diaryId = event.diaryId, + authorId = event.authorId, + writtenDate = event.writtenDate + )) } } From 1d4772c6271b98fbc3e305e6501f2a33f0f5ce0a Mon Sep 17 00:00:00 2001 From: goochul-im Date: Fri, 6 Feb 2026 21:43:54 +0900 Subject: [PATCH 13/16] =?UTF-8?q?feat(DiaryPreprocessingService):=20?= =?UTF-8?q?=EB=8B=A4=EC=9D=B4=EC=96=B4=EB=A6=AC=20=EA=B2=80=EC=83=89=20?= =?UTF-8?q?=EC=8B=9C=20rag=EC=99=80=20=ED=82=A4=EC=9B=8C=EB=93=9C=20?= =?UTF-8?q?=EA=B2=80=EC=83=89=EC=9D=84=20=EC=9C=84=ED=95=9C=20=EC=A0=84?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 코루틴을 이용하여 동시에 여러 IO 작업을 수행할 수 있도록 설정 --- .sisyphus/ultrawork-state.json | 7 -- .../diary/adapter/in/web/DiaryController.kt | 10 +++ ...ter.kt => DiaryKeywordExtractorAdapter.kt} | 9 +-- ...erAdapter.kt => KeywordEmbedderAdapter.kt} | 6 +- .../port/in/DiaryPreprocessingUseCase.kt | 23 ++++++ .../application/port/in/DiaryRagUseCase.kt | 16 ----- ...EmbedderPort.kt => KeywordEmbedderPort.kt} | 2 +- .../out/extract/DiaryKeywordExtracterPort.kt | 7 -- .../out/extract/DiaryKeywordExtractorPort.kt | 7 ++ .../out/vector/keyword/SaveKeywordRequest.kt | 2 +- .../service/DiaryPreprocessingService.kt | 70 +++++++++++++++++++ .../application/service/DiaryRagService.kt | 40 ----------- .../global/orchestration/DiaryEventHandler.kt | 19 ++--- ...yKeywordExtracterAdapterIntegrationTest.kt | 3 +- .../out/embed/SimpleEmbedderAdapterTest.kt | 2 +- .../service/DiaryRagServiceTest.kt | 7 ++ 16 files changed, 139 insertions(+), 91 deletions(-) delete mode 100644 .sisyphus/ultrawork-state.json create mode 100644 src/main/kotlin/b1a4/harudew/diary/adapter/in/web/DiaryController.kt rename src/main/kotlin/b1a4/harudew/diary/adapter/out/ai/{DiaryKeywordExtracterAdapter.kt => DiaryKeywordExtractorAdapter.kt} (89%) rename src/main/kotlin/b1a4/harudew/diary/adapter/out/embed/{SimpleEmbedderAdapter.kt => KeywordEmbedderAdapter.kt} (82%) create mode 100644 src/main/kotlin/b1a4/harudew/diary/application/port/in/DiaryPreprocessingUseCase.kt delete mode 100644 src/main/kotlin/b1a4/harudew/diary/application/port/in/DiaryRagUseCase.kt rename src/main/kotlin/b1a4/harudew/diary/application/port/out/embed/{SimpleEmbedderPort.kt => KeywordEmbedderPort.kt} (76%) delete mode 100644 src/main/kotlin/b1a4/harudew/diary/application/port/out/extract/DiaryKeywordExtracterPort.kt create mode 100644 src/main/kotlin/b1a4/harudew/diary/application/port/out/extract/DiaryKeywordExtractorPort.kt create mode 100644 src/main/kotlin/b1a4/harudew/diary/application/service/DiaryPreprocessingService.kt delete mode 100644 src/main/kotlin/b1a4/harudew/diary/application/service/DiaryRagService.kt create mode 100644 src/test/kotlin/b1a4/harudew/diary/application/service/DiaryRagServiceTest.kt diff --git a/.sisyphus/ultrawork-state.json b/.sisyphus/ultrawork-state.json deleted file mode 100644 index 01affa0..0000000 --- a/.sisyphus/ultrawork-state.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "active": true, - "started_at": "2026-01-17T01:23:38.411Z", - "original_prompt": "/oh-my-claude-sisyphus:ultrawork ", - "reinforcement_count": 4, - "last_checked_at": "2026-01-17T01:44:25.900Z" -} \ No newline at end of file diff --git a/src/main/kotlin/b1a4/harudew/diary/adapter/in/web/DiaryController.kt b/src/main/kotlin/b1a4/harudew/diary/adapter/in/web/DiaryController.kt new file mode 100644 index 0000000..c514849 --- /dev/null +++ b/src/main/kotlin/b1a4/harudew/diary/adapter/in/web/DiaryController.kt @@ -0,0 +1,10 @@ +package b1a4.harudew.diary.adapter.`in`.web + +import org.springframework.stereotype.Controller +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/diary") +class DiaryController { +} diff --git a/src/main/kotlin/b1a4/harudew/diary/adapter/out/ai/DiaryKeywordExtracterAdapter.kt b/src/main/kotlin/b1a4/harudew/diary/adapter/out/ai/DiaryKeywordExtractorAdapter.kt similarity index 89% rename from src/main/kotlin/b1a4/harudew/diary/adapter/out/ai/DiaryKeywordExtracterAdapter.kt rename to src/main/kotlin/b1a4/harudew/diary/adapter/out/ai/DiaryKeywordExtractorAdapter.kt index f0a6d22..322e6f2 100644 --- a/src/main/kotlin/b1a4/harudew/diary/adapter/out/ai/DiaryKeywordExtracterAdapter.kt +++ b/src/main/kotlin/b1a4/harudew/diary/adapter/out/ai/DiaryKeywordExtractorAdapter.kt @@ -1,7 +1,7 @@ package b1a4.harudew.diary.adapter.out.ai import b1a4.harudew.diary.adapter.exception.DiaryAnalysisFailedException -import b1a4.harudew.diary.application.port.out.extract.DiaryKeywordExtracterPort +import b1a4.harudew.diary.application.port.out.extract.DiaryKeywordExtractorPort import b1a4.harudew.diary.application.port.out.extract.KeywordExtractResponse import b1a4.harudew.global.infrastructure.ai.AiClientPort import b1a4.harudew.global.infrastructure.ai.AiModelRequest @@ -11,13 +11,13 @@ import org.springframework.core.io.Resource import org.springframework.stereotype.Component @Component -class DiaryKeywordExtracterAdapter( +class DiaryKeywordExtractorAdapter( private val aiClient: AiClientPort, @Value("classpath:/prompts/keyword-extract.st") private val prompt: Resource -) : DiaryKeywordExtracterPort { +) : DiaryKeywordExtractorPort { - override fun extract(content: String): KeywordExtractResponse? { + override fun extract(content: String): KeywordExtractResponse { val template = PromptTemplate(prompt) val renderedPrompt = template.render(mapOf("content" to content)) @@ -31,6 +31,7 @@ class DiaryKeywordExtracterAdapter( try { return aiClient.fetchEntity(renderedPrompt, request, KeywordExtractResponse::class.java) + ?: throw RuntimeException("Could not extract response from AiModel") } catch (e: Exception) { throw DiaryAnalysisFailedException( e, diff --git a/src/main/kotlin/b1a4/harudew/diary/adapter/out/embed/SimpleEmbedderAdapter.kt b/src/main/kotlin/b1a4/harudew/diary/adapter/out/embed/KeywordEmbedderAdapter.kt similarity index 82% rename from src/main/kotlin/b1a4/harudew/diary/adapter/out/embed/SimpleEmbedderAdapter.kt rename to src/main/kotlin/b1a4/harudew/diary/adapter/out/embed/KeywordEmbedderAdapter.kt index 493ca0a..aff1ab4 100644 --- a/src/main/kotlin/b1a4/harudew/diary/adapter/out/embed/SimpleEmbedderAdapter.kt +++ b/src/main/kotlin/b1a4/harudew/diary/adapter/out/embed/KeywordEmbedderAdapter.kt @@ -1,14 +1,14 @@ package b1a4.harudew.diary.adapter.out.embed import b1a4.harudew.diary.adapter.exception.EmbeddingFailedException -import b1a4.harudew.diary.application.port.out.embed.SimpleEmbedderPort +import b1a4.harudew.diary.application.port.out.embed.KeywordEmbedderPort import b1a4.harudew.global.infrastructure.embed.EmbedClientPort import org.springframework.stereotype.Component @Component -class SimpleEmbedderAdapter( +class KeywordEmbedderAdapter( private val embedClient: EmbedClientPort -) : SimpleEmbedderPort { +) : KeywordEmbedderPort { override fun embed(content: String): List { try { diff --git a/src/main/kotlin/b1a4/harudew/diary/application/port/in/DiaryPreprocessingUseCase.kt b/src/main/kotlin/b1a4/harudew/diary/application/port/in/DiaryPreprocessingUseCase.kt new file mode 100644 index 0000000..7abd8af --- /dev/null +++ b/src/main/kotlin/b1a4/harudew/diary/application/port/in/DiaryPreprocessingUseCase.kt @@ -0,0 +1,23 @@ +package b1a4.harudew.diary.application.port.`in` + +import java.time.LocalDate + +interface DiaryPreprocessingUseCase { + + fun ragPreprocessing(command: DiaryRagPreprocessingCommand) + fun keywordPreprocessing(command: DiaryKeywordPreprocessingCommand) + +} + +data class DiaryKeywordPreprocessingCommand( + val content: String, + val diaryId: Long, + val authorId: String +) + +data class DiaryRagPreprocessingCommand( + val content: String, + val diaryId: Long, + val authorId: String, + val writtenDate: LocalDate +) diff --git a/src/main/kotlin/b1a4/harudew/diary/application/port/in/DiaryRagUseCase.kt b/src/main/kotlin/b1a4/harudew/diary/application/port/in/DiaryRagUseCase.kt deleted file mode 100644 index 7a784d0..0000000 --- a/src/main/kotlin/b1a4/harudew/diary/application/port/in/DiaryRagUseCase.kt +++ /dev/null @@ -1,16 +0,0 @@ -package b1a4.harudew.diary.application.port.`in` - -import java.time.LocalDate - -interface DiaryRagUseCase { - - fun preprocessing(command: DiaryRagPreprocessingCommand) - -} - -data class DiaryRagPreprocessingCommand( - val content: String, - val diaryId: Long, - val authorId: String, - val writtenDate: LocalDate -) diff --git a/src/main/kotlin/b1a4/harudew/diary/application/port/out/embed/SimpleEmbedderPort.kt b/src/main/kotlin/b1a4/harudew/diary/application/port/out/embed/KeywordEmbedderPort.kt similarity index 76% rename from src/main/kotlin/b1a4/harudew/diary/application/port/out/embed/SimpleEmbedderPort.kt rename to src/main/kotlin/b1a4/harudew/diary/application/port/out/embed/KeywordEmbedderPort.kt index 1f47128..496410e 100644 --- a/src/main/kotlin/b1a4/harudew/diary/application/port/out/embed/SimpleEmbedderPort.kt +++ b/src/main/kotlin/b1a4/harudew/diary/application/port/out/embed/KeywordEmbedderPort.kt @@ -1,6 +1,6 @@ package b1a4.harudew.diary.application.port.out.embed -interface SimpleEmbedderPort { +interface KeywordEmbedderPort { fun embed(content: String): List diff --git a/src/main/kotlin/b1a4/harudew/diary/application/port/out/extract/DiaryKeywordExtracterPort.kt b/src/main/kotlin/b1a4/harudew/diary/application/port/out/extract/DiaryKeywordExtracterPort.kt deleted file mode 100644 index f91bc83..0000000 --- a/src/main/kotlin/b1a4/harudew/diary/application/port/out/extract/DiaryKeywordExtracterPort.kt +++ /dev/null @@ -1,7 +0,0 @@ -package b1a4.harudew.diary.application.port.out.extract - -interface DiaryKeywordExtracterPort { - - fun extract(content: String): KeywordExtractResponse? - -} diff --git a/src/main/kotlin/b1a4/harudew/diary/application/port/out/extract/DiaryKeywordExtractorPort.kt b/src/main/kotlin/b1a4/harudew/diary/application/port/out/extract/DiaryKeywordExtractorPort.kt new file mode 100644 index 0000000..87e089b --- /dev/null +++ b/src/main/kotlin/b1a4/harudew/diary/application/port/out/extract/DiaryKeywordExtractorPort.kt @@ -0,0 +1,7 @@ +package b1a4.harudew.diary.application.port.out.extract + +interface DiaryKeywordExtractorPort { + + fun extract(content: String): KeywordExtractResponse + +} diff --git a/src/main/kotlin/b1a4/harudew/diary/application/port/out/vector/keyword/SaveKeywordRequest.kt b/src/main/kotlin/b1a4/harudew/diary/application/port/out/vector/keyword/SaveKeywordRequest.kt index 4158621..7371d49 100644 --- a/src/main/kotlin/b1a4/harudew/diary/application/port/out/vector/keyword/SaveKeywordRequest.kt +++ b/src/main/kotlin/b1a4/harudew/diary/application/port/out/vector/keyword/SaveKeywordRequest.kt @@ -5,5 +5,5 @@ import b1a4.harudew.diary.application.port.out.vector.ContentVectorWrapper data class SaveKeywordRequest( val keywords: List, val diaryId: Long, - val authorId: Long + val authorId: String ) diff --git a/src/main/kotlin/b1a4/harudew/diary/application/service/DiaryPreprocessingService.kt b/src/main/kotlin/b1a4/harudew/diary/application/service/DiaryPreprocessingService.kt new file mode 100644 index 0000000..23e4dc0 --- /dev/null +++ b/src/main/kotlin/b1a4/harudew/diary/application/service/DiaryPreprocessingService.kt @@ -0,0 +1,70 @@ +package b1a4.harudew.diary.application.service + +import b1a4.harudew.diary.application.port.`in`.DiaryKeywordPreprocessingCommand +import b1a4.harudew.diary.application.port.`in`.DiaryRagPreprocessingCommand +import b1a4.harudew.diary.application.port.`in`.DiaryPreprocessingUseCase +import b1a4.harudew.diary.application.port.out.chunk.DiaryChunkPort +import b1a4.harudew.diary.application.port.out.embed.SentenceEmbedderPort +import b1a4.harudew.diary.application.port.out.embed.KeywordEmbedderPort +import b1a4.harudew.diary.application.port.out.extract.DiaryKeywordExtractorPort +import b1a4.harudew.diary.application.port.out.tagging.DiaryTaggingPort +import b1a4.harudew.diary.application.port.out.vector.ContentVectorWrapper +import b1a4.harudew.diary.application.port.out.vector.keyword.KeywordVectorPort +import b1a4.harudew.diary.application.port.out.vector.keyword.SaveKeywordRequest +import b1a4.harudew.diary.application.port.out.vector.sentence.SaveSentenceRequest +import b1a4.harudew.diary.application.port.out.vector.sentence.SentenceVectorPort +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.runBlocking +import org.springframework.stereotype.Component + +@Component +class DiaryPreprocessingService( + private val diaryChunkPort: DiaryChunkPort, + private val diaryTaggingPort: DiaryTaggingPort, + private val sentenceEmbedder: SentenceEmbedderPort, + private val sentenceVectorPort: SentenceVectorPort, + private val diaryKeywordExtractor: DiaryKeywordExtractorPort, + private val keywordEmbedder: KeywordEmbedderPort, + private val keywordVectorPort: KeywordVectorPort +) : DiaryPreprocessingUseCase { + + override fun ragPreprocessing(command: DiaryRagPreprocessingCommand) { + val taggedContent = diaryTaggingPort.tag(command.content.trim()) + val chunkedContent = diaryChunkPort.chunk(taggedContent.result) + + val sentences = runBlocking { + chunkedContent.chunks.map { chunk -> + async(Dispatchers.IO) { + ContentVectorWrapper(chunk, sentenceEmbedder.embedPassage(chunk)) + } + }.awaitAll() + } + + sentenceVectorPort.save(SaveSentenceRequest( + sentences = sentences, + diaryId = command.diaryId, + authorId = command.authorId, + date = command.writtenDate + )) + } + + override fun keywordPreprocessing(command: DiaryKeywordPreprocessingCommand) { + val extractResult = diaryKeywordExtractor.extract(command.content) + + val keywords = runBlocking { + extractResult.keywords.map { keyword -> + async(Dispatchers.IO) { + ContentVectorWrapper(keyword, keywordEmbedder.embed(keyword)) + } + }.awaitAll() + } + + keywordVectorPort.save(SaveKeywordRequest( + keywords = keywords, + diaryId = command.diaryId, + authorId = command.authorId + )) + } +} diff --git a/src/main/kotlin/b1a4/harudew/diary/application/service/DiaryRagService.kt b/src/main/kotlin/b1a4/harudew/diary/application/service/DiaryRagService.kt deleted file mode 100644 index 1218d7c..0000000 --- a/src/main/kotlin/b1a4/harudew/diary/application/service/DiaryRagService.kt +++ /dev/null @@ -1,40 +0,0 @@ -package b1a4.harudew.diary.application.service - -import b1a4.harudew.diary.application.port.`in`.DiaryRagPreprocessingCommand -import b1a4.harudew.diary.application.port.`in`.DiaryRagUseCase -import b1a4.harudew.diary.application.port.out.analysis.DiaryAnalysisResponse -import b1a4.harudew.diary.application.port.out.chunk.DiaryChunkPort -import b1a4.harudew.diary.application.port.out.embed.SentenceEmbedderPort -import b1a4.harudew.diary.application.port.out.embed.SimpleEmbedderPort -import b1a4.harudew.diary.application.port.out.tagging.DiaryTaggingPort -import b1a4.harudew.diary.application.port.out.vector.ContentVectorWrapper -import b1a4.harudew.diary.application.port.out.vector.sentence.SaveSentenceRequest -import b1a4.harudew.diary.application.port.out.vector.sentence.SentenceVectorPort -import b1a4.harudew.global.infrastructure.embed.dual.DualEmbedClientPort -import org.springframework.stereotype.Component - -@Component -class DiaryRagService( - private val diaryChunkPort: DiaryChunkPort, - private val diaryTaggingPort: DiaryTaggingPort, - private val sentenceEmbedder: SentenceEmbedderPort, - private val sentenceVectorPort: SentenceVectorPort -) : DiaryRagUseCase { - - override fun preprocessing(command: DiaryRagPreprocessingCommand) { - val taggedContent = diaryTaggingPort.tag(command.content.trim()) - val chunkedContent = diaryChunkPort.chunk(taggedContent.result) - - sentenceVectorPort.save(SaveSentenceRequest( - sentences = chunkedContent.chunks.map { chunk -> - ContentVectorWrapper( - chunk, - sentenceEmbedder.embedPassage(chunk) - ) - }, - diaryId = command.diaryId, - authorId = command.authorId, - date = command.writtenDate - )) - } -} diff --git a/src/main/kotlin/b1a4/harudew/global/orchestration/DiaryEventHandler.kt b/src/main/kotlin/b1a4/harudew/global/orchestration/DiaryEventHandler.kt index c1f4b26..3bcd6d3 100644 --- a/src/main/kotlin/b1a4/harudew/global/orchestration/DiaryEventHandler.kt +++ b/src/main/kotlin/b1a4/harudew/global/orchestration/DiaryEventHandler.kt @@ -1,9 +1,8 @@ package b1a4.harudew.global.orchestration import b1a4.harudew.diary.application.port.`in`.DiaryRagPreprocessingCommand -import b1a4.harudew.diary.application.port.`in`.DiaryRagUseCase +import b1a4.harudew.diary.application.port.`in`.DiaryPreprocessingUseCase import b1a4.harudew.diary.domain.event.DiaryCreateEvent -import jakarta.transaction.Transactional import org.springframework.stereotype.Component import org.springframework.transaction.event.TransactionPhase import org.springframework.transaction.event.TransactionalEventListener @@ -14,17 +13,19 @@ import org.springframework.transaction.event.TransactionalEventListener */ @Component class DiaryEventHandler( - private val diaryRagUseCase: DiaryRagUseCase + private val diaryPreprocessingUseCase: DiaryPreprocessingUseCase ) { @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) fun createHandler(event: DiaryCreateEvent) { - diaryRagUseCase.preprocessing(DiaryRagPreprocessingCommand( - content = event.content, - diaryId = event.diaryId, - authorId = event.authorId, - writtenDate = event.writtenDate - )) + diaryPreprocessingUseCase.ragPreprocessing( + DiaryRagPreprocessingCommand( + content = event.content, + diaryId = event.diaryId, + authorId = event.authorId, + writtenDate = event.writtenDate + ) + ) } } diff --git a/src/test/kotlin/b1a4/harudew/diary/adapter/out/ai/DiaryKeywordExtracterAdapterIntegrationTest.kt b/src/test/kotlin/b1a4/harudew/diary/adapter/out/ai/DiaryKeywordExtracterAdapterIntegrationTest.kt index 3370048..75e8df6 100644 --- a/src/test/kotlin/b1a4/harudew/diary/adapter/out/ai/DiaryKeywordExtracterAdapterIntegrationTest.kt +++ b/src/test/kotlin/b1a4/harudew/diary/adapter/out/ai/DiaryKeywordExtracterAdapterIntegrationTest.kt @@ -2,7 +2,6 @@ package b1a4.harudew.diary.adapter.out.ai import b1a4.harudew.annotation.IntegrationTest import org.assertj.core.api.Assertions.assertThat -import org.junit.jupiter.api.Tag import org.junit.jupiter.api.Test import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.context.SpringBootTest @@ -12,7 +11,7 @@ import org.springframework.boot.test.context.SpringBootTest class DiaryKeywordExtracterAdapterIntegrationTest { @Autowired - lateinit var diaryKeywordExtracterAdapter: DiaryKeywordExtracterAdapter + lateinit var diaryKeywordExtracterAdapter: DiaryKeywordExtractorAdapter @Test fun `주어진 내용을 단어 단위로 추출할 수 있다`(){ diff --git a/src/test/kotlin/b1a4/harudew/diary/adapter/out/embed/SimpleEmbedderAdapterTest.kt b/src/test/kotlin/b1a4/harudew/diary/adapter/out/embed/SimpleEmbedderAdapterTest.kt index f194b7d..c6d38dd 100644 --- a/src/test/kotlin/b1a4/harudew/diary/adapter/out/embed/SimpleEmbedderAdapterTest.kt +++ b/src/test/kotlin/b1a4/harudew/diary/adapter/out/embed/SimpleEmbedderAdapterTest.kt @@ -19,7 +19,7 @@ class SimpleEmbedderAdapterTest { lateinit var embedClient: EmbedClientPort @InjectMocks - lateinit var simpleEmbedderAdapter: SimpleEmbedderAdapter + lateinit var simpleEmbedderAdapter: KeywordEmbedderAdapter @Test @DisplayName("임베딩 요청 시 클라이언트를 호출하여 결과를 반환한다") diff --git a/src/test/kotlin/b1a4/harudew/diary/application/service/DiaryRagServiceTest.kt b/src/test/kotlin/b1a4/harudew/diary/application/service/DiaryRagServiceTest.kt new file mode 100644 index 0000000..037c184 --- /dev/null +++ b/src/test/kotlin/b1a4/harudew/diary/application/service/DiaryRagServiceTest.kt @@ -0,0 +1,7 @@ +package b1a4.harudew.diary.application.service + +import org.junit.jupiter.api.Assertions.* + +class DiaryRagServiceTest { + +} From dc781d8bbbdd2a78aa630e4b802f874a9f89882b Mon Sep 17 00:00:00 2001 From: goochul-im Date: Fri, 6 Feb 2026 21:54:57 +0900 Subject: [PATCH 14/16] =?UTF-8?q?refactor:=20authorId=20=ED=98=95=EC=8B=9D?= =?UTF-8?q?=20=EB=A7=9E=EC=B6=A4=EC=97=90=20=EB=94=B0=EB=9D=BC=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../port/out/vector/keyword/SearchKeywordQuery.kt | 2 +- .../port/out/vector/sentence/SearchSentenceQuery.kt | 2 +- .../global/infrastructure/qdrant/QdrantClientAdapter.kt | 3 +++ .../persistence/qdrant/QdrantKeywordVectorAdapterTest.kt | 2 +- .../diary/application/service/DiaryRagServiceTest.kt | 7 ------- 5 files changed, 6 insertions(+), 10 deletions(-) delete mode 100644 src/test/kotlin/b1a4/harudew/diary/application/service/DiaryRagServiceTest.kt diff --git a/src/main/kotlin/b1a4/harudew/diary/application/port/out/vector/keyword/SearchKeywordQuery.kt b/src/main/kotlin/b1a4/harudew/diary/application/port/out/vector/keyword/SearchKeywordQuery.kt index e2974ea..61f96d2 100644 --- a/src/main/kotlin/b1a4/harudew/diary/application/port/out/vector/keyword/SearchKeywordQuery.kt +++ b/src/main/kotlin/b1a4/harudew/diary/application/port/out/vector/keyword/SearchKeywordQuery.kt @@ -4,5 +4,5 @@ data class SearchKeywordQuery( val keyword: String, val diaryId: Long, val vector: List, - val authorId: Long + val authorId: String ) diff --git a/src/main/kotlin/b1a4/harudew/diary/application/port/out/vector/sentence/SearchSentenceQuery.kt b/src/main/kotlin/b1a4/harudew/diary/application/port/out/vector/sentence/SearchSentenceQuery.kt index 93272d6..263a173 100644 --- a/src/main/kotlin/b1a4/harudew/diary/application/port/out/vector/sentence/SearchSentenceQuery.kt +++ b/src/main/kotlin/b1a4/harudew/diary/application/port/out/vector/sentence/SearchSentenceQuery.kt @@ -2,6 +2,6 @@ package b1a4.harudew.diary.application.port.out.vector.sentence data class SearchSentenceQuery( val vector: List, - val authorId: Long, + val authorId: String, val limit: Int = 5 ) diff --git a/src/main/kotlin/b1a4/harudew/global/infrastructure/qdrant/QdrantClientAdapter.kt b/src/main/kotlin/b1a4/harudew/global/infrastructure/qdrant/QdrantClientAdapter.kt index c35b83d..8f596c6 100644 --- a/src/main/kotlin/b1a4/harudew/global/infrastructure/qdrant/QdrantClientAdapter.kt +++ b/src/main/kotlin/b1a4/harudew/global/infrastructure/qdrant/QdrantClientAdapter.kt @@ -2,6 +2,7 @@ package b1a4.harudew.global.infrastructure.qdrant import b1a4.harudew.global.util.UuidGenerator import io.qdrant.client.ConditionFactory.match +import io.qdrant.client.ConditionFactory.matchKeyword import io.qdrant.client.PointIdFactory.id import io.qdrant.client.QdrantClient import io.qdrant.client.VectorsFactory.vectors @@ -44,6 +45,7 @@ class QdrantClientAdapter( val filterBuilder = Common.Filter.newBuilder() query.filters.forEach { (key, value) -> when (value) { + is String -> filterBuilder.addMust(matchKeyword(key, value)) is Long -> filterBuilder.addMust(match(key, value)) is Int -> filterBuilder.addMust(match(key, value.toLong())) } @@ -70,6 +72,7 @@ class QdrantClientAdapter( val filterBuilder = Common.Filter.newBuilder() filter.forEach { (key, value) -> when (value) { + is String -> filterBuilder.addMust(matchKeyword(key, value)) is Long -> filterBuilder.addMust(match(key, value)) is Int -> filterBuilder.addMust(match(key, value.toLong())) } diff --git a/src/test/kotlin/b1a4/harudew/diary/adapter/out/persistence/qdrant/QdrantKeywordVectorAdapterTest.kt b/src/test/kotlin/b1a4/harudew/diary/adapter/out/persistence/qdrant/QdrantKeywordVectorAdapterTest.kt index b5b37f9..3c06d7a 100644 --- a/src/test/kotlin/b1a4/harudew/diary/adapter/out/persistence/qdrant/QdrantKeywordVectorAdapterTest.kt +++ b/src/test/kotlin/b1a4/harudew/diary/adapter/out/persistence/qdrant/QdrantKeywordVectorAdapterTest.kt @@ -84,7 +84,7 @@ class QdrantKeywordVectorAdapterTest { @DisplayName("키워드를 Qdrant에 저장하고 검색할 수 있다") fun `키워드 저장 및 검색 통합 테스트`() { // given - val testAuthorId = System.currentTimeMillis() + val testAuthorId = "test-author-${System.currentTimeMillis()}" val testDiaryId = 1L val testKeyword = "테스트키워드_${UUID.randomUUID()}" val testVector = generateTestVector(768) diff --git a/src/test/kotlin/b1a4/harudew/diary/application/service/DiaryRagServiceTest.kt b/src/test/kotlin/b1a4/harudew/diary/application/service/DiaryRagServiceTest.kt deleted file mode 100644 index 037c184..0000000 --- a/src/test/kotlin/b1a4/harudew/diary/application/service/DiaryRagServiceTest.kt +++ /dev/null @@ -1,7 +0,0 @@ -package b1a4.harudew.diary.application.service - -import org.junit.jupiter.api.Assertions.* - -class DiaryRagServiceTest { - -} From 8fb4473f6b6711e05d374cc5b43a491b7bb848ae Mon Sep 17 00:00:00 2001 From: goochul-im Date: Fri, 6 Feb 2026 21:56:23 +0900 Subject: [PATCH 15/16] =?UTF-8?q?test:=20DiaryPreprocessingServiceTest=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/DiaryPreprocessingServiceTest.kt | 200 ++++++++++++++++++ 1 file changed, 200 insertions(+) create mode 100644 src/test/kotlin/b1a4/harudew/diary/application/service/DiaryPreprocessingServiceTest.kt diff --git a/src/test/kotlin/b1a4/harudew/diary/application/service/DiaryPreprocessingServiceTest.kt b/src/test/kotlin/b1a4/harudew/diary/application/service/DiaryPreprocessingServiceTest.kt new file mode 100644 index 0000000..01e5612 --- /dev/null +++ b/src/test/kotlin/b1a4/harudew/diary/application/service/DiaryPreprocessingServiceTest.kt @@ -0,0 +1,200 @@ +package b1a4.harudew.diary.application.service + +import b1a4.harudew.diary.application.port.`in`.DiaryKeywordPreprocessingCommand +import b1a4.harudew.diary.application.port.`in`.DiaryRagPreprocessingCommand +import b1a4.harudew.diary.application.port.out.chunk.ChunkResponse +import b1a4.harudew.diary.application.port.out.chunk.DiaryChunkPort +import b1a4.harudew.diary.application.port.out.embed.KeywordEmbedderPort +import b1a4.harudew.diary.application.port.out.embed.SentenceEmbedderPort +import b1a4.harudew.diary.application.port.out.extract.DiaryKeywordExtractorPort +import b1a4.harudew.diary.application.port.out.extract.KeywordExtractResponse +import b1a4.harudew.diary.application.port.out.tagging.AiDiaryTaggingResponse +import b1a4.harudew.diary.application.port.out.tagging.DiaryTaggingPort +import b1a4.harudew.diary.application.port.out.vector.keyword.KeywordVectorPort +import b1a4.harudew.diary.application.port.out.vector.keyword.SaveKeywordRequest +import b1a4.harudew.diary.application.port.out.vector.sentence.SaveSentenceRequest +import b1a4.harudew.diary.application.port.out.vector.sentence.SentenceVectorPort +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.InjectMocks +import org.mockito.Mock +import org.mockito.Mockito.verify +import org.mockito.Mockito.`when` +import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.never +import java.time.LocalDate + +@ExtendWith(MockitoExtension::class) +class DiaryPreprocessingServiceTest { + + @Mock lateinit var diaryChunkPort: DiaryChunkPort + @Mock lateinit var diaryTaggingPort: DiaryTaggingPort + @Mock lateinit var sentenceEmbedder: SentenceEmbedderPort + @Mock lateinit var sentenceVectorPort: SentenceVectorPort + @Mock lateinit var diaryKeywordExtractor: DiaryKeywordExtractorPort + @Mock lateinit var keywordEmbedder: KeywordEmbedderPort + @Mock lateinit var keywordVectorPort: KeywordVectorPort + + @InjectMocks + lateinit var service: DiaryPreprocessingService + + @Test + @DisplayName("ragPreprocessing: 태깅 → 청킹 → 임베딩 → 벡터 저장 순서로 실행된다") + fun `ragPreprocessing processes and saves sentence vectors`() { + // given + val command = DiaryRagPreprocessingCommand( + content = " 오늘 날씨가 좋았다 ", + diaryId = 1L, + authorId = "user-1", + writtenDate = LocalDate.of(2025, 1, 1) + ) + + val taggedContent = AiDiaryTaggingResponse(result = "오늘 날씨가 좋았다") + val chunkedContent = ChunkResponse(chunks = listOf("오늘 날씨가", "좋았다")) + val vector1 = listOf(0.1, 0.2, 0.3) + val vector2 = listOf(0.4, 0.5, 0.6) + + `when`(diaryTaggingPort.tag("오늘 날씨가 좋았다")).thenReturn(taggedContent) + `when`(diaryChunkPort.chunk("오늘 날씨가 좋았다")).thenReturn(chunkedContent) + `when`(sentenceEmbedder.embedPassage("오늘 날씨가")).thenReturn(vector1) + `when`(sentenceEmbedder.embedPassage("좋았다")).thenReturn(vector2) + + // when + service.ragPreprocessing(command) + + // then + val captor = argumentCaptor() + verify(sentenceVectorPort).save(captor.capture()) + + val saved = captor.firstValue + assertThat(saved.diaryId).isEqualTo(1L) + assertThat(saved.authorId).isEqualTo("user-1") + assertThat(saved.date).isEqualTo(LocalDate.of(2025, 1, 1)) + assertThat(saved.sentences).hasSize(2) + assertThat(saved.sentences[0].content).isEqualTo("오늘 날씨가") + assertThat(saved.sentences[0].vector).isEqualTo(vector1) + assertThat(saved.sentences[1].content).isEqualTo("좋았다") + assertThat(saved.sentences[1].vector).isEqualTo(vector2) + } + + @Test + @DisplayName("ragPreprocessing: content의 앞뒤 공백을 제거한 후 태깅한다") + fun `ragPreprocessing trims content before tagging`() { + // given + val command = DiaryRagPreprocessingCommand( + content = " 공백 있는 내용 ", + diaryId = 1L, + authorId = "user-1", + writtenDate = LocalDate.of(2025, 1, 1) + ) + + `when`(diaryTaggingPort.tag("공백 있는 내용")) + .thenReturn(AiDiaryTaggingResponse(result = "공백 있는 내용")) + `when`(diaryChunkPort.chunk("공백 있는 내용")) + .thenReturn(ChunkResponse(chunks = listOf("공백 있는 내용"))) + `when`(sentenceEmbedder.embedPassage("공백 있는 내용")) + .thenReturn(listOf(0.1)) + + // when + service.ragPreprocessing(command) + + // then + verify(diaryTaggingPort).tag("공백 있는 내용") + } + + @Test + @DisplayName("ragPreprocessing: 여러 chunk를 모두 임베딩한다") + fun `ragPreprocessing embeds all chunks`() { + // given + val chunks = listOf("문장1", "문장2", "문장3") + val command = DiaryRagPreprocessingCommand( + content = "원본", + diaryId = 1L, + authorId = "user-1", + writtenDate = LocalDate.of(2025, 1, 1) + ) + + `when`(diaryTaggingPort.tag("원본")) + .thenReturn(AiDiaryTaggingResponse(result = "태깅됨")) + `when`(diaryChunkPort.chunk("태깅됨")) + .thenReturn(ChunkResponse(chunks = chunks)) + chunks.forEach { chunk -> + `when`(sentenceEmbedder.embedPassage(chunk)).thenReturn(listOf(0.1)) + } + + // when + service.ragPreprocessing(command) + + // then + chunks.forEach { chunk -> + verify(sentenceEmbedder).embedPassage(chunk) + } + + val captor = argumentCaptor() + verify(sentenceVectorPort).save(captor.capture()) + assertThat(captor.firstValue.sentences).hasSize(3) + } + + @Test + @DisplayName("keywordPreprocessing: 키워드 추출 → 임베딩 → 벡터 저장 순서로 실행된다") + fun `keywordPreprocessing extracts, embeds, and saves keyword vectors`() { + // given + val command = DiaryKeywordPreprocessingCommand( + content = "오늘 카페에서 커피를 마셨다", + diaryId = 2L, + authorId = "user-1" + ) + + val extractResult = KeywordExtractResponse(keywords = listOf("카페", "커피")) + val vector1 = listOf(0.1, 0.2) + val vector2 = listOf(0.3, 0.4) + + `when`(diaryKeywordExtractor.extract("오늘 카페에서 커피를 마셨다")) + .thenReturn(extractResult) + `when`(keywordEmbedder.embed("카페")).thenReturn(vector1) + `when`(keywordEmbedder.embed("커피")).thenReturn(vector2) + + // when + service.keywordPreprocessing(command) + + // then + val captor = argumentCaptor() + verify(keywordVectorPort).save(captor.capture()) + + val saved = captor.firstValue + assertThat(saved.diaryId).isEqualTo(2L) + assertThat(saved.authorId).isEqualTo("user-1") + assertThat(saved.keywords).hasSize(2) + assertThat(saved.keywords[0].content).isEqualTo("카페") + assertThat(saved.keywords[0].vector).isEqualTo(vector1) + assertThat(saved.keywords[1].content).isEqualTo("커피") + assertThat(saved.keywords[1].vector).isEqualTo(vector2) + } + + @Test + @DisplayName("keywordPreprocessing: 추출된 키워드가 없으면 빈 리스트로 저장한다") + fun `keywordPreprocessing saves empty list when no keywords extracted`() { + // given + val command = DiaryKeywordPreprocessingCommand( + content = "내용", + diaryId = 1L, + authorId = "user-1" + ) + + `when`(diaryKeywordExtractor.extract("내용")) + .thenReturn(KeywordExtractResponse(keywords = emptyList())) + + // when + service.keywordPreprocessing(command) + + // then + verify(keywordEmbedder, never()).embed(org.mockito.kotlin.any()) + + val captor = argumentCaptor() + verify(keywordVectorPort).save(captor.capture()) + assertThat(captor.firstValue.keywords).isEmpty() + } +} From c9ce7bab482608de72ea3eca016a2b777c866f1d Mon Sep 17 00:00:00 2001 From: goochul-im Date: Tue, 10 Feb 2026 19:44:48 +0900 Subject: [PATCH 16/16] =?UTF-8?q?build:=20git=20workflow=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 클로드 코드 리뷰어 - 젯브레인 코드 검사 --- .github/workflows/claude-code-review.yml | 114 ++++++++++++++++++++++ .github/workflows/qodana_code_quality.yml | 40 ++++++++ qodana.yaml | 49 ++++++++++ 3 files changed, 203 insertions(+) create mode 100644 .github/workflows/claude-code-review.yml create mode 100644 .github/workflows/qodana_code_quality.yml create mode 100644 qodana.yaml diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml new file mode 100644 index 0000000..19a44fb --- /dev/null +++ b/.github/workflows/claude-code-review.yml @@ -0,0 +1,114 @@ +name: Claude Code Review + +on: + pull_request: + types: [opened, synchronize, reopened] + +permissions: + contents: read + pull-requests: write + issues: write + +jobs: + claude-review: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Claude Code Review + uses: anthropics/claude-code-action@beta + with: + use_claude_max: true + claude_access_token: ${{ secrets.CLAUDE_ACCESS_TOKEN }} + direct_prompt: | + 당신은 **헥사고날 아키텍처(Ports & Adapters)** 를 사용하는 Kotlin/Spring Boot 프로젝트의 전문 코드 리뷰어입니다. + **모든 리뷰 내용은 반드시 한국어로 작성하세요.** + + 이 프로젝트의 패키지 구조는 다음과 같습니다: + - `adapter/in/web` — 인풋 어댑터 (REST 컨트롤러) + - `adapter/out` — 아웃풋 어댑터 (영속성, 외부 API, AI 등) + - `application/port/in` — 인풋 포트 (유스케이스 인터페이스) + - `application/port/out` — 아웃풋 포트 (드리븐 포트 인터페이스) + - `application/service` — 유스케이스 구현체 + - `domain` — 도메인 모델 및 비즈니스 로직 (프레임워크 의존성 없음) + + PR diff를 리뷰하고 아래 5개 항목에 대해 피드백을 제공하세요. + 각 항목마다 판정(✅ 통과 / ⚠️ 주의 / ❌ 위반)과 상세 설명을 작성하세요. + 위반 사항이 있다면 구체적인 수정 코드를 함께 제안하세요. + + --- + + ## 1. 헥사고날 아키텍처 준수 여부 + + 다음 규칙을 확인하세요: + - **Domain 레이어**는 `adapter`나 `application` 패키지를 import하면 안 됩니다. 프레임워크에 독립적이어야 합니다 (`@Entity`, `@Service` 등 Spring 어노테이션 사용 금지). + - **Application service**는 **포트 인터페이스**에만 의존해야 하며, 구체적인 어댑터에 직접 의존하면 안 됩니다. + - **인풋 어댑터**(컨트롤러)는 **인풋 포트** 인터페이스만 호출해야 하며, 서비스를 직접 호출하면 안 됩니다. + - **아웃풋 어댑터**는 **아웃풋 포트** 인터페이스를 구현해야 합니다. + - 레이어 간 순환 의존성이 없어야 합니다. + - DTO는 adapter 레이어에 위치해야 하며, domain이나 application 레이어에 있으면 안 됩니다. + + ## 2. 단위 테스트 작성 여부 + + 다음을 확인하세요: + - 새로 추가/수정된 소스 파일에 대응하는 테스트 파일이 있는지? + - 테스트가 모킹(Mockito, MockK 등)을 사용하여 단위를 격리하고 있는지? + - 변경 사항에 대한 테스트 커버리지가 적절한지? (최소한 서비스 레이어와 도메인 로직에는 테스트가 있어야 함) + - 엣지 케이스가 커버되고 있는지? + - 새로운 로직에 대한 테스트가 없다면 위반으로 표시하고, 어떤 테스트를 작성해야 하는지 제안하세요. + + ## 3. 성능 저하 우려 코드 + + 다음을 확인하세요: + - N+1 쿼리 패턴 (반복문 내부에서 DB 호출을 반복하는 경우) + - 리스트 조회 시 페이징 누락 + - 제한 없는 컬렉션 로딩 + - 코루틴이나 리액티브 스트림 내부에서의 블로킹 호출 + - 핫 패스에서의 대용량 객체 할당 + - 쿼리 패턴에서 암시되는 인덱스 누락 + - 반복문 내 비효율적인 문자열 연결 + - 불필요한 즉시 로딩(Eager fetching) + + ## 4. SOLID 원칙 준수 여부 + + 각 원칙을 확인하세요: + - **S (단일 책임 원칙):** 각 클래스가 하나의 명확한 책임만 가지고 있는지? + - **O (개방-폐쇄 원칙):** 확장에는 열려 있고 수정에는 닫혀 있는지? (인터페이스/추상화 사용) + - **L (리스코프 치환 원칙):** 하위 타입이 상위 타입을 대체해도 동작이 깨지지 않는지? + - **I (인터페이스 분리 원칙):** 인터페이스가 작고 집중적인지? 불필요한 구현을 강제하는 "뚱뚱한" 인터페이스가 없는지? + - **D (의존성 역전 원칙):** 상위 모듈이 구체 구현이 아닌 추상화에 의존하고 있는지? + + ## 5. 네이밍 컨벤션 통일성 (권장) + + 기존 코드베이스 패턴과의 일관성을 확인하세요: + - 서비스 클래스: `*Service` + - 포트 인터페이스: `*UseCase` (인풋), `*Port` (아웃풋) + - 어댑터: `*Adapter`, `*Controller` + - 도메인 모델: 단순 명사 (접미사 없음) + - DTO: `*Request`, `*Response` + - 테스트 클래스: `*Test`, `*IntegrationTest` + - 메서드 네이밍: camelCase, 동사 시작 (예: `findById`, `createDiary`) + - 새로운 이름이 이 코드베이스의 기존 컨벤션과 일관적인지? + + --- + + ## 출력 형식 + + 다음 형식으로 리뷰를 구조화하세요: + + ### 📋 코드 리뷰 요약 + + | 항목 | 결과 | + |------|------| + | 헥사고날 아키텍처 | ✅ / ⚠️ / ❌ | + | 단위 테스트 | ✅ / ⚠️ / ❌ | + | 성능 | ✅ / ⚠️ / ❌ | + | SOLID 원칙 | ✅ / ⚠️ / ❌ | + | 네이밍 컨벤션 | ✅ / ⚠️ / ❌ | + + 그 다음 각 항목에 대해 구체적인 파일 경로, 라인 번호, 코드 수정 제안을 포함한 상세 내용을 작성하세요. + + 긴 코드 제안은 `
` 접기 섹션을 사용하여 리뷰의 가독성을 유지하세요. diff --git a/.github/workflows/qodana_code_quality.yml b/.github/workflows/qodana_code_quality.yml new file mode 100644 index 0000000..d3697fa --- /dev/null +++ b/.github/workflows/qodana_code_quality.yml @@ -0,0 +1,40 @@ +#-------------------------------------------------------------------------------# +# Discover all capabilities of Qodana in our documentation # +# https://www.jetbrains.com/help/qodana/about-qodana.html # +#-------------------------------------------------------------------------------# + +name: Qodana +on: + workflow_dispatch: + pull_request: + push: + branches: + - main + - feat/diary + +jobs: + qodana: + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + checks: write + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha }} + fetch-depth: 0 + - name: 'Qodana Scan' + uses: JetBrains/qodana-action@v2025.3 + env: + QODANA_TOKEN: ${{ secrets.QODANA_TOKEN }} + with: + # When pr-mode is set to true, Qodana analyzes only the files that have been changed + pr-mode: false + use-caches: true + post-pr-comment: true + use-annotations: true + # Upload Qodana results (SARIF, other artifacts, logs) as an artifact to the job + upload-result: false + # quick-fixes available in Ultimate and Ultimate Plus plans + push-fixes: 'none' \ No newline at end of file diff --git a/qodana.yaml b/qodana.yaml new file mode 100644 index 0000000..ddc1568 --- /dev/null +++ b/qodana.yaml @@ -0,0 +1,49 @@ +#-------------------------------------------------------------------------------# +# Qodana analysis is configured by qodana.yaml file # +# https://www.jetbrains.com/help/qodana/qodana-yaml.html # +#-------------------------------------------------------------------------------# + +################################################################################# +# WARNING: Do not store sensitive information in this file, # +# as its contents will be included in the Qodana report. # +################################################################################# +version: "1.0" + +#Specify inspection profile for code analysis +profile: + name: qodana.starter + +#Enable inspections +#include: +# - name: + +#Disable inspections +#exclude: +# - name: +# paths: +# - + +projectJDK: "17" #(Applied in CI/CD pipeline) + +#Execute shell command before Qodana execution (Applied in CI/CD pipeline) +#bootstrap: sh ./prepare-qodana.sh + +#Install IDE plugins before Qodana execution (Applied in CI/CD pipeline) +#plugins: +# - id: #(plugin id can be found at https://plugins.jetbrains.com) + +# Quality gate. Will fail the CI/CD pipeline if any condition is not met +# severityThresholds - configures maximum thresholds for different problem severities +# testCoverageThresholds - configures minimum code coverage on a whole project and newly added code +# Code Coverage is available in Ultimate and Ultimate Plus plans +#failureConditions: +# severityThresholds: +# any: 15 +# critical: 5 +# testCoverageThresholds: +# fresh: 70 +# total: 50 + +#Qodana supports other languages, for example, Python, JavaScript, TypeScript, Go, C#, PHP +#For all supported languages see https://www.jetbrains.com/help/qodana/linters.html +linter: jetbrains/qodana-jvm-community:2025.3