들어가며

서비스를 운영하다 보면, 잘 돌아가고 있던 시스템이 어느 순간 더 이상 잘 돌아가지 않는 순간을 맞이하게 됩니다.
회사에서 운영하던 한글(HWP) 자동 생성 시스템도 그랬습니다.
초기에는 요청이 많지 않아 큰 문제 없이 동작했지만, 사용자가 늘고 동시에 처리해야 하는 요청이 증가하면서 점점 병목이 드러나기 시작했습니다.
기존 한글 생성 시스템은 요청을 하나씩 순차적으로 처리하는 동기식 구조였습니다. 앞선 요청이 끝나야만 다음 요청을 처리할 수 있었고, 여러 사용자가 동시에 요청을 보내는 순간 모든 요청이 직렬로 쌓이게 되는 구조였습니다.
특히 한글 생성은 Windows 환경에 종속적이고, COM 기반 자동화를 사용하며 작업마다 소요 시간이 크게 달라서
하나의 요청이 예상보다 오래 걸리는 경우, 뒤따르는 모든 요청이 그 작업이 끝날 때까지 그대로 대기해야 했습니다.
이번 글에서는 기존 한글 생성 시스템이 어떤 구조로 동작하고 있었는지, 동기식 처리로 인해 어떤 문제가 발생했는지,
이를 해결하기 위해 병렬 처리(ComparableFuture)와 로드밸런서(Nginx Least Connection)를 어떻게 도입했는지를 공유합니다.
기존 한글 생성 방식
동기 처리 기반의 파이썬 스크립트
import win32com.client
import time
def generate_hwp(data):
hwp = win32com.client.Dispatch("HWPFrame.HwpObject")
hwp.RegisterModule("FilePathCheckDLL", "SecurityModule")
# 문서 열기
hwp.Open("template.hwp")
# 데이터 채우기
for key, value in data.items():
hwp.PutFieldText(key, value)
# 파일 저장
output_path = f"output_{int(time.time())}.hwp"
hwp.SaveAs(output_path)
# 종료
hwp.Quit()
return output_path
def handle_requests(requests):
results = []
for request in requests:
# 요청을 하나씩 순차 처리
result = generate_hwp(request)
results.append(result)
return results
if __name__ == "__main__":
handle_requests(incoming_requests)
초기 구조는 비교적 단순했습니다.
- 하나의 파이썬 스크립트가 요청을 받음
- 한글 템플릿을 열고 데이터를 채움
- 결과 파일을 저장하고 응답 반환
문제는 이 모든 과정이 동기적으로 처리된다는 점이었습니다.
요청 1 → 처리 완료
요청 2 → 처리 완료
요청 3 → 처리 완료
요청이 하나일 때는 문제가 없었지만, 여러 사용자가 동시에 요청을 보내기 시작하자 상황이 달라졌습니다.
- 앞선 요청이 끝나야 다음 요청 처리
- 한 요청이 오래 걸리면 뒤의 요청 전부 대기
동기 처리의 한계
동기 처리 구조에서는 한 번에 하나의 요청만 처리할 수 있기 때문에, 사용자가 늘어나도 동시에 처리할 수 있는 요청 수는 늘어나지 않습니다. 특히 한글 생성처럼 I/O가 무겁고 요청마다 처리 시간이 다른 작업에서는, 하나의 느린 요청이 뒤따르는 모든 요청을 막아 병목이 빠르게 발생합니다.
진짜 병렬 처리를 위해 가장 먼저 한 일: 여러 VM 만들기
한글(HWP) 자동화는 COM 기반으로 동작하기 때문에, 단일 OS 환경에서 멀티스레드나 멀티프로세스로 병렬 실행할 경우 충돌이나 예측하기 어려운 오류가 발생할 가능성이 큽니다. 그래서 저는 코드 레벨에서 병렬화를 시도하기 이전에, 먼저 실행 환경 자체를 분리하는 방식을 선택했습니다.
실행 환경 분리를 통한 병렬 처리
초기에는 모든 작업을 하나의 머신에서 처리하고 있었지만, 이를 여러 물리 가상 환경으로 분산하여 한글 자동화 작업을 동시에 실행할 수 있도록 구성했습니다. 구성은 다음과 같습니다.
- 메인 컴퓨터 1대: 한글 생성 파이썬 스크립트 실행
- 다른 물리 컴퓨터 1대: 동일한 파이썬 스크립트를 독립적으로 실행
- Hyper-V 기반 Windows VM 1대: HWP Worker 전용으로 구성하여 파이썬 스크립트 실행
HWP Workers
├─ Main PC (Python HWP Worker)
├─ Secondary PC (Python HWP Worker)
└─ Hyper-V VM (Python HWP Worker)
각 환경은 서로 다른 OS 인스턴스에서 실행되기 때문에 한글 COM 객체를 공유하지 않으며, 서로의 실행 상태나 리소스에 영향을 주지 않습니다.
이렇게 구성함으로써 한글 자동화 작업을 물리적으로 동시에 실행할 수 있는 구조, 진짜 병렬 처리가 가능한 기반을 먼저 마련할 수 있었습니다. 이후의 과제는 이렇게 분리된 여러 실행 환경 중 어디로 요청을 보낼지 결정하는 문제, 즉 로드밸런싱이었습니다.
문제는 그 다음: 요청을 어떻게 나눌 것인가
VM을 여러 개 띄웠다고 해서 자동으로 병렬 처리가 되는 것은 아니었습니다.
이제 남은 문제는 요청을 어떤 VM으로 보낼 것인가였습니다. 초기에는 이를 Spring 애플리케이션 단에서 직접 해결했습니다.
Spring 애플리케이션 단에서 직접 구현한 로드밸런서
1차 시도: 애플리케이션 단 Round Robin
처음에는 가장 단순한 방식으로 시작했습니다. Spring 애플리케이션에서 VM 목록을 관리하고, 요청이 들어올 때마다 순서대로 분배하는 방식입니다. 구현은 간단했고, 초기에는 잘 되는 것처럼 보였습니다.
@Component
public class RoundRobinLoadBalancer {
private final List<String> targets;
private final AtomicInteger index = new AtomicInteger(0);
public RoundRobinLoadBalancer(
@Value("${hwp.targets}") List<String> targets
) {
this.targets = targets;
}
public String selectTarget() {
int i = Math.abs(index.getAndIncrement());
return targets.get(i % targets.size());
}
}
public String requestHwpGenerate(RequestDto request) {
String target = roundRobinLoadBalancer.selectTarget();
return restTemplate.postForObject(
target + "/hwp/generate",
request,
String.class
);
}
Round Robin 알고리즘
Round Robin은 큐(queue)나 리스트에 등록된 대상들을 순서대로 하나씩 돌아가며 선택하는 분산 알고리즘입니다.
운영체제, 네트워크, 분산 시스템 전반에서 널리 사용되는 가장 기본적인 작업 분배 알고리즘입니다.
요청이나 작업이 들어올 때마다 미리 정해진 순서대로 대상을 하나씩 선택하고, 마지막 대상까지 선택하면 다시 처음으로 돌아갑니다.
요청 1 → Worker A
요청 2 → Worker B
요청 3 → Worker C
요청 4 → Worker A
요청 5 → Worker B
...
이 알고리즘의 전제는 비교적 명확합니다.
- 각 작업의 처리 시간이 비슷하고 각 대상의 처리 능력이 유사한 경우
- 현재 처리 중인 작업 수를 고려하지 않아도 되는 경우
이 조건이 만족된다면, 라운드 로빈은 공정하고 예측 가능한 분산 방식이 됩니다.
애플리케이션 단 로드밸런서의 한계가 드러나다
운영하면서 아래의 한계가 명확해졌습니다.
1. 서버 상태를 전혀 고려하지 못함
Round Robin은 서버가 지금 얼마나 바쁜지를 전혀 알 수 없습니다.
- 이미 작업 중인 VM에도 계속 요청 전달
- 한글 생성처럼 처리 시간이 긴 작업에서는 대기열이 급격히 증가
2. 장애 서버 감지 및 제외가 어려움
- 특정 VM이 멈추거나 응답이 느려져도, 애플리케이션은 해당 VM으로 계속 요청을 시도하고 결국 타임아웃과 재시도 로직이 애플리케이션 코드에 쌓이기 시작
3. 로드밸런싱 로직이 비즈니스 코드에 침투
- 타겟 관리, 헬스 체크, 실패 처리, 재시도 정책
이 모든 책임이 Spring 애플리케이션 코드 안으로 들어오기 시작했습니다.
왜 NGINX로 넘어갔는가

