Devin.KR

클래스와 객체 - 데이터와 동작 묶기

개발자KR 조회 3

이 장에서 배우는 것

앞 장에서 메서드로 이름 붙은 코드 조각을 만들었다. 주문 수량을 계산하거나 안내 문구를 출력하는 일을 메서드로 묶으면 같은 코드를 반복해서 쓰지 않아도 된다. 이번에는 그 메서드가 다루는 데이터까지 함께 묶는다. 카페 메뉴의 이름, 가격, 재고를 하나의 대상으로 표현하고, 그 대상이 허용하는 방법으로만 상태를 바꾸게 만든다.

클래스(class)는 데이터와 동작을 묶어서 새로운 타입을 정의하는 수단이다. 객체(object)는 그 타입으로 만들어 실제 값을 보관하는 개별 대상이다. 아메리카노와 유자차는 같은 메뉴 타입을 사용하면서도 서로 다른 가격과 재고를 가질 수 있다. 이 차이를 코드로 표현하는 것이 이번 장의 출발점이다.

  • 클래스를 선언하고 생성자로 개별 객체의 시작 상태를 정한다.
  • 필드와 속성의 역할을 구분하고 필요한 정보만 외부에 공개한다.
  • 재고 변경 규칙을 메서드 안에 모아 캡슐화를 구현한다.
  • 객체마다 따로 갖는 멤버와 타입 전체가 공유하는 정적 멤버를 구분한다.
  • 두 변수가 같은 객체를 가리킬 때 상태 변경이 어떻게 보이는지 설명한다.

문제 상황

동네 카페의 콘솔 앱이 메뉴 두 개를 관리한다고 하자. 처음에는 이름, 가격, 재고를 각각의 변수로 두어도 큰 불편이 없다. 그러나 메뉴가 늘고 판매 처리와 입고 처리가 여러 곳에서 일어나면 어떤 값끼리 한 메뉴에 속하는지 확인하는 일이 잦아진다.

string coffeeName = "아메리카노";
int coffeePrice = 4500;
int coffeeStock = 5;

string teaName = "유자차";
int teaPrice = 5000;
int teaStock = 2;

이 코드는 값의 관계를 변수 이름으로 표현한다. 컴파일러는 coffeePrice와 teaStock이 서로 다른 메뉴에 속한다는 업무상의 관계를 모른다. 판매 메서드에 가격과 재고를 따로 전달하면 실수로 다른 메뉴의 값을 섞어 전달할 수도 있다. 변수 이름을 정성껏 붙이는 일만으로는 이런 문제를 충분히 줄이기 어렵다.

재고 변경 규칙도 흩어지기 쉽다. 판매할 때는 재고보다 많은 수량을 차감하면 안 되고, 입고할 때는 음수 수량을 더하면 안 된다. 각 호출 지점에서 이 조건을 따로 검사하면 어느 한 곳에서 검사를 빠뜨릴 수 있다. 재고를 공개된 변수로 두면 검사 메서드가 있어도 다른 코드가 그 변수를 직접 바꿀 수 있다.

이번 예제에서는 메뉴 하나를 CafeItem 객체 하나로 표현한다. 이름은 생성할 때 정하고, 가격과 재고는 정해진 메서드로 바꾼다. 예제를 작게 유지하기 위해 한 메뉴의 보관 한도는 1,000개로 정한다. 가격이 아직 설정되지 않은 메뉴는 가격 0원으로 표시하되 판매할 수 없게 한다. 변경 요청을 거절할 때는 false를 반환하고 기존 상태를 유지한다.

이 규칙은 카페 업무 전체를 모델링한 것이 아니다. 객체가 자신의 상태를 책임지도록 설계하는 연습에 필요한 범위다. 주문 내역이나 영수증을 추가하지 않고, 메뉴의 가격 설정과 입고, 판매에 집중한다.

클래스와 객체, 필드와 속성

타입 하나로 여러 객체를 만든다

CafeItem이라는 클래스를 선언하면 int나 string처럼 변수의 타입으로 사용할 수 있다. 다만 클래스를 선언하는 것만으로 아메리카노의 재고가 생기지는 않는다. new를 사용해 객체를 만들어야 개별 메뉴의 상태가 마련된다. 이렇게 만들어진 개별 객체를 인스턴스(instance)라고도 부른다.

