콘텐츠로 이동

캡슐화 (Encapsulation)

왜 쓰는지

객체의 내부 상태에 직접 접근하면 예측 불가능한 문제가 발생합니다: - 나이가 음수가 되거나 300살이 되는 경우 - 잔액이 실시간으로 바뀌어 추적 불가 - 한 곳에서 계산하는 규칙이 여러 곳에서 달라짐

객체의 상태를 메서드를 통해서만 변경하면 이런 문제를 방지할 수 있습니다.

핵심: 캡슐화는 객체의 내부 상태(필드)와 구현을 외부로부터 숨기고, 공개 인터페이스(메서드)를 통해서만 접근하게 합니다.

어떻게 쓰는지

접근 제어자 (Access Modifiers)

public class User {
    // 1️⃣ private: 클래스 내부에서만 접근
    private String password;
    private int age;

    // 2️⃣ 공개 메서드: 외부에서 사용
    public void setPassword(String newPassword) {
        if (newPassword == null || newPassword.length() < 8) {
            throw new IllegalArgumentException("비밀번호는 8자 이상이어야 합니다");
        }
        this.password = newPassword;
    }

    public String getPassword() {
        return "****";  // 보안: 실제 비밀번호 노출 안 함
    }

    public void setAge(int age) {
        if (age < 0 || age > 150) {
            throw new IllegalArgumentException("나이는 0~150 사이여야 합니다");
        }
        this.age = age;
    }

    public int getAge() {
        return age;
    }
}

// 사용
User user = new User();
user.setAge(25);           // ✅ OK
user.setAge(-5);           // ❌ IllegalArgumentException
user.age = -5;             // ❌ 컴파일 에러 (private)

접근 제어자 범위

public class AccessExample {
    public int publicField;           // 어디서나 접근 가능
    protected int protectedField;     // 패키지 + 상속 클래스에서 접근
    int packageField;                 // 같은 패키지에서만 접근 (default)
    private int privateField;         // 클래스 내부에서만 접근
}

Getter/Setter 패턴

public class Product {
    private String name;
    private int price;
    private int stock;

    // Getter
    public String getName() {
        return name;
    }

    public int getPrice() {
        return price;
    }

    public int getStock() {
        return stock;
    }

    // Setter
    public void setName(String name) {
        if (name == null || name.trim().isEmpty()) {
            throw new IllegalArgumentException("상품명은 필수입니다");
        }
        this.name = name;
    }

    public void setPrice(int price) {
        if (price < 0) {
            throw new IllegalArgumentException("가격은 0 이상이어야 합니다");
        }
        this.price = price;
    }

    public void setStock(int stock) {
        if (stock < 0) {
            throw new IllegalArgumentException("재고는 0 이상이어야 합니다");
        }
        this.stock = stock;
    }
}

불변 객체 (Immutable Object)

public final class Money {
    private final int amount;  // 한 번 생성되면 변경 불가
    private final String currency;

    public Money(int amount, String currency) {
        if (amount < 0) {
            throw new IllegalArgumentException("금액은 음수일 수 없습니다");
        }
        this.amount = amount;
        this.currency = currency;
    }

    public int getAmount() {
        return amount;
    }

    public String getCurrency() {
        return currency;
    }

    // Setter 없음 - 변경 불가능하므로 안전
}

// 사용
Money money = new Money(10000, "KRW");
// money.setAmount(20000);  // ❌ 메서드 없음 (변경 불가)

언제 쓰는지

상황 선택 이유
상태 검증 필요 ✅ 캡슐화 잘못된 값 방지
내부 구현 변경 가능성 ✅ 캡슐화 인터페이스 유지, 내부만 변경
데이터 보안 ✅ 캡슐화 private으로 접근 제한
단순 데이터 저장 선택적 접근 제어 최소화

장점

장점 설명
데이터 무결성 검증 로직으로 잘못된 상태 방지
유지보수 용이 내부 구현 변경이 외부 영향 없음
보안 private으로 민감한 정보 보호
유연성 getter/setter 구현 방식 변경 가능

단점

단점 설명
보일러플레이트 getter/setter 작성 번거로움
복잡성 검증 로직으로 코드 증가
성능 메서드 호출 오버헤드 (미미)

특징

1. 캡슐화 vs 정보 은닉

// ❌ 캡슐화 없음: public 필드
public class BankAccount {
    public int balance;  // 직접 수정 가능
}

BankAccount account = new BankAccount();
account.balance = -10000;  // 음수 잔액 가능 (문제!)

// ✅ 캡슐화: private 필드 + 메서드
public class BankAccount {
    private int balance;

    public void withdraw(int amount) {
        if (balance - amount < 0) {
            throw new IllegalArgumentException("잔액 부족");
        }
        balance -= amount;
    }
}

BankAccount account = new BankAccount();
account.withdraw(10000);  // 검증됨
// account.balance = -10000;  // ❌ 컴파일 에러

2. 읽기 전용 (Read-only) vs 읽기/쓰기

public class Configuration {
    private String apiKey;
    private String environment;

    // 읽기 전용: setter 없음
    public String getApiKey() {
        return apiKey;
    }

