안녕하세요~ 경제적 자유입니다.
더 자세히 공부하고 싶다면 아래 문서도 함께 참고해보세요.
- 참고 링크: Java Recursion
- 참고 링크: Java instanceof
1. 재귀 호출이란?
재귀 호출(Recursion)은 메서드가 실행되는 도중에 자기 자신을 다시 호출하는 방식입니다.
for (int number = 3; number >= 1; number--) {
System.out.println(number);
}
재귀 호출에서는 메서드가 숫자 하나를 출력한 뒤, 더 작은 숫자를 전달하면서 자기 자신을 다시 호출합니다.
countDown(3);
→ countDown(2);
→ countDown(1);
→ 종료
즉, 하나의 메서드가 문제를 조금 더 작은 문제로 바꾼 뒤 같은 메서드에 다시 맡기는 흐름이라고 보면 됩니다.
다만 메서드가 계속 자기 자신을 호출하기만 하면 호출이 끝나지 않습니다.
따라서 재귀 메서드에는 반드시 다음 두 가지가 필요합니다.
- 재귀 호출: 메서드가 자기 자신을 다시 호출하는 부분
- 종료 조건: 더 이상 자기 자신을 호출하지 않고 멈추는 부분
기본 문법
반환타입 메서드이름(매개변수) {
if (종료 조건) {
return 반환값;
}
return 메서드이름(변경된 값);
}
종료 조건이 만족되면 메서드는 값을 반환하면서 호출을 멈춥니다.
종료 조건이 만족되지 않으면 값을 조금 변경한 뒤 같은 메서드를 다시 호출합니다.
재귀 호출에서 중요한 것은 호출할 때마다 종료 조건에 가까워져야 한다는 점입니다.
public class Main {
public static void countDown(String name, int number) {
// 종료 조건
if (number == 0) {
System.out.println(name + "의 카운트다운이 끝났습니다.");
return;
}
System.out.println(name + ": " + number);
// 숫자를 1 줄여서 자기 자신을 다시 호출
countDown(name, number - 1);
}
public static void main(String[] args) {
String name = "상구";
countDown(name, 3);
}
}
// 실행 결과:
// 상구: 3
// 상구: 2
// 상구: 1
// 상구의 카운트다운이 끝났습니다.
main() 메서드에서 countDown("상구", 3)을 호출하면 다음과 같은 순서로 실행됩니다.
countDown("상구", 3)
countDown("상구", 2)
countDown("상구", 1)
countDown("상구", 0)
숫자가 0이 되기 전까지는 number - 1을 전달하면서 같은 메서드를 다시 호출합니다.
마지막으로 숫자가 0이 되면 다음 조건이 참이 됩니다.
if (number == 0)
이때 return이 실행되면서 더 이상 재귀 호출을 하지 않습니다.
여기서 number == 0이 종료 조건, countDown(name, number - 1)이 재귀 호출에 해당합니다.
재귀 호출은 어떻게 돌아오는 걸까?
재귀 호출은 단순히 아래 방향으로만 계속 내려가는 것이 아닙니다.
호출된 메서드가 끝나면 이전에 실행 중이던 메서드로 다시 돌아옵니다.
public class Main {
public static void printNumber(String name, int number) {
if (number == 0) {
return;
}
System.out.println(name + " 내려가는 중: " + number);
printNumber(name, number - 1);
System.out.println(name + " 돌아오는 중: " + number);
}
public static void main(String[] args) {
String name = "상구";
printNumber(name, 3);
}
}
// 실행 결과:
// 상구 내려가는 중: 3
// 상구 내려가는 중: 2
// 상구 내려가는 중: 1
// 상구 돌아오는 중: 1
// 상구 돌아오는 중: 2
// 상구 돌아오는 중: 3
재귀 호출과 스택
Java에서 메서드가 호출되면 해당 메서드를 실행하는 데 필요한 정보가 호출 스택(Call Stack)에 저장됩니다.
재귀 메서드가 자기 자신을 호출할 때마다 새로운 메서드 호출 정보가 스택에 쌓입니다.
countDown(3)
└── countDown(2)
└── countDown(1)
└── countDown(0)
가장 마지막에 호출된 countDown(0)이 먼저 끝나고, 그다음 countDown(1), countDown(2), countDown(3) 순서로 종료됩니다.
스택은 마지막에 들어온 것이 먼저 나가는 구조이기 때문입니다.
종료 조건이 없다면 메서드 호출이 계속 쌓이게 됩니다.
public class Main {
public static void repeat(String name) {
System.out.println(name);
// 종료 조건 없이 계속 자기 자신을 호출
repeat(name);
}
public static void main(String[] args) {
repeat("상구");
}
}
// 실행 결과:
// 상구
// 상구
// 상구
// ...
// 종료되지 않고 호출이 계속되어 StackOverflowError가 발생할 수 있음
이 코드는 repeat() 메서드를 멈출 조건이 없습니다.
결국 호출 스택의 공간이 부족해지면서 StackOverflowError가 발생합니다.
따라서 재귀 메서드를 작성할 때는 다음 두 가지를 꼭 확인해야 합니다.
- 호출을 멈추는 종료 조건이 있는가?
- 재귀 호출을 반복할수록 종료 조건에 가까워지는가?
public class Main {
public static void printName(String name, int index) {
// 문자열의 끝에 도달하면 종료
if (index == name.length()) {
return;
}
System.out.println(name.charAt(index));
// 다음 위치를 확인
printName(name, index + 1);
}
public static void main(String[] args) {
String name = "상구";
printName(name, 0);
}
}
// 실행 결과:
// 상
// 구
처음에는 index가 0이므로 name.charAt(0)에 해당하는 "상"이 출력됩니다.
그다음 index + 1을 전달해 printName(name, 1)을 호출하고, "구"를 출력합니다.
마지막에는 index와 name.length()가 같아지므로 재귀 호출이 종료됩니다.
이처럼 재귀 호출은 현재 위치를 처리하고 다음 위치를 같은 메서드에 맡기는 구조에서 사용할 수 있습니다.
재귀 호출은 언제 사용할까?
재귀 호출은 문제 자체가 반복적으로 작은 문제로 나누어지는 경우에 잘 어울립니다.
예를 들면 다음과 같은 상황이 있습니다.
- 팩토리얼 계산
- 폴더와 하위 폴더 탐색
- 트리 자료구조 탐색
- 그래프 탐색
- 분할 정복 알고리즘
- 백트래킹 문제
따라서 재귀가 문제 구조를 더 자연스럽게 표현할 때 사용하고, 호출 깊이가 지나치게 깊어지지 않는지 확인하는 것이 중요합니다.
2. instanceof 연산자란?
instanceof는 객체가 특정 타입으로 취급될 수 있는지 확인하는 연산자입니다.
기본적으로 다음과 같은 질문에 답한다고 보면 됩니다.
이 객체는 이 클래스 또는 인터페이스 타입에 해당하는가?
검사 결과는 boolean 값인 true 또는 false로 나옵니다.
객체 instanceof 타입
객체가 오른쪽에 적은 타입과 호환되면 true, 호환되지 않으면 false가 됩니다.
여기서 확인하는 것은 변수 선언부에 적힌 타입만이 아니라, 변수가 실제로 가리키는 객체의 타입입니다.
기본 문법
객체참조변수 instanceof 클래스 또는 인터페이스
예를 들어 다음과 같이 사용할 수 있습니다.
if (person instanceof Student) {
System.out.println("Student 타입의 객체입니다.");
}
person이 가리키는 객체가 Student 클래스의 객체이거나 Student의 하위 클래스 객체라면 조건이 참이 됩니다.
instanceof는 객체의 타입을 확인한 뒤 형 변환해야 하는 상황에서 자주 사용합니다.
public class Main {
public static void main(String[] args) {
String name = "상구";
boolean isString = name instanceof String;
boolean isObject = name instanceof Object;
System.out.println(isString);
System.out.println(isObject);
}
}
// 실행 결과:
// true
// true
상속 관계에서 instanceof 사용하기
class Person {
String name;
Person(String name) {
this.name = name;
}
}
class Student extends Person {
Student(String name) {
super(name);
}
void study() {
System.out.println(name + "가 Java를 공부합니다.");
}
}
public class Main {
public static void main(String[] args) {
Person person = new Student("상구");
System.out.println(person instanceof Person);
System.out.println(person instanceof Student);
if (person instanceof Student) {
Student student = (Student) person;
student.study();
}
}
}
// 실행 결과:
// true
// true
// 상구가 Java를 공부합니다.
변수 선언만 보면 person의 타입은 Person입니다.
Person person
하지만 실제로 생성한 객체는 Student입니다.
new Student("상구")
따라서 다음 두 조건이 모두 참이 됩니다.
person instanceof Person
person instanceof Student
Student는 Person을 상속받으므로 Student 객체는 Student 타입이면서 동시에 Person 타입으로도 취급할 수 있습니다.
하지만 person 변수에서는 Person에 정의된 멤버만 바로 사용할 수 있습니다.
Student에만 존재하는 study() 메서드를 호출하려면 다음과 같이 다운캐스팅해야 합니다.
Student student = (Student) person;
이때 실제 객체가 Student인지 확인하지 않고 무조건 형 변환하면 ClassCastException이 발생할 수 있습니다.
그래서 일반적으로는 instanceof로 타입을 확인한 뒤 형 변환합니다.
if (person instanceof Student) {
Student student = (Student) person;
student.study();
}
변수의 타입과 객체의 실제 타입
다음 코드는 instanceof를 이해할 때 중요한 차이를 보여줍니다.
Person person = new Student("상구");
이 코드에는 두 가지 타입이 존재합니다.
- 참조변수의 타입: Person
- 실제 객체의 타입: Student
왼쪽의 Person은 변수를 통해 어떤 멤버에 접근할 수 있는지 결정합니다.
오른쪽의 new Student("상구")는 메모리에 실제로 만들어진 객체의 타입을 나타냅니다.
instanceof는 실행 중에 변수가 실제로 어떤 객체를 가리키는지 확인합니다.
person instanceof Student
따라서 변수의 선언 타입이 Person이어도 실제 객체가 Student라면 결과는 true입니다.
instanceof와 잘못된 형 변환 방지하기
다음과 같이 Person을 상속받는 클래스가 두 개 있다고 생각해보겠습니다.
class Person {
String name;
Person(String name) {
this.name = name;
}
}
class Student extends Person {
Student(String name) {
super(name);
}
void study() {
System.out.println(name + "가 공부합니다.");
}
}
class Developer extends Person {
Developer(String name) {
super(name);
}
void code() {
System.out.println(name + "가 코드를 작성합니다.");
}
}
public class Main {
public static void main(String[] args) {
Person person = new Developer("상구");
if (person instanceof Student) {
Student student = (Student) person;
student.study();
} else if (person instanceof Developer) {
Developer developer = (Developer) person;
developer.code();
}
}
}
// 실행 결과:
// 상구가 코드를 작성합니다.
person 변수가 실제로 가리키는 객체는 Developer입니다.
따라서 다음 조건은 거짓입니다.
person instanceof Student
반면 다음 조건은 참입니다.
person instanceof Developer
instanceof 검사 없이 아래와 같이 변환하면 문제가 발생합니다.
Student student = (Student) person;
person이 실제로 가리키는 것은 Developer 객체이므로 Student로 변환할 수 없습니다. 실행하면 ClassCastException이 발생합니다.
따라서 여러 하위 타입을 하나의 부모 타입으로 관리하고 있다면, 형 변환 전에 실제 객체 타입을 확인하는 것이 안전합니다.
null을 instanceof로 검사하면 어떻게 될까?
instanceof 왼쪽에 있는 참조변수의 값이 null이면 결과는 항상 false입니다.
public class Main {
public static void main(String[] args) {
String name = null;
System.out.println(name instanceof String);
System.out.println(name instanceof Object);
}
}
// 실행 결과:
// false
// false
null은 어떤 객체도 가리키고 있지 않은 상태입니다.
따라서 String 객체인지 검사해도 false, Object 객체인지 검사해도 false가 됩니다.
이 검사 자체로 NullPointerException이 발생하지는 않습니다.
그래서 다음처럼 작성하면 객체가 null이 아닌지와 특정 타입인지 여부를 한 번에 확인할 수 있습니다.
if (name instanceof String) {
System.out.println(name.length());
}
조건이 참이라면 name은 null이 아니면서 String 타입의 객체를 가리킨다는 뜻입니다.
인터페이스도 instanceof로 검사할 수 있다
instanceof 오른쪽에는 클래스뿐 아니라 인터페이스도 사용할 수 있습니다.
interface Studyable {
void study();
}
class Student implements Studyable {
String name;
Student(String name) {
this.name = name;
}
@Override
public void study() {
System.out.println(name + "가 Java를 공부합니다.");
}
}
public class Main {
public static void main(String[] args) {
Student student = new Student("상구");
System.out.println(student instanceof Student);
System.out.println(student instanceof Studyable);
System.out.println(student instanceof Object);
}
}
// 실행 결과:
// true
// true
// true
Student 객체는 다음 세 가지 타입으로 취급할 수 있습니다.
- 자신의 클래스인 Student
- 구현한 인터페이스인 Studyable
- 모든 클래스의 최상위 클래스인 Object
따라서 세 개의 instanceof 검사 결과가 모두 true가 됩니다.
instanceof 패턴 매칭
비교적 최근 Java에서는 instanceof 검사와 형 변환을 한 문장으로 묶어 작성할 수도 있습니다.
다음은 기존 방식입니다.
if (person instanceof Student) {
Student student = (Student) person;
student.study();
}
person이 Student인지 확인한 뒤, 다시 명시적으로 형 변환하고 있습니다.
패턴 매칭을 사용하면 다음과 같이 줄일 수 있습니다.
class Person {
String name;
Person(String name) {
this.name = name;
}
}
class Student extends Person {
Student(String name) {
super(name);
}
void study() {
System.out.println(name + "가 Java를 공부합니다.");
}
}
public class Main {
public static void main(String[] args) {
Person person = new Student("상구");
// 타입 검사와 변수 선언을 함께 처리
if (person instanceof Student student) {
student.study();
}
}
}
// 실행 결과:
// 상구가 Java를 공부합니다.
다음 표현식에서 student는 패턴 변수입니다.
person instanceof Student student
person이 실제로 Student 타입일 때만 조건이 참이 되고, 조건문 내부에서 student 변수를 사용할 수 있습니다.
따라서 별도의 다운캐스팅 코드를 작성하지 않아도 됩니다.
다만 이 문법은 Java 16부터 정식 기능으로 사용할 수 있으므로, 이전 Java 버전을 사용한다면 기존의 형 변환 방식을 사용해야 합니다.
instanceof를 지나치게 많이 사용해도 될까?
instanceof는 형 변환 전에 객체 타입을 안전하게 확인할 수 있는 유용한 연산자입니다.
하지만 코드 곳곳에서 타입을 계속 검사하는 구조는 객체지향적인 설계가 충분히 활용되지 않았다는 신호일 수도 있습니다.
예를 들어 다음처럼 객체의 종류를 하나씩 검사할 수 있습니다.
if (person instanceof Student) {
// 학생의 행동
} else if (person instanceof Developer) {
// 개발자의 행동
}
타입이 계속 추가되면 조건문도 함께 길어집니다.
공통된 행동을 부모 클래스나 인터페이스의 메서드로 정의할 수 있다면, 오버라이딩과 다형성을 활용하는 편이 더 자연스러울 수 있습니다.
abstract class Person {
String name;
Person(String name) {
this.name = name;
}
abstract void introduce();
}
class Student extends Person {
Student(String name) {
super(name);
}
@Override
void introduce() {
System.out.println(name + "는 학생입니다.");
}
}
public class Main {
public static void main(String[] args) {
Person person = new Student("상구");
// 실제 객체의 오버라이딩된 메서드가 실행됨
person.introduce();
}
}
// 실행 결과:
// 상구는 학생입니다.
이 코드에서는 객체의 타입을 직접 검사하지 않아도 실제 객체에 맞는 introduce() 메서드가 실행됩니다.
따라서 instanceof는 무조건 피해야 하는 문법은 아니지만, 단순히 객체별 행동을 구분하기 위해 반복적으로 사용하고 있다면 다형성으로 바꿀 수 있는지 생각해볼 필요가 있습니다.
마무리
오늘은 Java의 재귀 호출과 instanceof 연산자에 대해 정리해봤습니다.
재귀 호출은 자주 사용되는 것인지는 잘 모르지만 알고리즘에서는 사용하는 것을 조금 볼 수는 있었습니다..
Instanceof는 이것이 어떤 자료형인지 그리고 어떠한 클래스에 속해있는 것인지에 대해서 알 수 있으며 많이 사용된다고 합니다만.. 조금 더 익숙해 지기를...
공부화이팅!

