모바일 개발에서의 Stack Overflow — 개념, 원인 및 방지 방법

저자: IT Sectr 게시일: 2026-03-29 읽는 시간: 9 분

Stack Overflow는 스레드의 최대 스택 깊이를 초과할 때 발생하는 호출 스택 오버플로 오류(java.lang.StackOverflowError)입니다. Java Virtual Machine Specification에 따르면, 64비트 시스템의 JVM에서 일반적인 스택 깊이는 1024 프레임입니다. 주요 원인은 기본 조건이 없는 무한 재귀입니다.

주요 포인트

  • StackOverflowError — 호출 스택 깊이 제한 초과 시 JVM 오류
  • 스택 깊이는 구성에 따라 512~2048 프레임으로 제한됩니다
  • 무한 재귀는 StackOverflowError의 가장 일반적인 원인입니다
  • 꼬리 재귀는 함수형 언어와 달리 JVM에서 최적화되지 않습니다
  • 반복적 대체는 오버플로를 방지하는 신뢰할 수 있는 방법입니다

Stack Overflow란

StackOverflowError는 Java Virtual Machine(JVM) 또는 Android Runtime(ART)의 치명적인 오류로, 스레드의 호출 스택이 최대 허용 깊이에 도달할 때 발생합니다. OutOfMemoryError(Heap 부족)와 달리 StackOverflowError는 메모리의 다른 영역인 스택과 관련되며, 여기에 메서드 호출 프레임과 지역 변수가 저장됩니다.

메서드 호출은 스택에 프레임(반환 주소, 매개변수, 지역 변수)을 생성합니다. 메서드가 반환되면 프레임은 제거됩니다. 기본 조건 없이 메서드가 자신을 호출(재귀)하면 스택이 가득 찰 때까지 프레임이 누적됩니다. JVM은 새 프레임을 할당할 수 없으며 «null» 메시지(Java의 경우) 또는 무한히 반복되는 스택 라인 표시와 함께 StackOverflowError를 throw합니다.

스레드 스택의 크기는 생성 시 고정되며 실행 중에 변경되지 않습니다. Android에서 메인 스레드의 일반적인 스택 크기는 32~48KB이며, 지역 변수가 많지 않은 메서드의 경우 약 512~1024 프레임의 깊이를 제공합니다. 백그라운드 스레드의 경우 기본 크기는 16~24KB로 더 작습니다.

호출 스택의 작동 방식

호출 스택(Call Stack)은 메서드 실행 순서를 관리하는 LIFO(Last In, First Out) 데이터 구조입니다. 프로그램이 메서드를 호출할 때마다 JVM은 스택에 프레임을 생성하고 맨 위에 배치합니다. 메서드가 완료되면 프레임이 제거됩니다.

각 프레임에는 피연산자 스택(바이트코드 명령용), 지역 변수 배열(this 포함), 상수 풀 참조 및 반환 주소가 포함됩니다. 메서드에 지역 변수가 많을수록 프레임 크기가 커지고 스택이 가득 차기 전에 호출할 수 있는 메서드 수가 줄어듭니다. 10개의 매개변수와 20개의 지역 변수가 있는 메서드는 매개변수가 없는 메서드보다 약 3배 더 많은 공간을 차지합니다.

Android에서는 ART가 데스크톱 JVM과 다른 자체 스택 구현을 사용합니다. ART는 특정 한도 내에서 스택을 동적으로 늘릴 수 있지만 각 스레드에는 여전히 하드 제한이 있습니다. 메인 스레드(UI 스레드)는 전체 Activity 수명 주기와 이벤트 처리를 처리하므로 가장 큰 스택을 갖습니다.

kotlin
// StackOverflowError로 이어지는 재귀
fun recursiveCall(depth: Int): Int {
    return recursiveCall(depth + 1) // 기본 조건 없음
}

// ~1000 깊이에서 StackOverflowError 발생
recursiveCall(0)

스택 오버플로의 주요 원인

다섯 가지 일반적인 시나리오가 모바일 애플리케이션에서 StackOverflowError를 발생시킵니다. 대부분은 재귀와 관련되어 있지만 덜 명백한 원인도 있습니다.

