유저 전적 통계 정보를 조회하는 로직이다.
주요 로직은 이렇다.
유저 전적 통계 정보 조회시, 유저 전적 기록 유무를 판단한다.
없다면, api 요청 이후 결과값을
(1) 전적 히스토리를 db에 저장한다.
전적 히스토리 정보를 기반으로 통계치를 만든 뒤
(2) 계산된 유저 레이팅 정보를 db에 업데이트 한다.
개선 전 로직
private final BattlesHistoryService battlesHistoryService;
private final UserAccountService userAccountService;
@Cacheable(value = "rating", key = "#nickname")
public RatingStaticsResponse getRating(String nickname) {
UserAccount find = userAccountService.getRate(nickname);
String accountId = find.getAccountId();
Map<Long, ShipInfo> shipInfoMap = shipInfoService.getShipInfo();
if (!find.hasBattleRecord()) {
List<BattlesHistory> apiHistory = fetchApiHistory(accountId);
//(1) 전적 히스토리 저장
battlesHistoryService.save(apiHistory, accountId);
// 계산
RatingStaticsResponse ratingStaticsResponse = createRatingStatics(calculateRatings(apiHistory, shipInfoMap));
//(2) 유저 레이팅 정보 업데이트
userAccountService.update(accountId, ratingStaticsResponse.getOverall().getRatingScore());
return ratingStaticsResponse;
}
// 계산
RatingStaticsResponse ratingStaticsResponse = createRatingStatics(calculateRatingsForExistingUser(find, accountId, shipInfoMap));
//(2) 유저 레이팅 정보 업데이트
userAccountService.update(accountId, ratingStaticsResponse.getOverall().getRatingScore());
return ratingStaticsResponse;
}
public class BattlesHistoryService {
...
@Transactionl
public void save(...){
jpaRepository.saveAll(...);
}
}
public class UserAccountService {
...
@Transactionl
public void update(...){
UserAccountEntity account = userAccountRepository.findByAccountId(accountId).get();
account.changeRatingScore(rating);
}
}
걸린 시간 6.75 s
getRating 메서드 시간이 너무나도 오래걸린다. 해당 메서드는 전적 히스토리 정보
를 기반으로 계산된 값을 보여주는것이 주 목적이다.
계산만 끝나고 바로 보여주면 될것을 굳이 DB저장을 모두 기다리고 보여준다? 너무 오바인거 같다.
또한 전적 히스토리 저장 실패, 유저 레이팅 정보 업데이트 실패 하더라도 계산값에 영향이 별로 없어서 트랜잭션 전파에 대해 그리 고민은 안해도 될것 같다.
현재 동기적인 로직을 비동기로 변경한다면 성능이 올라갈거 같다. 그리고 코드의 강결합도 어느정도 해소하고 싶다.
위의 이유로 인해 리펙토링을 진행한다.
1.@Async 추가
1차 개선
public class UserAccountService {
...
@Async
@Transactionl
public void update(...){
UserAccountEntity account = userAccountRepository.findByAccountId(accountId).get();
account.changeRatingScore(rating);
}
}
@DisplayName("유저정보를 수정 할 수 있다 ")
@Test
@Transactional
public void testUpdates() throws InterruptedException {
// given
String nickname = "xyamat0x";
int newRating = 200;
UserAccount userAccount = UserAccount.builder()
.nickname(nickname)
.accountId("1")
.build();
userAccountRepository.save(UserAccountEntity.from(userAccount));
//when
userAccountService.uppate(userAccount.getAccountId(), newRating);
Thread.sleep(5000);
//then
UserAccount find = userAccountService.getRate(nickname);
assertEquals(userAccount.getAccountId(), find.getAccountId());
assertEquals(userAccount.getNickname(), find.getNickname());
assertEquals(newRating, find.getRatingScore());
}

