💡이 글은 현재 리팩토링을 진행하고 있는 전시회 기록 서비스의 배치 작업 중 Read 부분에서의 외부 API 호출을 중심으로 고려해 볼 내용들을 소개하고 있습니다. 외부 API 호출 시 주의해야 할 트랜잭션 범위 설정과 외부 서버의 TPS를 고려한 개선 방안 도출 등에 관한 전반적인 내용과 추가로 고려할 수 있는 사항들을 소개합니다.
이번 글에서는 배치 작업 중 외부 API를 호출하여 데이터를 가져오는 부분을 개선하던 중 고민했던 점들에 대해 풀어나가보겠습니다.
저의 배치 작업 중 Reader가 수행하는 일에 해당됩니다. 전시회 목록 조회 API를 통해 기간 내 모든 전시회를 불러오고, 모든 전시회의 상세 정보를 조회한 후 전시 공간의 조회까지 이루어집니다.

1. 트랜잭션 범위 조절
기존 코드에서는 외부 API 호출부터 DB Insert까지 하나의 트랜잭션이 걸려있었습니다. 트랜잭션의 범위를 불필요하게 넓게 설정하면 트랜잭션이 시작되고 외부 API 응답을 기다리는 시간에도 DB 커넥션이 점유됩니다.
이 경우 외부 API가 지연되거나 처리해야 할 데이터 양이 많아지면, 불필요하게 오랜 시간 DB 커넥션을 붙잡게 됩니다. DB 커넥션을 오래 유지하게 되면 대기 중인 요청들은 커넥션 획득하지 못하고, 전반적인 처리율이 하락하게 됩니다. 또한, 트랜잭션이 커질수록, DB가 락을 오래 유지하거나, 오류 시 커다란 범위를 rollback해야 하므로 안정성과 성능 모두에 악영향을 끼칠 수 있습니다.
이러한 문제들의 발생을 방지하고자 DB 처리와 그 외 부분을 분리할 필요가 있었고, 필요한 시점에만 트랜잭션을 사용했습니다.

