문제 발견 "전부 public이면 편하지 않나?"
Java를 처음 배울 때 접근 제어자는 꽤나 귀찮은 존재입니다.
"아니 그냥 전부 public으로 해도 컴파일되고 동작도 하지 않나?
// 모든 필드가 public인 상품 엔티티
public class Product {
public Long id;
public String name;
public int price;
public int stock;
public Product() {} // 누구나 빈 객체를 만들 수 있습니다
}
이 코드의 문제는 아무 곳에서나 아무 값을 넣을 수 있다는 것입니다.
Product product = new Product();
product.stock = -100; // 재고가 마이너스?
product.price = 0; // 가격이 0원?
product.name = null; // 이름이 없는 상품?
컴파일러는 아무런 경고도 하지 않습니다.
이 코드가 문제를 일으키는 건 런타임에서, 그것도 한참 뒤에서나 알게 됩니다.
접근 제어자는 이런 실수를 컴파일 타임에 차단하기 위해 존재합니다.
네 가지 접근 제어자 - 열 수 있는 범위가 다르다
Java의 접근 제어자는 4가지이고, 각각 "누구에게 보여줄 것인가"를 결정합니다.
- private: 이 클래스 안에서만, 외부에서는 존재 자체를 모릅니다.
- default(package-private): 같은 패키지 안에서만, 접근 제어자를 아무것도 안 쓰면 default로 반영됩니다.
- protected: 같은 패키지, 자식 클래스에서 상속 관계에서 사용가능합니다.
- public: 어디서든 모든 코드에서 접근이 가능합니다.
접근 범위 (좁음 ← → 넓음)
private → default → protected → public
클래스 내부 같은 패키지 + 자식 클래스 어디서든
핵심은 "최소한으로 열고 필요할 때만 넓히는 것입니다". 처음부터 public으로 열면 나중에 좁히기 어렵지만, private에서 시작하면 필요한 순간에 열면 됩니다.
접근 제어자가 설계를 강제하는 방법
private 필드 + @Getter - 읽기만 허용하고, 변경은 비즈니스 메서드로만
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Product extends BaseTimeEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String name;
@Column(nullable = false)
private int price;
@Column(nullable = false)
private int stock;
// setter는 없다
/**
* 재고 차감.
* 요청 수량이 현재 재고보다 많으면 OutOfStockException 발생.
*/
public void decreaseStock(int quantity) {
if (this.stock < quantity) {
throw new OutOfStockException(this.id);
}
this.stock -= quantity;
}
}
모든 필드가 private입니다. @Getter로 읽기는 허용하지만, @Setter는 없습니다.
Stock을 바꾸고 싶으면? product.setStock(0) 같은 코드는 컴파일 에러가 납니다. 반드시 product.decreaseStock(quantity)를 거쳐야 합니다. 이 메서드는 재고 부족 여부를 검증하고, 조건을 통과해야만 값을 바꿉니다.
만약 public int stock이었다면? product.stock = -100으로 검증 없이 직접 수정할 수 있고, 재고가 마이너스인 상품이 DB에 저장될 수 있습니다. private로 이러한 가능성을 원천 차단할 수 있습니다.
이것이 "빈약한 도메인 모델(Anemic Domain Model)"을 피하는 핵심입니다. 비즈니스 규칙이 엔티티 내부에 캡슐화되고, 외부에서는 우회할 수 없습니다.
"그러면 아예 불변 객체로 만들면 되지 않나..?"라는 생각이 들 수 있습니다. 하지만 엔티티는 JPA가 상태를 변경해야 하므로 완전한 불변 객체로 만들 수 없습니다. 그래서 setter를 안 만들고, 비즈니스 메서드로만 변경을 허용하는 절충안을 씁니다. 완전한 불변이 필요한 DTO는 record로 만들면 됩니다.
protected 생성자 - JPA는 쓸 수 있지만, 개발자를 쓸 수 없게
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class User extends BaseTimeEntity {
private Long id;
private String email;
private String password;
private String name;
private Role role;
private User(String email, String password, String name, Role role) {
this.email = email;
this.password = password;
this.name = name;
this.role = role;
}
public static User of(String email, String encodedPassword, String name) {
return new User(email, encodedPassword, name, Role.USER);
}
}
여기에 생성자가 두 개가 있습니다.
- protected 기본 생성자: @NoArgsConstructor(access = AccessLevel.PROTECTED)가 만듭니다.
- private 전체 필드 생성자: 모든 값을 받는 생성자입니다.
protected 기본 생성자가 필요한 이유는 JPA 때문입니다. JPA는 DB에서 데이터를 가져와 엔티티 객체를 만들 때, 리플렉션으로 기본 생성자를 호출합니다. 그런데 public이 아니라 protected인 이유가 있습니다. public으로 열면 이런 코드가 가능해집니다.
User user = new User(); // 이메일도 없고, 비밀번호도 없는 유저
userRepository.save(user); // 빈 유저가 DB에 저장된다
protected로 제한하면 같은 패키지나 상속 관계가 아닌 외부 코드에서는 기본 생성자를 호출할 수 없습니다. JPA 프레임워크는 리플렉션으로 접근하므로 문제없이 동작하지만, 일반 애플리케이션 코드에서는 반드시 User.of() 팩토리 메서드를 사용해야 합니다.
그렇다면 private로 더 좁히면 안 되나? JPA 스펙상 기본 생성자는 public 또는 protected여야 합니다. private이면 리플렉션으로 객체를 생성할 수 없습니다. Hibernate 구현체에서는 private도 동작하는 경우가 있지만, JPA 스펙을 따르는 것이 안전합니다.
public static 팩토리 메서드 - 생성 경로를 하나로 강제
위에 User.of() 메서드를 다시 보겠습니다.
public static User of(String email, String encodedPassword, String name) {
return new User(email, encodedPassword, name, Role.USER);
}
private 생성자 + public static 팩토리 메서드 조합이 하는 일은 "이 방법으로만 만들어라"를 강제하는 것입니다.
User.of()는 role을 항상 Role.USER로 고정합니다. 외부에서 Role.ADMIN을 넘기는 것 자체가 불가능해집니다. 실수로 관리자 권한의 유저를 만드는 것을 코드 레벨에서 차단할 수 있게 됩니다.
Order.create()도 같은 패턴입니다.
public static Order create(Long userId) {
return new Order(userId);
// 내부에서 status = PENDING, totalPrice = 0으로 초기화
}
주문은 항상 PENDING 상태로 시작하고, 총금액은 0원에서 시작합니다. 이런 초기 불변 조건을 생성 시점에 보장할 수 있습니다.
package-private - Aggregate 경계를 패키지 가시성으로 강제
이 프로젝트에서 가장 정교한 접근 제어의 설계는 OrderItem에 있습니다.
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class OrderItem {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "order_id", nullable = false)
private Order order;
private Long productId;
private String productName;
private int price;
private int quantity;
private OrderItem(Long productId, String productName, int price, int quantity) {
this.productId = productId;
this.productName = productName;
this.price = price;
this.quantity = quantity;
}
// public이 아니다 — 같은 패키지(order.domain)에서만 호출 가능
static OrderItem of(Long productId, String productName, int price, int quantity) {
return new OrderItem(productId, productName, price, quantity);
}
// public이 아니다 — Order만 이 메서드를 호출할 수 있다
void setOrder(Order order) {
this.order = order;
}
}
of()와 serOrder()에 접근 제어자가 없습니다. 즉 package-private(default)입니다.
이것이 호출되는 곳은 같은 패키지에 있는 Order 엔티티입니다.
// Order.java — 같은 com.ecommerce.order.domain 패키지
public void addOrderItem(Long productId, String productName, int price, int quantity) {
OrderItem orderItem = OrderItem.of(productId, productName, price, quantity);
orderItem.setOrder(this);
this.orderItems.add(orderItem);
this.totalPrice += orderItem.getSubtotal();
}
OrderItem은 Order라는 Aggregate Root를 통해서만 생성되고 연결되어야 합니다. 만약 of()와 setOrder()가 public이었다면, OrderService나 OrderController에서 OrderItem을 독립적으로 만들어 임의의 Order에 붙일 수 있습니다.
// 이런 코드가 가능해져 버린다
OrderItem item = OrderItem.of(...);
item.setOrder(아무주문); // 도메인 규칙을 완전히 우회
package-private으로 제한하면, 외부에서는 반드시 order.addOrderItem(...)을 거쳐야 합니다. 이 메서드 안에서 총금액 계산까지 자동으로 처리됩니다.
패키지 경계가 곧 Aggregate 경계가 되는 것입니다.
물론 같은 패키지 안에 클래스가 많아지면 보호 효과는 줄어듭니다. 위 예시를 든 프로젝트에서는 order.domain 패키지에 Order, OrderItem, OrderRepository만 있으므로 경계가 명확해지긴 합니다. 패키지를 작게 유지하는 것이 package-private의 전제 조건이 됩니다.
정리
물론 비용이 발생합니다. setter 대신에 비즈니스 메서드를 만들고, 팩토리 메서드를 정의해야 하므로 코드량은 늘어날 수밖에 없습니다.
빠르게 프로토타이핑할 때는 걸림돌이 되기도 하는데요.. 그래도 public에서 private로 좁히는 것보다, private로 시작해서 필요할 때 넓히는 쪽이 안전하다고 생각합니다.
- private 필드 + @Getter: 읽기만 허용하고, 변경은 비즈니스 메서드로만 합니다.
- protected 생성자: JPA는 쓸 수 있되, 애플리케이션 코드에서 빈 객체 생성을 차단합니다.
- public static 팩토리 메서드: 생성 경로를 하나로 강제하고, 초기 불변 조건을 보장합니다.
- package-private: Aggregate Root를 통해 생성 및 변경만 허용합니다.
접근 제어자는 "이 코드를 어디까지 믿을 것인가?"를 선언하는 도구입니다. 좁게 열수록 코드 예측이 가능하고, 버그를 컴파일 타임에 잡을 수 있습니다.