Java 접근제어자를 제대로 이해하지 못하면, 모든 필드와 메서드를 public으로 선언해 객체 내부 상태가 외부에서 무분별하게 바뀌거나, protected를 써야 할 자리에 public을 쓰다가 패키지 밖의 클래스가 내부 구현에 직접 의존하게 되어 리팩토링이 불가능해지는 상황을 겪게 됩니다. “어차피 같은 프로젝트 코드인데 다 public으로 해도 되지 않나요?”라는 생각은 객체지향의 핵심 원칙인 캡슐화(Encapsulation)를 포기하는 것과 같습니다. 접근제어자는 단순히 “누가 접근할 수 있는가”를 넘어 “어떤 인터페이스를 외부에 노출하고, 어떤 구현을 숨길 것인가”를 결정하는 설계 도구입니다. 이 글에서는 4가지 접근제어자의 정확한 범위, 클래스·필드·메서드·생성자에 적용하는 방법, 상속 시 규칙, 패키지 구조와의 관계, 그리고 실무에서 올바르게 설계하는 원칙까지 예제 코드와 함께 완벽하게 정리합니다.
목차
- 접근제어자란? 캡슐화와의 관계 기초 개념
- 4가지 접근제어자 범위 완전 정복
- 클래스·필드·메서드·생성자별 적용 규칙
- 상속과 접근제어자 – 오버라이딩 규칙과 설계 패턴
- 패키지 구조와 접근제어자 실무 설계
- 전문가 관점 – 실무 설계 원칙·면접 핵심 정리·안티패턴
1. 접근제어자란? 캡슐화와의 관계 기초 개념
접근제어자가 없다면 어떤 일이 생기는가
접근제어자가 없는 세상을 상상해 봅시다. 모든 필드와 메서드가 어디서든 접근 가능하다면 어떤 문제가 생길까요?
java
// ❌ 접근제어자 없이 모든 것이 공개된 클래스 (C 구조체 스타일)
class BankAccount {
String owner;
double balance; // 잔액이 외부에서 직접 변경 가능!
String password; // 비밀번호가 외부에서 읽힘!
void transfer(BankAccount target, double amount) {
balance -= amount;
target.balance += amount;
}
}
// 외부 코드에서 일어나는 일들
BankAccount account = new BankAccount();
account.balance = 1000000000; // 잔액을 10억으로 직접 변경!
account.password = "0000"; // 비밀번호 직접 변경!
account.owner = "사기꾼"; // 소유자 이름 직접 변경!
// 문제:
// 1. 데이터 무결성 보장 불가 (유효성 검사 우회)
// 2. 내부 구현이 외부에 노출 → 리팩토링 시 모든 의존 코드 수정 필요
// 3. 의도하지 않은 접근으로 인한 버그 발생
캡슐화 – 접근제어자의 존재 이유
**캡슐화(Encapsulation)**는 객체지향의 4대 원칙 중 하나로, 객체의 내부 상태(데이터)와 구현을 외부로부터 숨기고 정해진 인터페이스(메서드)를 통해서만 접근하도록 하는 원칙입니다.
[캡슐화의 비유 – 자판기]
자판기 사용자:
✅ 버튼 누르기 (public 메서드)
✅ 음료 받기 (public 메서드)
✅ 잔액 확인 (public 메서드)
❌ 내부 재고 직접 변경 (private 필드)
❌ 내부 회로 직접 접근 (private 메서드)
❌ 내부 금고 열기 (private 필드)
→ 사용자는 버튼(인터페이스)만 알면 됨
→ 내부 구현이 바뀌어도 사용자 코드 변경 불필요
→ 잘못된 조작으로 인한 오류 방지
java
// ✅ 접근제어자로 캡슐화 구현
public class BankAccount {
private final String owner; // 외부 직접 변경 불가
private double balance; // 외부 직접 변경 불가
private String password; // 외부에서 읽기조차 불가
public BankAccount(String owner, String password, double initialBalance) {
this.owner = owner;
this.password = password;
this.balance = validateBalance(initialBalance); // 생성 시 검증
}
// 정해진 인터페이스를 통한 접근만 허용
public double getBalance() {
return balance; // 읽기만 허용
}
public void deposit(double amount) {
if (amount <= 0) throw new IllegalArgumentException("입금액은 0보다 커야 합니다");
this.balance += amount;
}
public void withdraw(double amount, String inputPassword) {
authenticate(inputPassword); // 인증 통과 후만 출금 가능
if (amount > balance) throw new InsufficientBalanceException();
this.balance -= amount;
}
// 외부에서 호출 불가, 내부에서만 사용
private void authenticate(String inputPassword) {
if (!this.password.equals(inputPassword)) {
throw new AuthenticationException("비밀번호가 틀렸습니다");
}
}
private double validateBalance(double amount) {
if (amount < 0) throw new IllegalArgumentException("초기 잔액은 0 이상이어야 합니다");
return amount;
}
}
4가지 접근제어자 한눈에 보기
Java는 4가지 접근제어자를 제공합니다.
[접근 범위 넓이 순서]
private < default(package-private) < protected < public
가장 좁음 가장 넓음
(클래스 내부만) (모든 곳)
2. 4가지 접근제어자 범위 완전 정복
접근 범위 비교표
| 접근제어자 | 같은 클래스 | 같은 패키지 | 자식 클래스 (다른 패키지) | 모든 곳 |
|---|---|---|---|---|
private | ✅ | ❌ | ❌ | ❌ |
default (생략) | ✅ | ✅ | ❌ | ❌ |
protected | ✅ | ✅ | ✅ | ❌ |
public | ✅ | ✅ | ✅ | ✅ |
private – 가장 강력한 은닉
클래스 내부에서만 접근 가능합니다. 같은 패키지의 다른 클래스, 심지어 자식 클래스에서도 직접 접근이 불가능합니다.
java
package com.example.bank;
public class BankAccount {
private double balance; // 이 클래스 내부에서만 접근 가능
private String accountNumber;
// ✅ 같은 클래스 내부에서 접근 가능
public void deposit(double amount) {
this.balance += amount; // OK – 같은 클래스
}
// ✅ 같은 클래스의 다른 인스턴스도 접근 가능 (중요한 사실!)
public boolean equals(BankAccount other) {
return this.accountNumber.equals(other.accountNumber);
// ↑ other의 private 필드에 접근 가능!
// 같은 클래스 인스턴스끼리는 private 공유
}
}
// ─────────────────────────────────────────────────
package com.example.bank; // 같은 패키지
public class BankManager {
public void checkBalance(BankAccount account) {
// ❌ 다른 클래스에서 private 접근 불가
System.out.println(account.balance); // 컴파일 오류!
System.out.println(account.accountNumber); // 컴파일 오류!
// ✅ public 메서드를 통한 간접 접근만 가능
System.out.println(account.getBalance()); // OK
}
}
// ─────────────────────────────────────────────────
package com.example.bank;
public class PremiumAccount extends BankAccount { // 자식 클래스
public void printInfo() {
// ❌ 자식 클래스에서도 부모의 private 접근 불가
System.out.println(this.balance); // 컴파일 오류!
System.out.println(this.accountNumber); // 컴파일 오류!
// ✅ 부모의 public/protected 메서드로 간접 접근
System.out.println(this.getBalance()); // OK
}
}
default (package-private) – 패키지 내부 공개
접근제어자를 아무것도 쓰지 않으면 default입니다. default라는 키워드가 따로 존재하지 않습니다. 같은 패키지 내에서만 접근 가능합니다.
java
package com.example.bank;
class AccountValidator { // default – 클래스 자체가 패키지 한정
// ↑ public 없음
double minBalance = 1000.0; // default 필드
// (package-private)
boolean isValidAmount(double amount) { // default 메서드
return amount > 0 && amount <= 10_000_000;
}
}
// ─────────────────────────────────────────────────
package com.example.bank; // 같은 패키지 ✅
public class BankAccount {
private final AccountValidator validator = new AccountValidator();
// ↑ 같은 패키지 → default 클래스 접근 가능
public void deposit(double amount) {
if (validator.isValidAmount(amount)) { // 같은 패키지 → OK
// 입금 처리
}
}
}
// ─────────────────────────────────────────────────
package com.example.payment; // 다른 패키지 ❌
import com.example.bank.AccountValidator; // ❌ 컴파일 오류!
// AccountValidator는 default → 다른 패키지에서 import 불가
public class PaymentService {
public void process() {
AccountValidator v = new AccountValidator(); // ❌ 컴파일 오류!
}
}
protected – 패키지 + 상속 공개
같은 패키지 내 클래스와 다른 패키지의 자식 클래스에서 접근 가능합니다. 상속 구조를 설계할 때 핵심적으로 사용합니다.
java
package com.example.animal;
public class Animal {
protected String name; // 같은 패키지 + 자식 클래스 접근 가능
protected int age;
protected void breathe() { // 자식 클래스에서 오버라이드 가능
System.out.println(name + "이(가) 숨을 쉽니다");
}
protected String getStatus() { // 자식 클래스에서 활용 가능
return "이름: " + name + ", 나이: " + age;
}
}
// ─────────────────────────────────────────────────
package com.example.animal; // 같은 패키지
public class AnimalTrainer {
public void train(Animal animal) {
System.out.println(animal.name); // 같은 패키지 → OK
animal.breathe(); // 같은 패키지 → OK
}
}
// ─────────────────────────────────────────────────
package com.example.pet; // 다른 패키지
import com.example.animal.Animal;
public class Dog extends Animal { // 자식 클래스
public void bark() {
// ✅ 자식 클래스 → protected 접근 가능
System.out.println(this.name + "이(가) 짖습니다"); // OK
this.breathe(); // OK
// ✅ 오버라이드 가능
}
@Override
protected void breathe() {
System.out.println(name + "이(가) 코로 숨을 쉽니다 (개)");
}
public void test() {
Animal otherAnimal = new Animal();
// ⚠️ 핵심 주의사항: 다른 인스턴스의 protected 접근은 자식 클래스 타입일 때만
// ❌ 자식 클래스가 아닌 Animal 인스턴스의 protected에는 접근 불가
// (다른 패키지에서는 자기 자신 또는 자기 타입으로 선언된 변수를 통해서만 접근 가능)
System.out.println(otherAnimal.name); // ❌ 컴파일 오류! (다른 패키지에서)
Dog dog = new Dog();
System.out.println(dog.name); // ✅ OK (같은 클래스 타입)
}
}
// ─────────────────────────────────────────────────
package com.example.service; // 다른 패키지, 상속 관계 없음
import com.example.animal.Animal;
public class AnimalService {
public void process(Animal animal) {
// ❌ 다른 패키지 + 상속 관계 없음 → protected 접근 불가
System.out.println(animal.name); // 컴파일 오류!
animal.breathe(); // 컴파일 오류!
}
}
public – 완전 공개
어디서든 접근 가능합니다. 공개 API를 정의할 때 사용합니다.
java
package com.example.bank;
public class BankService { // public 클래스 – 어디서나 접근 가능
public static final int MAX_TRANSFER_LIMIT = 10_000_000; // public 상수
public BankAccount createAccount(String owner, String password) {
return new BankAccount(owner, password, 0);
}
public void transfer(BankAccount from, BankAccount to,
double amount, String password) {
from.withdraw(amount, password);
to.deposit(amount);
}
}
// 어떤 패키지에서든 접근 가능
package com.example.ui;
import com.example.bank.BankService;
public class BankApp {
public static void main(String[] args) {
BankService service = new BankService(); // ✅ 다른 패키지에서도 OK
service.transfer(from, to, 50000, "1234"); // ✅ public 메서드 접근 OK
}
}
3. 클래스·필드·메서드·생성자별 적용 규칙
클래스에 적용하는 접근제어자
java
// 최상위 클래스(Top-Level Class)에는 public 또는 default만 사용 가능
// (protected, private는 최상위 클래스에 사용 불가)
public class PublicClass { } // ✅ 어디서나 접근 가능
class DefaultClass { } // ✅ 같은 패키지에서만 접근 가능
// protected class X { } // ❌ 컴파일 오류! 최상위 클래스에 protected 불가
// private class Y { } // ❌ 컴파일 오류! 최상위 클래스에 private 불가
// ─── 내부 클래스(Inner Class)는 4가지 모두 사용 가능 ───────────
public class Outer {
public class PublicInner { } // 외부에서 Outer.PublicInner로 접근 가능
protected class ProtectedInner { }
class DefaultInner { } // 같은 패키지에서만 접근 가능
private class PrivateInner { } // Outer 클래스 내부에서만 접근 가능
// private 내부 클래스 활용 예시 – Builder 패턴
public static class Builder { // public 정적 내부 클래스 (자주 쓰는 패턴)
private String name;
private int age;
public Builder name(String name) { this.name = name; return this; }
public Builder age(int age) { this.age = age; return this; }
public Person build() { return new Person(this); }
}
}
필드(Field)에 적용하는 접근제어자 – 실무 원칙
java
public class Product {
// ✅ 실무 권장: 필드는 private, 필요 시 메서드로 공개
private Long id; // 외부 변경 절대 불가
private String name;
private BigDecimal price;
private int stock;
private boolean active;
// ── 상수: public static final 조합 사용 ──────────────
public static final int MAX_STOCK = 99999; // 외부에서 참조 가능한 상수
public static final String CURRENCY = "KRW";
// ── package-private 상수 (패키지 내부용) ──────────────
static final String INTERNAL_CODE_PREFIX = "PRD";
// ── Getter: 읽기만 허용 ───────────────────────────────
public Long getId() { return id; }
public String getName() { return name; }
public BigDecimal getPrice() { return price; }
public int getStock() { return stock; }
public boolean isActive() { return active; }
// ── Setter: 필요한 것만, 검증 포함 ───────────────────
public void updatePrice(BigDecimal newPrice) {
if (newPrice == null || newPrice.compareTo(BigDecimal.ZERO) <= 0) {
throw new IllegalArgumentException("가격은 0보다 커야 합니다");
}
this.price = newPrice;
}
public void decreaseStock(int quantity) {
if (quantity <= 0 || quantity > this.stock) {
throw new IllegalStateException("재고가 부족합니다");
}
this.stock -= quantity;
}
// id는 Setter 없음 → 생성 후 변경 불가 (불변성)
// name은 Setter 없음 → 이름 변경 불가 (비즈니스 규칙)
}
메서드(Method)에 적용하는 접근제어자
java
public class OrderService {
// ── public: 외부 공개 API ─────────────────────────────
public OrderResult placeOrder(OrderRequest request) {
validateRequest(request); // private 메서드 호출
Order order = createOrder(request);// private 메서드 호출
notifyUser(order); // private 메서드 호출
return OrderResult.success(order);
}
public void cancelOrder(Long orderId, String reason) {
Order order = findOrder(orderId); // private 메서드 호출
validateCancellable(order); // private 메서드 호출
order.cancel(reason);
}
// ── protected: 자식 클래스에서 확장 가능한 훅(Hook) ──────
protected void notifyUser(Order order) {
// 기본 구현: 이메일 발송
// 자식 클래스(PremiumOrderService)에서 오버라이드 가능
emailService.sendOrderConfirmation(order);
}
protected boolean isOrderValid(Order order) {
return order.getAmount().compareTo(BigDecimal.ZERO) > 0;
}
// ── default (package-private): 같은 패키지의 테스트 클래스 접근 ──
Order createOrder(OrderRequest request) {
// 실제로는 private이 좋지만,
// 같은 패키지의 테스트 클래스(OrderServiceTest)에서 접근 위해 default 사용
// → 더 나은 방법: @VisibleForTesting 어노테이션 + 주석
return new Order(request);
}
// ── private: 내부 구현 세부 사항 ─────────────────────────
private void validateRequest(OrderRequest request) {
if (request == null) throw new NullPointerException("요청이 null입니다");
if (request.getItems().isEmpty()) throw new IllegalArgumentException("주문 항목 없음");
}
private Order findOrder(Long orderId) {
return orderRepository.findById(orderId)
.orElseThrow(() -> new OrderNotFoundException(orderId));
}
private void validateCancellable(Order order) {
if (order.getStatus() == OrderStatus.DELIVERED) {
throw new IllegalStateException("배송 완료된 주문은 취소 불가");
}
}
}
생성자(Constructor)에 적용하는 접근제어자
java
// ── private 생성자 패턴들 ───────────────────────────────────
// 1. 싱글톤 패턴
public class DatabaseConnectionPool {
private static final DatabaseConnectionPool INSTANCE =
new DatabaseConnectionPool();
private DatabaseConnectionPool() { // 외부에서 new 불가
initPool();
}
public static DatabaseConnectionPool getInstance() {
return INSTANCE;
}
}
// 2. 유틸리티 클래스 (인스턴스화 방지)
public class StringUtils {
private StringUtils() { // 인스턴스화 방지
throw new UnsupportedOperationException("유틸리티 클래스는 인스턴스화 불가");
}
public static boolean isEmpty(String s) {
return s == null || s.isEmpty();
}
public static String capitalize(String s) {
if (isEmpty(s)) return s;
return Character.toUpperCase(s.charAt(0)) + s.substring(1);
}
}
// 3. 정적 팩토리 메서드 패턴
public class Money {
private final BigDecimal amount;
private final String currency;
// private 생성자 – 직접 생성 불가
private Money(BigDecimal amount, String currency) {
this.amount = amount;
this.currency = currency;
}
// 정적 팩토리 메서드 – 의미 있는 이름과 유효성 검사 포함
public static Money of(BigDecimal amount, String currency) {
if (amount.compareTo(BigDecimal.ZERO) < 0) {
throw new IllegalArgumentException("금액은 0 이상이어야 합니다");
}
return new Money(amount, currency);
}
public static Money zero(String currency) {
return new Money(BigDecimal.ZERO, currency);
}
public static Money ofKrw(long amount) {
return new Money(BigDecimal.valueOf(amount), "KRW");
}
}
// ── protected 생성자 – 상속 허용, 직접 인스턴스화 방지 ──────────
public abstract class Shape {
protected final String color;
protected final double borderWidth;
protected Shape(String color, double borderWidth) {
// abstract 클래스이지만 생성자는 자식이 super()로 호출해야 함
// protected → 자식 클래스만 super() 호출 가능
this.color = color;
this.borderWidth = borderWidth;
}
public abstract double area();
}
public class Circle extends Shape {
private final double radius;
public Circle(double radius, String color) {
super(color, 1.0); // protected 부모 생성자 호출 가능
this.radius = radius;
}
@Override
public double area() {
return Math.PI * radius * radius;
}
}
4. 상속과 접근제어자 – 오버라이딩 규칙과 설계 패턴
오버라이딩 시 접근제어자 규칙
오버라이딩(Overriding) 황금 규칙: 자식 클래스에서 메서드를 오버라이드할 때 접근 범위를 좁힐 수 없다. 같거나 더 넓게만 변경 가능하다.
[오버라이딩 접근제어자 변경 가능 방향]
더 좁게 (불가) ←────── 현재 ──────→ 더 넓게 (가능)
private < default < protected < public
부모가 protected → 자식은 protected 또는 public 가능
(private, default로 축소 불가)
java
public class Parent {
public void publicMethod() { } // public
protected void protectedMethod() { } // protected
void defaultMethod() { } // default
// private void privateMethod() { } // private (오버라이드 불가 – 상속 안 됨)
}
public class Child extends Parent {
// ✅ public → public (유지)
@Override
public void publicMethod() { }
// ❌ public → protected (축소 불가, 컴파일 오류)
// @Override
// protected void publicMethod() { } // 컴파일 오류!
// ✅ protected → protected (유지)
@Override
protected void protectedMethod() { }
// ✅ protected → public (확장 가능)
// @Override
// public void protectedMethod() { } // OK – 더 넓게 허용
// ✅ default → default (유지)
@Override
void defaultMethod() { }
// ✅ default → protected (확장)
// @Override
// protected void defaultMethod() { } // OK
// ✅ default → public (확장)
// @Override
// public void defaultMethod() { } // OK
}
템플릿 메서드 패턴 – protected의 실무 활용
protected는 템플릿 메서드 패턴에서 핵심 역할을 합니다. 알고리즘의 뼈대는 부모 클래스의 public 메서드로 정의하고, 세부 구현은 자식 클래스가 오버라이드할 protected 메서드로 분리합니다.
java
// 추상 클래스 – 알고리즘 뼈대 정의
public abstract class DataExporter {
// public: 외부에서 호출하는 진입점 (템플릿 메서드)
public final void export(String destination) {
// 알고리즘 흐름은 고정 (final)
List<Object> data = readData(); // protected 훅
List<Object> processed = process(data); // protected 훅
validate(processed); // protected 훅
write(processed, destination); // protected 훅
cleanup(); // protected 훅
log("내보내기 완료: " + destination); // private 공통 로직
}
// protected: 자식 클래스가 반드시 구현해야 하는 추상 메서드
protected abstract List<Object> readData();
protected abstract void write(List<Object> data, String dest);
// protected: 자식 클래스가 선택적으로 오버라이드할 수 있는 훅
protected List<Object> process(List<Object> data) {
return data; // 기본: 변환 없이 그대로
}
protected void validate(List<Object> data) {
if (data == null || data.isEmpty()) {
throw new IllegalStateException("내보낼 데이터가 없습니다");
}
}
protected void cleanup() {
// 기본: 아무것도 하지 않음 (자식이 필요 시 오버라이드)
}
// private: 부모 클래스 내부 구현 세부사항
private void log(String message) {
System.out.println("[" + getClass().getSimpleName() + "] " + message);
}
}
// ── 구체 구현 클래스들 ──────────────────────────────────────
public class CsvExporter extends DataExporter {
private final CsvRepository repository;
public CsvExporter(CsvRepository repository) {
this.repository = repository;
}
@Override
protected List<Object> readData() {
return repository.findAll();
}
@Override
protected void write(List<Object> data, String dest) {
// CSV 파일로 저장
CsvWriter.write(data, dest);
}
@Override
protected List<Object> process(List<Object> data) {
// CSV는 날짜를 특정 형식으로 변환
return data.stream()
.map(this::formatDates)
.collect(Collectors.toList());
}
}
public class JsonExporter extends DataExporter {
@Override
protected List<Object> readData() {
return apiClient.fetchAll();
}
@Override
protected void write(List<Object> data, String dest) {
// JSON 파일로 저장
JsonWriter.write(data, dest);
}
@Override
protected void cleanup() {
// JSON은 임시 파일 정리 필요
tempFileManager.cleanAll();
}
// process(), validate()는 부모 기본 구현 사용
}
5. 패키지 구조와 접근제어자 실무 설계
레이어드 아키텍처에서의 접근제어자 설계
[레이어드 아키텍처 패키지 구조]
com.example.shop
├── controller (Controller 레이어)
├── service (Service 레이어)
├── repository (Repository 레이어)
├── domain (Domain 모델)
└── dto (Data Transfer Object)
java
// ── Controller 레이어 ──────────────────────────────────────
package com.example.shop.controller;
@RestController
@RequestMapping("/api/products")
@RequiredArgsConstructor
public class ProductController {
private final ProductService productService; // private: 외부 교체 불가
// public: REST API 엔드포인트 → 외부(클라이언트)와의 계약
@GetMapping("/{id}")
public ResponseEntity<ProductResponse> getProduct(@PathVariable Long id) {
ProductDto dto = productService.getProduct(id);
return ResponseEntity.ok(ProductResponse.from(dto));
}
@PostMapping
public ResponseEntity<ProductResponse> createProduct(
@RequestBody @Valid ProductCreateRequest request) {
ProductDto created = productService.createProduct(request);
return ResponseEntity.created(/* URI */).body(ProductResponse.from(created));
}
// private: 컨트롤러 내부 공통 로직
private URI buildLocationUri(Long productId) {
return ServletUriComponentsBuilder.fromCurrentRequest()
.path("/{id}").buildAndExpand(productId).toUri();
}
}
// ── Service 레이어 ─────────────────────────────────────────
package com.example.shop.service;
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class ProductService {
private final ProductRepository productRepository;
private final CategoryRepository categoryRepository;
// public: Controller에서 호출하는 공개 API
public ProductDto getProduct(Long id) {
return productRepository.findById(id)
.map(ProductDto::from)
.orElseThrow(() -> new ProductNotFoundException(id));
}
@Transactional
public ProductDto createProduct(ProductCreateRequest request) {
validateDuplicateName(request.getName()); // private 메서드
Category category = findCategory(request); // private 메서드
Product product = Product.create(request, category);
return ProductDto.from(productRepository.save(product));
}
// private: 서비스 내부 구현 세부사항 → 외부에 노출 불필요
private void validateDuplicateName(String name) {
if (productRepository.existsByName(name)) {
throw new DuplicateProductNameException(name);
}
}
private Category findCategory(ProductCreateRequest request) {
return categoryRepository.findById(request.getCategoryId())
.orElseThrow(() -> new CategoryNotFoundException(request.getCategoryId()));
}
}
// ── Domain 모델 ────────────────────────────────────────────
package com.example.shop.domain;
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED) // JPA용 기본 생성자
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id; // ← private: JPA가 관리, 외부 변경 불가
@Column(nullable = false)
private String name; // ← private: Setter 없음 → 불변성
@Column(nullable = false)
private BigDecimal price; // ← private
@Column(nullable = false)
private int stock; // ← private: 재고는 메서드로만 변경
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "category_id")
private Category category; // ← private
private boolean active = true; // ← private
// public 정적 팩토리 메서드 – 생성 진입점
public static Product create(ProductCreateRequest req, Category category) {
Product product = new Product();
product.name = req.getName();
product.price = req.getPrice();
product.stock = req.getInitialStock();
product.category = category;
return product;
}
// public 비즈니스 메서드 – 도메인 행위 공개
public void updatePrice(BigDecimal newPrice) {
validatePrice(newPrice);
this.price = newPrice;
}
public void decreaseStock(int quantity) {
if (quantity > this.stock) {
throw new InsufficientStockException(id, quantity, stock);
}
this.stock -= quantity;
}
public void deactivate() {
this.active = false;
}
// private: 도메인 내부 검증 로직
private void validatePrice(BigDecimal price) {
if (price == null || price.compareTo(BigDecimal.ZERO) <= 0) {
throw new IllegalArgumentException("가격은 0보다 커야 합니다: " + price);
}
}
}
모듈 경계로서의 default 활용
java
// 패키지를 모듈처럼 사용 – 내부 구현 숨기기
package com.example.shop.payment;
// 외부에 공개하는 인터페이스만 public
public interface PaymentGateway {
PaymentResult process(PaymentRequest request);
void cancel(String paymentId);
}
// 내부 구현 클래스는 default → 패키지 외부에서 직접 접근 불가
class StripePaymentGateway implements PaymentGateway {
private final StripeClient stripeClient;
StripePaymentGateway(StripeClient stripeClient) {
this.stripeClient = stripeClient;
}
@Override
public PaymentResult process(PaymentRequest request) { ... }
@Override
public void cancel(String paymentId) { ... }
}
// 팩토리 클래스도 default – 내부 구현 클래스 생성을 패키지 내부에서 담당
class PaymentGatewayFactory {
static PaymentGateway create(PaymentConfig config) {
if (config.isStripeEnabled()) {
return new StripePaymentGateway(new StripeClient(config.getStripeKey()));
}
return new KakaoPayGateway(new KakaoClient(config.getKakaoKey()));
}
}
// 외부에서는 공개 인터페이스만 사용 → 구현체 교체 자유
package com.example.shop.service;
public class OrderService {
private final PaymentGateway paymentGateway;
// ↑ public 인터페이스만 알면 됨
// StripePaymentGateway는 알 수도 없음 (default)
}
6. 전문가 관점 – 실무 설계 원칙·면접 핵심 정리·안티패턴
접근제어자 실무 설계 원칙 – 최소 권한 원칙
[최소 권한 원칙 (Principle of Least Privilege)]
"필요한 최소한의 접근 권한만 부여하라"
설계 순서:
① private으로 시작 (가장 좁게)
② 테스트나 상속이 필요하면 → default 또는 protected로 확장
③ 외부 API가 필요하면 → public으로 확장
④ 각 단계마다 "정말 이게 필요한가?" 질문
금기사항:
❌ 처음부터 모두 public으로 선언
❌ "나중에 필요할 수도 있으니까" public
❌ 테스트를 위해 public으로 변경 (→ 리플렉션이나 패키지 테스트 사용)
자주 나오는 면접 질문 정리
java
// Q1: default와 protected의 차이는?
// A: default는 '같은 패키지'에서만 접근 가능
// protected는 '같은 패키지 + 다른 패키지의 자식 클래스'에서 접근 가능
package com.example.a;
public class Parent {
protected void method() { }
void defaultMethod() { }
}
package com.example.b; // 다른 패키지
public class Child extends Parent {
void test() {
method(); // ✅ protected → 자식 클래스에서 접근 가능
defaultMethod(); // ❌ default → 다른 패키지에서 접근 불가
}
}
// Q2: private 멤버는 상속되는가?
// A: 상속은 되지만 직접 접근은 불가능. 부모의 public/protected 메서드를 통해 간접 사용
public class Parent {
private int secret = 42;
public int getSecret() { return secret; } // public을 통해 간접 접근
}
public class Child extends Parent {
void test() {
System.out.println(secret); // ❌ 직접 접근 불가 (컴파일 오류)
System.out.println(getSecret()); // ✅ 간접 접근 가능
}
}
// Q3: 오버라이딩 시 접근제어자를 좁힐 수 없는 이유는?
// A: 리스코프 치환 원칙(LSP) – 자식 클래스는 부모 클래스를 대체할 수 있어야 함
// 부모가 protected 메서드를 공개했는데
// 자식이 private으로 좁히면 다형성 위반
Parent obj = new Child(); // 업캐스팅
obj.protectedMethod(); // 부모 타입으로 호출
// 자식에서 private으로 오버라이드했다면? → 호출 불가 → LSP 위반
// Q4: 인터페이스의 접근제어자 규칙은?
// A: - 메서드: 기본값 public abstract (명시 안 해도 됨)
// - 상수: 기본값 public static final
// - default 메서드: public (명시해도 됨)
// - static 메서드: public
// - private 메서드 (Java 9+): 인터페이스 내부 구현 공유용
interface ApiService {
// 모두 암묵적으로 public abstract
void process();
String getData();
// Java 8+ default 메서드 (구현 포함)
default void log(String msg) {
System.out.println("[LOG] " + msg);
}
// Java 9+ private 메서드 (인터페이스 내부 구현 공유)
private void internalHelper() {
// default 메서드들이 공통으로 사용하는 내부 로직
}
}
안티패턴 – 피해야 할 잘못된 설계
java
// ❌ 안티패턴 1: 모든 것을 public으로 선언
public class UserAccount {
public String username; // 직접 변경 가능 → 유효성 검사 우회
public String password; // 평문 비밀번호 노출!
public int loginCount; // 외부에서 임의로 변경 가능
public boolean locked; // 잠금 상태 외부 변경 가능
// → "모든 게 공개된 구조체" → 객체지향 아님
}
// ✅ 올바른 설계
public class UserAccount {
private final String username;
private String hashedPassword;
private int loginCount;
private boolean locked;
public void login(String password) {
if (locked) throw new AccountLockedException();
if (!passwordEncoder.matches(password, hashedPassword)) {
loginCount++;
if (loginCount >= 5) this.locked = true; // 5회 실패 시 잠금
throw new InvalidPasswordException();
}
loginCount = 0; // 성공 시 초기화
}
}
// ❌ 안티패턴 2: 테스트를 위해 private → public 변경
public class PaymentService {
public void validateCard(String cardNumber) { // 테스트 위해 public으로 변경 ❌
// → 내부 로직이 외부 API가 되어버림
// → 다른 개발자가 이 메서드를 직접 호출할 수 있음
}
}
// ✅ 올바른 방법: 같은 패키지의 테스트 클래스 활용 (default)
class PaymentService { // 또는 패키지 분리
void validateCard(String cardNumber) { // default → 테스트 클래스에서 접근 가능
// ...
}
}
// src/test/java/com/example/payment/ (같은 패키지)
class PaymentServiceTest {
@Test
void validateCard_유효한_카드() {
PaymentService service = new PaymentService(...);
assertDoesNotThrow(() -> service.validateCard("4111111111111111")); // OK
}
}
// ❌ 안티패턴 3: protected를 과도하게 사용
public class BaseRepository {
protected EntityManager em; // 내부 구현 노출
protected String tableName; // 내부 구현 노출
protected Connection getConnection() { ... } // 내부 구현 노출
// → 자식 클래스가 부모 구현에 강하게 결합됨
// → 부모 내부 변경 시 모든 자식 클래스에 영향
}
// ✅ 올바른 방법: 필요한 기능만 protected로 노출
public abstract class BaseRepository<T, ID> {
private final EntityManager em; // private으로 은닉
protected BaseRepository(EntityManager em) {
this.em = em;
}
// 자식이 필요한 기능만 protected 메서드로 제공
protected T findById(ID id, Class<T> type) {
return em.find(type, id);
}
protected void persist(T entity) {
em.persist(entity);
}
}
접근제어자 선택 최종 체크리스트
[접근제어자 결정 체크리스트]
필드(Field):
□ 원칙: 항상 private
□ 예외: public static final 상수는 public
□ protected 필드 사용 자제 → 메서드로 노출
메서드(Method):
□ 공개 API (외부 클라이언트 사용) → public
□ 상속 계층에서 확장 가능한 훅 → protected
□ 같은 패키지 내 협력 (또는 테스트) → default
□ 내부 구현 세부사항 → private
생성자(Constructor):
□ 일반 인스턴스화 허용 → public
□ 정적 팩토리 메서드만 허용 → private 생성자 + public 팩토리
□ 추상 클래스·상속 구조 → protected 생성자
□ 싱글톤·유틸리티 → private 생성자
클래스(Class):
□ 최상위 클래스 → public 또는 default만 가능
□ 내부 구현 클래스 → default (패키지 외부 노출 방지)
□ 내부 클래스 → 용도에 맞게 4가지 모두 가능
오버라이딩:
□ 부모보다 좁은 접근제어자로 변경 → 컴파일 오류 확인
□ public 메서드를 protected로 → 불가
□ protected 메서드를 public으로 → 가능 (확장)
결론
Java 접근제어자는 단순히 “누가 접근할 수 있는가”를 제한하는 문법이 아니라, 객체의 책임과 경계를 정의하는 핵심 설계 도구입니다. private으로 내부 상태를 철저히 은닉하고, 필요한 기능만 public 메서드로 노출하는 캡슐화가 객체지향 설계의 출발점입니다. protected는 상속 계층에서 자식 클래스가 확장할 수 있는 훅을 정의할 때, default는 패키지 내부 협력 클래스 간 공유 기능을 정의할 때 의도적으로 사용합니다. 실무에서는 “최소 권한 원칙”에 따라 항상 private에서 시작해 필요한 만큼만 접근 범위를 넓히는 습관이 유지보수하기 쉬운 코드를 만드는 지름길입니다.
지금 바로 현재 프로젝트에서 public 필드를 검색해 private으로 전환하고, 테스트를 위해 public으로 열어둔 내부 메서드를 default나 패키지 테스트 구조로 개선하는 것부터 시작해 보세요.
답글 남기기