Devin.KR

완성 프로젝트 - 카페 일일 정산

개발자KR 조회 0

이 장에서 배우는 것

앞 장까지 변수, 조건과 반복, 프로시저, 레코드, 문자열, 유닛, 클래스, 예외, 컬렉션과 파일을 하나씩 익혔다. 이 장에서는 그것들을 모아 동네 카페의 하루를 마감하는 콘솔 프로그램을 만든다. 메뉴, 재고, 주문 기록 파일을 읽어서 메뉴별 매출과 재고 부족 경고가 들어 있는 정산표를 출력한다. 마지막에는 메모리 누수를 점검하고, 같은 코드를 Delphi 로 옮길 때 무엇이 달라지는지도 확인한다.

  • 프로그램을 메뉴·재고·주문 세 유닛과 main 으로 나누고, 어느 유닛이 어느 유닛을 쓰는지 정한다.
  • 쉼표로 구분한 주문 기록 파일을 읽어 잘못된 줄을 걸러내고, 나머지를 재고에 반영한다.
  • 메뉴별 매출과 비중, 재고 부족 경고, 처리하지 못한 주문을 정산표로 출력한다.
  • fpc -gh 로 컴파일해서 만든 객체를 모두 해제했는지 확인한다.
  • Free Pascal 코드를 Delphi 로 옮길 때 고쳐야 할 곳을 안다.

문제 상황

카페 사장님은 퇴근 전에 계산대에 쌓인 주문 기록을 보고 세 가지를 알고 싶어 한다. 오늘 메뉴별로 몇 잔이 팔려 얼마가 되었는지, 내일 아침에 채워 넣어야 할 재료가 무엇인지, 기록 중에 이상한 줄이 없는지다.

주문 기록은 한 줄에 주문 하나를 적은 텍스트 파일이다. 사람이 손으로 고치기도 해서 메뉴 코드가 소문자로 적히거나, 없는 코드가 들어가거나, 형식이 깨진 줄이 섞이기도 한다. 재고보다 많은 수량이 주문된 줄도 있다. 이런 줄 때문에 프로그램이 멈추면 안 된다. 처리할 수 있는 줄은 처리하고, 처리하지 못한 줄은 이유를 적어서 마지막에 보여 주어야 한다.

한 파일에 모든 것을 넣으면 코드는 금방 수백 줄이 된다. 가격표를 고치려는데 재고 계산 코드를 같이 읽어야 한다면 실수하기 쉽다. 그래서 역할별로 유닛을 나눈다.

유닛으로 나눈 설계

역할과 의존 방향

나눈 기준은 "무엇을 알고 있는가"다. 메뉴 유닛은 코드, 이름, 가격만 안다. 재고 유닛은 코드별 남은 수량만 안다. 두 유닛은 서로를 모른다. 주문 유닛이 둘을 함께 쓰며 파일 읽기와 판매 집계를 맡고, main 은 이들을 조립해서 출력한다.

main 이 맨 위에서 세 유닛을 쓰고 주문 유닛만 메뉴·재고 유닛을 쓰며 거꾸로 가는 의존은 없다.
유닛별 역할과 의존 관계
파일공개하는 것숨기는 것쓰는 유닛
cafemenu.pasTMenuEntry, TMenu목록을 담는 컨테이너Generics.Collections
cafestock.pasTStock코드별 수량 사전Generics.Collections
cafeorders.pasTOrderLine, ParseLine, TOrderProcessor판매 집계 사전, 거절 기록 목록CafeMenu, CafeStock
main.pas없음(프로그램)출력 형식위 세 유닛

화살표가 한 방향으로만 가는 것이 중요하다. 메뉴 유닛이 주문 유닛을 쓰기 시작하면 서로를 부르는 순환이 생기고, Pascal 컴파일러는 이를 오류로 막는다. 의존이 한 방향이면 메뉴만 따로 시험해 볼 수도 있다.

객체의 주인을 정한다

클래스로 만든 객체는 만든 쪽이 해제해야 한다. 이 프로그램에서는 규칙을 하나로 정했다. 만든 곳에서 해제한다. 메뉴 객체와 재고 객체는 main 이 만들고 main 이 해제한다. 주문 처리기는 이 둘을 생성자로 받아서 빌려 쓸 뿐이므로 소멸자에서 해제하지 않는다. 반대로 처리기가 스스로 만든 판매 집계 사전과 거절 기록 목록은 처리기의 소멸자에서 해제한다. 메뉴 항목 객체는 TObjectList 가 소유하도록 만들었기 때문에, 목록을 해제하면 항목도 함께 해제된다.

