공공부하자개발 · 영어 학습 노트
자바
초급자바의 뼈대0/8 완료
  • 00개발 환경과 첫 프로그램
  • 01변수, 데이터 타입, 형변환
  • 02연산자와 제어문
  • 031차원 · 2차원 배열
  • 04클래스, 객체, 생성자, 오버로딩
  • 05접근 제어자와 캡슐화
  • 06메서드 활용 패턴 (초급)
  • 07enum, 패키지와 import, static 과 final
사이트 소개개인정보처리방침연락처
© 2026 공부하자
홈 › 초급 › 05 / 8

접근 제어자와 캡슐화

섹션 7진행 0 / 8
1왜 배우는가2핵심 원리3코드 예제4응용 변형 예제5자주 하는 실수 (Tip)6연습 문제7정리‹ 이전다음 ›

4. 응용 변형 예제

같은 캡슐화 원칙을 다른 형태로 적용합니다. setter 대신 새 객체를 돌려주는 방식, 생성자를 숨기고 이름 있는 팩토리를 여는 방식, 상태 전이를 메서드로만 허용하는 방식, 컬렉션을 노출하는 두 가지 방식, 그리고 "값을 꺼내 판단"과 "객체에게 시키기"의 차이입니다.

변형 1: setter 검증 → 불변 객체 + with 메서드

예제 2의 Member는 setter로 값을 바꿨습니다. 필드를 모두 final로 두고, 바꾸고 싶으면 바뀐 값으로 새 객체를 만들어 돌려주는 withXxx 메서드를 제공하면 검증이 생성자 한 곳에 모이고 공유해도 안전합니다. 값이 자주 바뀌지 않는 데이터(회원 프로필, 설정)에 잘 맞습니다.

java
public final class ImmutableMember {
    private final String email;
    private final String nickname;
    private final int age;

    public ImmutableMember(String email, String nickname, int age) {
        if (email == null || !email.contains("@")) throw new IllegalArgumentException("이메일 형식 오류: " + email);
        if (nickname == null || nickname.length() < 2) throw new IllegalArgumentException("닉네임은 2자 이상");
        if (age < 14) throw new IllegalArgumentException("14세 이상만 가입 가능: " + age);
        this.email = email.toLowerCase();
        this.nickname = nickname;
        this.age = age;
    }

    public ImmutableMember withNickname(String newNickname) {    // 바꾸는 대신 새 객체
        return new ImmutableMember(email, newNickname, age);     // 검증은 생성자가 다시 수행
    }

    public String getEmail() { return email; }
    public String getNickname() { return nickname; }
    public int getAge() { return age; }

    @Override
    public String toString() { return nickname + " <" + email + "> " + age + "세"; }

    public static void main(String[] args) {
        ImmutableMember m1 = new ImmutableMember("Kim@Shop.com", "민수", 25);
        ImmutableMember m2 = m1.withNickname("민수짱");
        System.out.println(m1);    // 출력: 민수 <kim@shop.com> 25세   (원본은 그대로)
        System.out.println(m2);    // 출력: 민수짱 <kim@shop.com> 25세
        try {
            m1.withNickname("김");
        } catch (IllegalArgumentException e) {
            System.out.println("거부: " + e.getMessage());   // 출력: 거부: 닉네임은 2자 이상
        }
    }
}

변형 2: 생성자를 숨기고 정적 팩토리로 열기 — Won

예제 3의 Money는 public 생성자였습니다. 생성자를 private으로 감추고 of, zero, parse 같은 static 메서드만 열면 (1) 만드는 방법에 이름을 붙일 수 있고, (2) 0원 같은 자주 쓰는 값을 하나만 만들어 재사용할 수 있으며, (3) 문자열처럼 다른 형태의 입력도 받을 수 있습니다.

java
public final class Won {
    private static final Won ZERO = new Won(0);
    private final long amount;

    private Won(long amount) { this.amount = amount; }      // 밖에서는 new 불가

    public static Won of(long amount) {
        if (amount < 0) throw new IllegalArgumentException("음수 불가: " + amount);
        return amount == 0 ? ZERO : new Won(amount);         // 0원은 하나만 재사용
    }

    public static Won zero() { return ZERO; }

    public static Won parse(String text) {                    // "12,900원" 같은 표시 문자열도 받음
        return of(Long.parseLong(text.replace(",", "").replace("원", "").trim()));
    }

    public Won plus(Won o) { return of(amount + o.amount); }
    public boolean isZero() { return this == ZERO; }
    public long amount() { return amount; }

    @Override
    public String toString() { return String.format("%,d원", amount); }

    public static void main(String[] args) {
        Won a = Won.parse("12,900원");
        Won b = Won.of(3_000);
        System.out.println(a.plus(b));                             // 출력: 15,900원
        System.out.println(Won.of(0) == Won.zero());               // 출력: true   (같은 객체 재사용)
        System.out.println(Won.zero().plus(Won.zero()).isZero());  // 출력: true
        try {
            Won.of(-1);
        } catch (IllegalArgumentException e) {
            System.out.println(e.getMessage());                    // 출력: 음수 불가: -1
        }
    }
}

변형 3: 주문 상태 전이를 메서드로만 허용하기

status 필드에 setter를 두면 어떤 순서로든 바꿀 수 있어 "배송 중인데 결제 전"이 됩니다. 대신 pay(), ship(), cancel()처럼 허용된 전이만 메서드로 열고, 조건이 안 맞으면 IllegalStateException을 던집니다. 상태를 가진 실무 객체(주문, 결제, 티켓)의 기본 형태입니다.

java
public class OrderState {
    private final String id;
    private String status = "CREATED";      // 밖에서 직접 못 바꿈

    public OrderState(String id) { this.id = id; }

