엑셀 VBA에서 재귀 호출 과다로 “Out of stack space(오류 28)” 발생 시 완벽 해결 가이드

이 글의 목적은 엑셀 VBA에서 프로시저의 재귀 호출이 과도하여 발생하는 스택 고갈 오류(Out of stack space, 오류 28)를 체계적으로 진단하고, 코드 리팩터링과 이벤트 재진입 방지, 반복문 전환, 사용자 정의 스택 구현 등 실무 중심의 해결 전략을 제공하는 것이다.

1. 오류 개요: 왜 재귀가 문제를 일으키는가

VBA는 호출 스택 크기가 제한되어 있으며, 재귀 호출이 깊어지면 로컬 변수와 반환 주소를 저장하는 스택 프레임이 급격히 증가하여 “Out of stack space” 오류가 발생한다. 테일 재귀 최적화가 지원되지 않아 단순한 꼬리재귀도 누적 스택을 소비한다. 특히 워크시트 이벤트나 속성 프로시저에서 간접 재귀가 발생하면 호출 깊이를 통제하기 어렵다.

주의 : VBA에서는 런타임에서 스택 크기를 늘릴 수 없으므로, 해결책은 반드시 코드 설계 변경과 호출 구조 단순화에 기반해야 한다.

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. 품질 보증 절차

  1. 최악 케이스 데이터를 생성하여 깊이와 분기 폭을 측정한다.
  2. 즉시창 로그에서 깊이 상한과 처리 시간을 기록한다.
  3. 이벤트 플래그가 모든 경로에서 복구되는지 확인한다.
  4. 대용량 입력 3종 이상으로 회귀 테스트한다.

16. 문제 해결 흐름도(텍스트 버전)

[시작] | v 오류 28 발생? -- 아니오 --> 일반 디버깅 | 예 v 이벤트 핸들러 내 수정? -- 예 --> 재진입 방지/EnableEvents 적용 --> 재테스트 | | 아니오 v v 해결 직접/간접 재귀 존재? -- 아니오 --> 대형 지역 변수를 힙으로 이동(ByRef/모듈 변수) | 예 v 종료 조건 강화 및 깊이 상한 적용 --> 반복+스택으로 전환 --> 부하 테스트 --> 배포 

FAQ

VBA에서 스택 크기를 키울 수 있는가?

불가능하다. VBA 런타임은 스택 크기 조정을 허용하지 않으므로 코드 구조 변경으로 문제를 해결해야 한다.

꼬리재귀는 안전한가?

아니다. VBA는 테일 재귀 최적화를 지원하지 않으므로 꼬리재귀도 스택을 소비한다. 반복으로 전환하는 것이 안전하다.

이벤트 핸들러에서 Application.EnableEvents만으로 충분한가?

충분하지 않다. 오류 시 복구 보장이 필요하며, 추가로 재진입 플래그를 사용해 동시 실행을 차단해야 한다.

깊이 제한으로만 방어해도 되는가?

개발 단계에서 원인 규명까지 임시 가드로 유용하나, 배포 전에는 반복 전환 등 근본 해결을 적용해야 한다.

성능 저하 없이 안정성만 높이려면 무엇부터 하나?

이벤트 재진입 차단과 ByRef 전달 최적화부터 적용한다. 대부분의 현장 문제는 이 두 조치로 해결된다.