스프링 부트 트랜잭션 리팩토링

wellbeing-dough·2024년 4월 10일

문제점

우리 서비스의 1기 매칭 버전1에서 서로 매칭을 해주는 과정에서 생겼던 이슈이다
매칭 과정을 이렇다
0. 트랜잭션 시작
1. 남자 기준 여자와의 추천 데이터가 있는지 검증 -> 이미 있으면 예외
2. 여자 기준 남자와의 추천 데이터가 있는지 검증 -> 이미 있으면 예외
3. 남자 기준 여자와의 추천 데이터가 5개가 넘는지 검증 -> 넘으면 예외
4. 여자 기준 남자와의 추천 데이터가 5개가 넘는지 검증 -> 넘으면 예외
5. 남자 기준 여자와의 추가 추천 객체 생성
6. 여자 기준 남자와의 추가 추천 객체 생성
7. 5, 6 저장
8. 5를 기반으로 추가 추천 결제 객체 생성
9. 6를 기반으로 추가 추천 결제 객체 생성
10. 8, 9 저장
11. 남자에게 알림톡 쏘고 로그 저장
12. 여자에게 알림톡 쏘고 로그 저장
13. 트랜잭션 종료

문제점 1

매칭을 했는데 12번에서 알림톡 전송에 실패한 것이다..... 알림톡에 실패 로그가 찍혔는데 이게 싹다 롤백되서 로그가 찍혀도 누가누구랑 매칭을 돌리다가 실패했는지 찾기가 힘들고 심지어 11번 남성에게는 알림톡이 갔다.... 이미 간 알림톡은 롤백을 할 수 없다 그리고 알림톡전송이 실패했다 해도 매칭 데이터가 날라가버리는건 우리가 원하는 트랜잭션 롤백이 아니다

문제점 2

그리고 또 다른 문제는 알림톡을 보내는 과정이
1. ncloud에 알림톡을 보내는 api
2. ncloud에 알림톡이 잘 갔는지 확인하는 알림톡 결과 발송 api
3. 알림톡 결과 로그 저장
이렇게 두번을 보내야 한다 한번만 보내면 알림톡이 잘 갔는지 안갔는지 확인할 수 없다 생각보다 이게 오래걸린다 그동안 디비 커넥션을 계속 붙들고 있어서 매칭이 많이 일어나는 시간대에 간단한 api까지도 디비 커넥션을 취득하지 못해서 성능이 안좋아진다

문제점 3

또한 예전에 트랜잭션 전파 관리를 제대로 하지 않아 디비 커넥션이 계속 열려서 https://velog.io/@wellbeing-dough/MySQL-ALTER-TABLE-%EB%AA%85%EB%A0%B9%EC%96%B4-%EC%95%88%EB%90%98%EB%8A%94-%EB%AC%B8%EC%A0%9C 이런 이슈도 발생했다

결론 트랜잭션 관리를 확실하게 하자

해결

예전에 서비스 레이어의 세부 구현을 impl 레이어로 분리하여 리팩토링을 했었다
링크: https://velog.io/@wellbeing-dough/%EC%8A%A4%ED%94%84%EB%A7%81-%EB%B6%80%ED%8A%B8-%EC%84%9C%EB%B9%84%EC%8A%A4-%EB%A0%88%EC%9D%B4%EC%96%B4-%EB%A6%AC%ED%8E%99%ED%86%A0%EB%A7%81

이번에도 이 메서드를 예시를 들면

	@Transactional
    public void createPayBothAdditionalRecommendation(CreatePayBothAdditionalRecommendationRequest request) {
        additionalRecommendationValidator.validIsAlreadyExistBothAdditionalRecommendation(request.getUserId(), request.getPartnerUserId());
        additionalRecommendationValidator.validBothAdditionalRecommendCountOver(request.getUserId(), request.getPartnerUserId(), ADDITIONAL_RECOMMENDATION_COUNT);
        paymentAdditionalRecommendationManager.createBothPaymentAdditionalRecommendation(request);
        sendAlimTalkAdditionalRecommendation(request.getUserId(), request.getPartnerUserId());
        sendAlimTalkAdditionalRecommendation(request.getPartnerUserId(), request.getUserId());
    }
@Component
@RequiredArgsConstructor
public class AdditionalRecommendationValidator {

    private final AdditionalRecommendationRepository additionalRecommendationRepository;
    private final AdditionalRecommendationReader additionalRecommendationReader;