이전 글에서 위 그림으로 플로우를 개선하면서 자연스레 트랜잭션을 분리하게 되었습니다.
public void step() throws IOException {
List<Integer> performanceSeqList = batchReader.readExhibitionList();
List<ExhibitionDetailDTO.ExhibitionDetailResponseDTO.ExhibitionDetailMsgBodyDTO.PerformanceInfo> performanceInfoList = batchReader.readExhibitionDetail(performanceSeqList);
List<PlaceDetailDTO.PlaceDetailResponseDTO.PlaceDetailMsgBodyDTO.PlaceInfo> placeInfoList = batchReader.readPlaceDetail(performanceInfoList);
List<PlaceDtoToWrite> placeDtoListToWrite = batchProcessor.processPlace(placeInfoList);
List<ExhibitionDtoToWrite> exhibitionDtoListToWrite = batchProcessor.processExhibition(performanceInfoList);
batchWriter.writePlace(placeDtoListToWrite);
batchWriter.writeExhibition(exhibitionDtoListToWrite);
}
외부 API와 통신하여 데이터를 가져오는 Read와 가공하는 Process 부분은 트랜잭션이 필요하지 않았고, DB Insert/Update 작업을 위한 Writer의 각 메소드를 트랜잭션으로 묶었습니다. 외부 API를 불러오고, 가공 작업을 거친 후 Write 작업을 할 때, 트랜잭션이 시작되어 DB에 접근하게 됩니다.
1) 트랜잭션 전파
현재 작업 순서가 외부 API 호출 -> DB 접근 순서로 구성되어 있기 때문에 애초에 step() 함수가 트랜잭션으로 묶여있지 않다면 트랜잭션을 점유할 걱정을 크게 하지 않아도 괜찮습니다.
반대로 작업 순서가 DB 접근 -> 외부 API 호출처럼 트랜잭션이 필요한 작업 뒤에 외부 API를 호출해야 하는 경우에도 마찬가지로 큰 문제는 없습닌다. 하지만, 트랜잭션이 없어야 하는 곳에 실수로 트랜잭션이 걸리는 것을 막는 방법이 있습니다.
바로 트랜잭션 전파 속성을 NEVER로 설정해주는 안전 장치를 하나 두는 것입니다. 트랜잭션은 전파 속성 값이라는 것으로 이미 있는 트랜잭션에 대해 추가 트랜잭션 진행을 어떻게 할 것인지에 대해 결정합니다. 스프링에는 7가지 전파 속성이 존재하는데, NEVER는 트랜잭션을 사용하지 않고자 할 때 사용됩니다.
기존 트랜잭션이 없는 경우에는 트랜잭션 없이 작업을 진행하지만, 기존 트랜잭션이 있는 경우 IllegalTransactionStateException 예외를 발생시킵니다.
@Transactional(propagation = Propagation.NEVER)
public List<Integer> readExhibitionList() throws IOException {
String exhibitionListXml = openApiCaller.callExhibitionListApi(fromDate, toDate, page, rows);
String exhibitionListJsonStr = DataTypeTransferUtil.xmlStrToJsonStr(exhibitionListXml);
ExhibitionListDTO exhibitionList = objectMapper.readValue(exhibitionListJsonStr, ExhibitionListDTO.class);
// ...
}
이렇게 외부 API를 호출하는 함수 위에 @Transactional(propagation = Propagation.NEVER)를 설정해주면 트랜잭션이 열린 상태로 진입 시 예외를 발생시킵니다.
사실 바깥에 트랜잭션이 걸려 있는 상황이 아니면 잘 발생하지 않는 에러이며, 불필요한 코드가 증가하는 느낌이 들기도 합니다. 하지만, 코드 레벨에서 의도를 표현하는 정도의 의미도 있으며, 현재 작업에서는 필요성이 높지 않지만 다른 외부 API를 호출할 때 문제를 사전에 방지할 수 있는 전파 속성인 것 같습니다.
2. 외부 API 호출
배치 작업을 테스트 해보며 그라파나를 확인했을 때, 적은 건임에도 불과하고 외부 API를 호출하고 응답을 받기까지 시간이 상당히 걸렸고, 문제를 발생시킬 수 있기 때문에 원인을 파악해보았습니다.
우선, 그라파나를 살펴보니 시간이 들쑥날쑥한 경우가 있었습니다.

배치 작업을 실행시키고 로그가 찍히는 걸 유심히 지켜보니 시간이 오래 걸리는 경우 전시회 상세 조회 중간에 15초 가량 멈춤 현상이 있었습니다. 곰곰이 생각해보다가 제가 사용하고 있는 문화포털 Open API 명세서를 살펴봤는데, TPS 30이라는 제한이 적혀 있었습니다.

정확한 추측인지는 모르겠지만, 짧은 시간 동안 많은 호출을 연달아 했기 때문에 중간에 멈추는 경우가 있는 것이 아닐까 싶었습니다.(문의를 남겼으나 답이 없음..) 1000건 호출 시 600번대에서 멈추는 것을 확인했으며, 400건과 같이 조금 적은 호출을 한다면 멈춤 현상이 잡히지 않아 빠르게 처리되는 것처럼 보여 들쑥날쑥한 처리 시간이 확인되었습니다.
이에 대한 원인을 생각해보고, 여러 방법을 시도해보았습니다.
1) GC - Stop The World
외부 API를 대량으로 호출하고, 하나하나 객체로 저장했기 때문에, 메모리가 가득 차서 GC의 Stop The World 때문에 멈춤 현상이 발생할 수도 있겠다 싶었습니다.
할당한 후, 연결을 끊지 않아서 과연 GC가 맞을까 의구심이 들었지만, 그래도 확인해보기로 했습니다.

