쑥멍
쑥로그
쑥멍
전체 방문자
오늘
어제
  • 분류 전체보기 (65)
    • 메모 (0)
    • 안드로이드 (4)
      • 팁 (1)
      • 프로젝트 (3)
    • 파이썬 (1)
    • 스프링 (26)
      • 프로젝트 (16)
      • 에러 아카이빙 (1)
      • 해부 (9)
      • 튜토리얼 (0)
    • 리눅스 (3)
    • CS (14)
      • 컴퓨터구조 & OS (8)
      • 클린 아키텍처 (6)
    • 낙서 (5)
      • 일기 (0)
      • TIL (2)
      • 고민 (2)
    • 게임 (1)
      • 야숨 (1)
    • C (0)
    • Go (3)
    • Django (0)

블로그 메뉴

  • 홈
  • 태그
  • 방명록

공지사항

인기 글

태그

  • ㅁ
  • ㅜ

최근 댓글

최근 글

티스토리

hELLO · Designed By 정상우.
쑥멍

쑥로그

[스프링부트] 트랜잭션에서 외부 API 호출하는 문제에 대해
스프링/프로젝트

[스프링부트] 트랜잭션에서 외부 API 호출하는 문제에 대해

2023. 11. 14. 08:59

1. 첫 번째 문제: 외부 DB와 내부 DB 상태 불일치

1. 문제 상황

선물에 쌓인 후원금을 정산하는 로직에서 내 DB에 저장하는 것과 외부 API를 호출해서 실제로 입금이체를 하는 것 두 작업이 필요하다. 나는 이를 한 트랜잭션에서 수행하게 했다. 그런데 문제는, 내 DB에 입금이체 관련 정보를 저장하는 작업이 실패해도 외부 API 호출은 롤백되지 않는다는 것이다. 입금이체 취소 요청은 훨씬 복잡해서 할 수 없다. 이렇게 되면 입금이체가 성공했어도 내 DB는 그것을 알지 못하기 때문에 두 DB간의 불일치가 발생한다. 아직 입금이체가 되지 않은 선물로 보고 다음날에 다시 입금이체를 시도할 것이다. 

문제 상황

 

@Transactional(propagation = Propagation.REQUIRES_NEW)
public void depositExpiredGiftAmountTransactional(Gift gift) {
    // User
    Users receiver = gift.getEvent().getUsers();

    // Account
    Account receiverAccount = receiver.getDefaultAccount();

    // ⭐️ 입금이체 실행
    OpenbankingDepositResponseDto response = openbankingApi.depositAmount(String.valueOf(gift.getFunded()), receiverAccount.getName(), receiverAccount.getBankCode(), receiverAccount.getAccountNum());

    // api 응답 코드와 은행 응답 코드 두 개가 있음.
    String apiRspCode = response.getRsp_code();
    String bankRspCode = response.getRes_list().get(0).getBank_rsp_code();
    String rspMsg = response.getRsp_message();
    log.info("오픈뱅킹 입금이체 API 응답 코드: " + apiRspCode);
    log.info("오픈뱅킹 입금이체 참가은행 응답 코드: " + bankRspCode);
    log.info("오픈뱅킹 입금이체 응답 메세지: " + rspMsg);

    if(!response.getRes_list().isEmpty() && bankRspCode.equals("000")) {
        openbankingDepositService.save(OpenbankingStatus.PAID, response, gift, receiver);	
        gift.completeProcess();
        return;
    }
    if(apiRspCode.equals("A0007") ||bankRspCode.equals("400") || bankRspCode.equals("803")|| bankRspCode.equals("804") || bankRspCode.equals("822")){
            openbankingDepositService.save(OpenbankingStatus.UNCHECKED, response, gift, receiver);
            gift.shouldCheckProcess();
            return;
    }

    openbankingDepositService.save(OpenbankingStatus.FAILED, response, gift, receiver);
}

