7.5 캡슐화와 접근 제어자 (Encapsulation & Access Modifiers)
객체지향 프로그래밍에서 캡슐화(Encapsulation) 란 클래스의 데이터(변수)와 실제 데이터를 처리하는 메서드를 하나의 단위로 묶고, 중요한 내부 데이터를 외부에서 함부로 접근하지 못하도록 숨기는 것을 말합니다. 이를 정보 은닉(Information Hiding) 이라고도 부릅니다.
마치 감기약 캡슐이 쓰디쓴 약가루를 포장하여 환자가 약을 안전하고 편하게 넘길 수 있도록 돕는 것과 같은 원리입니다. 자바에서는 이를 접근 제어자(Access Modifiers) 로 제어합니다.
1. 접근 제어자의 종류
접근 제어자는 멤버(변수, 메서드)나 클래스 선언 시 사용되어, 해당 멤버에 접근할 수 있는 범위를 제한합니다. 보안 및 데이터 무결성 유지를 위해 필수적입니다. 자바의 접근 제어자 4가지는 다음과 같습니다 (접근 범위가 넓은 순서대로 나열):
| 제어자 명 | 접근 권한 설명 | 접근 범위 (가시성) |
|---|---|---|
public | 접근에 제한이 없습니다. 어디서든 사용 가능합니다. | 전체 접근 허용 |
protected | 같은 패키지 는 물론, 다른 패키지의 자식 클래스 에서도 접근 가능합니다. | 패키지 내 + 상속 관계 |
(default) | 아무런 키워드도 안 쓴 상태. 같은 패키지 내 에서만 접근 가능합니다. | 패키지 내 |
private | 오직 선언된 같은 클래스 내 에서만 접근 가능합니다! 가장 좁은 범위. | 클래스 내부 전용 |
접근 제어자 범위 시각화
┌──────────────────────────────────────────┐
│ 같은 클래스 (private, default, protected, public)
│ ┌────────────────────────────────────┐ │
│ │ 같은 패키지 (default, protected, public) │ │
│ │ ┌──────────────────────────────┐ │ │
│ │ │ 상속 관계 (protected, public) │ │ │
│ │ │ ┌──────────────────────┐ │ │ │
│ │ │ │ 전체 (public) │ │ │ │
│ │ │ └──────────────────────┘ │ │ │
│ │ └──────────────────────────────┘ │ │
│ └────────────────────────────────────┘ │
└──────────────────────────────────────────┘
클래스의 상태값(멤버 변수)은 무조건 private으로 선언하여 외부의 직접 접근을 막습니다. 메서드는 외부에서 호출해야 하는 것만 public으로 열어둡니다.
접근 제어자 동작 예제
package com.example.shop;
public class Product {
public String productId; // 어디서든 접근 가능
protected String category; // 같은 패키지 + 자식 클래스
String name; // (default) 같은 패키지 내에서만
private double price; // 오직 이 클래스 내부에서만
public double getPrice() {
return price; // 내부에서는 private 필드 접근 가능
}
}
package com.example.shop;
public class ProductTest {
public static void main(String[] args) {
Product p = new Product();
p.productId = "P001"; // OK - public
p.category = "전자제품"; // OK - 같은 패키지
p.name = "노트북"; // OK - 같은 패키지 (default)
// p.price = 999.99; // 컴파일 에러! private
System.out.println(p.getPrice()); // OK - public 메서드로 접근
}
}
2. 캡슐화의 적용 - Getter와 Setter
그렇다면 변수가 private으로 막혀있을 때 외부 클래스에서는 이 데이터를 어떻게 읽거나 수정할까요? 바로 public으로 선언된 메서드 를 통해서만 간접적으로 접근하도록 만듭니다. 우리는 이런 메서드들을 Getter와 Setter라고 부릅니다.
왜 필드를 직접 접근하지 않는가?
// 나쁜 예 - 필드를 public으로 직접 노출
public class BadPerson {
public int age; // 외부에서 p.age = -500; 같은 코드가 가능해짐
}
// 좋은 예 - private 필드 + Getter/Setter로 통제
public class GoodPerson {
private int age;
public void setAge(int age) {
if (age < 0 || age > 150) {
throw new IllegalArgumentException("올바르지 않은 나이: " + age);
}
this.age = age;
}
public int getAge() {
return age;
}
}
유효성 검사가 포함된 완전한 Getter/Setter 예제
public class Person {
// 1. 모든 변수는 private로 철저히 숨깁니다.
private String name;
private int age;
private String email;
// 기본 생성자
public Person() {}
// 모든 필드를 초기화하는 생성자
public Person(String name, int age, String email) {
setName(name); // Setter를 통해 유효성 검사 포함
setAge(age);
setEmail(email);
}
// --- Setter (값 설정) ---
public void setName(String name) {
if (name == null || name.trim().isEmpty()) {
throw new IllegalArgumentException("이름은 비어있을 수 없습니다.");
}
this.name = name.trim();
}
public void setAge(int age) {
if (age < 0 || age > 150) {
throw new IllegalArgumentException("올바르지 않은 나이: " + age);
}
this.age = age;
}
public void setEmail(String email) {
if (email == null || !email.contains("@")) {
throw new IllegalArgumentException("올바르지 않은 이메일 형식: " + email);
}
this.email = email;
}
// --- Getter (값 조회) ---
public String getName() { return name; }
public int getAge() { return age; }
public String getEmail(){ return email; }
@Override
public String toString() {
return "Person{name='" + name + "', age=" + age + ", email='" + email + "'}";
}
}
public class PersonTest {
public static void main(String[] args) {
Person p = new Person("김철수", 25, "kim@example.com");
System.out.println(p); // Person{name='김철수', age=25, email='kim@example.com'}
// 유효성 검사 통과 실패
try {
p.setAge(-5);
} catch (IllegalArgumentException e) {
System.out.println("에러: " + e.getMessage()); // 에러: 올바르지 않은 나이: -5
}
// 정상 변경
p.setAge(30);
System.out.println("변경된 나이: " + p.getAge()); // 변경된 나이: 30
}
}
3. 불변 객체 (Immutable Object)
Setter를 아예 제공하지 않고 private final 필드만 두면, 한 번 생성 후에 절대 변경할 수 없는 불변 객체(Immutable Object) 를 만들 수 있습니다. 불변 객체는 멀티스레드 환경에서 안전하고, 버그 발생 가능성이 낮습니다.
public class ImmutablePoint {
private final double x; // final: 한 번만 초기화 가능
private final double y;
// 생성자에서만 값을 설정
public ImmutablePoint(double x, double y) {
this.x = x;
this.y = y;
}
// Getter만 제공 (Setter 없음)
public double getX() { return x; }
public double getY() { return y; }
// 새로운 좌표가 필요하면 새 객체를 반환
public ImmutablePoint translate(double dx, double dy) {
return new ImmutablePoint(x + dx, y + dy);
}
@Override
public String toString() {
return "(" + x + ", " + y + ")";
}
}
public class ImmutableTest {
public static void main(String[] args) {
ImmutablePoint p1 = new ImmutablePoint(3.0, 4.0);
System.out.println("원래 좌표: " + p1); // (3.0, 4.0)
// p1.x = 10; // 컴파일 에러! final 필드는 변경 불가
// 이동하면 새 객체가 반환됨, 원본은 그대로
ImmutablePoint p2 = p1.translate(2.0, 1.0);
System.out.println("이동 후 p1: " + p1); // (3.0, 4.0) - 변하지 않음
System.out.println("이동 후 p2: " + p2); // (5.0, 5.0)
}
}
자바 표준 라이브러리에서 String, Integer, LocalDate 등이 모두 불변 객체입니다. 이 클래스들은 한 번 생성된 값을 절대 바꾸지 않고, 변환이 필요할 때 항상 새 객체를 반환합니다.
4. Java Record (Java 16+)
Java 16부터 도입된 record 는 불변 데이터 클래스를 극도로 간결하게 작성할 수 있게 해주는 문법입니다. 컴파일러가 자동으로 private final 필드, 생성자, Getter, equals(), hashCode(), toString()을 모두 생성해 줍니다.
// record 선언 - 딱 한 줄!
public record Point(double x, double y) {}
// 위 record는 아래 클래스와 동일한 기능을 합니다:
// - private final double x;
// - private final double y;
// - 모든 필드를 받는 생성자
// - getX() 대신 x(), getY() 대신 y() 메서드 (record accessor)
// - equals(), hashCode(), toString() 자동 구현
public record Person(String name, int age, String email) {
// 컴팩트 생성자: 유효성 검사를 추가할 수 있습니다
public Person {
if (name == null || name.isBlank()) {
throw new IllegalArgumentException("이름은 비어있을 수 없습니다.");
}
if (age < 0 || age > 150) {
throw new IllegalArgumentException("올바르지 않은 나이: " + age);
}
}
// 추가 메서드도 정의 가능
public boolean isAdult() {
return age >= 18;
}
}
public class RecordTest {
public static void main(String[] args) {
Person p = new Person("이영희", 22, "lee@example.com");
// record accessor 메서드 (get 접두사 없음)
System.out.println(p.name()); // 이영희
System.out.println(p.age()); // 22
System.out.println(p.email()); // lee@example.com
System.out.println(p.isAdult()); // true
// toString() 자동 생성
System.out.println(p); // Person[name=이영희, age=22, email=lee@example.com]
// equals() 자동 생성 (내용 비교)
Person p2 = new Person("이영희", 22, "lee@example.com");
System.out.println(p.equals(p2)); // true
// record는 불변이라 수정 불가
// p.name = "다른이름"; // 컴파일 에러!
}
}
- 단순히 데이터를 담는 DTO(Data Transfer Object) 용도라면
record를 강력히 추천합니다. - 값을 변경해야 하거나 복잡한 로직이 필요하면 일반 클래스를 사용합니다.
5. 패키지(Package)와 접근 제어 실전
패키지는 관련된 클래스들을 모아두는 폴더 개념입니다. 접근 제어자는 패키지 경계와 밀접하게 연관됩니다.
com.example
├── shop
│ ├── Product.java (public class)
│ └── ProductManager.java (같은 패키지 → default 멤버 접근 가능)
└── customer
└── Cart.java (다른 패키지 → public만 접근 가능)
package com.example.shop;
public class Product {
private double price; // 어디서도 직접 접근 불가
double stockCount; // (default) shop 패키지 내에서만
protected String supplierId; // shop 패키지 + Product를 상속한 클래스
public String productId; // 어디서든 접근 가능
public double getPrice() { return price; }
public void setPrice(double price) {
if (price < 0) throw new IllegalArgumentException("가격은 음수일 수 없습니다.");
this.price = price;
}
}
package com.example.shop;
// 같은 패키지 - default, protected 모두 접근 가능
public class ProductManager {
public void manage(Product p) {
System.out.println(p.productId); // OK - public
System.out.println(p.supplierId); // OK - protected (같은 패키지)
System.out.println(p.stockCount); // OK - default (같은 패키지)
System.out.println(p.getPrice()); // OK - public 메서드
// p.price; // 컴파일 에러! private
}
}
package com.example.customer;
import com.example.shop.Product;
// 다른 패키지 - public만 접근 가능
public class Cart {
public void addItem(Product p) {
System.out.println(p.productId); // OK - public
System.out.println(p.getPrice()); // OK - public 메서드
// p.supplierId; // 컴파일 에러! protected (다른 패키지, 상속 관계 아님)
// p.stockCount; // 컴파일 에러! default (다른 패키지)
}
}
6. 실전 예제: BankAccount 클래스
은행 계좌를 캡슐화로 안전하게 구현해 봅니다. 잔액(balance)은 절대 외부에서 직접 수정할 수 없고, 반드시 입금/출금 메서드를 통해서만 변경됩니다.
public class BankAccount {
private final String accountNumber; // 계좌번호 - 절대 변경 불가
private final String owner; // 계좌주 - 절대 변경 불가
private double balance; // 잔액 - private으로 보호
private int transactionCount; // 거래 횟수
public BankAccount(String accountNumber, String owner, double initialBalance) {
if (initialBalance < 0) {
throw new IllegalArgumentException("초기 잔액은 0 이상이어야 합니다.");
}
this.accountNumber = accountNumber;
this.owner = owner;
this.balance = initialBalance;
this.transactionCount = 0;
}
// 입금
public void deposit(double amount) {
if (amount <= 0) {
throw new IllegalArgumentException("입금액은 0보다 커야 합니다.");
}
balance += amount;
transactionCount++;
System.out.printf("[입금] %,.0f원 → 잔액: %,.0f원%n", amount, balance);
}
// 출금
public void withdraw(double amount) {
if (amount <= 0) {
throw new IllegalArgumentException("출금액은 0보다 커야 합니다.");
}
if (amount > balance) {
throw new IllegalStateException(
String.format("잔액 부족. 요청: %,.0f원, 현재 잔액: %,.0f원", amount, balance)
);
}
balance -= amount;
transactionCount++;
System.out.printf("[출금] %,.0f원 → 잔액: %,.0f원%n", amount, balance);
}
// 계좌 이체
public void transfer(BankAccount target, double amount) {
this.withdraw(amount); // 내 계좌에서 출금
target.deposit(amount); // 상대 계좌에 입금
System.out.printf("[이체] %s → %s, %,.0f원%n",
this.owner, target.owner, amount);
}
// Getter - 조회는 허용
public String getAccountNumber() { return accountNumber; }
public String getOwner() { return owner; }
public double getBalance() { return balance; }
public int getTransactionCount() { return transactionCount; }
// Setter 없음 - accountNumber, owner는 변경 불가
// balance는 deposit/withdraw 메서드로만 변경 가능
@Override
public String toString() {
return String.format("계좌[%s] 소유자: %s, 잔액: %,.0f원",
accountNumber, owner, balance);
}
}
public class BankAccountTest {
public static void main(String[] args) {
BankAccount alice = new BankAccount("110-1234-5678", "앨리스", 100_000);
BankAccount bob = new BankAccount("220-8765-4321", "밥", 50_000);
System.out.println(alice);
System.out.println(bob);
System.out.println();
alice.deposit(30_000); // [입금] 30,000원 → 잔액: 130,000원
alice.withdraw(20_000); // [출금] 20,000원 → 잔액: 110,000원
alice.transfer(bob, 40_000); // 이체 실행
System.out.println();
System.out.println(alice);
System.out.println(bob);
System.out.println("앨리스 거래 횟수: " + alice.getTransactionCount());
// 잔액 부족 시도
System.out.println();
try {
alice.withdraw(500_000);
} catch (IllegalStateException e) {
System.out.println("에러: " + e.getMessage());
}
// 직접 잔액 변경 시도 (불가!)
// alice.balance = 999_999; // 컴파일 에러! private 필드
}
}
[실행 결과]
계좌[110-1234-5678] 소유자: 앨리스, 잔액: 100,000원
계좌[220-8765-4321] 소유자: 밥, 잔액: 50,000원
[입금] 30,000원 → 잔액: 130,000원
[출금] 20,000원 → 잔액: 110,000원
[출금] 40,000원 → 잔액: 70,000원
[입금] 40,000원 → 잔액: 90,000원
[이체] 앨리스 → 밥, 40,000원
계좌[110-1234-5678] 소유자: 앨리스, 잔액: 70,000원
계좌[220-8765-4321] 소유자: 밥, 잔액: 90,000원
앨리스 거래 횟수: 3
에러: 잔액 부족. 요청: 500,000원, 현재 잔액: 70,000원
7. 캡슐화의 실제 이점
캡슐화는 단순히 필드를 숨기는 것을 넘어, 소프트웨어의 품질을 높이는 핵심 원칙입니다.
유지보수성 향상
// 내부 구현을 바꿔도 외부 코드는 변경 없음
public class Temperature {
private double celsius; // 내부에서는 섭씨로 저장
public void setFahrenheit(double f) {
this.celsius = (f - 32) * 5 / 9; // 변환 로직이 내부에 숨겨짐
}
public double getCelsius() {
return celsius;
}
public double getFahrenheit() {
return celsius * 9 / 5 + 32; // 필요 시 변환해서 반환
}
}
변경 용이성
내일 저장 방식을 섭씨에서 켈빈으로 바꾸더라도, Temperature 클래스 내부만 수정하면 됩니다. 이 클래스를 사용하는 수백 개의 외부 코드는 전혀 수정할 필요가 없습니다.
데이터 무결성
public class Percentage {
private int value; // 0 ~ 100 사이만 유효
public void setValue(int value) {
if (value < 0 || value > 100) {
throw new IllegalArgumentException("퍼센트는 0~100 사이여야 합니다: " + value);
}
this.value = value;
}
public int getValue() { return value; }
}
마치며
캡슐화는 객체지향의 4대 핵심 원칙(캡슐화, 상속, 다형성, 추상화) 중 하나입니다.
private필드 +public메서드: 항상 이 구조를 기본으로 삼습니다.- Getter/Setter: 단순히 필드를 래핑하는 것이 아니라 유효성 검사와 비즈니스 로직을 담는 창구입니다.
- 불변 객체: Setter 없이
final필드만 두면 스레드 안전성과 예측 가능성이 높아집니다. - Java Record: Java 16+에서 불변 데이터 클래스를 간결하게 선언할 수 있습니다.
이 개념들은 앞으로 배울 Spring Boot의 @Service, @Repository 등 레이어 구조와 디자인 패턴(Builder, Factory 등)의 기반이 됩니다.