역시 멈춤 시간이 0.01초도 되지 않는 것을 보아, Stop The World가 원인은 아니라고 판단했습니다.
2) Http Connection
배치 작업 리팩토링을 진행하며 기존에 사용던 HttpUrlConnection을 사용하여 외부 API와의 통신을 진행하고 있었습니다. HttpUrlConnection은 연결 작업이 필요하고, 기능이 제한되어 있어 실제로는 많이 사용되지 않지만, 저는 이미 구축되어 있었으며, 아직 다양한 기능까지는 필요하지 않기 때문에 그대로 사용하던 중 멈춤 현상이 이와 관련이 있을까 싶어 알아보았습니다.
Tomcat WAS 와 연결할때는 기본적으로 소켓 통신으로 Connection을 설정합니다. 3-way-handshake로 TCP 연결을 수립하고, 4-way-handshake로 연결을 종료합니다.

연결을 종료할 때, close 요청을 보낸 쪽은 소켓이 바로 닫히지 않고 TIME_WAIT 상태에 접어들게 됩니다. TIME_WAIT 상태가 필요한 이유는 크게 두 가지가 있습니다.
- 지연된 패킷을 받기 위해
- 상대방의 연결이 종료되었는지 확인하기 위해
이러한 이유로 소켓을 바로 닫지 않고 대기하는 상태로 잠시동안 보관하게 됩니다.
TIME_WAIT 상태로 닫히지 않고 존재하는 소켓이 많아질 경우 포트가 고갈되는 문제가 발생할 수 있습니다.
public String callExhibitionListApi(String from, String to, Integer cPage, Integer rows) throws IOException {
StringBuilder urlBuilder = new StringBuilder(CALL_EXHIBITION_LIST_URL);
// url build 작업 ...
HttpURLConnection connection = OpenApiUtil.createHttpURLConnectionGet(urlBuilder);
StringBuilder stringBuilder = OpenApiUtil.writeResponse(connection);
connection.disconnect();
return stringBuilder.toString();
}
현재 저의 배치 작업은 위 코드와 같이 하나의 외부 API 호출 시 커넥션을 열어 사용하고, 닫습니다. 짧은 시간에 연달아 여러 번 커넥션을 열고 닫고를 반복하다보면 TIME_WAIT 상태로 존재하는 소켓이 많아져, 결국 포트가 고갈되어 멈춤 현상이 발생하는 것이 아닐까 하는 의심이 생겼습니다.
우선, 요청을 보내고, Wire Shark를 통해 TCP 연결 상황을 살펴봤습니다.
처음에 SYN - SNY, ACK - ACK로 TCP 연결을 수립하고 데이터를 주고 받습니다. 하지만, 제 예상과는 다르게 매번 연결을 수립하지 않고, 처음 한 번만 3-way-handshake로 연결을 수립한 후, 이 연결을 재사용하고 있었습니다.
HttpUrlConnection은 서버의 응답 헤더에 Keep-Alive 설정이 있다면 connection을 살려두고 재사용합니다. (참고)
/* return it to the cache as still usable, if:
* 1) It's keeping alive, AND
* 2) It still has some connections left, AND
* 3) It hasn't had a error (PrintStream.checkError())
* 4) It hasn't timed out
*
* If this client is not keepingAlive, it should have been
* removed from the cache in the parseHeaders() method.
*/
public void finished() {
if (reuse) /* will be reused */
return;
keepAliveConnections--;
poster = null;
if (keepAliveConnections > 0 && isKeepingAlive() &&
!(serverOutput.checkError())) {
/* This connection is keepingAlive && still valid.
* Return it to the cache.
*/
putInKeepAliveCache();
} else {
closeServer();
}
}
disconnect() 내부에서 호출하는 finished() 로직에 작성된 것처럼 Keep-Alive 헤더가 있다면 Keep-Alive Cache에 해당 연결을 보관하고, 타임아웃되기 전까지 사용할 수 있다고 합니다.
실제로 응답을 확인해보면 헤더에 Keep-Alive 설정이 있었고, 이 때문에 TCP 연결을 한 번만 수립한 후 재사용 한 것이었습니다.
따라서 짧은 시간에 연달아 커넥션을 생성하여 소켓이 고갈되지 않았고, 커넥션 문제가 멈춤 현상의 원인이라고 보기 어려워졌습니다.
**번외 : 커넥션 풀
현재 작업 플로우처럼 동기적으로 순서에 따라 진행되는 경우에는 커넥션을 하나만 사용해도 큰 문제가 발생하지 않습니다. 하지만, 커넥션을 여러 개 사용해야 하는 경우에는 커넥션 풀을 지원하는 통신 방법으로 변경하는 것이 좋습니다.
3개의 쓰레드로 비동기/병렬 구성하여 Open API를 호출해 본 결과 TCP 연결을 세 번 수립하는 것을 확인했습니다.
사실, 이처럼 여러 커넥션을 사용하는 경우 뿐만 아니라 확장성 면에서도 커넥션 풀을 사용하는 것이 좋습니다. 매번 커넥션을 생성할 필요가 없어질 뿐더러 관리가 훨씬 용이해지기 때문입니다.
3) 외부 API의 응답 지연
가장 의심스러운 부분입니다!
현재 사용하고 있는 외부 API는 TPS가 30으로 제한되어 있습니다. 명세서에 응답 시간이 500ms로 되어 있지만 실제는 비교도 안 될 만큼 빠른 것과 대부분의 명세가 비슷하게 작성되어 있는 것으로 보아 명세서를 꼼꼼히 작성했다고 믿기는 힘들지만, 확실한 건 외부 API 호출 시에는 외부 서버의 성능을 고려해야 합니다.

