캡슐화는 객체지향 프로그래밍의 중요한 원칙 중 하나로, 백엔드 개발자에게 중요한 개념이다. 효과적인 캡슐화는 코드의 유지보수성, 재사용성, 그리고 확장성을 크게 향상시킨다. 따라서, 백엔드 개발자는 캡슐화의 개념과 어떻게 적용할지를 끊임없이 고민해야 한다.
캡슐화란?
캡슐화는 객체의 상태(데이터)와 행동(메서드)을 하나로 묶고, 객체의 내부 구현을 외부로부터 감추는 개념이다.
캡슐화의 목적
1. 정보 은닉: 객체 내부의 구현 세부사항을 외부에서 접근하지 못하도록 제한한다. ➡️ 객체의 상태 변경을 제어한다!
정보 은닉에는 다음과 같은 장점이 있다:
- 구현 변경이 자유롭다. 객체 내부 구현이 은닉되면 외부 코드에 영향을 주지 않고 내부 로직을 자유롭게 변경할 수 있다. 예를 들어, 자료구조를 배열에서 해시맵으로 변경해도 외부 코드는 수정할 필요가 없다.
- 객체의 사용자는 객체의 내부 구현을 알 필요 없이 단순한 인터페이스만 이해하면 된다. 이는 코드 이해도를 높인다.
- 민감한 데이터나 내부 상태를 외부로부터 보호함으로써 보안을 강화한다. 예를 들어, 사용자 비밀번호는 직접 접근할 수 없고 인증 메서드만 제공한다.
- 데이터 무결성을 보장한다. 객체 상태 변경을 검증된 메서드로만 제한할 수 있어 항상 유효한 상태를 유지할 수 있다.
// 정보 은닉이 없는 경우
user.balance = -1000; // 직접 접근으로 무결성 깨짐
// 정보 은닉이 있는 경우
user.withdraw(1000); // 메서드 내에서 잔액 검증 가능
-------------------------------------------------------------------------------
public class User {
private double balance; // private 필드로 직접 접근 불가
public void withdraw(double amount) {
if (amount <= 0) {
throw new IllegalArgumentException("인출 금액은 양수여야 합니다");
}
if (amount > balance) {
throw new InsufficientFundsException("잔액이 부족합니다");
}
// 검증을 통과한 경우에만 상태 변경
this.balance -= amount;
// 추가로 로깅, 감사, 이벤트 발행 등의 작업 수행 가능
logWithdrawal(amount);
notifyObservers();
}
// 필요한 경우 읽기 전용으로 잔액 제공
public double getBalance() {
return balance;
}
}
2. 인터페이스 제공: 객체와 상호작용할 수 있는 명확하고 안정적인 방법을 제공한다:
- 다형성을 지원한다. 동일한 인터페이스를 구현하는 여러 클래스를 통해 유연한 설계가 가능하며, 런타임에 구현체를 교체할 수 있다.
- 잘 설계된 인터페이스는 객체 사용 방법을 명확하게 제시해 코드 가독성과 사용성을 높인다.
- 인터페이스는 객체와 클라이언트 간의 명확한 계약을 정의한다. 이 계약이 유지되는 한, 양측은 내부 구현을 독립적으로 개발할 수 있다.
- 명확한 인터페이스는 목(mock) 객체 생성을 쉽게 하여 단위 테스트를 편하게 작성할 수 있다.
Java에서 캡슐화 구현 방법
1. 접근 제어자 활용하기
Java에서 제공하는 `private` 접근 제어자를 활용해 객체의 필드나 메서드를 캡슐화화 할 수 있다.
public class BankAccount {
private double balance; // 외부에서 직접 접근 불가
private List<Transaction> transactions; // 내부 구현 세부사항
public double getBalance() { // 공개 인터페이스
return balance;
}
public void deposit(double amount) {
validateAmount(amount);
balance += amount;
transactions.add(new Transaction("deposit", amount));
}
private void validateAmount(double amount) {
// 내부 검증 로직
}
}
2. 불변성(Immutability) 활용하기
가능한 경우 객체를 불변으로 설계하면 예측 가능성인 높아지고 동시성 문제를 줄일 수 있다. 캡슐화의 핵심 목표 중 하는 객체의 상태 변경을 제어하는 것이다. 불변 객체는 생성 후 상태가 절대 변경되지 않으므로, 캡슐화의 구현 방법이라 볼 수 있다.
public final class Money {
private final BigDecimal amount;
private final Currency currency;
public Money(BigDecimal amount, Currency currency) {
this.amount = amount;
this.currency = currency;
}
// 새 객체를 반환하는 연산
public Money add(Money other) {
if (!this.currency.equals(other.currency)) {
throw new IllegalArgumentException("통화가 다릅니다");
}
return new Money(this.amount.add(other.amount), this.currency);
}
// getter만 제공, setter 없음
public BigDecimal getAmount() {
return amount;
}
public Currency getCurrency() {
return currency;
}
}
3. 인터페이스 분리 원칙(ISP) 적용
클라이언트가 필요로 하는 메서드만 노출하는 인터페이스를 설계해 캡슐화를 강화한다.
// 결제 처리 인터페이스
public interface PaymentProcessor {
PaymentResult processPayment(PaymentRequest request);
}
// 결제 관리 인터페이스
public interface PaymentManager {
List<Payment> getPaymentHistory();
void refundPayment(String paymentId);
}
// 두 인터페이스를 구현하는 클래스
public class PaymentService implements PaymentProcessor, PaymentManager {
// 구현...
}
4. DTO(Data Transfer Objects) 사용
내부 도메인 객체를 직접 노출하는 대신 DTO를 사용하여 필요한 데이터만 전송한다.
// 내부 도메인 객체
public class User {
private Long id;
private String username;
private String password; // 민감 정보
private List<Role> roles;
// ...
}
// API 응답용 DTO
public class UserDTO {
private Long id;
private String username;
private List<String> roleNames; // 변환된 정보
// 생성자, getter 등
}
캡슐화 그리고 응집도와 결합도
응집도는 모듈에 포함된 내부 요소들이 연관된 정도를 나타낸다. 결합도는 의존성의 정도를 나타내며, 다른 모듈에 대한 얼마나 많은 지식을 가지고 있는지를 나타낸다. 응집도가 높을 수록 관련된 데이터와 함수가 묶여 있기 때문에 객체는 단일 책임을 가질 가능성이 높고 결합도가 낮을 수록 구체적인 구현보다 추상화된 인터페이스에 의존해 특정 모듈을 변경하는 경우 코드의 변경이 적다.
// 높은 응집도를 가진 OrderProcessor 클래스
public class OrderProcessor {
private InventoryService inventoryService;
private PaymentService paymentService;
private ShippingService shippingService;
public OrderResult processOrder(Order order) {
// 재고 확인, 결제 처리, 배송 준비 등
// 모든 메서드가 주문 처리라는 하나의 책임에 집중
}
private boolean checkInventory(Order order) { ... }
private PaymentResult processPayment(Order order) { ... }
private ShippingLabel createShippingLabel(Order order) { ... }
}
// 낮은 결합도의 예
public class OrderService {
private final PaymentProcessor paymentProcessor; // 인터페이스에 의존
public OrderService(PaymentProcessor paymentProcessor) {
this.paymentProcessor = paymentProcessor;
}
public void createOrder(Order order) {
// paymentProcessor의 구체적인 구현에 의존하지 않음
paymentProcessor.processPayment(new PaymentRequest(order));
}
}
캡슐화의 잘못된 적용과 주의 사항
1. 무의미한 getter/setter 지양하기: 모든 것을 private으로 만들고 getter/setter를 무분별하게 추가하는 것은 진정한 캡슐화가 아니다. 이는 단순히 코드량만 증가할 수도 있다. 객체의 책임과 관계를 고려해 필요한 인터페이스만 노출해야 한다.
단순 getter/setter 대신 의미 있는 메서드를 설계하자!
// 기존 방식: 과도한 getter/setter
public class Account {
private double balance;
private boolean active;
private Date lastTransactionDate;
public double getBalance() { return balance; }
public void setBalance(double balance) { this.balance = balance; }
public boolean isActive() { return active; }
public void setActive(boolean active) { this.active = active; }
public Date getLastTransactionDate() { return lastTransactionDate; }
public void setLastTransactionDate(Date date) { this.lastTransactionDate = date; }
}
// 개선된 방식: 도메인 중심 메서드
public class Account {
private double balance;
private boolean active;
private Date lastTransactionDate;
public double getBalance() { return balance; }
public void deposit(double amount) {
validateAmount(amount);
this.balance += amount;
this.lastTransactionDate = new Date();
}
public void withdraw(double amount) {
validateAmount(amount);
ensureSufficientFunds(amount);
this.balance -= amount;
this.lastTransactionDate = new Date();
}
public void activate() {
this.active = true;
}
public void deactivate() {
this.active = false;
}
public boolean isOperational() {
return active;
}
private void validateAmount(double amount) {
if (amount <= 0) {
throw new IllegalArgumentException("금액은 양수여야 합니다");
}
}
private void ensureSufficientFunds(double amount) {
if (amount > balance) {
throw new InsufficientFundsException("잔액이 부족합니다");
}
}
}
2. 과도한 추상화 피하기
모든 것을 인터페이스로 추상화하는 것은 항상 좋은 것이 아니다. 불필요하게 복잡한 계층 구조는 코드 이해를 어렵게 만들고 유지보수 비용을 증가시킨다.
// 과도한 추상화의 예
public interface UserRepositoryFactory {
UserRepository createUserRepository();
}
public interface UserRepository {
UserFinder createFinder();
}
public interface UserFinder {
User findById(long id);
}
// vs 적절한 추상화
public interface UserRepository {
User findById(long id);
User save(User user);
}
3. 방어적 복사 고려하기
가변 객체를 반환할 때는 방어적 복사를 고려해야 하자. 그렇지 않으면 객체 내부 상태가 예상치 못하게 변경될 수 있다.
// 잘못된 예: 내부 컬렉션을 직접 반환
public class Customer {
private List<Order> orders = new ArrayList<>();
public List<Order> getOrders() {
return orders; // 외부에서 직접 수정 가능
}
}
// 올바른 예: 방어적 복사 또는 불변 뷰 제공
public class Customer {
private final String name;
private final List<Order> orders = new ArrayList<>();
private final Map<String, Address> addresses = new HashMap<>();
// 컬렉션에 대한 방어적 복사 - 불변 뷰 반환
public List<Order> getOrders() {
return Collections.unmodifiableList(orders);
}
// 안전한 요소 추가 메서드
public void addOrder(Order order) {
if (order == null) throw new IllegalArgumentException("Order cannot be null");
orders.add(order);
}
// 가변 객체에 대한 방어적 복사
public Address getAddress(String type) {
Address address = addresses.get(type);
// null이 아니면 방어적 복사본 반환
return address != null ? new Address(address) : null;
}
// 안전한 가변 객체 설정
public void setAddress(String type, Address address) {
if (type == null || address == null) {
throw new IllegalArgumentException("Type and address must not be null");
}
// 원본이 아닌 복사본을 저장
addresses.put(type, new Address(address));
}
}
// 가변 객체의 복사 생성자
public class Address {
private String street;
private String city;
private String zipCode;
// 복사 생성자
public Address(Address other) {
this.street = other.street;
this.city = other.city;
this.zipCode = other.zipCode;
}
// 일반 생성자 및 기타 메서드
}