본문 바로가기
[프로젝트 일지]

트랜잭션과 병렬처리에 대한 고찰

by 프롯 2025. 10. 23.

이번 글에서는 프로젝트 진행 도중에 생겼던 궁금한 점을 해결하는 과정을 공유해보려고 합니다. 트랜젝션과 병렬처리의 트레이드 오프에 대한 내용인데요, 개인적으로는 맥락의 흐름이 아주 흥미로웠던 사례인것 같아 한번 함께 생각의 흐름을 따라서 읽어주시면 감사할것 같습니다.

 

평소와 같이 코딩을 하고 있었습니다. 저는 평소에 메서드 작성 후 AI에게 지금 내가 작성한게 책임분리는 잘 되어있는지, 그리고 성능상 병목이 의심되는 취약점, 또는 성능 개선의 여지가 있는지 물어보곤 합니다. 그날도 평소처럼 코드를 작성하고 여기서 병목이 생기거나 더 효율적으로 개선할 수 있는 부분이 있으면 알려달라고 질문했죠.

 

private PageCreationContextDto buildGenerationContext(Long storyId) {
    Story story = storyRepository.getById(storyId);
    Summary summary = summaryRepository.getByStory(story);
    List<Persona> personas = personaRepository.getAllByStoryId(storyId);

    return PageCreationContextDto.fromDomain(
        story, summary, personas, TOTAL_PAGE_COUNT
    );
}

 

그 당시 질문했던 메서드였는데요. 외부 API에 (ChatGPT API) 요청을 보내기 위해서 사전 맥락 데이터를 DB로부터 불러와 dto를 생성하여 반환하는 메서드였습니다. AI는 해당 메서드는 잘 작성되어 있지만, 여기다가 @Transactional(readOnly = true)를 붙이면 완벽해질 것이라고 답변했습니다. DB에 접근하는 메서드가 3개가 연달아 있으니 데이터 일관성과 성능을 위해서 하나의 단위로 묶는게 좋을것 같다는 말이었죠. 물론 더티체킹 (dirty-checking) 이슈도 있었구요.

 

트랜잭션은 이미 상위 메서드에 걸려 있었기 때문에 크게 도움이 되는 조언은 아니었습니다만, 한가지 걸리는 점이 있었습니다. 메서드를 보면 DB로부터 데이터를 3차례에 걸쳐서 불러오고 있죠. 그런데 이걸 하나로 묶는게 성능에 더 유리하다는 점이 이해가 가지 않았습니다.

 

제가 배웠던 컴퓨터 공학에서는 컴퓨터의 자원은 한정되어 있기에 그 안에서 최대한 많은 요청을 처리해야 하고, 그렇다 보니 긴 하나의 요청을 처리하는 것보다는 여러개의 짧은 요청을 처리하는 것이 더 높은 트레이드 오프를 가진다고 알고 있었죠. 특히 이 경우에는 DB에 요청을 보내는건데, 일반적으로 DB에 접근하기 위한 커넥션 풀 사이즈는 10개로 한정되어 있습니다. 그야말로 한정되어 있는 자원이라고 할 수 있죠. 이 중에 하나를 긴 시간동안 점유한다는건 시스템 운영 관점에서 좋은 선택은 아닌것 같았습니다.

 

제가 특히 이 문제에 민감하게 반응했던건 서비스 내에 외부 API를 많이 사용했기 때문입니다. 외부 API는 하나같이 많은 응답 시간을 요구했고, 이를 기다리기 위해서는 스프링부트의 워커스레드 라는 자원을 점유해야 했죠. 하나의 스레드를 하나의 요청이 오래 붙잡고 있는 것은 오랜 경험으로 피해야 한다는 것을 깨달았고 가능하면 최대한 병렬처리를 할 수 있도록 하였습니다. 여러개의 스레드를 사용하더라도 더 빠르게 스레드를 반환하고 다른 사용자에게 넘길 수 있도록 하는것이 더 나은 선택이었죠. 스레드는 한정된 자원이었으니까요.

 

물론 데이터의 양이 많지 않아 지금은 그 효과가 크지 않겠지만, 나중의 병목을 생각하면 DB에서 데이터를 가져오는 3개의 요청을 각각 다른 트랜젝션으로 쪼개어 커넥션 풀을 짧은 시간동안만 점유하도록 하는게 더 많은 사용자를 위한 선택 아닐까 라는 생각이 들었죠.

 

 

FIFO 알고리즘

 

RR 알고리즘

 