위 코드를 보면, 외부 API를 호출한 뒤에 그 응답에 따라 DB에 결과를 저장하고 있다. 외부 API 호출이 실패하면 그대로 예외가 전파되고 그 뒤로 더 진행되지 않아서 다행이지만, 외부 API 호출이 성공한 뒤에 DB 저장 작업이 실패한다면 문제가 된다. 앞서 말했듯이 당연히 외부 API 호출은 롤백되지 않는다.

 

2. 해결책 고르기

  • DB에 데이터를 저장한 후에 외부 API 호출
    • 외부 API 호출에서 예외 발생하면 앞서 수행한 DB 작업도 롤백되게 할 수 있음
    • DB에 외부 API 호출에 필요한 식별자를 저장 -> API 호출 -> 응답 결과 업데이트
    • DB에 데이터 저장 실패 시 바로 리턴
    • 장점: 구현이 간단함
  • 멱등성 제공
    • 외부 API를 몇 번을 호출하든 같은 결과를 만들게 하는 법을 생각해야 함
    • 입금이체 요청할 때 고유번호가 필요한데, 요청할 때마다 랜덤으로 생성하는 게 아니라, 선물 엔티티 id에서 몇 자 잘라 붙여서 만들기?
  • 상태 확인 및 복구 메커니즘 만들기
    • 정기적으로 오픈뱅킹 DB, 내 DB 간 상태 비교해서 불일치를 찾고 해결
    • 단점: 입금이체마다 내가 생성하는 고유번호를 요청에 포함해야 거래내역을 조회할 수 있음. 이러면 정기적으로 확인할 수가 없음. 오픈뱅킹 사이트 문서를 보니 핀테크이용번호를 획득하면 모든 거래내역을 조회할 수 있는 것 같은데, 핀테크이용번호를 획득하려면 계좌 등록 절차가 복잡해짐. 
  • SAGA 패턴
    • MSA에서 쓰는 방식이고 보상실패 시 보상 트랜잭션을 수행해서 이전 서비스들에게 롤백
    • 이라고는 하는데 무슨 소린지 잘 모르겠고 MSA가 뭔지도 잘 모르겠고 이 서비스가 MSA인 것도 아니어서 제외하기로 함

나는 첫 번째 방식을 선택했다. 구현이 간단하고, 성능을 좀 희생한다고 해도 사용자가 많은 서버도 아니라 괜찮을 것이라고 생각한다. 세 번째 방식이 괜찮아보이기는 한데 내가 계좌 등록 절차를 간단하게 하려고 해서 이걸 뜯어 고치지 않는 이상 안 될 것 같다. 그리고 불완전하지만 어느 정도 불일치를 해결하고 있는 메커니즘이 있기는 하다. UNCHECKED 상태인 선물 엔티티를 조회해 다시 확인한다. 그런데 이것도, 입금이체 성공 + 내 DB 저장 실패 케이스에는 대응을 못해서 첫 번째 방식을 덧붙여야 비로소 안전하다고 말할 수 있을 것 같다. 

 

3. 해결책: DB에 데이터를 무조건 저장한 후에 외부 API 호출하기

 