CafeItem coffee = new CafeItem("아메리카노");
CafeItem tea = new CafeItem("유자차");

두 줄은 서로 다른 객체를 만든다. coffee의 재고를 바꾸어도 tea의 재고는 바뀌지 않는다. 같은 클래스에 정의된 메서드를 사용하지만, 각 메서드는 호출 대상 객체의 데이터를 다룬다. coffee.TrySell(2)와 tea.TrySell(2)는 같은 판매 규칙을 서로 다른 메뉴에 적용하는 호출이다.

반면 기존 변수를 다른 변수에 대입하면 객체가 하나 더 생기는 것은 아니다. 클래스는 참조 형식이므로 아래 대입은 같은 객체를 가리키는 참조를 복사한다. counterItem을 통해 재고를 바꾸면 coffee로 읽는 재고에서도 변경이 보인다.

CafeItem counterItem = coffee;
coffee와 counterItem은 같은 객체를 가리키고 tea는 별도 객체를 가리킨다

그림의 화살표는 변수와 객체의 관계를 나타낸다. 실제 메모리 주소나 물리적 배치를 설명하는 그림은 아니다. 여기서 확인할 점은 변수의 수와 객체의 수가 같지 않을 수 있다는 사실이다. 같은 객체를 가리키는 변수가 둘이어도 재고는 그 객체 안에 하나만 있다.

저장 공간과 공개 방법을 구분한다

필드(field)는 객체나 타입 안에 선언하는 변수다. 이번 예제의 stock 필드는 실제 재고 수량을 저장한다. 속성(property)은 값을 읽거나 쓰는 방법을 멤버 형태로 제공한다. 호출하는 쪽에서는 변수처럼 보이지만, 속성에는 읽기와 쓰기의 허용 범위를 정할 수 있다.

private int stock;

public string Name { get; }
public int UnitPrice { get; private set; }
public int Stock => stock;

private는 해당 클래스 내부에서만 접근하도록 제한한다. 따라서 클래스 밖에서 stock을 직접 읽거나 바꿀 수 없다. public은 외부 코드에서 접근할 수 있게 한다. 공개된 Stock 속성은 내부의 stock 값을 읽어 반환하며, 값을 대입하는 기능은 제공하지 않는다.

Name과 UnitPrice처럼 접근자만 간단히 선언한 형태를 자동 구현 속성이라고 한다. 값을 보관할 필드는 컴파일러가 마련한다. Name은 get만 있으므로 일반적인 외부 대입이 불가능하며, 이 예제에서는 생성자에서 값을 정한다. UnitPrice는 외부에서 읽을 수 있지만 private set 때문에 클래스 내부에서만 값을 대입할 수 있다.

Stock의 화살표 표기는 값을 반환하는 식 하나로 읽기 동작을 정의한다. 여기서는 stock을 그대로 반환한다. Stock과 stock이라는 재고 저장 공간이 각각 생기는 것은 아니다. stock이 저장 공간이고 Stock은 그 값을 공개하는 읽기 통로다. 대소문자가 다르므로 C#에서는 서로 다른 이름이다.

메뉴의 데이터를 저장하고 공개하는 방법
선언역할외부에서 가능한 일
private int stock실제 재고 수량 저장직접 접근할 수 없다
Name { get; }생성할 때 정한 이름 공개이름 읽기
UnitPrice { get; private set; }클래스 내부에서 변경하는 가격 공개가격 읽기
Stock => stock현재 재고를 읽는 통로재고 읽기

모든 데이터에 무조건 속성을 붙이는 것이 설계의 목적은 아니다. 외부 코드가 알아야 하는 정보와 바꿀 수 있어야 하는 정보를 구분하는 것이 목적이다. 재고는 조회할 수 있어야 하지만 아무 정수로나 바꿀 수 있어서는 안 된다. 이 차이를 선언에 드러내면 호출하는 코드에서도 의도를 읽기 쉬워진다.

생성자와 캡슐화로 상태를 지킨다

