Skip to content

Commit d8a8775

Browse files
unam98alh0409
andauthored
유저 활동 카운터의 Lost Update 방지 (@Version + 격리 재시도) (#251)
* 유저 활동 카운터(createdCourse 등)의 Lost Update 방지 RunnectUser.createdCourse/createdRecord/createdScrap/createdPublicCourse가 순수 in-memory ++ 로만 구현돼 있어, 동시 요청이 같은 유저 row를 커밋 전에 읽으면 한쪽 증가분이 조용히 유실될 수 있었다(로컬 Postgres 대상 재현 테스트로 실증, 다음 커밋 참고). RunnectUser에 @Version 낙관적 락을 추가하고, 카운터 증가 + 스탬프 지급을 메인 트랜잭션(코스/기록/스크랩 생성)에서 분리된 REQUIRES_NEW 트랜잭션 (UserStampService.recordActivityAndAwardStamp)으로 격리했다 — 낙관적 락 충돌이 코스/기록/스크랩 생성 자체까지 롤백시키지 않게 하기 위함이다. 충돌 시에는 OptimisticLockRetrier가 지터를 두고 재시도한다. * 유저 카운터 Lost Update 재현/수정 검증 테스트 추가 로컬 Postgres에 대해 실제 트랜잭션 두 개가 커밋 전 상태를 서로 보지 못하는 상황을 재현해, @Version 도입 전에는 조용한 데이터 손상 (assertion 실패)으로, 도입 후에는 명시적 충돌 감지 (ObjectOptimisticLockingFailureException)로 바뀌었음을 검증한다. 두 번째 테스트는 실제 프로덕션 경로(OptimisticLockRetrier + recordActivityAndAwardStamp)를 스레드 10개로 동시 호출해, 재시도로 충돌이 모두 해소되고 최종 카운트가 정확히 맞는지(호출부에는 예외가 전파되지 않는지) 검증한다. * 스탬프/카운터 갱신이 메인 저장 롤백과 무관하게 커밋되던 문제 수정 CodeRabbit 리뷰 지적: recordActivityAndAwardStamp()가 REQUIRES_NEW라 호출 즉시 별도 트랜잭션으로 독립 커밋되는데, 이 호출이 메인 엔티티 저장보다 먼저(Scrap) 또는 저장 직후지만 같은 트랜잭션 커밋 전에(Course/Record) 실행되고 있었다. 메인 저장이 실패해 트랜잭션이 롤백돼도 이미 커밋된 스탬프/카운터는 되돌아가지 않는다. 실제로 재현: 같은 유저가 같은 코스를 동시에 스크랩하면 유니크 제약으로 한쪽만 저장에 성공하는데(#252), 수정 전에는 실패한 쪽도 스탬프/카운터가 반영돼 createdScrap이 실제 스크랩 개수(1)보다 많은 2로 기록됨을 테스트로 확인. OptimisticLockRetrier에 runAfterCommit()을 추가해, 메인 트랜잭션이 실제로 커밋된 뒤에만(TransactionSynchronization.afterCommit) 재시도 로직이 실행되게 했다 — 이 프로젝트에 이미 있던 랭킹(Redis) 갱신의 afterCommit 패턴과 동일한 구조. 재시도가 모두 소진되는 극단적인 경우엔 메인 작업 자체는 이미 성공했으므로 예외를 던지지 않고 로그로만 남긴다. * 스크랩 동시성 테스트가 로컬 시드 데이터에 우연히 의존하던 문제 수정 이 테스트는 PublicCourseRepository를 목으로 대체하면서도 (user_id, public_course_id) FK 제약을 만족하려면 실제 course/public_course row가 DB에 있어야 한다는 걸 놓치고, 로컬 개발 DB에 우연히 남아있던 더미 데이터 (id=1)를 그대로 가정하고 작성했었다. PR #251 CI에서 재현: CI는 매번 새로 뜨는 빈 DB라 그 row가 아예 없어서, 두 스레드 모두 진짜 유니크 제약 위반이 아니라 FK 제약 위반으로 실패했다. 그런데 둘 다 같은 DataIntegrityViolationException → ConflictException 경로를 타기 때문에 겉보기엔 "정상 통과"처럼 보였다 — 실제로는 검증하려던 레이스와 무관한 이유로 우연히 같은 예외 타입이 나온 것뿐이었다. 진단 로그로 capturedException/스레드별 결과/실제 저장된 scrap 개수/public_course 실존 여부를 직접 찍어보고서야 확인했다. 이제 @beforeeach에서 필요한 course/public_course row를 테스트가 직접 INSERT(네이티브 SQL)해서 어떤 환경에서 실행해도 동일하게 동작하도록 했다. (로컬 Postgres 컨테이너에 postgis 패키지가 아예 설치돼 있지 않았던 것도 같이 확인해 postgresql-15-postgis-3를 설치했다 — 기존에 문서화된 "PostGIS 공유 라이브러리 결함"의 실제 원인이었다.) * CI Postgres 이미지를 PostGIS 포함 이미지로 교체 Course.path가 PostGIS geometry 컬럼인데 CI는 순정 postgres:15를 써서 course 테이블 생성 자체가 매번 실패하고 있었다. Hibernate가 ddl-auto 실패를 조용히 로그만 남기고 넘어가는 바람에 지금까지 드러나지 않았을 뿐, CI에 course 테이블이 존재한 적이 없었다 — 실제 Course/PublicCourse row가 필요 없던 테스트만 우연히 계속 통과해온 것이었다. ScrapConcurrencyTest에 실제 course/public_course row를 직접 만들도록 고치는 과정에서 발견했다. prod/staging과 동일한 postgis/postgis 이미지로 바꿔 CI가 실제 스키마로 검증하도록 한다. --------- Co-authored-by: 나미 <dnska6657@gmail.com>
1 parent ff0a1ab commit d8a8775

13 files changed

Lines changed: 428 additions & 33 deletions

File tree

.github/workflows/prod-ci.yml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,14 @@ jobs:
1414

1515
# 테스트(contextLoads 등)는 실제 prod DB/Redis가 아니라 CI 안에서 띄우는
1616
# 임시 컨테이너를 바라보게 한다 (원격 prod 환경에 직접 의존하지 않도록).
17+
# Course.path가 PostGIS geometry 컬럼이라 순정 postgres 이미지로는 그 컬럼이 있는
18+
# 테이블(course) 생성 자체가 실패한다(Hibernate가 ddl-auto 실패를 조용히 무시해서
19+
# 지금까지 드러나지 않았을 뿐, course 테이블이 CI에 존재한 적이 없었다 — 실제 Course row가
20+
# 필요 없던 테스트만 우연히 계속 통과해온 것). prod/staging과 동일하게 PostGIS가 포함된
21+
# 이미지를 써서 CI가 실제 환경과 같은 스키마로 검증하도록 한다.
1722
services:
1823
postgres:
19-
image: postgres:15
24+
image: postgis/postgis:15-3.4
2025
env:
2126
POSTGRES_DB: runnect
2227
POSTGRES_USER: runnect
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
package org.runnect.server.common.module.concurrency;
2+
3+
import java.util.concurrent.ThreadLocalRandom;
4+
import lombok.extern.slf4j.Slf4j;
5+
import org.springframework.orm.ObjectOptimisticLockingFailureException;
6+
import org.springframework.stereotype.Component;
7+
import org.springframework.transaction.support.TransactionSynchronization;
8+
import org.springframework.transaction.support.TransactionSynchronizationManager;
9+
10+
/**
11+
* 낙관적 락(@Version) 충돌로 실패한 작업을 정해진 횟수만큼 재시도한다.
12+
* action은 반드시 REQUIRES_NEW 등으로 독립된 트랜잭션 단위여야 한다 — 그래야 각 시도가
13+
* 완전히 새로운 트랜잭션에서 최신 상태를 다시 읽고 재시도할 수 있다.
14+
*
15+
* 재시도 사이에 짧은 지터를 둔다 — 충돌한 모든 스레드가 즉시 동시에 재시도하면 서로
16+
* 다시 충돌할 확률이 높아지는 thundering herd 현상을 완화하기 위함이다.
17+
*/
18+
@Slf4j
19+
@Component
20+
public class OptimisticLockRetrier {
21+
22+
private static final int MAX_ATTEMPTS = 8;
23+
private static final int BASE_BACKOFF_MILLIS = 15;
24+
25+
public void runWithRetry(Runnable action) {
26+
for (int attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
27+
try {
28+
action.run();
29+
return;
30+
} catch (ObjectOptimisticLockingFailureException e) {
31+
if (attempt == MAX_ATTEMPTS) {
32+
throw e;
33+
}
34+
backoff(attempt);
35+
}
36+
}
37+
}
38+
39+
/**
40+
* action을 지금 실행하지 않고, 호출 시점에 진행 중인 트랜잭션이 실제로 커밋된 뒤에만
41+
* 실행되도록 예약한다. action 자체는 REQUIRES_NEW라 호출 시점(=원래의 runWithRetry)에
42+
* 즉시 별도 트랜잭션으로 독립 커밋되는데, 그 시점이 메인 트랜잭션의 저장보다 앞서면
43+
* 메인 저장이 나중에 실패해 롤백돼도 이 action은 이미 커밋돼 있어 되돌릴 수 없다.
44+
* afterCommit으로 미루면 메인 트랜잭션이 실제로 성공했을 때만 실행된다. 진행 중인
45+
* 트랜잭션이 없는 채로 호출되면(동기화 비활성) 예약할 커밋 시점 자체가 없으므로
46+
* 그 자리에서 바로 실행한다.
47+
*
48+
* 재시도가 모두 소진되는 경우(극단적인 지속 경합)는 메인 작업 자체는 이미 성공한
49+
* 뒤이므로, 그 실패를 클라이언트 응답의 실패로 만들지 않고 로그로만 남긴다.
50+
*/
51+
public void runAfterCommit(Runnable action) {
52+
if (!TransactionSynchronizationManager.isSynchronizationActive()) {
53+
runWithRetry(action);
54+
return;
55+
}
56+
57+
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
58+
@Override
59+
public void afterCommit() {
60+
try {
61+
runWithRetry(action);
62+
} catch (ObjectOptimisticLockingFailureException e) {
63+
log.error("커밋 후 재시도가 모두 소진되어 활동 카운터/스탬프 반영에 실패함", e);
64+
}
65+
}
66+
});
67+
}
68+
69+
private void backoff(int attempt) {
70+
int maxJitterMillis = BASE_BACKOFF_MILLIS * attempt;
71+
long sleepMillis = ThreadLocalRandom.current().nextLong(maxJitterMillis + 1);
72+
try {
73+
Thread.sleep(sleepMillis);
74+
} catch (InterruptedException e) {
75+
Thread.currentThread().interrupt();
76+
}
77+
}
78+
}

