필요할 때마다 하나씩 가져오는 대신, 상황에 따라 관련 데이터를 한 번에 메모리로 가져와 처리하는 것이 성능상 더 유리할 수 있다는 점을 배치 처리 사례를 통해 설명한다.
2026년 2월 15일·7분·1381단어·João Antunes
목차
많은 사람에게는 당연할 수도 있지만, 그렇지 않은 사람도 꽤 있어서 한 번 이야기해볼 만한 주제로 짧게 글을 써본다. 때로는 필요할 때마다 하나씩 가져오는 대신, “모든” 데이터를 한 번에 메모리로 벌크 로딩해서 작업하는 쪽이(성능 관점에서) 더 나은 경우가 있다.
물론 이는 상황에 매우 의존적이며, 여기서 “모든”이 무엇을 의미하는지도 맥락에 따라 달라진다. 이 글을 읽다 보면 그 부분도 다루게 될 것이다 🙂.
이 글에는 코드가 나오진 않지만, 코드를 떠올리면서 이야기해보려 한다.
수십만 개의 항목을 처리하는 배치 프로세스가 있다고 가정해보자. 이 항목들은 이미 데이터베이스에 존재하므로, 현재 데이터를 확인하고 로직을 적용한 다음, 그에 맞게 데이터베이스를 업데이트해야 한다.
가장 눈에 띄는(그리고 아마 가장 흔한) 접근을 따른다면, 배치 항목을 foreach로 돌면서 각 항목에 해당하는 데이터를 데이터베이스에 질의하고, 로직을 적용하고, 마지막으로 변경사항을 저장(persist)할 것이다.
눈에 보기엔 당연하고, 하나씩 처리한다고 해서 엄청 느릴 것 같지도 않지만, 자세히 뜯어보면 사실 최악이다. 예를 들어 각 항목을 이런 방식으로 처리하는 데 100ms가 걸린다고 해보자. 배치가 100k라면, 대략 3시간이 조금 안 된다. 여기에 더 복잡한 로직을 넣어서 I/O가 더 늘어나거나, 단순히 배치 크기만 더 커져도, “사소하게 처리되어야” 할 작업이 몇 시간씩 걸리는 상황이 된다.
위 예시는 사실 실무에서 있었던 사례다(글을 위해 단순화했을 뿐). 몇 년 전 회사에서 수십만 개의 항목이 들어있는 텍스트 파일을 처리해야 하는 시나리오가 있었는데, 어떤 때는 실행하는 데 거의 하루가 걸리기도 했다 🙃.
그럼 우리는 큰 노력 없이 이 문제를 어떻게 해결했을까?
다만 다시 강조하자면, 이건 굉장히 상황 의존적이니, 모든 문제에 그대로 적용된다고 생각하면 안 된다.
우리는 매일 수천 개의 행이 생성되는 시스템을 다루고 있었다. 그래서 앞에서 “모든” 데이터를 로드한다고 말할 때 따옴표를 붙인 건 우연이 아니다. 실제로는 모든 데이터를 가져오는 게 아니라, 엄밀히 필요한 것보다는 훨씬 많은 데이터를 가져오는 것이다. 무엇이 정확히 필요한지(시나리오에 따라) 알아내기 쉽지 않기 때문에, 우리는 어떤 데이터를 로드할지 결정하기 위해 휴리스틱을 사용했다.
이 경우엔 운이 좋게도 배치 파일에 날짜가 포함되어 있었다. 그래서 날짜들을 뽑아 일(day) 단위로 그룹화한 뒤, 항목별로 반복하는 대신 날짜별로 반복했다. 그리고 각 날짜에 대해 관련된 데이터를 전부 메모리로 로드했다. 즉, 수십만 번 데이터베이스에 접근하는 대신, 처리해야 하는 날짜당 몇 번 정도만 접근하게 만든 것이다.
이렇게 크게 복잡하지 않은 변경을 한 뒤, 거의 24시간에 가깝던 실행 시간이 약 15분으로 줄었다. 더 줄일 수도 있겠지만, 해야 할 일은 늘 더 있고, 제품 관점에서는 이 정도 개선이면 충분히 만족스러웠다(물론 엔지니어 관점에서는 몇 초 단위로 줄이고 싶긴 하지만 😅).
이 글 전체는 비교적 큰(엄청 큰 건 아니지만, 일반적인 웹 개발 플로우를 생각하면 큰 편이라고 할 수 있는) 배치 처리 예시에 초점을 맞추고 있다. 하지만 이 내용은 다른 유스케이스로도 쉽게 일반화할 수 있다.
수다스러운(chatty) I/O는 애플리케이션 성능을 망치는 가장 쉬운 방법 중 하나이며, 앞에서 봤듯이 배칭(batching)은 보통 이를 막는 훌륭한 수단이다.
먼저, 이 글은 배치 처리에 집중했지만, HTTP 요청을 처리하는 것 같은 시나리오에도(다른 방식으로) 같은 아이디어가 적용된다. 대부분의 경우 데이터베이스로의 라운드 트립 횟수를 줄이고 싶다. 이것이 ORMs를 지연 로딩(lazy loading)으로 설정해 두는 것이 끔찍한 이유 중 하나다. 개발자가 인지하지 못하는 사이에 백그라운드에서 쿼리가 실행될 수 있다. 그러다 프로덕션에서 “왜 이 엔드포인트가 느리지?”라는 질문이 나오고, 결국 HTTP 요청 하나 처리하는 데만 쿼리가 50개나 수행되고 있었다는 걸 알아내게 된다.
데이터베이스 접근 외에도, 다음과 같은 영역에서 유사한 문제가 발생할 수 있다:
마무리하기 전에, 주제와 관련해서 중요하다고 생각하는 짧은 포인트 몇 가지를 던지고 싶다. 다만 자세히 파고들진 않겠다.
앞에서 설명한 문제 같은 경우라면, 작업을 병렬화하는 것이 아마 사람들이 가장 먼저 떠올리는 아이디어 중 하나일 것이다.
병렬화는 실행 시간을 줄여주긴 하지만, 배칭만큼 임팩트가 크진 않을 가능성이 높다. 이유는 크게 두 가지다:
작업 성능을 개선하기 위한 또 다른 전략은 애플리케이션 서버에 있던 로직을 데이터베이스 서버로 옮기는 것이다.
나는 이 접근을 선호하진 않지만, 두 서버 사이를 오가는 데이터 양을 줄일 수 있다는 장점은 있다.
하지만 이것만으로는 배칭만큼의 효과를 기대하기 어렵다. 디스크 I/O는 여전히 있고, 적어도 우리의 시나리오에서는 많은 단일 데이터 포인트 쿼리를 던지는 것보다 한 번의 쿼리로 더 많은 데이터를 가져오는 편이 더 효율적이기 때문이다.
이런 문제를 논의할 때 자주 보게 되는 한 가지는, 사람들이 규모를 과대평가한다는 점이다.
앞에서 언급한 ‘수십만 개 항목이 들어있는 파일’을 기억하는가? 크기는 수십 MB 초반 수준이다 🤷♂️. 물론 이를 메모리의 자료구조로 로드하면 내가 어깨를 으쓱한 것보다 영향이 더 크겠지만, 그래도 엄청난 하드웨어를 들이붓지 않고도 쉽게 다룰 수 있는 작은 규모다.
그렇다고 해서 내가 끔찍한 설계를 장려하려는 건 아니다. 예전에 데이터베이스에서 해당 페이지 데이터만 가져오는 대신, 전체를 가져온 후 메모리에서 페이징하는 방식으로 페이지네이션 API 엔드포인트를 구현하는 사례를 본 적이 있다. 내가 장려하고 싶은 건 그런 게 아니라, 사람들이 당면한 문제를 철저히 이해한 다음 가능한 해결책들과 트레이드오프를 평가하는 것이다.
또 하나 언급하고 싶은 중요한 포인트는, 이런 문제를 다룰 때 사용하는 도구를 정말 잘 이해하는 것이 중요하다는 점이다.
내가 여기서 언급한 유스케이스와 관련된 일이었는지, 다른 사례였는지는 기억이 확실하진 않지만, 비슷한 작업을 하면서 ORM(Entity Framework Core)을 사용했던 한 시나리오가 있었다. 그리고 그 ORM은 약간의… “재미”를 선사했다.
(잠깐, 이런 종류의 배치 처리에 ORM을 쓰는 게 정말 좋은 생각인지에 대해서는 일단 넘어가자)
EF Core는 변경사항을 쉽게 저장할 수 있도록 체인지 트래커(change tracker)를 제공할 뿐 아니라, 설정하면 지연 로딩 기능도 제공한다. 이 지연 로딩은 프록시 객체로 구현된다. 이 두 기능이 결합될 때, 개발자가 이를 제대로 이해하지 못하면 메모리 관리 측면에서 큰 골칫거리가 될 수 있다. 사람들은 자신이 메모리에 로드한 데이터만 생각하다가, ORM이 기능 지원을 위해 추가로 구성한 데이터 구조들을 잊어버리기 쉽다.
(우리의 경우 EF 버전이 올라가면서 지연 로딩 프록시의 동작 방식이 바뀐 것도 도움이 되지 않았지만, 애초에 설계가 잘못되었기도 했다)
이 글은 여기까지다. 나에게는 꽤 자명한 이야기처럼 느껴지지만, 여러 사람과 이야기해보면 그들에게는 그렇지 않은 경우가 많아서 공유해볼 만하다고 생각했다. 많은 사람들이 ‘요청을 받는다 -> DB에 쿼리한다 -> 응답한다’라는 흔한 흐름에 어느 정도 갇혀 있어서, 조금만 다른 상황이 나오면 똑같은 패턴을 그대로 적용해 이런 문제가 생기곤 한다.
여기서는 한 가지 예시를 들었지만, 이런 예시는 훨씬 더 많고, 이런 상황을 다루는 방법도 다양하다. 그러니 이런 문제에 접근할 때 그 점을 염두에 두자.
들러줘서 고맙다, 다음에 또 보자!
© 2026 Coding Militia · Powered by Hugo&PaperMod