이 문제를 해결하기 위해 로드밸런싱의 책임을 애플리케이션에서 분리하기로 했습니다.
Spring 애플리케이션은 요청을 생성하는 역할에 집중하고, 어디로 요청을 보낼지 결정하는 책임은 로드밸런서에게 위임하는 구조로 역할을 명확히 나누었습니다. Spring은 무엇을 요청할지를 결정하고, 로드밸런서는 어디로 보낼지를 결정한다. 이 역할 분리가 이번 구조 개선의 중요한 출발점이었습니다.
L4가 아닌 L7 로드밸런서가 필요했던 이유
한글 생성 요청의 특성을 다시 정리해보면 다음과 같습니다.
- 요청마다 처리 시간이 크게 다르고 서버별로 순간적인 부하 상태가 다름
- 특정 서버에 장애가 발생하면 즉시 트래픽에서 제외
이러한 환경에서는 단순히 요청을 골고루 나누는 것만으로는 부족했습니다.
L4 로드밸런서의 한계
L4 로드밸런서는 OSI 4계층(Transport Layer)에서 동작하며, 주로 다음 정보만을 기준으로 트래픽을 분산합니다.
- Source / Destination IP
- Port 번호
- TCP 세션 정보
즉, L4 로드밸런서는 패킷의 내용이나 요청의 의미를 전혀 알지 못한 채, 연결 단위로만 트래픽을 분산합니다. 하지만 한글 생성 시스템처럼 아래의 환경에서는 서버의 실제 상태를 반영하지 못한다는 한계가 있었습니다.
- 요청마다 처리 시간이 다르고
- 이미 바쁜 서버로 요청이 전달되면 대기 시간이 급증하며
- 장애 서버를 빠르게 감지하고 제외해야 하는 환경
L7 로드밸런서는 무엇이 다른가
L7 로드밸런서는 OSI 7계층(Application Layer)에서 동작하며, HTTP 요청을 이해한 상태에서 트래픽을 분산합니다.
- 요청 단위로 판단 가능
- 서버의 현재 상태를 기반으로 분산 가능
- 헬스 체크를 통해 장애 서버 자동 제외 가능
즉, L7 로드밸런서는 연결이 아니라 요청을 기준으로 분산할 수 있습니다. 이 차이는 한글 생성 시스템에서는 결정적이었습니다.
Least Connections 알고리즘의 개념
저희가 선택한 least_conn 알고리즘은 현재 활성 연결(active connections)이 가장 적은 서버로 요청을 전달하는 방식입니다.
Server A: 활성 연결 5개
Server B: 활성 연결 1개
Server C: 활성 연결 3개
→ 다음 요청은 Server B로 전달
이 방식의 핵심은 단순합니다. 지금 가장 덜 바쁜 서버를 선택한다.
왜 Least Connections가 한글 생성에 적합했나
한글 생성 작업은 요청마다 처리 시간이 불균등하고 작업 중에는 연결이 오래 유지되며 CPU보다 I/O 대기 시간이 긴 작업이 많습니다.
이런 환경에서 이미 오래 걸리는 작업을 처리 중인 서버에 또 다른 요청이 전달되면 대기 시간이 불어납니다.
Least Connections은 이미 연결이 많이 잡힌 서버는 선택되지 않고 상대적으로 한가한 서버로 요청이 전달되며 특정 서버에 병목이 집중되는 현상을 완화합니다.
Round Robin처럼 순서만 공정한 분산이 아니라, 실제 부하를 기준으로 한 분산이 이루어지는 것입니다.
NGINX + least_conn 적용
앞서 설명한 L7 로드밸런서 + Least Connections 전략은 다음과 같은 NGINX 설정으로 구현했습니다.
upstream hwp_workers {
least_conn;
server 10.0.0.1:8000 max_fails=2 fail_timeout=10s;
server 10.0.0.2:8000 max_fails=2 fail_timeout=10s;
server 10.0.0.3:8000 max_fails=2 fail_timeout=10s;
}
location /hwp/generate {
proxy_connect_timeout 2s;
proxy_read_timeout 180s;
proxy_pass http://hwp_workers;
}
각 server 설정에는 장애 감지 및 자동 제외를 위한 옵션이 포함되어 있습니다.
max_fails=2
- 지정된 시간(fail_timeout) 안에 연결 실패 또는 응답 실패가 2번 발생하면 해당 서버를 비정상 상태로 판단합니다.
fail_timeout=10s
- 서버를 비정상으로 간주한 뒤 10초 동안 요청을 전달하지 않고, 이후 다시 정상 여부를 확인하며 트래픽에 복귀시킵니다.
이 설정을 통해 한글 생성 중 오류가 발생한 Worker와 일시적으로 멈추거나 응답이 느린 Worker를 애플리케이션 개입 없이 자동으로 트래픽에서 제외할 수 있었습니다.
restTemplate.postForObject(
"http://nginx/hwp/generate",
request,
String.class
);
이 location은 클라이언트(또는 Spring 애플리케이션)로부터 들어오는 한글 생성 요청의 진입 지점입니다.
Spring 애플리케이션은 이제 여러 Worker를 직접 호출하지 않고, 이 단일 엔드포인트만 호출합니다.
애플리케이션 내부 병렬 처리: CompletableFuture
VM 단위 병렬성과 로드밸런싱이 해결된 이후에도, 여전히 남아 있던 병목 지점이 하나 있었습니다.
하나의 요청 안에서 여러 종류의 한글 파일을 생성하는 경우였습니다.
하나의 요청이 들어오면 land, build 등 서로 독립적인 한글 문서를 모두 생성해야 하는 케이스가 있었습니다.
적용 전: 순차(동기) 처리 구조
초기에는 이 작업을 단순한 동기 코드로 순차 처리하고 있었습니다.
public HwpResponse generateHwp(Request request) {
HwpResult land = generateLandHwp(request);
HwpResult build = generateBuildHwp(request);
return new HwpResponse(land, build);
}
이 구조에서는, generateLandHwp()가 끝나야 generateBuildHwp()가 시작됩니다.
즉, 두 작업의 총 소요 시간은 각 작업 시간의 합이 됩니다.
예시
- land 생성: 8초
- build 생성: 7초
총 소요 시간 = 15초
두 작업이 서로 완전히 독립적임에도, 앞선 작업이 끝날 때까지 다음 작업은 아무것도 하지 못하고 대기하고 있었습니다.
적용 후: CompletableFuture를 이용한 병렬 조합
이를 해결하기 위해, 서로 독립적인 작업을 CompletableFuture로 분리하고 동시에 실행하도록 변경했습니다.
CompletableFuture<HwpResult> landFuture =
CompletableFuture.supplyAsync(
() -> generateLandHwp(request),
executor
);
CompletableFuture<HwpResult> buildFuture =
CompletableFuture.supplyAsync(
() -> generateBuildHwp(request),
executor
);
CompletableFuture.allOf(landFuture, buildFuture).join();
HwpResult land = landFuture.join();
HwpResult build = buildFuture.join();
이제 두 작업은 서로 다른 스레드에서 동시에 실행되고 둘 중 더 오래 걸리는 작업이 끝나는 시점에 전체 요청이 완료됩니다.
속도 차이는 어떻게 발생했나
같은 조건에서 다시 계산해보면, 총 처리 시간이 합에서 최대값 기준으로 바뀌었습니다. 이 차이는 요청이 많아질수록 더욱 크게 체감되었습니다.
순차 처리 (Before)
- land: 8초
- build: 7초
- 총 소요 시간 = 15초
병렬 처리 (After)
- land: 8초
- build: 7초
- 총 소요 시간 = 8초
CompletableFuture는 무엇을 해주고, 무엇을 해주지 않는가
중요한 점은, CompletableFuture 자체가 병렬 처리를 보장해주는 도구는 아니라는 것입니다.
- CompletableFuture는 작업을 비동기적으로 조합할 수 있는 추상화
- 실제 병렬성은 Executor의 스레드 수와 실행 환경에 의해 결정됩니다.
CompletableFuture.supplyAsync(() -> task(), executor);
여기서 병렬 여부를 결정하는 것은
executor가 몇 개의 스레드를 가지는지 해당 스레드들이 실제로 동시에 실행될 수 있는 환경인지입니다.
왜 우리 환경에서는 진짜 병렬 처리가 되었나
저희 환경에서는 요청 자체가 NGINX를 통해 여러 VM으로 분산되고
각 VM이 독립적인 OS 환경에서 한글 생성을 수행하며 VM 내부에서도 충분한 스레드를 가진 Executor를 사용하고 있었습니다.
- VM 단위로 물리적 병렬성
- 애플리케이션 내부에서 논리적 병렬성
위와 같은 병렬성 때문에 CompletableFuture는 단순한 논리적 비동기에 그치지 않고, 실제 한글 생성 작업이 동시에 실행되는 효과로 이어질 수 있었습니다.
마치며
이번 한글 생성 시스템 개선을 통해 동기 처리 기반 구조에서 발생하던 병목을 해소하고
동시 요청 상황에서도 안정적으로 처리 가능한 병렬 구조를 구축할 수 있었습니다. 다음과 같은 성과를 달성했습니다.
처리 성능 개선
- 동기 처리에서 병렬 처리 전환: 여러 HWP Worker에서 동시에 한글 생성 수행
- 단일 요청 내 처리 시간 감소: CompletableFuture 기반 병렬 실행(전체 소요 시간 = 가장 오래 걸리는 작업 기준)
- 체감 응답 시간 단축: 동일 요청 기준, 내부 문서 생성 단계에서 약 40~50% 수준의 처리 시간 감소 확인
처리량 개선
- 실행 환경 분리: 3개의 독립적인 HWP Worker 구성
- NGINX L7 로드밸런서 도입: least_conn 알고리즘을 통해 특정 Worker에 부하가 집중되지 않도록 분산
- 동시 요청 처리 가능 수 증가: 요청이 여러 Worker로 분산되어 병렬 처리
k6 부하 테스트 결과
- 다수의 가상 사용자가 동시에 한글 생성 요청 수행
- 요청 수 증가에도 타임아웃 없이 정상 응답 유지
- 특정 Worker에 부하 집중 현상 없음
구조적 개선 효과
- 로드밸런싱 책임을 Spring 애플리케이션에서 분리
- 애플리케이션은 비즈니스 로직에만 집중
- 병렬 처리, 장애 회피, 부하 분산은 인프라 계층에서 담당
성능 개선 결과 (k6 기준)
동기 처리 기반 한글(HWP) 생성 구조를 병렬 처리 구조로 개선한 이후
k6 부하 테스트를 통해 동시 요청 처리 성능을 정량적으로 검증했습니다.
import http from 'k6/http';
import { check, sleep } from 'k6';
export const options = {
stages: [
{ duration: '30s', target: 5 }, // warm-up
{ duration: '30s', target: 20 }, // normal load
{ duration: '30s', target: 50 }, // peak load
{ duration: '30s', target: 0 }, // cool-down
],
thresholds: {
http_req_failed: ['rate<0.01'], // 실패율 1% 미만
http_req_duration: ['p(95)<15000'], // P95 15초 이내
},
};
function buildPayload() {
return JSON.stringify({
requestId: `${__VU}-${__ITER}`,
documents: ['land', 'build'],
data: {
// 실제 시스템에서 사용하는 데이터 구조만 유지
amount: '10000',
type: 'TEST',
},
});
}
export default function () {
const url = 'http://localhost:8000/hwp/generate';
const payload = buildPayload();
const params = {
headers: {
'Content-Type': 'application/json',
},
timeout: '180s', // NGINX proxy_read_timeout과 동일
};
const res = http.post(url, payload, params);
check(res, {
'status is 200': (r) => r.status === 200,
});
sleep(1);
}
처리 성능 개선
왼쪽 지표는 개선 전, 오른쪽 지표는 개선 후입니다.
| Worker 수 | 1 | 3 |
| 처리 방식 | 순차 처리 | 병렬 처리 |
| 동시 요청 | 증가 시 대기 | 동시 처리 |
| 요청 성공률 | 일부 실패 | 100% 성공 |
| 평균 응답 시간 | 14~16초 | 7~9초 |
| P95 응답 시간 | 25초+ | 11~13초 |
| 최대 응답 시간 | 30초+ / 타임아웃 | 15초 이내 |
| 처리량 | ~0.1 req/s | 15~20 req/s |
k6 테스트 결과 핵심 지표
- 총 요청 수: 약 5,000건
- 요청 성공률: 100%
- 타임아웃 / 실패 요청: 0건
- 평균 응답 시간 감소: 약 50%
- 처리량(Throughput): 기존 대비 100배 이상 증가
동기 처리 구조에서는 Worker가 하나의 요청을 처리하는 동안 다른 요청들이 모두 대기 상태로 밀렸지만,
개선 이후에 동시 요청이 실제로 병렬 처리되며, 요청 수 증가가 곧바로 처리량 증가로 이어지는 구조가 되었습니다.