생성자는 시작 상태를 정한다

생성자(constructor)는 객체를 만들 때 실행되어 시작 상태를 정하는 특별한 멤버다. 이름은 클래스 이름과 같으며 반환 형식을 적지 않는다. void도 붙이지 않는다. new CafeItem("아메리카노")는 문자열을 받는 생성자를 호출하고, 생성자가 실행된 뒤 사용할 수 있는 객체의 참조를 얻는다.

완성 코드의 생성자는 이름을 정하고 가격과 재고를 0으로 시작한다. 공백만 있는 이름을 전달하면 “이름 없는 메뉴”로 바꾼다. 잘못된 이름을 거절하는 설계도 가능하지만, 이 예제는 대체 이름을 사용하는 정책을 선택한다. 이처럼 입력을 보정한다면 보정 결과를 호출하는 쪽에서도 예상할 수 있도록 규칙을 명확히 적어 두어야 한다.

정수 필드는 기본적으로 0으로 초기화된다. 그럼에도 생성자에서 stock = 0을 적은 이유는 메뉴가 재고 없이 등록된다는 업무 규칙을 눈에 보이게 하기 위해서다. Name도 생성자에서 반드시 정하므로 문자열 속성이 초기화되지 않았다는 컴파일러 경고를 피할 수 있다.

직접 매개변수 있는 생성자를 선언한 이 클래스에는 매개변수 없는 생성자가 자동으로 추가되지 않는다. 따라서 new CafeItem()은 사용할 수 없다. 객체를 만들려면 이름을 전달해야 한다는 조건이 생성자의 모양에 나타난다.

변경 요청을 검사한 뒤 반영한다

캡슐화(encapsulation)는 내부 표현을 감추고 정해진 통로로 상태를 다루게 하는 설계다. 필드에 private를 붙이는 일은 그 출발점이다. 공개한 메서드가 규칙을 지키도록 구현되어야 실제로 잘못된 상태를 줄일 수 있다. 재고 필드를 숨겨 놓고 음수도 그대로 대입하는 공개 메서드를 제공한다면 보호 효과가 작다.

이번 클래스의 변경 통로는 세 개다. TrySetPrice는 양수 가격만 받아들이고, TryRestock은 보관 한도 안에서 양수 수량을 더한다. TrySell은 가격이 설정되어 있고 판매 수량이 현재 재고 이하일 때만 차감한다. 이름 앞의 Try는 실패할 수 있는 작업이라는 의도를 표현하는 관례이며, 언어가 특별한 기능을 부여하는 문법은 아니다.

세 메서드는 성공하면 true, 거절하면 false를 반환한다. 중요한 공통점은 상태를 변경하기 전에 모든 조건을 검사한다는 것이다. 실패 경로에서는 곧바로 반환하므로 기존 가격과 재고가 그대로 남는다. 호출하는 쪽에서는 반환값으로 결과를 확인하고, 표시할 문구는 콘솔 코드에서 결정한다.

판매 조건을 통과한 요청만 재고를 차감하고 거절된 요청은 기존 재고를 유지한다

재고 한도 검사에서는 stock + quantity를 먼저 계산하지 않고 quantity > 1000 - stock을 사용한다. 현재 재고가 0부터 1,000 사이라는 규칙이 유지되므로 남은 공간은 안전하게 계산할 수 있다. 매우 큰 양수 수량을 전달해도 덧셈부터 수행하지 않고 거절한다. 조건식을 어떤 순서로 계산하는지도 상태를 지키는 구현의 일부다.

외부 코드에서 먼저 재고를 확인하는 것은 안내 문구를 만드는 데 도움이 될 수 있다. 그러나 재고를 실제로 바꾸는 메서드도 자신의 조건을 검사해야 한다. 호출자가 검사를 했다고 가정하면 새로운 호출 지점이 생길 때 규칙이 빠질 수 있다. 여기서는 한 흐름으로 실행되는 콘솔 앱을 다루며, 여러 작업이 동시에 재고를 바꾸는 문제는 포함하지 않는다.

객체의 멤버와 정적 멤버를 구분한다

