Devin.KR

Object Pascal · 기본

Object Pascal 첫걸음

유닛 - interface 와 implementation

unit 구조, interface 와 implementation 구획, uses 순환 피하기, initialization·finalization, 여러 파일 빌드

개발자KR · 원고 갱신

이 장에서 배우는 것

앞 장에서는 Format 과 SysUtils 로 문자열을 만드는 방법을 배웠다. 이제 카페 계산대 프로그램은 메뉴 이름, 가격, 주문 합계, 기록 출력을 모두 갖췄고, 그 코드가 전부 main.pas 한 파일에 들어 있다. 이 장에서는 그 코드를 역할별로 나눠 여러 파일로 옮긴다. Pascal 에서 파일 하나에 해당하는 코드 묶음을 유닛(unit)이라고 부른다. 유닛은 밖에 보여 줄 것과 감출 것을 문법으로 구분하고, 자기 일을 시작하고 마칠 때 실행할 코드를 따로 둘 수 있다.

  • unit 파일의 뼈대와 interface·implementation 두 구획이 각각 무엇을 담는지 설명한다.
  • uses 절을 어느 구획에 적어야 하는지 구분하고, 유닛끼리 서로를 부르는 순환 참조를 피한다.
  • initialization 과 finalization 이 실행되는 시점과 순서를 예측한다.
  • fpc 로 여러 파일을 한 번에 빌드하고, 컴파일 후 생기는 파일의 용도를 안다.

문제 상황

카페 주인이 메뉴에 케이크를 추가해 달라고 한다. main.pas 를 열었더니 메뉴 이름 배열, 가격 배열, 주문 합계 계산, 기록 출력이 한 파일에 섞여 있고 길이는 이미 수백 줄이다. 가격 하나를 바꾸려고 파일을 훑다가 합계 계산 함수의 지역 변수 이름을 잘못 건드리기 쉽다.

이름이 겹치는 일도 생긴다. 주문 합계를 Total 이라 부르고, 일일 정산 합계도 Total 이라 부르고 싶어진다. 한 파일 안에서는 같은 이름을 두 번 쓸 수 없으니 OrderTotal, DayTotal 처럼 억지로 길게 쓰게 된다. 정산 담당 코드가 주문 코드의 내부 배열을 직접 읽어 버리면, 나중에 주문 코드의 내부 구조를 바꿀 때 정산 코드까지 고쳐야 한다.

유닛으로 나누면 이 문제를 줄일 수 있다. 메뉴는 cafemenu.pas, 주문은 cafeorder.pas, 기록은 cafelog.pas 로 분리하고, 각 파일은 다른 파일이 써도 되는 것만 공개한다. 이 장의 예제는 이 세 유닛과 main.pas 로 구성된다.

유닛의 구조: interface 와 implementation

유닛의 뼈대

유닛 파일은 정해진 순서의 구획으로 이루어진다. 구획 순서는 바꿀 수 없다.

{$mode objfpc}{$H+}
unit 유닛이름;

interface
  { 밖에 공개하는 선언 }

implementation
  { 구현과 비공개 항목 }

initialization
  { 선택: 처음 로드될 때 한 번 실행 }
finalization
  { 선택: 프로그램이 끝날 때 한 번 실행 }
end.

파일 첫머리의 {$mode objfpc}{$H+} 는 앞 장까지와 같이 모든 파일에 적는다. 이 지시문은 파일마다 따로 적용되므로 main.pas 에만 적고 유닛 파일에서 빼면 유닛은 다른 모드로 컴파일된다. 마지막 줄은 end. 이고 마침표가 붙는다. initialization 과 finalization 은 없어도 되며, finalization 은 initialization 이 있을 때만 둘 수 있다.

유닛 이름은 파일 이름과 맞춰야 한다. unit CafeMenu; 는 cafemenu.pas 에 둔다. Pascal 은 대소문자를 구분하지 않지만 Linux 의 파일 시스템은 구분하므로, 파일 이름을 모두 소문자로 쓰면 어느 환경에서나 같은 방식으로 찾는다.

두 구획이 담는 것

interface 구획은 유닛의 공개 안내문이다. 여기에는 프로시저와 함수의 머리(이름, 매개변수, 반환 타입)만 적고, 본문은 적지 않는다. 타입, 상수, 변수도 이 구획에 적으면 밖에서 보인다. implementation 구획은 실제 본문과 비공개 항목을 둔다. 여기에 선언한 상수, 변수, 타입, 함수는 같은 유닛 안에서만 보인다.

