1. 개요

StackOverflowError 는 우리가 접할 수 있는 가장 일반적인 런타임 오류 중 하나이기 때문에 Java 개발자에게 성가실 수 있습니다.

이 기사에서는 다양한 코드 예제와 이를 처리할 수 있는 방법을 살펴Spring으로써 이 오류가 어떻게 발생할 수 있는지 알아봅니다.

2. 스택 프레임 및 StackOverflowError 발생 방식

기본부터 시작하겠습니다. 메서드가 호출되면 호출 스택 에 새 스택 프레임이 생성됩니다 . 이 스택 프레임은 호출된 메서드의 매개 변수, 로컬 변수 및 메서드의 반환 주소, 즉 호출된 메서드가 반환된 후 메서드 실행이 계속되어야 하는 지점을 포함합니다.

스택 프레임 생성은 중첩된 메서드 내에서 발견된 메서드 호출의 끝에 도달할 때까지 계속됩니다.

이 과정에서 JVM이 새 스택 프레임을 생성할 공간이 없는 상황이 발생하면 StackOverflowError 가 발생 합니다.

JVM이 이 상황에 직면하는 가장 일반적인 원인은 종료되지 않은/무한 재귀 입니다. StackOverflowError 에 대한 Javadoc 설명 에는 특정 코드 스니펫에서 너무 깊은 재귀의 결과로 오류가 발생한다고 언급되어 있습니다.

그러나 재귀가 이 오류의 유일한 원인은 아닙니다. 응용 프로그램이 스택이 소진될 때까지 메서드 내에서 메서드를 계속 호출하는 상황에서도 발생할 수 있습니다 . 어떤 개발자도 의도적으로 나쁜 코딩 관행을 따르지 않기 때문에 드문 경우입니다. 또 다른 드문 원인은 메서드 내부에 방대한 수의 지역 변수 가 있기 때문입니다 .

StackOverflowError 는 응용 프로그램이 클래스 간에 순환 관계 를 갖도록 설계되었을 때도 발생할 수 있습니다 . 이 상황에서 서로의 생성자가 반복적으로 호출되어 이 오류가 발생합니다. 이것은 또한 재귀의 한 형태로 간주될 수 있습니다.

이 오류를 일으키는 또 다른 흥미로운 시나리오는 클래스가 해당 클래스의 인스턴스 변수와 동일한 클래스 내에서 인스턴스화되는 경우입니다 . 이로 인해 동일한 클래스의 생성자가 반복해서(재귀적으로) 호출되어 결국 StackOverflowError가 발생합니다.

다음 섹션에서는 이러한 시나리오를 보여주는 몇 가지 코드 예제를 살펴보겠습니다.

3. 작동 중인 StackOverflowError

아래에 표시된 예 에서 개발자가 재귀 동작에 대한 종료 조건을 지정하는 것을 잊은 의도하지 않은 재귀로 인해 StackOverflowError 가 발생합니다.

public class UnintendedInfiniteRecursion {
    public int calculateFactorial(int number) {
        return number * calculateFactorial(number - 1);
    }
}

여기에서 메서드에 전달된 모든 값에 대해 모든 경우에 오류가 발생합니다.

public class UnintendedInfiniteRecursionManualTest {
    @Test(expected = StackOverflowError.class)
    public void givenPositiveIntNoOne_whenCalFact_thenThrowsException() {
        int numToCalcFactorial= 1;
        UnintendedInfiniteRecursion uir 
          = new UnintendedInfiniteRecursion();
        
        uir.calculateFactorial(numToCalcFactorial);
    }
    
    @Test(expected = StackOverflowError.class)
    public void givenPositiveIntGtOne_whenCalcFact_thenThrowsException() {
        int numToCalcFactorial= 2;
        UnintendedInfiniteRecursion uir 
          = new UnintendedInfiniteRecursion();
        
        uir.calculateFactorial(numToCalcFactorial);
    }
    
    @Test(expected = StackOverflowError.class)
    public void givenNegativeInt_whenCalcFact_thenThrowsException() {
        int numToCalcFactorial= -1;
        UnintendedInfiniteRecursion uir 
          = new UnintendedInfiniteRecursion();
        
        uir.calculateFactorial(numToCalcFactorial);
    }
}