@Transactional(propagation = Propagation.REQUIRES_NEW)
public void depositExpiredGiftAmountTransactional(Gift gift) {
    // User
    Users receiver = gift.getEvent().getUsers();

    // Account
    Account receiverAccount = receiver.getDefaultAccount();

    // ⭐️ 사전 저장
    String newBankTranId = bankTranId;
    newBankTranId = newBankTranId.concat(generateRandomString());
    OpenbankingDeposit deposit = openbankingDepositService.preSave(newBankTranId, gift, receiver);
    if (deposit == null) {
        // preSave()가 실패했으므로 종료
        return;
    }

    // ⭐️ 입금이체 실행
    OpenbankingDepositResponseDto response = openbankingApi.depositAmount(String.valueOf(gift.getFunded()), receiverAccount.getName(), receiverAccount.getBankCode(), receiverAccount.getAccountNum());

    // api 응답 코드와 은행 응답 코드 두 개가 있음.
    String apiRspCode = response.getRsp_code();
    String bankRspCode = response.getRes_list().get(0).getBank_rsp_code();
    String rspMsg = response.getRsp_message();
    log.info("오픈뱅킹 입금이체 API 응답 코드: " + apiRspCode);
    log.info("오픈뱅킹 입금이체 참가은행 응답 코드: " + bankRspCode);
    log.info("오픈뱅킹 입금이체 응답 메세지: " + rspMsg);

    if(!response.getRes_list().isEmpty() && bankRspCode.equals("000")) {
        openbankingDepositService.postProcess(deposit, OpenbankingStatus.PAID, response); // ⭐️
        gift.completeProcess();
        return;
    }
    if(apiRspCode.equals("A0007") ||bankRspCode.equals("400") || bankRspCode.equals("803")|| bankRspCode.equals("804") || bankRspCode.equals("822")){
            openbankingDepositService.postProcess(deposit, OpenbankingStatus.UNCHECKED, response); // ⭐️
            gift.shouldCheckProcess();
            return;
    }

    openbankingDepositService.postProcess(deposit, OpenbankingStatus.FAILED, response); // ⭐️
}

 

  • 요청 고유번호(bankTranId)는 원래 외부 API 호출 메소드 내에서 생성했는데, 이걸 내 DB에 먼저 저장하기 위해 밖으로 빼냄
  • Deposit 엔티티를 우선 Unchecked 상태로 불완전하게 저장 
  • preSave()에서 저장이 제대로 안 됐다면 Deposit 엔티티가 null일 것. 이 때 그냥 메소드 종료.
  • 그 뒤에 외부 API 호출하고 응답 받아옴
  • 응답에 따라 Deposit 엔티티 업데이트(postProcess())

그리고 중요한 점은 사전 저장 메소드(preSave())는 독립된 트랜잭션으로 실행되게 해야한다는 것이다.

@Transactional(propagation = Propagation.REQUIRES_NEW)
public OpenbankingDeposit preSave(String bankTranId, Gift gift, Users receiver) {
    OpenbankingDeposit deposit = OpenbankingDeposit.builder()
            .bank_tran_id(bankTranId)	// ⭐️
            .amount(-1)
            .tran_dtime("Unchecked")
            .tran_no("Unchecked")
            .status(OpenbankingStatus.UNCHECKED)	// ⭐️
            .receiver(receiver)
            .gift(gift)
            .build();

    openbankingDepositRepository.save(deposit);
    return deposit;
}

@Transactional(propagation = Propagation.REQUIRES_NEW)로 새 트랜잭션이 시작되게 했다. 이 옵션은, 부모 트랜잭션과 별도로 실행되게 하고 부모 트랜잭션이 실패해도 이 트랜잭션은 롤백되지 않는다. 이 옵션을 넣지 않으면 부모 트랜잭션의 끝까지 실행되어야 커밋된다. 하지만 이렇게 하면 의미가 없다. 부모 트랜잭션의 이후 로직과는 별개로, 이 트랜잭션은 반드시 실행되게 해야한다.

 

하지만 이 어노테이션을 붙이는 것만으로는 충분하지 않다. 이 트랜잭션이 실패해도, 부모 트랜잭션으로 예외가 전파되지 않기 때문이다. 여기서 실패하면 부모 트랜잭션도 끝나게 해야 한다. 

OpenbankingDeposit deposit = openbankingDepositService.preSave(newBankTranId, gift, receiver);
if (deposit == null) {
    // preSave()가 실패했으므로 종료
    return;
}

그래서 null 체크를 추가한 것이다. 일단 이렇게 하면 ... 아 아니구나? 방금 쓰면서 알았다. save()가 실패해도 Deposit은 null이 아니다. Deposit 엔티티를 애플리케이션 로직에서 값을 채운 후에 저장하기 때문이다. 이 점을 주의하자... 

 

try {
	OpenbankingDeposit deposit = openbankingDepositService.preSave(newBankTranId, gift, receiver);
} catch (Exception ex) {
	return;
}

try-catch문을 써서 예외가 발생했을 때 해당 트랜잭션이 종료되게 한다. 

 

