From de18f7da2eb75307142893e49c6fe63603b07a9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=EB=AF=B8?= Date: Tue, 25 Aug 2026 17:12:34 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EB=A7=88=EC=BB=A4=20=EC=9E=AC=ED=99=94?= =?UTF-8?q?=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20=EC=84=9C=EB=B2=84=20API=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20(=EC=86=8C=EB=B9=84/=EA=B4=91=EA=B3=A0=20?= =?UTF-8?q?=EB=A6=AC=EC=9B=8C=EB=93=9C=20=EC=A0=81=EB=A6=BD)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 코스를 그릴 때 찍는 마커 개수를 계정 단위 재화로 관리한다. 소비(코스 저장 시 차감)는 조회-비교-차감을 조건부 UPDATE 한 문장으로 합쳐 락 없이 원자적으로 처리하고, 적립(광고 시청 보상)은 MarkerRewardClaim의 유니크 제약으로 같은 리워드 요청이 중복 지급되지 않도록 멱등성을 보장한다. 동시성 테스트(MarkerQuotaConcurrencyTest)로 두 경로 모두 검증: - 잔액보다 많은 소비 요청이 몰려도 잔액이 음수로 내려가지 않음 - 같은 rewardTransactionId로 동시에 요청해도 지급은 정확히 1번만 발생 리워드 지급 경로를 만들며 REQUIRES_NEW 호출부에 별도 @Transactional을 씌우면 스레드당 커넥션을 2개씩 점유해 동시 요청이 몰릴 때 커넥션 풀이 예상보다 빨리 고갈될 수 있다는 걸 실제로 재현해 확인하고 수정함. Claude-Session: https://claude.ai/code/session_01SPpjaoQ3Pxfd4624hWZsik --- .../server/common/constant/ErrorStatus.java | 1 + .../server/common/constant/SuccessStatus.java | 3 + .../controller/MarkerQuotaController.java | 52 +++++ .../request/ConsumeMarkerQuotaRequestDto.java | 17 ++ .../request/GrantMarkerRewardRequestDto.java | 25 +++ .../GrantMarkerRewardResponseDto.java | 18 ++ .../dto/response/MarkerQuotaResponseDto.java | 17 ++ .../markerquota/entity/MarkerRewardClaim.java | 63 ++++++ .../MarkerRewardClaimRepository.java | 7 + .../service/MarkerQuotaService.java | 66 ++++++ .../service/MarkerRewardGrantExecutor.java | 52 +++++ .../server/user/entity/RunnectUser.java | 11 + .../user/repository/UserRepository.java | 24 ++ .../MarkerQuotaConcurrencyTest.java | 205 ++++++++++++++++++ 14 files changed, 561 insertions(+) create mode 100644 src/main/java/org/runnect/server/markerquota/controller/MarkerQuotaController.java create mode 100644 src/main/java/org/runnect/server/markerquota/dto/request/ConsumeMarkerQuotaRequestDto.java create mode 100644 src/main/java/org/runnect/server/markerquota/dto/request/GrantMarkerRewardRequestDto.java create mode 100644 src/main/java/org/runnect/server/markerquota/dto/response/GrantMarkerRewardResponseDto.java create mode 100644 src/main/java/org/runnect/server/markerquota/dto/response/MarkerQuotaResponseDto.java create mode 100644 src/main/java/org/runnect/server/markerquota/entity/MarkerRewardClaim.java create mode 100644 src/main/java/org/runnect/server/markerquota/repository/MarkerRewardClaimRepository.java create mode 100644 src/main/java/org/runnect/server/markerquota/service/MarkerQuotaService.java create mode 100644 src/main/java/org/runnect/server/markerquota/service/MarkerRewardGrantExecutor.java create mode 100644 src/test/java/org/runnect/server/markerquota/MarkerQuotaConcurrencyTest.java diff --git a/src/main/java/org/runnect/server/common/constant/ErrorStatus.java b/src/main/java/org/runnect/server/common/constant/ErrorStatus.java index cdd2ec1..423d993 100644 --- a/src/main/java/org/runnect/server/common/constant/ErrorStatus.java +++ b/src/main/java/org/runnect/server/common/constant/ErrorStatus.java @@ -83,6 +83,7 @@ public enum ErrorStatus { ALREADY_EXIST_NICKNAME_EXCEPTION(HttpStatus.CONFLICT, "중복된 닉네임입니다."), ALREADY_UPLOAD_COURSE_EXCEPTION(HttpStatus.CONFLICT, "이미 업로드된 코스입니다."), ALREADY_EXIST_HEALTH_DATA_EXCEPTION(HttpStatus.CONFLICT, "이미 건강 데이터가 등록된 기록입니다"), + INSUFFICIENT_MARKER_QUOTA_EXCEPTION(HttpStatus.CONFLICT, "마커 수량이 부족합니다."), /** * 500 INTERNAL SERVER ERROR diff --git a/src/main/java/org/runnect/server/common/constant/SuccessStatus.java b/src/main/java/org/runnect/server/common/constant/SuccessStatus.java index 911bdff..36db3b0 100644 --- a/src/main/java/org/runnect/server/common/constant/SuccessStatus.java +++ b/src/main/java/org/runnect/server/common/constant/SuccessStatus.java @@ -35,6 +35,9 @@ public enum SuccessStatus { GET_HEALTH_DATA_SUCCESS(HttpStatus.OK, "건강 데이터 조회 성공"), GET_HEALTH_SUMMARY_SUCCESS(HttpStatus.OK, "건강 통계 조회 성공"), GET_BANNER_SUCCESS(HttpStatus.OK, "배너 조회 성공"), + GET_MARKER_QUOTA_SUCCESS(HttpStatus.OK, "마커 잔여 수량 조회 성공"), + CONSUME_MARKER_QUOTA_SUCCESS(HttpStatus.OK, "마커 소비 성공"), + GRANT_MARKER_REWARD_SUCCESS(HttpStatus.OK, "마커 리워드 지급 처리 성공"), UPDATE_RECORD_SUCCESS(HttpStatus.OK, "활동 기록 수정 성공"), diff --git a/src/main/java/org/runnect/server/markerquota/controller/MarkerQuotaController.java b/src/main/java/org/runnect/server/markerquota/controller/MarkerQuotaController.java new file mode 100644 index 0000000..c843fe4 --- /dev/null +++ b/src/main/java/org/runnect/server/markerquota/controller/MarkerQuotaController.java @@ -0,0 +1,52 @@ +package org.runnect.server.markerquota.controller; + +import javax.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.runnect.server.common.constant.SuccessStatus; +import org.runnect.server.common.dto.ApiResponseDto; +import org.runnect.server.common.resolver.userId.UserId; +import org.runnect.server.markerquota.dto.request.ConsumeMarkerQuotaRequestDto; +import org.runnect.server.markerquota.dto.request.GrantMarkerRewardRequestDto; +import org.runnect.server.markerquota.dto.response.GrantMarkerRewardResponseDto; +import org.runnect.server.markerquota.dto.response.MarkerQuotaResponseDto; +import org.runnect.server.markerquota.service.MarkerQuotaService; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/marker-quota") +public class MarkerQuotaController { + + private final MarkerQuotaService markerQuotaService; + + @GetMapping + @ResponseStatus(HttpStatus.OK) + public ApiResponseDto getBalance(@UserId Long userId) { + return ApiResponseDto.success(SuccessStatus.GET_MARKER_QUOTA_SUCCESS, + MarkerQuotaResponseDto.of(markerQuotaService.getBalance(userId))); + } + + @PostMapping("/consume") + @ResponseStatus(HttpStatus.OK) + public ApiResponseDto consume( + @UserId Long userId, + @RequestBody @Valid ConsumeMarkerQuotaRequestDto request) { + return ApiResponseDto.success(SuccessStatus.CONSUME_MARKER_QUOTA_SUCCESS, + MarkerQuotaResponseDto.of(markerQuotaService.consume(userId, request.getAmount()))); + } + + @PostMapping("/reward") + @ResponseStatus(HttpStatus.OK) + public ApiResponseDto grantFromAd( + @UserId Long userId, + @RequestBody @Valid GrantMarkerRewardRequestDto request) { + return ApiResponseDto.success(SuccessStatus.GRANT_MARKER_REWARD_SUCCESS, + markerQuotaService.grantFromAd(userId, request.getAmount(), request.getRewardTransactionId())); + } +} diff --git a/src/main/java/org/runnect/server/markerquota/dto/request/ConsumeMarkerQuotaRequestDto.java b/src/main/java/org/runnect/server/markerquota/dto/request/ConsumeMarkerQuotaRequestDto.java new file mode 100644 index 0000000..aa95a55 --- /dev/null +++ b/src/main/java/org/runnect/server/markerquota/dto/request/ConsumeMarkerQuotaRequestDto.java @@ -0,0 +1,17 @@ +package org.runnect.server.markerquota.dto.request; + +import javax.validation.constraints.Min; +import javax.validation.constraints.NotNull; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PRIVATE) +@AllArgsConstructor +public class ConsumeMarkerQuotaRequestDto { + @NotNull + @Min(1) + private Integer amount; +} diff --git a/src/main/java/org/runnect/server/markerquota/dto/request/GrantMarkerRewardRequestDto.java b/src/main/java/org/runnect/server/markerquota/dto/request/GrantMarkerRewardRequestDto.java new file mode 100644 index 0000000..9f2f2b5 --- /dev/null +++ b/src/main/java/org/runnect/server/markerquota/dto/request/GrantMarkerRewardRequestDto.java @@ -0,0 +1,25 @@ +package org.runnect.server.markerquota.dto.request; + +import javax.validation.constraints.Min; +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotNull; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PRIVATE) +@AllArgsConstructor +public class GrantMarkerRewardRequestDto { + @NotNull + @Min(1) + private Integer amount; + + /** + * 광고 SDK가 발급하는, 시청 1건당 고유한 식별자. 같은 값이 중복으로 들어와도 + * MarkerRewardClaim의 유니크 제약으로 한 번만 지급된다. + */ + @NotBlank + private String rewardTransactionId; +} diff --git a/src/main/java/org/runnect/server/markerquota/dto/response/GrantMarkerRewardResponseDto.java b/src/main/java/org/runnect/server/markerquota/dto/response/GrantMarkerRewardResponseDto.java new file mode 100644 index 0000000..2fa2db6 --- /dev/null +++ b/src/main/java/org/runnect/server/markerquota/dto/response/GrantMarkerRewardResponseDto.java @@ -0,0 +1,18 @@ +package org.runnect.server.markerquota.dto.response; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PRIVATE) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class GrantMarkerRewardResponseDto { + private boolean granted; + private int balance; + + public static GrantMarkerRewardResponseDto of(boolean granted, int balance) { + return new GrantMarkerRewardResponseDto(granted, balance); + } +} diff --git a/src/main/java/org/runnect/server/markerquota/dto/response/MarkerQuotaResponseDto.java b/src/main/java/org/runnect/server/markerquota/dto/response/MarkerQuotaResponseDto.java new file mode 100644 index 0000000..2aa683f --- /dev/null +++ b/src/main/java/org/runnect/server/markerquota/dto/response/MarkerQuotaResponseDto.java @@ -0,0 +1,17 @@ +package org.runnect.server.markerquota.dto.response; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PRIVATE) +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class MarkerQuotaResponseDto { + private int balance; + + public static MarkerQuotaResponseDto of(int balance) { + return new MarkerQuotaResponseDto(balance); + } +} diff --git a/src/main/java/org/runnect/server/markerquota/entity/MarkerRewardClaim.java b/src/main/java/org/runnect/server/markerquota/entity/MarkerRewardClaim.java new file mode 100644 index 0000000..67ad209 --- /dev/null +++ b/src/main/java/org/runnect/server/markerquota/entity/MarkerRewardClaim.java @@ -0,0 +1,63 @@ +package org.runnect.server.markerquota.entity; + +import javax.persistence.Entity; +import javax.persistence.FetchType; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; +import javax.persistence.JoinColumn; +import javax.persistence.ManyToOne; +import javax.persistence.Table; +import javax.persistence.UniqueConstraint; +import javax.persistence.Column; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.runnect.server.common.entity.AuditingTimeEntity; +import org.runnect.server.user.entity.RunnectUser; + +/** + * 영상 광고 시청 보상으로 마커 재화를 지급받은 기록. (user_id, rewardTransactionId) 유니크 + * 제약이 같은 리워드가 중복 지급되는 걸 막는 유일한 장치다 — 네트워크 재시도나 광고 SDK 콜백 + * 중복으로 같은 시청 건에 대한 지급 요청이 여러 번 들어와도, 두 번째 이후 저장은 제약 위반으로 + * 실패해 잔액이 두 번 늘어나지 않는다. 지급 성공 여부를 애플리케이션에서 먼저 조회해 판단하지 + * 않는 이유는, 조회와 저장 사이에도 같은 레이스가 생길 수 있기 때문이다 — DB 유니크 제약만이 + * 이 판단을 원자적으로 보장한다. + */ +@Getter +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(uniqueConstraints = { + @UniqueConstraint( + columnNames = {"user_id", "reward_transaction_id"} + ) +}) +public class MarkerRewardClaim extends AuditingTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private RunnectUser runnectUser; + + @Column(name = "reward_transaction_id", nullable = false, length = 100) + private String rewardTransactionId; + + @Column(nullable = false) + private int grantedAmount; + + @Builder + public MarkerRewardClaim(RunnectUser runnectUser, String rewardTransactionId, int grantedAmount) { + this.runnectUser = runnectUser; + this.rewardTransactionId = rewardTransactionId; + this.grantedAmount = grantedAmount; + } + + @Override + public void updateDeletedAt() { + throw new RuntimeException("MarkerRewardClaim은 삭제되지 않는 지급 이력입니다."); + } +} diff --git a/src/main/java/org/runnect/server/markerquota/repository/MarkerRewardClaimRepository.java b/src/main/java/org/runnect/server/markerquota/repository/MarkerRewardClaimRepository.java new file mode 100644 index 0000000..e883c03 --- /dev/null +++ b/src/main/java/org/runnect/server/markerquota/repository/MarkerRewardClaimRepository.java @@ -0,0 +1,7 @@ +package org.runnect.server.markerquota.repository; + +import org.runnect.server.markerquota.entity.MarkerRewardClaim; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface MarkerRewardClaimRepository extends JpaRepository { +} diff --git a/src/main/java/org/runnect/server/markerquota/service/MarkerQuotaService.java b/src/main/java/org/runnect/server/markerquota/service/MarkerQuotaService.java new file mode 100644 index 0000000..abeea66 --- /dev/null +++ b/src/main/java/org/runnect/server/markerquota/service/MarkerQuotaService.java @@ -0,0 +1,66 @@ +package org.runnect.server.markerquota.service; + +import lombok.RequiredArgsConstructor; +import org.runnect.server.common.constant.ErrorStatus; +import org.runnect.server.common.exception.ConflictException; +import org.runnect.server.common.exception.NotFoundException; +import org.runnect.server.markerquota.dto.response.GrantMarkerRewardResponseDto; +import org.runnect.server.user.entity.RunnectUser; +import org.runnect.server.user.repository.UserRepository; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class MarkerQuotaService { + + private final UserRepository userRepository; + private final MarkerRewardGrantExecutor markerRewardGrantExecutor; + + @Transactional(readOnly = true) + public int getBalance(Long userId) { + return findUser(userId).getMarkerQuotaBalance(); + } + + /** + * amount만큼 마커 재화를 소비한다(코스 저장 시점에 호출). 잔액 확인과 차감이 UserRepository의 + * 조건부 UPDATE 한 문장으로 원자적으로 처리되므로, 같은 유저의 요청이 겹쳐도 잔액이 실제보다 + * 잘못 계산될 여지가 없다. 갱신된 행이 없으면(잔액 부족) 409로 알린다. + */ + @Transactional + public int consume(Long userId, int amount) { + int updatedRows = userRepository.consumeMarkerQuota(userId, amount); + if (updatedRows == 0) { + throw new ConflictException( + ErrorStatus.INSUFFICIENT_MARKER_QUOTA_EXCEPTION, + ErrorStatus.INSUFFICIENT_MARKER_QUOTA_EXCEPTION.getMessage()); + } + return findUser(userId).getMarkerQuotaBalance(); + } + + /** + * 광고 시청 보상을 지급한다. 같은 rewardTransactionId로 중복 요청이 들어오면 + * MarkerRewardGrantExecutor의 유니크 제약 위반으로 두 번째부터는 조용히 무시(granted=false)된다. + * + * 이 메서드는 의도적으로 @Transactional을 붙이지 않는다 — 그래야 markerRewardGrantExecutor.grant() + * (REQUIRES_NEW)가 이 스레드의 유일한 트랜잭션으로 실행되고 끝나며, 그 뒤에 잔액 조회가 별도의 + * 새 트랜잭션으로 열린다. 이 메서드에 @Transactional을 붙이면 그 트랜잭션의 커넥션을 쥔 채로 + * REQUIRES_NEW 커넥션을 하나 더 요구하게 돼, 요청이 몰릴 때 커넥션 풀이 두 배 빠르게 고갈된다. + */ + public GrantMarkerRewardResponseDto grantFromAd(Long userId, int amount, String rewardTransactionId) { + boolean granted = true; + try { + markerRewardGrantExecutor.grant(userId, rewardTransactionId, amount); + } catch (DataIntegrityViolationException e) { + granted = false; + } + return GrantMarkerRewardResponseDto.of(granted, getBalance(userId)); + } + + private RunnectUser findUser(Long userId) { + return userRepository.findById(userId) + .orElseThrow(() -> new NotFoundException( + ErrorStatus.NOT_FOUND_USER_EXCEPTION, ErrorStatus.NOT_FOUND_USER_EXCEPTION.getMessage())); + } +} diff --git a/src/main/java/org/runnect/server/markerquota/service/MarkerRewardGrantExecutor.java b/src/main/java/org/runnect/server/markerquota/service/MarkerRewardGrantExecutor.java new file mode 100644 index 0000000..dd61798 --- /dev/null +++ b/src/main/java/org/runnect/server/markerquota/service/MarkerRewardGrantExecutor.java @@ -0,0 +1,52 @@ +package org.runnect.server.markerquota.service; + +import lombok.RequiredArgsConstructor; +import org.runnect.server.common.constant.ErrorStatus; +import org.runnect.server.common.exception.NotFoundException; +import org.runnect.server.markerquota.entity.MarkerRewardClaim; +import org.runnect.server.markerquota.repository.MarkerRewardClaimRepository; +import org.runnect.server.user.entity.RunnectUser; +import org.runnect.server.user.repository.UserRepository; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +/** + * 같은 광고 리워드 요청이 재시도나 콜백 중복으로 짧은 시간에 두 번 들어오면, MarkerRewardClaim의 + * (user_id, rewardTransactionId) 유니크 제약이 두 번째 저장을 거부한다. 이 저장은 REQUIRES_NEW로 + * 호출부(MarkerQuotaService)와 분리된 트랜잭션에서 실행한다 — 제약 위반 예외를 여기서 삼키지 + * 않고 그대로 던져야 이 트랜잭션이 정상적으로 롤백된다(SocialSignUpRegistrar와 동일한 이유 — + * Postgres는 실패한 문장 이후의 트랜잭션을 "aborted" 상태로 만들어, 캐치하고 계속 진행하면 + * 커밋 시점에 UnexpectedRollbackException으로 다시 실패한다). 예외는 그대로 전파시켜 + * 호출부(MarkerQuotaService.grantFromAd)에서 잡아 "이미 지급됨"으로 처리한다. + * + * 호출부는 이 메서드를 감싸는 별도의 @Transactional을 두지 않는다. 만약 호출부가 트랜잭션을 이미 + * 열어둔 채로 이 REQUIRES_NEW를 호출하면, 그 스레드는 호출부의 커넥션과 이 메서드의 커넥션을 + * 동시에 점유하게 된다 — 동시 요청이 많아지면 스레드마다 커넥션을 2개씩 필요로 하게 돼 커넥션 + * 풀이 예상보다 훨씬 빨리 고갈될 수 있다(실제로 동시성 테스트에서 스레드 10개로 이 상태를 + * 재현해 확인함). 그래서 userId만 받아 이 메서드 안에서 직접 조회하고, 호출부는 이 호출이 끝난 + * 뒤에야 결과 조회용 트랜잭션을 새로 연다. + */ +@Component +@RequiredArgsConstructor +public class MarkerRewardGrantExecutor { + + private final UserRepository userRepository; + private final MarkerRewardClaimRepository markerRewardClaimRepository; + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void grant(Long userId, String rewardTransactionId, int amount) { + RunnectUser runnectUser = userRepository.findById(userId) + .orElseThrow(() -> new NotFoundException( + ErrorStatus.NOT_FOUND_USER_EXCEPTION, ErrorStatus.NOT_FOUND_USER_EXCEPTION.getMessage())); + + markerRewardClaimRepository.saveAndFlush( + MarkerRewardClaim.builder() + .runnectUser(runnectUser) + .rewardTransactionId(rewardTransactionId) + .grantedAmount(amount) + .build() + ); + userRepository.grantMarkerQuota(userId, amount); + } +} diff --git a/src/main/java/org/runnect/server/user/entity/RunnectUser.java b/src/main/java/org/runnect/server/user/entity/RunnectUser.java index 9c29b9d..9c2e87b 100644 --- a/src/main/java/org/runnect/server/user/entity/RunnectUser.java +++ b/src/main/java/org/runnect/server/user/entity/RunnectUser.java @@ -32,6 +32,8 @@ }) public class RunnectUser extends AuditingTimeEntity { + private static final int DEFAULT_MARKER_QUOTA_BALANCE = 20; + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @@ -68,6 +70,14 @@ public class RunnectUser extends AuditingTimeEntity { @Column(nullable = false) private Long createdScrap; + /** + * 코스를 그릴 때 찍을 수 있는 마커 재화. 소비(코스 저장)와 적립(광고 시청 보상)이 각각 + * UserRepository의 조건부 UPDATE(consumeMarkerQuota/grantMarkerQuota) 한 쿼리로 원자적으로 + * 처리되므로, 이 필드는 벌크 UPDATE를 통해서만 바뀌고 엔티티 세터를 거치지 않는다. + */ + @Column(nullable = false, columnDefinition = "integer default 20") + private int markerQuotaBalance; + /** * 카운터 필드(createdCourse 등)를 다른 트랜잭션이 동시에 읽고 수정하면 Lost Update가 * 발생할 수 있어 낙관적 락을 건다. 충돌 시 UserStampService.recordActivityAndAwardStamp가 @@ -100,6 +110,7 @@ public RunnectUser(String nickname, String socialId, String email, SocialType pr this.createdRecord = 0L; this.createdPublicCourse = 0L; this.createdScrap = 0L; + this.markerQuotaBalance = DEFAULT_MARKER_QUOTA_BALANCE; } public RunnectUser(String nickname) { diff --git a/src/main/java/org/runnect/server/user/repository/UserRepository.java b/src/main/java/org/runnect/server/user/repository/UserRepository.java index dafc46b..72dc4a5 100644 --- a/src/main/java/org/runnect/server/user/repository/UserRepository.java +++ b/src/main/java/org/runnect/server/user/repository/UserRepository.java @@ -4,6 +4,7 @@ import org.runnect.server.user.entity.RunnectUser; import org.runnect.server.user.entity.SocialType; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -22,6 +23,29 @@ public interface UserRepository extends JpaRepository { Optional findByEmailAndProvider(String email, SocialType provider); + // UPDATE + + /** + * 마커 재화 소비. "잔액 조회 -> 비교 -> 차감"을 나누지 않고 조건(잔액 >= amount)과 차감을 + * 하나의 UPDATE 문에 담아 처리한다 — 같은 유저의 요청이 짧은 시간에 겹쳐도 이 문장 자체가 + * DB에서 원자적으로 실행되어 잔액이 잘못 계산되거나 음수로 내려갈 여지가 없다. 반환값이 + * 0이면 잔액 부족으로 차감이 일어나지 않은 것이다. + */ + @Modifying(clearAutomatically = true) + @Query("UPDATE RunnectUser u SET u.markerQuotaBalance = u.markerQuotaBalance - :amount " + + "WHERE u.id = :userId AND u.markerQuotaBalance >= :amount") + int consumeMarkerQuota(@Param("userId") Long userId, @Param("amount") int amount); + + /** + * 마커 재화 적립(광고 시청 보상). 같은 리워드가 중복 지급되지 않도록 하는 멱등성 보장은 + * 호출부(MarkerRewardGrantExecutor)의 MarkerRewardClaim 유니크 제약이 담당하고, 이 쿼리는 + * 실제 잔액 증가만 원자적으로 수행한다. + */ + @Modifying(clearAutomatically = true) + @Query("UPDATE RunnectUser u SET u.markerQuotaBalance = u.markerQuotaBalance + :amount " + + "WHERE u.id = :userId") + int grantMarkerQuota(@Param("userId") Long userId, @Param("amount") int amount); + // DELETE } diff --git a/src/test/java/org/runnect/server/markerquota/MarkerQuotaConcurrencyTest.java b/src/test/java/org/runnect/server/markerquota/MarkerQuotaConcurrencyTest.java new file mode 100644 index 0000000..0535925 --- /dev/null +++ b/src/test/java/org/runnect/server/markerquota/MarkerQuotaConcurrencyTest.java @@ -0,0 +1,205 @@ +package org.runnect.server.markerquota; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import javax.persistence.EntityManager; +import javax.persistence.PersistenceContext; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.runnect.server.common.exception.ConflictException; +import org.runnect.server.markerquota.dto.response.GrantMarkerRewardResponseDto; +import org.runnect.server.markerquota.service.MarkerQuotaService; +import org.runnect.server.user.entity.RunnectUser; +import org.runnect.server.user.entity.SocialType; +import org.runnect.server.user.repository.UserRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; + +/** + * 마커 재화의 두 가지 갱신 경로(소비/적립)가 동시 요청에도 정합성을 지키는지 실제 로컬 + * Postgres로 검증한다. + * + * 1) {@link ConsumeQuota#요청이_몰려도_잔액이_음수로_내려가지_않는다}: 소비는 잔액 확인과 차감을 + * UserRepository.consumeMarkerQuota() 조건부 UPDATE 한 문장으로 처리한다. 트랜잭션/락을 + * 직접 관리하지 않아도, 이 문장 자체가 DB에서 원자적으로 실행되므로 잔액보다 많은 소비가 + * 성공할 수 없다는 것을 검증한다. + * 2) {@link GrantReward#같은_트랜잭션ID로_동시에_요청해도_한_번만_지급된다}: 적립은 + * MarkerRewardClaim의 (user_id, rewardTransactionId) 유니크 제약으로 멱등성을 보장한다. + * 같은 광고 시청 건에 대한 지급 요청이 재시도 등으로 동시에 여러 번 들어와도 실제 지급은 + * 정확히 한 번만 일어나야 한다. + */ +@SpringBootTest +class MarkerQuotaConcurrencyTest { + + @Autowired + private MarkerQuotaService markerQuotaService; + + @Autowired + private UserRepository userRepository; + + @Autowired + private PlatformTransactionManager transactionManager; + + @PersistenceContext + private EntityManager entityManager; + + private Long testUserId; + + @AfterEach + void tearDown() { + if (testUserId == null) { + return; + } + TransactionTemplate tx = new TransactionTemplate(transactionManager); + tx.executeWithoutResult(status -> { + // 리워드 지급 과정에서 생긴 MarkerRewardClaim 자식 row부터 지워야 FK 제약을 안 건드린다. + entityManager.createQuery("DELETE FROM MarkerRewardClaim c WHERE c.runnectUser.id = :userId") + .setParameter("userId", testUserId) + .executeUpdate(); + userRepository.deleteById(testUserId); + }); + } + + private Long createTestUserWithBalance(String nickname, int balance) { + TransactionTemplate tx = new TransactionTemplate(transactionManager); + Long userId = tx.execute(status -> userRepository.save( + RunnectUser.builder() + .nickname(nickname) + .socialId("marker-quota-test-social-id-" + nickname) + .email("marker-quota-test-" + nickname + "@runnect.test") + .provider(SocialType.VISITOR) + .build() + ).getId()); + + tx.executeWithoutResult(status -> { + int updated = userRepository.consumeMarkerQuota(userId, 20 - balance); + assertThat(updated).withFailMessage("테스트 초기 잔액 세팅 실패").isEqualTo(1); + }); + return userId; + } + + @Nested + @DisplayName("마커 재화 소비") + class ConsumeQuota { + + @Test + void 요청이_몰려도_잔액이_음수로_내려가지_않는다() throws InterruptedException { + int initialBalance = 5; + testUserId = createTestUserWithBalance("cc-consume", initialBalance); + int threadCount = 10; + + ExecutorService executor = Executors.newFixedThreadPool(threadCount); + CountDownLatch readyLatch = new CountDownLatch(threadCount); + CountDownLatch startLatch = new CountDownLatch(1); + CountDownLatch doneLatch = new CountDownLatch(threadCount); + AtomicInteger successCount = new AtomicInteger(0); + AtomicInteger insufficientCount = new AtomicInteger(0); + AtomicInteger unexpectedFailureCount = new AtomicInteger(0); + + for (int i = 0; i < threadCount; i++) { + executor.submit(() -> { + try { + readyLatch.countDown(); + startLatch.await(); + markerQuotaService.consume(testUserId, 1); + successCount.incrementAndGet(); + } catch (ConflictException e) { + insufficientCount.incrementAndGet(); + } catch (Exception e) { + unexpectedFailureCount.incrementAndGet(); + } finally { + doneLatch.countDown(); + } + }); + } + + readyLatch.await(); + startLatch.countDown(); + boolean completed = doneLatch.await(15, TimeUnit.SECONDS); + executor.shutdown(); + + assertThat(completed).withFailMessage("스레드가 제한 시간 내에 끝나지 않음").isTrue(); + assertThat(unexpectedFailureCount.get()) + .withFailMessage("예상 못 한 예외가 %d건 발생함", unexpectedFailureCount.get()) + .isZero(); + assertThat(successCount.get()) + .withFailMessage("초기 잔액(%d)보다 많은 소비가 성공함", initialBalance) + .isEqualTo(initialBalance); + assertThat(insufficientCount.get()).isEqualTo(threadCount - initialBalance); + + int finalBalance = markerQuotaService.getBalance(testUserId); + assertThat(finalBalance) + .withFailMessage("잔액이 음수로 내려가거나 잘못 계산됨 (최종 잔액=%d)", finalBalance) + .isZero(); + } + } + + @Nested + @DisplayName("마커 재화 적립(광고 리워드)") + class GrantReward { + + @Test + void 같은_트랜잭션ID로_동시에_요청해도_한_번만_지급된다() throws InterruptedException { + testUserId = createTestUserWithBalance("cc-reward", 0); + int threadCount = 10; + int rewardAmount = 5; + String sameRewardTransactionId = "ad-watch-txn-duplicate-test"; + + ExecutorService executor = Executors.newFixedThreadPool(threadCount); + CountDownLatch readyLatch = new CountDownLatch(threadCount); + CountDownLatch startLatch = new CountDownLatch(1); + CountDownLatch doneLatch = new CountDownLatch(threadCount); + AtomicInteger grantedCount = new AtomicInteger(0); + AtomicInteger rejectedCount = new AtomicInteger(0); + AtomicInteger unexpectedFailureCount = new AtomicInteger(0); + + for (int i = 0; i < threadCount; i++) { + executor.submit(() -> { + try { + readyLatch.countDown(); + startLatch.await(); + GrantMarkerRewardResponseDto result = markerQuotaService.grantFromAd( + testUserId, rewardAmount, sameRewardTransactionId); + if (result.isGranted()) { + grantedCount.incrementAndGet(); + } else { + rejectedCount.incrementAndGet(); + } + } catch (Exception e) { + unexpectedFailureCount.incrementAndGet(); + } finally { + doneLatch.countDown(); + } + }); + } + + readyLatch.await(); + startLatch.countDown(); + boolean completed = doneLatch.await(15, TimeUnit.SECONDS); + executor.shutdown(); + + assertThat(completed).withFailMessage("스레드가 제한 시간 내에 끝나지 않음").isTrue(); + assertThat(unexpectedFailureCount.get()) + .withFailMessage("예상 못 한 예외가 %d건 발생함", unexpectedFailureCount.get()) + .isZero(); + assertThat(grantedCount.get()) + .withFailMessage("같은 트랜잭션ID로 %d번 지급이 성공함 — 중복 지급 발생", grantedCount.get()) + .isEqualTo(1); + assertThat(rejectedCount.get()).isEqualTo(threadCount - 1); + + int finalBalance = markerQuotaService.getBalance(testUserId); + assertThat(finalBalance) + .withFailMessage("리워드가 중복 반영되어 잔액이 %d이 아닌 %d로 기록됨", rewardAmount, finalBalance) + .isEqualTo(rewardAmount); + } + } +}