Devin.KR

Object Pascal · 기본

Object Pascal 첫걸음

예외 처리 - try except 와 try finally

raise, try except on E: 클래스, 사용자 예외 클래스, try finally 와 함께 쓰기, EConvertError·ERangeError, 오류 메시지 설계

개발자KR · 원고 갱신

이 장에서 배우는 것

앞 장까지 만든 계산대 프로그램은 입력이 올바르다고 믿고 움직였다. 실제 가게에서는 메뉴판에 없는 이름이 들어오고, 수량 칸에 숫자 대신 글자가 들어오고, 재고보다 많은 주문이 들어온다. 이 장은 그런 상황을 프로그램이 스스로 알리고 수습하게 만드는 방법을 다룬다. 오류가 났을 때 던지는 것이 예외(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 로 받는다.

사용자 예외는 Exception 아래 한 가지에 모으고, on 절은 자식 클래스부터 차례로 검사된다.

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 로 처리만 맡긴다.

예외는 호출의 반대 방향으로 올라가며 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. 글자 하나를 받아 숫자로 바꾸되, 바꿀 수 없으면 -1 을 돌려주는 함수 ReadQtyOrMinus 를 try except 와 EConvertError 로 만들어라.
  2. 받은 돈이 모자랄 때 던질 사용자 예외 EShortPayment 를 EOrderError 의 자식으로 만들어라. 생성자는 받은 돈과 필요한 금액을 받아 "받은 돈 3000원, 필요한 금액 4500원" 형식의 메시지를 만든다.
  3. 다음 코드의 출력을 예상하라.
    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.
  4. 완성 코드의 TryOrder 에서 on E: Exception 분기를 맨 위로 옮기면 어떻게 되는지 설명하라.

정답과 해설

  1. 변환을 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 이 의미 있는 값이 될 수 있으면 이 방식 대신 예외를 올리는 편이 안전하다.

  2. 메시지를 생성자 한 곳에서 만든다.
    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) 한 줄이면 된다.

  3. 출력은 다음과 같다.
    A
    B
    C x
    D

    raise 가 일어나면 A 다음 줄은 건너뛰고 안쪽 finally 가 B 를 찍는다. 예외는 계속 올라가 바깥 except 에서 C x 로 처리된다. 처리가 끝났으므로 프로그램은 D 로 이어진다.

  4. Exception 은 모든 예외의 부모이므로 첫 분기가 모든 예외를 받는다. 두 번째 주문인 라떼 3개에서 EOutOfStock 이 그 분기에 걸려 "예상 밖 오류: EOutOfStock" 을 찍고 raise; 로 다시 던진다. 받는 곳이 없으므로 프로그램은 처리되지 않은 예외 메시지와 함께 종료되고, 뒤의 주문과 재고 출력은 실행되지 않는다. 자식 클래스를 먼저 쓰는 규칙이 필요한 이유다.

오탈자·오류 제보 비공개로 접수되어 원고 수정에 반영됩니다

이메일 등 개인정보는 받지 않습니다. 답변이 필요한 질문은 아래 댓글을 이용해 주세요.

READER FEEDBACK

질문·의견

내용에 관한 질문이나 더 나은 설명을 위한 의견을 남겨 주세요. 오탈자는 위의 제보 양식이 더 빨리 반영됩니다. 이 댓글은 원래 게시글과 같은 자리에 쌓입니다.

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.