구획별로 들어가는 것과 밖에서 보이는 정도
구획적는 것다른 파일에서
interface공개할 타입·상수·함수 머리보인다
implementation함수 본문, 비공개 상수·변수·타입보이지 않는다
initialization시작할 때 실행할 문장직접 부를 수 없다
finalization끝날 때 실행할 문장직접 부를 수 없다
다른 파일은 interface 구획만 부를 수 있고 implementation 구획의 내용에는 접근하지 못한다.

이 장의 CafeOrder 유닛에서는 주문 줄을 담는 레코드와 배열을 implementation 에 두었다. main.pas 는 AddLine 과 OrderTotal 만 부를 수 있고, 배열을 직접 읽거나 고칠 수 없다. 나중에 배열을 다른 자료 구조로 바꾸더라도 interface 의 함수 머리가 같으면 main.pas 는 고칠 필요가 없다.

implementation 에서 함수 본문을 쓸 때는 interface 의 머리와 같은 이름, 같은 매개변수, 같은 반환 타입으로 적어야 한다. 다르면 컴파일러가 선언을 찾지 못한다는 오류를 낸다.

uses 와 순환 피하기

uses 는 두 곳에 쓸 수 있다

다른 유닛의 이름을 쓰려면 uses 절에 그 유닛을 적는다. 유닛에서는 uses 를 interface 아래와 implementation 아래 두 곳에 적을 수 있다. 기준은 간단하다. interface 의 선언문 안에서 그 유닛의 타입을 쓰면 interface 쪽에 적는다. 본문에서만 쓰면 implementation 쪽에 적는다.

CafeOrder 의 AddLine 은 매개변수 타입으로 CafeMenu 의 TMenuCode 를 쓰므로 interface 쪽에 uses CafeMenu; 가 필요하다. 반면 기록을 남기는 CafeLog 는 본문 안에서만 부르므로 implementation 쪽에 둔다.

uses 는 전달되지 않는다. main.pas 가 CafeOrder 를 쓴다고 해서 CafeOrder 가 쓰는 CafeMenu 의 이름까지 main.pas 에서 보이지는 않는다. main.pas 가 mcAmericano 를 직접 쓰려면 main.pas 에도 CafeMenu 를 적어야 한다.

순환 참조

유닛 A 의 interface 가 B 를 쓰고 B 의 interface 가 A 를 쓰면 컴파일러는 어느 쪽을 먼저 처리해야 할지 정할 수 없다. 이것을 순환 참조(circular reference)라고 한다. 컴파일러는 "Circular unit reference between …" 와 같은 오류를 낸다.

유닛 의존 관계는 한 방향으로만 흐르는 그래프여야 하며, 초기화는 의존하는 유닛이 먼저, 종료는 그 반대 순서로 일어난다.

순환은 보통 이런 때 생긴다. 메뉴 유닛이 주문 수를 알고 싶어서 CafeOrder 를 쓰고, 주문 유닛은 이미 CafeMenu 를 쓰고 있다. 해결 방법은 세 가지다.

  • uses 를 implementation 쪽으로 옮긴다. 두 유닛의 implementation 은 서로를 참조해도 되는 경우가 많다. 다만 이것은 급한 해결이고, 구조 자체가 꼬였다는 신호일 수 있다.
  • 둘이 함께 쓰는 타입이나 상수를 새 유닛으로 뽑는다. 두 유닛이 새 유닛을 쓰면 화살표가 한 방향이 된다.
  • 필요한 값을 매개변수로 넘긴다. 이 장의 CafeLog 는 CafeOrder 를 모른다. 주문 번호를 로그에 넣고 싶으면 CafeOrder 가 문자열로 만들어서 WriteLog 에 넘긴다.

설계할 때는 그림처럼 의존 관계를 위에서 아래로 그려 본다. 아래쪽 유닛(메뉴, 기록)은 위쪽 유닛(주문, 메인)을 모르는 구조가 되면 순환이 생기지 않는다.

initialization, finalization 과 여러 파일 빌드

initialization 과 finalization