지금까지의 이름, 가격, 재고는 객체마다 따로 보관하는 정보다. 반면 이 프로그램에서 메뉴 객체를 몇 개 만들었는지는 개별 메뉴 하나의 정보가 아니다. 이런 값은 정적 멤버(static member)로 타입에 둘 수 있다. 정적 멤버는 개별 객체에 속하지 않으며 타입을 기준으로 접근한다.

public static int CreatedCount { get; private set; }

생성자에서 CreatedCount++를 실행하면 객체를 만들 때마다 같은 계수가 증가한다. 이를 읽을 때는 coffee.CreatedCount가 아니라 CafeItem.CreatedCount라고 적는다. 정적이라는 말은 값이 바뀌지 않는다는 뜻이 아니다. 이 속성의 값은 생성자가 실행될 때마다 바뀐다.

CreatedCount는 프로그램 실행 중 생성한 객체의 누적 개수다. 현재 판매 중인 메뉴 수나 현재 사용 중인 객체 수를 뜻하지 않는다. 객체를 더 이상 사용하지 않아도 자동으로 줄어들지 않으며, 파일에 저장하지 않았으므로 프로그램을 새로 실행하면 다시 0에서 시작한다. 이름과 설명을 이렇게 맞추어야 계수를 잘못 해석하지 않는다.

정적 메서드도 정의할 수 있지만 호출 대상 객체가 없으므로 그 자체로 어느 메뉴의 stock을 사용할지 알 수 없다. 이번 예제의 판매 메서드는 특정 메뉴의 재고를 바꾸므로 정적 메서드로 만들지 않는다. 여러 곳에서 편하게 호출하고 싶다는 이유만으로 static을 붙이면 데이터가 누구에게 속하는지 흐려질 수 있다.

완성 코드

.NET 10 콘솔 프로젝트의 Program.cs를 다음 내용으로 바꾼다. 실행 문장을 위에 두고 CafeItem 선언을 아래에 둔 한 파일 구성이다. 최상위 문을 사용하는 파일에서는 실행 문장이 타입 선언보다 앞에 있어야 한다. 입력, 현재 시각, 임의의 수에 의존하지 않으므로 실행할 때마다 같은 결과가 나온다.

using System;

CafeItem coffee = new CafeItem("아메리카노");
CafeItem tea = new CafeItem("유자차");

coffee.TrySetPrice(4500);
coffee.TryRestock(5);
tea.TrySetPrice(5000);
tea.TryRestock(2);

Console.WriteLine($"생성한 메뉴 객체: {CafeItem.CreatedCount}개");
Console.WriteLine($"{coffee.Name}: {coffee.UnitPrice}원, 재고 {coffee.Stock}개");
Console.WriteLine($"{tea.Name}: {tea.UnitPrice}원, 재고 {tea.Stock}개");

CafeItem counterItem = coffee;
bool firstSale = counterItem.TrySell(2);
Console.WriteLine($"아메리카노 2개 판매: {(firstSale ? "성공" : "거절")}");
Console.WriteLine($"coffee로 확인한 재고: {coffee.Stock}개");

bool secondSale = coffee.TrySell(4);
Console.WriteLine($"아메리카노 4개 판매: {(secondSale ? "성공" : "거절")}");

bool restock = coffee.TryRestock(-1);
Console.WriteLine($"음수 입고: {(restock ? "성공" : "거절")}");

bool priceChange = coffee.TrySetPrice(-100);
Console.WriteLine($"음수 가격 변경: {(priceChange ? "성공" : "거절")}");

Console.WriteLine($"최종 {coffee.Name}: {coffee.UnitPrice}원, 재고 {coffee.Stock}개");
Console.WriteLine($"최종 {tea.Name}: {tea.UnitPrice}원, 재고 {tea.Stock}개");
Console.WriteLine($"생성한 메뉴 객체: {CafeItem.CreatedCount}개");

class CafeItem
{
    private int stock;

    public string Name { get; }
    public int UnitPrice { get; private set; }
    public int Stock => stock;
    public static int CreatedCount { get; private set; }

    public CafeItem(string name)
    {
        Name = string.IsNullOrWhiteSpace(name) ? "이름 없는 메뉴" : name;
        UnitPrice = 0;
        stock = 0;
        CreatedCount++;
    }