    public void pay() {
        require("CREATED", "결제");
        status = "PAID";
    }

    public void ship() {
        require("PAID", "배송");
        status = "SHIPPED";
    }

    public void cancel() {
        if (status.equals("SHIPPED")) throw new IllegalStateException(id + ": 배송 시작 후 취소 불가");
        status = "CANCELLED";
    }

    private void require(String expected, String action) {
        if (!status.equals(expected)) {
            throw new IllegalStateException(id + ": " + status + " 상태에서는 " + action + " 불가");
        }
    }

    public String getStatus() { return status; }

    public static void main(String[] args) {
        OrderState o1 = new OrderState("O-1");
        o1.pay();
        o1.ship();
        System.out.println(o1.getStatus());        // 출력: SHIPPED
        try {
            o1.cancel();
        } catch (IllegalStateException e) {
            System.out.println(e.getMessage());    // 출력: O-1: 배송 시작 후 취소 불가
        }

        OrderState o2 = new OrderState("O-2");
        try {
            o2.ship();
        } catch (IllegalStateException e) {
            System.out.println(e.getMessage());    // 출력: O-2: CREATED 상태에서는 배송 불가
        }
        o2.cancel();
        System.out.println(o2.getStatus());        // 출력: CANCELLED
    }
}

변형 4: 컬렉션 필드 노출 — 복사본 vs 읽기 전용 뷰

예제 5는 배열을 clone으로 복사해 돌려줬습니다. List를 쓰면 두 가지 선택이 있습니다. 복사본은 받은 쪽이 마음대로 수정해도 원본이 안전하지만 원본의 이후 변화가 보이지 않고, 읽기 전용 뷰(Collections.unmodifiableList)는 수정을 막으면서 원본의 변화가 그대로 비칩니다. 스냅샷이 필요하면 복사본, 항상 최신을 보여 주려면 뷰입니다.

java
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;

public class TagList {
    private final List<String> tags = new ArrayList<>();

    public void addTag(String tag) {
        if (tag == null || tag.isBlank()) throw new IllegalArgumentException("빈 태그");
        tags.add(tag.trim().toLowerCase());
    }

    public List<String> copyOfTags() { return new ArrayList<>(tags); }               // 복사본
    public List<String> viewOfTags() { return Collections.unmodifiableList(tags); }  // 읽기 전용 뷰

    public static void main(String[] args) {
        TagList t = new TagList();
        t.addTag(" Java ");
        t.addTag("Spring");

        List<String> copy = t.copyOfTags();
        copy.add("hack");
        System.out.println(t.viewOfTags());          // 출력: [java, spring]   (원본 안 바뀜)

        List<String> view = t.viewOfTags();
        try {
            view.add("hack");
        } catch (UnsupportedOperationException e) {
            System.out.println("뷰는 수정 불가");     // 출력: 뷰는 수정 불가
        }

        t.addTag("JPA");
        System.out.println(view);                    // 출력: [java, spring, jpa]   (뷰에는 반영됨)
        System.out.println(copy);                    // 출력: [java, spring, hack]  (복사본은 그대로)
    }
}

변형 5: 값을 꺼내 판단 vs 객체에게 시키기 (Tell, Don't Ask)

getter로 잔액·한도를 전부 꺼내서 호출부가 판단하면, 같은 규칙이 호출부마다 복제되고 하나라도 빠뜨리면 버그입니다. 규칙을 객체 안의 withdraw에 넣고 호출부는 결과만 받게 하면 규칙이 한 곳에 삽니다. getter가 있다고 잘못은 아니지만, 판단 로직이 getter 뒤에 따라붙기 시작하면 그 판단을 객체 안으로 옮길 신호입니다.

java
public class TellDontAsk {
    static class Account {
        private long balance;
        private final long dailyLimit;
        private long withdrawnToday;

        Account(long balance, long dailyLimit) {
            this.balance = balance;
            this.dailyLimit = dailyLimit;
        }

        // 스타일 A: 상태를 다 꺼내 준다 → 규칙이 호출부마다 복제됨
        long getBalance() { return balance; }
        long getDailyLimit() { return dailyLimit; }
        long getWithdrawnToday() { return withdrawnToday; }

        // 스타일 B: 규칙을 객체 안에 둔다 → 호출부는 결과만 받음
        boolean withdraw(long amount) {
            if (amount <= 0 || amount > balance || withdrawnToday + amount > dailyLimit) return false;
            balance -= amount;
            withdrawnToday += amount;
            return true;
        }
    }

    public static void main(String[] args) {
        Account acc = new Account(100_000, 50_000);

        // A: 호출부가 규칙을 알아야 한다 (한도 검사를 빼먹기 쉽다)
        long req = 30_000;
        if (req <= acc.getBalance() && acc.getWithdrawnToday() + req <= acc.getDailyLimit()) {
            System.out.println("A: 출금 가능");                // 출력: A: 출금 가능
        }

        // B: 객체에게 시킨다
        System.out.println("B: " + acc.withdraw(30_000));   // 출력: B: true
        System.out.println("B: " + acc.withdraw(30_000));   // 출력: B: false  (일일 한도 초과)
        System.out.println(acc.getBalance());               // 출력: 70000
    }
}
응용 변형 예제
  • 변형 1: setter 검증 → 불변 객체 + with 메서드
  • 변형 2: 생성자를 숨기고 정적 팩토리로 열기 — Won
  • 변형 3: 주문 상태 전이를 메서드로만 허용하기
  • 변형 4: 컬렉션 필드 노출 — 복사본 vs 읽기 전용 뷰
  • 변형 5: 값을 꺼내 판단 vs 객체에게 시키기 (Tell, Don't Ask)
이전 섹션3 코드 예제4 / 7다음 섹션5 자주 하는 실수 (Tip)