유닛의 initialization 구획은 프로그램이 본문 begin 에 들어가기 전에 한 번 실행된다. 전역 변수의 시작 값을 정하거나 준비 작업을 할 때 쓴다. finalization 구획은 프로그램 본문이 끝난 뒤 한 번 실행되고, 정리 작업과 마무리 출력을 둔다.

실행 순서에는 규칙이 있다. 어떤 유닛이 다른 유닛을 쓰면 쓰이는 쪽이 먼저 초기화된다. 종료는 정확히 반대다. 이 장의 예에서 CafeOrder 는 implementation 에서 CafeLog 를 쓰므로 초기화는 CafeLog, CafeOrder 순서이고, 종료는 CafeOrder, CafeLog 순서다. 이렇게 되어야 CafeOrder 의 finalization 이 실행될 때 CafeLog 가 아직 살아 있다. 서로 의존 관계가 없는 유닛 사이의 순서는 보장되지 않으므로, 그 순서에 기대는 코드는 쓰지 않는다.

initialization 은 가벼운 일만 맡긴다. 이 장의 예제는 설명을 위해 화면에 한 줄씩 출력하지만, 실제 프로그램에서는 변수 초기화 정도가 적당하다. 오래 걸리거나 실패할 수 있는 일을 여기에 두면 프로그램 본문이 시작하기도 전에 문제가 생기고 원인을 찾기도 어렵다.

여러 파일 빌드

파일이 여러 개여도 명령은 주 프로그램 하나만 적는다. fpc 는 main.pas 의 uses 절을 읽고, 같은 폴더에서 이름이 같은 .pas 파일을 찾아 먼저 컴파일한 뒤 하나의 실행 파일로 묶는다. 유닛은 컴파일되면 .ppu(유닛의 interface 정보)와 .o(기계어) 파일을 남긴다. 다음 번 빌드에서는 바뀐 유닛과 그 영향을 받는 유닛만 다시 컴파일한다.

자주 쓰는 fpc 명령과 쓰는 때
명령하는 일쓰는 때
fpc main.pas바뀐 유닛만 컴파일하고 연결평소 빌드
fpc -B main.pas모든 유닛을 다시 컴파일이상한 오류가 남을 때
fpc -Fu./lib main.paslib 폴더에서도 유닛을 찾음유닛을 폴더로 나눴을 때

유닛의 implementation 만 고치면 보통 그 유닛만 다시 컴파일한다. interface 를 고치면 그 유닛을 쓰는 다른 유닛도 다시 컴파일한다. 의존 관계를 얕게 유지할수록 빌드가 빨라지는 이유다.

Delphi 와 다른 점을 짧게 적는다. Delphi 는 프로그램 파일이 .dpr 이고, uses 절에서 CafeMenu in 'src\cafemenu.pas' 처럼 경로를 줄 때가 많다. fpc 도 같은 문법을 받지만 이 장에서는 같은 폴더에 두고 이름으로 찾게 한다. initialization, finalization 의 의미와 순서 규칙은 두 환경이 같다.

완성 코드

네 파일을 같은 폴더에 둔다. 가격과 이름은 CafeMenu, 기록은 CafeLog, 주문은 CafeOrder, 이를 사용하는 프로그램은 main.pas 다.

cafemenu.pas

{$mode objfpc}{$H+}
unit CafeMenu;

interface

type
  TMenuCode = (mcAmericano, mcLatte, mcCake);

function MenuName(Code: TMenuCode): string;
function MenuPrice(Code: TMenuCode): Integer;

implementation

const
  Names: array[TMenuCode] of string = ('아메리카노', '라테', '치즈케이크');
  Prices: array[TMenuCode] of Integer = (3500, 4200, 5500);

function MenuName(Code: TMenuCode): string;
begin
  Result := Names[Code];
end;

function MenuPrice(Code: TMenuCode): Integer;
begin
  Result := Prices[Code];
end;

end.

cafelog.pas

{$mode objfpc}{$H+}
unit CafeLog;

interface

procedure WriteLog(const Msg: string);
procedure DumpLog;

implementation

uses
  SysUtils;

const
  MaxLines = 20;

var
  Lines: array[1..MaxLines] of string;
  Count: Integer;

procedure WriteLog(const Msg: string);
begin
  if Count < MaxLines then
  begin
    Inc(Count);
    Lines[Count] := Msg;
  end;
end;

procedure DumpLog;
var
  I: Integer;