    public bool TrySetPrice(int newPrice)
    {
        if (newPrice <= 0)
        {
            return false;
        }

        UnitPrice = newPrice;
        return true;
    }

    public bool TryRestock(int quantity)
    {
        if (quantity <= 0 || quantity > 1000 - stock)
        {
            return false;
        }

        stock += quantity;
        return true;
    }

    public bool TrySell(int quantity)
    {
        if (UnitPrice <= 0 || quantity <= 0 || quantity > stock)
        {
            return false;
        }

        stock -= quantity;
        return true;
    }
}

줄별 해설

객체를 만들고 준비하는 문장

using System;은 Console을 짧은 이름으로 사용할 수 있게 한다. 이어지는 두 선언은 CafeItem 타입의 변수를 만들고, new로 만든 서로 다른 객체의 참조를 저장한다. 생성자에 넘긴 문자열은 각 객체의 Name에 들어간다. 이 시점의 가격과 재고는 모두 0이고 CreatedCount는 2다.

coffee.TrySetPrice(4500)과 coffee.TryRestock(5)는 아메리카노 객체를 준비한다. 다음 두 호출은 유자차 객체를 준비한다. 이 네 줄은 예제에서 성공하는 고정값을 전달하므로 반환값을 사용하지 않았다. C#에서는 반환값이 있는 메서드를 호출하고 그 값을 사용하지 않아도 된다. 사용자가 입력한 값처럼 결과를 미리 알 수 없는 경우에는 아래 판매 코드처럼 반환값을 확인해야 한다.

첫 번째 Console.WriteLine은 클래스 이름으로 정적 속성을 읽는다. 다음 두 줄은 각각의 객체에서 이름, 가격, 재고를 읽는다. 문자열 보간 안에서도 속성은 다른 식과 마찬가지로 사용할 수 있다. 출력 형식에 자릿수 구분 기호나 통화 형식을 지정하지 않았으므로 표시할 단위는 문자열의 “원”과 “개”로 직접 적었다.

같은 객체를 통한 판매와 거절

CafeItem counterItem = coffee;는 coffee가 가진 참조를 복사한다. 여기에는 new가 없으며 생성자도 호출되지 않는다. 따라서 CreatedCount는 여전히 2다. 다음 줄의 counterItem.TrySell(2)는 아메리카노 재고를 5에서 3으로 바꾸고 true를 반환한다.

firstSale을 사용하는 출력 문장은 조건 연산자로 “성공”과 “거절” 중 하나를 고른다. 이어 coffee.Stock을 출력하면 counterItem으로 바꾼 결과인 3을 읽는다. 두 변수의 이름은 다르지만 변경 대상 객체가 같다는 사실을 실행 결과로 확인하는 부분이다.

secondSale은 재고 3개에서 4개를 판매하려는 요청의 결과다. 판매 메서드는 false를 반환하고 재고를 바꾸지 않는다. restock은 음수 입고 요청의 결과이며, priceChange는 음수 가격 설정 요청의 결과다. 두 요청도 거절된다. 마지막 세 출력 문장은 아메리카노가 가격 4,500원과 재고 3개를 유지하고, 유자차는 영향을 받지 않았으며, 객체 수도 늘지 않았음을 보여 준다.

클래스 선언과 생성자

class CafeItem은 새로운 타입의 본문을 시작한다. private int stock;은 각 객체가 자신의 재고 저장 공간을 갖도록 선언한다. 이어지는 Name, UnitPrice, Stock은 외부에서 조회할 정보를 제공한다. CreatedCount에만 static이 붙었으므로 이 계수는 객체별로 따로 마련되지 않는다.

public CafeItem(string name)은 문자열 하나를 받는 생성자다. Name에 대입하는 식은 string.IsNullOrWhiteSpace로 사용할 이름이 비어 있거나 공백뿐인지 확인한다. 그런 경우에는 대체 이름을 저장하고, 그렇지 않으면 전달받은 문자열을 저장한다. 이 메서드가 null도 검사할 수 있더라도 여기의 매개변수 선언은 null을 받겠다는 계약이 아니므로 예제 호출에서는 문자열을 전달한다.

