본문 바로가기

내가 잊어버리기 싫어서 적는 개발 지식

[Spring JPA] 한 트랜잭션 내부에서의 외부 API 콜과 엮인 영속성 문제

이 카테고리를 만든 이유


이 카테고리에서 첫 글인 만큼 카테고리의 이유는 여기에 한번만 적겠다.

 

회사에서 개발하면서 모르는 지식들을 지피티형이나 구글링을 통해 서칭하는데, 가끔 [어 이거 옛날에 한번 검색했던 기억이 나는데?]라는 생각과 함께 나의 수준에 탄식을 하는 일이 있었다.

 

그래서 올해부터는 머리를 탁 치고 깨달았던 개발 관련 지식을 이 카테고리에 정리할 계획이다. 

회고 말고는 쓸 주제도 명확하게 정하기 힘들었는데 다행이다.

 

사실 올해 마지막 글이 될 수도 있음

 

 

 

 

한 트랜잭션 내부에서의 외부 API 콜


데이터 베이스를 이용해 데이터를 가공하고 서빙하는 서비스를 개발함에 있어서 트랜잭션 관리는 아주 중요한 영역이다.

성능 이슈는 그렇다 쳐도 데이터 정합성을 해치는 상황이 발생하면 안되기 때문이다.

 

회사에서 서비스를 개발하다보면 수 많은 사내 서비스를 연동하게 된다.

계정, 메일, 푸시, 결제, 쿠폰 등 우리가 개발하는 서비스에 필요한 영역들은 거의 모두 사내서비스로 제공되고 있는 경우가 많으며, 새로 개발할 필요 없이 제공되는 API 콜 또는 구축된 SDK를 통해 편하게 사용할 수 있다.

 

특히 API로 제공되는 경우가 대부분인데, 직접 개발하는 서비스의 DB 접근과 외부 API 콜이 밀접한 관련이 있을 때 에러 처리와 트랜잭션에 대해 깊은 고민을 하게 된다.

 

크게 생각해보면 두가지 방식이 있다.

 

1. 하나의 트랜잭션 안에서의 DB 접근과 API 콜 수행

2. 개별적인 트랜잭션을 통해 로직 구현

 

앞으로 전개되는 모든 내용은 Java + Spring + JPA(hibernate)를 사용한다는 전제를 가진다.

 

1. 

하나의 트랜잭션 내에서 DB 접근을 먼저 수행할지, 외부 API 콜을 먼저 수행할지 정해야 한다.

 

외부 API콜을 먼저 수행한 후, DB 작업 및 서비스 로직을 진행한다고 생각해보자.

@Transactional
public void test() {
    externalService.apiCall();
    
    // 서비스 로직
    // ex
    Entity entity = entityRepository.findById(2);
    entity.setField("nice");
}

 

이때 만약 외부 API 콜에서 에러가 발생한다면 아래 코드들을 수행조차 하지 않고 예외 처리에 들어가기 때문에 괜찮다.

문제는 API 콜을 성공적으로 수행한 후, 이후의 프로세스에서 예외가 발생할 때인데, 외부 API를 제공하는 서비스가 성공했던 처리를 롤백해주는 기능을 제공해주지 않는다면 대참사가 발생한다.

 

때문에, 보통 외부 API 콜은 기능의 맨 마지막에 놓는게 일반적이다.

@Transactional
public void test() {
    // 서비스 로직
    // ex
    Entity entity = entityRepository.findById(2);
    entity.setField("nice");
    
    externalService.apiCall();
}

 

DB작업(서비스 로직을 포함)을 모두 마친 이후에, API 콜을 한다.

API 콜을 하기 이전에 예외가 발생하면 당연히 문제가 없다.

API 콜에서 에러가 날 때 신경을 좀 써야하는데, 서비스 로직에서 flush를 진행하지 않는다면 영속성 컨텍스트에만 반영이 되고 실제 DB에는 반영이 되지 않기 때문에 괜찮고, DB에 반영이 된다 하더라도 익셉션이 발생하는 순간 @Transactional 어노테이션 덕분에 DB에 commit 이 되지 않고, 그동안의 작업이 롤백되어 괜찮다.

 

