C#LAB
C# 2.6 · 2부. 객체지향

캡슐화와 설계 원칙

무엇을 감추고 무엇을 드러낼지 정하는 기준입니다.

예상 학습 시간 16분 실행 단추가 있는 예제는 고쳐서 실행해 볼 수 있습니다 난이도 기초
개념 설명

어긋날 수 없게 만들기

장바구니에 담긴 물건의 값을 모두 더한 합계를 들고 있다고 해보겠습니다. 물건을 담을 때마다 합계도 함께 늘려야 하는데, 둘 다 밖에서 건드릴 수 있으면 어긋나는 순간이 반드시 옵니다 — 물건만 담고 합계를 잊거나, 합계만 고치고 물건은 그대로 두는 코드가 어딘가에 생깁니다.

캡슐화는 값과 그 값을 다루는 일을 하나로 묶고, 밖에서는 정해진 길로만 드나들게 하는 것입니다. 담는 길이 하나뿐이면 그 길에서만 합계를 맞추면 되고, 어긋날 자리가 애초에 없어집니다.

이렇게 객체가 살아 있는 동안 늘 참이어야 하는 규칙불변식이라고 합니다. "합계는 담긴 값의 합과 같다", "잔액은 음수가 될 수 없다" 같은 것입니다. 캡슐화의 목적은 불변식을 지킬 수 있는 자리를 좁히는 것입니다.

최소 예제 · 편집 가능

어긋날 자리를 없앤 장바구니

아래 코드는 브라우저 안에서 실제로 실행됩니다. 고쳐서 눌러 보셔도 됩니다.

Program.cs
var cart = new Cart();

cart.Add(3000);
cart.Add(-500);      // 규칙에 어긋나 담기지 않습니다
cart.Add(2000);

Console.WriteLine(cart.Total);
Console.WriteLine(cart.Count);

class Cart {
    // 밖에서는 보이지도, 건드리지도 못합니다
    private readonly List<int> _prices = new();

    public int Count => _prices.Count;
    public int Total { get; private set; }

    // 담는 길은 이것 하나뿐입니다
    public void Add(int price) {
        if (price <= 0) return;

        _prices.Add(price);
        Total += price;
    }
}
출력
5000 2

코드는 고쳐서 실행해 볼 수 있습니다. 처음 누를 때만 실행기를 내려받느라 잠시 걸립니다. 적은 코드는 서버로 나가지 않습니다.

합계와 물건 목록이 어긋날 수 없습니다. 둘을 함께 바꾸는 자리가 Add 하나뿐이기 때문입니다. 밖에서 Total 을 고칠 수도, _prices 에 손댈 수도 없습니다.

규칙을 어기는 값이 들어와도 그 자리에서 걸립니다. 음수가 담긴 채 한참 뒤에 이상한 합계로 드러나는 것보다, 들어오는 문턱에서 막는 편이 원인을 찾기 쉽습니다.

상세 사용법

무엇을 감추고 무엇을 드러내나

기준은 하나입니다. "어떻게 하는지"는 감추고 "무엇을 할 수 있는지"만 드러냅니다. 값을 목록에 담든 배열에 담든 그것은 안쪽 사정이고, 밖에서는 담고 꺼내고 세는 일만 알면 됩니다.

  • 감출 것 — 값을 담는 필드, 셈하는 중간 과정, 담는 방식.
  • 드러낼 것 — 할 수 있는 일(메서드), 물어볼 수 있는 것(읽기 전용 프로퍼티).

감추어 두면 안쪽을 마음대로 바꿀 수 있습니다. 위 예제에서 List 를 배열로 갈아 끼워도, 합계를 미리 들고 있지 않고 그때그때 세도록 바꿔도, 사용하는 쪽 코드는 한 줄도 고칠 필요가 없습니다. 이것이 2.2에서 "처음부터 프로퍼티로 두라"고 한 까닭과 같습니다.

클래스 하나는 한 가지 일만

장바구니가 값을 셈하는 것에 더해 화면에 그리고 파일로 저장까지 한다면, 그 클래스는 세 가지 이유로 고쳐지게 됩니다. 셈법이 바뀌어도, 화면이 바뀌어도, 저장 방식이 바뀌어도 같은 파일을 열어야 합니다.

그래서 클래스 하나는 한 가지 이유로만 바뀌게 나눕니다. 2.1에서 "함께 다녀야 할 값들을 하나로 묶는다"고 했는데, 그 짝이 되는 규칙입니다 — 함께 바뀌는 것끼리 묶고, 따로 바뀌는 것은 나눕니다.

바꾸지 말고 더 만들기

새 종류가 늘어날 때마다 기존 코드를 열어 조건을 하나씩 붙이면, 잘 돌던 것이 그때마다 깨질 위험을 안습니다. 2.4의 다형성은 그 문제를 기존 코드를 건드리지 않고 새 클래스만 만드는 쪽으로 풉니다.

// 종류가 늘 때마다 이 메서드를 열어야 합니다
if (item.Kind == "음악") { /* ... */ }
else if (item.Kind == "영상") { /* ... */ }

