From 87ba51ff09f5cf4ed3351e34cb3911be019dff4a Mon Sep 17 00:00:00 2001 From: robinjoon Date: Fri, 6 Mar 2026 20:03:59 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20Goal=20=ED=83=80=EC=9E=85=EC=97=90?= =?UTF-8?q?=20=EB=8B=AC=EC=84=B1=EB=A5=A0/=ED=86=B5=EA=B3=84=20computed=20?= =?UTF-8?q?fields=20=EC=B6=94=EA=B0=80=20(#18)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Goal 조회 시 하위 Task 기반 통계(totalTaskCount, completedTaskCount, achievementRate)를 응답 시 계산하여 반환한다. DataLoader로 N+1 방지. Co-Authored-By: Claude Opus 4.6 --- docs/plan/#18-goal-task-stats/checklist.md | 12 ++++++ docs/plan/#18-goal-task-stats/plan.md | 21 +++++++++ .../datafetcher/GoalDataFetcher.kt | 3 ++ .../task/application/service/TaskService.kt | 5 +++ .../loop/task/domain/model/GoalTaskStats.kt | 12 ++++++ .../task/domain/repository/TaskRepository.kt | 4 ++ .../persistence/ExposedTaskRepository.kt | 34 +++++++++++++++ .../datafetcher/GoalTaskStatsDataFetcher.kt | 32 ++++++++++++++ .../dataloader/GoalTaskStatsDataLoader.kt | 21 +++++++++ src/main/resources/schema/goal.graphqls | 6 +++ .../application/service/TaskServiceTest.kt | 37 ++++++++++++++++ .../task/domain/model/GoalTaskStatsTest.kt | 43 +++++++++++++++++++ 12 files changed, 230 insertions(+) create mode 100644 docs/plan/#18-goal-task-stats/checklist.md create mode 100644 docs/plan/#18-goal-task-stats/plan.md create mode 100644 src/main/kotlin/kr/io/team/loop/task/domain/model/GoalTaskStats.kt create mode 100644 src/main/kotlin/kr/io/team/loop/task/presentation/datafetcher/GoalTaskStatsDataFetcher.kt create mode 100644 src/main/kotlin/kr/io/team/loop/task/presentation/dataloader/GoalTaskStatsDataLoader.kt create mode 100644 src/test/kotlin/kr/io/team/loop/task/domain/model/GoalTaskStatsTest.kt diff --git a/docs/plan/#18-goal-task-stats/checklist.md b/docs/plan/#18-goal-task-stats/checklist.md new file mode 100644 index 0000000..f85dc93 --- /dev/null +++ b/docs/plan/#18-goal-task-stats/checklist.md @@ -0,0 +1,12 @@ +# Goal 타입 달성률/통계 검증 체크리스트 + +## 필수 항목 +- [x] 아키텍처 원칙 준수 (docs/architecture.md 기준) +- [x] 레이어 의존성 규칙 위반 없음 +- [x] BC 간 참조 규칙 준수 (Task BC가 Goal 타입 필드 resolve) +- [x] 테스트 코드 작성 완료 (Domain, Application 필수) +- [x] 모든 테스트 통과 +- [x] 기존 테스트 깨지지 않음 + +## 선택 항목 +- [x] DataLoader로 N+1 방지 확인 diff --git a/docs/plan/#18-goal-task-stats/plan.md b/docs/plan/#18-goal-task-stats/plan.md new file mode 100644 index 0000000..5a9bc3c --- /dev/null +++ b/docs/plan/#18-goal-task-stats/plan.md @@ -0,0 +1,21 @@ +# Goal 타입에 달성률/통계 computed fields 추가 계획 + +> Issue: #18 + +## 설계 방향 + +- Goal GraphQL 타입에 `completedTaskCount`, `totalTaskCount`, `achievementRate` 필드 추가 +- DB 저장 없이 응답 시 Task 데이터 기반으로 계산 +- DataLoader로 N+1 방지 (Goal 목록 조회 시 한 번의 batch 쿼리) +- **BC 간 참조 규칙 준수**: Task BC의 DataFetcher가 Goal 타입의 통계 필드를 resolve + - Goal BC는 Task BC를 참조하지 않음 + - Task BC의 Presentation 레이어에서 `@DgsData(parentType = "Goal")` 사용 + +## 단계 + +- [x] 1단계: GraphQL 스키마 — Goal 타입에 통계 필드 추가 +- [x] 2단계: Domain — GoalTaskStats 모델 + TaskRepository에 `countByGoalIds` 추가 (TDD) +- [x] 3단계: Application — TaskService에 `getStatsByGoalIds` 메서드 추가 (TDD) +- [x] 4단계: Infrastructure — ExposedTaskRepository에 집계 쿼리 구현 +- [x] 5단계: Presentation — GoalTaskStatsDataLoader + GoalTaskStatsDataFetcher 구현 +- [x] 6단계: 전체 테스트 통과 확인 및 검증 diff --git a/src/main/kotlin/kr/io/team/loop/goal/presentation/datafetcher/GoalDataFetcher.kt b/src/main/kotlin/kr/io/team/loop/goal/presentation/datafetcher/GoalDataFetcher.kt index eab46ee..54126c6 100644 --- a/src/main/kotlin/kr/io/team/loop/goal/presentation/datafetcher/GoalDataFetcher.kt +++ b/src/main/kotlin/kr/io/team/loop/goal/presentation/datafetcher/GoalDataFetcher.kt @@ -70,5 +70,8 @@ class GoalDataFetcher( title = title.value, createdAt = createdAt.toString(), updatedAt = updatedAt?.toString(), + totalTaskCount = 0, + completedTaskCount = 0, + achievementRate = 0.0, ) } diff --git a/src/main/kotlin/kr/io/team/loop/task/application/service/TaskService.kt b/src/main/kotlin/kr/io/team/loop/task/application/service/TaskService.kt index 9df1635..a0dba13 100644 --- a/src/main/kotlin/kr/io/team/loop/task/application/service/TaskService.kt +++ b/src/main/kotlin/kr/io/team/loop/task/application/service/TaskService.kt @@ -1,8 +1,10 @@ package kr.io.team.loop.task.application.service +import kr.io.team.loop.common.domain.GoalId import kr.io.team.loop.common.domain.MemberId import kr.io.team.loop.common.domain.exception.AccessDeniedException import kr.io.team.loop.common.domain.exception.EntityNotFoundException +import kr.io.team.loop.task.domain.model.GoalTaskStats import kr.io.team.loop.task.domain.model.Task import kr.io.team.loop.task.domain.model.TaskCommand import kr.io.team.loop.task.domain.model.TaskQuery @@ -34,6 +36,9 @@ class TaskService( return taskRepository.updateStatus(command) } + @Transactional(readOnly = true) + fun getStatsByGoalIds(goalIds: Set): Map = taskRepository.countByGoalIds(goalIds) + @Transactional fun delete( command: TaskCommand.Delete, diff --git a/src/main/kotlin/kr/io/team/loop/task/domain/model/GoalTaskStats.kt b/src/main/kotlin/kr/io/team/loop/task/domain/model/GoalTaskStats.kt new file mode 100644 index 0000000..58a505e --- /dev/null +++ b/src/main/kotlin/kr/io/team/loop/task/domain/model/GoalTaskStats.kt @@ -0,0 +1,12 @@ +package kr.io.team.loop.task.domain.model + +import kr.io.team.loop.common.domain.GoalId + +data class GoalTaskStats( + val goalId: GoalId, + val totalCount: Int, + val completedCount: Int, +) { + val achievementRate: Double + get() = if (totalCount == 0) 0.0 else (completedCount.toDouble() / totalCount) * 100.0 +} diff --git a/src/main/kotlin/kr/io/team/loop/task/domain/repository/TaskRepository.kt b/src/main/kotlin/kr/io/team/loop/task/domain/repository/TaskRepository.kt index 1c33bf0..79c49c1 100644 --- a/src/main/kotlin/kr/io/team/loop/task/domain/repository/TaskRepository.kt +++ b/src/main/kotlin/kr/io/team/loop/task/domain/repository/TaskRepository.kt @@ -1,6 +1,8 @@ package kr.io.team.loop.task.domain.repository +import kr.io.team.loop.common.domain.GoalId import kr.io.team.loop.common.domain.TaskId +import kr.io.team.loop.task.domain.model.GoalTaskStats import kr.io.team.loop.task.domain.model.Task import kr.io.team.loop.task.domain.model.TaskCommand import kr.io.team.loop.task.domain.model.TaskQuery @@ -15,4 +17,6 @@ interface TaskRepository { fun findAll(query: TaskQuery): List fun findById(id: TaskId): Task? + + fun countByGoalIds(goalIds: Set): Map } diff --git a/src/main/kotlin/kr/io/team/loop/task/infrastructure/persistence/ExposedTaskRepository.kt b/src/main/kotlin/kr/io/team/loop/task/infrastructure/persistence/ExposedTaskRepository.kt index 98ec23c..8841afc 100644 --- a/src/main/kotlin/kr/io/team/loop/task/infrastructure/persistence/ExposedTaskRepository.kt +++ b/src/main/kotlin/kr/io/team/loop/task/infrastructure/persistence/ExposedTaskRepository.kt @@ -3,21 +3,28 @@ package kr.io.team.loop.task.infrastructure.persistence import kr.io.team.loop.common.domain.GoalId import kr.io.team.loop.common.domain.MemberId import kr.io.team.loop.common.domain.TaskId +import kr.io.team.loop.task.domain.model.GoalTaskStats import kr.io.team.loop.task.domain.model.Task import kr.io.team.loop.task.domain.model.TaskCommand import kr.io.team.loop.task.domain.model.TaskQuery import kr.io.team.loop.task.domain.model.TaskStatus import kr.io.team.loop.task.domain.model.TaskTitle import kr.io.team.loop.task.domain.repository.TaskRepository +import org.jetbrains.exposed.v1.core.Case import org.jetbrains.exposed.v1.core.Op import org.jetbrains.exposed.v1.core.ResultRow import org.jetbrains.exposed.v1.core.SortOrder +import org.jetbrains.exposed.v1.core.Sum import org.jetbrains.exposed.v1.core.and +import org.jetbrains.exposed.v1.core.count import org.jetbrains.exposed.v1.core.eq import org.jetbrains.exposed.v1.core.greaterEq +import org.jetbrains.exposed.v1.core.inList +import org.jetbrains.exposed.v1.core.intLiteral import org.jetbrains.exposed.v1.core.lessEq import org.jetbrains.exposed.v1.jdbc.deleteWhere import org.jetbrains.exposed.v1.jdbc.insert +import org.jetbrains.exposed.v1.jdbc.select import org.jetbrains.exposed.v1.jdbc.selectAll import org.jetbrains.exposed.v1.jdbc.update import org.springframework.stereotype.Repository @@ -81,6 +88,33 @@ class ExposedTaskRepository : TaskRepository { .singleOrNull() ?.toTask() + override fun countByGoalIds(goalIds: Set): Map { + if (goalIds.isEmpty()) return emptyMap() + val goalIdValues = goalIds.map { it.value } + val completedCount = + Sum( + Case() + .When(TaskTable.status eq TaskStatus.DONE.name, intLiteral(1)) + .Else(intLiteral(0)), + org.jetbrains.exposed.v1.core + .IntegerColumnType(), + ) + val totalCount = TaskTable.taskId.count() + return TaskTable + .select(TaskTable.goalId, totalCount, completedCount) + .where { TaskTable.goalId inList goalIdValues } + .groupBy(TaskTable.goalId) + .associate { row -> + val goalId = GoalId(row[TaskTable.goalId]) + goalId to + GoalTaskStats( + goalId = goalId, + totalCount = row[totalCount].toInt(), + completedCount = row[completedCount] ?: 0, + ) + } + } + private fun ResultRow.toTask(): Task = Task( id = TaskId(this[TaskTable.taskId]), diff --git a/src/main/kotlin/kr/io/team/loop/task/presentation/datafetcher/GoalTaskStatsDataFetcher.kt b/src/main/kotlin/kr/io/team/loop/task/presentation/datafetcher/GoalTaskStatsDataFetcher.kt new file mode 100644 index 0000000..855ddda --- /dev/null +++ b/src/main/kotlin/kr/io/team/loop/task/presentation/datafetcher/GoalTaskStatsDataFetcher.kt @@ -0,0 +1,32 @@ +package kr.io.team.loop.task.presentation.datafetcher + +import com.netflix.graphql.dgs.DgsComponent +import com.netflix.graphql.dgs.DgsData +import com.netflix.graphql.dgs.DgsDataFetchingEnvironment +import kr.io.team.loop.codegen.types.Goal +import kr.io.team.loop.task.domain.model.GoalTaskStats +import java.util.concurrent.CompletableFuture + +@DgsComponent +class GoalTaskStatsDataFetcher { + @DgsData(parentType = "Goal", field = "totalTaskCount") + fun totalTaskCount(dfe: DgsDataFetchingEnvironment): CompletableFuture { + val goal = dfe.getSource()!! + val dataLoader = dfe.getDataLoader("goalTaskStats")!! + return dataLoader.load(goal.id.toLong()).thenApply { it?.totalCount ?: 0 } + } + + @DgsData(parentType = "Goal", field = "completedTaskCount") + fun completedTaskCount(dfe: DgsDataFetchingEnvironment): CompletableFuture { + val goal = dfe.getSource()!! + val dataLoader = dfe.getDataLoader("goalTaskStats")!! + return dataLoader.load(goal.id.toLong()).thenApply { it?.completedCount ?: 0 } + } + + @DgsData(parentType = "Goal", field = "achievementRate") + fun achievementRate(dfe: DgsDataFetchingEnvironment): CompletableFuture { + val goal = dfe.getSource()!! + val dataLoader = dfe.getDataLoader("goalTaskStats")!! + return dataLoader.load(goal.id.toLong()).thenApply { it?.achievementRate ?: 0.0 } + } +} diff --git a/src/main/kotlin/kr/io/team/loop/task/presentation/dataloader/GoalTaskStatsDataLoader.kt b/src/main/kotlin/kr/io/team/loop/task/presentation/dataloader/GoalTaskStatsDataLoader.kt new file mode 100644 index 0000000..3890bd9 --- /dev/null +++ b/src/main/kotlin/kr/io/team/loop/task/presentation/dataloader/GoalTaskStatsDataLoader.kt @@ -0,0 +1,21 @@ +package kr.io.team.loop.task.presentation.dataloader + +import com.netflix.graphql.dgs.DgsDataLoader +import kr.io.team.loop.common.domain.GoalId +import kr.io.team.loop.task.application.service.TaskService +import kr.io.team.loop.task.domain.model.GoalTaskStats +import org.dataloader.MappedBatchLoader +import java.util.concurrent.CompletableFuture +import java.util.concurrent.CompletionStage + +@DgsDataLoader(name = "goalTaskStats") +class GoalTaskStatsDataLoader( + private val taskService: TaskService, +) : MappedBatchLoader { + override fun load(keys: Set): CompletionStage> = + CompletableFuture.supplyAsync { + val goalIds = keys.map { GoalId(it) }.toSet() + val stats = taskService.getStatsByGoalIds(goalIds) + stats.map { (goalId, goalTaskStats) -> goalId.value to goalTaskStats }.toMap() + } +} diff --git a/src/main/resources/schema/goal.graphqls b/src/main/resources/schema/goal.graphqls index 4a2f8c5..ff50282 100644 --- a/src/main/resources/schema/goal.graphqls +++ b/src/main/resources/schema/goal.graphqls @@ -24,6 +24,12 @@ type Goal { createdAt: String! "수정일시" updatedAt: String + "해당 목표의 전체 할일 수" + totalTaskCount: Int! + "해당 목표의 완료된 할일 수" + completedTaskCount: Int! + "해당 목표의 달성률 (0.0 ~ 100.0, 할일이 없으면 0.0)" + achievementRate: Float! } "목표 생성 입력" diff --git a/src/test/kotlin/kr/io/team/loop/task/application/service/TaskServiceTest.kt b/src/test/kotlin/kr/io/team/loop/task/application/service/TaskServiceTest.kt index 57b443e..5108638 100644 --- a/src/test/kotlin/kr/io/team/loop/task/application/service/TaskServiceTest.kt +++ b/src/test/kotlin/kr/io/team/loop/task/application/service/TaskServiceTest.kt @@ -14,6 +14,7 @@ import kr.io.team.loop.common.domain.MemberId import kr.io.team.loop.common.domain.TaskId import kr.io.team.loop.common.domain.exception.AccessDeniedException import kr.io.team.loop.common.domain.exception.EntityNotFoundException +import kr.io.team.loop.task.domain.model.GoalTaskStats import kr.io.team.loop.task.domain.model.Task import kr.io.team.loop.task.domain.model.TaskCommand import kr.io.team.loop.task.domain.model.TaskQuery @@ -129,6 +130,42 @@ class TaskServiceTest : } } + Given("목표별 할일 통계 조회 시") { + When("할일이 있는 목표들이면") { + val goalId1 = GoalId(1L) + val goalId2 = GoalId(2L) + val goalIds = setOf(goalId1, goalId2) + val statsMap = + mapOf( + goalId1 to GoalTaskStats(goalId = goalId1, totalCount = 5, completedCount = 3), + goalId2 to GoalTaskStats(goalId = goalId2, totalCount = 2, completedCount = 2), + ) + every { taskRepository.countByGoalIds(goalIds) } returns statsMap + + val result = taskService.getStatsByGoalIds(goalIds) + + Then("목표별 통계를 반환한다") { + result[goalId1]!!.totalCount shouldBe 5 + result[goalId1]!!.completedCount shouldBe 3 + result[goalId1]!!.achievementRate shouldBe 60.0 + result[goalId2]!!.totalCount shouldBe 2 + result[goalId2]!!.completedCount shouldBe 2 + result[goalId2]!!.achievementRate shouldBe 100.0 + } + } + + When("빈 goalIds이면") { + val goalIds = emptySet() + every { taskRepository.countByGoalIds(goalIds) } returns emptyMap() + + val result = taskService.getStatsByGoalIds(goalIds) + + Then("빈 맵을 반환한다") { + result shouldBe emptyMap() + } + } + } + Given("할일 삭제 시") { When("본인 할일이면") { val command = TaskCommand.Delete(taskId = TaskId(1L)) diff --git a/src/test/kotlin/kr/io/team/loop/task/domain/model/GoalTaskStatsTest.kt b/src/test/kotlin/kr/io/team/loop/task/domain/model/GoalTaskStatsTest.kt new file mode 100644 index 0000000..5a5cf7f --- /dev/null +++ b/src/test/kotlin/kr/io/team/loop/task/domain/model/GoalTaskStatsTest.kt @@ -0,0 +1,43 @@ +package kr.io.team.loop.task.domain.model + +import io.kotest.core.spec.style.BehaviorSpec +import io.kotest.matchers.shouldBe +import kr.io.team.loop.common.domain.GoalId + +class GoalTaskStatsTest : + BehaviorSpec({ + + Given("GoalTaskStats 달성률 계산 시") { + When("할일이 있으면") { + val stats = GoalTaskStats(goalId = GoalId(1L), totalCount = 10, completedCount = 7) + + Then("달성률을 백분율로 반환한다") { + stats.achievementRate shouldBe 70.0 + } + } + + When("모든 할일이 완료되면") { + val stats = GoalTaskStats(goalId = GoalId(1L), totalCount = 5, completedCount = 5) + + Then("달성률 100.0을 반환한다") { + stats.achievementRate shouldBe 100.0 + } + } + + When("할일이 없으면") { + val stats = GoalTaskStats(goalId = GoalId(1L), totalCount = 0, completedCount = 0) + + Then("달성률 0.0을 반환한다") { + stats.achievementRate shouldBe 0.0 + } + } + + When("완료된 할일이 없으면") { + val stats = GoalTaskStats(goalId = GoalId(1L), totalCount = 3, completedCount = 0) + + Then("달성률 0.0을 반환한다") { + stats.achievementRate shouldBe 0.0 + } + } + } + }) From c7b7f0cc92ef067085276a16e61e754169f69288 Mon Sep 17 00:00:00 2001 From: robinjoon Date: Fri, 6 Mar 2026 21:06:40 +0900 Subject: [PATCH 2/3] =?UTF-8?q?refactor:=20=EB=AA=A9=ED=91=9C=EB=B3=84=20?= =?UTF-8?q?=ED=86=B5=EA=B3=84=20=EC=A7=91=EA=B3=84=20=EB=A1=9C=EC=A7=81?= =?UTF-8?q?=EC=9D=84=20Infrastructure=EC=97=90=EC=84=9C=20Application?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Repository는 단순 조회(findAllByGoalIds)만 수행하고, TaskService에서 Task 목록 기반으로 통계를 계산하도록 변경. Co-Authored-By: Claude Opus 4.6 --- docs/plan/#18-goal-task-stats/plan.md | 1 + .../task/application/service/TaskService.kt | 15 +++++++- .../task/domain/repository/TaskRepository.kt | 3 +- .../persistence/ExposedTaskRepository.kt | 32 +++-------------- .../application/service/TaskServiceTest.kt | 34 +++++++++++-------- 5 files changed, 40 insertions(+), 45 deletions(-) diff --git a/docs/plan/#18-goal-task-stats/plan.md b/docs/plan/#18-goal-task-stats/plan.md index 5a9bc3c..a3cddc6 100644 --- a/docs/plan/#18-goal-task-stats/plan.md +++ b/docs/plan/#18-goal-task-stats/plan.md @@ -19,3 +19,4 @@ - [x] 4단계: Infrastructure — ExposedTaskRepository에 집계 쿼리 구현 - [x] 5단계: Presentation — GoalTaskStatsDataLoader + GoalTaskStatsDataFetcher 구현 - [x] 6단계: 전체 테스트 통과 확인 및 검증 +- [ ] 7단계: 리팩토링 — 집계 로직을 Infrastructure에서 Application으로 이동 diff --git a/src/main/kotlin/kr/io/team/loop/task/application/service/TaskService.kt b/src/main/kotlin/kr/io/team/loop/task/application/service/TaskService.kt index a0dba13..e80f9b2 100644 --- a/src/main/kotlin/kr/io/team/loop/task/application/service/TaskService.kt +++ b/src/main/kotlin/kr/io/team/loop/task/application/service/TaskService.kt @@ -8,6 +8,7 @@ import kr.io.team.loop.task.domain.model.GoalTaskStats import kr.io.team.loop.task.domain.model.Task import kr.io.team.loop.task.domain.model.TaskCommand import kr.io.team.loop.task.domain.model.TaskQuery +import kr.io.team.loop.task.domain.model.TaskStatus import kr.io.team.loop.task.domain.repository.TaskRepository import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -37,7 +38,19 @@ class TaskService( } @Transactional(readOnly = true) - fun getStatsByGoalIds(goalIds: Set): Map = taskRepository.countByGoalIds(goalIds) + fun getStatsByGoalIds(goalIds: Set): Map { + val tasks = taskRepository.findAllByGoalIds(goalIds) + return tasks + .groupBy { it.goalId } + .map { (goalId, goalTasks) -> + goalId to + GoalTaskStats( + goalId = goalId, + totalCount = goalTasks.size, + completedCount = goalTasks.count { it.status == TaskStatus.DONE }, + ) + }.toMap() + } @Transactional fun delete( diff --git a/src/main/kotlin/kr/io/team/loop/task/domain/repository/TaskRepository.kt b/src/main/kotlin/kr/io/team/loop/task/domain/repository/TaskRepository.kt index 79c49c1..25fca01 100644 --- a/src/main/kotlin/kr/io/team/loop/task/domain/repository/TaskRepository.kt +++ b/src/main/kotlin/kr/io/team/loop/task/domain/repository/TaskRepository.kt @@ -2,7 +2,6 @@ package kr.io.team.loop.task.domain.repository import kr.io.team.loop.common.domain.GoalId import kr.io.team.loop.common.domain.TaskId -import kr.io.team.loop.task.domain.model.GoalTaskStats import kr.io.team.loop.task.domain.model.Task import kr.io.team.loop.task.domain.model.TaskCommand import kr.io.team.loop.task.domain.model.TaskQuery @@ -18,5 +17,5 @@ interface TaskRepository { fun findById(id: TaskId): Task? - fun countByGoalIds(goalIds: Set): Map + fun findAllByGoalIds(goalIds: Set): List } diff --git a/src/main/kotlin/kr/io/team/loop/task/infrastructure/persistence/ExposedTaskRepository.kt b/src/main/kotlin/kr/io/team/loop/task/infrastructure/persistence/ExposedTaskRepository.kt index 8841afc..6757f5b 100644 --- a/src/main/kotlin/kr/io/team/loop/task/infrastructure/persistence/ExposedTaskRepository.kt +++ b/src/main/kotlin/kr/io/team/loop/task/infrastructure/persistence/ExposedTaskRepository.kt @@ -3,28 +3,22 @@ package kr.io.team.loop.task.infrastructure.persistence import kr.io.team.loop.common.domain.GoalId import kr.io.team.loop.common.domain.MemberId import kr.io.team.loop.common.domain.TaskId -import kr.io.team.loop.task.domain.model.GoalTaskStats import kr.io.team.loop.task.domain.model.Task import kr.io.team.loop.task.domain.model.TaskCommand import kr.io.team.loop.task.domain.model.TaskQuery import kr.io.team.loop.task.domain.model.TaskStatus import kr.io.team.loop.task.domain.model.TaskTitle import kr.io.team.loop.task.domain.repository.TaskRepository -import org.jetbrains.exposed.v1.core.Case import org.jetbrains.exposed.v1.core.Op import org.jetbrains.exposed.v1.core.ResultRow import org.jetbrains.exposed.v1.core.SortOrder -import org.jetbrains.exposed.v1.core.Sum import org.jetbrains.exposed.v1.core.and -import org.jetbrains.exposed.v1.core.count import org.jetbrains.exposed.v1.core.eq import org.jetbrains.exposed.v1.core.greaterEq import org.jetbrains.exposed.v1.core.inList -import org.jetbrains.exposed.v1.core.intLiteral import org.jetbrains.exposed.v1.core.lessEq import org.jetbrains.exposed.v1.jdbc.deleteWhere import org.jetbrains.exposed.v1.jdbc.insert -import org.jetbrains.exposed.v1.jdbc.select import org.jetbrains.exposed.v1.jdbc.selectAll import org.jetbrains.exposed.v1.jdbc.update import org.springframework.stereotype.Repository @@ -88,31 +82,13 @@ class ExposedTaskRepository : TaskRepository { .singleOrNull() ?.toTask() - override fun countByGoalIds(goalIds: Set): Map { - if (goalIds.isEmpty()) return emptyMap() + override fun findAllByGoalIds(goalIds: Set): List { + if (goalIds.isEmpty()) return emptyList() val goalIdValues = goalIds.map { it.value } - val completedCount = - Sum( - Case() - .When(TaskTable.status eq TaskStatus.DONE.name, intLiteral(1)) - .Else(intLiteral(0)), - org.jetbrains.exposed.v1.core - .IntegerColumnType(), - ) - val totalCount = TaskTable.taskId.count() return TaskTable - .select(TaskTable.goalId, totalCount, completedCount) + .selectAll() .where { TaskTable.goalId inList goalIdValues } - .groupBy(TaskTable.goalId) - .associate { row -> - val goalId = GoalId(row[TaskTable.goalId]) - goalId to - GoalTaskStats( - goalId = goalId, - totalCount = row[totalCount].toInt(), - completedCount = row[completedCount] ?: 0, - ) - } + .map { it.toTask() } } private fun ResultRow.toTask(): Task = diff --git a/src/test/kotlin/kr/io/team/loop/task/application/service/TaskServiceTest.kt b/src/test/kotlin/kr/io/team/loop/task/application/service/TaskServiceTest.kt index 5108638..f348d04 100644 --- a/src/test/kotlin/kr/io/team/loop/task/application/service/TaskServiceTest.kt +++ b/src/test/kotlin/kr/io/team/loop/task/application/service/TaskServiceTest.kt @@ -14,7 +14,6 @@ import kr.io.team.loop.common.domain.MemberId import kr.io.team.loop.common.domain.TaskId import kr.io.team.loop.common.domain.exception.AccessDeniedException import kr.io.team.loop.common.domain.exception.EntityNotFoundException -import kr.io.team.loop.task.domain.model.GoalTaskStats import kr.io.team.loop.task.domain.model.Task import kr.io.team.loop.task.domain.model.TaskCommand import kr.io.team.loop.task.domain.model.TaskQuery @@ -131,32 +130,39 @@ class TaskServiceTest : } Given("목표별 할일 통계 조회 시") { + val goalId1 = GoalId(1L) + val goalId2 = GoalId(2L) + + fun task( + goalId: GoalId, + status: TaskStatus, + ) = savedTask.copy(goalId = goalId, status = status) + When("할일이 있는 목표들이면") { - val goalId1 = GoalId(1L) - val goalId2 = GoalId(2L) val goalIds = setOf(goalId1, goalId2) - val statsMap = - mapOf( - goalId1 to GoalTaskStats(goalId = goalId1, totalCount = 5, completedCount = 3), - goalId2 to GoalTaskStats(goalId = goalId2, totalCount = 2, completedCount = 2), + val tasks = + listOf( + task(goalId1, TaskStatus.TODO), + task(goalId1, TaskStatus.DONE), + task(goalId1, TaskStatus.DONE), + task(goalId2, TaskStatus.DONE), ) - every { taskRepository.countByGoalIds(goalIds) } returns statsMap + every { taskRepository.findAllByGoalIds(goalIds) } returns tasks val result = taskService.getStatsByGoalIds(goalIds) Then("목표별 통계를 반환한다") { - result[goalId1]!!.totalCount shouldBe 5 - result[goalId1]!!.completedCount shouldBe 3 - result[goalId1]!!.achievementRate shouldBe 60.0 - result[goalId2]!!.totalCount shouldBe 2 - result[goalId2]!!.completedCount shouldBe 2 + result[goalId1]!!.totalCount shouldBe 3 + result[goalId1]!!.completedCount shouldBe 2 + result[goalId2]!!.totalCount shouldBe 1 + result[goalId2]!!.completedCount shouldBe 1 result[goalId2]!!.achievementRate shouldBe 100.0 } } When("빈 goalIds이면") { val goalIds = emptySet() - every { taskRepository.countByGoalIds(goalIds) } returns emptyMap() + every { taskRepository.findAllByGoalIds(goalIds) } returns emptyList() val result = taskService.getStatsByGoalIds(goalIds) From 206f8f5978134f4ecd6d008d6f39ab71b86e5cb6 Mon Sep 17 00:00:00 2001 From: robinjoon Date: Fri, 6 Mar 2026 21:52:34 +0900 Subject: [PATCH 3/3] =?UTF-8?q?refactor:=20GoalTaskStats=EB=A5=BC=20domain?= =?UTF-8?q?/model=EC=97=90=EC=84=9C=20application/dto=EB=A1=9C=20=EC=9D=B4?= =?UTF-8?q?=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 집계 결과 DTO이므로 아키텍처 규칙에 따라 application/dto/에 배치. GoalTaskStats → GoalTaskStatsDto로 이름 변경. Co-Authored-By: Claude Opus 4.6 --- .../dto/GoalTaskStatsDto.kt} | 4 ++-- .../loop/task/application/service/TaskService.kt | 6 +++--- .../datafetcher/GoalTaskStatsDataFetcher.kt | 8 ++++---- .../dataloader/GoalTaskStatsDataLoader.kt | 6 +++--- .../dto/GoalTaskStatsDtoTest.kt} | 14 +++++++------- 5 files changed, 19 insertions(+), 19 deletions(-) rename src/main/kotlin/kr/io/team/loop/task/{domain/model/GoalTaskStats.kt => application/dto/GoalTaskStatsDto.kt} (77%) rename src/test/kotlin/kr/io/team/loop/task/{domain/model/GoalTaskStatsTest.kt => application/dto/GoalTaskStatsDtoTest.kt} (63%) diff --git a/src/main/kotlin/kr/io/team/loop/task/domain/model/GoalTaskStats.kt b/src/main/kotlin/kr/io/team/loop/task/application/dto/GoalTaskStatsDto.kt similarity index 77% rename from src/main/kotlin/kr/io/team/loop/task/domain/model/GoalTaskStats.kt rename to src/main/kotlin/kr/io/team/loop/task/application/dto/GoalTaskStatsDto.kt index 58a505e..5570916 100644 --- a/src/main/kotlin/kr/io/team/loop/task/domain/model/GoalTaskStats.kt +++ b/src/main/kotlin/kr/io/team/loop/task/application/dto/GoalTaskStatsDto.kt @@ -1,8 +1,8 @@ -package kr.io.team.loop.task.domain.model +package kr.io.team.loop.task.application.dto import kr.io.team.loop.common.domain.GoalId -data class GoalTaskStats( +data class GoalTaskStatsDto( val goalId: GoalId, val totalCount: Int, val completedCount: Int, diff --git a/src/main/kotlin/kr/io/team/loop/task/application/service/TaskService.kt b/src/main/kotlin/kr/io/team/loop/task/application/service/TaskService.kt index e80f9b2..163f549 100644 --- a/src/main/kotlin/kr/io/team/loop/task/application/service/TaskService.kt +++ b/src/main/kotlin/kr/io/team/loop/task/application/service/TaskService.kt @@ -4,7 +4,7 @@ import kr.io.team.loop.common.domain.GoalId import kr.io.team.loop.common.domain.MemberId import kr.io.team.loop.common.domain.exception.AccessDeniedException import kr.io.team.loop.common.domain.exception.EntityNotFoundException -import kr.io.team.loop.task.domain.model.GoalTaskStats +import kr.io.team.loop.task.application.dto.GoalTaskStatsDto import kr.io.team.loop.task.domain.model.Task import kr.io.team.loop.task.domain.model.TaskCommand import kr.io.team.loop.task.domain.model.TaskQuery @@ -38,13 +38,13 @@ class TaskService( } @Transactional(readOnly = true) - fun getStatsByGoalIds(goalIds: Set): Map { + fun getStatsByGoalIds(goalIds: Set): Map { val tasks = taskRepository.findAllByGoalIds(goalIds) return tasks .groupBy { it.goalId } .map { (goalId, goalTasks) -> goalId to - GoalTaskStats( + GoalTaskStatsDto( goalId = goalId, totalCount = goalTasks.size, completedCount = goalTasks.count { it.status == TaskStatus.DONE }, diff --git a/src/main/kotlin/kr/io/team/loop/task/presentation/datafetcher/GoalTaskStatsDataFetcher.kt b/src/main/kotlin/kr/io/team/loop/task/presentation/datafetcher/GoalTaskStatsDataFetcher.kt index 855ddda..142af18 100644 --- a/src/main/kotlin/kr/io/team/loop/task/presentation/datafetcher/GoalTaskStatsDataFetcher.kt +++ b/src/main/kotlin/kr/io/team/loop/task/presentation/datafetcher/GoalTaskStatsDataFetcher.kt @@ -4,7 +4,7 @@ import com.netflix.graphql.dgs.DgsComponent import com.netflix.graphql.dgs.DgsData import com.netflix.graphql.dgs.DgsDataFetchingEnvironment import kr.io.team.loop.codegen.types.Goal -import kr.io.team.loop.task.domain.model.GoalTaskStats +import kr.io.team.loop.task.application.dto.GoalTaskStatsDto import java.util.concurrent.CompletableFuture @DgsComponent @@ -12,21 +12,21 @@ class GoalTaskStatsDataFetcher { @DgsData(parentType = "Goal", field = "totalTaskCount") fun totalTaskCount(dfe: DgsDataFetchingEnvironment): CompletableFuture { val goal = dfe.getSource()!! - val dataLoader = dfe.getDataLoader("goalTaskStats")!! + val dataLoader = dfe.getDataLoader("goalTaskStats")!! return dataLoader.load(goal.id.toLong()).thenApply { it?.totalCount ?: 0 } } @DgsData(parentType = "Goal", field = "completedTaskCount") fun completedTaskCount(dfe: DgsDataFetchingEnvironment): CompletableFuture { val goal = dfe.getSource()!! - val dataLoader = dfe.getDataLoader("goalTaskStats")!! + val dataLoader = dfe.getDataLoader("goalTaskStats")!! return dataLoader.load(goal.id.toLong()).thenApply { it?.completedCount ?: 0 } } @DgsData(parentType = "Goal", field = "achievementRate") fun achievementRate(dfe: DgsDataFetchingEnvironment): CompletableFuture { val goal = dfe.getSource()!! - val dataLoader = dfe.getDataLoader("goalTaskStats")!! + val dataLoader = dfe.getDataLoader("goalTaskStats")!! return dataLoader.load(goal.id.toLong()).thenApply { it?.achievementRate ?: 0.0 } } } diff --git a/src/main/kotlin/kr/io/team/loop/task/presentation/dataloader/GoalTaskStatsDataLoader.kt b/src/main/kotlin/kr/io/team/loop/task/presentation/dataloader/GoalTaskStatsDataLoader.kt index 3890bd9..71c332d 100644 --- a/src/main/kotlin/kr/io/team/loop/task/presentation/dataloader/GoalTaskStatsDataLoader.kt +++ b/src/main/kotlin/kr/io/team/loop/task/presentation/dataloader/GoalTaskStatsDataLoader.kt @@ -2,8 +2,8 @@ package kr.io.team.loop.task.presentation.dataloader import com.netflix.graphql.dgs.DgsDataLoader import kr.io.team.loop.common.domain.GoalId +import kr.io.team.loop.task.application.dto.GoalTaskStatsDto import kr.io.team.loop.task.application.service.TaskService -import kr.io.team.loop.task.domain.model.GoalTaskStats import org.dataloader.MappedBatchLoader import java.util.concurrent.CompletableFuture import java.util.concurrent.CompletionStage @@ -11,8 +11,8 @@ import java.util.concurrent.CompletionStage @DgsDataLoader(name = "goalTaskStats") class GoalTaskStatsDataLoader( private val taskService: TaskService, -) : MappedBatchLoader { - override fun load(keys: Set): CompletionStage> = +) : MappedBatchLoader { + override fun load(keys: Set): CompletionStage> = CompletableFuture.supplyAsync { val goalIds = keys.map { GoalId(it) }.toSet() val stats = taskService.getStatsByGoalIds(goalIds) diff --git a/src/test/kotlin/kr/io/team/loop/task/domain/model/GoalTaskStatsTest.kt b/src/test/kotlin/kr/io/team/loop/task/application/dto/GoalTaskStatsDtoTest.kt similarity index 63% rename from src/test/kotlin/kr/io/team/loop/task/domain/model/GoalTaskStatsTest.kt rename to src/test/kotlin/kr/io/team/loop/task/application/dto/GoalTaskStatsDtoTest.kt index 5a5cf7f..2a35251 100644 --- a/src/test/kotlin/kr/io/team/loop/task/domain/model/GoalTaskStatsTest.kt +++ b/src/test/kotlin/kr/io/team/loop/task/application/dto/GoalTaskStatsDtoTest.kt @@ -1,15 +1,15 @@ -package kr.io.team.loop.task.domain.model +package kr.io.team.loop.task.application.dto import io.kotest.core.spec.style.BehaviorSpec import io.kotest.matchers.shouldBe import kr.io.team.loop.common.domain.GoalId -class GoalTaskStatsTest : +class GoalTaskStatsDtoTest : BehaviorSpec({ - Given("GoalTaskStats 달성률 계산 시") { + Given("GoalTaskStatsDto 달성률 계산 시") { When("할일이 있으면") { - val stats = GoalTaskStats(goalId = GoalId(1L), totalCount = 10, completedCount = 7) + val stats = GoalTaskStatsDto(goalId = GoalId(1L), totalCount = 10, completedCount = 7) Then("달성률을 백분율로 반환한다") { stats.achievementRate shouldBe 70.0 @@ -17,7 +17,7 @@ class GoalTaskStatsTest : } When("모든 할일이 완료되면") { - val stats = GoalTaskStats(goalId = GoalId(1L), totalCount = 5, completedCount = 5) + val stats = GoalTaskStatsDto(goalId = GoalId(1L), totalCount = 5, completedCount = 5) Then("달성률 100.0을 반환한다") { stats.achievementRate shouldBe 100.0 @@ -25,7 +25,7 @@ class GoalTaskStatsTest : } When("할일이 없으면") { - val stats = GoalTaskStats(goalId = GoalId(1L), totalCount = 0, completedCount = 0) + val stats = GoalTaskStatsDto(goalId = GoalId(1L), totalCount = 0, completedCount = 0) Then("달성률 0.0을 반환한다") { stats.achievementRate shouldBe 0.0 @@ -33,7 +33,7 @@ class GoalTaskStatsTest : } When("완료된 할일이 없으면") { - val stats = GoalTaskStats(goalId = GoalId(1L), totalCount = 3, completedCount = 0) + val stats = GoalTaskStatsDto(goalId = GoalId(1L), totalCount = 3, completedCount = 0) Then("달성률 0.0을 반환한다") { stats.achievementRate shouldBe 0.0