위와 같은 특성으로, 우리는 한 트랜잭션 내에서 DB 작업을 포함한 서비스 로직과 외부 API 콜을 해야할 때, 외부 콜은 맨 마지막에 넣어놓는 방식을 채택한다.

 

위 설명에서 굳이 볼드를 통해 강조한 내용이 있는데, 저 부분이 이 글을 쓰게 된 이유이다.

개발된 로직 중 문제가 발생했는데, 영속성 컨텍스트와 실제 DB의 트랜잭션 내에서의 커밋을 제대로 신경쓰지 못해 발생했다.

 

나는 Spring 프레임워크가 Data JPA가 제공하는 .save() 메소드를 호출하거나 가져온 entity의 속성을 바꾸는 .set() 같은 메소드를 사용할 때 무조건 쿼리의 유효성을 판단해준다고 생각했다.

 

무슨 말이냐면, 어떤 테이블의 컬럼 A가 not null 속성을 가지고 있다고 가정해보자.

또한 not null 속성은 DB에만 반영되어있으며, 코드에서는 이를 관리하지 않음

@Transactional
public void test() {
    Entity entity = new Entity();
    entityRepository.save(entity);
    
    externalService.apiCall();
}

 

이때, 위 로직을 수행하면 외부 API콜까지 갈까?

 

나는 스프링 형님이 당연히 save() 단계에서 DB에 쿼리 유효성을 검사해 익셉션을 내줄 것이라고 굳게 믿고 있었고, 실제로 그렇게 작동하는지 확인조차 안해봤었다. 

 

그러나 실제로 entity저장은 DB에서 not null 오류를 뱉으며 실패했는데, 외부 API 콜이 호출된 흔적이 있었다.

문제가 있는 쿼리가 발견되지 않고, API 콜까지 문제없이 진행된 것이다.

 

위 로직과 비슷하게 다른 곳에서도 save() 이후 외부 API 콜을 하는 로직이 있었는데 분명 얘는 문제가 없었다.

그렇다면 뭐가다를까?

 

바로 영속성 컨텍스트가 flush 되는 시점이다.

@Transactional이 붙은 서비스 단에서 flush를 직접 명시하지 않으면 전파된 트랜잭션이 마무리 될 때 영속성 컨텍스트의 내용이 DB에 반영된다.

또한, save()는 기본적으로 flush를 같이 수행하지 않지만 entity의 PK에 아래와 같은 옵션을 붙여 사용하면 entity를 save하는 시점에 PK를 알아야 하므로 DB에 적용하는 로직을 수행한다.

@GeneratedValue(strategy = GenerationType.IDENTITY)

 

위 옵션을 가지지 않는 entity가 save될 때는 flush를 수행하지 않으며(DB에 쿼리를 수행하지 않음) 이 쿼리가 유효한지에 대한 판단을 내릴 수 없다.

 

그래서 외부 API 콜까지 무사히 진행됐지만 이후의 DB 반영에서 오류가 난 것이다.

 

즉, 영속성 컨텍스트에 있는 변경점이 DB에 적용되지 않는 이상 쿼리의 유효성은 판단할 수 없으므로 이전의 로직에서 내부적으로 쿼리 유효성이 있는 flush가 수행되는지 확인해야 한다.

위 예제는 PK 자동 생성 옵션에 따른 save() 메소드의 flush 차이인데, 다른 메소드에서도 신경을 써야한다.

 

신경쓰기 귀찮다고 무분별한 flush를 난사하면 앱 퍼포먼스에 해를 끼친다는 챗 지피티 형님의 말씀도 들었다.

따라서 다음 코드를 통해 문제를 해결할 수 있다,

@Transactional
public void test() {
    Entity entity = new Entity();
    entityRepository.save(entity);
    entityRepository.flush();
    
    externalService.apiCall();
}

 

 

하지만, not null 속성을 가진 필드에 항상 값이 들어가도록 로직을 구현하거나 entity의 컬럼에 not null 속성을 명시해주는게 가장 좋겠지요

 

 

2. 

단순히 원자성을 고려한다면 1번과 같이 하나의 트랜잭션 내에서 모두 처리하면 된다. 