운영체제의 스케줄러에 대해 배운 분들은 위 그림이 무슨 뜻인지 이해 되실텐데요. 위 사진은 FIFO 알고리즘으로, 동시에 작업 요청이 들어왔을 때 하나의 작업이 끝난 다음에 그 다음 요청을 처리하는 First-In, First-Out 으로 처리합니다. 아래는 RR 알고리즘으로, 동시에 작업 요청이 들어왔을 때 일정 시간 (단위 시간만큼, time slice) 동안 작업을 진행하고, 바로 다음 작업으로 넘어가며 돌아가면서, 번갈아가면서 작업을 처리하게 됩니다. 원을 돌듯이 처리한다고 하여 Round-Robin 알고리즘이라고 부르죠.

 

그래프 오른쪽에 보이는 T는 average response라고, 사용자가 응답을 받기까지의 평균 시간을 뜻합니다. 해당 요청에 대한 완성된 응답이 아니더라도, 무언가 사용자에게 보여줄 수 있는 평균 시간이라고 생각하면 편한데, 이 부분에서는 RR이 압도적으로 유리한것을 확인할 수 있죠. FIFO와 RR 둘다 작업이 완전히 끝나는 시간은 동일함에도 불구하구요. 그래서 보통 UX를 생각하면 RR이 유리하다고 판단하는게 일반적입니다.

 

 

 

이제 우리의 논의로 돌아와보죠. 위에서 보여드렸던 메서드를 하나의 트랜잭션으로 묶는건 마치 FIFO 알고리즘 처럼 보입니다. 해당 요청이 끝나고 나서야 커넥션 풀을 반환하고 다른 요청을 받을 수 있으니까요. 반면 각 요청을 별개의 트랜젝션으로 묶는 것은 RR과 비슷해 보입니다. 하나의 요청은 당연히 3개의 요청보다는 짧은 시간동안 풀을 점유할 거고, 그럼 그동안 다른 요청을 수행할 수 있을 테니까요. 이 관점에서 생각하면, 트랜젝션을 분리하는것이 UX에서 더 이득을 얻을 수 있지 않을까요?

 

 

여기까지 AI에게 설명하며, 너는 틀렸고 내가 맞다라는걸 설득하려고 했으나 제가 놓친 부분이 있었습니다. 바로 RR에는 컨텍스트 스위칭 비용이 있다는 점이었죠. CPU가 하나의 작업을 하다가 다른 작업을 시작하기 위해선, 작업을 전환하기 위한 리소스 소모가 발생합니다. 이때 발생하는 CPU 오버헤드를 컨텍스트 스위칭 (context switching) 이라고 하는데, 저희의 논의에서도 이런 컨텍스트 스위칭이 존재했죠. 바로 네트워크 지연 시간(Latency) 입니다.

 

스프링부트와 데이터베이스는 별개의 서버에서 운영됩니다. 간혹 뜻이 통용되기 때문에 헷갈릴 수 있지만, 이 둘은 엄연히 다른 인스턴스에서 운영되죠. 따라서 서버와 데이터베이스가 통신하기 위해서는 네트워크를 통해야 하고, 이 시간을 줄이기 위해 DB 커넥션 풀을 활용해 미리 연결하는 방식을 사용하여 최적화하려고 하지만, 그럼에도 데이터베이스 통신은 비용이 많이 드는 작업입니다.

 

저희가 DB로부터 필요로 하는 데이터는 복잡하지 않은, 아주 간단한 데이터입니다. 데이터를 조회하는 시간이 매우 짧다는 뜻이고, 이는 후에 서비스 규모가 커지더라도 변하지 않을 것입니다. 심지어 데이터를 ID로 불러오는데, 여기에는 인덱스가 적용되어 있어서 더더욱 많은 시간이 걸리지 않죠. 하지만 DB로 요청을 보내고 데이터를 가져오는 왕복 시간, 다시말해 네트워크 지연시간은 상당히 부담이 되는 시간입니다. 

 

이런 네트워크 지연시간을 고려하면 저희가 선택할 수 있는 트레이드 오프는 다음과 같습니다. DB 왕복 횟수를 늘리는 대신 각 요청의 응답 평균 시간을 줄일것인지 (네트워크 지연시간 비용 포기), 또는 DB 왕복횟수를 줄이고 한번에 긴 요청을 보낼지 (단일 요청 응답시간 비용 포기)를 선택해야죠. 그런데 언급했던 것처럼, 저희가 필요한 데이터는 정말 짧은 시간안에 조회가 가능한 데이터입니다. 규모가 커지더라도 이는 변하지 않을겁니다. 따라서 이 경우에는 데이터 조회에 필요한 비용보다 DB 왕복 비용이 더 크다고 볼 수 있겠죠. 결국 여기서는 트랜잭션으로 묶어서 DB 왕복 비용을 줄이는게 더 좋은 트레이드 오프를 가질 수 있다고 할 수 있을것 같습니다.

 