if(!response.getRes_list().isEmpty() && bankRspCode.equals("000")) {
    openbankingDepositService.postProcess(deposit, OpenbankingStatus.PAID, response); // ⭐️
    gift.completeProcess();
    return;
}
if(apiRspCode.equals("A0007") ||bankRspCode.equals("400") || bankRspCode.equals("803")|| bankRspCode.equals("804") || bankRspCode.equals("822")){
        openbankingDepositService.postProcess(deposit, OpenbankingStatus.UNCHECKED, response); // ⭐️
        gift.shouldCheckProcess();
        return;
}

openbankingDepositService.postProcess(deposit, OpenbankingStatus.FAILED, response); // ⭐️

입금이체를 실행한 후에 받은 응답에 따라 Deposit 엔티티를 수정한다.

 

public void postProcess(OpenbankingDeposit deposit, OpenbankingStatus status, OpenbankingDepositResponseDto dto) {
    deposit.postProcess(dto, status);
    openbankingDepositRepository.save(deposit);
}

이 메소드에서 findById()로 Deposit 엔티티를 조회하지 않기 때문에 직접 save()를 해야 수정이 반영된다는 것도 주의해야 한다. 

 

+나중에 Unchecked인 엔티티 다시 조회해서 처리 끝내기

이 부분은 내가 원래 구현해뒀었다. 로직은 입금이체와 유사하다. for문으로 Unchecked 상태인 선물들을 순회하며 하나씩 이체결과를 확인하는 요청을 수행한다. 

@Transactional(propagation = Propagation.REQUIRES_NEW)
public void checkDepositResult(Gift gift) {
    List<OpenbankingDeposit> deposits = openbankingDepositService.findAllByGiftDESC(gift);
    OpenbankingDeposit deposit = deposits.get(0);
    DepositResultCheckResponseDto response = openbankingApi.depositResultCheck(deposit);
    String rspCode = response.getRes_list().get(0).getBank_rsp_code();

    // 성공
    if(rspCode.equals("000")) {
        gift.completeProcess();
        deposit.success();
        return;
    }

    // 실패 1) 나중에 입금이체 재요청
    if(rspCode.equals("701") || rspCode.equals("813")) {
        gift.shouldReDeposit();
        deposit.fail();
    }

    // 실패 2) 나중에 이체결과조회 재요청
}

 

이 확인 메커니즘에서도 제대로 처리되지 못한 선물들을 위해 나름 안전하게 설계한다고 한 게 아래의 코드다.

// TODO: 은행 점검 없는 다른 시간대로 변경 예정(상의해야됨)
@Scheduled(cron="0 2 0 * * *")
public void depositExpiredGifts() {
    depositTask.depositExpiredGiftAmount();
}

// 위 입금이체 시도에서 실패했을 가능성 고려해 2분 뒤에 입금이체 결과 확인 요청
@Scheduled(cron = "0 4 0 * * *")
public void checkDepositResult() {
    depositTask.checkDepositResult();
}

// 위 입금이체 결과 확인 요청에서 실패했을 가능성 고려해 4분 뒤에 입금이체 재요청
// (왜냐하면 입금이체 결과 확인 실패의 모든 경우는 그냥 입금이체 재요청으로 처리해야됨)
@Scheduled(cron = "0 8 0 * * *")
public void retryDepositExpiredGifts() {
    depositTask.depositExpiredGiftAmount();
}

// 위 입금이체 재요청이 실패했을 가능성 고려해 2분 뒤에 입금이체 재요청의 결과 확인 요청
// 여기서 실패한다면 끝. 다음날에 시도함.
@Scheduled(cron = "0 10 0 * * *")
public void retryCheckDepositResult() {
    depositTask.checkDepositResult();
}
  1. 입금이체 시도
  2. 1번 실패했을 가능성 고려해 2분 뒤에 확인 시도
  3. 위의 확인 요청에서 입금이체 실패로 분류된 선물들 그냥 당일에 처리하기 위해 다시 입금이체 시도
  4. 3번 실패했을 가능성 고려해 3번의 입금이체 확인 시도

4번에서도 Unchecked이거나 Failed 상태(입금이체가 어떻게 처리되었는지 알 수 없음 혹은 입금이체 실패해서 재시도해야함)면 어쩔 수 없이 다음날에 재시도하는 것으로 구현했다. 


