Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions docs/plan/#18-goal-task-stats/checklist.md
Original file line number Diff line number Diff line change
@@ -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 방지 확인
22 changes: 22 additions & 0 deletions docs/plan/#18-goal-task-stats/plan.md
Original file line number Diff line number Diff line change
@@ -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으로 이동
Original file line number Diff line number Diff line change
Expand Up @@ -70,5 +70,8 @@ class GoalDataFetcher(
title = title.value,
createdAt = createdAt.toString(),
updatedAt = updatedAt?.toString(),
totalTaskCount = 0,
completedTaskCount = 0,
achievementRate = 0.0,
)
}
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -34,6 +37,21 @@ class TaskService(
return taskRepository.updateStatus(command)
}

@Transactional(readOnly = true)
fun getStatsByGoalIds(goalIds: Set<GoalId>): Map<GoalId, GoalTaskStatsDto> {
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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -15,4 +16,6 @@ interface TaskRepository {
fun findAll(query: TaskQuery): List<Task>

fun findById(id: TaskId): Task?

fun findAllByGoalIds(goalIds: Set<GoalId>): List<Task>
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -81,6 +82,15 @@ class ExposedTaskRepository : TaskRepository {
.singleOrNull()
?.toTask()

override fun findAllByGoalIds(goalIds: Set<GoalId>): List<Task> {
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]),
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Int> {
val goal = dfe.getSource<Goal>()!!
val dataLoader = dfe.getDataLoader<Long, GoalTaskStatsDto>("goalTaskStats")!!
return dataLoader.load(goal.id.toLong()).thenApply { it?.totalCount ?: 0 }
}

@DgsData(parentType = "Goal", field = "completedTaskCount")
fun completedTaskCount(dfe: DgsDataFetchingEnvironment): CompletableFuture<Int> {
val goal = dfe.getSource<Goal>()!!
val dataLoader = dfe.getDataLoader<Long, GoalTaskStatsDto>("goalTaskStats")!!
return dataLoader.load(goal.id.toLong()).thenApply { it?.completedCount ?: 0 }
}

@DgsData(parentType = "Goal", field = "achievementRate")
fun achievementRate(dfe: DgsDataFetchingEnvironment): CompletableFuture<Double> {
val goal = dfe.getSource<Goal>()!!
val dataLoader = dfe.getDataLoader<Long, GoalTaskStatsDto>("goalTaskStats")!!
return dataLoader.load(goal.id.toLong()).thenApply { it?.achievementRate ?: 0.0 }
}
}
Original file line number Diff line number Diff line change
@@ -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<Long, GoalTaskStatsDto> {
override fun load(keys: Set<Long>): CompletionStage<Map<Long, GoalTaskStatsDto>> =
CompletableFuture.supplyAsync {
val goalIds = keys.map { GoalId(it) }.toSet()
val stats = taskService.getStatsByGoalIds(goalIds)
stats.map { (goalId, goalTaskStats) -> goalId.value to goalTaskStats }.toMap()
}
}
6 changes: 6 additions & 0 deletions src/main/resources/schema/goal.graphqls
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@ type Goal {
createdAt: String!
"수정일시"
updatedAt: String
"해당 목표의 전체 할일 수"
totalTaskCount: Int!
"해당 목표의 완료된 할일 수"
completedTaskCount: Int!
"해당 목표의 달성률 (0.0 ~ 100.0, 할일이 없으면 0.0)"
achievementRate: Float!
}

"목표 생성 입력"
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
}
}
})
Original file line number Diff line number Diff line change
Expand Up @@ -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<GoalId>()
every { taskRepository.findAllByGoalIds(goalIds) } returns emptyList()

val result = taskService.getStatsByGoalIds(goalIds)

Then("빈 맵을 반환한다") {
result shouldBe emptyMap()
}
}
}

Given("할일 삭제 시") {
When("본인 할일이면") {
val command = TaskCommand.Delete(taskId = TaskId(1L))
Expand Down