UnitPrice = 0;과 stock = 0;은 시작 상태를 정한다. CreatedCount++;는 이번 객체 생성을 공통 계수에 반영한다. 생성자에서는 이처럼 개별 객체의 초기화와 타입 차원의 계수 갱신을 함께 수행할 수 있다. 다만 계수 갱신은 동시 실행을 고려한 구현이 아니며, 이 예제의 순차 실행 범위에서 사용한다.

세 변경 메서드의 검사와 대입

TrySetPrice의 반환 형식은 bool이고 매개변수는 새 가격이다. newPrice <= 0이면 곧바로 false를 반환한다. 조건을 통과했을 때만 UnitPrice에 값을 대입하고 true를 반환한다. private set은 클래스 내부의 대입을 허용하므로 이 문장은 사용할 수 있다.

TryRestock은 수량이 0 이하이거나 남은 보관 공간보다 크면 거절한다. ||로 연결한 조건 중 하나라도 참이면 실패다. 조건을 통과하면 stock += quantity;로 실제 재고를 늘린다. 여기서 stock은 메서드를 호출한 객체의 필드이므로 coffee로 호출하면 아메리카노의 재고가 바뀐다.

TrySell은 가격 설정 여부, 양수 수량 여부, 재고 충분 여부를 차례로 확인한다. 조건을 통과한 수량은 현재 재고 이하이므로 stock -= quantity; 뒤에도 재고는 음수가 되지 않는다. 각 메서드의 마지막 return true;는 요청이 반영되었다는 뜻이다. 판매 대금 같은 별도 값을 반환하는 것은 아니다.

실행 결과

프로젝트 폴더에서 다음 명령을 실행한다. 새 프로젝트를 만드는 경우에는 먼저 아래의 생성 명령을 실행하고 Program.cs를 교체한다.

dotnet new console --framework net10.0 -n CafeClasses
cd CafeClasses

코드를 저장한 뒤 실행하는 명령은 다음과 같다.

dotnet run

프로그램의 예상 출력은 다음과 같다.

생성한 메뉴 객체: 2개
아메리카노: 4500원, 재고 5개
유자차: 5000원, 재고 2개
아메리카노 2개 판매: 성공
coffee로 확인한 재고: 3개
아메리카노 4개 판매: 거절
음수 입고: 거절
음수 가격 변경: 거절
최종 아메리카노: 4500원, 재고 3개
최종 유자차: 5000원, 재고 2개
생성한 메뉴 객체: 2개

거절 문구 뒤에 재고를 되돌리는 코드는 없다. 실패할 요청은 변경 전에 반환했기 때문이다. 또 counterItem이라는 변수를 추가했어도 생성한 객체는 두 개다. 재고 3개와 객체 수 2개라는 두 결과를 함께 확인하면 상태 변경과 참조 복사의 차이를 구분할 수 있다.

실무에서 자주 틀리는 것

아래 코드는 각 실수를 보여 주는 부분 코드다. 틀린 예에는 의도적으로 컴파일되지 않거나 업무 규칙을 어기는 문장이 포함되어 있다. 완성 코드 전체를 대체하지 않고 해당 선언이나 사용 위치를 비교한다.

재고에 공개 쓰기 기능을 붙인다

다음과 같이 선언하면 아무 코드에서나 음수 재고를 넣을 수 있다. 속성을 사용했다는 사실만으로 재고 규칙이 생기는 것은 아니다.

// 잘못된 선언과 사용
public int Stock { get; set; }

// 클래스 밖에서 실행할 수 있게 된다.
coffee.Stock = -8;

필드를 숨기고 읽기 속성만 공개한다. 변경은 수량을 검사하는 메서드로 요청한다. 재고를 임의의 최종 값으로 덮어쓰는 대신 입고나 판매라는 동작을 표현할 수 있다.

// 클래스 내부의 고친 선언
private int stock;
public int Stock => stock;

// 클래스 밖의 사용
bool accepted = coffee.TryRestock(8);

