캡슐화 (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 + 검증 메서드:
⚠️ 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(); // 모든 멤버 삭제
✅ 복사본 또는 읽기 전용으로:
💡 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 접근 제어자 - 불변객체 — 캡슐화의 극단