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
10 changes: 9 additions & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,15 @@ kr.io.team.loop/
├── ServerApplication.kt
├── common/ # 공통 모듈
│ ├── domain/ # 공유 VO (MemberId, TaskId 등)
│ │ └── event/ # 공유 이벤트 (BC 간 통신)
│ └── config/ # 공통 설정 (Security, GraphQL 등)
└── {bounded-context}/ # 도메인별 Bounded Context
├── presentation/
│ ├── datafetcher/ # @DgsComponent (Query/Mutation 리졸버)
│ └── dataloader/ # @DgsDataLoader (N+1 방지, 필요시에만)
├── application/
│ ├── dto/ # (선택) 변환 필요시에만 생성
│ ├── listener/ # (선택) BC 간 이벤트 수신
│ └── service/ # BC당 1개 Service 기본
├── domain/
│ ├── model/ # 엔티티, VO, Command, Query
Expand All @@ -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에서 사용하는 것만 배치. 필요한 것만 생성.

## 데이터 흐름

Expand Down Expand Up @@ -108,6 +111,7 @@ DataFetcher: List<Task> 반환 → 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 레이어를 만들지 않음

Expand All @@ -125,6 +129,8 @@ DataFetcher: List<Task> 반환 → 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` |
Expand All @@ -142,6 +148,8 @@ DataFetcher: List<Task> 반환 → 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 쿼리를 유발할 때 |
| 새 파일 생성 | 기존 파일에 추가할 수 없는지 먼저 확인 |
Expand Down
139 changes: 139 additions & 0 deletions docs/layers/bc-event.md
Original file line number Diff line number Diff line change
@@ -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) {
goalRepository.findById(command.goalId)
?: throw EntityNotFoundException("Goal not found: ${command.goalId.value}")

goalRepository.delete(command)
eventPublisher.publishEvent(GoalDeletedEvent(command.goalId))
}
}
```

**규칙**:
- 상태 변경(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<GoalDeletedEvent>()) }` |
| 수신 (Listener) | 단위 테스트 | 이벤트 객체를 직접 생성하여 핸들러 메서드 호출, Repository mock으로 삭제 검증 |
| 통합 (발행→수신) | 통합 테스트 | `@SpringBootTest`에서 발행 후 수신 BC의 상태 변경 확인 (선택적) |
12 changes: 12 additions & 0 deletions docs/plan/#40-goal-delete-task-cascade/checklist.md
Original file line number Diff line number Diff line change
@@ -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/에 배치)
15 changes: 15 additions & 0 deletions docs/plan/#40-goal-delete-task-cascade/plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# 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 정리)
- [x] 8단계: DailyGoalRemovedEvent에 memberId 추가 — TDD
- [x] 9단계: Goal 삭제 시 DailyGoal 정리 추가 — TDD
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
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,
)
Original file line number Diff line number Diff line change
@@ -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,
)
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -11,13 +13,15 @@ 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

@Service
class GoalService(
private val goalRepository: GoalRepository,
private val dailyGoalRepository: DailyGoalRepository,
private val eventPublisher: ApplicationEventPublisher,
) {
@Transactional
fun create(command: GoalCommand.Create): Goal = goalRepository.save(command)
Expand Down Expand Up @@ -55,7 +59,9 @@ 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))
}

@Transactional
Expand Down Expand Up @@ -83,5 +89,6 @@ class GoalService(
)
}
dailyGoalRepository.delete(command)
eventPublisher.publishEvent(DailyGoalRemovedEvent(command.goalId, command.memberId, command.date))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ interface DailyGoalRepository {

fun delete(command: DailyGoalCommand.Remove)

fun deleteByGoalId(goalId: GoalId)

fun existsByGoalIdAndMemberIdAndDate(
goalId: GoalId,
memberId: MemberId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(
Expand Down Expand Up @@ -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.deleteByGoalIdAndMemberIdAndTaskDate(event.goalId, event.memberId, event.date)
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
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
Expand All @@ -13,6 +15,14 @@ interface TaskRepository {

fun delete(command: TaskCommand.Delete)

fun deleteByGoalId(goalId: GoalId)

fun deleteByGoalIdAndMemberIdAndTaskDate(
goalId: GoalId,
memberId: MemberId,
taskDate: LocalDate,
)

fun findAll(query: TaskQuery): List<Task>

fun findById(id: TaskId): Task?
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -63,6 +64,22 @@ class ExposedTaskRepository : TaskRepository {
TaskTable.deleteWhere { taskId eq command.taskId.value }
}

override fun deleteByGoalId(goalId: GoalId) {
TaskTable.deleteWhere { TaskTable.goalId eq goalId.value }
}

override fun deleteByGoalIdAndMemberIdAndTaskDate(
goalId: GoalId,
memberId: MemberId,
taskDate: LocalDate,
) {
TaskTable.deleteWhere {
(TaskTable.goalId eq goalId.value) and
(TaskTable.memberId eq memberId.value) and
(TaskTable.taskDate eq taskDate)
}
}

override fun findAll(query: TaskQuery): List<Task> {
var condition: Op<Boolean> = Op.TRUE
query.memberId?.let { condition = condition and (TaskTable.memberId eq it.value) }
Expand Down
Loading
Loading