begin
  for I := 1 to Count do
    WriteLn(Format('  %d. %s', [I, Lines[I]]));
end;

initialization
  Count := 0;
  WriteLn('[log] 열림');

finalization
  WriteLn(Format('[log] 닫힘, 기록 %d줄', [Count]));

end.

cafeorder.pas

{$mode objfpc}{$H+}
unit CafeOrder;

interface

uses
  CafeMenu;

procedure BeginOrder;
procedure AddLine(Code: TMenuCode; Qty: Integer);
function OrderTotal: Integer;
function OrderNumber: Integer;

implementation

uses
  SysUtils, CafeLog;

type
  TOrderLine = record
    Code: TMenuCode;
    Qty: Integer;
  end;

const
  MaxOrderLines = 10;

var
  OrderLines: array[1..MaxOrderLines] of TOrderLine;
  LineCount: Integer;
  Number: Integer;

procedure BeginOrder;
begin
  Inc(Number);
  LineCount := 0;
  WriteLog(Format('주문 %d 시작', [Number]));
end;

procedure AddLine(Code: TMenuCode; Qty: Integer);
begin
  if LineCount >= MaxOrderLines then
    Exit;
  Inc(LineCount);
  OrderLines[LineCount].Code := Code;
  OrderLines[LineCount].Qty := Qty;
  WriteLog(Format('%s x%d', [MenuName(Code), Qty]));
end;

function OrderTotal: Integer;
var
  I: Integer;
begin
  Result := 0;
  for I := 1 to LineCount do
    Result := Result + MenuPrice(OrderLines[I].Code) * OrderLines[I].Qty;
end;

function OrderNumber: Integer;
begin
  Result := Number;
end;

initialization
  Number := 0;
  LineCount := 0;
  WriteLn('[order] 주문 번호는 1번부터 시작한다');

finalization
  WriteLn(Format('[order] 처리한 주문 %d건', [Number]));

end.

main.pas

{$mode objfpc}{$H+}
program Main;

uses
  SysUtils, CafeMenu, CafeOrder, CafeLog;

procedure PrintReceipt;
begin
  WriteLn(Format('주문 %d 합계: %d원', [OrderNumber, OrderTotal]));
end;

begin
  WriteLn('== 메인 시작 ==');
  BeginOrder;
  AddLine(mcAmericano, 2);
  AddLine(mcCake, 1);
  PrintReceipt;
  BeginOrder;
  AddLine(mcLatte, 3);
  PrintReceipt;
  WriteLn('-- 기록 --');
  DumpLog;
  WriteLn('== 메인 끝 ==');
end.

줄별 해설

cafemenu.pas

  • interface 에는 열거 타입 TMenuCode 와 함수 머리 두 개만 있다. 이름 배열과 가격 배열은 implementation 의 상수라서 밖에서 직접 읽을 수 없다.
  • array[TMenuCode] of string 은 열거 타입을 첨자로 쓰는 배열이다. 원소 수가 열거 값 수와 맞지 않으면 컴파일 오류가 나므로, 메뉴를 추가하고 가격을 빼먹는 실수를 컴파일러가 잡아 준다.
  • 함수 본문은 Result 에 값을 대입한다. 이 유닛은 uses 가 하나도 없는 가장 아래쪽 유닛이다.

cafelog.pas

  • interface 는 WriteLog 와 DumpLog 둘만 공개한다. 줄을 담는 배열 Lines 와 개수 Count 는 비공개다.
  • uses SysUtils 는 implementation 쪽에 있다. Format 을 본문에서만 쓰기 때문이다.
  • WriteLog 는 배열이 가득 차지 않았을 때만 줄을 추가한다. 가득 차면 조용히 무시하는 단순한 규칙이다.
  • initialization 은 Count 를 0 으로 두고 열림 메시지를 출력한다. finalization 은 쌓인 줄 수를 출력한다. 메시지는 모두 프로그램 본문 밖에서 나온다는 것을 보이기 위한 출력이다.

