private 필드에 public setter를 붙이면 캡슐화가 아니다 — 클래스 설계의 함정

많은 Java 튜토리얼이 "캡슐화 = 필드를 private으로 하고 getter/setter를 만드는 것"이라고 가르친다. 이 설명은 틀렸다.

생각해 보자: private int balance;public void setBalance(int b) { this.balance = b; }를 붙였다. 외부에서 balance = -1000000을 직접 못 할 뿐, setBalance(-1000000)으로 똑같이 음수 잔액을 만들 수 있다. 비밀번호를 자물쇠로 걸어두고 열쇠를 현관에 두는 것과 같다.

진짜 캡슐화는 "객체가 자신의 상태를 스스로 보호하는 것"이다. 이 글은 클래스와 객체의 생성, 접근 제어, 생성자 설계를 다루며 — 왜 setter 없는 설계가 더 강력한지를 보여준다.

클래스와 객체 — 설계도와 인스턴스

클래스(class)는 객체의 설계도다. 필드(상태)와 메서드(동작)를 정의한다. 객체(object)는 그 설계도로 만들어진 실체(instance)다. (JLS §8.1)

// Java 25
public class BankAccount {
    // 필드 (상태)
    private String owner;
    private long balance;

    // 생성자
    public BankAccount(String owner, long initialBalance) {
        this.owner = owner;
        this.balance = initialBalance;
    }

    // 메서드 (동작) — 캡슐화된 상태를 안전하게 변경
    public void deposit(long amount) {
        if (amount <= 0) {
            throw new IllegalArgumentException("입금액은 양수여야 합니다: " + amount);
        }
        balance += amount;
    }

    public void withdraw(long amount) {
        if (amount <= 0) {
            throw new IllegalArgumentException("출금액은 양수여야 합니다: " + amount);
        }
        if (amount > balance) {
            throw new IllegalStateException("잔액 부족: 현재 " + balance + "원");
        }
        balance -= amount;
    }

    public long getBalance() {
        return balance;
    }
}

이 클래스가 "getter/setter 자동 생성"과 다른 점은 — balance를 직접 수정할 방법(setBalance())이 없다. 잔액 변경은 반드시 deposit()이나 withdraw()를 거쳐야 하며, 이 메서드들은 비즈니스 규칙(양수 검사, 잔액 확인)을 강제한다. 이것이 캡슐화다.

왜 캡슐화가 중요한가 — 내부 메커니즘: 캡슐화는 "객체의 상태를 직접 바꾸지 못하게 하고, 반드시 메서드를 통해서만 바꾸게 하는 것"이다. balanceprivate이므로 외부에서 account.balance = -1000을 시도하면 컴파일 에러가 난다. 이 에러가 런타임 에러보다 백배 낫다 — 컴파일 에러는 빌드할 때 즉시 발견되지만, 런타임 에러는 사용자가 결제할 때 발견된다.

비유: 캡슐화는 은행 창구다. 고객이 금고에 직접 들어가서 돈을 빼가면 안 된다 — 반드시 창구원(메서드)을 통해 "5만 원 출금합니다"라고 요청해야 한다. 창구원은 잔액을 확인하고 규칙을 검사한 후 출금한다. 금고(balance)를 private으로 잠그고, 창구원(deposit/withdraw)만 public으로 열어둔다.

this — 자기 참조

this는 현재 객체 자신을 가리키는 참조다. (JLS §15.8.3)

// Java 25
public class Person {
    private String name;

    public Person(String name) {
        this.name = name;   // this.name = 필드, name = 매개변수
    }

    // this로 같은 클래스의 다른 생성자 호출 (생성자 체이닝)
    public Person() {
        this("Unknown");   // Person("Unknown") 호출 — 첫 번째 줄이어야 함
    }
}

생성자에서 this(...)로 다른 생성자를 호출할 때, 반드시 생성자의 첫 번째 줄이어야 한다. (JLS §8.8.7.1)

접근 제어자 — 4단계 보호

Java는 4단계의 접근 제어자를 제공한다. (JLS §6.6)

접근 제어자 같은 클래스 같은 패키지 하위 클래스 전체
public O O O O
protected O O O* X
(package-private, 생략) O O X X
private O X X X

protected는 같은 패키지에서도 접근 가능하다. 하위 클래스가 다른 패키지에 있어도 접근 가능하지만, "같은 패키지" 접근 권한이 더 넓다는 점을 간과하기 쉽다. protected를 "상속 전용"으로 생각하면 함정이다.

접근 제어의 원칙 — 최소 노출

필드는 기본적으로 private으로 한다. 메서드는 외부에서 호출해야 하는 것만 public으로 한다. 내부 구현 메서드는 private으로 숨긴다. 이 원칙을 최소 노출 원칙(principle of least privilege)이라 한다.

// Java 25 — 접근 제어 설계 예
public class TemperatureSensor {
    private double currentTemp;    // private: 외부 직접 접근 차단
    private double offset;         // private: 보정값