2. 두 번째 문제: DB 커넥션 고갈과 트랜잭션 분리에 대해

1. 문제 상황

보다시피 내 코드를 보면 외부 API 호출과 DB 저장이 같은 트랜잭션에 있다. 이렇게 하면 생길 수 있는 잠재적인 위험이 있는데, DB 커넥션 풀이 고갈될 수 있다는 점이다. 사실 외부 API 호출하는 부분은 트랜잭션이 실질적으로 사용되는 부분은 아니다. 하지만 외부 서비스를 호출한 후 응답을 받기까지 기다리는 시간동안 스레드가 대기해야하기 때문에 낭비다. 불필요한 대기 시간은 줄이는 게 좋다. 

내 코드의 경우, DB 사전 저장하는 부분은 별개의 트랜잭션으로 실행되고, 외부 API 호출은 앞서 언급했듯 트랜잭션과는 관련이 없어서 실질적으로 트랜잭션이 필요한 부분은 노란색의 DB 업데이트 부분 뿐이다. 트랜잭션 대기 시간의 대부분이 트랜잭션과 관련이 없는 부분 때문에 발생한다. 실제로 필요한 부분은 DB 업데이트 부분 뿐인데도 DB 사전 저장, 외부 API 호출이 모두 끝날 때까지 기다려야 한다.

 

2. 해결책: 분리 & 비동기 실행

보통은 외부 API 호출과 DB 저장 작업을 분리해서 해결한다. 외부 API 호출을 바깥으로 빼내면 된다. 

또한 DB 사전 저장과 외부 API 호출은 순차적으로 실행될 필요가 없다. 둘 사이에 데이터 의존성이 없기 때문이다. 이 부분도 수정해서 트랜잭션 주기를 더 짧게 줄일 수 있다. 

개선 후

이 방식으로 수정했다. DB 사전 저장과 외부 API 호출을 비동기로 호출해 병렬로 실행한 뒤, 두 작업이 모두 끝나면 DB 업데이트를 하는 트랜잭션을 시작한다. 트랜잭션이 이 셋 모두를 포함할 필요는 없었는데 처음에는 잘 쓸 줄 몰라서 그냥 어노테이션을 붙였던 것 같다. 

 

@Component
@RequiredArgsConstructor
@Slf4j
public class DepositTask {
    ...
    
    public void depositExpiredGiftAmount() {
        List<Gift> gifts = giftRepository.findExpiredGiftFetchJoin(GiftState.expired, ProcessState.none);

        List<CompletableFuture<Void>> futures = gifts.stream()
                .map(gift -> CompletableFuture.runAsync(() -> depositExpiredGiftAmountInternal(gift)))
                .collect(Collectors.toList());

        CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
    }

    public void depositExpiredGiftAmountInternal(Gift gift) {
        // User
        Users receiver = gift.getEvent().getUsers();

        // Account
        Account receiverAccount = receiver.getDefaultAccount();

        // 사전 저장
        String newBankTranId = bankTranId;
        newBankTranId = newBankTranId.concat(generateRandomString());
        String finalNewBankTranId = newBankTranId;
        CompletableFuture<OpenbankingDeposit> depositFuture = CompletableFuture
                .supplyAsync(() -> openbankingDepositService.preSave(finalNewBankTranId, gift, receiver))
                .exceptionally(ex -> {
                    log.info("preSave()가 실패했으므로 종료");
                    return null;
                });


        // 입금이체 실행
        CompletableFuture<OpenbankingDepositResponseDto> responseFuture = CompletableFuture
                .supplyAsync(() -> openbankingApi.depositAmount(String.valueOf(gift.getFunded()), receiverAccount.getName(), receiverAccount.getBankCode(), receiverAccount.getAccountNum()));

        depositFuture.thenAcceptBoth(responseFuture, (deposit, response) -> {
            // api 응답 코드와 은행 응답 코드 두 개가 있음.
            String apiRspCode = response.getRsp_code();
            String bankRspCode = response.getRes_list().get(0).getBank_rsp_code();
            String rspMsg = response.getRsp_message();
            log.info("오픈뱅킹 입금이체 API 응답 코드: " + apiRspCode);
            log.info("오픈뱅킹 입금이체 참가은행 응답 코드: " + bankRspCode);
            log.info("오픈뱅킹 입금이체 응답 메세지: " + rspMsg);

            if(!response.getRes_list().isEmpty() && bankRspCode.equals("000")) {
                depositTaskTransactional.postProcess(gift, ProcessState.completed, deposit, OpenbankingStatus.PAID, response);
                return;
            }
            if(apiRspCode.equals("A0007") ||bankRspCode.equals("400") || bankRspCode.equals("803")|| bankRspCode.equals("804") || bankRspCode.equals("822")){
                depositTaskTransactional.postProcess(gift, ProcessState.check, deposit, OpenbankingStatus.UNCHECKED, response);
                return;
            }

            depositTaskTransactional.postProcess(gift, ProcessState.none, deposit, OpenbankingStatus.FAILED, response);

        }).join();
    }
}
@Component
@RequiredArgsConstructor
@Slf4j
public class DepositTaskTransactional {
    ...
    