저희의 논의는 외부 API와 연동할 때와는 완전히 다른 경우라고 볼 수 있죠. 외부 API, 특히 ChatGPT API는 경우가 다른게 한번 요청을 하려고 하면 20~30초 정도 기다려야 응답이 옵니다. 고작 ms 단위로 떨어지는 DB 왕복 비용에 비할 바가 아니기에 요청을 쪼갤 수 있는 경우 반드시 쪼개는 것이 유리하죠.

 

DB로부터 대량의 데이터를 조회하는 경우도 마찬가지입니다. 수백만건의 데이터가 있고, 수십개의 테이블을 조인하여 조회해야 하는 경우 하나의 트랜젝션으로 묶는것은 권장되지 않습니다. 메모리에 부담도 되고, 실패 시 롤백 비용도 무시할 수 없기 때문이죠.

 

수학 좋아하시나요?

 

여기까지 왔을 떄 제 생각이 틀렸다는 것은 깨달았지만, 한가지 궁금증이 더 생겼습니다. 이 논의에서는 고려해야 하는 변수가 굉장히 명확하지 않나요? DB 왕복 횟수, 왕복에 걸리는 시간 (네트워크 지연시간), 데이터 조회 시간만 고려하면, 각 시간에 따라 어느게 더 이득일지 고려하는 공식을 세울 수 있을거 같아요. 

 

몇 가지 변수를 정의해 보겠습니다.

  • L: 네트워크 왕복 시간 (Latency, 예: 5ms)
  • D: DB 쿼리 시간 (DB Time, 예: 10ms)
  • n: 총 쿼리 횟수 (예: 3회)
  • C_total: 트랜잭션 내에서 커넥션을 점유한 채 대기하는 '순수 앱 로직 시간' (쿼리와 쿼리 사이의 비즈니스 로직, 또는 API 대기 시간)

 

A. 트랜잭션으로 묶는 경우 (FIFO)

  • 최초 1회 왕복(L) + 모든 DB 쿼리 시간(D_total) + 모든 앱 로직 시간(C_total)
  • 커넥션 점유 시간 = L + D_{total} + C_{total}

B. 트랜잭션을 쪼개는 경우 (RR)

  • 앱 로직(C_total) 중에는 커넥션을 반납합니다.
  • 대신, 쿼리 횟수(n)만큼 네트워크 왕복(L)을 해야 합니다.
  • 커넥션 점유 시간 = (n * L) + D_{total}

 

그렇다면 언제 트랜젝션을 쪼개는게 이득일까요? B의 점유 시간 < A의 점유 시간 인 상황을 찾아야 하는데요.

 

(n * L) + D_{total} < L + D_{total} + C_{total}

 

양변의 D_{total}을 소거하고 L을 넘기면, 다음과 같은 간단한 식이 나옵니다.

 

(n - 1) * L < C_{total}

 

(n - 1) * L 은 트랜잭션을 묶음으로써 절약하는 네트워크 비용입니다. C_{total} 은 트랜잭션이 커넥션을 붙잡고 대기하는 비용(주로 API 대기 시간)입니다. 즉, 커넥션을 붙잡고 대기하는 비용이 네트워크 왕복을 아끼는 비용보다 커지는 순간, 트랜잭션을 쪼개는 것이 트레이드 오프에서 우위를 차지한다고 볼 수 있겠죠

 

현재 코드 (DB 조회 3번)

  • C_{total} = approx 0 (API 대기 없음)
  • (3 - 1) * 5ms < 0 ->  거짓. 이 경우에는 하나의 트랜잭션으로 묶어야 합니다.

외부 API 호출 (ChatGPT)

  • C_{total} = approx 20,000ms (20초 대기)
  • (2 - 1) * 5ms < 20000ms ->  . 이 경우에는 분리하는게 더 좋습니다.

 

지금까지 제 사소한 의문점에서 시작된 에피소드를 소개드렸습니다. 마지막에 되게 멋있는 척 수학적인 식으로 전개하는 모습을 보여드렸습니다만, 사실 저건 아이디어만 떠올리고 실제 수식 전개는 AI의 도움을 받았습니다. 실무에서 성능을 고려해야 할때, 데이터를 통한 의사결정을 어떻게 해야 할지 간단하게 알아볼 수 있었던 에피소드였던 것 같습니다.

 

다들 읽어주셔서 감사합니다 d^^b