캡슐화와 설계 원칙
무엇을 감추고 무엇을 드러낼지 정하는 기준입니다.
어긋날 수 없게 만들기
장바구니에 담긴 물건의 값을 모두 더한 합계를 들고 있다고 해보겠습니다. 물건을 담을 때마다 합계도 함께 늘려야 하는데, 둘 다 밖에서 건드릴 수 있으면 어긋나는 순간이 반드시 옵니다 — 물건만 담고 합계를 잊거나, 합계만 고치고 물건은 그대로 두는 코드가 어딘가에 생깁니다.
캡슐화는 값과 그 값을 다루는 일을 하나로 묶고, 밖에서는 정해진 길로만 드나들게 하는 것입니다. 담는 길이 하나뿐이면 그 길에서만 합계를 맞추면 되고, 어긋날 자리가 애초에 없어집니다.
이렇게 객체가 살아 있는 동안 늘 참이어야 하는 규칙을 불변식이라고 합니다. "합계는 담긴 값의 합과 같다", "잔액은 음수가 될 수 없다" 같은 것입니다. 캡슐화의 목적은 불변식을 지킬 수 있는 자리를 좁히는 것입니다.
어긋날 자리를 없앤 장바구니
아래 코드는 브라우저 안에서 실제로 실행됩니다. 고쳐서 눌러 보셔도 됩니다.
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; } }
코드는 고쳐서 실행해 볼 수 있습니다. 처음 누를 때만 실행기를 내려받느라 잠시 걸립니다. 적은 코드는 서버로 나가지 않습니다.
합계와 물건 목록이 어긋날 수 없습니다. 둘을 함께 바꾸는 자리가
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접근 제한자 · 프로퍼티 · readonly
- 6.0읽기 전용 자동 프로퍼티 — { get; }
- 9.0대상 타입 new — new() 로 형식 이름 생략
- 12.0기본 생성자 — 필요한 것을 밖에서 받기
이 단원의 내용은 대부분 문법이 아니라 설계라 버전을 가리지 않습니다. 위 눈금은 예제에 쓰인 문법이 언제부터 되는지를 적은 것입니다.
다만 바꿀 수 없게 만드는 문법은 꾸준히 늘었습니다 — 만든 뒤로
못 바꾸게 하는 init(C# 9.0, 2.2에서 다룹니다),
담지 않으면 못 만들게 하는 required(C# 11.0,
2.3에서 다룹니다)가 그렇습니다. 지켜야 할 규칙을 사람의 주의가 아니라
컴파일러에 맡기는 방향입니다.
직접 해보기
Thermometer 클래스를 만들어 지금 온도와
가장 높았던 온도를 들고 있게 해보세요. 온도를 적는 길은 메서드
하나뿐이어야 하고, 최고 온도는 밖에서 바꿀 수 없어야 합니다.
알림을 보내는 INotifier 약속을 만들고, 그것을 받아
사용하는 클래스를 하나 만들어보세요. 그 클래스를 고치지 않고
서로 다른 두 가지 방식으로 알림이 나가게 하면 됩니다.
- 캡슐화의 목적은 불변식을 지킬 자리를 좁히는 것입니다. 길이 하나면 어긋날 수 없습니다.
- 어떻게 하는지는 감추고, 무엇을 할 수 있는지만 드러냅니다. 그래야 안쪽을 마음대로 바꿉니다.
- 클래스 하나는 한 가지 이유로만 바뀌게 나눕니다.
- 실제 구현이 아니라 인터페이스에 기대면 사용하는 쪽을 고치지 않고 갈아 끼울 수 있습니다.
C# 버전별 변경 이력 에서 각 버전이 무엇을 더했는지 볼 수 있습니다.