    public double read() {         // public: 외부 인터페이스
        return currentTemp + calibrate();
    }

    private double calibrate() {   // private: 내부 구현
        return offset * 0.95;
    }
}

패키지 — 네임스페이스와 접근 단위

패키지는 관련 클래스를 묶는 네임스페이스다. (JLS §7)

// Java 25
package com.example.banking;

public class Account { ... }
  • 패키지명은 보통 역도메인(com.example.project) 형식을 따른다.
  • 같은 패키지의 클래스는 import 없이 사용 가능.
  • package-private(접근 제어자 생략) 멤버는 같은 패키지에서만 접근 가능.

static — 인스턴스가 아닌 클래스에 속하는 것

static 키워드는 필드나 메서드가 인스턴스가 아닌 클래스에 속한다는 것을 나타낸다. (JLS §8.3.1.1, §8.4.3.2)

// Java 25
public class Counter {
    private static int total = 0;   // 모든 인스턴스가 공유
    private int count;              // 인스턴스마다 별도

    public Counter() {
        count = 0;
        total++;
    }

    public void increment() {
        count++;
    }

    public static int getTotal() {   // 인스턴스 없이 호출 가능: Counter.getTotal()
        return total;
    }
}
// Java 25
Counter c1 = new Counter();
Counter c2 = new Counter();
c1.increment();
c1.increment();
c2.increment();
System.out.println(c1.count);         // 2 (인스턴스별)
System.out.println(c2.count);         // 1
System.out.println(Counter.getTotal()); // 2 (생성된 인스턴스 수)

static 사용 시 주의

// Java 25 — 위험: static 필드와 인스턴스 메서드의 관계
public class BadExample {
    static int shared = 0;
    int unique = 0;

    // static 메서드에서는 인스턴스 필드에 접근 불가
    public static void method() {
        // System.out.println(unique);  // 컴파일 에러: non-static 필드 접근
        System.out.println(shared);      // OK: static 필드 접근
    }
}

static 필드는 클래스당 하나만 존재하므로, 동시성 환경에서 경쟁 조건(race condition)이 발생하기 쉽다. mutable static 필드는 가급적 피한다. 상수(static final)만 static으로 두는 것이 안전하다.

final — 세 가지 의미

final 키워드는 위치에 따라 의미가 다르다. (JLS §4.12.4, §8.1.1.2, §8.3.1.2)

위치 의미
final 변수 재할당 불가 (값 고정). 단, 객체의 내부 상태는 변경 가능
final 필드 한 번만 할당 가능 (생성자에서). 불변성의 기초
final 메서드 하위 클래스에서 override 불가
final 클래스 상속 불가 (예: String, Integer)
// Java 25 — final의 세 가지
final int x = 42;
// x = 99;   // 컴파일 에러: final 변수 재할당 불가

final StringBuilder sb = new StringBuilder("Hello");
sb.append(" World");   // OK: 객체 내부는 변경 가능 (참조가 final일 뿐)
// sb = new StringBuilder("New");   // 컴파일 에러: 참조 자체는 변경 불가

final 필드는 참조를 고정할 뿐, 객체의 내용을 불변으로 만들지 않는다. final List<String> list = new ArrayList<>();에서 list.add("x")가 가능한 이유다. 완전한 불변 객체를 만들려면 record나 수동 설계가 필요하다.

record — 불변 데이터 객체의 한 줄 표현 (Java 16+, JEP-395)

데이터만 담는 불변 객체를 간결하게 정의할 수 있다. (JEP-395)

// Java 25 — 전통적 불변 클래스 (장황함)
public final class Point {
    private final double x;
    private final double y;

    public Point(double x, double y) {
        this.x = x;
        this.y = y;
    }
    public double x() { return x; }
    public double y() { return y; }
    @Override
    public boolean equals(Object o) { ... }
    @Override
    public int hashCode() { ... }
    @Override
    public String toString() { ... }
}

// Java 25 — record (한 줄)
public record Point(double x, double y) {}

record는 컴파일러가 자동으로 생성자, 접근자(x(), y()), equals(), hashCode(), toString()을 만들어 준다. 자세한 내용은 08-record-sealed-enum.md에서 풀어간다.

생성자 — 객체의 초기 상태를 보장하는 계약

생성자는 객체가 사용 가능한 상태로 생성되도록 보장한다. (JLS §8.8)

// Java 25 — 생성자로 불변식(invariant) 보장
public class EmailAddress {
    private final String address;

    public EmailAddress(String address) {
        if (address == null || !address.contains("@")) {
            throw new IllegalArgumentException("유효하지 않은 이메일: " + address);
        }
        this.address = address;
    }

    public String getAddress() {
        return address;
    }
}

이 클래스의 객체는 @가 포함된 유효한 이메일 주소만 가질 수 있다 — 생성자에서 검증하기 때문이다. 이것이 생성자의 핵심 역할: "이 객체가 존재한다면, 이 객체는 유효하다"라는 계약을 보장하는 것.