주문 기록 파일 읽기

파일 형식

한 줄은 주문번호,메뉴코드,수량 세 칸이다. 프로그램은 실행할 때 예제 주문 12줄을 orders.txt 로 먼저 쓴 다음, 같은 파일을 읽어서 처리한다. 표준 입력을 읽지 않아도 실행되고, 파일 읽기 코드도 실제 파일로 확인해 볼 수 있게 하려는 구성이다. 파일은 프로그램을 실행한 폴더에 만들어진다.

한 줄을 칸으로 쪼개는 일은 TStringList 에 맡긴다. Delimiter 를 쉼표로 하고 StrictDelimiter 를 True 로 해야 공백이 구분자로 취급되지 않는다. 숫자 변환은 StrToIntDef 로 한다. 변환에 실패하면 예외 대신 기본값 0 을 돌려주고, 번호와 수량은 0 이하이면 잘못된 값으로 본다.

걸러내는 세 단계

주문 줄은 쪼개기와 검사를 거쳐 정산에 반영되고, 형식 오류·없는 코드·재고 부족 줄은 거절 목록으로 간다.
처리하지 못하는 줄의 종류와 판단 위치
사유판단하는 곳재고 변화기록 예
형식 오류ParseLine없음8번째 줄: 형식 오류
없는 메뉴 코드Apply 의 첫 검사없음주문 1004: 없는 메뉴 코드 X99
재고 부족Apply 의 둘째 검사없음(줄 전체 거절)주문 1005: C02 재고 부족

재고가 모자랄 때는 일부만 파는 대신 그 줄을 통째로 거절한다. 가게 규칙에 따라 달라질 수 있는 부분이므로 TStock.Take 한 곳에만 두었다. 규칙이 바뀌면 그 함수만 고치면 된다.

메모리 누수 점검

이 프로그램은 객체를 여러 개 만든다. 하나라도 해제하지 않으면 실행 중에는 아무 증상이 없다가 오래 켜 두는 프로그램에서 문제가 된다. Free Pascal 에는 이를 점검하는 도구가 컴파일러에 들어 있다. 컴파일할 때 -gh 를 주면 heaptrc 라는 유닛이 프로그램에 붙는다. 이 유닛은 힙(heap, 객체가 만들어지는 메모리 영역)에서 빌려 쓴 블록을 모두 기록해 두었다가, 프로그램이 끝날 때 아직 돌려주지 않은 블록의 수를 보고한다.

보고서 끝부분에 0 unfreed memory blocks : 0 이 나오면 해제하지 않은 블록이 없다는 뜻이다. 0 이 아니면 어딘가에서 Free 가 빠진 것이다. -gl 도 함께 주면 어느 줄에서 만든 블록인지 호출 위치가 같이 출력된다. 이 점검은 개발할 때만 사용한다. 기록 때문에 프로그램이 느려지고 메모리를 더 쓰기 때문이다.

Delphi 로 옮길 때의 차이

이 책의 문법은 대부분 Delphi 와 같다. 그래도 그대로는 컴파일되지 않는 곳이 몇 군데 있다.

Free Pascal 코드를 Delphi 로 옮길 때 고칠 곳
항목Free Pascal(이 책)Delphi할 일
제네릭 사용specialize TDictionary<string, Integer>TDictionary<string, Integer>specialize 삭제
모드 지시어{$mode objfpc}{$H+}필요 없음삭제하고 {$APPTYPE CONSOLE} 추가
표준 유닛 이름SysUtils, Generics.CollectionsSystem.SysUtils, System.Generics.Collectionsuses 절 수정
문자열 길이UTF-8 바이트 수UTF-16 문자 수한글 정렬 계산 확인
파일 인코딩바이트를 그대로 읽음BOM 이 없으면 ANSI 로 읽을 수 있음LoadFromFile 에 TEncoding.UTF8 지정
누수 점검컴파일 옵션 -ghReportMemoryLeaksOnShutdown := True프로그램 첫머리에 한 줄 추가

Windows 콘솔에서 한글이 깨져 보이면 Windows 유닛의 SetConsoleOutputCP(CP_UTF8) 을 호출한다. 유닛 파일 이름과 unit 이름을 맞추는 규칙, constructor/destructor/override 문법, try finally 구조는 그대로 쓸 수 있다.