지금처럼 Read, Process, Write를 각각 일괄 처리하는 방식은 Read 작업에 몰려 있는 외부 API 호출 작업이 짧은 간격으로 대량 발생하기 때문에 자칫하면 서버에서 허용하는 요청 한계를 넘을 수 있었고, 이에 따른 멈춤 현상이 발생할 수도 있을 것이라 생각했습니다.

920건으로 호출 테스트를 해 보고, 그라파나에 나타난 시간을 확인했습니다.
920건의 전시회 상세 조회가 가장 오랜 시간이 걸렸으며, 로그를 살펴보니 요청이 실패하지는 않았으나, 중간에 20초 가량의 멈춤 현상이 있었습니다.
역시 몰려있는 외부 API 호출이 문제라고 생각되어 플로우를 변경해보기로 했습니다. Read, Process, Write를 각각 처리하던 방식에서 청크 단위로 한 사이클씩 처리하는 방식으로 결정했습니다.

이렇게 작업을 처리하게 된다면 외부 API 호출 - 가공 - DB 접근 사이클을 작게 여러 번 반복하게 됩니다. 따라서 연달아 호출되던 외부 API가 시간을 두고 천천히 호출될 수 있습니다.

처리 방식을 변경하고 실행시켜보니, 중간에 멈춤 현상이 없었고, 이에 따라 전시회 상세 조회 호출 시간이 20초 가량 줄어들었습니다. 몰아서 처리하던 방식을 조금 분산시킨 것만으로 멈춤 현상이 사라졌습니다. 중요한 건 외부 서버의 TPS를 확인하고 이에 맞게 요청을 보내는 것입니다.
청크 방식으로 변경하는 것 외에도 기존 방식에서 요청에 제한을 두어 일정 시간에 보낼 수 있는 요청을 정해놓을 수 있습니다. 저는 배치에서 호출하는 것이기 때문에 청크 방식이 더 나을 것이라고 생각지만, 외부 API를 서비스 사용자가 요청하는 경우에는 처리율 제한을 위한 장치 도입을 고려하면 좋을 것 같습니다.
응답이 바로 실패하지 않는 것으로 보아 외부 API 명세에 지정된 에러 처리와 별도로 타임아웃 처리도 필요합니다. 긴 시간 응답을 받지 못하고 대기하는 것보다 처리하여 다음 작업을 수행하는 것이 더욱 효율적일 것 같습니다. 제 경우에만 해당되는 것이 아닌, 다른 외부 API 호출 시에도 고려할 사항입니다.
아래에서 소개할 비동기/병렬 처리를 도입하면 성능을 더 개선할 수 있을 것이라고 생각했지만, 안정성이 많이 떨어졌습니다. TPS가 제한된 상황에서는 그 상황에 맞추어 안정적으로 운영될 수 있도록 하는 것이 중요한 것 같습니다.
3. 비동기와 병렬 처리
사실 직전의 문제를 살펴보며 더 이상의 성능 개선을 하면 에러가 훨씬 많이 발생할 것이라고 생각했고, 실제로 안하느니만 못 한 결과를 맞이했습니다. 그래도 학습하는 차원에서 알아본 내용들을 정리하고 소개하면 좋을 것 같아서 추가로 작성했습니다!
청크 단위로 작업을 처리하게 되면 병렬 처리를 도입하기 수월합니다. OPEN API 서버의 TPS는 바꿀 수 없지만, 호출에 텀을 주었기 때문입니다. 물론 데이터 수가 적고 TPS가 제한되어 있는 상황에서는 크게 기대할 정도는 아니지만 청크를 잘 끊고 쓰레드 개수를 조절한다면 조금이나마 개선할 수 있게 됩니다.

