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)이 객체에게 주인권을 돌려주는 길이다.


참고