완성 코드

네 파일을 같은 폴더에 두고 fpc main.pas 로 컴파일한다. 컴파일러가 uses 절을 따라가 유닛 파일을 찾아 함께 컴파일한다.

cafemenu.pas

unit CafeMenu;

{$mode objfpc}{$H+}

interface

uses
  Generics.Collections;

type
  TMenuEntry = class
  private
    FCode: string;
    FName: string;
    FPrice: Integer;
  public
    constructor Create(const ACode, AName: string; APrice: Integer);
    property Code: string read FCode;
    property Name: string read FName;
    property Price: Integer read FPrice;
  end;

  TMenu = class
  private
    FEntries: specialize TObjectList<TMenuEntry>;
    function GetCount: Integer;
    function GetEntry(Index: Integer): TMenuEntry;
  public
    constructor Create;
    destructor Destroy; override;
    procedure Add(const ACode, AName: string; APrice: Integer);
    function Find(const ACode: string): TMenuEntry;
    property Count: Integer read GetCount;
    property Entries[Index: Integer]: TMenuEntry read GetEntry; default;
  end;

implementation

constructor TMenuEntry.Create(const ACode, AName: string; APrice: Integer);
begin
  inherited Create;
  FCode := ACode;
  FName := AName;
  FPrice := APrice;
end;

constructor TMenu.Create;
begin
  inherited Create;
  FEntries := specialize TObjectList<TMenuEntry>.Create(True);
end;

destructor TMenu.Destroy;
begin
  FEntries.Free;
  inherited Destroy;
end;

function TMenu.GetCount: Integer;
begin
  Result := FEntries.Count;
end;

function TMenu.GetEntry(Index: Integer): TMenuEntry;
begin
  Result := FEntries[Index];
end;

procedure TMenu.Add(const ACode, AName: string; APrice: Integer);
begin
  FEntries.Add(TMenuEntry.Create(ACode, AName, APrice));
end;

function TMenu.Find(const ACode: string): TMenuEntry;
var
  I: Integer;
begin
  Result := nil;
  for I := 0 to FEntries.Count - 1 do
    if FEntries[I].Code = ACode then
    begin
      Result := FEntries[I];
      Exit;
    end;
end;

end.

cafestock.pas

unit CafeStock;

{$mode objfpc}{$H+}

interface

uses
  Generics.Collections;

type
  TStock = class
  private
    FQty: specialize TDictionary<string, Integer>;
  public
    constructor Create;
    destructor Destroy; override;
    procedure SetQty(const Code: string; AQty: Integer);
    function Remaining(const Code: string): Integer;
    function Take(const Code: string; N: Integer): Boolean;
  end;

implementation

constructor TStock.Create;
begin
  inherited Create;
  FQty := specialize TDictionary<string, Integer>.Create;
end;

destructor TStock.Destroy;
begin
  FQty.Free;
  inherited Destroy;
end;

procedure TStock.SetQty(const Code: string; AQty: Integer);
begin
  FQty.AddOrSetValue(Code, AQty);
end;

function TStock.Remaining(const Code: string): Integer;
begin
  if not FQty.TryGetValue(Code, Result) then
    Result := 0;
end;

function TStock.Take(const Code: string; N: Integer): Boolean;
var
  Cur: Integer;
begin
  Cur := 0;
  Result := FQty.TryGetValue(Code, Cur) and (Cur >= N);
  if Result then
    FQty[Code] := Cur - N;
end;

end.

cafeorders.pas

unit CafeOrders;

{$mode objfpc}{$H+}

interface

uses
  Classes, Generics.Collections, CafeMenu, CafeStock;

type
  TOrderLine = record
    OrderNo: Integer;
    Code: string;
    Qty: Integer;
  end;

  TOrderProcessor = class
  private
    FMenu: TMenu;
    FStock: TStock;
    FSold: specialize TDictionary<string, Integer>;
    FLog: TStringList;
    FAccepted: Integer;
    FRejected: Integer;
  public
    constructor Create(AMenu: TMenu; AStock: TStock);
    destructor Destroy; override;
    procedure Apply(const Line: TOrderLine);
    procedure LoadFromFile(const FileName: string);
    function SoldQty(const Code: string): Integer;
    property Accepted: Integer read FAccepted;
    property Rejected: Integer read FRejected;
    property Log: TStringList read FLog;
  end;

