리워드 책임 분리 개발기 — 생성,수령, 충전을 하나의 흐름에서 떼어내기
리워드 책임 분리 개발기 — 생성, 수령, 충전을 하나의 흐름에서 떼어내기
안녕하세요. duurian 팀에서 백엔드 개발을 담당하고 있는 정지원입니다.
처음 대화 연속일수 보상을 만들 때의 흐름은 직관적이었습니다. 조건을 만족하면 리워드를 생성하고 곧바로 두리안을 충전했습니다. 작은 기능일 때는 한 메서드에서 모든 일이 끝나는 편이 이해하기 쉬웠습니다.
문제는 리워드가 늘면서 시작됐습니다. 일일 대화, 연속 대화, 레벨업, 페르소나 공개처럼 생성 계기가 다양해졌고, 사용자가 직접 “받기”를 누르는 UI도 추가됐습니다. 생성 시점과 수령 시점 모두에서 충전이 가능해지자 같은 보상이 서로 다른 경로를 통해 두 번 충전되는 사고가 발생했습니다.
이 글은 이 문제를 단순한 중복 호출 버그로 보지 않고, 리워드의 생성·수령·재화 충전 책임을 다시 정의한 과정을 다룹니다.
각 블록이 하나의 책임을 갖는 구조. Photo by Duc Anh Nguyen on Pexels.
AI 대화와 보상 파이프라인 개발기
- AI 대화 후처리 개선기
- 리워드 생성·수령·충전 책임 분리 — 현재 글
- 상태 조건 UPDATE로 중복 수령 방지
1. 사고를 한 장으로 요약하면
운영에서 기대한 변화는 +50 두리안이었지만 실제로는 +100이 반영됐습니다.
sequenceDiagram
autonumber
participant E as 보상 생성 경로
participant R as 보상 수령 경로
participant C as 두리안 충전
participant B as 사용자 잔액
E->>C: 동일 보상으로 +50
C->>B: 잔액 반영
R->>C: 동일 보상으로 다시 +50
C->>B: 잔액 반영
Note over B: 기대 +50 / 실제 +100
처음에는 “충전 메서드가 두 번 호출됐다”가 원인처럼 보였습니다. 더 정확한 원인은 다음과 같았습니다.
하나의 보상에 대해 충전을 시작할 수 있는 책임이 두 경로에 동시에 존재했다.
| 관찰한 현상 | 표면적 처방 | 구조적 처방 |
|---|---|---|
| 같은 보상으로 두 번 충전 | 한쪽 호출 삭제 | 충전 시작 권한을 한 흐름에만 둔다 |
| 생성과 지급의 의미 혼재 | 메서드 이름 변경 | 상태와 유스케이스를 분리한다 |
| 수령 API 재호출 | 컨트롤러 중복 방지 | DB 상태 전이를 성공 조건으로 사용한다 |
| 잔액만 남아 원인 추적 어려움 | 로그 추가 | 재화 원장에 출처를 기록한다 |
2. 처음 설계가 틀렸다기보다 요구사항이 달라졌다
초기 연속 대화 보상은 “조건 달성 즉시 지급”이었습니다.
flowchart LR
C["연속일수 계산"] --> P["보상 상품 조회"]
P --> R["Reward 생성"]
R --> D["두리안 즉시 충전"]
D --> B["잔액 반영"]
당시 CreateRewardService는 다음 책임을 함께 가지고 있었습니다.
- 보상 조건을 계산한다.
- 보상 상품을 선택한다.
- Reward 레코드를 저장한다.
- 사용자 두리안 잔액을 증가시킨다.
- 두리안 거래 내역을 기록한다.
사용자가 별도의 수령 행동을 하지 않는 요구사항에서는 합리적인 흐름이었습니다. 이후 UI에 “받기”와 “모두 받기”가 생기면서 리워드는 즉시 지급 결과가 아니라 수령 가능한 권리가 됐습니다.
1
2
기존 의미 Reward = 지급된 재화의 기록
변경 의미 Reward = 사용자가 수령할 수 있는 권리
의미가 달라졌는데 기존 충전 경로를 그대로 남겨두면 생성과 수령 양쪽이 같은 부수효과를 가질 수밖에 없습니다.
3. 리워드와 두리안은 같은 도메인이 아니다
책임을 분리하기 위해 먼저 두 개념을 구분했습니다.
| 도메인 | 핵심 질문 | 대표 데이터 |
|---|---|---|
| Reward | 왜, 어떤 조건으로, 어떤 보상을 받을 권리가 생겼는가? | type, status, productId |
| UserDurian | 실제 잔액이 얼마나 변했고 그 출처는 무엇인가? | balance, transaction, provider |
classDiagram
class Reward {
UUID id
UUID userId
RewardType type
RewardStatus status
UUID durianProductId
}
class ProductDurian {
UUID id
int durianBase
int durianBonus
int duration
}
class UserDurian {
UUID userId
int balancePaid
int balanceFree
long version
}
class UserDurianTransaction {
UUID providerId
DurianProviderType providerType
int amount
int balanceBefore
int balanceAfter
}
Reward --> ProductDurian : 어떤 상품인가
UserDurianTransaction --> Reward : providerId
UserDurian --> UserDurianTransaction : 잔액 변화의 원장
Reward는 재화 그 자체가 아닙니다. “상품 X를 받을 수 있는 권리”를 나타냅니다. 실제 재화 변화는 UserDurian과 UserDurianTransaction이 담당합니다.
분리 후의 핵심 문장
- 생성은 권리를 만든다.
- 수령은 권리의 상태를 전이한다.
- 충전은 전이된 권리를 잔액과 원장에 반영한다.
4. 목표 구조: 세 유스케이스, 하나의 방향
flowchart LR
subgraph Create["1. 생성"]
C1["조건 검증"] --> C2["Reward<br/>PENDING"]
end
subgraph Receive["2. 수령"]
R1["소유권 검증"] --> R2["PENDING → RECEIVED"]
end
subgraph Charge["3. 충전"]
D1["거래 원장 생성"] --> D2["사용자 잔액 증가"]
end
C2 --> R1
R2 --> D1
style Create fill:#e3f2fd,stroke:#1565c0
style Receive fill:#fff8e1,stroke:#f9a825
style Charge fill:#e8f5e9,stroke:#2e7d32
의존 방향도 한쪽으로만 흐르게 했습니다.
1
2
3
4
5
6
CreateRewardUseCase
↓ PENDING Reward
ReceiveRewardUseCase
↓ 성공한 상태 전이
ChargeDurianUseCase
↓ 거래 원장 + 잔액
충전 서비스는 리워드가 수령 가능한지 판단하지 않습니다. 리워드 수령 서비스는 잔액을 직접 계산하지 않습니다. 생성 서비스는 사용자가 언제 수령했는지 결정하지 않습니다.
5. 생성 책임: 조건을 판정하고 PENDING을 만든다
대화 보상 생성은 다음 조건을 처리합니다.
flowchart TD
A["대화 보상 생성 시도"] --> B{오늘 이미 생성됐는가?}
B -->|예| X["스킵"]
B -->|아니오| C["연속일수 계산"]
C --> D{1일 이상인가?}
D -->|아니오| X
D -->|예| Q{저품질 대화인가?}
Q -->|예| S["DAY1 미지급 이력 저장"]
Q -->|아니오| R1["DAY1 Reward 생성"]
S --> M["7·14·28일 조건 확인"]
R1 --> M
M --> R2["조건 충족 시 연속 Reward 생성"]
실제 생성 메서드는 Reward를 저장한 뒤 결과만 반환합니다. 이 경로에는 두리안 충전 호출이 없습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
private fun createRewardIfExists(
command: CreateRewardCommand,
feature: RewardFeature,
condition: RewardCondition,
): CommandRewardResult? {
val featureReward =
queryFeatureRewardPort.findByFeatureAndCondition(
feature,
condition
) ?: return null
val product =
queryProductDurianPort.findById(
featureReward.durianProductId
) ?: return null
val reward = Reward.create(
userId = command.userId,
type = RewardType.CONVERSATION,
durianProductId = product.id
)
val savedReward = commandRewardPort.saveReward(reward)
return CommandRewardResult(
id = savedReward.id,
userId = savedReward.userId,
type = savedReward.type,
status = savedReward.status,
durianProductId = savedReward.durianProductId,
quantity = product.durianTotal,
description = product.description,
createdAt = savedReward.createdAt,
updatedAt = savedReward.updatedAt
)
}
Reward.create의 기본 상태는 PENDING입니다.
stateDiagram-v2
[*] --> PENDING: 대화·레벨업 조건 달성
PENDING --> RECEIVED: 사용자가 수령
RECEIVED --> [*]
레벨업 보상도 같은 규칙을 따릅니다. 레벨 상승과 기존 동일 상품 보상 여부를 검사한 뒤 PENDING Reward만 생성합니다.
6. 수령 책임: 상태 전이에 성공한 요청만 충전을 시작한다
단건 수령 서비스는 다음 순서로 동작합니다.
sequenceDiagram
autonumber
actor U as 사용자
participant R as ReceiveRewardService
participant DB as Reward Repository
participant C as ChargeDurianService
participant L as Durian Ledger
U->>R: rewardId 수령 요청
R->>DB: Reward 조회
R->>R: 소유권·현재 상태 검사
R->>DB: PENDING 조건 UPDATE
DB-->>R: affectedRows
alt affectedRows = 1
R->>C: rewardId로 충전 요청
C->>L: 거래 원장 + 잔액 반영
R-->>U: 수령 결과
else affectedRows = 0
R-->>U: 이미 수령된 보상
end
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
@Transactional
override fun receiveReward(
command: ReceiveRewardCommand
): ReceiveRewardsResult {
val reward = queryRewardPort.findById(command.rewardId)
?: throw NotFoundRewardException()
if (!reward.isOwnedBy(command.userId)) {
throw AccessDeniedException()
}
if (reward.isAlreadyReceived()) {
throw AlreadyReceivedRewardException()
}
val updatedCount =
commandRewardPort.updateStatusToReceived(reward.id)
if (updatedCount == 0) {
throw AlreadyReceivedRewardException()
}
val updatedReward =
queryRewardPort.findById(reward.id)
?: throw NotFoundRewardException()
val balance =
chargeDurianUseCase.chargeDurianByReward(
ChargeDurianByRewardCommand(
userId = command.userId,
rewardId = updatedReward.id,
productId = updatedReward.durianProductId,
title = resolveTitleByRewardType(updatedReward.type)
)
)
// updatedReward와 balance로 ReceiveRewardsResult를 구성한다.
}
위 코드는 응답 DTO 매핑을 제외한 실제 핵심 흐름입니다. 사전 상태 확인은 빠른 실패를 위한 사용자 친화적 검증입니다. 실제 중복 방지의 최종 판단은 DB의 조건부 UPDATE 결과가 담당합니다. 이 부분은 세 번째 글에서 동시성 타임라인과 함께 자세히 다룹니다.
7. 충전 책임: 원장을 먼저 만들고 잔액을 갱신한다
ChargeDurianService는 보상 외에도 주문, 관리자 지급, 쿠폰 등 여러 출처의 충전을 처리합니다.
flowchart LR
O["유료 주문"] --> C["ChargeDurianService"]
R["리워드 수령"] --> C
A["관리자 지급"] --> C
P["쿠폰"] --> C
C --> T["UserDurianTransaction 저장"]
C --> U["UserDurian 잔액 저장"]
공통 충전 로직은 상품을 조회하고, 현재 잔액을 기준으로 거래 원장을 만든 뒤, 무료 또는 유료 잔액을 증가시킵니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
private fun processingDurian(
productId: UUID,
userId: UUID,
durianType: DurianType,
providerType: DurianProviderType,
providerId: UUID,
meta: String?,
): GetUserDurianBalanceResult {
val productDurian = queryProductDurianPort.findById(productId)
?: throw NotFoundProductException()
val userDurian = queryUserDurianPort.findByUserId(userId)
?: UserDurian.create(userId)
val currentBalancePaid = userDurian.balancePaid
val currentBalanceFree = userDurian.balanceFree
val currentTotalBalance = userDurian.balanceTotal
val totalAmount =
productDurian.durianBase + productDurian.durianBonus
val expiresAt = TimezoneConverter.calculateExpiresAtInUtc(
productDurian.duration.toLong()
)
val chargeTransaction = UserDurianTransaction.createCharge(
userId = userId,
durianType = durianType,
providerType = providerType,
providerId = providerId,
amount = totalAmount,
balanceBefore = currentTotalBalance,
expiresAt = expiresAt,
meta = meta
)
commandUserDurianTransactionPort.save(chargeTransaction)
val updatedUserDurian = if (durianType.isFree()) {
userDurian.copy(
balanceFree = currentBalanceFree + totalAmount
)
} else {
userDurian.copy(
balancePaid = currentBalancePaid + totalAmount
)
}
commandUserDurianPort.save(updatedUserDurian)
return GetUserDurianBalanceResult(
balancePaid = updatedUserDurian.balancePaid,
balanceFree = updatedUserDurian.balanceFree,
balanceTotal = updatedUserDurian.balanceTotal
)
}
providerType과 providerId가 남기는 것
| 필드 | 보상 충전 예시 | 의미 |
|---|---|---|
| providerType | REWARD | 어떤 도메인에서 시작됐는가 |
| providerId | reward.id | 어떤 보상이 원인이었는가 |
| transactionType | CHARGE | 증가·차감 중 무엇인가 |
| amount | 50 | 얼마가 변했는가 |
| balanceBefore / After | 100 / 150 | 전후 잔액 |
이 정보 덕분에 잔액 숫자 하나만 보는 대신 “어떤 보상 때문에 언제 얼마나 증가했는지”를 추적할 수 있습니다.
현재 제약의 정확한 범위
거래 엔티티에는 (providerType, providerId, transactionType) 유일 제약이 선언되어 있지 않습니다. 따라서 원장 자체가 보상 충전의 중복을 최종 차단하는 구조는 아닙니다. 현재의 1차 중복 차단선은 Reward의 PENDING → RECEIVED 조건부 UPDATE입니다.
8. 책임 분리와 트랜잭션 분리는 같은 말이 아니다
서비스 클래스는 세 개로 나눴지만 단건 수령은 하나의 Spring 트랜잭션 안에서 처리됩니다.
flowchart LR
subgraph TX["하나의 수령 트랜잭션"]
A["Reward 상태 전이"] --> B["두리안 원장 저장"]
B --> C["사용자 잔액 저장"]
end
C --> D{모두 성공?}
D -->|예| E["COMMIT"]
D -->|아니오| F["ROLLBACK"]
ReceiveRewardService.receiveReward와 ChargeDurianService.chargeDurianByReward는 서로 다른 Spring 빈입니다. 두 메서드 모두 기본 전파 속성의 @Transactional을 사용하므로 충전 서비스는 바깥 수령 트랜잭션에 참여합니다.
| 실패 지점 | Reward 상태 | 거래 원장 | 사용자 잔액 |
|---|---|---|---|
| 조건부 UPDATE 실패 | 변경 없음 | 생성 안 됨 | 변경 없음 |
| 상품 조회 실패 | 롤백 | 생성 안 됨 | 변경 없음 |
| 원장 저장 실패 | 롤백 | 롤백 | 변경 없음 |
| 잔액 저장 실패 | 롤백 | 롤백 | 롤백 |
| 모든 단계 성공 | RECEIVED | 커밋 | 커밋 |
책임은 분리하되 원자적으로 함께 성공해야 하는 상태 변화는 같은 트랜잭션에 남겼습니다.
클래스 경계는 “누가 무엇을 아는가”를 나누고, 트랜잭션 경계는 “무엇이 함께 성공해야 하는가”를 묶습니다.
9. 일괄 수령은 충전 서비스에도 별도 유스케이스가 필요했다
여러 보상을 하나씩 충전하면 사용자 잔액을 반복 조회·저장하고 거래도 건별 호출하게 됩니다. 그래서 일괄 수령은 성공한 보상 목록을 하나의 벌크 커맨드로 넘깁니다.
flowchart TD
P["PENDING 보상 N개 조회"] --> U["조건부 일괄 상태 UPDATE"]
U --> S["성공한 보상 구성"]
S --> T["N개 거래 원장 객체 생성"]
T --> TS["거래 원장 saveAll"]
TS --> B["최종 잔액 한 번 저장"]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
@Transactional
override fun chargeDurianBulk(
command: ChargeDurianBulkCommand
): GetUserDurianBalanceResult {
val products = queryProductDurianPort
.findAllByIdIn(command.items.map { it.productId }.distinct())
.associateBy { it.id }
val userDurian =
queryUserDurianPort.findByUserId(command.userId)
?: UserDurian.create(command.userId)
var paid = userDurian.balancePaid
var free = userDurian.balanceFree
var total = userDurian.balanceTotal
val transactions = mutableListOf<UserDurianTransaction>()
for (item in command.items) {
val product = products[item.productId]
?: throw NotFoundProductException()
val amount = product.durianBase + product.durianBonus
val expiresAt = TimezoneConverter.calculateExpiresAtInUtc(
product.duration.toLong()
)
transactions += UserDurianTransaction.createCharge(
userId = command.userId,
durianType = item.durianType,
providerType = item.providerType,
providerId = item.providerId,
amount = amount,
balanceBefore = total,
expiresAt = expiresAt,
meta = item.title?.toMetaJson()
)
if (item.durianType.isFree()) free += amount
else paid += amount
total += amount
}
commandUserDurianTransactionPort.saveAll(transactions)
val updatedUserDurian = commandUserDurianPort.save(
userDurian.copy(balancePaid = paid, balanceFree = free)
)
return GetUserDurianBalanceResult(
balancePaid = updatedUserDurian.balancePaid,
balanceFree = updatedUserDurian.balanceFree,
balanceTotal = updatedUserDurian.balanceTotal
)
}
| 항목 | N번 단건 충전 | 벌크 충전 |
|---|---|---|
| 상품 조회 | 최대 N회 | 고유 상품 ID 한 번 |
| 사용자 잔액 조회 | N회 | 한 번 |
| 잔액 저장 | N회 | 한 번 |
| 원장 데이터 | N건 | N건 유지 |
| 트랜잭션 경계 | 반복 가능 | 한 번 |
원장은 N건을 유지합니다. 일괄 처리라고 해서 원인 추적 단위를 잃지 않으면서, 잔액 계산과 저장만 묶었습니다.
10. 모든 보상이 같은 정책을 따르는 것은 아니다
현재 코드에서 대화 보상과 레벨업 보상은 PENDING으로 생성되고 사용자가 수령할 때 충전됩니다. 그러나 페르소나 최초 공개 보상은 예외입니다.
flowchart LR
subgraph Claimable["사용자 수령형"]
C["대화·레벨업 조건"] --> P["PENDING"]
P --> R["수령"]
R --> D["충전"]
end
subgraph Immediate["즉시 지급형"]
O["페르소나 최초 공개"] --> I["RECEIVED 생성"]
I --> IC["즉시 충전"]
end
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
val reward = Reward.create(
userId = command.userId,
type = RewardType.PERSONA,
durianProductId = product.id,
status = RewardStatus.RECEIVED
)
val savedReward = commandRewardPort.saveReward(reward)
chargeDurianUseCase.chargeDurianByReward(
ChargeDurianByRewardCommand(
userId = command.userId,
rewardId = savedReward.id,
productId = product.id,
title = DurianTransactionTitle.REWARD_PERSONA_PUBLIC
)
)
이 경로까지 포함해 “모든 Reward 생성은 충전과 완전히 분리됐다”고 말하면 현재 구현과 다릅니다.
| 보상 타입 | 생성 상태 | 사용자 수령 필요 | 충전 시작 위치 |
|---|---|---|---|
| CONVERSATION | PENDING | 예 | ReceiveRewardService |
| LEVEL_UP | PENDING | 예 | ReceiveRewardService |
| PERSONA 공개 | RECEIVED | 아니오 | CreateRewardService |
책임 분리는 코드 모양을 통일하는 일이 아니라 제품 정책을 정확히 드러내는 일입니다. 즉시 지급형과 수령형이 모두 필요하다면 타입이나 지급 정책을 명시적으로 모델링하는 편이 다음 개선 방향입니다.
11. 포트로 경계를 고정했다
핵심 서비스는 영속성 구현과 직접 결합하지 않습니다.
flowchart LR
subgraph Core["Core"]
CR["CreateRewardService"]
RR["ReceiveRewardService"]
CD["ChargeDurianService"]
CP["CommandRewardPort"]
QP["QueryRewardPort"]
DP["CommandUserDurianPort"]
TP["CommandUserDurianTransactionPort"]
end
subgraph Infra["PostgreSQL Adapter"]
RA["Reward Adapter"]
DA["UserDurian Adapter"]
TA["Transaction Adapter"]
end
CR --> CP
RR --> QP
RR --> CP
RR --> CD
CD --> DP
CD --> TP
CP -.-> RA
QP -.-> RA
DP -.-> DA
TP -.-> TA
| 서비스 | 알아야 하는 것 | 몰라도 되는 것 |
|---|---|---|
| CreateRewardService | 보상 조건, 상품, 초기 상태 | 잔액 계산 방식 |
| ReceiveRewardService | 소유권, 상태 전이, 충전 유스케이스 | JPA 저장 구현 |
| ChargeDurianService | 상품 수량, 원장, 잔액 | 보상 생성 조건 |
이 경계 덕분에 조건부 UPDATE는 Reward 어댑터에, 잔액 계산은 충전 서비스에, 오케스트레이션은 수령 서비스에 둘 수 있었습니다.
12. 분리 후에도 남은 동시성 과제
책임을 분리하면 중복 충전 지점은 줄어들지만 동시성 문제가 자동으로 사라지는 것은 아닙니다.
12.1 보상 생성의 선조회 경쟁
오늘 보상 존재 여부를 조회한 뒤 새 Reward를 저장하는 구조는 동시에 두 요청이 들어오면 둘 다 “없음”을 볼 수 있습니다. Reward 테이블에 업무 키 유일 제약이 없다면 중복 생성 가능성이 남습니다.
12.2 거래 원장의 유일 제약 부재
같은 rewardId를 원인으로 하는 CHARGE 원장이 두 번 생성되는 것을 DB 제약으로 막지 않습니다. 수령 상태 전이가 뚫리거나 다른 즉시 지급 경로가 추가되면 원장 계층만으로는 방어하지 못합니다.
12.3 사용자 잔액의 동시 갱신
UserDurianEntity에는 @Version이 있어 잃어버린 갱신을 감지합니다. 다만 충돌 시 어떤 요청을 재시도할지에 대한 정책은 별도로 필요합니다.
flowchart TD
G["중복 방지 레이어"] --> A["생성: 업무 키 UNIQUE"]
G --> B["수령: 상태 조건 UPDATE"]
G --> C["원장: provider 유일 키"]
G --> D["잔액: version 또는 잠금"]
style B fill:#c8e6c9,stroke:#2e7d32,color:#111
style A fill:#fff9c4,stroke:#f9a825,color:#111
style C fill:#fff9c4,stroke:#f9a825,color:#111
style D fill:#e3f2fd,stroke:#1565c0,color:#111
현재 구현은 B를 핵심 게이트로 사용하고 D에서 낙관적 잠금을 적용합니다. A와 C를 DB 제약으로 보강하면 한 계층의 실수가 실제 재화 중복으로 이어질 가능성을 더 낮출 수 있습니다.
13. 배운 점
13.1 중복 호출보다 중복 책임을 먼저 찾아야 한다
한 줄을 삭제하면 당장의 중복 충전은 멈춥니다. 하지만 다음 보상 타입이 같은 실수를 반복할 수 있습니다. 누가 충전을 시작할 수 있는지 한 곳으로 제한한 것이 더 근본적인 수정이었습니다.
13.2 상태는 UI 표시값이 아니라 부수효과의 게이트다
PENDING과 RECEIVED는 목록 화면을 위한 라벨이 아닙니다. 충전을 시작할 수 있는 요청을 결정하는 업무 규칙입니다.
13.3 책임을 나눠도 원자성은 유지할 수 있다
생성·수령·충전을 서로 다른 서비스로 나눴지만 수령 상태, 원장, 잔액은 같은 트랜잭션에서 커밋됩니다. 모듈성과 원자성은 서로 반대되는 목표가 아닙니다.
13.4 원장은 잔액만큼 중요하다
재화 시스템에서 “현재 얼마인가”만으로는 운영 사고를 분석할 수 없습니다. providerType과 providerId를 포함한 거래 원장이 있어야 변화의 원인을 역추적할 수 있습니다.
13.5 예외 정책은 숨기지 말아야 한다
페르소나 공개 보상은 여전히 즉시 지급입니다. 모든 타입을 억지로 같은 흐름에 넣기보다 어떤 정책이 왜 예외인지 드러내는 편이 유지보수에 유리합니다.
마무리
리워드 생성과 두리안 충전을 한 메서드에서 처리하던 구조는 초기 요구사항에는 충분했습니다. 그러나 수령 UI와 여러 보상 타입이 추가되면서 “생성 즉시 지급”이라는 암묵적 의미가 더 이상 맞지 않았습니다.
Reward를 받을 권리, Receive를 상태 전이의 오케스트레이션, Charge를 원장과 잔액 변경으로 다시 정의하면서 충전 시작점은 수령 흐름으로 모였습니다. 클래스는 나뉘었지만 함께 성공해야 하는 상태 변화는 하나의 트랜잭션으로 묶었습니다.
도메인 책임 분리의 효과는 파일 수가 늘어나는 데 있지 않습니다. 같은 부수효과를 시작할 수 있는 이유와 장소가 하나로 줄어드는 데 있습니다.