이런 식으로 외부 API 호출 속도에 구애를 덜 받으며 작업을 구성할 수 있습니다.
public void testAsyncStep() throws IOException {
ExecutorService executor = Executors.newFixedThreadPool(3); // 쓰레드 3개 고정
// ...
List<CompletableFuture<Void>> futures = new ArrayList<>();
for (int step = 1; step <= totalSteps; step++) {
final int currentStep = step;
CompletableFuture<Void> future = CompletableFuture.supplyAsync(() -> {
try {
return openApiCaller.callExhibitionListApi(fromDate, toDate, currentStep, rows);
} catch (Exception e) {
return null;
}
}, executor);
futures.add(future);
}
// 비동기 작업 완료 대기
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
executor.shutdown();
}
스트림을 사용하여 병렬 작업을 진행할 수도 있었지만, 저는 외부 API를 호출하는 것이기 때문에 이를 기다리는 시간 역시 꽤나 길어질 수 있습니다. 따라서 작업을 동기적으로 진행하면 큰 의미가 없었고, 외부 API의 응답을 기다리지 않고 다음 작업을 진행할 수 있도록 비동기 처리를 했습니다.
즉, 위 코드처럼 ExcutorService로 쓰레드를 할당하고, CompletableFuture를 함께 사용하여 비동기 병렬 처리를 할 수 있습니다.

6개의 step을 3개의 쓰레드로 제한해놓고 실행시켜보면 쓰레드 1, 2, 3으로 나누어 작업을 처리하는 것을 확인할 수 있습니다.
큰 제목으로 분류하여 따로 다루지는 않았지만, 중간에 언급한 것들도 함께 다루면 좋습니다. 특히, 사용자가 외부 API를 호출하는 기능을 제공하는 서비스라면 더욱 고려할 것이 많아집니다. 재시도, 타임아웃, 처리율 제한, 요청 TPS 조절, 서킷브레이커 도입 등.. 더 나은 서비스 경험을 제공하기 위해 생각해보면 좋은 요소들이 많습니다.
무작정 비동기 병렬로 나누어 처리하면 속도가 빨라질 것이라고 생각했습니다. 하지만, 주어진 상황을 잘 살펴보고, 제한 사항이 있다면, 그 속에서의 최선의 선택을 해야 합니다. 좋아보인다고 무작정 도입하는 것이 아니라 주어진 상황에 맞게 선택할 수 있어야 하며, 때론 개선의 방향을 조금 바꾸어 생각해 보는 것도 도움이 될 것 같습니다.
[참고] :
'Project' 카테고리의 다른 글
| 회원과 비회원을 고려한 복합 조회수 카운팅 정책 (0) | 2025.03.20 |
|---|---|
| Bulk 연산으로 Write 작업 개선하기 (0) | 2025.03.13 |
| 배치 프로세스 리팩토링 (0) | 2025.03.03 |
| Swagger 코드 분리, Http Status (0) | 2025.02.28 |
| 효과적인 파일 업로드 방법 (0) | 2025.01.19 |