function ParseLine(const S: string; out Line: TOrderLine): Boolean;

implementation

uses
  SysUtils;

function ParseLine(const S: string; out Line: TOrderLine): Boolean;
var
  Parts: TStringList;
begin
  Line.OrderNo := 0;
  Line.Code := '';
  Line.Qty := 0;
  Parts := TStringList.Create;
  try
    Parts.StrictDelimiter := True;
    Parts.Delimiter := ',';
    Parts.DelimitedText := S;
    Result := Parts.Count = 3;
    if Result then
    begin
      Line.OrderNo := StrToIntDef(Trim(Parts[0]), 0);
      Line.Code := UpperCase(Trim(Parts[1]));
      Line.Qty := StrToIntDef(Trim(Parts[2]), 0);
      Result := (Line.OrderNo > 0) and (Line.Qty > 0) and (Line.Code <> '');
    end;
  finally
    Parts.Free;
  end;
end;

constructor TOrderProcessor.Create(AMenu: TMenu; AStock: TStock);
begin
  inherited Create;
  FMenu := AMenu;
  FStock := AStock;
  FSold := specialize TDictionary<string, Integer>.Create;
  FLog := TStringList.Create;
end;

destructor TOrderProcessor.Destroy;
begin
  FLog.Free;
  FSold.Free;
  inherited Destroy;
end;

procedure TOrderProcessor.Apply(const Line: TOrderLine);
var
  Prev: Integer;
begin
  if FMenu.Find(Line.Code) = nil then
  begin
    FLog.Add(Format('주문 %d: 없는 메뉴 코드 %s', [Line.OrderNo, Line.Code]));
    Inc(FRejected);
    Exit;
  end;
  if not FStock.Take(Line.Code, Line.Qty) then
  begin
    FLog.Add(Format('주문 %d: %s 재고 부족 (요청 %d, 남음 %d)',
      [Line.OrderNo, Line.Code, Line.Qty, FStock.Remaining(Line.Code)]));
    Inc(FRejected);
    Exit;
  end;
  Prev := 0;
  FSold.TryGetValue(Line.Code, Prev);
  FSold.AddOrSetValue(Line.Code, Prev + Line.Qty);
  Inc(FAccepted);
end;

procedure TOrderProcessor.LoadFromFile(const FileName: string);
var
  Lines: TStringList;
  I: Integer;
  Line: TOrderLine;
begin
  Lines := TStringList.Create;
  try
    Lines.LoadFromFile(FileName);
    for I := 0 to Lines.Count - 1 do
    begin
      if Trim(Lines[I]) = '' then
        Continue;
      if ParseLine(Lines[I], Line) then
        Apply(Line)
      else
      begin
        FLog.Add(Format('%d번째 줄: 형식 오류 "%s"', [I + 1, Lines[I]]));
        Inc(FRejected);
      end;
    end;
  finally
    Lines.Free;
  end;
end;

function TOrderProcessor.SoldQty(const Code: string): Integer;
begin
  if not FSold.TryGetValue(Code, Result) then
    Result := 0;
end;

end.

main.pas

program Main;

{$mode objfpc}{$H+}

uses
  SysUtils, Classes, CafeMenu, CafeStock, CafeOrders;

const
  OrderFile = 'orders.txt';
  LowLimit = 2;
  Separator = '------------------------------------';

procedure WriteSampleOrders;
var
  Lines: TStringList;
begin
  Lines := TStringList.Create;
  try
    Lines.Add('1001,A01,2');
    Lines.Add('1001,C01,1');
    Lines.Add('1002,A02,3');
    Lines.Add('1003,A01,3');
    Lines.Add('1003,C02,2');
    Lines.Add('1004,B01,2');
    Lines.Add('1004,X99,1');
    Lines.Add('이건 잘못된 줄');
    Lines.Add('1005,c02,2');
    Lines.Add('1006,A02,1');
    Lines.Add('1007,A01,2');
    Lines.Add('1007,B01,1');
    Lines.SaveToFile(OrderFile);
  finally
    Lines.Free;
  end;
end;

procedure BuildMenu(Menu: TMenu);
begin
  Menu.Add('A01', '아메리카노', 3000);
  Menu.Add('A02', '카페라떼', 3500);
  Menu.Add('B01', '녹차', 3200);
  Menu.Add('C01', '크루아상', 2800);
  Menu.Add('C02', '치즈케이크', 4500);