생성자에서 public 메서드를 호출하면 안 된다 — 하위 클래스가 그 메서드를 override했을 때, 아직 초기화되지 않은 필드에 접근할 수 있다. 생성자에서는 private이나 final 메서드만 호출한다. (JLS §12.5)

실습 — 캡슐화 vs getter/setter 자동 생성

// Java 25 — EncapsulationDemo.java
public class EncapsulationDemo {
    // 잘못된 캡슐화: setter가 내부 규칙을 우회
    static class BadAccount {
        private long balance;
        public long getBalance() { return balance; }
        public void setBalance(long balance) { this.balance = balance; }
        // 누구나 balance를 음수로 만들 수 있음 → 비즈니스 규칙 없음
    }

    // 올바른 캡슐화: 상태 변경은 메서드를 통해서만, 규칙 강제
    static class GoodAccount {
        private long balance;
        public GoodAccount(long initial) {
            if (initial < 0) throw new IllegalArgumentException("초기 잔액은 음수 불가");
            this.balance = initial;
        }
        public long getBalance() { return balance; }
        // setBalance() 없음 → 외부에서 직접 수정 불가
        public void transfer(GoodAccount target, long amount) {
            if (amount <= 0) throw new IllegalArgumentException("이체액은 양수");
            if (amount > balance) throw new IllegalStateException("잔액 부족");
            this.balance -= amount;
            target.balance += amount;
        }
    }

    public static void main(String[] args) {
        BadAccount bad = new BadAccount();
        bad.setBalance(-1000000);   // "성공" — 비즈니스 규칙 위반!
        System.out.println("Bad balance: " + bad.getBalance());

        GoodAccount g1 = new GoodAccount(10000);
        GoodAccount g2 = new GoodAccount(5000);
        g1.transfer(g2, 3000);
        System.out.println("g1: " + g1.getBalance() + ", g2: " + g2.getBalance());
        // g1: 7000, g2: 8000

        try {
            g1.transfer(g2, -1000);   // 예외 발생
        } catch (IllegalArgumentException e) {
            System.out.println("거부: " + e.getMessage());
        }
    }
}
java EncapsulationDemo.java
Bad balance: -1000000
g1: 7000, g2: 8000
거부: 이체액은 양수

확인할 것: BadAccount는 비즈니스 규칙 없이 잔액이 음수가 되는 것을 허용한다. GoodAccountsetBalance()가 없고, 상태 변경은 transfer()를 통해서만 가능하며 규칙을 강제한다. 이것이 캡슐화의 본질이다.

요약 — 이 글의 결론

  • 캡슐화는 "private + getter/setter"가 아니다. 객체의 상태를 외부에서 직접 변경하지 못하게 하고, 변경은 비즈니스 규칙을 강제하는 메서드를 통해서만 허용하는 것이다. 무분별한 setter는 캡슐화를 깨뜨린다.
  • 접근 제어는 최소 노출 원칙을 따른다. 필드는 private, 공개 인터페이스만 public. 내부 구현은 숨긴다.
  • static은 클래스 수준, 인스턴스는 객체 수준. static 필드는 모든 인스턴스가 공유하므로 동시성에 주의한다. 상수(static final)만 static으로 두는 것이 안전하다.
  • final은 세 가지 의미를 갖는다. 변수(재할당 금지), 메서드(override 금지), 클래스(상속 금지). final 필드는 참조를 고정할 뿐 객체 내용까지 불변으로 만들지는 않는다.
  • 생성자는 불변식 계약이다. "이 객체가 존재한다 = 이 객체는 유효하다"를 보장해야 한다. 생성자에서 검증하지 않으면, 무효한 상태의 객체가 시스템 전체에 퍼진다.

생각해 볼 문제

  1. BankAccount 클래스에서 balance 필드를 public으로 변경하면 어떤 일이 벌어질 수 있는가? 구체적인 시나리오를 3가지 이상 생각해 보자.
  2. static 필드를 mutable로 유지해야 하는 상황은 언제인가? 그때 동시성을 어떻게 보장할 수 있는가?
  3. final List<String> list = new ArrayList<>();에서 list.add("x")가 가능한 이유를 설명하라. 완전히 불변인 리스트를 만들려면?
  4. record의 accessor 메서드가 getX()가 아니라 x()인 이유는 무엇인가? 이 설계가 JavaBeans 규약과 어떻게 다른가?
  5. 생성자에서 virtual method call(override 가능한 메서드 호출)이 위험한 이유를 구체적인 코드 예로 설명하라.

참고

'Develop Artifacts > Java' 카테고리의 다른 글

Java - 07. interface and abstract  (0) 2026.07.12
Java - 06. inheritance and polymorphism  (0) 2026.07.12
Java - 04. array and string  (0) 2026.07.12
Java - 03. operator control  (0) 2026.07.12
Java - 02. types  (0) 2026.07.10