나중에 재고 실사 기능이 필요하다면 그 목적에 맞는 메서드와 검사 규칙을 추가하면 된다. 편의를 위해 쓰기 접근을 모두 열어 두는 것과는 구분해야 한다.

먼저 변경하고 나서 실패를 반환한다

판매 수량을 먼저 차감한 뒤 음수인지 확인하면 거절된 요청이 객체를 이미 바꾸어 놓는다. 호출자는 false를 보고 판매되지 않았다고 판단하지만 재고는 잘못된 값으로 남을 수 있다.

// 잘못된 TrySell 본문
stock -= quantity;
if (stock < 0)
{
    return false;
}
return true;

검사를 먼저 끝내고 그다음 상태를 바꾼다. 이 순서라면 실패 경로에서 원래 값을 복구하는 코드가 필요 없다.

// 고친 TrySell 본문
if (UnitPrice <= 0 || quantity <= 0 || quantity > stock)
{
    return false;
}

stock -= quantity;
return true;

거절되었을 때 객체가 어떤 상태인지도 메서드의 계약에 포함된다. 성공 여부만 정하고 실패 뒤의 상태를 정하지 않으면 호출하는 코드가 메서드를 믿고 사용하기 어렵다.

객체별 재고를 정적 필드로 선언한다

다음 선언은 모든 메뉴가 재고 저장 공간 하나를 공유하게 한다. 생성자에서 stock을 0으로 만들면 새 메뉴를 등록하는 일이 기존 메뉴의 재고까지 바꾸게 된다.

// 잘못된 선언
private static int stock;

메뉴마다 달라야 하는 값에서는 static을 뺀다. 객체 생성 누적 개수처럼 정말로 타입 전체에 속하는 정보에 정적 멤버를 사용한다.

// 고친 선언
private int stock;
public static int CreatedCount { get; private set; }

판단 기준은 “여러 메서드에서 사용하는가”가 아니다. “메뉴 두 개가 이 값을 서로 다르게 가져야 하는가”다. 여러 메서드가 사용하더라도 각 메뉴의 재고는 개별 객체에 속한다.

객체 대입을 독립된 복사로 생각한다

아래 코드는 별도 메뉴를 만든다고 생각하면 잘못이다. preview에서 판매해도 원래 coffee의 재고가 바뀐다. 컴파일 오류가 없어서 실행 후에야 의도와 다른 결과를 발견할 수 있다.

// 독립된 객체를 기대했다면 잘못된 코드
CafeItem preview = coffee;
preview.TrySell(1);

같은 값을 가진 별도 객체가 필요하다면 실제로 새 객체를 만들어야 한다. 다음은 이 클래스가 공개한 기능만으로 값을 옮기는 부분 코드다. 원본 가격이 0인 경우에는 새 객체도 이미 0이므로 가격 설정을 생략한다.

CafeItem preview = new CafeItem(coffee.Name);

if (coffee.UnitPrice > 0)
{
    preview.TrySetPrice(coffee.UnitPrice);
}

if (coffee.Stock > 0)
{
    preview.TryRestock(coffee.Stock);
}

이 코드는 현재 예제의 속성에 맞춘 수동 복사다. 객체를 새로 만들었으므로 CreatedCount도 하나 늘어난다. 참조 대입과 객체 생성은 결과뿐 아니라 생성자의 실행 여부에서도 차이가 난다.

한눈에 보기

클래스의 구성 요소를 고르는 기준
구성 요소담당하는 일예제에서의 선택확인할 점
필드상태 저장stock외부의 직접 변경이 필요한가
속성읽기와 쓰기 접근 제공Name, UnitPrice, Stock읽기와 쓰기의 범위가 적절한가
생성자시작 상태 설정이름 설정, 가격과 재고 0만든 직후 상태가 규칙에 맞는가
인스턴스 메서드개별 객체의 동작 수행TryRestock, TrySell실패해도 상태가 유지되는가
정적 멤버타입에 속한 정보 제공CreatedCount객체별 값과 혼동하지 않았는가