end;

procedure BuildStock(Stock: TStock);
begin
  Stock.SetQty('A01', 8);
  Stock.SetQty('A02', 6);
  Stock.SetQty('B01', 4);
  Stock.SetQty('C01', 5);
  Stock.SetQty('C02', 3);
end;

procedure PrintSales(Menu: TMenu; Orders: TOrderProcessor);
var
  I, Qty, Sales, TotalQty, TotalSales: Integer;
  Share: Double;
  Item: TMenuEntry;
begin
  TotalQty := 0;
  TotalSales := 0;
  for I := 0 to Menu.Count - 1 do
  begin
    Item := Menu[I];
    Qty := Orders.SoldQty(Item.Code);
    Inc(TotalQty, Qty);
    Inc(TotalSales, Qty * Item.Price);
  end;

  WriteLn('== 일일 정산표 ==');
  WriteLn('CODE  QTY    SALES SHARE   MENU');
  WriteLn(Separator);
  for I := 0 to Menu.Count - 1 do
  begin
    Item := Menu[I];
    Qty := Orders.SoldQty(Item.Code);
    Sales := Qty * Item.Price;
    if TotalSales > 0 then
      Share := Sales * 100.0 / TotalSales
    else
      Share := 0;
    WriteLn(Format('%-4s %4d %8d %5.1f%%  %s',
      [Item.Code, Qty, Sales, Share, Item.Name]));
  end;
  WriteLn(Separator);
  WriteLn(Format('%-4s %4d %8d', ['SUM', TotalQty, TotalSales]));
  WriteLn(Format('처리한 주문 줄: 수락 %d, 거절 %d',
    [Orders.Accepted, Orders.Rejected]));
end;

procedure PrintLowStock(Menu: TMenu; Stock: TStock);
var
  I, Left, Found: Integer;
  Item: TMenuEntry;
begin
  WriteLn(Format('== 재고 부족 경고 (남은 수량 %d 이하) ==', [LowLimit]));
  Found := 0;
  for I := 0 to Menu.Count - 1 do
  begin
    Item := Menu[I];
    Left := Stock.Remaining(Item.Code);
    if Left <= LowLimit then
    begin
      WriteLn(Format('%s %s: %d개 남음', [Item.Code, Item.Name, Left]));
      Inc(Found);
    end;
  end;
  if Found = 0 then
    WriteLn('없음');
end;

procedure PrintRejected(Orders: TOrderProcessor);
var
  I: Integer;
begin
  WriteLn('== 처리하지 못한 주문 ==');
  for I := 0 to Orders.Log.Count - 1 do
    WriteLn(Orders.Log[I]);
  if Orders.Log.Count = 0 then
    WriteLn('없음');
end;

procedure RunSettlement;
var
  Menu: TMenu;
  Stock: TStock;
  Orders: TOrderProcessor;
begin
  Menu := nil;
  Stock := nil;
  Orders := nil;
  try
    Menu := TMenu.Create;
    Stock := TStock.Create;
    BuildMenu(Menu);
    BuildStock(Stock);
    Orders := TOrderProcessor.Create(Menu, Stock);
    Orders.LoadFromFile(OrderFile);

    PrintSales(Menu, Orders);
    WriteLn;
    PrintLowStock(Menu, Stock);
    WriteLn;
    PrintRejected(Orders);
  finally
    Orders.Free;
    Stock.Free;
    Menu.Free;
  end;
end;

begin
  WriteSampleOrders;
  RunSettlement;
end.

줄별 해설

cafemenu.pas

  • interface 아래에는 다른 유닛이 알아야 하는 선언만 둔다. TMenuEntry 의 필드(FCode 등)는 private 이라 같은 유닛 밖에서는 읽거나 쓸 수 없고, 속성(property)으로만 읽을 수 있다. 값을 바꾸는 길이 막혀 있어 항목이 만들어진 뒤에는 가격이 바뀌지 않는다.
  • specialize TObjectList<TMenuEntry> 는 "TMenuEntry 를 담는 목록"을 만든다. 생성자의 True 는 목록이 항목을 소유한다는 뜻이다. 그래서 TMenu.Destroy 에서 FEntries.Free 한 줄이면 항목 객체들도 함께 해제된다.
  • default 가 붙은 Entries 속성 덕분에 main 에서 Menu[I] 로 항목을 꺼낼 수 있다.
  • Find 는 먼저 Result := nil 로 시작하고, 찾으면 대입한 뒤 Exit 한다. 없는 코드는 nil 로 알려 주고, 호출하는 쪽이 nil 검사를 한다.

