Skip to content

Commit 5c3904c

Browse files
authored
Merge pull request #120 from Kimjipang/main
[volume 4] 트랜잭션 및 동시성 구현
2 parents 6f9119f + 8cd83e5 commit 5c3904c

7 files changed

Lines changed: 125 additions & 4 deletions

File tree

apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ public LikeInfo doLike(LikeV1Dto.LikeRequest request) {
3636
() -> new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 상품입니다.")
3737
);
3838

39-
return likeRepository.findByUserIdAndProductId(userId, productId)
39+
return likeRepository.findByUserIdAndProductIdForUpdate(userId, productId)
4040
.map(LikeInfo::from)
4141
.orElseGet(() -> {
4242
Like newLike = request.toEntity();

apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,22 @@
44
import jakarta.persistence.Column;
55
import jakarta.persistence.Entity;
66
import jakarta.persistence.Table;
7+
import jakarta.persistence.UniqueConstraint;
78
import lombok.AccessLevel;
89
import lombok.Getter;
910
import lombok.NoArgsConstructor;
1011

1112
@Getter
1213
@Entity
13-
@Table(name = "product_like")
14+
@Table(
15+
name = "product_like",
16+
uniqueConstraints = {
17+
@UniqueConstraint(
18+
name = "uk_like_user_product",
19+
columnNames = {"ref_user_id", "ref_product_id"}
20+
)
21+
}
22+
)
1423
@NoArgsConstructor(access = AccessLevel.PROTECTED)
1524
public class Like extends BaseEntity {
1625
@Column(name = "ref_user_id", nullable = false)

apps/commerce-api/src/main/java/com/loopers/domain/like/LikeRepository.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,15 @@
33
import java.util.Optional;
44

55
public interface LikeRepository {
6+
Optional<Like> findByUserIdAndProductIdForUpdate(Long userId, Long productId);
7+
68
Optional<Like> findByUserIdAndProductId(Long userId, Long productId);
79

810
Like save(Like like);
911

1012
void delete(Like like);
1113

1214
int countByProductId(Long productId);
15+
16+
int countByUserIdAndProductId(Long userId, Long productId);
1317
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,21 @@
11
package com.loopers.infrastructure.like;
22

33
import com.loopers.domain.like.Like;
4+
import jakarta.persistence.LockModeType;
45
import org.springframework.data.jpa.repository.JpaRepository;
6+
import org.springframework.data.jpa.repository.Lock;
7+
import org.springframework.data.jpa.repository.Query;
58

69
import java.util.Optional;
710

811
public interface LikeJpaRepository extends JpaRepository<Like, Long> {
912
Optional<Like> findByUserIdAndProductId(Long userId, Long productId);
1013

1114
int countByProductId(Long productId);
15+
16+
int countByUserIdAndProductId(Long userId, Long productId);
17+
18+
@Lock(LockModeType.PESSIMISTIC_WRITE)
19+
@Query("SELECT l FROM Like l WHERE l.userId = :userId AND l.productId = :productId")
20+
Optional<Like> findByUserIdAndProductIdForUpdate(Long userId, Long productId);
1221
}

apps/commerce-api/src/main/java/com/loopers/infrastructure/like/LikeRepositoryImpl.java

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,11 @@
1212
public class LikeRepositoryImpl implements LikeRepository {
1313
private final LikeJpaRepository likeJpaRepository;
1414

15+
@Override
16+
public Optional<Like> findByUserIdAndProductIdForUpdate(Long userId, Long productId) {
17+
return likeJpaRepository.findByUserIdAndProductIdForUpdate(userId, productId);
18+
}
19+
1520
@Override
1621
public Optional<Like> findByUserIdAndProductId(Long userId, Long productId) {
1722
return likeJpaRepository.findByUserIdAndProductId(userId, productId);
@@ -31,4 +36,9 @@ public void delete(Like like) {
3136
public int countByProductId(Long productId) {
3237
return likeJpaRepository.countByProductId(productId);
3338
}
39+
40+
@Override
41+
public int countByUserIdAndProductId(Long userId, Long productId) {
42+
return likeJpaRepository.countByUserIdAndProductId(userId, productId);
43+
}
3444
}
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
package com.loopers.domain.like;
2+
3+
import com.loopers.application.like.LikeFacade;
4+
import com.loopers.domain.product.Product;
5+
import com.loopers.domain.product.ProductRepository;
6+
import com.loopers.domain.user.Gender;
7+
import com.loopers.domain.user.UserEntity;
8+
import com.loopers.domain.user.UserRepository;
9+
import com.loopers.interfaces.api.like.LikeV1Dto;
10+
import org.junit.jupiter.api.Test;
11+
import org.springframework.beans.factory.annotation.Autowired;
12+
import org.springframework.boot.test.context.SpringBootTest;
13+
14+
import java.math.BigDecimal;
15+
import java.util.concurrent.CountDownLatch;
16+
import java.util.concurrent.ExecutorService;
17+
import java.util.concurrent.Executors;
18+
19+
import static org.assertj.core.api.Assertions.assertThat;
20+
21+
@SpringBootTest
22+
public class LikeConcurrencyIntegrationTest {
23+
24+
@Autowired
25+
private LikeFacade likeFacade;
26+
27+
@Autowired
28+
private LikeRepository likeRepository;
29+
30+
@Autowired
31+
private UserRepository userRepository;
32+
33+
@Autowired
34+
private ProductRepository productRepository;
35+
36+
@Test
37+
void 동시에_100개의_좋아요요청이_들어오면_좋아요는_1개만_생성된다() throws Exception {
38+
39+
// 1. 유저/상품 테스트 데이터 생성
40+
UserEntity user = userRepository.save(
41+
new UserEntity(
42+
"happy97",
43+
"test@test.com",
44+
Gender.MALE,
45+
"1997-09-23",
46+
"test1234!"
47+
)
48+
);
49+
50+
Product product = productRepository.save(
51+
new Product(
52+
1L,
53+
"테스트 상품",
54+
BigDecimal.valueOf(10000),
55+
100
56+
)
57+
);
58+
59+
Long userId = user.getId();
60+
Long productId = product.getId();
61+
62+
// 2. 동시 실행 환경 설정
63+
int threadCount = 100;
64+
ExecutorService executor = Executors.newFixedThreadPool(threadCount);
65+
CountDownLatch latch = new CountDownLatch(threadCount);
66+
67+
// 3. 동시에 doLike() 실행
68+
for (int i = 0; i < threadCount; i++) {
69+
executor.submit(() -> {
70+
try {
71+
likeFacade.doLike(new LikeV1Dto.LikeRequest(userId, productId));
72+
} catch (Exception e) {
73+
// 스레드 예외 출력 (중요)
74+
e.printStackTrace();
75+
} finally {
76+
latch.countDown();
77+
}
78+
});
79+
}
80+
81+
// 4. 모든 요청이 끝날 때까지 대기
82+
latch.await();
83+
84+
// 5. 좋아요 개수 확인
85+
long count = likeRepository.countByUserIdAndProductId(userId, productId);
86+
87+
assertThat(count).isEqualTo(1);
88+
}
89+
}

apps/commerce-api/src/test/java/com/loopers/domain/user/UserServiceIntegrationTest.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ void returnsUserEntity_when_user_exists() {
109109
userService.save(userEntity);
110110

111111
// act
112-
UserEntity foundUser = userService.getUserByLoginId(loginId);
112+
UserEntity foundUser = userService.findUserByLoginId(loginId);
113113

114114
// assert
115115
assertThat(foundUser).isNotNull();
@@ -127,7 +127,7 @@ void returnsNull_when_user_not_exists() {
127127

128128
// act
129129
final CoreException result = assertThrows(CoreException.class, () -> {
130-
userService.getUserByLoginId(loginId);
130+
userService.findUserByLoginId(loginId);
131131
});
132132

133133
// assert

0 commit comments

Comments
 (0)