클래스를 설계할 때는 먼저 무엇을 저장할지 적고, 다음으로 어떤 변경을 허용할지 적는다. 공개 범위는 그 결정에 따라 정한다. 이번 예제에서는 재고 조회를 허용하되 직접 대입은 막고, 수량 검사를 통과하는 변경만 받아들였다. 다음 장에서는 값처럼 다루려는 데이터에 맞춰 타입을 표현하는 방법을 살펴본다.

연습 문제

  1. 완성 코드의 CafeItem에 IsSoldOut이라는 읽기 전용 속성을 추가한다. 재고가 0이면 true를 반환해야 한다. 가격 설정 여부는 판단에 포함하지 않는다. 저장용 필드를 새로 만들지 않고 구현한다.
  2. 완성 코드의 마지막 출력 문장 다음, class CafeItem 선언 앞에 coffee.TryRestock(997)과 coffee.TryRestock(1)을 순서대로 호출하는 코드를 넣는다고 하자. 각 반환값과 최종 재고를 구하고, 그렇게 되는 이유를 설명한다.
  3. 클래스에 TryRename(string newName) 메서드를 추가한다. 비어 있거나 공백뿐인 이름이면 false를 반환하고 기존 이름을 유지한다. 나머지는 새 이름을 저장하고 true를 반환한다. Name 속성 선언도 필요한 만큼 바꾼다.
  4. 원래 완성 코드의 마지막 출력 문장 다음, 클래스 선언 앞에 CafeItem extra = tea;와 extra.TrySell(1);을 추가한다. coffee.Stock, tea.Stock, extra.Stock, CafeItem.CreatedCount의 값을 각각 구한다.

정답과 해설

  1. 클래스 본문에 다음 속성을 추가한다.

    public bool IsSoldOut => stock == 0;

    조회할 때 현재 재고를 비교하므로 입고와 판매 뒤에도 따로 갱신할 필요가 없다. 별도 필드를 두면 재고와 품절 표시를 함께 변경해야 하고 둘이 어긋날 가능성이 생긴다. 기존 상태에서 바로 계산할 수 있는 정보는 읽기 속성으로 표현할 수 있다.

  2. 첫 호출은 true, 두 번째 호출은 false이며 최종 재고는 1,000개다. 원래 프로그램의 마지막 아메리카노 재고는 3개이므로 남은 공간은 997개다. 첫 요청은 그 공간과 정확히 같아 허용된다. 다음 요청에서는 남은 공간이 0개이므로 1개 입고도 거절된다.

    bool firstRestock = coffee.TryRestock(997);
    bool secondRestock = coffee.TryRestock(1);
    
    Console.WriteLine(firstRestock ? "성공" : "거절");
    Console.WriteLine(secondRestock ? "성공" : "거절");
    Console.WriteLine(coffee.Stock);

    추가한 부분의 출력은 순서대로 “성공”, “거절”, “1000”이다. 한도를 넘는 요청을 거절해도 첫 번째 입고 결과는 유지된다.

  3. Name의 선언을 다음과 같이 교체하고 메서드를 클래스 본문에 추가한다.

    public string Name { get; private set; }
    
    public bool TryRename(string newName)
    {
        if (string.IsNullOrWhiteSpace(newName))
        {
            return false;
        }
    
        Name = newName;
        return true;
    }

    기존의 get만 있는 자동 구현 속성에는 일반 인스턴스 메서드에서 값을 대입할 수 없다. private set을 추가하면 클래스 내부의 메서드는 대입할 수 있고 외부의 직접 대입은 계속 제한된다. 생성자는 빈 이름을 대체하지만 이름 변경 메서드는 빈 이름을 거절한다. 두 동작의 정책 차이를 구분해야 한다.

  4. coffee.Stock은 3, tea.Stock과 extra.Stock은 각각 1, CafeItem.CreatedCount는 2다. extra는 유자차 객체를 함께 가리킨다. 유자차는 가격이 설정되어 있고 재고가 2개이므로 1개 판매가 성공한다. 아메리카노는 별도 객체이므로 영향을 받지 않는다.

    새로운 변수 선언이 있었어도 new를 실행하지 않았으므로 생성자는 호출되지 않는다. 변수 추가만으로 객체 생성 계수가 증가하지 않는다는 점을 확인하는 문제다.

댓글 0

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

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