Skip to content

Commit c995758

Browse files
authored
Merge pull request #202 from Young-Flow/refactor/#184
[refactor] 동시성 이슈
2 parents 3e5454b + 6e27397 commit c995758

File tree

15 files changed

+443
-5
lines changed

15 files changed

+443
-5
lines changed

build.gradle

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,9 @@ dependencies {
6060

6161
// Discord Webhook
6262
implementation("club.minnced:discord-webhooks:0.8.4")
63+
64+
// awaitility
65+
testImplementation 'org.awaitility:awaitility:4.3.0'
6366
}
6467

6568
// ------------------

src/main/java/com/pitchain/bmscrap/domain/BmScrap.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
@Entity
1111
@Getter
1212
@NoArgsConstructor(access = AccessLevel.PROTECTED)
13+
@Table(uniqueConstraints = @UniqueConstraint(columnNames = {"member_id", "bm_id"}))
1314
public class BmScrap {
1415

1516
@Id
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
package com.pitchain.common.config;
2+
3+
import lombok.extern.slf4j.Slf4j;
4+
import org.slf4j.MDC;
5+
import org.springframework.aop.interceptor.AsyncUncaughtExceptionHandler;
6+
import org.springframework.context.annotation.Bean;
7+
import org.springframework.context.annotation.Configuration;
8+
import org.springframework.core.task.TaskDecorator;
9+
import org.springframework.scheduling.annotation.AsyncConfigurer;
10+
import org.springframework.scheduling.annotation.EnableAsync;
11+
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
12+
13+
import java.lang.reflect.Method;
14+
import java.util.Map;
15+
import java.util.concurrent.Executor;
16+
17+
@Configuration
18+
@EnableAsync
19+
public class AsyncConfig implements AsyncConfigurer {
20+
21+
@Bean
22+
public Executor asyncTaskExecutor() {
23+
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
24+
//TODO - corePoolSize, maxPoolSize, queueCapacity는 서비스에 맞게 테스트를 통해 설정
25+
executor.setCorePoolSize(Runtime.getRuntime().availableProcessors());
26+
executor.setMaxPoolSize(Runtime.getRuntime().availableProcessors());
27+
// executor.setQueueCapacity();
28+
executor.setTaskDecorator(new MdcTaskDecorator());
29+
executor.setThreadNamePrefix("async");
30+
executor.setWaitForTasksToCompleteOnShutdown(true);
31+
executor.initialize();
32+
33+
return executor;
34+
}
35+
36+
@Override
37+
public Executor getAsyncExecutor() {
38+
return asyncTaskExecutor();
39+
}
40+
41+
@Override
42+
public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
43+
return new AsyncExceptionHandler();
44+
}
45+
46+
@Slf4j
47+
private static class AsyncExceptionHandler implements AsyncUncaughtExceptionHandler {
48+
49+
@Override
50+
public void handleUncaughtException(Throwable throwable, Method method, Object... params) {
51+
log.error("비동기 실행 중 에러가 발생했습니다: ", throwable);
52+
}
53+
}
54+
55+
@Slf4j
56+
private static class MdcTaskDecorator implements TaskDecorator {
57+
@Override
58+
public Runnable decorate(Runnable runnable) {
59+
Map<String, String> contextMap = MDC.getCopyOfContextMap();
60+
return () -> {
61+
try {
62+
if (contextMap != null) {
63+
MDC.setContextMap(contextMap);
64+
}
65+
runnable.run();
66+
} finally {
67+
MDC.clear();
68+
}
69+
};
70+
}
71+
}
72+
73+
}

src/main/java/com/pitchain/common/config/RedisConfig.java

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,21 @@
44
import org.springframework.beans.factory.annotation.Value;
55
import org.springframework.context.annotation.Bean;
66
import org.springframework.context.annotation.Configuration;
7+
import org.springframework.core.io.ClassPathResource;
78
import org.springframework.data.redis.connection.RedisConnectionFactory;
89
import org.springframework.data.redis.connection.RedisStandaloneConfiguration;
910
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
11+
import org.springframework.data.redis.core.script.DefaultRedisScript;
12+
import org.springframework.data.redis.core.script.RedisScript;
1013
import org.springframework.data.redis.listener.ChannelTopic;
1114
import org.springframework.data.redis.listener.RedisMessageListenerContainer;
1215
import org.springframework.data.redis.listener.adapter.MessageListenerAdapter;
1316
import org.springframework.data.redis.repository.configuration.EnableRedisRepositories;
17+
import org.springframework.scripting.ScriptSource;
18+
import org.springframework.scripting.support.ResourceScriptSource;
19+
20+
import java.io.IOException;
21+
import java.util.List;
1422