    public void validBothAdditionalRecommendCountOver(Long userId, Long partnerUserId, Long additionalRecommendationCount) {
        Long userRecommendationCount = additionalRecommendationRepository.countByUserAndType(userId);
        Long partnerUserRecommendationCount = additionalRecommendationRepository.countByUserAndType(partnerUserId);

        if (userRecommendationCount >= additionalRecommendationCount || partnerUserRecommendationCount >= additionalRecommendationCount) {
            throw new AdditionalRecommendationExceedException(
                    ErrorCode.ADDITIONAL_RECOMMENDATION_EXCEED_ERROR,
                    ErrorCode.ADDITIONAL_RECOMMENDATION_EXCEED_ERROR.getStatusMessage()
            );
        }
    }

    public void validIsAlreadyExistBothAdditionalRecommendation(Long userId, Long partnerUserId) {
        if (additionalRecommendationReader.isAdditionalRecommendationExistWithPartnerUser(userId, partnerUserId) ||
                additionalRecommendationReader.isAdditionalRecommendationExistWithPartnerUser(partnerUserId, userId)) {
            throw new AdditionalRecommendationAlreadyExistException(
                    ErrorCode.ADDITIONAL_RECOMMENDATION_ALREADY_EXIST_ERROR,
                    ErrorCode.ADDITIONAL_RECOMMENDATION_ALREADY_EXIST_ERROR.getStatusMessage()
            );
        }
    }

}
@Component
@RequiredArgsConstructor
public class PaymentAdditionalRecommendationManager {

    private final AdditionalRecommendationRepository additionalRecommendationRepository;
    private final RecommendationPaymentRepository recommendationPaymentRepository;


    public void createBothPaymentAdditionalRecommendation(CreatePayBothAdditionalRecommendationRequest request) {
        List<AdditionalRecommendation> bothAdditionalRecommendation = request.toPayBothAdditionalRecommendations();
        additionalRecommendationRepository.saveAll(bothAdditionalRecommendation);
        List<RecommendationPayment> bothRecommendationPayment = createBothRecommendationPayment(
                bothAdditionalRecommendation.get(0).getId(),
                bothAdditionalRecommendation.get(1).getId()
        );
        recommendationPaymentRepository.saveAll(bothRecommendationPayment);
    }

    private List<RecommendationPayment> createBothRecommendationPayment(Long recommendationId, Long anotherRecommendationId) {
        RecommendationPayment recommendationPaymentForUser = RecommendationPayment.builder()
                .recommendationId(recommendationId)
                .paymentStatus(RecommendationPaymentStatus.BEFORE_PAYMENT)
                .build();

        RecommendationPayment anotherRecommendationPaymentForUser = RecommendationPayment.builder()
                .recommendationId(anotherRecommendationId)
                .paymentStatus(RecommendationPaymentStatus.BEFORE_PAYMENT)
                .build();
        return Arrays.asList(recommendationPaymentForUser, anotherRecommendationPaymentForUser);
    }

}
    private void sendAlimTalkAdditional(Long userId, Long partnerUserId) {
        UserProfile userProfile = userProfileReader.readByUserId(userId);
        UserProfile partnerUserProfile = userProfileReader.readByUserId(partnerUserId);
        AlimTalkFeignRequest userAlimTalkFeignRequest = additionalRecommendationAlimTalkClassifier.classifyByEssentialRecommendation(userProfile, partnerUserProfile);
        alimTalkManager.sendAlimTalkAndSaveLog(userId, userAlimTalkFeignRequest);
    }

이렇게 모든 작업이 하나의 트랜잭션으로 묶여서 문제점 1, 2가 발생한다
그리고 아무생각없이 서비스 계층에 별생각 없이 당연하듯이 @Transactional을 박으니까 문제점 3이 발생한다

그래서 impl계층에 트랜잭션을 걸어서

  1. 남녀 상호 추천데이터가 있는지 검증하는 트랜잭션 시작
  2. 남녀 상호 추천 데이터가 있는지 검증 -> 이미 있으면 예외
  3. 남녀 상호 추천데이터가 있는지 검증하는 트랜잭션 종료
  4. 남녀 상호 추천 데이터가 5개가 넘는지 검증하는 트랜잭션 시작
  5. 남녀 상호 추천 데이터가 5개가 넘는지 검증 -> 넘으면 예외
  6. 남녀 상호 추천 데이터가 5개가 넘는지 검증하는 트랜잭션 종료
  7. 남녀 상호 추천 데이터와 추천 결제 데이터 생성하는 트랜잭션 시작
  8. 남녀 상호 추천 데이터와 추천 결제 데이터 생성
  9. 남녀 상호 추천 데이터와 추천 결제 데이터 생성하는 트랜잭션 종료
  10. 남자에게 알림톡 쏘고 로그 저장하는 트랜잭션 시작
  11. 남자에게 알림톡 쏘고 로그 저장
  12. 남자에게 알림톡 쏘고 로그 저장하는 트랜잭션 종료
  13. 여자에게 알림톡 쏘고 로그 저장하는 트랜잭션 시작
  14. 여자에게 알림톡 쏘고 로그 저장
  15. 여자에게 알림톡 쏘고 로그 저장하는 트랜잭션 종료

