본문으로 바로가기
728x90
반응형
SMALL

유저 전적 통계 정보를 조회하는 로직이다.


주요 로직은 이렇다.

유저 전적 통계 정보 조회시, 유저 전적 기록 유무를 판단한다.

없다면, 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 을 알아보면 좋을것이다.

 

728x90
반응형
LIST