cafestock.pas

  • 재고는 코드를 열쇠로 하는 사전(TDictionary)에 둔다. 코드로 바로 찾을 수 있어서 메뉴가 늘어나도 검색 코드를 고칠 필요가 없다.
  • Remaining 은 TryGetValue 가 실패하면 0 을 돌려준다. 없는 코드의 재고는 0 이라는 규칙이다.
  • Take 의 and 는 왼쪽이 거짓이면 오른쪽을 계산하지 않는다. 코드가 없거나 수량이 모자라면 Result 가 거짓이 되어 재고가 줄지 않고, 충분할 때만 FQty[Code] 에서 빼 준다.

cafeorders.pas

  • uses 절이 두 군데다. interface 쪽에는 선언에 나오는 TStringList, TDictionary, TMenu, TStock 의 유닛을 둔다. implementation 쪽의 SysUtils 는 구현에서만 쓰므로 바깥에 알릴 필요가 없다.
  • ParseLine 은 맨 처음에 out 매개변수 Line 의 모든 칸을 채운다. 실패하더라도 쓰레기 값이 남지 않는다.
  • 코드는 UpperCase(Trim(...)) 로 정리한다. 예제의 c02 가 C02 로 처리된다.
  • Apply 는 검사를 세 번에 나눠 한다. 메뉴 확인, 재고 차감, 판매 수량 누적 순서다. 재고 차감이 성공한 다음에만 누적하므로 거절된 줄은 매출에 들어가지 않는다.
  • Prev := 0 다음에 TryGetValue 를 부르면, 처음 팔린 메뉴는 0 에서 시작하고 이미 팔린 메뉴는 기존 수량에서 시작한다.
  • LoadFromFile 은 읽은 줄 수만큼 반복한다. Lines 는 try finally 로 감싸 읽는 도중 문제가 생겨도 해제된다. 형식 오류 메시지에는 줄 번호 I + 1 을 쓴다. 목록 번호는 0 부터지만 사람이 세는 줄 번호는 1 부터이기 때문이다.
  • 소멸자에는 처리기가 만든 FLog 와 FSold 만 있다. FMenu 와 FStock 은 빌린 것이므로 해제하지 않는다.

main.pas

  • 상수 LowLimit 은 경고 기준을 한곳에 모은 것이다. 머리말 출력에도 이 값을 그대로 가져다 써서 기준을 고쳐도 문구가 어긋나지 않는다.
  • PrintSales 는 메뉴를 두 번 훑는다. 첫 번째는 합계를 구하는 데, 두 번째는 각 메뉴의 비중을 합계로 나누어 출력하는 데 쓴다. 합계가 있어야 비중을 계산할 수 있기 때문이다.
  • 서식 %5.1f%% 는 소수점 한 자리로 고정하고, %% 는 글자 % 하나를 뜻한다. %-4s 는 왼쪽 정렬로 4칸이다. 한글 이름을 마지막 칸에 둔 것은 아래 "자주 틀리는 것"에서 설명한다.
  • 순회는 항상 메뉴 목록 순서로 한다. 사전은 순서를 보장하지 않으므로 사전을 직접 훑으면 실행할 때마다 출력 순서가 달라질 수 있다.
  • RunSettlement 는 지역 변수 세 개를 먼저 nil 로 만든다. 지역 변수는 초기값이 없어서, 생성이 중간에 실패한 채 finally 로 넘어가면 쓰레기 값을 해제하려 들기 때문이다. nil 인 객체에 Free 를 부르는 것은 안전하다. 해제는 만든 순서의 반대로 한다.

실행 결과

$ fpc main.pas
$ ./main
== 일일 정산표 ==
CODE  QTY    SALES SHARE   MENU
------------------------------------
A01     7    21000  37.2%  아메리카노
A02     4    14000  24.8%  카페라떼
B01     3     9600  17.0%  녹차
C01     1     2800   5.0%  크루아상
C02     2     9000  16.0%  치즈케이크
------------------------------------
SUM    17    56400
처리한 주문 줄: 수락 9, 거절 3

