Architecture & Design/Software Architecture
Principles - 05. 캡슐화와 정보 은닉
계기판 뒤에 엔진이 있다 — 캡슐화와 정보 은닉
운전자가 자동차를 몰 때 보는 건 계기판이다 — 속도계, 연료 게이지, 엔진 경고등. 엔진 내부의 실린더 배열이나 점화 타이밍은 보이지 않는다. 엔진이 터보에서 하이브리드로 바뀌어도, 계기판이 같으면 운전자는 모른다. 이것이 캡슐화(encapsulation)다 — 내부 구현을 숨기고 외부에 필요한 것만 노출한다.
David Parnas는 1972년 논문에서 모듈을 나누는 기준으로 "무엇이 변할 수 있는가"를 제시했다. 변할 가능성이 있는 것을 모듈 안으로 숨기고, 변하지 않는 것만 인터페이스로 노출한다. 그러면 내부가 바뀌어도 외부(다른 모듈)가 영향을 받지 않는다.
캡슐화의 두 층 — 데이터 은닉과 구현 은닉
데이터 은닉은 객체의 내부 상태(필드)를 외부에서 직접 못 건드리게 하는 것이다. 구현 은닉은 메서드의 내부 로직(어떻게 하는가)을 숨기고 "무엇을 하는가"만 노출하는 것이다.
// 캡슐화 위반 — 내부가 전부 노출
class Cart {
public List<Item> items = new ArrayList<>(); // public 필드 — 누구나 직접 조작
public double total; // 외부에서 마음대로 바꿈
}
cart.items.add(new Item("sku-1", 2)); // Cart의 동의 없이 내부 변경
cart.total = 0; // 총액을 0으로 조작 가능 — 규칙 없음
// 캡슐화 준수 — 내부를 숨기고 행동만 노출
class Cart {
private final List<Item> items = new ArrayList<>(); // private — 외부 접근 불가
void add(Item item) {
items.add(item); // Cart가 동의한 방식으로만 추가
}
Money total() {
return items.stream() // 총액은 외부에서 못 바꿈 — 계산만 가능
.map(Item::subtotal)
.reduce(Money.ZERO, Money::add);
}
}
getter/setter는 캡슐화가 아니다
흔한 오해가 있다 — private 필드에 getter/setter를 달면 캡슐화가 된다고. 아니다. getTotal()이 있으면 외부에서 total을 읽을 수 있고, setTotal(0)이 있으면 외부에서 마음대로 바꿀 수 있다. 필드가 private이어도, getter/setter가 있으면 사실상 public 필드와 같다 — 구멍만 뚫은 것이다.
진짜 캡슐화는 상태를 묻지 말고 행동을 부르는 것이다(Tell, Don't Ask). cart.getTotal()이 아니라 cart.checkout()이 있다면, Cart가 자기 상태를 바탕으로 checkout을 수행한다 — 외부에서 total을 읽어 판단하지 않는다.
Parnas의 기준 — "변경에 대한 저항"
Parnas의 기준은 "이것을 바꿨을 때 다른 모듈에 영향이 가는가"다. 영향이 가면 그것은 캡슐화되지 않은 것이다.
| 변경 | 영향 범위 | 캡슐화 여부 |
|---|---|---|
items를 List → Map으로 |
외부가 getItems()로 List를 받아 쓰면 깨짐 |
미흡 — 내부 타입이 노출 |
total()의 계산 로직 변경 |
외부는 total()만 부르므로 영향 없음 |
양호 — 구현이 숨겨짐 |
add()에 수량 제한 추가 |
외부는 add(item)을 부르므로 영향 없음 |
양호 — 규칙이 내부에 |
설계 사례 — 캡슐화 위반에서 개선까지
flowchart LR
subgraph Before["캡슐화 위반"]
S1["OrderService"] -->|getStatus| O1["Order<br/>(public 필드)"]
S1 -->|setStatus| O1
Note1["서비스가 Order의 상태를 판단하고 변경<br/>규칙이 서비스에 있음"]
end
subgraph After["캡슐화 준수"]
S2["OrderService"] -->|cancel| O2["Order<br/>(private 필드)"]
Note2["Order가 자기 규칙을 지킴<br/>서비스는 호출만"]
end
// Before — 캡슐화 위반: 서비스가 Order의 내부를 읽고 조작
class OrderService {
void cancel(Long orderId) {
Order order = repo.findById(orderId);
if (order.getStatus() == OrderStatus.SHIPPED) // 상태를 묻고 (Ask)
throw new IllegalStateException();
if (order.getPlacedAt().isBefore(threeDaysAgo)) // 또 묻고
throw new IllegalStateException();
order.setStatus(OrderStatus.CANCELLED); // 상태를 직접 바꿈
repo.save(order);
}
}
// "배송 중 취소 불가" 규칙이 OrderService에 있음 — Order는 모름
// After — 캡슐화 준수: Order가 자기 규칙을 지님
class Order {
private OrderStatus status; // private — 외부에서 못 읽음
private Instant placedAt; // private
void cancel() { // 행동을 부름 (Tell)
if (status == OrderStatus.SHIPPED)
throw new IllegalStateException("배송 중인 주문은 취소할 수 없습니다");
if (placedAt.isBefore(Instant.now().minus(Duration.ofDays(3))))
throw new IllegalStateException("3일 경과 시 취소 불가");
status = OrderStatus.CANCELLED;
}
}
class OrderService {
void cancel(Long orderId) {
Order order = repo.findById(orderId);
order.cancel(); // "취소해"라고만 말함 — 규칙은 Order가 앎
repo.save(order);
}
}
Before에서는 OrderService가 Order의 상태를 읽고(getStatus), 판단하고(if), 바꾼다(setStatus) — Order는 수동적 데이터 주머니다. 규칙("배송 중 취소 불가")이 서비스에 박혀 있다. After에서는 Order가 자기 상태를 private으로 숨기고, cancel()이라는 행동으로 규칙을 지킨다 — 서비스는 호출만 한다.
이것이 Parnas가 말한 "정보 은닉"의 실천이다 — 변경될 수 있는 것(취소 규칙, 상태 전환 조건)을 Order 안에 숨기고, 변경되지 않는 것(서비스의 호출 흐름)만 외부에 노출한다. 취소 규칙이 바뀌면(3일 → 7일) Order.cancel() 한 곳만 고치면 된다 — 서비스는 모른다.
캡슐화를 깨뜨리는 5가지 패턴
코드 리뷰에서 캡슐화 위반을 발견하는 전형적 패턴들이다.
| 패턴 | 증상 | 코드 냄새 |
|---|---|---|
| getter/setter 남발 | 필드가 private이어도 사실상 public | order.getStatus(), order.setStatus(4) |
| 상태를 묻고 판단 (Ask) | 호출자가 객체 상태를 읽어 자기가 판단 | if (order.getStatus() == PENDING) order.setStatus(CANCELLED) |
| 내부 컬렉션 노출 | getList()가 내부 List를 그대로 반환 |
cart.getItems().clear() — Cart 동의 없이 비움 |
| 생성 시만 검증, 그 후엔 무방비 | 생성자는 검증하지만 setter는 미검증 | new Money(-100)는 막고 setAmount(-100)는 허용 |
| DTO에 행동 추가 | 데이터 전송 객체에 비즈니스 로직 스며듦 | OrderDto.applyDiscount() — DTO가 도메인 침범 |
// 패턴 3: 내부 컬렉션 노출 — 외부에서 내부 리스트 직접 조작
class Cart {
private List<Item> items = new ArrayList<>();
public List<Item> getItems() { return items; } // 내부 List 그대로 반환
}
cart.getItems().clear(); // Cart 동의 없이 비움 → 캡슐화 붕괴
// 수정 — 행동으로만 접근, 조회는 불변 복사본
class Cart {
private List<Item> items = new ArrayList<>();
public void add(Item item) { items.add(item); }
public List<Item> view() { return List.copyOf(items); } // 불변 → 수정 불가
}
캡슐화의 본질은 "필드를 private으로 만드는 것"이 아니라 "객체가 자기 상태의 주인이 되는 것"이다. 상태를 묻고(Ask) 외부에서 판단하는 순간, 객체는 주인권을 잃는다. 행동을 부르는 것(Tell)이 객체에게 주인권을 돌려주는 길이다.
참고
- Parnas, David — "On the Criteria To Be Used in Decomposing Systems into Modules" (CACM, 1972) — 접근 2026-07-15
- Ousterhout, John — (2nd ed, 2021), Ch.8 (정보 은닉) — 접근 2026-07-15
- Martin, Robert C. —
(Pearson, 2008), Ch.6 (캡슐화, Tell Don't Ask) — 접근 2026-07-15
'Architecture & Design > Software Architecture' 카테고리의 다른 글
| Principles - 07. 오류 처리 전략 (0) | 2026.07.16 |
|---|---|
| Principles - 06. 계약 설계와 API (0) | 2026.07.16 |
| Principles - 04. 의존성 규칙 (0) | 2026.07.16 |
| Principles - 03. 패러다임의 함의 (0) | 2026.07.16 |
| Principles - 02. 추상화와 깊은 모듈 (0) | 2026.07.16 |