이 플로우를 코드에 적용해보자

    public void createPayBothAdditionalRecommendation(CreatePayBothAdditionalRecommendationRequest request) {
        additionalRecommendationValidator.validIsAlreadyExistBothAdditionalRecommendation(request.getUserId(), request.getPartnerUserId());
        additionalRecommendationValidator.validBothAdditionalRecommendCountOver(request.getUserId(), request.getPartnerUserId(), ADDITIONAL_RECOMMENDATION_COUNT);
        paymentAdditionalRecommendationManager.createBothPaymentAdditionalRecommendation(request);
        sendAlimTalkAdditionalRecommendation(request.getUserId(), request.getPartnerUserId());
        sendAlimTalkAdditionalRecommendation(request.getPartnerUserId(), request.getUserId());
    }

위 서비스 레이어에는 트랜잭션을 전부 제거 하였다

@Component
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class AdditionalRecommendationValidator {

    private final AdditionalRecommendationRepository additionalRecommendationRepository;
    private final AdditionalRecommendationReader additionalRecommendationReader;
    

    public void validBothAdditionalRecommendCountOver(Long userId, Long partnerUserId, Long additionalRecommendationCount) {
        Long userRecommendationCount = additionalRecommendationRepository.countByUserAndType(userId);
        Long partnerUserRecommendationCount = additionalRecommendationRepository.countByUserAndType(partnerUserId);

        if (userRecommendationCount >= additionalRecommendationCount || partnerUserRecommendationCount >= additionalRecommendationCount) {
            throw new AdditionalRecommendationExceedException(
                    ErrorCode.ADDITIONAL_RECOMMENDATION_EXCEED_ERROR,
                    ErrorCode.ADDITIONAL_RECOMMENDATION_EXCEED_ERROR.getStatusMessage()
            );
        }
    }

    public void validIsAlreadyExistBothAdditionalRecommendation(Long userId, Long partnerUserId) {
        if (additionalRecommendationReader.isAdditionalRecommendationExistWithPartnerUser(userId, partnerUserId) ||
                additionalRecommendationReader.isAdditionalRecommendationExistWithPartnerUser(partnerUserId, userId)) {
            throw new AdditionalRecommendationAlreadyExistException(
                    ErrorCode.ADDITIONAL_RECOMMENDATION_ALREADY_EXIST_ERROR,
                    ErrorCode.ADDITIONAL_RECOMMENDATION_ALREADY_EXIST_ERROR.getStatusMessage()
            );
        }
    }

}

상세 구현 컴포넌트에 validator는 검증을 한다, 읽기만하지 쓰기작업은 하지 않는다 그래서 @Transactional(readOnly = true)을 클래스에 달았다

@Component
@RequiredArgsConstructor
@Transactional
public class PaymentAdditionalRecommendationManager {

    private final AdditionalRecommendationRepository additionalRecommendationRepository;
    private final RecommendationPaymentRepository recommendationPaymentRepository;


    public void createBothPaymentAdditionalRecommendation(CreatePayBothAdditionalRecommendationRequest request) {
        List<AdditionalRecommendation> bothAdditionalRecommendation = request.toPayBothAdditionalRecommendations();
        additionalRecommendationRepository.saveAll(bothAdditionalRecommendation);
        List<RecommendationPayment> bothRecommendationPayment = createBothRecommendationPayment(
                bothAdditionalRecommendation.get(0).getId(),
                bothAdditionalRecommendation.get(1).getId()
        );
        recommendationPaymentRepository.saveAll(bothRecommendationPayment);
    }

    private List<RecommendationPayment> createBothRecommendationPayment(Long recommendationId, Long anotherRecommendationId) {
        RecommendationPayment recommendationPaymentForUser = RecommendationPayment.builder()
                .recommendationId(recommendationId)
                .paymentStatus(RecommendationPaymentStatus.BEFORE_PAYMENT)
                .build();

        RecommendationPayment anotherRecommendationPaymentForUser = RecommendationPayment.builder()
                .recommendationId(anotherRecommendationId)
                .paymentStatus(RecommendationPaymentStatus.BEFORE_PAYMENT)
                .build();
        return Arrays.asList(recommendationPaymentForUser, anotherRecommendationPaymentForUser);
    }

}

그리고 추천과 추천 결제 데이터 생성은 여기에 달았고 쓰는 작업을 하기 때문에 @Transactional을 달았다

이렇게 서비스 레이어에 트랜잭션을 안걸고 구현 컴포넌트에만 트랜잭션을 각각 달기로 정했다