1523
@Configuration
1624
@EnableRedisRepositories
@@ -45,4 +53,12 @@ public MessageListenerAdapter messageListenerAdapter(RedisSubscriber subscriber)
4553
return new MessageListenerAdapter(subscriber, "onMessage");
4654
}
4755

56+
@Bean
57+
public RedisScript<List> script() throws IOException {
58+
ScriptSource scriptSource = new ResourceScriptSource(new ClassPathResource("META-INF/scripts/getanddeleteall.lua"));
59+
String script = scriptSource.getScriptAsString();
60+
61+
return new DefaultRedisScript<>(script, List.class);
62+
}
63+
4864
}

src/main/java/com/pitchain/common/exception/GlobalExceptionHandler.java

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,17 @@
66
import com.pitchain.common.apiPayload.ErrorStatus;
77
import jakarta.validation.ConstraintViolationException;
88
import lombok.RequiredArgsConstructor;
9+
import lombok.extern.slf4j.Slf4j;
910
import org.springframework.context.MessageSource;
11+
import org.springframework.dao.DataIntegrityViolationException;
1012
import org.springframework.http.HttpStatus;
1113
import org.springframework.http.ResponseEntity;
1214
import org.springframework.validation.ObjectError;
1315
import org.springframework.web.bind.MethodArgumentNotValidException;
1416
import org.springframework.web.bind.annotation.ExceptionHandler;
1517
import org.springframework.web.bind.annotation.RestControllerAdvice;
1618