// 이쪽은 새 클래스만 만들면 됩니다 — 이 줄은 그대로입니다
item.Play();

실제 구현이 아닌, 약속된 인터페이스에 기대기

어떤 클래스가 다른 클래스를 이름으로 콕 집어 사용하면 둘이 단단히 붙습니다. 하나를 갈아 끼우려면 다른 하나도 고쳐야 하고, 테스트할 때도 진짜를 준비해야 합니다. 2.5의 인터페이스는 그 사이에 약속을 하나 끼워 그 매듭을 풉니다.

실제 구현에 기대면
class OrderService {
    // 이 하나로 못박혔습니다
    private readonly EmailSender _sender
        = new EmailSender();

    public void Place() {
        _sender.Send("주문됨");
    }
}

// 문자로 바꾸려면 이 클래스를
// 고쳐야 합니다
인터페이스에 기대면
class OrderService(ISender sender) {
    public void Place() {
        sender.Send("주문됨");
    }
}

// 무엇을 넣을지는 밖에서 정합니다
new OrderService(new EmailSender());
new OrderService(new SmsSender());

오른쪽은 OrderService한 줄도 고치지 않고 보내는 방식을 갈아 끼웁니다. 테스트할 때 실제로 보내지 않는 가짜를 넣기도 쉽습니다. 필요한 것을 밖에서 받아 오는 이 방식은 ASP.NET Core가 기본으로 삼는 구조이기도 해서, 두 번째 트랙에서 다시 만납니다.

버전 배지

이 문법, 몇 버전부터 사용할 수 있나요

1.0
2.0
3.0
6.0
8.0
9.0
11.0
12.0
14.0
  • 1.0접근 제한자 · 프로퍼티 · readonly
  • 6.0읽기 전용 자동 프로퍼티 — { get; }
  • 9.0대상 타입 new — new() 로 형식 이름 생략
  • 12.0기본 생성자 — 필요한 것을 밖에서 받기
설계

이 단원의 내용은 대부분 문법이 아니라 설계라 버전을 가리지 않습니다. 위 눈금은 예제에 쓰인 문법이 언제부터 되는지를 적은 것입니다.

다만 바꿀 수 없게 만드는 문법은 꾸준히 늘었습니다 — 만든 뒤로 못 바꾸게 하는 init(C# 9.0, 2.2에서 다룹니다), 담지 않으면 못 만들게 하는 required(C# 11.0, 2.3에서 다룹니다)가 그렇습니다. 지켜야 할 규칙을 사람의 주의가 아니라 컴파일러에 맡기는 방향입니다.

실습 문제

직접 해보기

1. 어긋날 수 없는 온도계 난이도 하

Thermometer 클래스를 만들어 지금 온도와 가장 높았던 온도를 들고 있게 해보세요. 온도를 적는 길은 메서드 하나뿐이어야 하고, 최고 온도는 밖에서 바꿀 수 없어야 합니다.

"최고 온도는 지금까지 적힌 값 가운데 가장 큰 값" 이 불변식입니다. 적는 자리가 하나뿐이면 그 자리에서만 맞추면 됩니다. 밖에서 못 바꾸게 하는 것은 private set 입니다.
var t = new Thermometer(); t.Record(21); t.Record(27); t.Record(24); Console.WriteLine($"지금 {t.Current}도, 최고 {t.Highest}도"); // 지금 24도, 최고 27도 class Thermometer { public int Current { get; private set; } public int Highest { get; private set; } public void Record(int value) { Current = value; if (value > Highest) Highest = value; } }
2. 갈아 끼울 수 있게 만들기 난이도 중

알림을 보내는 INotifier 약속을 만들고, 그것을 받아 사용하는 클래스를 하나 만들어보세요. 그 클래스를 고치지 않고 서로 다른 두 가지 방식으로 알림이 나가게 하면 됩니다.

필요한 것을 new 로 안에서 만들지 말고 밖에서 받으세요. 2.3의 기본 생성자를 사용하면 짧게 적을 수 있습니다.
new Alarm(new ConsoleNotifier()).Ring(); new Alarm(new SilentNotifier()).Ring(); interface INotifier { void Notify(string message); } // 무엇으로 알릴지 모른 채 알림만 부탁합니다 class Alarm(INotifier notifier) { public void Ring() => notifier.Notify("일어날 시간입니다"); } class ConsoleNotifier : INotifier { public void Notify(string message) => Console.WriteLine(message); } class SilentNotifier : INotifier { public void Notify(string message) { } }
요약
  • 캡슐화의 목적은 불변식을 지킬 자리를 좁히는 것입니다. 길이 하나면 어긋날 수 없습니다.
  • 어떻게 하는지는 감추고, 무엇을 할 수 있는지만 드러냅니다. 그래야 안쪽을 마음대로 바꿉니다.
  • 클래스 하나는 한 가지 이유로만 바뀌게 나눕니다.
  • 실제 구현이 아니라 인터페이스에 기대면 사용하는 쪽을 고치지 않고 갈아 끼울 수 있습니다.

C# 버전별 변경 이력 에서 각 버전이 무엇을 더했는지 볼 수 있습니다.