유저 레이팅 정보 업데이트 메서드에 @Async
를 추가했다. 그러나 JPA의 더티체킹
이 진행되지 않았다.
찾아본 결과, @Async
과 @Transactionl
을 같은 레벨에서 사용하는 순간 새로운 쓰레드를 생성
하여 새롭게 호출하기 때문에 기존 메인 쓰레드에서 호출한 트랜잭션 컨텍스트
가 유지되지 않는다는 것이다.
TEST 삽질
아래와 같은 경우에도 더티체킹
이 진행되지 않는다.
@Transactional
public void uppate(String accountId, int rating){
log.info("update catch");
UserAccountEntity account = userAccountRepository.findByAccountId(accountId).get();
account.changeRatingScore(rating);
}
@Async
public void test(String accountId, int rating){
try {
uppate(accountId, rating);
} catch (Exception e) {
log.error("❌ 비동기 처리 중 예외 발생!", e);
}
}
test 메서드를 만들어서 @Transactional
이 선언된 uppateRate 메서드를 호출했다.
@Transactional
은 Spring의 프록시 방식의 AOP로 동작하게 됨으로 프록시 객체를 통해서 호출되는지? 실제 객체를 통해 호출되는지? 에 따라서 트랜잭션 동작 여부를 결정한다.
위의 경우는 자기 자신을 호출한 것인데 이럴 경우 this를 통해서 자기 자신의 인스턴스 메서드를 직접 호출한것이므로 프록시 AOP가 호출되지 않는다.
설령@Transactional
이 선언된 자기 자신을 호출하더라도 마찬가지이다. @Transactional
과 @Async
는 클래스 단위로 서로 분리해야한다.
다른 방법을 찾아야 한다.
2.ApplicationEventPublisher + EventListener 사용
DB 관련된 부분을 MQ 를 사용해서 서비스 모듈을 분리할까? 까지 생각했다. 그러나 오버 엔지니어링이라 판단.
최대한 소스단에서 MQ 느낌으로 사용할 수 있는 방법을 찾기 시작했다.
그러다가 Spring 의 ApplicationEventPublisher
찾았다. 명칭 그대로 이벤트 기반으로 사용할 수 있고 내가 원하는 비동기 로직 적용과 코드간의 강결합을 어느정도 해결 할 수 있겠다 라는 판단이 들었다.
ApplicationEventPublisher
자세한 내용은 여기 https://docs.spring.io/spring-modulith/reference/events.html
개선 후 로직
private final ApplicationEventPublisher applicationEventPublisher;
@Cacheable(value = "rating", key = "#nickname")
public RatingStaticsResponse getRating(String nickname) {
UserAccount find = userAccountService.getRate(nickname);
String accountId = find.getAccountId();
Map<Long, ShipInfo> shipInfoMap = shipInfoService.getShipInfo();
if (!find.hasBattleRecord()) {
log.info("db 에 없습니다.");
List<BattlesHistory> apiHistory = fetchApiHistory(accountId);
//(1) 전적 히스토리 저장 개선
applicationEventPublisher.publishEvent(CreatedBattleHistoryEvent.builder()
.accountId(accountId)
.battles(apiHistory)
.build());
RatingStaticsResponse ratingStaticsResponse = createRatingStatics(calculateRatings(apiHistory,
shipInfoMap));
//(2) 유저 레이팅 정보 업데이트 개선
applicationEventPublisher.publishEvent(UpdatedUserRatingEvent.builder()
.accountId(find.getAccountId())
.rating(ratingStaticsResponse.getOverall().getRatingScore())
.build());
return ratingStaticsResponse;
}
log.info("db 에 있습니다.");
RatingStaticsResponse ratingStaticsResponse = createRatingStatics(calculateRatingsForExistingUser(find, accountId, shipInfoMap));
//(2) 유저 레이팅 정보 업데이트 개선
applicationEventPublisher.publishEvent(UpdatedUserRatingEvent.builder()
.accountId(find.getAccountId())
.rating(ratingStaticsResponse.getOverall().getRatingScore())
.build());
return ratingStaticsResponse;
}
================= 유저 레이팅 정보 업데이트 =================
@Slf4j
@RequiredArgsConstructor
@Component
public class UpdatedUserEventHandler {
private final UserAccountService userAccountService;
@Async
@EventListener
public void updateRating(UpdatedUserRatingEvent updatedUserRatingEvent){
log.info("event catch");
userAccountService.uppate(updatedUserRatingEvent.getAccountId(), updatedUserRatingEvent.getRating());
}
}
@Builder
@Getter
public class UpdatedUserRatingEvent {
private String accountId;
private int rating;
}
================= 전적 히스토리 저장 =================
@Slf4j
@RequiredArgsConstructor
@Component
public class CreateBattleHistoryEventHandler {
private final BattlesHistoryService battlesHistoryService;
@Async
@EventListener
public void create(CreatedBattleHistoryEvent createdEvent){
log.info("event catch");
battlesHistoryService.save(createdEvent.getBattles(), createdEvent.getAccountId());
}
}
@Getter
@Builder
public class CreatedBattleHistoryEvent {
String accountId;
List<BattlesHistory> battles;
}
걸린 시간 2.45 s
applicationEventPublisher
에서 이벤트를 발행하면, 이벤트 핸들러에서 소비하는 구조이다.
DB 저장 행위 별로 핸들러를 만들어줬고 이제 그 핸들러에가 DB 저장 역할과 책임을 하는 것이다. 기능적 분리 + 비동기 둘다 성공적으로 적용됬다.
더이상 getRating 메서드는 DB관련 로직에 묶여있지 않아도 된다!.
만약에 이벤트 핸들러 끼리 하나의 트랜잭션으로 묶어서 동작하고 싶다면 트랜잭션 동작을 인식하는 리스너인 @transactionalEventListener
을 알아보면 좋을것이다.