다만 위 방법은 문제가 있는데, 하나의 트랜잭션이 물고 있는 커넥션이 외부 API 콜의 오버헤드까지 감당해야 한다는 점이다. 

 

DB에 insert/update 과 많이 발생하는 서비스는 DB 커넥션을 물었다면 빨리 로직을 끝내고 놓아주는게 중요한데, 서버 머신 위치에 따라 바다를 건널 수도 있는 외부 API 콜이 이 DB 커넥션의 시간에 영향을 준다면 분명히 퍼포먼스에 영향을 줄것이다.

 

이를 타개하기 위해선 내부 서비스 로직과 외부 API 콜을 별개의 트랜잭션을 통해 관리해야 한다.

사실 외부 API 콜을 트랜잭션이라고 부르긴 다소 애매하지만, 편한 이해를 위해 이렇게 칭하겠다.

 

@Transactional
public void test() {
    testService.serviceLogic();
    testService.externalApiCall();
}
@Transactional
public void serviceLogic() {
    // 서비스 로직
    Entity entity = entityRepository.findById(2);
    entity.setField("nice");
}

public void externalApiCall() {
	externalService.apiCall();
}

 

당장 위 코드처럼 서비스 로직에 아무 속성 없이 트랜잭션 어노테이션을 사용한다면 부모 메소드의 트랜잭션을 그대로 전파받기 때문에 1과 다른 점이 없다.

 

다만, 메소드를 나누어놨기 때문에 서비스 로직 메소드 의 트랜잭션 어노테이션에 rollbackFor 옵션을 사용해 특정 상황에서만 롤백을 수행 한다든지, NESTED 방식을 사용하여 외부 API 콜의 결과와 상관없이 항상 트랜잭션을 커밋하는 방식 등을 통해 보다 동적으로 트랜잭션 관리를 할 수 있다.

 

관리 해야하는 클래스와 코드가 많아지긴 해도 얼핏보면 위 방식이 좋아보인다.

 

그러나 위처럼 개별적으로 트랜잭션을 관리하면 외부 API 콜의 에러에 대해 서비스 로직의 트랜잭션을 롤백하기 매우 힘들다.

 

가령, 다음과 같이 서비스 로직 메소드의 트랜잭션을 NESTED 옵션을 통해 개별적으로 관리한다고 하자.

@Transactional(propagation = Propagation.NESTED)
public void serviceLogic() {
    // 서비스 로직
    Entity entity = entityRepository.findById(2);
    entity.setField("nice");
}

 

이러면 부모 메소드에서 외부 API 콜 메소드를 호출할 때 에러가 발생한다 해도 이미 독립적인 트랜잭션을 통해 DB에 반영되었기 때문에(커밋 까지 완료) 이를 롤백하려면 개발자가 피땀을 흘리며 보상 트랜잭션을 따로 구현해야 한다.

 

즉, 테스트 메소드는 다음과 같이 구현될 수 있다.

@Transactional
public void test() {
    testService.serviceLogic();
    try{
    	testService.externalApiCall();
    } catch (예상한 익셉션 e) {
    	// 독립적인 보상 트랜잭션 로직 수행
    }
}

 

또한 힘들게 위 로직을 구현했다 한들, 외부 API 콜 실패 후 성공한 서비스 로직으로 인한 DB 변경 사항을 롤백하는 보상 로직 수행이 완료되기 전 어떤 문제가 생길지 모두 예측하고 방어해야 하기 때문에 쉬운 작업은 절대 아니다.

 

 

 

결론


일단 외부 API 콜이 가벼운지 무거운지, 실패해도 상관 없는 건지 등에 따라 서비스 로직과 하나의 트랜잭션에 묶을지 말지 판단을 잘하는 것이 중요하다. 

 

또한, 만약 하나의 트랜잭션에 묶을 것이라면 flush가 있는지 확인을 통해 외부 API 콜 이후에 DB 커밋 실패와 같은 상황을 대비할 수 있다는 점이 결론이다.

 

 

 

틀린 내용 발견 시, 수정요청 주시면 정말 감사드리겠습니다