cafeorder.pas

  • interface 의 uses CafeMenu; 는 AddLine 의 매개변수 타입 TMenuCode 때문에 필요하다.
  • implementation 의 uses SysUtils, CafeLog; 는 본문에서만 필요한 유닛이다. CafeLog 를 이쪽에 둔 덕분에 CafeLog 쪽에서 CafeOrder 를 쓰게 되더라도 interface 순환은 생기지 않는다.
  • TOrderLine, OrderLines, LineCount, Number 는 모두 비공개다. 밖에서는 함수로만 접근한다.
  • BeginOrder 는 번호를 하나 올리고 줄 수를 0 으로 되돌린 뒤 기록을 남긴다. AddLine 은 줄이 가득 차면 Exit 로 바로 빠져나온다.
  • OrderTotal 는 줄마다 가격에 수량을 곱해 더한다. 첫 주문은 3500×2 + 5500 = 12500, 둘째 주문은 4200×3 = 12600 이다.

main.pas

  • uses 에 네 유닛이 모두 있는 이유가 있다. Format 은 SysUtils, mcAmericano 는 CafeMenu, BeginOrder 는 CafeOrder, DumpLog 는 CafeLog 에 있기 때문이다.
  • 프로그램 본문이 시작되기 전에 CafeLog 와 CafeOrder 의 initialization 출력이 나온다. 본문이 끝나면 finalization 출력이 반대 순서로 나온다.
  • CafeMenu 는 initialization 이 없어서 출력이 없다.

실행 결과

$ fpc main.pas
$ ./main
[log] 열림
[order] 주문 번호는 1번부터 시작한다
== 메인 시작 ==
주문 1 합계: 12500원
주문 2 합계: 12600원
-- 기록 --
  1. 주문 1 시작
  2. 아메리카노 x2
  3. 치즈케이크 x1
  4. 주문 2 시작
  5. 라테 x3
== 메인 끝 ==
[order] 처리한 주문 2건
[log] 닫힘, 기록 5줄

첫 줄 두 개는 본문이 시작되기 전, 마지막 두 줄은 본문이 끝난 뒤에 나온 것이다. 컴파일할 때는 fpc 의 버전과 줄 수를 알리는 메시지가 먼저 나오지만 경고는 나오지 않는다.

실무에서 자주 틀리는 것

구현만 쓰고 interface 에 선언하지 않는다

유닛 안에서는 함수를 잘 쓰는데 main.pas 에서만 찾지 못하는 경우다.

{ cafeorder.pas — 틀림: implementation 에만 있다 }
implementation
function LineTotal: Integer;
begin
  Result := 0;
end;

{ main.pas }
WriteLn(LineTotal);   { Error: Identifier not found "LineTotal" }

밖에서 부를 함수는 interface 에 머리를 적어야 한다.

{ cafeorder.pas — 고침 }
interface
function LineTotal: Integer;

implementation
function LineTotal: Integer;
begin
  Result := 0;
end;

interface 의 uses 로 순환을 만든다

{ cafemenu.pas — 틀림 }
interface
uses CafeOrder;      { CafeOrder 의 interface 도 CafeMenu 를 쓴다 }

{ 컴파일: Circular unit reference between CafeMenu and CafeOrder }

먼저 정말 필요한 의존인지 따져 본다. 본문에서만 쓴다면 implementation 쪽으로 옮긴다. 둘이 같은 타입을 쓰는 것이 원인이면 그 타입을 새 유닛으로 뽑는다.

{ cafemenu.pas — 고침 1: 본문에서만 쓴다면 }
interface
{ uses 없음 }

implementation
uses CafeOrder;

파일 이름과 unit 이름이 다르다