이렇게 하면
1. 트랜잭션으로 묶여야 되는 것들은 트랜잭션으로 묶고 트랜잭션에 묶이지 않아야 될것들은 묶이지 않을 수 있다 -> 직접 트랜잭션을 확실하게 관리하여 잘못 롤백되거나 롤백이 안되는 이슈가 생기지 않는다
2. 알림톡을 쏘고 로그를 저장하는것도 따로 트랜잭션으로 묶어서 매칭 트래픽이 많이 발생하는 경우에 디비 커넥션에 영향을 주지 않는다
3. 트랜잭션 전파를 정확하게 관리하여 문제점 3같은 경우도 일어나지 않는다

또한 예상치 못하게 추가적인 장점이 있는데 서비스 레이어에 트랜잭션이 없다보니까 하나의 트랜잭션으로 묶여야 하는 기능들을 세부 구현 컴포넌트에서 같은 트랜잭션으로 묶어야 했다 이렇게 하다 보니까 서비스 레이어의 코드 부분들이 명확하게 분리되고 비즈니스 흐름이 더 명확해져 가독성이 좋아지고 책임을 더 잘 분리할 수 있게 되었다

ex)

    public void createPayBothAdditionalRecommendation(CreatePayBothAdditionalRecommendationRequest request) {
        additionalRecommendationValidator.validIsAlreadyExistBothAdditionalRecommendation(request.getUserId(), request.getPartnerUserId());
        additionalRecommendationValidator.validBothAdditionalRecommendCountOver(request.getUserId(), request.getPartnerUserId(), ADDITIONAL_RECOMMENDATION_COUNT);
        
        AdditionalRecommendation additionalRecommendation = request.toPayAdditionalRecommendation();
        AdditionalRecommendation anotherAdditionalRecommendation = request.toPayAdditionalAnotherRecommendation();
        additionalRecommendationWriter.write(additionalRecommendation);
        additionalRecommendationWriter.write(anotherAdditionalRecommendation);

        RecommendationPayment recommendationPayment = craeteRecommendationPayment(additionalRecommendation.getId());
        RecommendationPayment anotherRecommendationPayment = craeteRecommendationPayment(anotherAdditionalRecommendation.getId());
        recommendationPaymentWriter.write(recommendationPayment);
        recommendationPaymentWriter.write(anotherRecommendationPayment);

        sendAlimTalkAdditionalMatching(request.getUserId(), request.getPartnerUserId());
        sendAlimTalkAdditionalMatching(request.getPartnerUserId(), request.getUserId());
    }

이 코드에서 트랜잭션으로 무조건 묶여야 할 부분이 있다 추가 추천 객체를 생성하고 저장, 추가 추천 결제 객체를 생성하고 저장
하지만 이렇게하면 추가 추천 결제 객체를 생성하고 저장하다가 실패하면 추가 추천 객체가 롤백되지 않는다 이럼 안된다

    public void createPayBothAdditionalRecommendation(CreatePayBothAdditionalRecommendationRequest request) {
        additionalRecommendationValidator.validIsAlreadyExistBothAdditionalRecommendation(request.getUserId(), request.getPartnerUserId());
        additionalRecommendationValidator.validBothAdditionalRecommendCountOver(request.getUserId(), request.getPartnerUserId(), ADDITIONAL_RECOMMENDATION_COUNT);
        paymentAdditionalRecommendationManager.createBothPaymentAdditionalRecommendation(request);
        sendAlimTalkAdditionalRecommendation(request.getUserId(), request.getPartnerUserId());
        sendAlimTalkAdditionalRecommendation(request.getPartnerUserId(), request.getUserId());
    }

이렇게 추가 추천을 생성하고 저장, 추가 추천 결제를 생성하고 저장 하는 과정을 하나의 세부 구현 컴포넌트로 분리하여 하나의 트랜잭션으로 묶으니 서비스 계층의 메서드가 더 좋아졌다

이렇게 트랜잭션을 잘게 쪼갰는데 api가 클라이언트에게 응답될 때 까지 영속성 컨텍스트를 유지하기 때문에 디비 커넥션 또한 계속 가지고 있다 우리 서비스는 알림톡을 보내고 받은 유저가 행위를 하는 시간이 몰려 있는(순간 트래픽)이 중요한 서비스이기 때문에 OSIV를 false로 해서 transaction이 끝나면 영속성 컨텍스트가 닫히게 하였다

이렇게 하면 지연로딩이 닫힐 수 있지만 우리는 연관관계를 맺는 것에 대한 엄격한 기준을 새워서 현재까지 연관관계를 걸지 않았다 그래서 문제가 없다(지연로딩 자체가 없다)

0개의 댓글