예외 처리 - try except 와 try finally
이 장에서 배우는 것
앞 장까지 만든 계산대 프로그램은 입력이 올바르다고 믿고 움직였다. 실제 가게에서는 메뉴판에 없는 이름이 들어오고, 수량 칸에 숫자 대신 글자가 들어오고, 재고보다 많은 주문이 들어온다. 이 장은 그런 상황을 프로그램이 스스로 알리고 수습하게 만드는 방법을 다룬다. 오류가 났을 때 던지는 것이 예외(exception)이고, 던져진 예외를 받아 처리하는 것이 예외 처리다.
raise로 예외를 일으키고,try except on E: 클래스 do로 종류별로 받는다.Exception을 상속해 주문 도메인에 맞는 사용자 예외 클래스를 만든다.try finally로 예외가 나도 자원을 반드시 정리하고,try except와 겹쳐 쓴다.- 표준 예외
EConvertError,ERangeError가 언제 나오는지 안다. - 읽는 사람이 바로 조치할 수 있는 오류 메시지를 설계한다.
문제 상황
카페 계산대에 주문 한 줄이 들어온다. 메뉴 이름과 수량이 각각 글자로 온다. 이 한 줄을 처리하다 보면 실패할 지점이 여러 곳이다.
- 수량 칸이 "두 개" 처럼 숫자가 아니다.
- 메뉴판에 없는 이름이다.
- 수량이 0 이하이다.
- 재고가 모자란다.
예외 없이 이를 다루려면 함수마다 성공 여부를 알리는 반환값을 만들고, 호출하는 쪽마다 그 값을 검사해야 한다. 검사 하나를 빠뜨리면 잘못된 값이 조용히 다음 단계로 넘어가 재고가 음수가 된다. 더 곤란한 것은 주문 처리 도중에 임시로 만든 전표 객체가 있을 때다. 중간에 실패하면 그 객체를 해제하는 줄까지 도달하지 못해 메모리가 샌다.
예외는 이 두 문제를 나눠서 푼다. 실패를 발견한 곳은 raise 로 던지기만 하고, 처리할 수 있는 곳이 except 로 받는다. 중간에 낀 코드는 finally 로 뒷정리만 맡는다.
예외를 던지고 받기
raise 와 try except
예외는 객체다. raise 뒤에 예외 클래스의 객체를 만들어 적으면 현재 실행이 즉시 멈추고, 호출한 쪽으로 거슬러 올라가며 받아줄 except 를 찾는다.
if AQty <= 0 then
raise ERangeError.CreateFmt('수량은 1 이상이어야 한다: %d', [AQty]);
CreateFmt 는 앞 장들에서 본 Format 과 같은 서식으로 메시지를 만든다. 받는 쪽은 try 블록으로 위험한 코드를 감싼다.
try
Stock.Take('라떼', 3);
except
on E: EOutOfStock do
Writeln('거절: ', E.Message);
end;
on E: 클래스 do 에서 E 는 던져진 예외 객체를 가리키는 이름이고, E.Message 는 만들 때 넣은 메시지다. 던져진 예외가 지정한 클래스이거나 그 자식 클래스이면 이 분기가 실행된다. try 안에서 raise 가 일어난 줄 아래는 실행되지 않는다.
예외 객체는 except 블록이 끝나면 자동으로 해제된다. 앞 장에서 객체는 만든 쪽이 해제한다고 배웠지만, 예외 객체만은 처리가 끝나면 언어가 해제해 주므로 E.Free 를 쓰지 않는다.
어느 on 에도 맞지 않는 예외는 처리되지 않은 채 더 바깥으로 올라간다. 끝까지 받는 곳이 없으면 프로그램이 오류 메시지를 찍고 종료한다.
처리하지 못하면 다시 던진다
기록만 남기고 판단은 바깥에 맡기고 싶을 때는 except 안에서 인자 없이 raise; 만 쓴다. 지금 처리 중인 예외를 그대로 다시 던진다.
except
on E: Exception do
begin
Writeln('예상 밖 오류: ', E.ClassName);
raise;
end;
end;
E.ClassName 은 객체의 클래스 이름을 글자로 돌려준다. 어떤 종류의 예외인지 로그에 남길 때 쓴다.
변환 오류를 우리 말로 바꾸기
글자를 숫자로 바꾸는 StrToInt 는 숫자로 읽을 수 없는 글자를 만나면 EConvertError 를 던진다. 이 예외의 기본 메시지는 영어이고 어느 주문의 어느 칸인지 알려 주지 않는다. 그래서 변환하는 자리에서 바로 받아 주문 도메인의 예외로 바꿔 던진다.
function ParseQty(const S: string): Integer;
begin
Result := 0;
try
Result := StrToInt(S);
except
on E: EConvertError do
raise EOrderError.CreateFmt('수량을 숫자로 읽을 수 없다: "%s"', [S]);
end;
end;
except 안에서 새 예외를 던지면 원래 예외는 해제되고 새 예외가 올라간다. 호출하는 쪽은 라이브러리의 예외 종류를 몰라도 주문 오류 하나만 알면 된다.
표준 예외 두 가지
| 클래스 | 발생 상황 | 이 장의 예 |
|---|---|---|
EConvertError | 글자를 숫자로 바꾸지 못함 | StrToInt('두 개') |
ERangeError | 값이나 첨자가 허용 범위를 벗어남 | 수량 0, 배열 첨자 4 |
Exception | 모든 예외의 부모 | 마지막 안전망 분기 |
ERangeError 는 두 방식으로 만난다. 하나는 위 코드처럼 우리가 규칙 위반을 알리려고 직접 던지는 경우다. 다른 하나는 컴파일러가 배열 첨자를 검사하게 켰을 때 범위 밖 첨자에서 저절로 나오는 경우다. 검사는 파일 첫머리의 {$R+} 로 켠다. 이 검사는 기본적으로 꺼져 있고, 꺼진 상태에서 범위 밖 첨자를 쓰면 예외 없이 엉뚱한 메모리를 읽는다. 학습용 프로그램은 켜 두는 편이 낫다.
Delphi 와 다른 점: Delphi 는
uses System.SysUtils처럼 이름 공간을 붙이고, 범위 검사 옵션도 같은{$R+}이다. 이 장의raise,on E: 클래스 do,try finally문법은 둘이 같다. 표준 예외의 기본 메시지 문구는 다를 수 있으므로 문구를 비교하는 코드는 쓰지 않는다.
사용자 예외 클래스와 메시지 설계
Exception 을 상속한다
앞 장에서 배운 상속이 여기서 쓰인다. 주문 관련 오류를 한데 묶는 부모 예외를 하나 만들고, 구체적인 실패마다 자식을 만든다.
type
EOrderError = class(Exception);
EUnknownMenu = class(EOrderError)
private
FMenuName: string;
public
constructor Create(const AMenuName: string);
property MenuName: string read FMenuName;
end;
관례로 예외 클래스 이름은 E 로 시작한다. 본문이 없는 class(Exception); 도 올바른 선언이다. 새 필드가 필요 없으면 이름만으로 종류를 구분하는 용도로 충분하다. EUnknownMenu 는 받는 쪽이 메뉴 이름을 따로 꺼내 쓸 수 있도록 필드와 속성을 더했다.
부모를 하나 두면 받는 쪽에서 선택지가 생긴다. 세부 종류를 구분하려면 자식 클래스로 받고, 주문 오류 전체를 한꺼번에 받으려면 EOrderError 로 받는다.
on 절의 순서
on 절은 위에서부터 차례로 검사하고 처음 맞는 하나만 실행한다. 자식은 부모 클래스의 객체로도 취급되므로 on E: EOrderError 는 EUnknownMenu 도 받아 버린다. 따라서 자식 클래스를 먼저, 부모를 나중에 써야 한다. Exception 은 모든 예외의 부모이므로 항상 마지막에 둔다.
오류 메시지 설계
메시지는 가게 직원이나 나중에 로그를 읽는 개발자가 읽는다. 다음 세 가지를 지키면 대부분 충분하다.
- 무엇이 문제인지와 문제의 값을 함께 적는다. "오류" 보다 "라떼 재고 2개, 요청 3개" 가 낫다.
- 메시지는 만드는 곳에서 한 번만 정한다. 받는 쪽마다 문구를 다시 지으면 같은 오류가 여러 모양이 된다.
- 사용자에게 보일 문구와 로그용 정보를 섞지 않는다. 클래스 이름은 로그에, 메시지는 화면에 쓴다.
메시지를 만드는 일을 예외 클래스의 생성자에 넣으면 이 규칙을 지키기 쉽다.
constructor EOutOfStock.Create(const AMenuName: string; ALeft, AWanted: Integer);
begin
inherited CreateFmt('%s 재고 %d개, 요청 %d개', [AMenuName, ALeft, AWanted]);
end;
던지는 쪽은 raise EOutOfStock.Create('라떼', 2, 3) 한 줄이면 되고, 문구는 이 생성자 한 곳에만 있다.
try finally 와 함께 쓰기
try except 가 오류를 받아 처리하는 구문이라면, try finally 는 오류가 나든 안 나든 마지막에 반드시 실행할 코드를 보장하는 구문이다. 객체를 만들었으면 반드시 해제한다는 앞 장의 규칙을 예외가 있어도 지키게 해 준다.
Slip := TStringList.Create;
try
{ 전표를 쓰고 재고를 차감한다 }
finally
Slip.Free;
end;
Create 는 try 앞에 둔다. try 안에서 만들다가 생성이 실패하면 아직 값이 없는 변수에 Free 를 부르게 된다. finally 는 예외를 처리하지 않는다. 정리를 마친 뒤 예외는 그대로 바깥으로 올라간다. 그래서 두 구문을 겹쳐 쓴다. 안쪽은 finally 로 정리만, 바깥쪽은 except 로 처리만 맡긴다.
그림은 이 장의 예제를 따른다. Take 에서 던진 예외는 ServeOrder 의 finally 를 지나며 전표를 정리하고, TryOrder 의 except 에서 처리된다. 처리가 끝나면 Main 은 다음 주문으로 계속 진행한다.
완성 코드
여섯 건의 주문을 차례로 처리한다. 성공 두 건, 실패 네 건이다. 실패는 재고 부족, 없는 메뉴, 숫자가 아닌 수량, 범위 밖 수량이다. 마지막에는 범위 검사가 만드는 ERangeError 도 확인한다. 파일 이름은 main.pas 이다.
{$mode objfpc}{$H+}{$R+}
program Main;
uses
SysUtils, Classes;
type
EOrderError = class(Exception);
EUnknownMenu = class(EOrderError)
private
FMenuName: string;
public
constructor Create(const AMenuName: string);
property MenuName: string read FMenuName;
end;
EOutOfStock = class(EOrderError)
public
constructor Create(const AMenuName: string; ALeft, AWanted: Integer);
end;
TStockBook = class
private
FNames: array[0..2] of string;
FLeft: array[0..2] of Integer;
function IndexOf(const AMenuName: string): Integer;
public
constructor Create;
procedure Take(const AMenuName: string; AQty: Integer);
function Left(const AMenuName: string): Integer;
procedure Print;
end;
constructor EUnknownMenu.Create(const AMenuName: string);
begin
inherited Create('메뉴판에 없는 이름: ' + AMenuName);
FMenuName := AMenuName;
end;
constructor EOutOfStock.Create(const AMenuName: string; ALeft, AWanted: Integer);
begin
inherited CreateFmt('%s 재고 %d개, 요청 %d개', [AMenuName, ALeft, AWanted]);
end;
constructor TStockBook.Create;
begin
inherited Create;
FNames[0] := '아메리카노';
FLeft[0] := 5;
FNames[1] := '라떼';
FLeft[1] := 2;
FNames[2] := '크루아상';
FLeft[2] := 1;
end;
function TStockBook.IndexOf(const AMenuName: string): Integer;
var
I: Integer;
begin
Result := -1;
for I := 0 to High(FNames) do
if FNames[I] = AMenuName then
begin
Result := I;
Exit;
end;
end;
procedure TStockBook.Take(const AMenuName: string; AQty: Integer);
var
Idx: Integer;
begin
Idx := IndexOf(AMenuName);
if Idx < 0 then
raise EUnknownMenu.Create(AMenuName);
if AQty <= 0 then
raise ERangeError.CreateFmt('수량은 1 이상이어야 한다: %d', [AQty]);
if AQty > FLeft[Idx] then
raise EOutOfStock.Create(AMenuName, FLeft[Idx], AQty);
FLeft[Idx] := FLeft[Idx] - AQty;
end;
function TStockBook.Left(const AMenuName: string): Integer;
var
Idx: Integer;
begin
Idx := IndexOf(AMenuName);
if Idx < 0 then
raise EUnknownMenu.Create(AMenuName);
Result := FLeft[Idx];
end;
procedure TStockBook.Print;
var
I: Integer;
begin
Writeln('남은 재고');
for I := 0 to High(FNames) do
Writeln(Format(' %s: %d개', [FNames[I], FLeft[I]]));
end;
function ParseQty(const S: string): Integer;
begin
Result := 0;
try
Result := StrToInt(S);
except
on E: EConvertError do
raise EOrderError.CreateFmt('수량을 숫자로 읽을 수 없다: "%s"', [S]);
end;
end;
procedure ServeOrder(Stock: TStockBook; const AMenuName, AQtyText: string);
var
Qty, I: Integer;
Slip: TStringList;
begin
Slip := TStringList.Create;
try
Slip.Add('주문 접수: ' + AMenuName);
Qty := ParseQty(AQtyText);
Stock.Take(AMenuName, Qty);
Slip.Add(Format('재고 차감 %d개, 남은 수량 %d개', [Qty, Stock.Left(AMenuName)]));
for I := 0 to Slip.Count - 1 do
Writeln(' ', Slip[I]);
finally
Writeln(Format(' [정리] 전표 %d줄 해제', [Slip.Count]));
Slip.Free;
end;
end;
procedure TryOrder(Stock: TStockBook; const AMenuName, AQtyText: string);
begin
Writeln('> ', AMenuName, ' ', AQtyText);
try
ServeOrder(Stock, AMenuName, AQtyText);
except
on E: EUnknownMenu do
Writeln(' 거절: 메뉴판에 없는 이름 "', E.MenuName, '"');
on E: EOrderError do
Writeln(' 거절: ', E.Message);
on E: ERangeError do
Writeln(' 거절: ', E.Message);
on E: Exception do
begin
Writeln(' 예상 밖 오류: ', E.ClassName);
raise;
end;
end;
end;
procedure DemoRangeCheck;
var
Prices: array[1..3] of Integer;
I: Integer;
begin
for I := 1 to 3 do
Prices[I] := I * 1000;
I := 4;
try
Writeln(Prices[I]);
except
on E: ERangeError do
Writeln('범위 밖 첨자: ', E.ClassName);
end;
end;
var
Stock: TStockBook;
begin
Stock := TStockBook.Create;
try
TryOrder(Stock, '아메리카노', '2');
TryOrder(Stock, '라떼', '3');
TryOrder(Stock, '모카', '1');
TryOrder(Stock, '크루아상', '두 개');
TryOrder(Stock, '라떼', '0');
TryOrder(Stock, '크루아상', '1');
DemoRangeCheck;
Stock.Print;
finally
Stock.Free;
end;
end.
줄별 해설
{$mode objfpc}{$H+}{$R+}: 객체 지향 모드, 긴 문자열, 범위 검사를 켠다. 마지막 것이DemoRangeCheck의 예외를 만든다.EOrderError = class(Exception);: 주문 오류의 공통 부모다. 본문이 필요 없다.EUnknownMenu,EOutOfStock: 생성자에서inherited Create또는inherited CreateFmt로 부모에게 메시지를 넘긴다. 메시지 문구는 이 두 곳에만 있다.TStockBook.Take: 검사를 이름, 수량 범위, 재고 순으로 하고 실패하면 해당 예외를 던진다. 세 검사를 모두 통과해야 재고를 줄인다. 그래서 던져진 경우에는 재고가 바뀌지 않는다.ParseQty: 라이브러리의EConvertError를EOrderError로 바꿔 던진다.Result := 0은 컴파일러가 결과값 초기화 여부를 의심하지 않게 하려는 것이다.ServeOrder:TStringList로 전표를 만들고try finally로 감싼다. 성공하든 실패하든finally가 줄 수를 찍고 전표를 해제한다.TryOrder: 한 건씩except로 받는다. 자식인EUnknownMenu가 부모EOrderError보다 위에 있다.ERangeError는EOrderError의 자식이 아니므로 별도 분기가 필요하다. 마지막Exception분기는 예상하지 못한 오류를 기록하고raise;로 다시 던진다.DemoRangeCheck: 변수I를 4로 만들어 1..3 배열의 범위를 벗어난다. 첨자를 상수로 쓰면 컴파일 때 오류가 나므로 변수를 쓴다.- 마지막
try finally: 재고 장부를 만든 뒤 어떤 일이 있어도 해제한다.
실행 결과
$ fpc main.pas
$ ./main
> 아메리카노 2
주문 접수: 아메리카노
재고 차감 2개, 남은 수량 3개
[정리] 전표 2줄 해제
> 라떼 3
[정리] 전표 1줄 해제
거절: 라떼 재고 2개, 요청 3개
> 모카 1
[정리] 전표 1줄 해제
거절: 메뉴판에 없는 이름 "모카"
> 크루아상 두 개
[정리] 전표 1줄 해제
거절: 수량을 숫자로 읽을 수 없다: "두 개"
> 라떼 0
[정리] 전표 1줄 해제
거절: 수량은 1 이상이어야 한다: 0
> 크루아상 1
주문 접수: 크루아상
재고 차감 1개, 남은 수량 0개
[정리] 전표 2줄 해제
범위 밖 첨자: ERangeError
남은 재고
아메리카노: 3개
라떼: 2개
크루아상: 0개
실패한 주문에서도 [정리] 줄이 거절 메시지보다 먼저 찍힌다. 예외가 올라가는 길에 finally 가 먼저 실행되고, 그다음 except 가 처리하기 때문이다. 실패한 주문은 재고를 줄이지 못했으므로 라떼는 2개 그대로다.
실무에서 자주 틀리는 것
부모 예외를 먼저 쓴다
틀린 코드:
except
on E: Exception do
Writeln('오류: ', E.Message);
on E: EUnknownMenu do
Writeln('없는 메뉴: ', E.MenuName);
end;
첫 분기가 모든 예외를 받으므로 두 번째 분기는 실행되지 않는다. 고친 코드는 자식을 먼저 쓴다.
except
on E: EUnknownMenu do
Writeln('없는 메뉴: ', E.MenuName);
on E: Exception do
Writeln('오류: ', E.Message);
end;
Create 를 try 안에 넣는다
틀린 코드:
var
Slip: TStringList;
begin
try
Slip := TStringList.Create;
Slip.Add('주문');
finally
Slip.Free;
end;
end;
Create 줄에서 문제가 생기면 Slip 에는 값이 없는데 finally 가 Free 를 부른다. 고친 코드는 Create 를 try 앞에 둔다.
Slip := TStringList.Create;
try
Slip.Add('주문');
finally
Slip.Free;
end;
빈 except 로 오류를 삼킨다
틀린 코드:
try
Stock.Take(AMenuName, Qty);
except
end;
어떤 오류가 나도 조용히 지나가서 재고가 왜 안 줄었는지 알 수 없다. 처리할 수 있는 종류만 받고, 나머지는 위로 올려 보낸다.
try
Stock.Take(AMenuName, Qty);
except
on E: EOrderError do
Writeln('거절: ', E.Message);
end;
정보 없는 메시지를 던진다
틀린 코드:
raise Exception.Create('오류');
어느 메뉴인지, 얼마를 요청했는지 알 수 없다. 고친 코드는 문제의 값을 넣고 클래스로 종류를 구분한다.
raise EOutOfStock.Create(AMenuName, FLeft[Idx], AQty);
한눈에 보기
| 구문 | 역할 | 예외가 났을 때 | 쓰는 곳 |
|---|---|---|---|
raise 객체 | 예외를 던진다 | 실행을 멈추고 위로 올린다 | 오류를 발견한 곳 |
try … except | 예외를 받아 처리한다 | 맞는 on 분기 실행 | 처리할 수 있는 곳 |
try … finally | 정리 코드를 보장한다 | 정리 후 예외를 계속 올린다 | 객체를 만든 곳 |
raise; | 받은 예외를 다시 던진다 | except 안에서만 쓴다 | 기록 후 바깥에 맡길 때 |
| 점검 항목 | 나쁜 예 | 좋은 예 |
|---|---|---|
| 값을 포함한다 | 수량 오류 | 수량은 1 이상이어야 한다: 0 |
| 대상을 밝힌다 | 재고 부족 | 라떼 재고 2개, 요청 3개 |
| 만드는 곳에서 정한다 | 호출마다 문구 작성 | 예외 생성자에서 한 번 |
연습 문제
- 글자 하나를 받아 숫자로 바꾸되, 바꿀 수 없으면 -1 을 돌려주는 함수
ReadQtyOrMinus를try except와EConvertError로 만들어라. - 받은 돈이 모자랄 때 던질 사용자 예외
EShortPayment를EOrderError의 자식으로 만들어라. 생성자는 받은 돈과 필요한 금액을 받아 "받은 돈 3000원, 필요한 금액 4500원" 형식의 메시지를 만든다. - 다음 코드의 출력을 예상하라.
begin try try Writeln('A'); raise Exception.Create('x'); finally Writeln('B'); end; except on E: Exception do Writeln('C ', E.Message); end; Writeln('D'); end. - 완성 코드의
TryOrder에서on E: Exception분기를 맨 위로 옮기면 어떻게 되는지 설명하라.
정답과 해설
- 변환을
try로 감싸고EConvertError에서 -1 을 돌려준다.function ReadQtyOrMinus(const S: string): Integer; begin try Result := StrToInt(S); except on E: EConvertError do Result := -1; end; end;변환이 성공하면
except는 실행되지 않는다. 주문 수량으로 -1 이 의미 있는 값이 될 수 있으면 이 방식 대신 예외를 올리는 편이 안전하다. - 메시지를 생성자 한 곳에서 만든다.
type EShortPayment = class(EOrderError) public constructor Create(AReceived, ANeeded: Integer); end; constructor EShortPayment.Create(AReceived, ANeeded: Integer); begin inherited CreateFmt('받은 돈 %d원, 필요한 금액 %d원', [AReceived, ANeeded]); end;던지는 쪽은
raise EShortPayment.Create(3000, 4500)한 줄이면 된다. - 출력은 다음과 같다.
A B C x Draise가 일어나면A다음 줄은 건너뛰고 안쪽finally가B를 찍는다. 예외는 계속 올라가 바깥except에서C x로 처리된다. 처리가 끝났으므로 프로그램은D로 이어진다. Exception은 모든 예외의 부모이므로 첫 분기가 모든 예외를 받는다. 두 번째 주문인 라떼 3개에서EOutOfStock이 그 분기에 걸려 "예상 밖 오류: EOutOfStock" 을 찍고raise;로 다시 던진다. 받는 곳이 없으므로 프로그램은 처리되지 않은 예외 메시지와 함께 종료되고, 뒤의 주문과 재고 출력은 실행되지 않는다. 자식 클래스를 먼저 쓰는 규칙이 필요한 이유다.