기본 조건이 없는 무한 재귀

가장 일반적인 원인입니다. 개발자가 중지 조건 없이 또는 결코 true가 되지 않는 조건으로 재귀 메서드를 작성합니다. 각 호출은 프레임을 추가하고, 프레임 크기에 따라 500~2000회 반복으로 스택이 가득 찹니다. 일반적인 예: n == 0을 확인하지 않고 n! 팩토리얼 계산.

각 재귀 메서드의 시작 부분에서 기본 조건을 확인하세요. Kotlin에서는 require() 또는 check()를 사용하여 매개변수를 검증하세요. 깊은 재귀(100레벨 이상)의 경우 반복적 접근 방식으로 대체하는 것을 고려하세요.

생성자의 순환 종속성

클래스 A가 B의 인스턴스를 생성하고, 클래스 B가 A의 인스턴스를 생성하는 경우 — 이는 생성자의 순환 종속성입니다. A를 생성하려고 하면 B의 생성자가 호출되고, B의 생성자가 A의 생성자를 호출하여 StackOverflowError까지 계속됩니다. DI 프레임워크(Dagger, Hilt)는 컴파일 타임에 이러한 순환을 감지하지만 수동 객체 생성은 감지하지 못합니다.

종속성 그래프와 함께 의존성 주입을 사용하세요. Dagger 또는 Koin은 빌드 타임에 순환을 확인합니다. 순환을 피할 수 없는 경우 직접 종속성을 지연 초기화 또는 Provider 팩토리가 있는 인터페이스로 대체하세요.

kotlin
// 순환 종속성 — StackOverflowError
class A(private val b: B)
class B(private val a: A)

// 지연 해결
class A(private val bProvider: Provider<B>)

그래프 탐색의 깊은 재귀

재귀를 통한 View 트리(ViewGroup.getChildAt()), 파일 시스템 또는 JSON 구조 탐색은 500~1000개 요소를 초과하는 깊이에서 스택 제한을 초과할 수 있습니다. 20단계 중첩의 Android ViewGroup은 드물지만 2000개의 중첩 객체가 있는 JSON의 재귀적 파싱은 실제 시나리오입니다.

재귀적 탐색을 명시적 Stack<T> 또는 ArrayDeque를 사용한 반복적 탐색으로 대체하세요. 이렇게 하면 힙 객체가 스택 제한의 영향을 받지 않으므로 스택 오버플로 위험이 완전히 제거됩니다. Queue를 통한 BFS(너비 우선 탐색)도 문제를 해결합니다.

onConfigurationChanged의 잘못된 처리

Android特有의 원인: 구성이 잘못 처리될 때 수명 주기 메서드의 순환 호출. 예를 들어 onConfigurationChanged 내에서 recreate()를 호출하면 다시 onConfigurationChanged가 호출되고 StackOverflowError까지 계속됩니다. 유사하게: onLayout() 내의 setContentView()가 또 다른 측정 및 레이아웃을 트리거합니다.

구성 변경과 관련된 메서드 내에서 recreate()를 호출하지 마세요. 테마 변경 시 UI를 업데이트하려면 recreate 없이 setTheme()를 사용하세요. 동적 방향 변경의 경우 — 구성에 플래그 없이 requestOrientation()을 한 번 호출하세요.

순환 참조가 있는 직렬화

Gson, Moshi 또는 Kotlin Serialization이 순환 참조(A가 B를 참조, B가 A를 참조)가 있는 객체를 직렬화하려고 하면 무한 재귀에 빠지고 StackOverflowError로 충돌합니다. 이는 양방향 관계(JPA, ForeignKey가 있는 Room)가 있는 Entity를 직렬화할 때 일반적인 문제입니다.

순환의 한쪽에 @Transient, @JsonIgnore 또는 @kotlinx.serialization.Transient를 사용하세요. Gson의 경우 — 명시적 깊이 제한이 있는 JsonSerializer. Room의 경우 — Entity를 직접 직렬화하지 말고 DTO 매퍼를 사용하세요.

StackOverflowError 진단 및 수정 방법

