- 공유 링크 만들기
- X
- 이메일
- 기타 앱
이 글의 목적은 엑셀 VBA에서 프로시저의 재귀 호출이 과도하여 발생하는 스택 고갈 오류(Out of stack space, 오류 28)를 체계적으로 진단하고, 코드 리팩터링과 이벤트 재진입 방지, 반복문 전환, 사용자 정의 스택 구현 등 실무 중심의 해결 전략을 제공하는 것이다.
1. 오류 개요: 왜 재귀가 문제를 일으키는가
VBA는 호출 스택 크기가 제한되어 있으며, 재귀 호출이 깊어지면 로컬 변수와 반환 주소를 저장하는 스택 프레임이 급격히 증가하여 “Out of stack space” 오류가 발생한다. 테일 재귀 최적화가 지원되지 않아 단순한 꼬리재귀도 누적 스택을 소비한다. 특히 워크시트 이벤트나 속성 프로시저에서 간접 재귀가 발생하면 호출 깊이를 통제하기 어렵다.
2. 대표 시나리오와 징후
| 상황 | 설명 | 주요 징후 | 재현 포인트 |
|---|---|---|---|
| 직접 재귀 | 프로시저 A가 자기 자신을 호출한다. | 깊은 입력에서 즉시 오류 28 | 탈출 조건 누락 또는 종료 임계값 과소 |
| 간접 재귀 | A→B→C→A 형태로 순환 호출이 발생한다. | 일부 케이스만 실패, 재현성 낮음 | 조건 분기에서 희귀 경로로 순환 유입 |
| 이벤트 재진입 | Worksheet_Change 내부에서 셀 갱신으로 동일 이벤트가 연쇄 발생한다. | 입력 즉시 프리징 후 오류 28 | Application.EnableEvents 미사용 |
| 속성 프로시저 순환 | Property Get/Let/Set 간 상호 호출 | 개체 사용 시 간헐적 스택 고갈 | 게터에서 세터 호출 또는 반대 |
| 과도한 깊이의 분할 정복 | 퀵정렬, 트리 탐색 등에서 경계값 제어 미흡 | 대용량 데이터에서만 오류 28 | 피벗 분할 편향, 불균형 트리 |
3. 1차 진단 체크리스트
- 즉시 창(Immediate Window)에
Debug.Print로 호출 깊이와 인수 크기를 출력한다. - 모든 이벤트 핸들러 시작 지점에 재진입 방지 플래그를 둔다.
- 종료 조건이 상수 시간 내 검증 가능한지 확인한다.
- 최악 복잡도에서 깊이가
O(n)이상이면 반복 구조로 전환을 검토한다.
' 모듈 범위에서 사용 Public g_CallDepth As Long
Sub TraceDepth(name As String)
g_CallDepth = g_CallDepth + 1
Debug.Print String$(g_CallDepth, "."), name, " t=" & Timer
End Sub
Sub TraceReturn()
g_CallDepth = g_CallDepth - 1
End Sub
Variants나 대형 배열을 ByVal로 전달하면 스택/힙 압력이 급증한다. 가능하면 ByRef를 사용하고 지역 대형 배열 사용을 피한다.4. 이벤트 재진입(Worksheet_Change 등) 차단 패턴
셀을 수정하는 이벤트 핸들러 내부에서 동일 이벤트가 재발화하면 사실상 재귀가 된다. 다음 패턴으로 재진입을 차단한다.
Option Explicit Private m_InChange As Boolean
Private Sub Worksheet_Change(ByVal Target As Range)
If m_InChange Then Exit Sub
On Error GoTo CleanUp
m_InChange = True
Application.EnableEvents = False
' ... 셀 처리 로직 ...
If Not Intersect(Target, Range("A:A")) Is Nothing Then
With Target
.Offset(0, 1).Value = UCase$(.Value2)
End With
End If
CleanUp:
Application.EnableEvents = True
m_InChange = False
End Sub
EnableEvents가 다시 True로 복구되지 않으면 후속 기능이 마비된다. On Error와 보장 복구 절차를 반드시 포함해야 한다.5. 재귀 → 반복 전환: 사용자 정의 스택 구현
VBA는 테일 재귀 최적화를 지원하지 않으므로, 깊은 재귀는 반복과 명시적 스택으로 치환하는 것이 안정적이다.
5.1 예시: 재귀 DFS를 반복으로 변환
Option Explicit
Public Type Node
Id As Long
Left As Long
Right As Long
End Type
Sub DFS_Iterative(ByRef nodes() As Node, ByVal root As Long)
Dim st() As Long, top As Long
ReDim st(1 To 1)
top = 1: st(top) = root
Do While top >= 1
Dim cur As Long
cur = st(top): top = top - 1
Debug.Print "Visit"; nodes(cur).Id
' 우선순위에 따라 Push 순서 결정
If nodes(cur).Right <> 0 Then
top = top + 1
If top > UBound(st) Then ReDim Preserve st(1 To UBound(st) * 2)
st(top) = nodes(cur).Right
End If
If nodes(cur).Left <> 0 Then
top = top + 1
If top > UBound(st) Then ReDim Preserve st(1 To UBound(st) * 2)
st(top) = nodes(cur).Left
End If
Loop
End Sub
5.2 예시: 퀵정렬 꼬리재귀 제거
Sub QuickSortIter(ByRef A() As Double, ByVal L As Long, ByVal R As Long) Dim S_L() As Long, S_R() As Long, top As Long ReDim S_L(1 To 1): ReDim S_R(1 To 1) top = 1: S_L(top) = L: S_R(top) = R
Do While top >= 1
L = S_L(top): R = S_R(top): top = top - 1
Do While L < R
Dim i As Long, j As Long, p As Double
i = L: j = R: p = A((L + R) \ 2)
Do While i <= j
Do While A(i) < p: i = i + 1: Loop
Do While A(j) > p: j = j - 1: Loop
If i <= j Then
Dim t As Double: t = A(i): A(i) = A(j): A(j) = t
i = i + 1: j = j - 1
End If
Loop
' 작은 구간을 스택에, 큰 구간은 루프 계속로 처리
If (j - L) < (R - i) Then
If i < R Then
top = top + 1
If top > UBound(S_L) Then
ReDim Preserve S_L(1 To UBound(S_L) * 2)
ReDim Preserve S_R(1 To UBound(S_R) * 2)
End If
S_L(top) = i: S_R(top) = R
End If
R = j
Else
If L < j Then
top = top + 1
If top > UBound(S_L) Then
ReDim Preserve S_L(1 To UBound(S_L) * 2)
ReDim Preserve S_R(1 To UBound(S_R) * 2)
End If
S_L(top) = L: S_R(top) = j
End If
L = i
End If
Loop
Loop
End Sub
6. 종료 조건 강화와 폭발적 분기 억제
- 정확한 기저조건을 먼저 검사하고, 이후에 분기를 확장한다.
- 입력 규모 또는 분기수에 상한을 설정한다.
- 임시 디버그 가드로 최대 깊이 제한을 둔다.
Private Const MAX_DEPTH As Long = 1000
Function RecurSolve(ByVal depth As Long, ByVal n As Long) As Long
If depth > MAX_DEPTH Then Err.Raise 5, , "Depth limit exceeded"
If n <= 1 Then
RecurSolve = n
Exit Function
End If
RecurSolve = RecurSolve(depth + 1, n - 1) + RecurSolve(depth + 1, n - 2)
End Function
7. 속성 프로시저 순환 차단
Property Get 내부에서 같은 속성 또는 대응 Property Let을 호출하면 순환이 발생한다. 내부 필드를 분리하여 직접 접근해야 한다.
Private m_Value As Long
Public Property Get Value() As Long
Value = m_Value ' 다른 속성 접근 금지
End Property
Public Property Let Value(ByVal v As Long)
m_Value = v ' 여기서 Value 재호출 금지
End Property
8. 메모리와 전달 방식 최적화
- 큰 배열은 ByRef로 전달하여 복사 비용을 줄인다.
- 지역 대형 개체를 재귀마다 생성하지 말고 상위에서 재사용한다.
- 문자열 결합은
Join,StringBuilder유사 패턴으로 최소화한다.
Sub ProcessLarge(ByRef arr() As Double) ' ByRef 기본값을 명시하여 의도를 고정한다 End Sub 9. Application.OnTime으로 호출 분할
긴 재귀를 이벤트 루프에 분할 투영하면 스택 누적 없이 진행할 수 있다.
Option Explicit Private m_Q() As Long, m_Idx As Long, m_Len As Long
Sub StartBatch()
' 처리해야 할 작업 큐 초기화
Dim i As Long
m_Len = 10000
ReDim m_Q(1 To m_Len)
For i = 1 To m_Len: m_Q(i) = i: Next
m_Idx = 1
Application.OnTime Now, "ProcessNext"
End Sub
Sub ProcessNext()
Dim k As Long, chunk As Long
chunk = 200 ' 한 번에 처리량
For k = 1 To chunk
If m_Idx > m_Len Then Exit For
' 개별 작업 처리
' ...
m_Idx = m_Idx + 1
Next
If m_Idx <= m_Len Then
Application.OnTime Now, "ProcessNext"
End If
End Sub
10. 실무 점검표
| 항목 | 체크 내용 | 권장 조치 |
|---|---|---|
| 이벤트 재진입 | EnableEvents 복구 보장 여부 | Try-Finally 패턴으로 복구 및 플래그 사용 |
| 종료 조건 | 모든 경로에서 도달 가능 여부 | 빠른 반환 패턴으로 전면에 배치 |
| 자료구조 | 깊이 O(n) 재귀 여부 | 반복과 사용자 정의 스택으로 변환 |
| 인수 전달 | ByVal로 대형 데이터 복사 여부 | ByRef 전환 및 참조 재사용 |
| 속성 순환 | Getter/Setter 상호호출 | 백킹 필드 직접 접근 |
| 디버깅 | 호출 깊이 로깅 유무 | 전역 카운터와 즉시창 출력 |
11. 케이스 스터디: 재귀 파일 탐색 리팩터링
재귀로 폴더를 깊게 순회하면 스택 고갈 위험이 있다. FileSystemObject와 큐를 이용해 반복으로 변환한다.
Option Explicit
Sub WalkFilesIter(ByVal rootPath As String)
Dim fso As Object: Set fso = CreateObject("Scripting.FileSystemObject")
Dim q As Object: Set q = CreateObject("System.Collections.ArrayList")
q.Add fso.GetFolder(rootPath)
Do While q.Count > 0
Dim fld As Object
Set fld = q(q.Count - 1)
q.RemoveAt q.Count - 1
Dim f As Object
For Each f In fld.Files
Debug.Print f.Path
Next
Dim subf As Object
For Each subf In fld.SubFolders
q.Add subf
Next
Loop
End Sub
12. 재귀 허용 설계 시 안전장치
- 입력 전처리로 깊이를 제한한다.
- 랜덤 피벗 선택 등으로 최악 케이스 확률을 낮춘다.
- 깊이 임계치 도달 시 반복 루틴으로 폴백한다.
Function SafeQuickSort(ByRef A() As Double) As Boolean On Error GoTo Fallback QuickSortRecur A, LBound(A), UBound(A), 0 SafeQuickSort = True Exit Function Fallback: QuickSortIter A, LBound(A), UBound(A) SafeQuickSort = False End Function 13. 성능 관점: 재귀 대비 반복의 이점
| 항목 | 재귀 | 반복+스택 | 비고 |
|---|---|---|---|
| 스택 사용량 | 깊이에 비례하여 증가 | 상수 수준 | 대용량 처리에 반복 우세 |
| 가독성 | 간결하나 위험 경로 숨김 | 장황하나 제어 명확 | 실무는 안정성 우선 |
| 예외 처리 | 상위 전파 복잡 | 루프 내 국소 처리 용이 | 회복력 높음 |
14. 실무 템플릿 모음
14.1 재진입 방지 템플릿
Private m_Busy As Boolean
Public Sub RunSafe()
If m_Busy Then Exit Sub
On Error GoTo ExitPoint
m_Busy = True
' ... 본 처리 ...
ExitPoint:
m_Busy = False
End Sub
14.2 깊이 카운터 템플릿
Private m_Depth As Long
Private Function EnterBlock() As Long
m_Depth = m_Depth + 1
EnterBlock = m_Depth
Debug.Print "Depth=" & m_Depth
End Function
Private Sub LeaveBlock()
m_Depth = m_Depth - 1
End Sub
14.3 반복 스택 유틸리티
Private Type Stack data() As Variant top As Long End Type
Private Sub StackInit(ByRef s As Stack, Optional cap As Long = 16)
ReDim s.data(1 To cap): s.top = 0
End Sub
Private Sub StackPush(ByRef s As Stack, ByVal v As Variant)
s.top = s.top + 1
If s.top > UBound(s.data) Then ReDim Preserve s.data(1 To UBound(s.data) * 2)
s.data(s.top) = v
End Sub
Private Function StackPop(ByRef s As Stack) As Variant
StackPop = s.data(s.top)
s.top = s.top - 1
End Function
Private Function StackEmpty(ByRef s As Stack) As Boolean
StackEmpty = (s.top = 0)
End Function
15. 품질 보증 절차
- 최악 케이스 데이터를 생성하여 깊이와 분기 폭을 측정한다.
- 즉시창 로그에서 깊이 상한과 처리 시간을 기록한다.
- 이벤트 플래그가 모든 경로에서 복구되는지 확인한다.
- 대용량 입력 3종 이상으로 회귀 테스트한다.
16. 문제 해결 흐름도(텍스트 버전)
[시작] | v 오류 28 발생? -- 아니오 --> 일반 디버깅 | 예 v 이벤트 핸들러 내 수정? -- 예 --> 재진입 방지/EnableEvents 적용 --> 재테스트 | | 아니오 v v 해결 직접/간접 재귀 존재? -- 아니오 --> 대형 지역 변수를 힙으로 이동(ByRef/모듈 변수) | 예 v 종료 조건 강화 및 깊이 상한 적용 --> 반복+스택으로 전환 --> 부하 테스트 --> 배포 FAQ
VBA에서 스택 크기를 키울 수 있는가?
불가능하다. VBA 런타임은 스택 크기 조정을 허용하지 않으므로 코드 구조 변경으로 문제를 해결해야 한다.
꼬리재귀는 안전한가?
아니다. VBA는 테일 재귀 최적화를 지원하지 않으므로 꼬리재귀도 스택을 소비한다. 반복으로 전환하는 것이 안전하다.
이벤트 핸들러에서 Application.EnableEvents만으로 충분한가?
충분하지 않다. 오류 시 복구 보장이 필요하며, 추가로 재진입 플래그를 사용해 동시 실행을 차단해야 한다.
깊이 제한으로만 방어해도 되는가?
개발 단계에서 원인 규명까지 임시 가드로 유용하나, 배포 전에는 반복 전환 등 근본 해결을 적용해야 한다.
성능 저하 없이 안정성만 높이려면 무엇부터 하나?
이벤트 재진입 차단과 ByRef 전달 최적화부터 적용한다. 대부분의 현장 문제는 이 두 조치로 해결된다.
- 공유 링크 만들기
- X
- 이메일
- 기타 앱