From 4f4bab62dba4e10f8d8c5c6b804d921738256ec6 Mon Sep 17 00:00:00 2001 From: robinjoon Date: Wed, 25 Mar 2026 15:27:54 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20Goal/DailyGoal=20=EC=82=AD=EC=A0=9C?= =?UTF-8?q?=20=EC=8B=9C=20=EC=97=B0=EA=B4=80=20Task=20=EC=82=AD=EC=A0=9C?= =?UTF-8?q?=20=E2=80=94=20BC=20=EA=B0=84=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20?= =?UTF-8?q?=ED=86=B5=EC=8B=A0=20(#40)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spring ApplicationEvent 기반 BC 간 이벤트 통신을 도입하여 Goal 삭제 시 GoalDeletedEvent, DailyGoal 제거 시 DailyGoalRemovedEvent를 발행하고 Task BC에서 수신하여 연관 Task를 삭제합니다. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/architecture.md | 10 +- docs/layers/bc-event.md | 139 ++++++++++++++++++ .../#40-goal-delete-task-cascade/checklist.md | 12 ++ .../plan/#40-goal-delete-task-cascade/plan.md | 13 ++ .../domain/event/DailyGoalRemovedEvent.kt | 9 ++ .../common/domain/event/GoalDeletedEvent.kt | 7 + .../goal/application/service/GoalService.kt | 6 + .../task/application/service/TaskService.kt | 14 ++ .../task/domain/repository/TaskRepository.kt | 8 + .../persistence/ExposedTaskRepository.kt | 12 ++ .../application/service/GoalServiceTest.kt | 20 ++- .../application/service/TaskServiceTest.kt | 29 ++++ 12 files changed, 277 insertions(+), 2 deletions(-) create mode 100644 docs/layers/bc-event.md create mode 100644 docs/plan/#40-goal-delete-task-cascade/checklist.md create mode 100644 docs/plan/#40-goal-delete-task-cascade/plan.md create mode 100644 src/main/kotlin/kr/io/team/loop/common/domain/event/DailyGoalRemovedEvent.kt create mode 100644 src/main/kotlin/kr/io/team/loop/common/domain/event/GoalDeletedEvent.kt diff --git a/docs/architecture.md b/docs/architecture.md index 04778ee..5cc43c6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -25,6 +25,7 @@ kr.io.team.loop/ ├── ServerApplication.kt ├── common/ # 공통 모듈 │ ├── domain/ # 공유 VO (MemberId, TaskId 등) +│ │ └── event/ # 공유 이벤트 (BC 간 통신) │ └── config/ # 공통 설정 (Security, GraphQL 등) └── {bounded-context}/ # 도메인별 Bounded Context ├── presentation/ @@ -32,6 +33,7 @@ kr.io.team.loop/ │ └── dataloader/ # @DgsDataLoader (N+1 방지, 필요시에만) ├── application/ │ ├── dto/ # (선택) 변환 필요시에만 생성 + │ ├── listener/ # (선택) BC 간 이벤트 수신 │ └── service/ # BC당 1개 Service 기본 ├── domain/ │ ├── model/ # 엔티티, VO, Command, Query @@ -54,7 +56,8 @@ src/main/resources/schema/ # GraphQL 스키마 정의 - **[Application Layer](layers/application.md)** — Service (BC당 1개 기본, 150줄 분리), Application DTO (선택적) - **[Infrastructure Layer](layers/infrastructure.md)** — Exposed Table (BC 소유), Repository 구현체, BC 간 FK 참조 - **[Presentation Layer](layers/presentation.md)** — GraphQL 스키마, DGS DataFetcher, DataLoader -- **Common** — 공유 VO(`domain/`), 공통 설정(`config/`). 2개 이상 BC에서 사용하는 VO만 배치. 필요한 것만 생성. +- **[BC 간 이벤트 통신](layers/bc-event.md)** — Spring ApplicationEvent 기반 BC 간 상태 변경 전파 +- **Common** — 공유 VO(`domain/`), 공유 이벤트(`domain/event/`), 공통 설정(`config/`). 2개 이상 BC에서 사용하는 것만 배치. 필요한 것만 생성. ## 데이터 흐름 @@ -108,6 +111,7 @@ DataFetcher: List 반환 → DGS가 스키마 기반으로 필드 직렬 - **Domain 레이어**: `common/domain/`의 공유 VO만 사용 (예: `MemberId`, `TaskId`) - **Infrastructure 레이어**: 다른 BC의 Table을 FK로 참조 허용 +- **Application 레이어**: 상태 변경 전파가 필요하면 [이벤트 통신](layers/bc-event.md) 사용 (Spring ApplicationEvent) - **직접 참조 금지**: common을 제외한 다른 BC의 코드를 직접 import하지 않음 - **publicapi 불필요**: 현재 규모에서는 BC 간 public API 레이어를 만들지 않음 @@ -125,6 +129,8 @@ DataFetcher: List 반환 → DGS가 스키마 기반으로 필드 직렬 | Application | Service | `{BC}Service.kt` | `TaskService.kt` | | Application | Service (분리시) | `{BC}{Command\|Query}Service.kt` | `TaskCommandService.kt` | | Application | DTO | `{Name}Dto.kt` | `AuthTokenDto.kt` | +| Application | Event Listener | `{발행BC}EventListener.kt` | `GoalEventListener.kt` | +| Common | Event | `{Entity}{과거분사}Event.kt` | `GoalDeletedEvent.kt` | | Infrastructure | Table | `{Entity}Table.kt` | `TaskTable.kt` | | Infrastructure | Repository 구현체 | `Exposed{Entity}Repository.kt` | `ExposedTaskRepository.kt` | | Presentation | DataFetcher | `{BC}DataFetcher.kt` | `TaskDataFetcher.kt` | @@ -142,6 +148,8 @@ DataFetcher: List 반환 → DGS가 스키마 기반으로 필드 직렬 | Service 분리 | 단일 Service가 150줄 초과할 때 | | Domain Service | 도메인 객체 여럿 엮임 + 100줄 이상 + 여러 repo 조합이 아닐 때 | | `application/dto/` 디렉토리 | Application DTO가 1개 이상 필요할 때 | +| `application/listener/` 디렉토리 | 이벤트 핸들러가 2개 이상이거나 로직이 단순 위임을 넘어설 때 | +| `common/domain/event/` 이벤트 | BC 간 상태 변경 전파가 필요할 때 | | `infrastructure/external/` | 외부 API 호출이 필요할 때 | | DataLoader | 부모 목록 조회 시 자식 필드가 N+1 쿼리를 유발할 때 | | 새 파일 생성 | 기존 파일에 추가할 수 없는지 먼저 확인 | diff --git a/docs/layers/bc-event.md b/docs/layers/bc-event.md new file mode 100644 index 0000000..5d3469a --- /dev/null +++ b/docs/layers/bc-event.md @@ -0,0 +1,139 @@ +# BC 간 이벤트 통신 + +BC 간 직접 참조 없이 상태 변경을 전파하는 방법. Spring `ApplicationEventPublisher`를 사용합니다. + +## 언제 사용하는가 + +한 BC의 상태 변경이 다른 BC의 후속 작업을 유발할 때 사용합니다. + +| 조건 | 설명 | +|------|------| +| BC 간 직접 참조가 불가능할 때 | common을 제외한 다른 BC의 코드를 import할 수 없으므로 | +| FK CASCADE로 해결할 수 없을 때 | DB 레벨 제약은 Infrastructure 레이어 결합이므로 비즈니스 로직 전파에 부적합 | +| 발행 BC가 수신 BC의 존재를 몰라야 할 때 | 의존 역전: 발행자는 이벤트만 발행, 누가 수신하는지 모름 | + +## 이벤트 정의 + +`common/domain/event/`에 위치합니다. 2개 이상의 BC가 관여하는 공유 계약이므로 common에 배치합니다. + +```kotlin +// common/domain/event/GoalDeletedEvent.kt +data class GoalDeletedEvent( + val goalId: GoalId, +) +``` + +**규칙**: +- 이벤트 클래스는 **불변 data class** +- 수신자가 후속 작업에 필요한 **최소한의 식별자만** 포함 (VO 사용) +- 명명: `{Entity}{과거분사}Event` (예: `GoalDeletedEvent`, `MemberRegisteredEvent`) +- 비즈니스 로직을 포함하지 않음 + +## 발행 (Publisher) + +Application Service에서 `ApplicationEventPublisher`를 주입받아 발행합니다. + +```kotlin +@Service +class GoalService( + private val goalRepository: GoalRepository, + private val eventPublisher: ApplicationEventPublisher, +) { + @Transactional + fun delete(command: GoalCommand.Delete) { + val goal = goalRepository.findById(command.goalId) + ?: throw NoSuchElementException("Goal not found: ${command.goalId.value}") + + goalRepository.delete(command) + eventPublisher.publishEvent(GoalDeletedEvent(goal.id)) + } +} +``` + +**규칙**: +- 상태 변경(DB 반영) **이후**에 이벤트를 발행 +- 발행 BC는 수신 BC의 존재를 모름 — import하지 않음 +- 하나의 비즈니스 동작에서 하나의 이벤트 발행이 기본 + +## 수신 (Listener) + +수신 BC의 Application Service(또는 전용 EventListener 클래스)에서 `@TransactionalEventListener`로 수신합니다. + +```kotlin +@Service +class TaskService( + private val taskRepository: TaskRepository, +) { + @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT) + fun handleGoalDeleted(event: GoalDeletedEvent) { + taskRepository.deleteByGoalId(event.goalId) + } +} +``` + +**규칙**: +- 기본적으로 `BEFORE_COMMIT` 사용 — 발행자와 같은 트랜잭션에서 원자적 처리 +- 수신 로직이 실패하면 발행자의 트랜잭션도 롤백됨 (데이터 일관성 보장) +- 수신 메서드명: `handle{EventName}` (예: `handleGoalDeleted`) + +### TransactionPhase 선택 기준 + +| Phase | 트랜잭션 | 사용 시점 | +|-------|----------|-----------| +| `BEFORE_COMMIT` | 발행자와 동일 | **기본값**. 원자성이 필요할 때 (삭제 전파, 상태 동기화) | +| `AFTER_COMMIT` | 별도 트랜잭션 | 실패해도 발행자에 영향 없어야 할 때 (알림, 로그) | + +## 수신 로직이 커질 때 + +이벤트 리스너 로직이 Service에 섞이기 어려울 만큼 커지면 전용 클래스로 분리합니다. + +```kotlin +// task/application/listener/GoalEventListener.kt +@Component +class GoalEventListener( + private val taskRepository: TaskRepository, +) { + @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT) + fun handleGoalDeleted(event: GoalDeletedEvent) { + taskRepository.deleteByGoalId(event.goalId) + } +} +``` + +**분리 기준**: 이벤트 핸들러가 2개 이상이거나, 핸들러 로직이 단순 위임을 넘어설 때. + +## 디렉토리 구조 + +```text +common/ +└── domain/ + └── event/ # 공유 이벤트 클래스 + └── GoalDeletedEvent.kt + +{publisher-bc}/ +└── application/ + └── service/ + └── GoalService.kt # eventPublisher.publishEvent(...) + +{subscriber-bc}/ +└── application/ + ├── service/ + │ └── TaskService.kt # @TransactionalEventListener (간단한 경우) + └── listener/ # (선택) 전용 리스너 클래스 + └── GoalEventListener.kt +``` + +## 파일 명명 규칙 + +| 위치 | 명명 규칙 | 예시 | +|------|-----------|------| +| 이벤트 클래스 | `{Entity}{과거분사}Event.kt` | `GoalDeletedEvent.kt` | +| 전용 리스너 | `{발행BC}EventListener.kt` | `GoalEventListener.kt` | + +## 테스트 + +| 대상 | 유형 | 방식 | +|------|------|------| +| 발행 (Service) | 단위 테스트 | MockK으로 `ApplicationEventPublisher` mock, `verify { publishEvent(any()) }` | +| 수신 (Listener) | 단위 테스트 | 이벤트 객체를 직접 생성하여 핸들러 메서드 호출, Repository mock으로 삭제 검증 | +| 통합 (발행→수신) | 통합 테스트 | `@SpringBootTest`에서 발행 후 수신 BC의 상태 변경 확인 (선택적) | diff --git a/docs/plan/#40-goal-delete-task-cascade/checklist.md b/docs/plan/#40-goal-delete-task-cascade/checklist.md new file mode 100644 index 0000000..3c99b6c --- /dev/null +++ b/docs/plan/#40-goal-delete-task-cascade/checklist.md @@ -0,0 +1,12 @@ +# Goal/DailyGoal 삭제 시 연관 Task 삭제 검증 체크리스트 + +## 필수 항목 +- [x] 아키텍처 원칙 준수 (docs/architecture.md 기준) +- [x] BC 간 이벤트 통신 규칙 준수 (docs/layers/bc-event.md 기준) +- [x] 레이어 의존성 규칙 위반 없음 +- [x] 테스트 코드 작성 완료 (Domain, Application 필수) +- [x] 모든 테스트 통과 +- [x] 기존 테스트 깨지지 않음 + +## 선택 항목 (해당 시) +- [x] BC 간 통신 규칙 준수 (이벤트 클래스는 common/domain/event/에 배치) diff --git a/docs/plan/#40-goal-delete-task-cascade/plan.md b/docs/plan/#40-goal-delete-task-cascade/plan.md new file mode 100644 index 0000000..53a189a --- /dev/null +++ b/docs/plan/#40-goal-delete-task-cascade/plan.md @@ -0,0 +1,13 @@ +# Goal/DailyGoal 삭제 시 연관 Task 삭제 계획 + +> Issue: #40 + +## 단계 + +- [x] 1단계: 이벤트 클래스 생성 — GoalDeletedEvent, DailyGoalRemovedEvent (common/domain/event/) +- [x] 2단계: TaskRepository 인터페이스에 deleteByGoalId, deleteByGoalIdAndTaskDate 추가 +- [x] 3단계: GoalService 이벤트 발행 TDD — RED(테스트 작성) → GREEN(구현) → REFACTOR +- [x] 4단계: TaskService 이벤트 수신 TDD — RED(테스트 작성) → GREEN(구현) → REFACTOR +- [x] 5단계: ExposedTaskRepository에 deleteByGoalId, deleteByGoalIdAndTaskDate 구현 +- [x] 6단계: 전체 테스트 통과 확인 및 검증 체크리스트 완료 +- [x] 7단계: 코드 리뷰 피드백 반영 (FQN import 정리) diff --git a/src/main/kotlin/kr/io/team/loop/common/domain/event/DailyGoalRemovedEvent.kt b/src/main/kotlin/kr/io/team/loop/common/domain/event/DailyGoalRemovedEvent.kt new file mode 100644 index 0000000..8ff9ee5 --- /dev/null +++ b/src/main/kotlin/kr/io/team/loop/common/domain/event/DailyGoalRemovedEvent.kt @@ -0,0 +1,9 @@ +package kr.io.team.loop.common.domain.event + +import kotlinx.datetime.LocalDate +import kr.io.team.loop.common.domain.GoalId + +data class DailyGoalRemovedEvent( + val goalId: GoalId, + val date: LocalDate, +) diff --git a/src/main/kotlin/kr/io/team/loop/common/domain/event/GoalDeletedEvent.kt b/src/main/kotlin/kr/io/team/loop/common/domain/event/GoalDeletedEvent.kt new file mode 100644 index 0000000..22c391b --- /dev/null +++ b/src/main/kotlin/kr/io/team/loop/common/domain/event/GoalDeletedEvent.kt @@ -0,0 +1,7 @@ +package kr.io.team.loop.common.domain.event + +import kr.io.team.loop.common.domain.GoalId + +data class GoalDeletedEvent( + val goalId: GoalId, +) diff --git a/src/main/kotlin/kr/io/team/loop/goal/application/service/GoalService.kt b/src/main/kotlin/kr/io/team/loop/goal/application/service/GoalService.kt index 6ef6ece..639258c 100644 --- a/src/main/kotlin/kr/io/team/loop/goal/application/service/GoalService.kt +++ b/src/main/kotlin/kr/io/team/loop/goal/application/service/GoalService.kt @@ -2,6 +2,8 @@ package kr.io.team.loop.goal.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.event.DailyGoalRemovedEvent +import kr.io.team.loop.common.domain.event.GoalDeletedEvent import kr.io.team.loop.common.domain.exception.AccessDeniedException import kr.io.team.loop.common.domain.exception.DuplicateEntityException import kr.io.team.loop.common.domain.exception.EntityNotFoundException @@ -11,6 +13,7 @@ import kr.io.team.loop.goal.domain.model.GoalCommand import kr.io.team.loop.goal.domain.model.GoalQuery import kr.io.team.loop.goal.domain.repository.DailyGoalRepository import kr.io.team.loop.goal.domain.repository.GoalRepository +import org.springframework.context.ApplicationEventPublisher import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -18,6 +21,7 @@ import org.springframework.transaction.annotation.Transactional class GoalService( private val goalRepository: GoalRepository, private val dailyGoalRepository: DailyGoalRepository, + private val eventPublisher: ApplicationEventPublisher, ) { @Transactional fun create(command: GoalCommand.Create): Goal = goalRepository.save(command) @@ -56,6 +60,7 @@ class GoalService( throw AccessDeniedException("Goal does not belong to member: ${memberId.value}") } goalRepository.delete(command) + eventPublisher.publishEvent(GoalDeletedEvent(command.goalId)) } @Transactional @@ -83,5 +88,6 @@ class GoalService( ) } dailyGoalRepository.delete(command) + eventPublisher.publishEvent(DailyGoalRemovedEvent(command.goalId, command.date)) } } 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 0bf3f47..58e1ddb 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 @@ -2,6 +2,8 @@ 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.event.DailyGoalRemovedEvent +import kr.io.team.loop.common.domain.event.GoalDeletedEvent 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 @@ -12,6 +14,8 @@ 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 +import org.springframework.transaction.event.TransactionPhase +import org.springframework.transaction.event.TransactionalEventListener @Service class TaskService( @@ -65,4 +69,14 @@ class TaskService( } taskRepository.delete(command) } + + @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT) + fun handleGoalDeleted(event: GoalDeletedEvent) { + taskRepository.deleteByGoalId(event.goalId) + } + + @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT) + fun handleDailyGoalRemoved(event: DailyGoalRemovedEvent) { + taskRepository.deleteByGoalIdAndTaskDate(event.goalId, event.date) + } } 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 e7ebf08..51deb4d 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 kotlinx.datetime.LocalDate import kr.io.team.loop.common.domain.GoalId import kr.io.team.loop.task.domain.model.Task import kr.io.team.loop.task.domain.model.TaskCommand @@ -13,6 +14,13 @@ interface TaskRepository { fun delete(command: TaskCommand.Delete) + fun deleteByGoalId(goalId: GoalId) + + fun deleteByGoalIdAndTaskDate( + goalId: GoalId, + taskDate: LocalDate, + ) + fun findAll(query: TaskQuery): List fun findById(id: TaskId): Task? 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 76be321..7f02c86 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 @@ -1,5 +1,6 @@ package kr.io.team.loop.task.infrastructure.persistence +import kotlinx.datetime.LocalDate import kr.io.team.loop.common.domain.GoalId import kr.io.team.loop.common.domain.MemberId import kr.io.team.loop.task.domain.model.Task @@ -63,6 +64,17 @@ class ExposedTaskRepository : TaskRepository { TaskTable.deleteWhere { taskId eq command.taskId.value } } + override fun deleteByGoalId(goalId: GoalId) { + TaskTable.deleteWhere { TaskTable.goalId eq goalId.value } + } + + override fun deleteByGoalIdAndTaskDate( + goalId: GoalId, + taskDate: LocalDate, + ) { + TaskTable.deleteWhere { (TaskTable.goalId eq goalId.value) and (TaskTable.taskDate eq taskDate) } + } + override fun findAll(query: TaskQuery): List { var condition: Op = Op.TRUE query.memberId?.let { condition = condition and (TaskTable.memberId eq it.value) } diff --git a/src/test/kotlin/kr/io/team/loop/goal/application/service/GoalServiceTest.kt b/src/test/kotlin/kr/io/team/loop/goal/application/service/GoalServiceTest.kt index 5f361d8..70db55f 100644 --- a/src/test/kotlin/kr/io/team/loop/goal/application/service/GoalServiceTest.kt +++ b/src/test/kotlin/kr/io/team/loop/goal/application/service/GoalServiceTest.kt @@ -7,10 +7,13 @@ import io.kotest.matchers.shouldBe import io.mockk.every import io.mockk.justRun import io.mockk.mockk +import io.mockk.slot import io.mockk.verify import kotlinx.datetime.LocalDate import kr.io.team.loop.common.domain.GoalId import kr.io.team.loop.common.domain.MemberId +import kr.io.team.loop.common.domain.event.DailyGoalRemovedEvent +import kr.io.team.loop.common.domain.event.GoalDeletedEvent import kr.io.team.loop.common.domain.exception.AccessDeniedException import kr.io.team.loop.common.domain.exception.DuplicateEntityException import kr.io.team.loop.common.domain.exception.EntityNotFoundException @@ -23,6 +26,7 @@ import kr.io.team.loop.goal.domain.model.GoalQuery import kr.io.team.loop.goal.domain.model.GoalTitle import kr.io.team.loop.goal.domain.repository.DailyGoalRepository import kr.io.team.loop.goal.domain.repository.GoalRepository +import org.springframework.context.ApplicationEventPublisher import java.time.Instant class GoalServiceTest : @@ -30,7 +34,8 @@ class GoalServiceTest : val goalRepository = mockk() val dailyGoalRepository = mockk() - val goalService = GoalService(goalRepository, dailyGoalRepository) + val eventPublisher = mockk(relaxed = true) + val goalService = GoalService(goalRepository, dailyGoalRepository, eventPublisher) val memberId = MemberId(1L) val otherMemberId = MemberId(2L) @@ -133,6 +138,12 @@ class GoalServiceTest : Then("삭제가 수행된다") { verify { goalRepository.delete(command) } } + + Then("GoalDeletedEvent가 발행된다") { + val eventSlot = slot() + verify { eventPublisher.publishEvent(capture(eventSlot)) } + eventSlot.captured.goalId shouldBe GoalId(1L) + } } When("존재하지 않는 목표이면") { @@ -229,6 +240,13 @@ class GoalServiceTest : Then("삭제가 수행된다") { verify { dailyGoalRepository.delete(command) } } + + Then("DailyGoalRemovedEvent가 발행된다") { + val eventSlot = slot() + verify { eventPublisher.publishEvent(capture(eventSlot)) } + eventSlot.captured.goalId shouldBe GoalId(1L) + eventSlot.captured.date shouldBe date + } } When("해당 날짜에 목표가 배치되어 있지 않으면") { 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 e8d92f0..50288a6 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 @@ -11,6 +11,8 @@ import io.mockk.verify import kotlinx.datetime.LocalDate import kr.io.team.loop.common.domain.GoalId import kr.io.team.loop.common.domain.MemberId +import kr.io.team.loop.common.domain.event.DailyGoalRemovedEvent +import kr.io.team.loop.common.domain.event.GoalDeletedEvent 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.Task @@ -203,6 +205,33 @@ class TaskServiceTest : } } + Given("GoalDeletedEvent 수신 시") { + When("해당 goalId의 Task가 있으면") { + val event = GoalDeletedEvent(goalId = GoalId(1L)) + justRun { taskRepository.deleteByGoalId(GoalId(1L)) } + + taskService.handleGoalDeleted(event) + + Then("해당 goalId의 모든 Task가 삭제된다") { + verify { taskRepository.deleteByGoalId(GoalId(1L)) } + } + } + } + + Given("DailyGoalRemovedEvent 수신 시") { + When("해당 goalId와 date의 Task가 있으면") { + val date = LocalDate(2025, 2, 20) + val event = DailyGoalRemovedEvent(goalId = GoalId(1L), date = date) + justRun { taskRepository.deleteByGoalIdAndTaskDate(GoalId(1L), date) } + + taskService.handleDailyGoalRemoved(event) + + Then("해당 goalId와 date의 Task가 삭제된다") { + verify { taskRepository.deleteByGoalIdAndTaskDate(GoalId(1L), date) } + } + } + } + Given("할일 삭제 시") { When("본인 할일이면") { val command = TaskCommand.Delete(taskId = TaskId(1L)) From f29bd943ce0e9a808638d570c3a18eb5b566efa3 Mon Sep 17 00:00:00 2001 From: robinjoon Date: Wed, 25 Mar 2026 15:49:44 +0900 Subject: [PATCH 2/3] =?UTF-8?q?fix:=20=EC=BD=94=EB=93=9C=20=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0=20=EB=B0=98=EC=98=81=20=E2=80=94=20DailyGoalRemovedEv?= =?UTF-8?q?ent=20memberId=20=EC=B6=94=EA=B0=80,=20Goal=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C=20=EC=8B=9C=20DailyGoal=20=EC=A0=95=EB=A6=AC=20(#40)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DailyGoalRemovedEvent에 memberId를 추가하여 특정 멤버의 Task만 삭제되도록 범위 제한 - Goal 삭제 시 연관 DailyGoal을 먼저 삭제하여 고아 레코드 방지 Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/plan/#40-goal-delete-task-cascade/plan.md | 2 ++ .../loop/common/domain/event/DailyGoalRemovedEvent.kt | 2 ++ .../team/loop/goal/application/service/GoalService.kt | 3 ++- .../loop/goal/domain/repository/DailyGoalRepository.kt | 2 ++ .../persistence/ExposedDailyGoalRepository.kt | 4 ++++ .../team/loop/task/application/service/TaskService.kt | 2 +- .../team/loop/task/domain/repository/TaskRepository.kt | 4 +++- .../persistence/ExposedTaskRepository.kt | 9 +++++++-- .../loop/goal/application/service/GoalServiceTest.kt | 6 ++++++ .../loop/task/application/service/TaskServiceTest.kt | 10 +++++----- 10 files changed, 34 insertions(+), 10 deletions(-) diff --git a/docs/plan/#40-goal-delete-task-cascade/plan.md b/docs/plan/#40-goal-delete-task-cascade/plan.md index 53a189a..a4c9ab0 100644 --- a/docs/plan/#40-goal-delete-task-cascade/plan.md +++ b/docs/plan/#40-goal-delete-task-cascade/plan.md @@ -11,3 +11,5 @@ - [x] 5단계: ExposedTaskRepository에 deleteByGoalId, deleteByGoalIdAndTaskDate 구현 - [x] 6단계: 전체 테스트 통과 확인 및 검증 체크리스트 완료 - [x] 7단계: 코드 리뷰 피드백 반영 (FQN import 정리) +- [x] 8단계: DailyGoalRemovedEvent에 memberId 추가 — TDD +- [x] 9단계: Goal 삭제 시 DailyGoal 정리 추가 — TDD diff --git a/src/main/kotlin/kr/io/team/loop/common/domain/event/DailyGoalRemovedEvent.kt b/src/main/kotlin/kr/io/team/loop/common/domain/event/DailyGoalRemovedEvent.kt index 8ff9ee5..5b2c512 100644 --- a/src/main/kotlin/kr/io/team/loop/common/domain/event/DailyGoalRemovedEvent.kt +++ b/src/main/kotlin/kr/io/team/loop/common/domain/event/DailyGoalRemovedEvent.kt @@ -2,8 +2,10 @@ package kr.io.team.loop.common.domain.event import kotlinx.datetime.LocalDate import kr.io.team.loop.common.domain.GoalId +import kr.io.team.loop.common.domain.MemberId data class DailyGoalRemovedEvent( val goalId: GoalId, + val memberId: MemberId, val date: LocalDate, ) diff --git a/src/main/kotlin/kr/io/team/loop/goal/application/service/GoalService.kt b/src/main/kotlin/kr/io/team/loop/goal/application/service/GoalService.kt index 639258c..5c5429f 100644 --- a/src/main/kotlin/kr/io/team/loop/goal/application/service/GoalService.kt +++ b/src/main/kotlin/kr/io/team/loop/goal/application/service/GoalService.kt @@ -59,6 +59,7 @@ class GoalService( if (!goal.isOwnedBy(memberId)) { throw AccessDeniedException("Goal does not belong to member: ${memberId.value}") } + dailyGoalRepository.deleteByGoalId(command.goalId) goalRepository.delete(command) eventPublisher.publishEvent(GoalDeletedEvent(command.goalId)) } @@ -88,6 +89,6 @@ class GoalService( ) } dailyGoalRepository.delete(command) - eventPublisher.publishEvent(DailyGoalRemovedEvent(command.goalId, command.date)) + eventPublisher.publishEvent(DailyGoalRemovedEvent(command.goalId, command.memberId, command.date)) } } diff --git a/src/main/kotlin/kr/io/team/loop/goal/domain/repository/DailyGoalRepository.kt b/src/main/kotlin/kr/io/team/loop/goal/domain/repository/DailyGoalRepository.kt index 90ab0c3..9c87b9c 100644 --- a/src/main/kotlin/kr/io/team/loop/goal/domain/repository/DailyGoalRepository.kt +++ b/src/main/kotlin/kr/io/team/loop/goal/domain/repository/DailyGoalRepository.kt @@ -11,6 +11,8 @@ interface DailyGoalRepository { fun delete(command: DailyGoalCommand.Remove) + fun deleteByGoalId(goalId: GoalId) + fun existsByGoalIdAndMemberIdAndDate( goalId: GoalId, memberId: MemberId, diff --git a/src/main/kotlin/kr/io/team/loop/goal/infrastructure/persistence/ExposedDailyGoalRepository.kt b/src/main/kotlin/kr/io/team/loop/goal/infrastructure/persistence/ExposedDailyGoalRepository.kt index 39a9b8a..9ec106c 100644 --- a/src/main/kotlin/kr/io/team/loop/goal/infrastructure/persistence/ExposedDailyGoalRepository.kt +++ b/src/main/kotlin/kr/io/team/loop/goal/infrastructure/persistence/ExposedDailyGoalRepository.kt @@ -43,6 +43,10 @@ class ExposedDailyGoalRepository : DailyGoalRepository { } } + override fun deleteByGoalId(goalId: GoalId) { + DailyGoalTable.deleteWhere { DailyGoalTable.goalId eq goalId.value } + } + override fun existsByGoalIdAndMemberIdAndDate( goalId: GoalId, memberId: MemberId, 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 58e1ddb..c79b20a 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 @@ -77,6 +77,6 @@ class TaskService( @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT) fun handleDailyGoalRemoved(event: DailyGoalRemovedEvent) { - taskRepository.deleteByGoalIdAndTaskDate(event.goalId, event.date) + taskRepository.deleteByGoalIdAndMemberIdAndTaskDate(event.goalId, event.memberId, event.date) } } 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 51deb4d..33bb793 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,6 +2,7 @@ package kr.io.team.loop.task.domain.repository import kotlinx.datetime.LocalDate import kr.io.team.loop.common.domain.GoalId +import kr.io.team.loop.common.domain.MemberId 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.TaskId @@ -16,8 +17,9 @@ interface TaskRepository { fun deleteByGoalId(goalId: GoalId) - fun deleteByGoalIdAndTaskDate( + fun deleteByGoalIdAndMemberIdAndTaskDate( goalId: GoalId, + memberId: MemberId, taskDate: LocalDate, ) 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 7f02c86..618b765 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 @@ -68,11 +68,16 @@ class ExposedTaskRepository : TaskRepository { TaskTable.deleteWhere { TaskTable.goalId eq goalId.value } } - override fun deleteByGoalIdAndTaskDate( + override fun deleteByGoalIdAndMemberIdAndTaskDate( goalId: GoalId, + memberId: MemberId, taskDate: LocalDate, ) { - TaskTable.deleteWhere { (TaskTable.goalId eq goalId.value) and (TaskTable.taskDate eq taskDate) } + TaskTable.deleteWhere { + (TaskTable.goalId eq goalId.value) and + (TaskTable.memberId eq memberId.value) and + (TaskTable.taskDate eq taskDate) + } } override fun findAll(query: TaskQuery): List { diff --git a/src/test/kotlin/kr/io/team/loop/goal/application/service/GoalServiceTest.kt b/src/test/kotlin/kr/io/team/loop/goal/application/service/GoalServiceTest.kt index 70db55f..cd20b92 100644 --- a/src/test/kotlin/kr/io/team/loop/goal/application/service/GoalServiceTest.kt +++ b/src/test/kotlin/kr/io/team/loop/goal/application/service/GoalServiceTest.kt @@ -131,10 +131,15 @@ class GoalServiceTest : When("본인 목표이면") { val command = GoalCommand.Delete(goalId = GoalId(1L)) every { goalRepository.findById(GoalId(1L)) } returns savedGoal + justRun { dailyGoalRepository.deleteByGoalId(GoalId(1L)) } justRun { goalRepository.delete(command) } goalService.delete(command, memberId) + Then("연관된 DailyGoal이 먼저 삭제된다") { + verify { dailyGoalRepository.deleteByGoalId(GoalId(1L)) } + } + Then("삭제가 수행된다") { verify { goalRepository.delete(command) } } @@ -245,6 +250,7 @@ class GoalServiceTest : val eventSlot = slot() verify { eventPublisher.publishEvent(capture(eventSlot)) } eventSlot.captured.goalId shouldBe GoalId(1L) + eventSlot.captured.memberId shouldBe memberId eventSlot.captured.date shouldBe date } } 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 50288a6..40ecbae 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 @@ -219,15 +219,15 @@ class TaskServiceTest : } Given("DailyGoalRemovedEvent 수신 시") { - When("해당 goalId와 date의 Task가 있으면") { + When("해당 goalId, memberId, date의 Task가 있으면") { val date = LocalDate(2025, 2, 20) - val event = DailyGoalRemovedEvent(goalId = GoalId(1L), date = date) - justRun { taskRepository.deleteByGoalIdAndTaskDate(GoalId(1L), date) } + val event = DailyGoalRemovedEvent(goalId = GoalId(1L), memberId = memberId, date = date) + justRun { taskRepository.deleteByGoalIdAndMemberIdAndTaskDate(GoalId(1L), memberId, date) } taskService.handleDailyGoalRemoved(event) - Then("해당 goalId와 date의 Task가 삭제된다") { - verify { taskRepository.deleteByGoalIdAndTaskDate(GoalId(1L), date) } + Then("해당 goalId, memberId, date의 Task만 삭제된다") { + verify { taskRepository.deleteByGoalIdAndMemberIdAndTaskDate(GoalId(1L), memberId, date) } } } } From b11a13f281c5ad5f51dc5368b973e1afe8634042 Mon Sep 17 00:00:00 2001 From: robinjoon Date: Wed, 25 Mar 2026 15:58:50 +0900 Subject: [PATCH 3/3] =?UTF-8?q?docs:=20bc-event.md=20=EC=98=88=EC=8B=9C=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=EB=A5=BC=20=EC=8B=A4=EC=A0=9C=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=EA=B3=BC=20=EC=9D=BC=EC=B9=98=EC=8B=9C=ED=82=B4=20(#4?= =?UTF-8?q?0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/layers/bc-event.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/layers/bc-event.md b/docs/layers/bc-event.md index 5d3469a..2454350 100644 --- a/docs/layers/bc-event.md +++ b/docs/layers/bc-event.md @@ -41,11 +41,11 @@ class GoalService( ) { @Transactional fun delete(command: GoalCommand.Delete) { - val goal = goalRepository.findById(command.goalId) - ?: throw NoSuchElementException("Goal not found: ${command.goalId.value}") + goalRepository.findById(command.goalId) + ?: throw EntityNotFoundException("Goal not found: ${command.goalId.value}") goalRepository.delete(command) - eventPublisher.publishEvent(GoalDeletedEvent(goal.id)) + eventPublisher.publishEvent(GoalDeletedEvent(command.goalId)) } } ```