    @Transactional
    public void postProcess(Gift gift, ProcessState state, OpenbankingDeposit deposit, OpenbankingStatus status, OpenbankingDepositResponseDto response) {
        openbankingDepositService.postProcess(deposit, status, response);
        giftService.setProcessState(gift, state);
    }
}

 

리팩토링을 적용한 최종 코드다.

바뀐 점은 다음과 같다.

  • 원래 for문을 순회하는 메소드인 depositExpiredGiftAmount()와 트랜잭션 적용을 위해 클래스를 분리해서 depositExpiredGiftAmountTransactional()에서 각 선물 하나에 대한 작업을 수행했었는데, 외부 메소드와 내부 메소드 둘 다 @Transactional 어노테이션을 제거했다. 
    • 트랜잭션 범위를 postProcess()에만 적용할 것이기 때문에 두 메소드를 같은 클래스에 두고 postProcess()를 타 클래스로 분리했다. 
  • DB 사전 저장과 외부 API 호출을 CompletableFuture를 이용해 비동기적으로 실행시켰다. 
  • depositFuture.thenAcceptBoth(responseFuture, ...) 는 depositFuture(사전 저장 결과)와 responseFuture(외부 API 호출 결과)를 조합해서 나머지 로직을 처리한다.
    • 두 결과를 조합하되 리턴값이 필요없을 때는 thenAccpetBoth()를 사용한다.
  • 마지막의 join()은 두 작업이 모두 완료될 때까지 현재 스레드를 블록하여 기다린다. 
  • postProcess()가 줄인 트랜잭션이다.
    • 추가로, 원래는 도메인의 상태 변경 메소드를 직접 호출했는데 서비스를 통해 호출하는 것으로 변경했다.
    • 여기에 대해 사소한 고민이 있는데... 그냥 setProcessState() 메소드 하나로 통일할지, ProcessState 마다 이름을 붙여서 메소드를 따로 제공할지 모르겠다. 

'스프링 > 프로젝트' 카테고리의 다른 글

[스프링] 책임 연쇄 패턴으로 요금 정책 구현하기  (0) 2024.03.09
[스프링] 복잡한 비즈니스 로직 추상화  (0) 2024.03.01
Spring WebFlux + OAuth2 로그인 구현 과정  (0) 2023.11.11
[스프링부트] 교착 상태 테스트 & 해결 과정  (0) 2023.10.29
[스프링부트] 성능 테스트 & 튜닝 과정  (0) 2023.09.16
    '스프링/프로젝트' 카테고리의 다른 글
    • [스프링] 책임 연쇄 패턴으로 요금 정책 구현하기
    • [스프링] 복잡한 비즈니스 로직 추상화
    • Spring WebFlux + OAuth2 로그인 구현 과정
    • [스프링부트] 교착 상태 테스트 & 해결 과정
    쑥멍
    쑥멍

    티스토리툴바