== 재고 부족 경고 (남은 수량 2 이하) ==
A01 아메리카노: 1개 남음
A02 카페라떼: 2개 남음
B01 녹차: 1개 남음
C02 치즈케이크: 1개 남음

== 처리하지 못한 주문 ==
주문 1004: 없는 메뉴 코드 X99
8번째 줄: 형식 오류 "이건 잘못된 줄"
주문 1005: C02 재고 부족 (요청 2, 남음 1)

손으로 확인해 보면 아메리카노는 1001에서 2잔, 1003에서 3잔, 1007에서 2잔으로 7잔이고 7 × 3000 = 21000 이다. 치즈케이크는 3개 중 1003 에서 2개가 팔렸고, 1005 가 2개를 더 요청했지만 남은 것이 1개뿐이라 그 줄 전체가 거절되었다.

이어서 누수를 점검한다.

$ fpc -gh -gl main.pas
$ ./main
(정산표는 위와 같다)
Heap dump by heaptrc unit of main
N memory blocks allocated : A/B
N memory blocks freed     : A/B
0 unfreed memory blocks : 0
True heap size : ...
True free heap : ...

마지막 블록은 형태를 보인 예시다. N, A, B 와 힙 크기는 환경마다 다르다. 보고서는 표준 오류로 나올 수 있으니 터미널에서 직접 실행해서 본다. 확인할 것은 0 unfreed memory blocks : 0 한 줄이다.

실무에서 자주 틀리는 것

빌려 쓴 객체까지 해제한다

처리기가 메뉴를 받아서 쓰고, 소멸자에서 "내 것처럼" 해제하는 경우다.

destructor TOrderProcessor.Destroy;
begin
  FMenu.Free;      // main 도 Menu.Free 를 부른다
  FLog.Free;
  FSold.Free;
  inherited Destroy;
end;

main 의 finally 에서 Menu.Free 가 다시 실행되면 이미 해제된 객체를 또 해제하게 되어 실행 중 오류가 난다. -gh 를 켜 두면 같은 블록을 두 번 해제했다는 보고가 나온다. 빌린 객체는 해제하지 않고, 만든 곳에서 한 번만 해제한다.

destructor TOrderProcessor.Destroy;
begin
  FLog.Free;
  FSold.Free;
  inherited Destroy;
end;

객체를 연달아 만들고 try 하나로 감싼다

procedure Run;
var
  A, B: TStock;
begin
  A := TStock.Create;
  B := TStock.Create;      // 여기서 예외가 나면 A 는 해제되지 않는다
  try
    // 사용
  finally
    B.Free;
    A.Free;
  end;
end;

try 앞의 만들기에서 문제가 생기면 finally 에 도달하지 못한다. 객체 하나를 만들 때마다 바로 아래에 try finally 를 둔다. 이 장의 RunSettlement 처럼 변수를 먼저 nil 로 만들고 한 구간에서 묶어 처리하는 방법도 있다.

procedure Run;
var
  A, B: TStock;
begin
  A := TStock.Create;
  try
    B := TStock.Create;
    try
      // 사용
    finally
      B.Free;
    end;
  finally
    A.Free;
  end;
end;

한글 이름을 폭 지정으로 맞추려 한다

WriteLn(Format('%-10s %4d', [Item.Name, Qty]));   // 이름 칸이 들쭉날쭉하다

이 환경의 string 은 UTF-8 바이트로 저장되어서 한글 한 글자가 3바이트다. %-10s 는 글자 수가 아니라 바이트 수로 채운다. 같은 폭을 주어도 화면에서는 줄마다 칸이 달라진다. 숫자와 코드처럼 ASCII 로 된 열을 앞에 두고, 한글 이름은 맨 오른쪽 열에 폭 없이 출력하면 줄이 맞는다.

WriteLn(Format('%-4s %4d  %s', [Item.Code, Qty, Item.Name]));

Free 를 빠뜨리고 점검하지 않는다

procedure TOrderProcessor.LoadFromFile(const FileName: string);
var
  Lines: TStringList;
begin
  Lines := TStringList.Create;
  Lines.LoadFromFile(FileName);
  // ... 처리 ...
end;                       // Lines.Free 가 없다