19+
@Slf4j
1720
@RestControllerAdvice
1821
@RequiredArgsConstructor
1922
public class GlobalExceptionHandler {
@@ -72,4 +75,17 @@ public ResponseEntity<CustomResponse> handleJWTVerificationException(JWTVerifica
7275
.status(HttpStatus.UNAUTHORIZED)
7376
.body(customResponse);
7477
}
78+
79+
@ExceptionHandler(DataIntegrityViolationException.class)
80+
public ResponseEntity<CustomResponse> handleDataIntegrityViolationException(DataIntegrityViolationException e) {
81+
log.error("DataIntegrityViolationException = {}", e.getMessage());
82+
String errorMessage = "DataIntegrityViolationException(제약 조건 위반 오류)가 발생했습니다.";
83+
84+
HttpStatus httpStatus = HttpStatus.BAD_REQUEST;
85+
CustomResponse customResponse = CustomResponse.onFailure(httpStatus.name(), errorMessage);
86+
87+
return ResponseEntity
88+
.status(httpStatus)
89+
.body(customResponse);
90+
}
7591
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
package com.pitchain.common.redis;
2+
3+
import lombok.RequiredArgsConstructor;
4+
import org.springframework.data.redis.core.HashOperations;
5+
import org.springframework.data.redis.core.RedisTemplate;
6+
import org.springframework.data.redis.core.script.RedisScript;
7+
import org.springframework.stereotype.Component;
8+
9+
import java.util.Collections;
10+
import java.util.List;
11+
import java.util.Map;
12+
13+
@Component
14+
@RequiredArgsConstructor
15+
public class RedisHashRepository {
16+
17+
private final RedisTemplate<String, String> redisTemplate;
18+
private final RedisScript<List> script;
19+
20+
public void increment(String key, String hashKey, Long value) {
21+
redisTemplate.opsForHash().increment(key, hashKey, value);
22+
}
23+
24+
public Map<String, String> findAll(String key) {
25+
HashOperations<String, String, String> ops = redisTemplate.opsForHash();
26+
return ops.entries(key);
27+
}
28+
29+
public List<String> getAndDeleteAll(String key) {
30+
return redisTemplate.execute(script, Collections.singletonList(key));
31+
}
32+
33+
}

src/main/java/com/pitchain/sp/application/SpService.java

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
public class SpService {
1919
private final SpCommandService spCommandService;
2020
private final SpQueryService spQueryService;
21+
private final SpViewsService spViewsService;
2122

2223
@Transactional
2324
public void createSp(MemberDetails memberDetails, Long bmId, SpCreateReq spCreateReq, MultipartFile thumbnailImg) {
@@ -34,9 +35,10 @@ public InfinityScrollRes<SpDetailRes> getSpDetailsFilteredCategory(MemberDetails
3435
return spQueryService.getSpDetailsFilteredCategory(memberDetails, mainCategoryInKorean, lastSpId, size);
3536
}
3637

37-
@Transactional(readOnly = true)
3838
public SpDetailRes getSpDetail(MemberDetails memberDetails, Long bmId, Long spId) {
39-
return spQueryService.getSpDetail(memberDetails, bmId, spId);
39+
SpDetailRes spDetailRes = spQueryService.getSpDetail(memberDetails, bmId, spId);
40+
spViewsService.updateSpView(spId);
41+
return spDetailRes;
4042
}
4143

4244
@Transactional
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
package com.pitchain.sp.application;
2+
3+
import com.pitchain.common.redis.RedisHashRepository;
4+
import com.pitchain.sp.infrastucture.SpRepositoryCustom;
5+
import com.pitchain.sp.infrastucture.dto.SpViewsDto;
6+
import lombok.RequiredArgsConstructor;
7+
import lombok.extern.slf4j.Slf4j;
8+
import org.springframework.scheduling.annotation.Async;
9+
import org.springframework.scheduling.annotation.Scheduled;
10+
import org.springframework.stereotype.Service;
11+
import org.springframework.transaction.annotation.Transactional;
12+
13+
import java.util.ArrayList;
14+
import java.util.List;
15+
16+
@Slf4j
17+
@Service
18+
@RequiredArgsConstructor
19+
public class SpViewsService {
20+
21+
private final SpRepositoryCustom spRepositoryCustom;
22+
private final RedisHashRepository redisHashRepository;
23+
24+
private static final String SP_VIEW_REDIS_KEY = "spView";
25+
26+
/**
27+
* Redis에 Sp 조회수 증가
28+
* @param spId
29+
*/
30+
@Async
31+
public void updateSpView(Long spId) {
32+
redisHashRepository.increment(SP_VIEW_REDIS_KEY, String.valueOf(spId), 1L);
33+
}
34+
35+
/**
36+
* Redis에서 DB로 Sp 조회수 업데이트
37+
*/
38+
@Transactional
39+
public void updateSpViews() {
40+
List<String> spViewsResult = redisHashRepository.getAndDeleteAll(SP_VIEW_REDIS_KEY);
41+
List<SpViewsDto> spViewsDtoList = parseResult(spViewsResult);
42+
43+
for (SpViewsDto spViewsDto : spViewsDtoList) {
44+
spRepositoryCustom.updateSpView(spViewsDto.spId(), spViewsDto.views());
45+
}
46+
}
47+
48+
/**
49+
* 1분마다 Redis에서 DB로 Sp 조회수 업데이트하는 작업 수행
50+
*/
51+
@Scheduled(cron = "0 */1 * * * *")
52+
public void runUpdateSpViews() {
53+
try {
54+
updateSpViews();
55+
} catch (Exception e) {
56+
log.error("Scheduling task [runUpdateSpViews] failed", e);
57+
}
58+
}
59+
60+
/**
61+
* Redis에서 가져온 Sp 조회수 String 리스트를 Dto 리스트로 파싱
62+
* @param List<String>
63+
* @return List<SpViewsDto>
64+
*/
65+
private List<SpViewsDto> parseResult(List<String> spViewsStringList) {
66+
if (spViewsStringList.size() % 2 != 0){
67+
log.error("spViewsStringList 개수가 올바르지 않습니다.");
68+
throw new IllegalArgumentException("spViewsStringList 개수가 올바르지 않습니다.");
69+
}
70+
71+
List<SpViewsDto> spViewsDtoList = new ArrayList<>();
72+
for (int i = 0; i < spViewsStringList.size(); i += 2) {
73+
Long spId = Long.parseLong(spViewsStringList.get(i));
74+
Long views = Long.parseLong(spViewsStringList.get(i + 1));
75+
76+
SpViewsDto spViewsDto = new SpViewsDto(spId, views);
77+
spViewsDtoList.add(spViewsDto);
78+
}
79+
80+
return spViewsDtoList;
81+
}
82+
}

src/main/java/com/pitchain/sp/application/res/SpDetailRes.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ public record SpDetailRes(
3131
@S3Url
3232
String thumbnailImgURL,
3333
@NotNull
34-
Integer views,
34+
Long views,
3535
@NotBlank
3636
String name,
3737
@NotBlank

src/main/java/com/pitchain/sp/domain/Sp.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ public class Sp extends BaseEntity {
2828
@Column(nullable = false)
2929
private String thumbnailImgKey;
3030

31-
private int views = 0;
31+
private Long views;
3232

3333
@Column(nullable = false)
3434
private String name;
@@ -42,7 +42,7 @@ public static Sp of(Bm bm, String thumbnailImgKey, String name) {
4242
sp.thumbnailImgKey = thumbnailImgKey;
4343
sp.name = name;
4444
sp.spStatus = SpStatus.TRANSCODING;
45-
sp.views = 0;
45+
sp.views = 0L;
4646
return sp;
4747
}
4848

0 commit comments

Comments
 (0)