약 16분 읽기
100개가 넘으면 눈으로 볼 수 없다
RPA 운영에 있어서 가장 큰 도전 중 하나는 많은 수의 프로세스를 관리하는 것이다. 처음에는 몇 개 안 되는 프로세스만 다루면 되었으나, 시간이 지나면서 그 수가 급격히 늘어났다. 결국 100개가 넘는 프로세스를 동시에 운영하게 되었고, 이 때부터 오케스트레이터 대시보드를 통해 하나하나 상태를 확인하는 게 불가능해졌다. 여러 화면을 전환하며 로그를 살피는 일은 많은 시간을 소모시켰고, 단순한 오류를 놓치는 경우도 생겼다.
이런 문제를 해결하기 위해 CSV 내보내기 기능을 활용하기로 했다. 오케스트레이터의 대시보드에서 직접 눈으로 확인하기보다, 로그 데이터를 CSV로 내보내 파싱하는 쪽으로 방향을 잡았다. 이렇게 하면 한 눈에 모든 로그를 확인할 수 있고, 특정 패턴이나 오류를 검색하여 관리하는 것이 훨씬 수월해진다. CSV 파일을 통해 로그를 정리하면 프로세스들이 어떻게 작동하고 있는지 전체적인 그림도 더 쉽게 파악할 수 있었다.
하지만 CSV 내보내기도 처음에는 시행착오가 있었다. 데이터를 내보내는 과정에서 형식이 맞지 않거나, 필요한 정보가 누락되는 일이 생겼다. 여러 번 시도 끝에 필요한 정보만 깔끔하게 추출할 수 있는 방법을 찾았다. 이후로는 로그 분석이 훨씬 수월해졌고, 오류의 원인도 보다 정확하게 찾아낼 수 있었다. 100개가 넘는 프로세스를 관리하기 위해서는 더 이상 화면에서 눈으로 보는 방법이 아닌, 이런 자동화된 방법이 필수적이었다.
오류 메시지를 원인으로 묶기
운영 중 많은 시간을 소비하게 되는 부분 중 하나는 오류 메시지를 통한 원인 파악이었다. 다양한 형태로 메시지가 나타나는데, 실제로는 같은 문제로 인한 경우가 많았기 때문에 정규화를 통해 동일한 원인끼리 묶는 작업이 필요했다. 예를 들어 ‘Element not found’와 ‘Could not find the UI element’는 사실상 같은 오류였다. 하지만 이 메시지들이 각각 다른 형태로 기록되다 보니 처음에는 각각을 따로 분석해야 했고, 이는 상당히 손이 너무 많이 갔다.
이런 문제를 해결하기 위해 개인적인 스크립트를 작성하게 되었다. 각 오류 메시지를 정규식으로 파싱하여 공통된 원인으로 묶어주는 코드였다. 이렇게 해서 서로 다른 형태로 기록된 메시지들을 동일한 원인으로 인식할 수 있게 되었다. 코드 작성 당시에는 정규식을 어떻게 적용해야 할지, 그리고 유사한 메시지들을 어떻게 비교할 지에 대한 고민이 많았다. 결국 다양한 테스트를 통해 쓸 만한 방식을 찾았다.
import csv, re
from collections import Counter
from pathlib import Path
# 표현이 달라도 원인이 같은 것들을 하나로 모은다
NORMALIZE = [
(re.compile(r'(element not found|could not find the ui element'
r'|selector.*not found)', re.I), 'UI_ELEMENT_NOT_FOUND'),
(re.compile(r'(timeout|timed out|exceeded.*seconds)', re.I), 'TIMEOUT'),
(re.compile(r'(access.*denied|unauthorized|401|403)', re.I), 'AUTH'),
(re.compile(r'(file.*not found|could not find file'
r'|디렉터리를 찾을 수 없)', re.I), 'FILE_MISSING'),
(re.compile(r'(excel|workbook).*(locked|in use|사용 중)', re.I), 'EXCEL_LOCKED'),
(re.compile(r'(connection.*(refused|reset)|network)', re.I), 'NETWORK'),
]
def normalize(msg: str) -> str:
for pat, tag in NORMALIZE:
if pat.search(msg or ''):
return tag
return 'OTHER'
def analyze(csv_path: Path):
by_cause, by_process = Counter(), Counter()
rows = list(csv.DictReader(csv_path.open(encoding='utf-8-sig')))
for r in rows:
if r.get('Level') not in ('Error', 'Fatal'):
continue
cause = normalize(r.get('Message', ''))
by_cause[cause] += 1
by_process[(r.get('ProcessName'), cause)] += 1
print(f"오류 {sum(by_cause.values())}건 / 전체 {len(rows)}건")
for cause, n in by_cause.most_common():
print(f" {cause:24} {n:5}건")
print("\n반복 장애 프로세스 (같은 원인 3회 이상)")
for (proc, cause), n in by_process.most_common():
if n >= 3:
print(f" {proc:34} {cause:22} {n}회")
return by_cause, by_process
오류 메시지를 정규화해서 같은 원인끼리 묶는다
이 코드에서는 우선적으로 메시지의 키워드를 기준으로 정규식을 적용했다. 공통적으로 발생하는 패턴을 찾아낸 후, 이를 기반으로 메시지를 묶는 로직을 구성했다. 예를 들어, ‘not found’, ‘missing’ 같은 키워드를 통해 누락된 요소를 식별하고, 이를 동일한 원인으로 분류할 수 있도록 했다. 이 방식 덕분에 로그에서의 혼란을 줄이고, 오류 분석의 정확도와 속도가 크게 향상되었다. 결국 이러한 접근법은 시간이 흐를수록 더 많은 오류를 신속하게 처리할 수 있도록 도와주었다.
묶어보니 나온 원인 분포
오케스트레이터 전역 로그를 분석해서 오류의 원인을 파악해봤다. 뽑아보니 UI_ELEMENT_NOT_FOUND가 가장 많았으며, 그다음으로는 FILE_MISSING이 많았다. 의외로 코드 결함으로 인한 오류는 소수에 불과했다. 이렇게 해서 프로세스 내에서 발생하는 주된 문제의 경향성을 파악할 수 있었다.
또한, 오류가 시간에 따라 고르게 분포되어 있지 않다는 점도 확인할 수 있었다. 특정 날짜에 오류가 한꺼번에 몰리는 경향이 있었고, 이는 주로 대상 시스템이 업데이트된 날이나, 월초·월말처럼 관리자 업무가 변화하는 시점에 집중되어 있었다. 즉 사람과 시스템 쪽 변화가 장애를 만들고 있었다.
반복적으로 같은 원인이 발생하는 프로세스는 소수에 불과했지만, 그 소수가 장애 건수의 큰 부분을 차지하고 있었다. 그래서 모든 문제를 한꺼번에 해결하기보다는, 반복적으로 문제가 발생하는 부분을 우선적으로 해결하는 방향으로 순서를 바꿔 접근하기로 했다. 대책은 뒤에 적는다.
5만 행 엑셀 처리 시간 줄이기
다량의 데이터를 처리하는 과정에서 엑셀 파일을 다루는 것은 매우 일반적이다. 그러나 5만 행에 이르는 데이터를 한 행씩 UI를 통해 처리하면서 시간적 소모가 커지는 문제가 발생했다. 처음에는 단순히 Read Range 액티비티를 사용해 각 행을 For Each Row로 순회하며 UI 작업을 수행하도록 했다. 하지만 이 방식은 시간이 너무 오래 걸려 쓸 수 없었다. 5만 행을 순차적으로 다루다 보니 하루가 다 가는 경우도 있었다. 그래서 이러한 처리 방식을 개선할 필요가 있었다.
이에 따라, 엑셀 데이터를 빠르게 처리할 수 있는 방법을 찾아야 했다. 가장 먼저 떠오른 해결책은 전체 데이터를 한 번에 DataTable에 올린 후, 필요에 따라 LINQ를 사용해 데이터를 필터링하고 필요한 행만 처리하는 방식이었다. 이렇게 해서 불필요한 UI 접근을 최소화할 수 있었다. UI 작업을 최소화하여 시간이 대폭 줄어드는 효과를 기대할 수 있었다.
<!-- X 5만 행 × For Each Row × UI 조작 = 몇 시간 -->
<Sequence DisplayName="느린 방식">
<ui:ExcelReadRange Range="" SheetName="Sheet1" Result="[dt]" />
<ui:ForEachRow DataTable="[dt]">
<ui:TypeInto Text="[row("code").ToString()]" />
<ui:Click Selector="..." />
</ui:ForEachRow>
</Sequence>
<!-- O DataTable 에서 먼저 걸러내고, 남은 것만 UI 로 처리한다 -->
<Sequence DisplayName="필터 먼저">
<ui:ExcelReadRange Range="" SheetName="Sheet1" Result="[dt]" />
<!-- LINQ 로 처리 대상만 추린다. 5만 → 보통 수백 건 -->
<Assign>
<Assign.To><OutArgument x:TypeArguments="sd:DataTable">[dtTarget]</OutArgument></Assign.To>
<Assign.Value>
<InArgument x:TypeArguments="sd:DataTable">
[dt.AsEnumerable()
.Where(Function(r) r("status").ToString() = "대기"
AndAlso Not String.IsNullOrWhiteSpace(r("code").ToString()))
.CopyToDataTable()]
</InArgument>
</Assign.Value>
</Assign>
<ui:ForEachRow DataTable="[dtTarget]">
<!-- 여기서만 UI 를 건드린다 -->
</ui:ForEachRow>
</Sequence>
5만 행을 한 건씩 UI로 처리하면 안 된다
우선, Read Range로 전체 데이터를 DataTable에 한 번에 불러오는 것이 빠르다. 이 방법은 UI와 상호작용하지 않고 메모리에서 직접 작업을 수행하기 때문에 속도가 훨씬 빠르다. 이후 LINQ를 사용해 필요한 데이터를 추출한다. 이로써 전체 데이터를 대상으로 하는 반복 작업을 줄이고, 원하는 데이터만 선택적으로 다룰 수 있다. 그렇게 함으로써 엑셀 처리 시간이 크게 단축되었다. 실제로 이 방식으로 바꾸고 난 후, 5만 행 데이터를 처리하는 시간이 몇 시간에서 몇 분으로 줄어들었다.
결과적으로, 이 접근 방식은 처리 시간을 확실히 줄였다. 이전에는 엑셀을 다루는 데만 많은 시간이 소요되었지만, DataTable과 LINQ의 조합으로 그 시간을 크게 줄일 수 있었다. 불필요한 UI 상호작용을 최소화하고 필요한 데이터에만 집중할 수 있게 되면서 손이 훨씬 덜 가게 됐다.
UI 변경으로 3개가 동시에 죽은 날
어느 날, 세 개의 RPA 프로세스가 동시에 실패하는 사건이 발생했다. 살펴보니 공통으로 사용하는 사이트가 UI를 변경하면서 모든 프로세스가 막히게 된 것이었다. 셀렉터가 너무 딱딱하게 설정되어 있었기 때문에 UI가 조금만 바뀌어도 작동하지 않았던 것이다. 그동안 안정적이라고 생각했던 부분이 무너져버리자 셀렉터 설정 방식에 의문을 가지게 됐다. UI 변경은 일상적인 일이고, 이를 예외적으로 보지 말고 상수로 다뤄야 한다는 결론을 내렸다.
그래서 셀렉터를 느슨하게 설정하기로 방향을 전환했다. 파일명 역시 고정된 값이 아닌 패턴 매칭 방식으로 수정했다. 이렇게 하면 UI나 파일명이 변경되더라도 프로세스가 자동으로 적응할 수 있다. 결국에는 이런 공통 부분을 라이브러리로 빼서 관리하기로 했다. 셀렉터나 패턴이 변경될 때마다 모든 프로세스를 일일이 수정할 필요 없이 라이브러리만 수정하면 되도록 한 것이다.
<!-- X 전체 경로를 다 박아두면 레이아웃만 바뀌어도 죽는다 -->
<ui:Click>
<ui:Click.Target>
<ui:Target Selector="<html app='chrome.exe' title='업무포털' />
<webctrl tag='DIV' idx='3' />
<webctrl tag='TABLE' idx='2' />
<webctrl tag='TR' idx='7' />
<webctrl tag='BUTTON' idx='1' />" />
</ui:Click.Target>
</ui:Click>
<!-- O 의미 있는 속성만 남긴다. idx 는 최후의 수단 -->
<ui:Click>
<ui:Click.Target>
<ui:Target Selector="<html app='chrome.exe' />
<webctrl tag='BUTTON' aaname='조회' />"
WaitForReady="COMPLETE" TimeoutMS="30000" />
</ui:Click.Target>
</ui:Click>
<!-- 파일명도 고정하지 않는다. 담당자가 바뀌면 규칙이 바뀐다 -->
<!-- X: "2026년_08월_실적보고.xlsx" -->
<!-- O: Directory.GetFiles(path, "*실적*.xls*")
.OrderByDescending(Function(f) File.GetLastWriteTime(f)).First() -->
셀렉터를 느슨하게 — UI가 바뀌는 건 예외가 아니라 상수다
이후 동일한 문제가 발생했을 때, 셀렉터의 변경은 라이브러리에서 한 번의 수정만으로 해결할 수 있었다. 이렇게 함으로써 유지보수에 드는 시간을 크게 줄일 수 있었다. 더 이상 UI가 변경되었다고 해서 프로세스가 멈추지 않았고, 무엇보다 안정적으로 운영할 수 있었다. 이전에는 같은 문제로 인해 여러 프로세스가 한번에 실패할 수 있었지만, 라이브러리화를 통해 그런 상황을 방지할 수 있었다. 결국 셀렉터와 파일명 처리의 방법을 바꾸면서 장애가 줄고 대응도 빨라졌다.
지금 아침마다 보는 것
아침에 출근하자마자 가장 먼저 하는 일은 전날 밤의 배치 실행 결과를 확인하는 것이다. 수많은 프로세스 중 실패한 것만 필터링해서 어떤 원인 태그가 얼마나 발생했는지 본다. 이 작업이 중요한 이유는 원인을 빨리 알아내는 데 있다. 같은 원인이 반복되면 즉시 조치가 필요하기 때문이다. 여러 번 실패한 것들이라면 추가 분석이 필요하다.
알림 임계값은 3회로 설정해두었다. 같은 원인으로 세 번 이상 실패한 경우에는 그날의 처리 대상으로 올린다. 반면 1~2회 발생한 단발적인 실패는 보통 재실행으로 해결한다. 모든 오류를 추적하려고 하면 하루가 다 가버리기 때문에, 우선순위를 정하는 것이 필수적이다. 따라서 필요한 대응은 그날의 상황에 따라 유연하게 결정한다.
주간으로는 오류 원인의 분포를 확인한다. 예를 들어, TIMEOUT 오류가 늘어난다면 이는 외부 시스템의 응답 속도가 느려졌다는 신호일 수 있다. FILE_MISSING 오류가 증가하는 경우, 담당자나 파일 규칙이 바뀌었을 가능성을 염두에 둔다. 주간 데이터는 일시적인 문제가 아닌 지속적인 패턴을 찾아내는 데 유용하다. 이렇게 해서 장기적인 개선 방향을 설정할 수 있다.
함께 볼 글
이 글과 이어지는 내용은 아래에 정리해뒀다.