{ 파일 이름이 menu.pas 인데 내용은 unit CafeMenu; }
{ main.pas 의 uses CafeMenu 에서: Can't find unit CafeMenu }

fpc 는 uses 에 적힌 이름으로 파일을 찾는다. 파일 이름을 cafemenu.pas 로 바꾸면 해결된다. 이름을 바꾸기 싫다면 uses CafeMenu in 'menu.pas' 로 경로를 알려 줄 수도 있지만, 이름을 맞추는 쪽이 단순하다.

같은 이름을 가진 두 유닛의 순서를 모르고 쓴다

두 유닛이 같은 이름의 함수를 공개하면, uses 에서 뒤에 적은 유닛의 것이 앞의 것을 가린다. 아래 예의 두 유닛은 설명을 위해 가정한 것이다.

uses OrderSum, DaySum;    { 둘 다 Total 을 공개한다 }
WriteLn(Total);           { 뒤에 적은 DaySum 의 Total 이 불린다 }

유닛 이름을 앞에 붙이면 어느 쪽인지 분명해진다. uses 순서를 바꾸기만 해도 결과가 달라지는 코드는 읽기 어렵다.

WriteLn(OrderSum.Total);
WriteLn(DaySum.Total);

한눈에 보기

유닛의 구성 요소와 규칙 요약
요소역할규칙이 장의 예
unit 이름유닛을 가리키는 이름파일 이름과 같게 한다CafeMenu = cafemenu.pas
interface공개 선언함수는 머리만 적는다MenuName, AddLine
implementation본문과 비공개 항목머리가 interface 와 같아야 한다OrderLines 배열
uses다른 유닛 사용전달되지 않고 순환하면 오류CafeOrder 가 CafeMenu 를 사용
initialization시작 시 한 번 실행의존하는 유닛이 먼저[log] 열림
finalization종료 시 한 번 실행초기화의 반대 순서[log] 닫힘

연습 문제

  1. 메뉴에 모카(4800원)를 추가하려고 한다. 어느 파일의 어느 부분을 고쳐야 하는지 말하고, 고치는 항목을 모두 적어라.
  2. CafeLog 가 로그 줄 앞에 주문 번호를 붙이고 싶어서 OrderNumber 를 부르려고 한다. CafeLog 에서 CafeOrder 를 쓰면 어떤 일이 생기고, 순환 없이 해결하려면 어떻게 하는가.
  3. main.pas 의 uses 절을 SysUtils, CafeMenu, CafeLog, CafeOrder 로 바꾸면 실행 결과의 첫 두 줄이 달라지는가. 이유도 설명하라.
  4. unit A 의 interface 가 B 를 쓰고, unit B 의 interface 가 A 를 쓴다. 이 코드를 컴파일하면 어떻게 되며, 고치는 방법 두 가지를 말하라.

정답과 해설

  1. cafemenu.pas 만 고친다. interface 의 TMenuCode 에 mcMocha 를 추가하고, implementation 의 Names 와 Prices 의 원소를 하나씩 늘린다. 열거 타입을 첨자로 쓰는 배열은 원소 수가 맞지 않으면 컴파일 오류가 나므로, 세 곳 중 하나를 빼먹으면 컴파일러가 알려 준다. 함수 머리는 바뀌지 않아서 cafeorder.pas 와 main.pas 는 고칠 필요가 없다. 다만 interface 의 타입이 바뀌었으므로 그 유닛을 쓰는 파일은 다시 컴파일된다.
  2. CafeOrder 의 interface 는 CafeMenu 만 쓰고 implementation 은 CafeLog 를 쓴다. 여기에 CafeLog 가 CafeOrder 를 쓰도록 하면 두 유닛이 서로를 참조하는 구조가 된다. CafeLog 가 CafeOrder 를 implementation 에서만 쓴다면 컴파일은 되지만, 가장 아래에 있어야 할 기록 유닛이 위쪽을 알게 되어 의존 방향이 꼬인다. 해결은 CafeOrder 가 WriteLog(Format('[%d] ...', [Number])) 처럼 문자열을 만들어 넘기는 것이다. CafeLog 는 받은 문자열만 저장하면 되고 주문 번호가 무엇인지 알 필요가 없다.
  3. 달라지지 않는다. CafeOrder 가 implementation 에서 CafeLog 를 쓰므로, uses 에 어떤 순서로 적든 CafeLog 가 먼저 초기화된다. 첫 두 줄은 [log] 열림, [order] 주문 번호는 1번부터 시작한다 로 그대로다. 의존 관계가 있으면 순서가 정해지고, 의존 관계가 없는 유닛끼리만 uses 순서가 영향을 줄 수 있다.
  4. 컴파일러가 "Circular unit reference between A and B" 같은 오류를 낸다. 고치는 방법은 둘 중 하나의 uses 를 implementation 으로 옮기는 것, 또는 둘이 함께 쓰는 선언을 새 유닛 C 로 뽑아서 A 와 B 가 모두 C 를 쓰게 하는 것이다. 둘 중 하나가 매개변수로 값을 받도록 바꿔 의존 자체를 없애는 방법도 있다.
오탈자·오류 제보 비공개로 접수되어 원고 수정에 반영됩니다

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

READER FEEDBACK

질문·의견

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

댓글 0

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

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