StackOverflowError 진단은 다른 메모리 오류보다 쉽습니다. 스택 트레이스는 대부분의 경우 반복되는 호출 시퀀스를 보여줍니다. 이는 즉시 재귀를 가리킵니다.

스택 트레이스 읽기

StackOverflowError의 스택 트레이스는 독특합니다. 처음 200~500줄 후에 동일한 호출 패턴이 반복되기 시작합니다. JVM은 끝에서 반복되는 줄을 자르고 «... 1234 more»를 표시합니다. «...» 앞의 반복되지 않는 줄 수는 오류를 일으킨 재귀 깊이를 나타냅니다.

스택 트레이스의 첫 줄을 읽으세요 — 어떤 메서드에서 반복이 시작되었는지 보여줍니다. 자신을 호출하거나 자신으로 돌아오는 호출 체인을 생성하는 메서드를 찾으세요. 기본 조건을 수정하거나 재귀를 루프로 대체하세요.

스택 크기 늘리기(임시 해결책)

임시로 JVM 플래그 -Xss를 통해 스택 크기를 늘려 문제를 해결할 수 있습니다. Android의 경우 스택 크기는 AndroidManifest를 통해 설정됩니다. android:largeHeap은 스택에 영향을 주지 않습니다. 코드에서 스레드 스택을 늘리려면: Thread(ThreadGroup, Runnable, name, stackSize). stackSize는 바이트 단위의 원하는 크기입니다.

kotlin
// 확장된 스택으로 스레드 생성
val thread = Thread(null, runnable, "big-stack-thread", 64 * 1024)
thread.start()

중요: 스택을 늘려도 문제가 해결되지 않고 지연될 뿐입니다. 10,000레벨 재귀의 경우 64KB 스택이 128KB 스택으로 대체되어 20,000레벨이 되지만 오류는 여전히 발생하며 단지 나중에 발생할 뿐입니다. 유일한 올바른 해결책은 재귀의 반복적 대체입니다.

재귀를 반복으로 대체

반복적 알고리즘은 중간 상태를 저장하기 위해 호출 스택을 사용하지 않고 힙(Stack<T> 또는 ArrayDeque)에 저장합니다. 이진 트리 탐색, 팩토리얼 계산, 피보나치 — 모든 재귀는 명시적 스택을 사용하여 반복으로 변환할 수 있습니다.

kotlin
// 반복적 트리 탐색 — StackOverflow 위험 없음
fun traverseIterative(root: Node?) {
    val stack = ArrayDeque<Node>()
    stack.push(root)
    while (stack.isNotEmpty()) {
        val node = stack.pop() ?: continue
        process(node)
        node.right?.let { stack.push(it) }
        node.left?.let { stack.push(it) }
    }
}

Stack Overflow 방지 방법

StackOverflowError 예방은 프로덕션에 도달하기 전에 잠재적 재귀 순환을 식별하는 일련의 규칙과 도구입니다.

디버그 빌드의 재귀 깊이 제한

디버그 빌드의 재귀 메서드에 보호용 깊이 카운터를 추가하세요. 깊이가 임계값(예: 1000)을 초과하면 명확한 메시지와 함께 예외를 throw하세요. 이렇게 하면 읽을 수 없는 트레이스의 StackOverflowError가 이해하기 쉬운 비즈니스 예외로 변환됩니다.

kotlin
fun safeRecursive(n: Int, depth: Int = 0): Int {
    if (depth > 1000) {
        throw IllegalStateException("재귀가 1000레벨을 초과했습니다")
    }
    return if (n <= 1) n
           else safeRecursive(n - 1, depth + 1)
}

정적 코드 분석

Detekt(Kotlin)와 Infer(Facebook)는 정적 분석 수준에서 잠재적 무한 재귀를 찾습니다. Detekt에는 매개변수 변경 없이 자기 호출에 대해 경고하는 PotentiallyInfiniteRecursion 규칙이 있습니다. CI 규칙 세트에서 활성화하고 심각도를 error로 설정하세요.

재귀에 초점을 맞춘 코드 리뷰