이 코드는 컴파일 경고도 없고 출력도 정상이다. 그러나 fpc -gh 로 실행하면 끝에 0 이 아닌 unfreed memory blocks 가 나온다. 기능을 하나 추가할 때마다 점검 한 번을 습관으로 하면 원인이 되는 코드가 그 변경 안에 있다는 것을 알 수 있다. 고친 코드는 완성 코드의 LoadFromFile 처럼 만든 직후 try finally 로 감싼다.

Lines := TStringList.Create;
try
  Lines.LoadFromFile(FileName);
  // ... 처리 ...
finally
  Lines.Free;
end;

한눈에 보기

이 장의 설계 결정과 근거
주제선택이유확인 방법
유닛 나누기메뉴, 재고, 주문, main각 유닛이 한 가지만 알게 한다순환 uses 가 없다
객체 소유만든 곳에서 해제이중 해제와 누수를 막는다fpc -gh 의 unfreed 수
주문 줄 검증ParseLine 과 Apply 에서 거른다나쁜 줄 하나가 전체를 멈추지 않는다처리하지 못한 주문 목록
출력 순서메뉴 목록 순서로 순회사전 순서에 기대지 않아 결과가 같다같은 입력의 출력 비교
소수 표기%5.1f자릿수를 고정한다비중 열
Delphi 이식specialize 삭제, 유닛 이름 수정컴파일러 방언 차이컴파일 오류 메시지

연습 문제

  1. 메뉴에 C03 마들렌 2500 을 추가하고 재고 4개를 준다. 주문 기록 끝에 1008,C03,3 을 더하면 SUM 행의 수량과 매출, 그리고 C03 의 남은 수량은 얼마인가? 재고 부족 경고에는 C03 가 나타나는가?
  2. LowLimit 을 1 로 바꾸면 재고 부족 경고에 어떤 메뉴가 나오는가? (원래 예제 기준이다.)
  3. TOrderProcessor.Destroy 에서 FLog.Free 줄을 지우고 fpc -gh 로 실행하면 보고서가 어떻게 달라지는가? 원인과 고치는 방법을 말하라.
  4. 다음 날 아침에 재료를 들여오는 Restock(Code, N) 을 추가하려 한다. 어느 파일의 어느 부분을 고쳐야 하고, 왜 main 은 고치지 않아도 되는가?

정답과 해설

  1. C03 는 3개가 팔려 7500 원이 늘어난다. 수량은 17 + 3 = 20, 매출은 56400 + 7500 = 63900 이므로 SUM 행은 SUM 20 63900 이다. 남은 수량은 4 − 3 = 1 이고 기준 2 이하이므로 경고에 C03 마들렌: 1개 남음 이 나타난다. 메뉴 순서에서 C03 는 C02 뒤에 있어 경고의 맨 끝에 나온다. 코드는 BuildMenu 와 BuildStock 에 한 줄씩, 주문 기록에 한 줄 더하면 되고 다른 유닛은 고치지 않는다.
  2. 남은 수량이 1 이하인 메뉴만 나온다. A01(1), B01(1), C02(1) 이 나오고 남은 수량이 2 인 A02 는 빠진다. C01 은 4 라서 원래도 나오지 않는다. 상수 한 줄만 바꿨는데 머리말의 "2 이하"도 "1 이하"로 함께 바뀐다.
  3. 보고서에 0 이 아닌 unfreed memory blocks 가 나온다. TStringList 객체와 그 안의 문자열 기록이 해제되지 않은 것이다. 출력은 정상이므로 화면만 봐서는 알 수 없다. -gl 도 함께 주면 어디서 만든 블록인지 호출 위치가 나온다. 소멸자에 FLog.Free 를 되돌려 놓으면 0 unfreed memory blocks : 0 으로 돌아온다.
  4. 재고를 직접 다루는 곳은 cafestock.pas 이다. interface 의 TStock 선언에 procedure Restock(const Code: string; N: Integer); 를 추가하고, implementation 에 구현을 적는다.
    procedure TStock.Restock(const Code: string; N: Integer);
    begin
      FQty.AddOrSetValue(Code, Remaining(Code) + N);
    end;
    main 은 uses CafeStock 만 하고 있으므로 새 메서드가 공개되는 순간부터 쓸 수 있다. 호출하는 코드를 추가하지 않으면 기존 동작은 달라지지 않는다. 사전을 어떻게 쓰는지는 유닛 안에 숨겨져 있어서 이런 변경이 다른 파일로 퍼지지 않는다.

댓글 0

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

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