그러나 다음 예제에서는 종료 조건이 지정되었지만 -1 값이 calculateFactorial() 메서드에 전달 되면 종료되지 않은/무한 재귀가 발생 하는 경우 결코 충족되지 않습니다 .

public class InfiniteRecursionWithTerminationCondition {
    public int calculateFactorial(int number) {
       return number == 1 ? 1 : number * calculateFactorial(number - 1);
    }
}

이 테스트 세트는 이 시나리오를 보여줍니다.

public class InfiniteRecursionWithTerminationConditionManualTest {
    @Test
    public void givenPositiveIntNoOne_whenCalcFact_thenCorrectlyCalc() {
        int numToCalcFactorial = 1;
        InfiniteRecursionWithTerminationCondition irtc 
          = new InfiniteRecursionWithTerminationCondition();

        assertEquals(1, irtc.calculateFactorial(numToCalcFactorial));
    }

    @Test
    public void givenPositiveIntGtOne_whenCalcFact_thenCorrectlyCalc() {
        int numToCalcFactorial = 5;
        InfiniteRecursionWithTerminationCondition irtc 
          = new InfiniteRecursionWithTerminationCondition();

        assertEquals(120, irtc.calculateFactorial(numToCalcFactorial));
    }

    @Test(expected = StackOverflowError.class)
    public void givenNegativeInt_whenCalcFact_thenThrowsException() {
        int numToCalcFactorial = -1;
        InfiniteRecursionWithTerminationCondition irtc 
          = new InfiniteRecursionWithTerminationCondition();

        irtc.calculateFactorial(numToCalcFactorial);
    }
}

이 특별한 경우에 종료 조건을 다음과 같이 지정하면 오류를 완전히 방지할 수 있습니다.

public class RecursionWithCorrectTerminationCondition {
    public int calculateFactorial(int number) {
        return number <= 1 ? 1 : number * calculateFactorial(number - 1);
    }
}

실제로 이 시나리오를 보여주는 테스트는 다음과 같습니다.

public class RecursionWithCorrectTerminationConditionManualTest {
    @Test
    public void givenNegativeInt_whenCalcFact_thenCorrectlyCalc() {
        int numToCalcFactorial = -1;
        RecursionWithCorrectTerminationCondition rctc 
          = new RecursionWithCorrectTerminationCondition();

        assertEquals(1, rctc.calculateFactorial(numToCalcFactorial));
    }
}

이제 클래스 간의 순환 관계의 결과로 StackOverflowError 가 발생 하는 시나리오를 살펴보겠습니다 . 순환 관계를 일으키는 생성자 내에서 서로를 인스턴스화하는 ClassOneClassTwo 를 고려해 보겠습니다 .

public class ClassOne {
    private int oneValue;
    private ClassTwo clsTwoInstance = null;
    
    public ClassOne() {
        oneValue = 0;
        clsTwoInstance = new ClassTwo();
    }
    
    public ClassOne(int oneValue, ClassTwo clsTwoInstance) {
        this.oneValue = oneValue;
        this.clsTwoInstance = clsTwoInstance;
    }
}
public class ClassTwo {
    private int twoValue;
    private ClassOne clsOneInstance = null;
    
    public ClassTwo() {
        twoValue = 10;
        clsOneInstance = new ClassOne();
    }
    
    public ClassTwo(int twoValue, ClassOne clsOneInstance) {
        this.twoValue = twoValue;
        this.clsOneInstance = clsOneInstance;
    }
}

이제 이 테스트에서 볼 수 있듯이 ClassOne 을 인스턴스화하려고 한다고 가정해 보겠습니다 .

public class CyclicDependancyManualTest {
    @Test(expected = StackOverflowError.class)
    public void whenInstanciatingClassOne_thenThrowsException() {
        ClassOne obj = new ClassOne();
    }
}

ClassOne의 생성자가 ClassTwo 를 인스턴스화하고 ClassTwo 의 생성자가 다시 ClassOne 을 인스턴스화 하기 때문에 이것은 StackOverflowError 로 끝납니다 . 그리고 이것은 스택을 넘칠 때까지 반복적으로 발생합니다.