    // 읽기/쓰기: setter 있음
    public void setEnvironment(String environment) {
        if (!environment.equals("dev") && !environment.equals("prod")) {
            throw new IllegalArgumentException("Invalid environment");
        }
        this.environment = environment;
    }

    public String getEnvironment() {
        return environment;
    }
}

3. 지연 초기화 (Lazy Initialization)

public class DataLoader {
    private List<Data> data;  // null로 시작

    // getter: 필요할 때만 로드
    public List<Data> getData() {
        if (data == null) {
            data = loadDataFromDatabase();  // 첫 호출 시만 로드
        }
        return data;
    }

    private List<Data> loadDataFromDatabase() {
        // 비용이 많이 드는 작업
        return new ArrayList<>();
    }
}

// 사용
DataLoader loader = new DataLoader();
List<Data> data = loader.getData();  // 첫 호출 시 로드
List<Data> data2 = loader.getData();  // 캐시된 데이터 반환

4. Java Bean 규칙

// JavaBean 표준: getter/setter 규칙
public class Person {
    private String firstName;
    private String lastName;
    private int age;

    // getter: get + 필드명 (첫 글자 대문자)
    public String getFirstName() { return firstName; }
    public String getLastName() { return lastName; }
    public int getAge() { return age; }

    // setter: set + 필드명
    public void setFirstName(String firstName) { this.firstName = firstName; }
    public void setLastName(String lastName) { this.lastName = lastName; }
    public void setAge(int age) { this.age = age; }
}

// 이 규칙을 따르면 프레임워크(Spring, Jackson)가 자동으로 처리 가능

주의할 점

❌ 모든 필드를 public으로 노출

public class User {
    public String password;  // ❌ 보안 위험
    public int age;          // ❌ 검증 불가
}

user.password = "";  // 빈 비밀번호
user.age = -10;      // 음수 나이

✅ private + 검증 메서드:

public class User {
    private String password;
    private int age;

    public void setPassword(String password) {
        if (password == null || password.length() < 8) {
            throw new IllegalArgumentException("비밀번호는 8자 이상");
        }
        this.password = password;
    }
}

⚠️ Getter로 가변 객체 반환

public class Team {
    private List<String> members = new ArrayList<>();

    // ❌ 위험: 외부에서 list를 직접 수정 가능
    public List<String> getMembers() {
        return members;  // 직접 반환
    }
}

Team team = new Team();
team.getMembers().add("Alice");  // 캡슐화 무시
team.getMembers().clear();       // 모든 멤버 삭제

✅ 복사본 또는 읽기 전용으로:

public List<String> getMembers() {
    return new ArrayList<>(members);  // 복사본 반환
}

// 또는
public List<String> getMembers() {
    return Collections.unmodifiableList(members);  // 읽기 전용
}

💡 Lombok으로 보일러플레이트 제거

// 수동
public class User {
    private String name;
    private int age;

    public String getName() { return name; }
    public void setName(String name) { this.name = name; }
    public int getAge() { return age; }
    public void setAge(int age) { this.age = age; }
}

// Lombok 사용
@Getter @Setter
public class User {
    private String name;
    private int age;
}

베스트 프랙티스

권장 방식 이유
필드는 기본적으로 private 외부에서 객체 상태를 직접 깨뜨리지 못하게 함
Setter는 필요한 경우에만 제공 모든 필드에 setter를 열면 사실상 public 필드와 크게 다르지 않음
의미 있는 행동 메서드 사용 setBalance()보다 deposit(), withdraw()가 규칙을 담기 좋음
생성자에서 필수값 검증 객체가 처음부터 유효한 상태로 생성됨
가변 컬렉션은 복사본 또는 읽기 전용 반환 외부 코드가 내부 리스트를 직접 수정하지 못하게 함
값 객체는 불변으로 설계 금액, 기간, 좌표처럼 값 자체가 의미인 객체는 변경보다 새 객체 생성이 안전함

Lombok의 @Getter, @Setter가 곧 캡슐화는 아닙니다.

보일러플레이트를 줄이는 도구일 뿐이고, 어떤 값을 외부에 공개할지와 어떤 상태 변경을 허용할지는 직접 설계해야 합니다.

실무에서는?

위치 캡슐화 기준
Entity/Domain 필드는 숨기고 changeName(), cancel(), pay()처럼 규칙이 담긴 메서드 제공
DTO 단순 전달 목적이면 getter/setter나 record를 사용해도 괜찮음
Value Object final 필드와 검증 생성자로 불변 객체를 우선 고려
비밀번호/API Key 원문 getter를 만들지 않고 마스킹, 검증, 변경 메서드만 제공
컬렉션 필드 Collections.unmodifiableList() 또는 복사본으로 외부 변경 차단
JPA Entity 프레임워크용 기본 생성자는 열어두되, 상태 변경 메서드는 도메인 의도를 드러내게 작성

정리

항목 설명
캡슐화 필드는 private, 메서드는 public
Getter/Setter 필드 접근을 메서드로 제어
검증 메서드에서 조건 확인
불변성 final + 초기화만으로 안전성 확보
접근 제어자 public, protected, (default), private

관련 파일: - OOP 개요 — 객체지향 4원칙 흐름 - 추상화 — 역할 정의 - 상속 — protected 접근 제어자 - 불변객체 — 캡슐화의 극단