src/main/java/org/runnect/server/course/service/CourseService.java

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import org.locationtech.jts.geom.LineString;
1010
import org.runnect.server.common.dto.DepartureResponse;
1111
import org.runnect.server.common.exception.NotFoundException;
12+
import org.runnect.server.common.module.concurrency.OptimisticLockRetrier;
1213
import org.runnect.server.common.module.convert.CoordinatePathConverter;
1314
import org.runnect.server.common.module.convert.DepartureConverter;
1415
import org.runnect.server.course.dto.request.CourseCreateRequestDto;
@@ -39,6 +40,7 @@ public class CourseService {
3940
private final PublicCourseRepository publicCourseRepository;
4041
private final UserRepository userRepository;
4142
private final UserStampService userStampService;
43+
private final OptimisticLockRetrier optimisticLockRetrier;
4244

4345
@Transactional
4446
public CourseCreateResponseDto createCourse(Long userId, CourseCreateRequestDto requestDto,
@@ -65,8 +67,9 @@ public CourseCreateResponseDto createCourse(Long userId, CourseCreateRequestDto
6567
.build();
6668

6769
Course saved = courseRepository.save(course);
68-
user.updateCreatedCourse();
69-
userStampService.createStampByUser(user, StampType.c);
70+
optimisticLockRetrier.runAfterCommit(
71+
() -> userStampService.recordActivityAndAwardStamp(user.getId(), StampType.c)
72+
);
7073

7174
return CourseCreateResponseDto.of(saved.getId(), saved.getCreatedAt());
7275
}

src/main/java/org/runnect/server/record/service/RecordService.java

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import org.runnect.server.common.constant.ErrorStatus;
1010
import org.runnect.server.common.exception.NotFoundException;
1111
import org.runnect.server.common.exception.PermissionDeniedException;
12+
import org.runnect.server.common.module.concurrency.OptimisticLockRetrier;
1213
import org.runnect.server.course.entity.Course;
1314
import org.runnect.server.course.repository.CourseRepository;
1415
import org.runnect.server.publicCourse.entity.PublicCourse;
@@ -52,6 +53,7 @@ public class RecordService {
5253
private final UserStampService userStampService;
5354
private final RecordHealthDataRepository recordHealthDataRepository;
5455
private final RecordRankingService recordRankingService;
56+
private final OptimisticLockRetrier optimisticLockRetrier;
5557

5658
@Transactional
5759
public CreateRecordResponseDto createRecord(Long userId, CreateRecordRequestDto request) {
@@ -91,8 +93,9 @@ public CreateRecordResponseDto createRecord(Long userId, CreateRecordRequestDto
9193
registerRankingUpdateAfterCommit(publicCourse.getId(), userId, record.getId(), time);
9294
}
9395

94-
user.updateCreatedRecord();
95-
userStampService.createStampByUser(user, StampType.r);
96+
optimisticLockRetrier.runAfterCommit(
97+
() -> userStampService.recordActivityAndAwardStamp(userId, StampType.r)
98+
);
9699

97100
CreateRecordDto recordDto = new CreateRecordDto(record.getId(), record.getCreatedAt().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS")));
98101

src/main/java/org/runnect/server/scrap/service/ScrapService.java

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import org.runnect.server.common.constant.ErrorStatus;
77
import org.runnect.server.common.exception.ConflictException;
88
import org.runnect.server.common.exception.NotFoundException;
9+
import org.runnect.server.common.module.concurrency.OptimisticLockRetrier;
910
import org.runnect.server.publicCourse.entity.PublicCourse;
1011
import org.runnect.server.publicCourse.repository.PublicCourseRepository;
1112
import org.runnect.server.scrap.dto.request.CreateAndDeleteScrapRequestDto;
@@ -29,6 +30,7 @@ public class ScrapService {
2930
private final UserRepository userRepository;
3031
private final PublicCourseRepository publicCourseRepository;
3132
private final UserStampService userStampService;
33+
private final OptimisticLockRetrier optimisticLockRetrier;
3234

3335
@Transactional
3436
public CreateAndDeleteScrapResponseDto createAndDeleteScrap(Long userId, CreateAndDeleteScrapRequestDto request) {
@@ -46,9 +48,6 @@ public CreateAndDeleteScrapResponseDto createAndDeleteScrap(Long userId, CreateA
4648
.runnectUser(user)
4749
.build();
4850

49-
user.updateCreatedScrap();
50-
userStampService.createStampByUser(user, StampType.s);
51-
5251
// 동시에 같은 코스를 스크랩하는 요청이 겹치면 둘 다 "기존 스크랩 없음"을 보고
5352
// 각자 저장을 시도할 수 있다 — (user_id, public_course_id) 유니크 제약으로 DB가
5453
// 하나는 거부하는데, 그 예외를 그대로 두면 500으로 샌다(HealthService의 기존
@@ -60,6 +59,10 @@ public CreateAndDeleteScrapResponseDto createAndDeleteScrap(Long userId, CreateA
6059
ErrorStatus.ALREADY_EXIST_SCRAP_EXCEPTION,
6160
ErrorStatus.ALREADY_EXIST_SCRAP_EXCEPTION.getMessage());
6261
}
62+
63+
optimisticLockRetrier.runAfterCommit(
64+
() -> userStampService.recordActivityAndAwardStamp(userId, StampType.s)
65+
);
6366
} else {
6467
// 기존 스크랩한 내역이 있을 때
6568
scrap.updateScrapTF(true);

src/main/java/org/runnect/server/user/entity/RunnectUser.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import javax.persistence.OneToMany;
1313
import javax.persistence.Table;
1414
import javax.persistence.UniqueConstraint;
15+
import javax.persistence.Version;
1516
import lombok.AccessLevel;
1617
import lombok.Builder;
1718
import lombok.Getter;
@@ -67,6 +68,14 @@ public class RunnectUser extends AuditingTimeEntity {
6768
@Column(nullable = false)
6869
private Long createdScrap;
6970

71+
/**
72+
* 카운터 필드(createdCourse 등)를 다른 트랜잭션이 동시에 읽고 수정하면 Lost Update가
73+
* 발생할 수 있어 낙관적 락을 건다. 충돌 시 UserStampService.recordActivityAndAwardStamp가
74+
* 별도 트랜잭션에서 재시도한다.
75+
*/
76+
@Version
77+
private Long version;
78+
7079
@OneToMany(mappedBy = "runnectUser")
7180
private List<Course> courses = new ArrayList<>();
7281

src/main/java/org/runnect/server/user/service/UserStampService.java

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import org.runnect.server.user.repository.UserRepository;
1212
import org.runnect.server.user.repository.UserStampRepository;
1313
import org.springframework.stereotype.Service;
14+
import org.springframework.transaction.annotation.Propagation;
1415
import org.springframework.transaction.annotation.Transactional;
1516

1617
@Service
@@ -20,6 +21,45 @@ public class UserStampService {
2021
private final UserStampRepository userStampRepository;
2122
private final UserRepository userRepository;
2223

24+
/**
25+
* 활동 카운터 증가 + 스탬프 지급을, 이 활동을 유발한 메인 트랜잭션(코스/기록/스크랩 생성)과
26+
* 분리된 별도 트랜잭션에서 수행한다. RunnectUser의 카운터 필드에는 낙관적 락(@Version)이
27+
* 걸려있어, 동시에 같은 유저의 카운터를 갱신하는 다른 요청과 충돌하면
28+
* ObjectOptimisticLockingFailureException이 발생한다 — 메인 트랜잭션(코스/기록/스크랩 생성
29+
* 자체)까지 롤백시키지 않기 위해 REQUIRES_NEW로 격리했다. 충돌 시 재시도는 호출부가
30+
* OptimisticLockRetrier로 감싸서 처리한다.
31+
*/
32+
@Transactional(propagation = Propagation.REQUIRES_NEW)
33+
public void recordActivityAndAwardStamp(Long userId, StampType stampType) {
34+
RunnectUser user = userRepository.findById(userId)
35+
.orElseThrow(() -> new NotFoundUserException(NOT_FOUND_USER_EXCEPTION,
36+
NOT_FOUND_USER_EXCEPTION.getMessage()));
37+
38+
incrementActivityCount(user, stampType);
39+
createStampByUser(user, stampType);
40+
41+
// 여기서 명시적으로 flush해서, 버전 충돌이 이 메서드 안에서 즉시 드러나게 한다
42+
// (그래야 REQUIRES_NEW 트랜잭션 경계를 벗어나기 전에 예외가 호출부로 전파된다).
43+
userRepository.saveAndFlush(user);
44+
}
45+
46+
private void incrementActivityCount(RunnectUser user, StampType stampType) {
47+
switch (stampType) {
48+
case c:
49+
user.updateCreatedCourse();
50+
break;
51+
case s:
52+
user.updateCreatedScrap();
53+
break;
54+
case r:
55+
user.updateCreatedRecord();
56+
break;
57+
case u:
58+
user.updateCreatedPublicCourse();
59+
break;
60+
}
61+
}
62+
2363
@Transactional
2464
public void createStampByUser(RunnectUser user, StampType stampType) {
2565
Long activityCount = getActivityCount(user, stampType);

src/test/java/org/runnect/server/course/service/CourseServiceTest.java

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import static org.assertj.core.api.Assertions.assertThat;
44
import static org.assertj.core.api.Assertions.assertThatThrownBy;
55
import static org.mockito.ArgumentMatchers.any;
6+
import static org.mockito.Mockito.doAnswer;
67
import static org.mockito.Mockito.mock;
78
import static org.mockito.Mockito.never;
89
import static org.mockito.Mockito.verify;
@@ -24,6 +25,7 @@
2425
import org.mockito.junit.jupiter.MockitoExtension;
2526
import org.runnect.server.common.exception.BadRequestException;
2627
import org.runnect.server.common.exception.NotFoundException;
28+
import org.runnect.server.common.module.concurrency.OptimisticLockRetrier;
2729
import org.runnect.server.common.module.convert.CoordinateDto;
2830
import org.runnect.server.common.module.convert.CoordinatePathConverter;
2931
import org.runnect.server.course.dto.request.CourseCreateRequestDto;
@@ -55,13 +57,20 @@ class CourseServiceTest {
5557
private UserRepository userRepository;
5658
@Mock
5759
private UserStampService userStampService;
60+
@Mock
61+
private OptimisticLockRetrier optimisticLockRetrier;
5862

5963
private CourseService courseService;
6064

6165
@BeforeEach
6266
void setUp() {
6367
courseService = new CourseService(courseRepository, publicCourseRepository, userRepository,
64-
userStampService);
68+
userStampService, optimisticLockRetrier);
69+
org.mockito.Mockito.lenient().doAnswer(invocation -> {
70+
Runnable action = invocation.getArgument(0);
71+
action.run();
72+
return null;
73+
}).when(optimisticLockRetrier).runAfterCommit(any());
6574
}
6675

6776
private RunnectUser buildUser(Long id) {
@@ -130,8 +139,7 @@ class CreateCourse {
130139

131140
assertThat(response.getId()).isEqualTo(100L);
132141
assertThat(response.getCreatedAt()).isEqualTo(LocalDateTime.of(2026, 1, 1, 0, 0));
133-
assertThat(user.getCreatedCourse()).isEqualTo(1L);
134-
verify(userStampService).createStampByUser(user, StampType.c);
142+
verify(userStampService).recordActivityAndAwardStamp(1L, StampType.c);
135143

136144
ArgumentCaptor<Course> captor = ArgumentCaptor.forClass(Course.class);
137145
verify(courseRepository).save(captor.capture());

src/test/java/org/runnect/server/record/service/RecordServiceTest.java

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
import org.mockito.junit.jupiter.MockitoExtension;
2525
import org.runnect.server.common.exception.NotFoundException;
2626
import org.runnect.server.common.exception.PermissionDeniedException;
27+
import org.runnect.server.common.module.concurrency.OptimisticLockRetrier;
2728
import org.runnect.server.course.entity.Course;
2829
import org.runnect.server.course.repository.CourseRepository;
2930
import org.runnect.server.health.entity.RecordHealthData;
@@ -68,13 +69,21 @@ class RecordServiceTest {
6869
private RecordHealthDataRepository recordHealthDataRepository;
6970
@Mock
7071
private org.runnect.server.ranking.service.RecordRankingService recordRankingService;
72+
@Mock
73+
private OptimisticLockRetrier optimisticLockRetrier;
7174

7275
private RecordService recordService;
7376

7477
@BeforeEach
7578
void setUp() {
7679
recordService = new RecordService(recordRepository, userRepository, courseRepository,
77-
publicCourseRepository, userStampService, recordHealthDataRepository, recordRankingService);
80+
publicCourseRepository, userStampService, recordHealthDataRepository, recordRankingService,
81+
optimisticLockRetrier);
82+
org.mockito.Mockito.lenient().doAnswer(invocation -> {
83+
Runnable action = invocation.getArgument(0);
84+
action.run();
85+
return null;
86+
}).when(optimisticLockRetrier).runAfterCommit(any());
7887
}
7988

8089
private RunnectUser buildUser(Long id) {
@@ -163,8 +172,7 @@ class CreateRecord {
163172
CreateRecordResponseDto response = recordService.createRecord(1L, request);
164173

165174
assertThat(response.getRecord().getId()).isEqualTo(100L);
166-
assertThat(user.getCreatedRecord()).isEqualTo(1L);
167-
verify(userStampService).createStampByUser(user, StampType.r);
175+
verify(userStampService).recordActivityAndAwardStamp(1L, StampType.r);
168176
verify(publicCourseRepository, never()).findById(any());
169177
verify(recordRankingService, never()).updateBestRecord(anyLong(), anyLong(), anyLong(), any());
170178
}

0 commit comments

Comments
 (0)