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..a3cddc6 --- /dev/null +++ b/docs/plan/#18-goal-task-stats/plan.md @@ -0,0 +1,22 @@ +# 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단계: 전체 테스트 통과 확인 및 검증 +- [ ] 7단계: 리팩토링 — 집계 로직을 Infrastructure에서 Application으로 이동 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/dto/GoalTaskStatsDto.kt b/src/main/kotlin/kr/io/team/loop/task/application/dto/GoalTaskStatsDto.kt new file mode 100644 index 0000000..5570916 --- /dev/null +++ b/src/main/kotlin/kr/io/team/loop/task/application/dto/GoalTaskStatsDto.kt @@ -0,0 +1,12 @@ +package kr.io.team.loop.task.application.dto + +import kr.io.team.loop.common.domain.GoalId + +data class GoalTaskStatsDto( + 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/application/service/TaskService.kt b/src/main/kotlin/kr/io/team/loop/task/application/service/TaskService.kt index 9df1635..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 @@ -1,11 +1,14 @@ 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.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 +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 @@ -34,6 +37,21 @@ class TaskService( return taskRepository.updateStatus(command) } + @Transactional(readOnly = true) + fun getStatsByGoalIds(goalIds: Set): Map { + val tasks = taskRepository.findAllByGoalIds(goalIds) + return tasks + .groupBy { it.goalId } + .map { (goalId, goalTasks) -> + goalId to + GoalTaskStatsDto( + goalId = goalId, + totalCount = goalTasks.size, + completedCount = goalTasks.count { it.status == TaskStatus.DONE }, + ) + }.toMap() + } + @Transactional fun delete( command: TaskCommand.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 1c33bf0..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 @@ -1,5 +1,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.Task import kr.io.team.loop.task.domain.model.TaskCommand @@ -15,4 +16,6 @@ interface TaskRepository { fun findAll(query: TaskQuery): List fun findById(id: TaskId): Task? + + 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 98ec23c..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 @@ -15,6 +15,7 @@ import org.jetbrains.exposed.v1.core.SortOrder import org.jetbrains.exposed.v1.core.and 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.lessEq import org.jetbrains.exposed.v1.jdbc.deleteWhere import org.jetbrains.exposed.v1.jdbc.insert @@ -81,6 +82,15 @@ class ExposedTaskRepository : TaskRepository { .singleOrNull() ?.toTask() + override fun findAllByGoalIds(goalIds: Set): List { + if (goalIds.isEmpty()) return emptyList() + val goalIdValues = goalIds.map { it.value } + return TaskTable + .selectAll() + .where { TaskTable.goalId inList goalIdValues } + .map { it.toTask() } + } + 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..142af18 --- /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.application.dto.GoalTaskStatsDto +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..71c332d --- /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.dto.GoalTaskStatsDto +import kr.io.team.loop.task.application.service.TaskService +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/dto/GoalTaskStatsDtoTest.kt b/src/test/kotlin/kr/io/team/loop/task/application/dto/GoalTaskStatsDtoTest.kt new file mode 100644 index 0000000..2a35251 --- /dev/null +++ b/src/test/kotlin/kr/io/team/loop/task/application/dto/GoalTaskStatsDtoTest.kt @@ -0,0 +1,43 @@ +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 GoalTaskStatsDtoTest : + BehaviorSpec({ + + Given("GoalTaskStatsDto 달성률 계산 시") { + When("할일이 있으면") { + val stats = GoalTaskStatsDto(goalId = GoalId(1L), totalCount = 10, completedCount = 7) + + Then("달성률을 백분율로 반환한다") { + stats.achievementRate shouldBe 70.0 + } + } + + When("모든 할일이 완료되면") { + val stats = GoalTaskStatsDto(goalId = GoalId(1L), totalCount = 5, completedCount = 5) + + Then("달성률 100.0을 반환한다") { + stats.achievementRate shouldBe 100.0 + } + } + + When("할일이 없으면") { + val stats = GoalTaskStatsDto(goalId = GoalId(1L), totalCount = 0, completedCount = 0) + + Then("달성률 0.0을 반환한다") { + stats.achievementRate shouldBe 0.0 + } + } + + When("완료된 할일이 없으면") { + val stats = GoalTaskStatsDto(goalId = GoalId(1L), totalCount = 3, completedCount = 0) + + Then("달성률 0.0을 반환한다") { + stats.achievementRate shouldBe 0.0 + } + } + } + }) 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..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 @@ -129,6 +129,49 @@ 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 goalIds = setOf(goalId1, goalId2) + val tasks = + listOf( + task(goalId1, TaskStatus.TODO), + task(goalId1, TaskStatus.DONE), + task(goalId1, TaskStatus.DONE), + task(goalId2, TaskStatus.DONE), + ) + every { taskRepository.findAllByGoalIds(goalIds) } returns tasks + + val result = taskService.getStatsByGoalIds(goalIds) + + Then("목표별 통계를 반환한다") { + 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.findAllByGoalIds(goalIds) } returns emptyList() + + val result = taskService.getStatsByGoalIds(goalIds) + + Then("빈 맵을 반환한다") { + result shouldBe emptyMap() + } + } + } + Given("할일 삭제 시") { When("본인 할일이면") { val command = TaskCommand.Delete(taskId = TaskId(1L))