Java 30
멈추지 않는 프로그램: 예외 처리 마스터하기
컴파일 에러와 런타임 에러, Exception 계층, try-catch-finally, 다중 catch가 한꺼번에 나오면 무엇을 잡고 무엇을 계속 실행하는지 헷갈린다.
근거 · 교안 p219-p228
1장: 예측 못한 충돌: 런타임 에러가 프로그램을 멈출 때
솔라는 모니터를 향해 몸을 기울인 채 미간을 찌푸렸다. 방금 전까지 잘 돌아가던 간단한 일정 관리 프로그램이 흔적도 없이 사라지고, 코드 편집기의 콘솔 창에는 생경한 붉은색 글씨들만 가득했다.
“이상하다…”
나직한 솔라의 목소리에, 책을 읽던 루나가 고개를 들었다.
“분명 실행하기 전엔 아무 문제 없다고 나왔는데. 오타나 문법 오류는 다 잡았다고 생각했는데, 왜 중간에 그냥 죽어버리지?”
솔라의 손가락이 가리키는 곳은 프로그램의 실행 버튼이었다. 그녀에게 ‘에러’란 코드를 실행하기 전, 컴파일러가 “여기 틀렸어!” 하고 미리 알려주는 친절한 경고 같은 것이었다. 문법에 맞지 않는 기호를 쓰거나, 약속된 단어를 잘못 입력했을 때처럼. 하지만 이번엔 달랐다. 프로그램은 순순히 실행되는 듯 보였다. 그러다 특정 기능을 누르는 순간, 마치 전원이 나가버린 기계처럼 멈춰버렸다.
루나는 조용히 다가와 솔라의 화면을 들여다보았다. 복잡한 에러 메시지 대신, 그녀는 솔라가 작성한 코드의 흐름에 시선을 두었다.
“실행하기 전까지는 아무도 모르는 문제가 있어. 마치 잘 닦인 길인 줄 알고 달렸는데, 갑자기 나타난 싱크홀 같은 거지.”
“싱크홀?”
“응. 코드가 도로 위를 달리는 자동차라고 생각해봐. 문법 오류는 출발 전에 정비소에서 고칠 수 있는 문제야. 하지만 도로 자체가 갑자기 꺼져버리는 건, 달려보기 전까진 알 수 없지.”
루나는 말없이 새 코드 파일을 열었다.
“아주 작은 자동차를 하나 만들어서, 일부러 그런 길로 보내보자.”
루나의 말에 솔라는 키보드 위로 손을 옮겼다. 루나는 텅 빈 파일에 들어갈 작은 ‘주머니’를 하나 상상해보라고 했다.
“작은 간식 주머니가 있다고 해보자. 안에는 세 가지 간식이 순서대로 들어있어.”
솔라는 코드를 입력했다.
String[] snacks = {"쿠키", "초콜릿", "사탕"};
“좋아. 이제 첫 번째 간식부터 차례대로 꺼내서 확인해보자.”
솔라는 망설임 없이 다음 코드를 추가했다.
System.out.println(snacks[0]); // 첫 번째 간식
System.out.println(snacks[1]); // 두 번째 간식
System.out.println(snacks[2]); // 세 번째 간식
System.out.println("주머니에 있는 간식을 모두 확인했습니다.");
“여기까지 실행하면 어떻게 될까?” 루나가 물었다.
“당연히 쿠키, 초콜릿, 사탕이 순서대로 출력되고, 마지막엔 확인했다는 메시지가 나오겠지.”
솔라는 자신 있게 실행 버튼을 눌렀다. 예상대로 콘솔 창에 세 가지 간식 이름과 마지막 문장이 깔끔하게 찍혔다. 문제없는 길이었다.
루나가 화면의 코드 한 줄을 조용히 가리켰다. “그럼, 이 주머니에는 간식이 세 개 들어있는데… 만약 우리가 네 번째 칸에 뭐가 있는지 물어보면 어떻게 될까?”
솔라는 잠시 고민에 빠졌다. “네 번째 칸? 없으니까… ‘없음’이라고 나오거나, 아니면 그냥 아무것도 안 뜨지 않을까? 실행하기 전에 ‘그런 건 없어’라고 알려줄 것 같은데.” 그녀의 생각은 여전히 ‘미리 확인 가능한 에러’에 머물러 있었다.
“한번 해보자. 간식을 모두 확인했다는 메시지 다음에, 네 번째 간식을 꺼내는 코드를 넣어봐. 그리고 그 뒤에 ‘프로그램이 안전하게 종료되었습니다.‘라는 문장도 추가해줘.”
솔라는 고개를 갸웃거리며 코드를 수정했다.
String[] snacks = {"쿠키", "초콜릿", "사탕"};
System.out.println(snacks[0]);
System.out.println(snacks[1]);
System.out.println(snacks[2]);
System.out.println("주머니에 있는 간식을 모두 확인했습니다.");
System.out.println(snacks[3]); // 존재하지 않는 네 번째 간식
System.out.println("프로그램이 안전하게 종료되었습니다.");
다시 실행 버튼을 누른 순간, 아까와는 다른 상황이 펼쳐졌다.
콘솔 창에는 “쿠키”, “초콜릿”, “사탕”, 그리고 “주머니에 있는 간식을 모두 확인했습니다.”까지는 순서대로 출력되었다. 하지만 그 직후, 화면은 붉은색 에러 메시지로 뒤덮였다. ArrayIndexOutOfBoundsException. 배열의 범위를 벗어났다는 뜻이었다.
솔라가 주목한 것은 다른 부분이었다.
“어… ‘프로그램이 안전하게 종료되었습니다.‘라는 메시지가 안 나왔어.”
그녀는 붉은 글씨와 코드의 마지막 줄을 번갈아 보았다. 프로그램은 마지막 문장을 실행할 기회조차 얻지 못하고, 네 번째 간식을 꺼내려던 그 자리에서 그대로 멈춰버린 것이다. 루나가 말한 ‘싱크홀’이 눈앞에 나타난 듯했다.
“아… 이게 ‘비정상 종료’구나. 그냥 거기서 모든 게 끝나버리는 거네.”
솔라는 ‘런타임 에러는 실행 중 발생해 프로그램을 비정상 종료시킨다’는 문장의 의미를 이제 글자가 아닌, 멈춰버린 프로그램의 침묵으로 이해했다. 코드는 위에서 아래로 흐르는 물줄기와 같았다. 하지만 런타임 에러라는 절벽을 만나자, 물줄기는 더 이상 나아가지 못하고 그 자리에서 증발해버린 것이다.
“알겠어. 프로그램이 길을 가다가 예고도 없이 나타난 함정에 빠져서 멈춰버리는 거네.”
솔라가 화면을 응시하며 말했다. 그녀의 눈빛은 더 이상 혼란이 아닌, 새로운 문제에 대한 집요함으로 빛나고 있었다.
“그럼… 이 함정을 미리 알려주진 못하더라도, 함정에 빠진 순간 프로그램을 그냥 죽게 내버려 두는 게 아니라, 어떻게든 수습해서 계속 길을 가게 할 방법은 없어? 저 ‘프로그램이 안전하게 종료되었습니다.‘라는 마지막 문장을 꼭 보고 싶은데.”
2장: 멈춤 없이 계속: try-catch로 예외 잡기
솔라의 질문이 남긴 침묵 속에서, 루나는 키보드를 향해 손을 뻗었다. 이전과 똑같은 코드 파일이 화면에 떠 있었지만, 무언가 달라져 있었다. 이전 실행에서 붉은 에러 메시지를 토해내며 프로그램을 멈춰 세웠던 바로 그 한 줄, 존재하지 않는 네 번째 간식을 꺼내려던 System.out.println(snacks[3]); 코드의 위아래로 낯선 단어들이 감싸고 있었다.
루나는 아무 말 없이 새로운 구조물을 솔라에게 보여주었다. try 라는 단어가 문제의 코드를 끌어안고 있었고, 그 바로 아래에는 catch 라는 단어가 뒤를 받치고 있었다. 마치 도로의 위험 구간을 알리는 표지판과 그 옆에 마련된 비상 대피소처럼 보였다.
String[] snacks = {"쿠키", "초콜릿", "사탕"};
System.out.println(snacks[0]);
System.out.println(snacks[1]);
System.out.println(snacks[2]);
System.out.println("주머니에 있는 간식을 모두 확인했습니다.");
try {
System.out.println(snacks[3]); // 위험할 수 있는 시도
} catch (ArrayIndexOutOfBoundsException e) {
System.out.println("주머니에 그 칸은 비어있어요!");
}
System.out.println("프로그램이 안전하게 종료되었습니다.");
솔라는 화면의 변화를 눈으로 좇았다. ‘try’는 ‘시도하다’라는 뜻이니, 저 안에서 무언가 시도한다는 건 짐작할 수 있었다. 하지만 그 아래 catch, ‘잡는다’는 단어가 그녀의 시선을 붙들었다.
“함정에 빠졌을 때 그걸 ‘잡는다’는 건가? 그럼 잡고 나서… 그냥 끝나는 거 아냐? 에러 메시지를 붉은색 대신 우리가 정한 문장으로 보여주고 멈추는 거지?”
솔라의 추측은 catch를 그저 에러를 인식하고 프로그램을 종료하는, 조금 더 세련된 방식일 거라는 생각에 머물러 있었다. 프로그램이 멈추는 것은 여전히 당연한 수순처럼 느껴졌다.
루나는 솔라의 혼잣말을 들으며 조용히 실행 버튼을 가리켰다. 백 마디 설명보다 한 번의 실행이 더 명확한 답을 줄 거라는 듯했다. 솔라는 마른침을 삼키고 마우스를 움직여 프로그램을 실행했다.
결과는 놀라웠다.
콘솔 창에는 예상대로 “쿠키”, “초콜릿”, “사탕”, 그리고 “주머니에 있는 간식을 모두 확인했습니다.”가 차례로 나타났다. 그리고 그 다음, 붉은 에러 메시지 대신 catch 블록 안에 솔라가 써넣은 “주머니에 그 칸은 비어있어요!” 라는 문장이 출력되었다.
하지만 진짜 놀라운 순간은 그 다음에 찾아왔다. 바로 그 아래, 솔라가 그토록 보고 싶어 했던 마지막 문장, “프로그램이 안전하게 종료되었습니다.”가 마침내 화면에 나타난 것이다.
”…어? 계속되네?”
솔라는 자신도 모르게 탄성을 내뱉었다. 프로그램은 멈추지 않았다. 네 번째 간식을 꺼내려다 문제가 생긴 바로 그 순간, 프로그램의 흐름이 try 블록을 탈출해 catch 라는 비상구로 점프했다. 그곳에서 약속된 수습 코드(“주머니에 그 칸은 비어있어요!”)를 실행한 뒤, 아무 일 없었다는 듯 다시 원래의 길로 돌아와 마지막 문장까지 무사히 실행을 마친 것이다.
이전에는 런타임 에러라는 절벽 앞에서 증발해버렸던 물줄기가, 이제는 catch라는 우회로를 따라 흐르며 다시 본류로 합류하는 모습이 머릿속에 그려졌다.
“알겠다! try-catch는 에러가 났다고 프로그램을 멈추는 게 아니었어. try 구역에서 문제가 생기면, catch가 그걸 받아서 처리한 다음에… 프로그램의 나머지 부분을 계속 실행하도록 흐름을 이어주는 거였구나.”
솔라는 try 블록은 예외 가능 코드를 감싸고 catch는 예외 발생 시 실행된다는 문장의 진짜 의미를 깨달았다. 여기서 ‘실행된다’는 것은 단순히 catch 블록 안의 코드가 동작한다는 뜻을 넘어, 프로그램의 전체적인 생명줄을 유지하며 ‘계속 나아간다’는 약속을 포함하는 것이었다. catch는 종착역이 아니라, 잠시 들르는 휴게소였다.
새로운 확신에 찬 솔라의 표정을 보던 루나가 나지막이 물었다.
“만약 우리가 간식을 꺼내기 전에 주머니를 열고, 일이 다 끝난 뒤에는 주머니를 꼭 닫아야 한다고 해보자. 주머니를 닫는 코드는 어디에 두는 게 가장 안전할까? 간식을 성공적으로 꺼냈을 때도, 꺼내다 실패했을 때도, 어떤 상황에서든 반드시 닫혀야 하는데.”
솔라는 잠시 고민에 빠졌다. “음… 간식을 잘 꺼냈을 때를 대비해서 try 블록 마지막에 ‘닫기’ 코드를 넣고, 실패했을 경우를 대비해서 catch 블록 마지막에도 ‘닫기’ 코드를 넣으면 되지 않을까?”
그녀는 자신의 대답에 만족했지만, 이내 미간을 찌푸렸다. 똑같은 코드를 두 번이나 써야 한다는 점이 마음에 걸렸다. 만약 ‘닫는’ 방식이 바뀌면 두 곳 모두를 수정해야 했다. 뭔지 모를 찜찜함이 남았다. 예외가 발생하든 안 하든, 반드시 실행되어야 할 일을 보장하는 더 확실한 방법은 없을까?
3장: 무조건 실행: finally 블록의 약속
찜찜함을 떨치지 못한 솔라는 결국 직접 코드를 수정하기 시작했다. 이전 장에서 발견한 try-catch 구조는 프로그램의 흐름을 유지하는 훌륭한 비상구였지만, 성공했을 때와 실패했을 때 모두 처리해야 하는 공통의 마무리 작업이 문제였다. 솔라의 생각은 단순했다. try 블록이 무사히 끝났을 때를 대비해 마지막에 ‘주머니 닫기’ 코드를 넣고, catch 블록이 실행되는 만일의 사태를 대비해 그곳에도 똑같은 코드를 넣어두는 것이었다.
그녀의 손끝에서 코드가 바뀌었다. 성공 경로와 실패 경로 양쪽에 ‘보험’을 들어두는 셈이었다.
// 솔라의 첫 번째 시도: 중복된 '닫기' 코드
System.out.println("주머니를 엽니다.");
try {
// String snack = snacks[1]; // 성공 시나리오
String snack = snacks[3]; // 실패 시나리오
System.out.println(snack + "을(를) 꺼냈습니다.");
System.out.println("주머니를 닫습니다."); // 성공 시의 닫기
} catch (ArrayIndexOutOfBoundsException e) {
System.out.println("주머니에 그 칸은 비어있어요!");
System.out.println("주머니를 닫습니다."); // 실패 시의 닫기
}
System.out.println("프로그램이 안전하게 종료되었습니다.");
코드를 실행하자 의도대로 작동했다. 간식을 꺼내는 데 실패하자 catch 블록이 실행되었고, “주머니에 그 칸은 비어있어요!” 라는 메시지와 함께 “주머니를 닫습니다.”가 출력되었다. 간식을 성공적으로 꺼내는 코드로 바꾸고 실행해도 결과는 마찬가지였다. try 블록 안에서 간식 이름이 출력되고, “주머니를 닫습니다.”가 나타났다. 완벽해 보였다.
하지만 솔라는 여전히 모니터 앞에서 미간을 펴지 못했다. “작동은 하는데… 똑같은 코드가 두 군데나 있잖아. 지금은 그냥 출력문이지만, 만약 주머니를 닫는 과정이 복잡해지면 어떡해? 양쪽 다 고쳐야 하고, 하나라도 빼먹으면 큰일 나는데. 이건… 안전한 방법이 아닌 것 같아.”
그때, 솔라의 혼잣말을 듣고 있던 루나가 조용히 키보드를 넘겨받았다. 그녀는 솔라가 추가했던 두 줄의 ‘주머니 닫기’ 코드를 모두 지웠다. 그리고는 catch 블록 바로 아래에, finally라는 새로운 블록을 만들고 그 안에 단 한 줄의 ‘주머니 닫기’ 코드를 옮겨 넣었다.
// 루나가 수정한 코드: finally 블록의 등장
System.out.println("주머니를 엽니다.");
try {
System.out.println("네 번째 간식을 꺼내봅니다...");
System.out.println(snacks[3]); // 예외 발생!
System.out.println("간식을 꺼냈습니다."); // 이 줄은 실행되지 않음
} catch (ArrayIndexOutOfBoundsException e) {
System.out.println("주머니에 그 칸은 비어있었어요.");
} finally {
System.out.println("주머니를 닫습니다."); // 어떤 상황에서든 반드시 실행
}
System.out.println("프로그램이 안전하게 종료되었습니다.");
finally. ‘마침내’ 라는 뜻의 단어. 솔라는 이 구조가 낯설었다. “이건… catch 블록이 실행될 때만 같이 실행되는 또 다른 블록인가?” 그녀는 finally가 catch에 딸린 부속품 같은 것이라고 짐작했다.
루나는 말없이 코드의 한 부분을 가리켰다. 예외가 발생하는 snacks[3] 부분이었다. “이대로 한번 실행해보고, 그 다음엔 예외가 발생하지 않도록 snacks[1]로 바꿔서 다시 실행해봐. 두 경우에 콘솔 창이 어떻게 달라지는지.”
솔라는 먼저 예외가 발생하는 코드를 그대로 실행했다.
실행 결과 (예외 발생 시):
주머니를 엽니다.
네 번째 간식을 꺼내봅니다...
주머니에 그 칸은 비어있었어요.
주머니를 닫습니다.
프로그램이 안전하게 종료되었습니다.
예상대로였다. try 블록에서 예외가 터지자마자 실행이 catch 블록으로 점프했고, 그 뒤에 finally 블록이 실행됐다. 여기까지는 솔라의 추측과 크게 다르지 않았다.
문제는 다음 실험이었다. 솔라는 snacks[3]를 snacks[1]로 바꾸었다. 이제 try 블록은 아무 문제 없이 성공적으로 실행될 것이다. 그녀의 생각대로라면, catch 블록이 실행되지 않으니 finally 블록도 그냥 건너뛸 터였다.
하지만 실행 결과는 그녀의 예상을 완전히 빗나갔다.
실행 결과 (예외 미발생 시):
주머니를 엽니다.
네 번째 간식을 꺼내봅니다...
초콜릿
간식을 꺼냈습니다.
주머니를 닫습니다.
프로그램이 안전하게 종료되었습니다.
“어…?” 솔라는 두 개의 실행 결과를 번갈아 보았다. try가 성공적으로 끝나자 catch 블록은 당연히 건너뛰었지만, finally 블록의 “주머니를 닫습니다.”는 어김없이 실행되었다. 실패했을 때도, 성공했을 때도.
그 순간, 솔라는 finally의 진정한 역할을 깨달았다. finally는 성공의 대가도, 실패의 위로도 아니었다. 그것은 어떤 상황에서든 반드시 지켜져야 하는 ‘절대적인 약속’이었다. try 블록에서 길을 잃고 catch로 빠지든, try 블록을 무사히 통과하든, 프로그램의 흐름은 마지막에 반드시 finally라는 관문을 통과해야만 했다.
“알겠다! finally는 예외가 발생하든 안 하든, 심지어 catch가 있든 없든 상관없이, try 블록에 진입하기만 하면 무조건 실행되는 거구나! 그래서 아까처럼 코드를 두 번 쓸 필요 없이, 여기에 한 번만 써두면 되는 거였어.”
‘finally는 예외 여부와 무관하게 실행되어 자원 정리에 쓰인다.’는 문장의 의미가 명확해졌다. ‘무관하게’라는 말의 무게를, 솔라는 두 번의 실행 결과로 체감했다. 열었으면 반드시 닫아야 하는 파일, 사용했으면 반드시 반납해야 하는 네트워크 연결처럼, 프로그램의 여정과 상관없이 반드시 처리해야 하는 뒷정리를 위한 가장 안전한 장소였다.
새로운 구조에 만족한 솔라는 잠시 코드를 바라보다가 문득 다른 궁금증이 생겼다. 지금은 주머니의 ‘칸’이 없어서 생기는 문제(ArrayIndexOutOfBoundsException) 하나만 다루고 있다.
“그런데 언니, 만약에 주머니에서 간식을 꺼냈는데, 그게 간식이 아니라 엉뚱한 돌멩이여서 먹을 수가 없는 경우라면 어떡하지? 이건 칸이 없는 거랑은 다른 종류의 문제잖아. 이럴 때도 지금 이 catch가 똑같이 처리하는 게 맞을까? 아니면 문제 종류에 따라 다르게 대응해야 하는 거 아냐?”
4장: 똑똑하게 잡기: 다중 catch와 예외 계층
솔라의 시선은 이전 장에서 완성한 try-catch-finally 구조에 머물러 있었다. finally가 제공하는 절대적인 안정감은 만족스러웠지만, 그녀의 마음속에 떠오른 질문은 사라지지 않았다. ‘칸이 없는 문제’와 ‘내용물이 이상한 문제’는 분명 다른데, 지금의 구조는 오직 ArrayIndexOutOfBoundsException이라는 단 하나의 함정만 알고 있을 뿐이었다.
솔라는 자신의 질문을 코드로 직접 확인해보기로 했다. 그녀는 기존의 간식 주머니 대신, 각 칸에 물건의 ‘개수’가 문자열로 적힌 새로운 목록을 만들었다. 그런데 목록의 한가운데에는 숫자 대신 ‘돌멩이’라는 엉뚱한 단어가 끼어 있었다. 숫자로 바꿀 수 없는 값. 이것이 바로 그녀가 생각한 두 번째 종류의 문제였다.
// 새로운 문제 상황: 숫자와 문자가 섞인 목록
String[] itemData = {"15", "20", "돌멩이", "25"};
그녀는 try 블록 안의 코드를 수정했다. 목록에서 값을 꺼내 숫자로 바꾸는 시도를 하도록. 그리고 catch 블록을 하나 더 추가했다. ‘돌멩이’ 같은 값을 숫자로 바꾸려 할 때 발생하는 NumberFormatException을 잡기 위해서였다. 이제 프로그램은 두 종류의 함정을 잡을 수 있는 구조가 되었다.
“칸이 없어서 생기는 문제랑, 내용물이 이상해서 생기는 문제. 둘 다 잡을 수 있게 catch를 두 개 쓰면 되겠지.” 솔라는 혼잣말하며 코드를 구성했다. 그런데 문득, 더 편리한 방법이 떠올랐다.
“어차피 둘 다 ‘예외적인 상황’이잖아. 그럼 그냥 Exception이라는 큰 그물 하나로 모든 예외를 한 번에 잡으면 더 편하지 않을까? 문제 종류마다 catch를 따로 만드는 것보다 훨씬 깔끔해 보이는데.”
그녀의 생각은 그럴듯했다. 세분된 처리보다는 포괄적인 처리가 더 효율적으로 느껴졌다. 솔라는 자신의 아이디어를 코드로 옮겼다. 가장 넓은 범위의 Exception을 잡는 catch 블록을 맨 위에 두고, 그 아래에 구체적인 예외들을 처리하는 블록들을 배치했다. 큰 그물을 먼저 던져서 뭐든 건지고 보자는 심산이었다.
try {
String data = itemData[2]; // "돌멩이"를 꺼낸다
int quantity = Integer.parseInt(data); // 숫자로 바꾸려다 문제 발생!
System.out.println("수량: " + quantity);
} catch (Exception e) { // 가장 넓은 범위의 그물을 먼저 던진다
System.out.println("알 수 없는 문제가 발생했습니다.");
} catch (ArrayIndexOutOfBoundsException e) { // 두 번째 그물
System.out.println("목록에 해당 칸이 없습니다.");
} catch (NumberFormatException e) { // 세 번째 그물
System.out.println("이건 숫자로 바꿀 수 없어요.");
}
하지만 코드를 완성하기도 전에, 편집기 화면에 낯선 붉은 밑줄이 그어졌다. NumberFormatException을 잡으려던 catch 블록이었다. 솔라가 마우스를 올려보자 ‘도달할 수 없는 코드(unreachable code)’라는 메시지가 나타났다. 실행하기도 전에 컴파일러가 경고를 보내는 상황. 마치 길을 나서려는 자동차의 내비게이션이 “그 길은 이미 막혔습니다”라고 알려주는 것 같았다.
“도달할 수 없다고? 왜?”
솔라의 중얼거림을 들은 루나가 조용히 다가와 화면을 보았다. 그녀는 솔라가 짠 코드의 순서를 손가락으로 짚으며 비유를 들었다.
“우체국에서 편지를 분류한다고 생각해봐. ‘서울시 강남구’로 가는 편지, ‘서울시’로 가는 편지, 그리고 ‘대한민국’으로 가는 편지들을 분류해야 해. 그런데 분류하는 사람이 제일 먼저 ‘대한민국’이라고 적힌 가장 큰 상자에 모든 편지를 다 넣어버리면 어떻게 될까?”
솔라는 잠시 생각에 잠겼다. “그러면… ‘서울시’ 상자나 ‘서울시 강남구’ 상자로는 편지가 하나도 가지 않겠지. 이미 큰 상자에 다 들어가 버렸으니까.”
“바로 그거야.” 루나가 말했다. “예외들도 가족 관계처럼 계층이 있어. NumberFormatException이나 ArrayIndexOutOfBoundsException은 Exception이라는 더 큰 개념에 속하는, 이를테면 자식 같은 존재들이야. catch 블록은 위에서부터 차례대로 내려오면서, 자기가 처리할 수 있는 첫 번째 블록에서 멈춰.”
그 순간 솔라는 모든 것을 이해했다. ‘돌멩이’를 숫자로 바꾸려 할 때 발생하는 NumberFormatException은 그 자체로도 Exception의 일종이었다. 따라서 가장 위에 있는 catch (Exception e) 라는 큰 그물에 무조건 먼저 걸려들게 된다. 그 결과, 그 아래에 있던 더 구체적인 catch (NumberFormatException e) 블록은 영원히 실행될 기회를 잃어버리는 것이었다. 컴파일러가 ‘도달할 수 없는 코드’라고 경고한 이유였다.
“아하! 그래서 순서가 중요하구나! 좁은 그물, 더 구체적인 녀석부터 먼저 기회를 줘야 하는 거였어.”
솔라는 즉시 catch 블록의 순서를 바꿨다. 구체적인 예외들(NumberFormatException, ArrayIndexOutOfBoundsException)을 먼저 배치하고, 혹시 모를 다른 모든 예외를 잡기 위한 Exception은 맨 마지막에 두었다. 마치 우편 분류를 하듯, 가장 구체적인 주소부터 처리하고 나머지를 일반 우편함에 넣는 순서였다.
// 올바른 순서로 수정된 코드
try {
// ...
} catch (NumberFormatException e) { // 구체적인 문제 1
System.out.println("이건 숫자로 바꿀 수 없어요.");
} catch (ArrayIndexOutOfBoundsException e) { // 구체적인 문제 2
System.out.println("목록에 해당 칸이 없습니다.");
} catch (Exception e) { // 포괄적인 문제 (마지막 보루)
System.out.println("알 수 없는 문제가 발생했습니다.");
}
이제 붉은 밑줄은 사라졌다. 솔라는 두 가지 시나리오를 테스트했다. 첫 번째는 목록의 세 번째 칸에 있는 ‘돌멩이’를 숫자로 바꾸려는 시도였다.
실행 결과 1:
이건 숫자로 바꿀 수 없어요.
정확히 NumberFormatException을 처리하는 catch 블록이 실행되었다. 두 번째는 존재하지 않는 다섯 번째 칸에 접근하려는 시도였다.
실행 결과 2:
목록에 해당 칸이 없습니다.
이번에는 ArrayIndexOutOfBoundsException을 처리하는 catch 블록이 응답했다. 프로그램은 문제의 종류를 정확히 식별하고 그에 맞는 각기 다른 비상 대피소로 흐름을 옮겼다.
솔라는 이제 ‘다중 catch는 구체적인 예외를 먼저 두고 부모 예외를 나중에 둔다’는 문장이 단순한 규칙이 아니라, 예외 처리 시스템의 논리적인 작동 방식 그 자체임을 깨달았다. 하나의 큰 그물로 모든 걸 뭉뚱그려 처리하는 것은 편리한 게 아니라, 문제 상황에 대한 구체적인 대응을 포기하는 것이었다.
새로운 깨달음에 만족한 솔라의 눈에, 방금 처리한 두 가지 예외가 다시 들어왔다. ‘숫자가 아닌 값을 바꾸려는 시도’, ‘목록의 범위를 벗어나는 접근’. 문득, 그녀는 다른 종류의 질문을 떠올렸다.
“그런데 언니, 생각해보니까 이 두 가지 문제는 내가 코드를 짤 때 조금만 더 조심했으면 막을 수 있었던 거 아닐까? 값을 숫자로 바꾸기 전에 이게 정말 숫자인지 확인하거나, 배열에 접근하기 전에 인덱스가 범위 안에 있는지 미리 확인했다면… 애초에 이런 예외는 발생하지 않았을 텐데. 그럼 try-catch로 꼭 잡아야만 하는 예외는 따로 있는 거야?”
5장: 처리할 것인가, 말 것인가: 체크 vs. 언체크 예외
솔라는 새로운 코드 파일을 열어 화면을 둘로 나눴다. 왼쪽에는 이전 장에서 다뤘던, 문자열을 숫자로 바꾸는 코드를 놓았다. 오른쪽에는 생전 처음 보는, 파일의 내용을 읽어오는 코드를 한 줄 적었다. 그녀의 질문, 즉 어떤 예외는 미리 조심해서 막을 수 있지 않냐는 생각의 연장선이었다. 개발자의 부주의로 발생하는 문제와, 프로그램 외부의 요인으로 발생하는 문제. 이 둘은 근본적으로 다르지 않을까?
솔라는 먼저 왼쪽의 코드, NumberFormatException이 발생할 수 있는 코드를 뚫어지게 쳐다봤다. try-catch 블록이 없었지만, 코드 편집기는 아무런 불평도 하지 않았다. 실행 전까지는 완벽하게 합법적인 코드로 보였다.
하지만 오른쪽의 파일 읽기 코드 new FileReader("note.txt");는 상황이 달랐다. 코드를 입력하자마자 편집기는 즉시 붉은 밑줄을 그으며 경고를 보냈다. ‘처리되지 않은 예외(Unhandled exception: java.io.IOException)’. try-catch로 감싸거나, 이 문제를 다른 곳으로 던지겠다고 선언하라는 강압적인 요구였다.
“이상하네….”
솔라의 미간이 좁혀졌다. 둘 다 실행 중에 문제가 생길 수 있는 예외적인 상황인데, 왜 하나는 가만히 놔두고 다른 하나는 컴파일조차 못 하게 막는 걸까? 그녀는 모든 예외는 try-catch로 감싸야만 컴파일 에러를 피할 수 있다고 어렴풋이 생각하고 있었다. 하지만 눈앞의 상황은 그 생각과 정면으로 배치됐다.
“하나는 그냥 통과시켜주면서, 다른 하나는 왜 이렇게 꽉 잡고 안 놔주는 거야? 둘 다 Exception의 한 종류일 텐데.”
그녀의 혼잣말에 루나가 다가와 양쪽 화면을 차례로 살폈다. 왼쪽의 ‘숫자 변환’ 코드와 오른쪽의 ‘파일 읽기’ 코드. 그리고 그 둘을 대하는 컴파일러의 상반된 태도.
“여행을 떠나기 전에 짐을 챙기는 것과 같아.” 루나가 조용히 입을 열었다. “어떤 준비물은 공항에서 반드시 확인하지만, 어떤 물건은 스스로 챙겨야 하는 것처럼.”
루나는 오른쪽의 파일 읽기 코드를 가리켰다. “이건 여권 같은 거야. IOException. 파일이 없거나, 읽을 권한이 없는 문제는 내 프로그램 바깥 세상에서 일어나는 일이지. 내가 아무리 코드를 완벽하게 짜도 막을 수 없어. 그래서 컴파일러라는 출국 심사관이 비행기에 태우기 전에 반드시 물어보는 거야. ‘혹시 파일이 없을 경우에 어떻게 대처할 건가요? 여권은 준비됐나요?’ 라고. 이 질문에 대답(try-catch)하지 않으면 절대 비행기(프로그램)를 띄워주지 않아.”
“아… 내가 통제할 수 없는 외부 문제니까, 반드시 대처 방안을 마련하라고 강제하는 거구나.”
“맞아. 그래서 이런 예외를 ‘체크 예외(Checked Exception)’라고 불러. 컴파일러가 직접 확인(check)하거든.”
이어서 루나의 손가락이 왼쪽의 숫자 변환 코드로 옮겨갔다.
“그럼 이건?” 솔라가 물었다.
“이건 여행지에서 타이어가 펑크 나는 것과 비슷해. NumberFormatException이나 ArrayIndexOutOfBoundsException 같은 것들 말이야. 이건 개발자가 조금만 더 주의를 기울였다면 미리 막을 수 있는 문제들이지. 숫자로 바꾸기 전에 이게 정말 숫자인지 확인하거나, 배열에 접근하기 전에 인덱스가 유효한지 검사하는 식으로. 그래서 컴파일러는 이걸 일일이 간섭하지 않아. ‘운전자가 알아서 하겠지’ 하고 믿고 넘어가 주는 거야. 이건 개발자의 책임이자 선택의 영역이지.”
“그래서 처리하라고 강제하지 않는 거구나. ‘언체크 예외(Unchecked Exception)’… 확인하지 않는 예외.”
솔라는 두 개의 코드를 다시 바라봤다. 이제 컴파일러의 상반된 태도가 이해되기 시작했다. 붉은 밑줄을 그으며 화를 내던 것은 귀찮게 하려던 게 아니라, 프로그램의 안정성을 위한 최소한의 안전장치였던 것이다. 개발자가 통제할 수 없는 위험에 대해서는 반드시 대비책을 세우도록 강제하는 것. 반면, 개발자의 부주의로 발생할 수 있는 문제에 대해서는 처리의 자율성을 부여하는 것.
‘체크 예외는 처리 강제, 언체크 예외는 선택 처리다.’
책에서 읽었던 무미건조한 문장이 비로소 생생한 의미로 다가왔다. ‘강제’와 ‘선택’은 컴파일러가 우리에게 말을 거는 방식이었다.
모든 조각이 맞춰지는 느낌이었다. 런타임 에러로 속절없이 멈추던 프로그램. try-catch로 비상구를 만들어 흐름을 이어가던 경험. finally로 어떤 상황에서든 뒷정리를 약속했던 순간. 다중 catch로 문제의 종류에 따라 똑똑하게 대응했던 방법. 그리고 마침내, 어떤 문제를 반드시 처리해야 하고 어떤 문제를 선택적으로 다룰지 결정하는 기준까지.
솔라는 처음 마주했던, 멈춰버린 일정 관리 프로그램을 떠올렸다. 그리고는 깨끗한 메모장을 열어 자신만의 ‘안정적인 프로그램 흐름 설계도’를 그리기 시작했다. 더 이상 추상적인 개념이 아니었다. 그녀의 손끝에서 구체적인 계획이 만들어졌다.
<멈추지 않는 일정 관리 프로그램 설계 원칙>
-
위험 요소 식별 및 분류:
일정 파일 읽기: 파일이 없을 수 있다 (IOException). -> 체크 예외. 컴파일러가 강제하니try-catch로 반드시 처리. “파일을 찾을 수 없습니다.” 안내.사용자 날짜 입력: “15일” 대신 “열닷새” 라고 입력할 수 있다 (NumberFormatException). -> 언체크 예외.try-catch로 처리해 “숫자만 입력하세요.” 라고 안내하는 것이 사용자에게 친절하다. (선택적 처리)저장된 일정 조회: 3개만 저장된 목록에서 5번째 일정을 찾으려 할 수 있다 (ArrayIndexOutOfBoundsException). -> 언체크 예외. 이건 조회 전에 목록의 크기를 확인하는 로직을 추가해서 예방하는 게 더 나은 설계다. (예방 우선)
-
흐름 제어:
- 어떤 예외가 발생하더라도 프로그램은 종료되지 않는다.
catch블록에서 상황을 알리고, 다시 입력을 받거나 기본 메뉴로 돌아간다.
- 어떤 예외가 발생하더라도 프로그램은 종료되지 않는다.
-
자원 관리:
- 파일을 열었다면, 성공하든 실패하든
finally블록에서 반드시 파일을 닫아준다. (.close())
- 파일을 열었다면, 성공하든 실패하든
설계도를 작성하며 솔라는 고개를 끄덕였다. ‘예외 처리는 실행 중 발생하는 비정상 상황을 잡아 프로그램이 계속 흐르도록 하는 구조다.’ 처음에는 막연하게만 들렸던 이 문장이, 이제는 그녀가 직접 그린 이 설계도 그 자체처럼 느껴졌다. 예외는 더 이상 두려운 방해물이 아니었다. 예측하고, 분류하고, 전략적으로 대응해서 프로그램의 흐름을 더욱 견고하게 만드는 설계의 일부였다. 멈추지 않는 프로그램을 향한 첫 번째 지도가 완성된 순간이었다.