코드 리뷰에서 다음에 주의하세요: 자기 호출 메서드, 람다 내부의 재귀 호출(Kotlin 인라인 함수), 다른 클래스 간의 순환 호출, 프로퍼티 위임의 재귀. 각 재귀 메서드에 대해 확인: 기본 조건이 있는지, 각 단계에서 매개변수가 변경되는지, 매개변수 변경이 기본 조건에 도달함을 보장하는지.

꼬리 재귀 변환(제한적)

Kotlin은 tailrec 수정자를 지원합니다. 재귀 메서드가 tailrec으로 표시되고 호출이 꼬리(마지막 작업)인 경우 컴파일러가 이를 반복으로 변환합니다. 그러나 tailrec은 자기 호출(메서드가 직접 자신을 호출)에만 작동하고 상호 재귀에는 작동하지 않으며 Android 호환 Kotlin 버전 1.5 이전에서는 지원되지 않습니다.

kotlin
tailrec fun factorial(n: Int, acc: Int = 1): Int {
    return if (n <= 1) acc
           else factorial(n - 1, acc * n) // 꼬리 호출
}

자주 묻는 질문

StackOverflowError를 try-catch로 잡을 수 있나요?

가능합니다만 Java 수준에서만 가능합니다. Error는 Exception과 마찬가지로 Throwable입니다. 그러나 StackOverflowError 후에는 스택이 손상되어 맞지 않았던 프레임이 올바르게 완료될 수 없습니다. catch 블록에서 새 객체를 생성하려고 하면 또 다른 StackOverflowError가 발생할 수 있습니다.

Android의 기본 스택 크기는?

메인 스레드의 경우 — 32~48KB, 백그라운드 스레드의 경우 — 16~24KB. 정확한 크기는 Android 버전과 기기 제조업체에 따라 다릅니다. ART는 동적 스택 확장을 사용하지만 초기 값의 2배를 초과하지 않습니다.

꼬리 재귀로 StackOverflowError를 방지할 수 있나요?

Kotlin에서는 — 가능합니다. 메서드가 tailrec으로 표시된 경우 컴파일러가 꼬리 재귀를 반복으로 변환하여 스택 증가를 완전히 제거합니다. Java에서는 꼬리 재귀가 JVM에 의해 최적화되지 않습니다(Scala와 같은 함수형 언어와 달리).

에뮬레이터에서는 StackOverflowError가 발생하지만 기기에서는 발생하지 않는 이유는?

스택 크기는 에뮬레이터와 실제 기기에서 다를 수 있습니다. 에뮬레이터는 일반적인 512~1024KB 스택의 데스크톱 JVM을 사용하는 반면 Android ART는 32~48KB를 사용합니다. 오류는 데스크톱 JVM보다 ART에서 먼저 나타납니다.

StackOverflowError와 OutOfMemoryError의 차이점은?

메모리 영역: StackOverflowError는 스택 오류(호출 프레임), OutOfMemoryError는 힙 오류(객체)입니다. StackOverflowError는 거의 항상 재귀로 인해 발생하는 반면 OutOfMemoryError는 메모리 누수나 큰 객체로 인해 발생합니다.

요약

  • StackOverflowError — 재귀 깊이 제한 초과 시 호출 스택 오버플로
  • Android의 스택 깊이는 메인 스레드에서 512~1024 프레임
  • 무한 재귀가 주요 원인; 각 재귀 메서드에서 기본 조건 확인
  • 생성자의 순환 종속성 — 덜 명백하지만 일반적인 오버플로 원인
  • 명시적 Stack<T>를 통한 재귀의 반복적 대체로 위험 완전 제거
  • Kotlin의 tailrec은 꼬리 재귀를 컴파일러 수준에서 반복으로 변환
  • 정적 분석(Detekt, Infer)은 실행 전 잠재적 무한 재귀 탐지

턴키 방식의 모바일 애플리케이션을 개발해 드립니다

IT Sectr는 2017년부터 스타트업과 기업을 위한 iOS 및 Android 애플리케이션을 만듭니다. 저희가 상담해 드리고 최적의 솔루션을 제안하겠습니다.

프로젝트 논의

더 읽어보기