다음으로 클래스가 해당 클래스의 인스턴스 변수와 동일한 클래스 내에서 인스턴스화될 때 어떤 일이 발생하는지 살펴보겠습니다.

다음 예제에서 볼 수 있듯이 AccountHolder 는 자신을 인스턴스 변수 jointAccountHolder 로 인스턴스화합니다 .

public class AccountHolder {
    private String firstName;
    private String lastName;
    
    AccountHolder jointAccountHolder = new AccountHolder();
}

AccountHolder 클래스가 인스턴스화 되면 이 테스트에서 볼 수 있듯이 생성자의 재귀 호출로 인해 StackOverflowError가 발생 합니다 .

public class AccountHolderManualTest {
    @Test(expected = StackOverflowError.class)
    public void whenInstanciatingAccountHolder_thenThrowsException() {
        AccountHolder holder = new AccountHolder();
    }
}

4. StackOverflowError 처리

StackOverflowError 가 발생 했을 때 가장 좋은 방법 은 스택 추적을 주의 깊게 검사하여 반복되는 줄 번호 패턴을 식별하는 것입니다. 이렇게 하면 문제가 있는 재귀가 있는 코드를 찾을 수 있습니다.

앞에서 본 코드 예제로 인해 발생한 몇 가지 스택 추적을 살펴보겠습니다.

이 스택 추적은 예상되는 예외 선언 을 생략하는 경우 InfiniteRecursionWithTerminationConditionManualTest 에 의해 생성됩니다.

java.lang.StackOverflowError

 at c.b.s.InfiniteRecursionWithTerminationCondition
  .calculateFactorial(InfiniteRecursionWithTerminationCondition.java:5)
 at c.b.s.InfiniteRecursionWithTerminationCondition
  .calculateFactorial(InfiniteRecursionWithTerminationCondition.java:5)
 at c.b.s.InfiniteRecursionWithTerminationCondition
  .calculateFactorial(InfiniteRecursionWithTerminationCondition.java:5)
 at c.b.s.InfiniteRecursionWithTerminationCondition
  .calculateFactorial(InfiniteRecursionWithTerminationCondition.java:5)

여기에서 라인 번호 5가 반복되는 것을 볼 수 있습니다. 재귀 호출이 수행되는 곳입니다. 이제 재귀가 올바른 방식으로 수행되는지 확인하기 위해 코드를 검사하는 문제입니다.

다음은 CyclicDependancyManualTest 를 실행하여 얻은 스택 추적입니다 (역시 예상되는 예외 없이).

java.lang.StackOverflowError
  at c.b.s.ClassTwo.<init>(ClassTwo.java:9)
  at c.b.s.ClassOne.<init>(ClassOne.java:9)
  at c.b.s.ClassTwo.<init>(ClassTwo.java:9)
  at c.b.s.ClassOne.<init>(ClassOne.java:9)

이 스택 추적은 순환 관계에 있는 두 클래스에서 문제를 일으키는 줄 번호를 보여줍니다. ClassTwo 의 라인 번호 9 ClassOne 의 라인 번호 9 는 다른 클래스를 인스턴스화하려는 생성자 내부의 위치를 ​​가리킵니다.

코드를 철저히 검사하고 다음 중 어느 것도(또는 다른 코드 논리 오류) 오류의 원인이 아닌 경우:

  • 잘못 구현된 재귀(예: 종료 조건 없음)
  • 클래스 간의 순환 의존성
  • 해당 클래스의 인스턴스 변수와 동일한 클래스 내에서 클래스 인스턴스화

스택 크기를 늘리는 것이 좋습니다. 설치된 JVM에 따라 기본 스택 크기가 다를 수 있습니다.

-Xss 플래그를 사용하여 프로젝트 구성 또는 명령줄에서 스택 크기를 늘릴 수 있습니다 .

5. 결론

이 기사에서는 Java 코드가 원인이 되는 방법과 이를 진단하고 수정하는 방법을 포함 하여 StackOverflowError 에 대해 자세히 살펴보았습니다 .

이 기사와 관련된 소스 코드 는 GitHub 에서 찾을 수 있습니다 .

Generic footer banner