Skip to content

Commit c45368f

Browse files
authored
Merge pull request #103 from JobDri-Developer/refactor/#62-payment
[Refactor] 결제 승인 트랜잭션 분리 및 멱등성 보강 (#62)
2 parents d3a4e80 + 8cd0ef5 commit c45368f

10 files changed

Lines changed: 352 additions & 47 deletions

File tree

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
-- Manual migration to enforce credit transaction idempotency at the database level.
2+
-- Run after backing up the database.
3+
4+
-- Remove duplicate rows that violate the intended uniqueness rule and keep the earliest row.
5+
WITH ranked_duplicates AS (
6+
SELECT
7+
id,
8+
ROW_NUMBER() OVER (
9+
PARTITION BY user_id, type, reference_id
10+
ORDER BY id
11+
) AS duplicate_rank
12+
FROM credit_transactions
13+
WHERE reference_id IS NOT NULL
14+
)
15+
DELETE FROM credit_transactions
16+
WHERE id IN (
17+
SELECT id
18+
FROM ranked_duplicates
19+
WHERE duplicate_rank > 1
20+
);
21+
22+
-- Abort before adding the constraint if duplicates still remain for any reason.
23+
DO $$
24+
BEGIN
25+
IF EXISTS (
26+
SELECT 1
27+
FROM credit_transactions
28+
WHERE reference_id IS NOT NULL
29+
GROUP BY user_id, type, reference_id
30+
HAVING COUNT(*) > 1
31+
) THEN
32+
RAISE EXCEPTION
33+
'Duplicate credit_transactions remain for (user_id, type, reference_id); aborting unique constraint creation.';
34+
END IF;
35+
END $$;
36+
37+
DO $$
38+
BEGIN
39+
IF NOT EXISTS (
40+
SELECT 1
41+
FROM information_schema.table_constraints
42+
WHERE table_schema = current_schema()
43+
AND table_name = 'credit_transactions'
44+
AND constraint_name = 'uk_credit_transactions_user_type_reference'
45+
) THEN
46+
ALTER TABLE credit_transactions
47+
ADD CONSTRAINT uk_credit_transactions_user_type_reference
48+
UNIQUE (user_id, type, reference_id);
49+
END IF;
50+
END $$;

src/main/java/com/jobdri/jobdri_api/domain/payment/entity/CreditTransaction.java

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,15 @@
1010
@NoArgsConstructor(access = AccessLevel.PROTECTED)
1111
@AllArgsConstructor(access = AccessLevel.PRIVATE)
1212
@Builder(access = AccessLevel.PRIVATE)
13-
@Table(name = "credit_transactions")
13+
@Table(
14+
name = "credit_transactions",
15+
uniqueConstraints = {
16+
@UniqueConstraint(
17+
name = "uk_credit_transactions_user_type_reference",
18+
columnNames = {"user_id", "type", "reference_id"}
19+
)
20+
}
21+
)
1422
public class CreditTransaction extends CreatedAtEntity {
1523

1624
@Id
@@ -34,6 +42,7 @@ public class CreditTransaction extends CreatedAtEntity {
3442
@Column(nullable = false)
3543
private String description;
3644

45+
@Column(nullable = false, name = "reference_id")
3746
private String referenceId;
3847

3948
public static CreditTransaction create(

src/main/java/com/jobdri/jobdri_api/domain/payment/entity/Payment.java

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,11 @@ public static Payment createPending(
6666
.build();
6767
}
6868

69+
public void markProcessing(String paymentKey) {
70+
this.paymentKey = paymentKey;
71+
this.status = PaymentStatus.PROCESSING;
72+
}
73+
6974
public void complete(String paymentKey) {
7075
this.paymentKey = paymentKey;
7176
this.status = PaymentStatus.COMPLETED;
@@ -75,4 +80,12 @@ public void complete(String paymentKey) {
7580
public void fail() {
7681
this.status = PaymentStatus.FAILED;
7782
}
83+
84+
public boolean belongsTo(Long userId) {
85+
return user != null && user.getId() != null && user.getId().equals(userId);
86+
}
87+
88+
public boolean hasPaymentKey(String paymentKey) {
89+
return this.paymentKey != null && this.paymentKey.equals(paymentKey);
90+
}
7891
}

src/main/java/com/jobdri/jobdri_api/domain/payment/entity/PaymentStatus.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
@Getter
66
public enum PaymentStatus {
77
PENDING,
8+
PROCESSING,
89
FAILED,
910
COMPLETED
1011
}

src/main/java/com/jobdri/jobdri_api/domain/payment/repository/CreditTransactionRepository.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@
55
import org.springframework.data.jpa.repository.JpaRepository;
66

77
import java.util.List;
8+
import java.util.Optional;
89

910
public interface CreditTransactionRepository extends JpaRepository<CreditTransaction, Long> {
1011
List<CreditTransaction> findAllByUserIdOrderByCreatedAtDescIdDesc(Long userId);
1112
List<CreditTransaction> findAllByUserIdAndTypeOrderByCreatedAtDescIdDesc(Long userId, CreditTransactionType type);
13+
Optional<CreditTransaction> findByUserIdAndTypeAndReferenceId(Long userId, CreditTransactionType type, String referenceId);
1214
}

src/main/java/com/jobdri/jobdri_api/domain/payment/service/CreditService.java

Lines changed: 40 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import lombok.RequiredArgsConstructor;
1111
import org.springframework.stereotype.Service;
1212
import org.springframework.transaction.annotation.Transactional;
13+
import org.springframework.util.StringUtils;
1314

1415
@Service
1516
@RequiredArgsConstructor
@@ -20,32 +21,33 @@ public class CreditService {
2021

2122
@Transactional
2223
public int charge(User user, int amount, String description, String referenceId) {
23-
validatePositiveAmount(amount);
24-
User managedUser = getManagedUser(user);
25-
managedUser.increaseCredit(amount);
26-
saveTransaction(managedUser, CreditTransactionType.CHARGE, amount, description, referenceId);
27-
return managedUser.getCredit();
24+
return apply(user, CreditTransactionType.CHARGE, amount, description, referenceId);
2825
}
2926

3027
@Transactional
3128
public int use(User user, int amount, String description, String referenceId) {
32-
validatePositiveAmount(amount);
33-
User managedUser = getManagedUser(user);
34-
try {
35-
managedUser.decreaseCredit(amount);
36-
} catch (IllegalArgumentException e) {
37-
throw new GeneralException(GeneralErrorCode.INSUFFICIENT_CREDIT, "크레딧이 부족합니다.");
38-
}
39-
saveTransaction(managedUser, CreditTransactionType.USE, -amount, description, referenceId);
40-
return managedUser.getCredit();
29+
return apply(user, CreditTransactionType.USE, amount, description, referenceId);
4130
}
4231

4332
@Transactional
4433
public int refund(User user, int amount, String description, String referenceId) {
34+
return apply(user, CreditTransactionType.REFUND, amount, description, referenceId);
35+
}
36+
37+
private int apply(User user, CreditTransactionType type, int amount, String description, String referenceId) {
4538
validatePositiveAmount(amount);
39+
validateReferenceId(referenceId);
4640
User managedUser = getManagedUser(user);
47-
managedUser.increaseCredit(amount);
48-
saveTransaction(managedUser, CreditTransactionType.REFUND, amount, description, referenceId);
41+
CreditTransaction existingTransaction = creditTransactionRepository
42+
.findByUserIdAndTypeAndReferenceId(managedUser.getId(), type, referenceId)
43+
.orElse(null);
44+
if (existingTransaction != null) {
45+
return existingTransaction.getBalanceAfter();
46+
}
47+
48+
int transactionAmount = resolveTransactionAmount(type, amount);
49+
applyCreditChange(managedUser, type, amount);
50+
saveTransaction(managedUser, type, transactionAmount, description, referenceId);
4951
return managedUser.getCredit();
5052
}
5153

@@ -55,6 +57,28 @@ private void validatePositiveAmount(int amount) {
5557
}
5658
}
5759

60+
private void validateReferenceId(String referenceId) {
61+
if (!StringUtils.hasText(referenceId)) {
62+
throw new GeneralException(GeneralErrorCode.INVALID_PARAMETER, "referenceId는 필수입니다.");
63+
}
64+
}
65+
66+
private void applyCreditChange(User user, CreditTransactionType type, int amount) {
67+
if (type == CreditTransactionType.USE) {
68+
try {
69+
user.decreaseCredit(amount);
70+
} catch (IllegalArgumentException e) {
71+
throw new GeneralException(GeneralErrorCode.INSUFFICIENT_CREDIT, "크레딧이 부족합니다.");
72+
}
73+
return;
74+
}
75+
user.increaseCredit(amount);
76+
}
77+
78+
private int resolveTransactionAmount(CreditTransactionType type, int amount) {
79+
return type == CreditTransactionType.USE ? -amount : amount;
80+
}
81+
5882
private void saveTransaction(
5983
User user,
6084
CreditTransactionType type,

src/main/java/com/jobdri/jobdri_api/domain/payment/service/PaymentService.java

Lines changed: 16 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77
import com.jobdri.jobdri_api.domain.payment.entity.CreditPlan;
88
import com.jobdri.jobdri_api.domain.payment.entity.CreditTransactionType;
99
import com.jobdri.jobdri_api.domain.payment.entity.Payment;
10-
import com.jobdri.jobdri_api.domain.payment.entity.PaymentStatus;
1110
import com.jobdri.jobdri_api.domain.payment.repository.CreditTransactionRepository;
1211
import com.jobdri.jobdri_api.domain.payment.repository.PaymentRepository;
1312
import com.jobdri.jobdri_api.domain.user.entity.User;
@@ -18,6 +17,7 @@
1817
import lombok.RequiredArgsConstructor;
1918
import org.springframework.beans.factory.annotation.Value;
2019
import org.springframework.stereotype.Service;
20+
import org.springframework.transaction.annotation.Propagation;
2121
import org.springframework.transaction.annotation.Transactional;
2222

2323
import java.util.Arrays;
@@ -32,8 +32,8 @@ public class PaymentService {
3232
private final UserService userService;
3333
private final PaymentRepository paymentRepository;
3434
private final CreditTransactionRepository creditTransactionRepository;
35+
private final PaymentTransactionService paymentTransactionService;
3536
private final TossPaymentClient tossPaymentClient;
36-
private final CreditService creditService;
3737

3838
@Value("${payment.toss.client-key:}")
3939
private String tossClientKey;
@@ -68,38 +68,24 @@ public PaymentPrepareResponse prepare(User user, PaymentPrepareRequest request)
6868
return PaymentPrepareResponse.of(payment, tossClientKey);
6969
}
7070

71-
@Transactional
71+
@Transactional(propagation = Propagation.NOT_SUPPORTED)
7272
public PaymentConfirmResponse confirm(User user, PaymentConfirmRequest request) {
7373
User validatedUser = userService.validateUser(user);
74-
Payment payment = paymentRepository.findByOrderIdForUpdate(request.orderId())
75-
.orElseThrow(() -> new GeneralException(
76-
GeneralErrorCode.PAYMENT_NOT_FOUND,
77-
"결제 정보를 찾을 수 없습니다. orderId=" + request.orderId()
78-
));
79-
80-
if (!payment.getUser().getId().equals(validatedUser.getId())) {
81-
throw new GeneralException(GeneralErrorCode.FORBIDDEN, "해당 결제에 접근할 수 없습니다.");
82-
}
83-
if (payment.getStatus() != PaymentStatus.PENDING) {
84-
throw new GeneralException(GeneralErrorCode.PAYMENT_ALREADY_PROCESSED, "이미 처리된 결제입니다.");
85-
}
86-
if (payment.getPrice() != request.amount()) {
87-
throw new GeneralException(GeneralErrorCode.PAYMENT_AMOUNT_MISMATCH, "결제 금액이 일치하지 않습니다.");
74+
PaymentTransactionService.PaymentConfirmationStart start =
75+
paymentTransactionService.startConfirmation(validatedUser.getId(), request);
76+
if (start.alreadyCompleted()) {
77+
return PaymentConfirmResponse.of(start.payment(), userService.getUser(validatedUser.getId()).getCredit());
8878
}
8979

90-
TossPaymentConfirmResponse tossResponse =
91-
tossPaymentClient.confirm(request.paymentKey(), request.orderId(), request.amount());
92-
validateTossResponse(request, tossResponse);
93-
94-
payment.complete(request.paymentKey());
95-
int creditBalance = creditService.charge(
96-
validatedUser,
97-
payment.getCreditAmount(),
98-
payment.getContent(),
99-
payment.getOrderId()
100-
);
101-
102-
return PaymentConfirmResponse.of(payment, creditBalance);
80+
try {
81+
TossPaymentConfirmResponse tossResponse =
82+
tossPaymentClient.confirm(request.paymentKey(), request.orderId(), request.amount());
83+
validateTossResponse(request, tossResponse);
84+
} catch (RuntimeException e) {
85+
paymentTransactionService.failConfirmation(validatedUser.getId(), request.orderId(), request.paymentKey());
86+
throw e;
87+
}
88+
return paymentTransactionService.completeConfirmation(validatedUser.getId(), request);
10389
}
10490

10591
public CreditBalanceResponse getBalance(User user) {
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
package com.jobdri.jobdri_api.domain.payment.service;
2+
3+
import com.jobdri.jobdri_api.domain.payment.dto.request.PaymentConfirmRequest;
4+
import com.jobdri.jobdri_api.domain.payment.dto.response.PaymentConfirmResponse;
5+
import com.jobdri.jobdri_api.domain.payment.entity.Payment;
6+
import com.jobdri.jobdri_api.domain.payment.entity.PaymentStatus;
7+
import com.jobdri.jobdri_api.domain.payment.repository.PaymentRepository;
8+
import com.jobdri.jobdri_api.domain.user.entity.User;
9+
import com.jobdri.jobdri_api.domain.user.service.UserService;
10+
import com.jobdri.jobdri_api.global.apiPayload.code.GeneralErrorCode;
11+
import com.jobdri.jobdri_api.global.apiPayload.exception.GeneralException;
12+
import lombok.RequiredArgsConstructor;
13+
import org.springframework.stereotype.Service;
14+
import org.springframework.transaction.annotation.Transactional;
15+
16+
@Service
17+
@RequiredArgsConstructor
18+
public class PaymentTransactionService {
19+
20+
private final PaymentRepository paymentRepository;
21+
private final UserService userService;
22+
private final CreditService creditService;
23+
24+
@Transactional
25+
public PaymentConfirmationStart startConfirmation(Long userId, PaymentConfirmRequest request) {
26+
Payment payment = getOwnedPaymentForUpdate(userId, request.orderId());
27+
validateAmount(payment, request.amount());
28+
29+
if (payment.getStatus() == PaymentStatus.COMPLETED) {
30+
if (!payment.hasPaymentKey(request.paymentKey())) {
31+
throw new GeneralException(GeneralErrorCode.PAYMENT_ALREADY_PROCESSED, "이미 처리된 결제입니다.");
32+
}
33+
return new PaymentConfirmationStart(payment, true);
34+
}
35+
if (payment.getStatus() == PaymentStatus.PROCESSING || payment.getStatus() == PaymentStatus.FAILED) {
36+
throw new GeneralException(GeneralErrorCode.PAYMENT_ALREADY_PROCESSED, "이미 처리된 결제입니다.");
37+
}
38+
39+
payment.markProcessing(request.paymentKey());
40+
return new PaymentConfirmationStart(payment, false);
41+
}
42+
43+
@Transactional
44+
public PaymentConfirmResponse completeConfirmation(Long userId, PaymentConfirmRequest request) {
45+
Payment payment = getOwnedPaymentForUpdate(userId, request.orderId());
46+
validateAmount(payment, request.amount());
47+
if (!payment.hasPaymentKey(request.paymentKey())) {
48+
throw new GeneralException(GeneralErrorCode.PAYMENT_CONFIRM_FAILED, "결제 승인 정보가 일치하지 않습니다.");
49+
}
50+
if (payment.getStatus() == PaymentStatus.COMPLETED) {
51+
return PaymentConfirmResponse.of(payment, userService.getUser(userId).getCredit());
52+
}
53+
if (payment.getStatus() != PaymentStatus.PROCESSING) {
54+
throw new GeneralException(GeneralErrorCode.PAYMENT_ALREADY_PROCESSED, "이미 처리된 결제입니다.");
55+
}
56+
57+
User user = userService.getUser(userId);
58+
payment.complete(request.paymentKey());
59+
int creditBalance = creditService.charge(
60+
user,
61+
payment.getCreditAmount(),
62+
payment.getContent(),
63+
payment.getOrderId()
64+
);
65+
return PaymentConfirmResponse.of(payment, creditBalance);
66+
}
67+
68+
@Transactional
69+
public void failConfirmation(Long userId, String orderId, String paymentKey) {
70+
Payment payment = getOwnedPaymentForUpdate(userId, orderId);
71+
if (!payment.hasPaymentKey(paymentKey)) {
72+
return;
73+
}
74+
if (payment.getStatus() == PaymentStatus.PROCESSING) {
75+
payment.fail();
76+
}
77+
}
78+
79+
private Payment getOwnedPaymentForUpdate(Long userId, String orderId) {
80+
Payment payment = paymentRepository.findByOrderIdForUpdate(orderId)
81+
.orElseThrow(() -> new GeneralException(
82+
GeneralErrorCode.PAYMENT_NOT_FOUND,
83+
"결제 정보를 찾을 수 없습니다. orderId=" + orderId
84+
));
85+
if (!payment.belongsTo(userId)) {
86+
throw new GeneralException(GeneralErrorCode.FORBIDDEN, "해당 결제에 접근할 수 없습니다.");
87+
}
88+
return payment;
89+
}
90+
91+
private void validateAmount(Payment payment, int amount) {
92+
if (payment.getPrice() != amount) {
93+
throw new GeneralException(GeneralErrorCode.PAYMENT_AMOUNT_MISMATCH, "결제 금액이 일치하지 않습니다.");
94+
}
95+
}
96+
97+
public record PaymentConfirmationStart(Payment payment, boolean alreadyCompleted) {
98+
}
99+
}

0 commit comments

Comments
 (0)