웹앱을 만들면서 재귀 함수 때문에 이렇게 오래 고생할 줄은 몰랐습니다. C언어를 공부할 때는 재귀 함수가 나오면 팩토리얼이나 피보나치 정도만 떠올렸는데, 대댓글과 카테고리 메뉴를 직접 만들다 보니 생각보다 자연스럽게 쓰게 됐습니다.
처음에는 별로 어렵지 않을 거라고 생각했습니다. 댓글 아래에 대댓글이 있으면 다시 같은 함수를 호출하고, 카테고리 안에 하위 카테고리가 있으면 또 같은 함수를 호출하면 된다고 생각했습니다. 실제로 코드도 몇 줄 안 됐습니다. 그런데 그 몇 줄에서 실수 하나가 생기면서 거의 6시간을 날렸습니다.
오류가 발생했을 때 원인을 도저히 못 찾아서 같은 코드만 계속 보고 있었습니다. 중간에는 그냥 오늘은 포기할까 싶었고, 마지막에는 재귀 함수를 아예 빼버리고 다른 방법으로 다시 만들어야 하나 생각할 정도였습니다.
대댓글 재귀 호출
처음 문제가 생긴 건 대댓글을 화면에 보여주는 부분이었습니다. 댓글 안에 대댓글이 있으면 다시 같은 함수를 호출하도록 만들었습니다.
function renderComment(comment) {
console.log(comment.text);
for (const child of comment.children) {
renderComment(child);
}
}
처음 몇 개의 댓글로 테스트했을 때는 잘 됐습니다. 댓글 아래에 대댓글도 잘 나왔고, 대댓글 안에 답글을 하나 더 넣어도 제대로 표시됐습니다. 여기까지는 생각보다 쉽게 끝났다고 생각했습니다.
그러다가 코드를 조금 수정했는데 갑자기 브라우저가 멈추기 시작했습니다. 개발자 도구를 열어보니 콘솔에는 이런 오류가 떠 있었습니다.
RangeError: Maximum call stack size exceeded
재귀 함수를 잘못 작성하면 호출이 계속 쌓이면서 이런 오류가 날 수 있다는 건 알고 있었습니다. 문제는 제 코드에서 어디가 잘못됐는지 도저히 찾을 수가 없었다는 점입니다.
종료 조건 누락
한참 뒤에 발견한 첫 번째 문제는 종료 조건이 제대로 들어가 있지 않았다는 것이었습니다. 코드를 수정하면서 테스트용으로 재귀 호출 부분을 따로 만들었는데, 끝나는 조건을 빼먹었습니다.
function renderReply(reply) {
console.log(reply.text);
renderReply(reply);
}
지금 보면 너무 뻔합니다. 같은 reply를 계속 자기 자신에게 넘기고 있으니 끝날 방법이 없습니다. 그런데 오류를 찾고 있을 때는 이게 그렇게 안 보였습니다.
처음에는 브라우저 문제인가 싶어서 새로고침도 해보고, 개발 서버도 껐다 켰습니다. 그다음에는 댓글 데이터를 의심했고, API 응답도 계속 확인했습니다. 그래도 똑같았습니다.
한두 시간이 지나니까 슬슬 짜증이 나기 시작했습니다. 오류 메시지는 계속 같은데 원인은 안 보였습니다. 코드를 수정했다가 다시 원래대로 돌리는 일을 반복하다 보니 나중에는 어느 부분을 처음에 어떻게 작성했는지도 헷갈렸습니다.
결국 재귀 함수 안에 로그를 하나씩 추가하면서 몇 번 호출되는지 보기 시작했습니다.
function renderReply(reply) {
console.log("호출됨:", reply.id);
renderReply(reply);
}
콘솔에 같은 ID가 끝없이 찍히는 걸 보고서야 확실히 이상하다는 생각이 들었습니다.
호출됨: 3 호출됨: 3 호출됨: 3 호출됨: 3 호출됨: 3 ...
그제야 다음 대댓글로 넘어가야 하는데 현재 댓글을 그대로 다시 넘기고 있다는 걸 발견했습니다.
재귀 인자 실수
원래 작성하려던 코드는 이거였습니다.
function renderReply(reply) {
console.log(reply.text);
for (const child of reply.children ?? []) {
renderReply(child);
}
}
그런데 코드를 수정하다가 실수로 이렇게 작성했던 겁니다.
function renderReply(reply) {
console.log(reply.text);
for (const child of reply.children ?? []) {
renderReply(reply);
}
}
child를 넘겨야 하는데 reply를 넘기고 있었습니다. 변수 이름 하나 차이였습니다.
이 오류 때문에 정말 오래 헤맸습니다. 반복문도 정상이고 children도 제대로 들어오고 있었습니다. 그래서 재귀 호출 안에 있는 인자 하나를 잘못 넣었을 거라고는 생각을 못 했습니다.
더 황당했던 건 코드를 보고 또 봤다는 점입니다. 화면에 띄워놓고 몇 번이나 읽었는데도 reply가 child로 보였던 것 같습니다. 이미 머릿속에서 제가 작성하려던 코드를 보고 있으니 실제로 적혀 있는 코드가 잘 안 보였습니다.
6시간 디버깅 시간
오류를 처음 발견하고 거의 6시간 정도를 헤맸습니다. 처음 한 시간 정도는 금방 찾을 수 있을 거라고 생각했습니다. 두 시간이 넘어가면서부터는 짜증이 났고, 네다섯 시간이 지나니까 그냥 재귀 함수를 사용하지 말까 하는 생각까지 들었습니다.
중간에는 코드를 아예 지우고 다시 작성하기도 했습니다. 함수 이름도 바꿔보고 반복문도 바꿔보고, 혹시 브라우저가 이상한 건가 싶어서 다른 브라우저에서도 실행해봤습니다. 당연히 똑같이 오류가 났습니다.
가장 답답했던 건 오류가 복잡해서가 아니었습니다. 코드가 너무 짧았습니다. 몇 줄밖에 안 되는데 그 안에서 문제를 못 찾고 있다는 게 더 짜증 났습니다.
결국 포기하기 직전에 재귀 호출에 들어가는 값을 하나씩 찍어봤습니다.
console.log("현재 댓글:", reply.id);
console.log("자식 댓글:", child.id);
renderReply(reply);
콘솔에서는 현재 댓글과 자식 댓글의 ID가 분명히 다르게 나오는데, 다음 호출에서는 또 현재 댓글 ID가 나왔습니다.
그 순간 재귀 호출 부분을 다시 봤습니다.
renderReply(reply);
여기서 한동안 멈춰 있었습니다. 그리고 바로 아래처럼 바꿨습니다.
renderReply(child);
저장하고 다시 브라우저를 새로고침했는데 아까까지 멈추던 화면이 바로 떴습니다. 콘솔에 계속 나오던 빨간 오류도 사라졌습니다.
6시간 가까이 찾던 문제가 고작 변수 하나였습니다. 허무한데 동시에 엄청 시원했습니다. 진짜로 몇 초 동안 화면만 보고 있었습니다. 계속 실패하던 코드가 갑자기 너무 아무렇지도 않게 잘 돌아가니까 웃음도 났습니다.
카테고리 종료 조건
대댓글에서 그렇게 고생하고 나니 카테고리 메뉴를 만들 때는 재귀 함수를 꽤 조심해서 작성했습니다. 그런데 여기서도 또 한 번 실수했습니다.
function renderCategory(category, depth = 0) {
console.log(category.name);
if (depth === 5) {
return;
}
for (const child of category.children ?? []) {
renderCategory(child, depth);
}
}
이번에는 최대 5단계까지만 카테고리를 보여주려고 depth를 넣었습니다. 종료 조건도 있으니 이번에는 괜찮을 거라고 생각했습니다.
그런데 또 호출이 끝나지 않았습니다.
이유는 depth 값을 증가시키지 않았기 때문이었습니다.
renderCategory(child, depth);
depth는 처음부터 끝까지 0이었습니다. 그러니 아래 조건에 도달할 일이 없었습니다.
if (depth === 5) {
return;
}
수정은 한 글자 수준으로 간단했습니다.
renderCategory(child, depth + 1);
재귀 함수에서 종료 조건을 작성했다고 끝이 아니었습니다. 재귀 호출을 할 때 그 종료 조건에 가까워지도록 값도 바뀌어야 했습니다.
C언어에서 재귀 함수를 배울 때 n - 1을 왜 계속 강조하는지 이때 제대로 알았습니다.
function countDown(n) {
if (n === 0) {
return;
}
countDown(n - 1);
}
종료 조건이 있어도 아래처럼 작성하면 아무 소용이 없습니다.
function countDown(n) {
if (n === 0) {
return;
}
countDown(n);
}
웹앱에서 대댓글이나 카테고리를 만들 때도 똑같았습니다. 다음 대댓글을 넘기든 깊이 값을 올리든, 재귀 호출을 할 때 이전과 다른 상태로 넘어가야 했습니다.
재귀 함수 무한 호출
재귀 함수 오류를 몇 번 겪고 나니 Maximum call stack size exceeded라는 문구만 봐도 예전처럼 막막하지는 않게 됐습니다.
처음에는 오류가 뜨면 코드 전체를 의심했습니다. 이제는 가장 먼저 현재 함수가 다음 호출에서 무엇을 넘기고 있는지 확인합니다.
console.log("현재:", current);
console.log("다음:", next);
그리고 종료 조건도 같이 봅니다. 종료 조건이 있는지, 있어도 실제로 그 조건에 도달할 수 있는지를 확인합니다.
if (depth >= 5) {
return;
}
아무리 멀쩡한 종료 조건이 있어도 depth가 계속 같은 값이면 의미가 없었습니다. 자식 댓글을 호출해야 하는데 현재 댓글을 다시 호출해도 마찬가지였습니다.
오류 발견 순간
개발하면서 오류가 나면 짜증이 나는 건 어쩔 수 없는 것 같습니다. 특히 몇 시간 동안 못 찾으면 더 그렇습니다. 검색해도 이미 아는 이야기만 나오고, 코드를 봐도 이상한 곳이 안 보이고, 이것저것 바꿔도 계속 같은 오류가 뜨면 그냥 그만하고 싶어집니다.
이번에도 거의 포기하기 직전까지 갔습니다. 내일 다시 볼까 생각했고, 재귀 함수를 빼버릴까도 생각했습니다.
그런데 마지막으로 로그를 하나 더 찍어본 게 답을 찾게 해줬습니다. 현재 댓글과 다음 댓글은 다른데 재귀 함수에 들어가는 값은 계속 같다는 걸 발견했고, 결국 reply 대신 child를 넣으면서 해결됐습니다.
6시간 동안 사람을 괴롭히던 오류가 한 줄 수정으로 끝났습니다.
renderReply(reply);
이걸
renderReply(child);
이렇게 바꾼 게 전부였습니다.
고치기 전에는 그렇게 안 보이던 게 고치고 나니까 너무 뻔하게 보였습니다. 그래서 더 허무했고, 그만큼 더 짜릿했습니다.
웹앱 재귀 함수 경험
C언어에서 재귀 함수를 공부할 때는 웹앱을 만들면서 실제로 써먹을 일이 얼마나 있을까 싶었습니다. 직접 대댓글과 카테고리를 만들어보니 쓸 일이 있었습니다.
그리고 책에서 재귀 문제를 풀 때보다 실제 웹앱에서 오류를 한 번 겪는 게 훨씬 오래 기억에 남았습니다.
종료 조건을 빠뜨리면 어떻게 되는지, 다음 호출에 같은 값을 넘기면 왜 끝나지 않는지, 종료 조건을 만들어놓고도 값이 변하지 않으면 왜 소용이 없는지를 직접 겪었습니다.
특히 6시간 가까이 오류를 못 찾아서 포기하려다가 마지막에 변수 하나를 발견했던 순간은 아직도 기억에 남습니다. 그날 이후 재귀 함수가 나오면 코드를 작성하는 것보다 먼저 다음 호출에 어떤 값이 들어가는지를 한 번 더 확인하게 됐습니다.
몇 시간 동안 헤맬 때는 다시는 재귀 함수를 쓰고 싶지 않았는데, 막상 오류를 잡고 나니 오히려 제대로 하나 배운 기분이 들었습니다. 개발하면서 이런 순간 때문에 계속 붙잡고 있는 건가 싶기도 했습니다.
결론
웹앱을 만들면서 재귀 함수 오류 하나 때문에 6시간 가까이 헤매고 포기할 생각까지 했지만, 결국 원인은 재귀 호출에 잘못된 값을 넘긴 한 줄의 코드였습니다. 당시에는 정말 답답했지만 직접 무한 호출을 겪고 원인을 찾아낸 덕분에 종료 조건과 재귀 호출 값을 왜 꼼꼼하게 확인해야 하는지는 확실히 배웠습니다.
FAQ
웹앱 개발에서도 재귀 함수를 자주 사용하나요?
항상 사용하는 것은 아니지만 대댓글, 카테고리 메뉴, 폴더 목록처럼 같은 형태의 데이터가 계속 아래로 이어지는 기능에서는 재귀 함수가 꽤 잘 맞습니다.
재귀 함수에서 가장 흔하게 발생하는 오류는 무엇인가요?
종료 조건을 빼먹거나, 다음 값을 넘겨야 하는데 현재 값을 그대로 다시 넘기는 실수가 대표적입니다. 이런 경우 함수 호출이 끝나지 않으면서 Maximum call stack size exceeded 오류가 발생할 수 있습니다.
종료 조건이 있는데도 무한 재귀가 발생할 수 있나요?
가능합니다. 종료 조건이 있어도 재귀 호출을 할 때 값이 계속 같다면 해당 조건에 도달하지 못합니다. 예를 들어 depth === 5에서 종료하도록 만들었는데 다음 호출에서도 계속 같은 depth를 넘기면 재귀 호출이 끝나지 않을 수 있습니다.
Maximum call stack size exceeded 오류는 왜 발생하나요?
함수 호출이 너무 많이 쌓였을 때 발생합니다. 재귀 함수가 종료되지 않고 자기 자신을 계속 호출하면 호출 기록이 계속 쌓이다가 브라우저가 처리할 수 있는 한계를 넘게 됩니다.
재귀 함수 오류를 찾을 때 가장 도움이 됐던 방법은 무엇인가요?
현재 값과 다음 호출에 넘기는 값을 콘솔에 직접 찍어보는 방법이 가장 도움이 됐습니다. 코드를 계속 읽을 때는 보이지 않던 실수도 현재 값, 다음 값, depth 등을 출력해보면 의외로 쉽게 발견되는 경우가 있습니다.
재귀 함수 대신 반복문을 사용해도 되나요?
경우에 따라 가능합니다. 재귀 호출이 너무 깊어질 가능성이 있거나 호출 스택 문제가 걱정된다면 반복문과 스택 자료형을 이용해서 구현할 수도 있습니다. 다만 대댓글이나 하위 카테고리처럼 계층이 이어지는 기능은 재귀 함수로 작성했을 때 코드가 간단해지는 경우가 많습니다.