들어가기에 앞서,
우리는 Getter와 Setter에 대해 학습했다. OOP 원칙 중 캡슐화에 대해 인지하였고, 따라서 Setter 사용을 지양해야 한다는 것 정도는 이미 알고 있는 사실이다.
그렇다면 왜 Getter 사용도 지양해야 하는 걸까?
또는, Setter를 지양해야 하는 명확한 이유는 뭘까?
Getter와 Setter의 탄생
객체 지향은 정보 은닉에서 시작한다.
객체 지향 언어의 장점은 물론 다양하겠지만, 가장 큰 장점 중 하나가 유연성이다. 이러한 설계 방식은 변화하는 요구사항에 쉽게 대응할 수 있도록 한다.
유연성을 가능하게 하는 요소는 무엇일까?
바로, 1. 캡슐화 2. 상속 3. 다형성이다.
그중 캡슐화, 이하 정보 은닉은 내부 구현을 숨기고 필요한 부분만 외부에 공개하도록 한다. 외부 코드가 내부 구현에 의존하지 않기 때문에, 내부 로직을 변경하더라도 사이드 이펙트가 발생하지 않아 유지보수가 용이하다.
객체 간 관계가 없다면, 그것은 별도의 시스템이다.
한 가지 재미있는 사실이 있다.
그건 바로, 유연성을 확보하는 가장 좋은 방법이 객체 간 서로 모르게 하는 것이라는 점이다.
예를 들어, 한 객체가 다른 객체를 생성한다던가, 다른 객체의 메서드를 호출한다던가, 다른 객체의 정보를 조회한다던가, 그 어떠한 접점조차 없는 "진짜 모르는 사이" 말이다. 서로에 대한 코드가 전혀 존재하지 않으니, 영향을 주는 것이 불가능할 테고, 이로 인해 유연성이 보장된다.
그런데 현실은, 객체 간 협력이 존재할 수밖에 없다. 애초에 객체 간 관계성이 전혀 없다면, 그건 그냥 별도의 시스템이니까. 아이러니하게도 이러한 관계 부여 행위가 유연성을 방해하지만 말이다.
결국 핵심은 관계성을 지울 수 없으니 최대한 좋은 관계성을 부여하자는 것이다. 완벽하게 유연할 수는 없으니 최대한의 유연성을 갖도록 노력하자.
좋은 관계성이란?
- 잦은 변동성이 예상되는 객체에는 의존하지 않는다.
- 외부로 노출되는 메서드를 최소화한다.
- 객체의 책임을 최소화한다.
세 가지를 적었지만, 사실 다 같은 말이나 다름없다.
필연적인 관계라면, 관계 발생 확률을 최소화하자.
사실, 정보 은닉은 훨씬 큰 개념이다.
캡슐화가 정보 은닉이고, 정보 은닉이 곧 캡슐화라고 생각하는 이들이 많다. 하지만 실제 정보 은닉은 캡슐화보다 더 큰 개념이다. 정확히 말하자면, 정보 은닉 방법 중 하나가 캡슐화인 것이다.
정보 은닉 방법은 크게 세 가지다.
- 객체의 구체적인 타입을 숨긴다. → 업캐스팅
- 객체의 필드 및 메서드를 숨긴다. → 캡슐화
- 구현을 숨긴다. → 인터페이스 기반 설계
숨김으로써 의존하는 것을 막고, 객체 간의 구체적인 결합도를 약화시킨다.
생성자도 숨겨버린다. 이와 관련해 `추상 팩토리 패턴`, `팩토리 메서드 패턴` 같은 것들이 있지만, 지금 하고 싶은 얘기는 이게 아니므로, 관련 내용은 추후 다른 포스트에서 다루도록 하겠다.
숨긴 필드에 접근할 수가 없어!
캡슐화로 인해 모든 필드가 private으로 숨겨졌다. 그러다 보니 우리는 특정 필드를 수정하거나 조회하는 것이 불가능하다. 그래서 나온 방법이 public 메서드를 통해 간접적으로 필드를 다루는 것이다.
이것이 Setter와 Getter의 탄생이다.
한 가지 확실하게 짚고 넘어가자.
필드를 프라이빗으로 숨기고, 그걸 퍼블릭 메서드로 접근해 사용한다면, 이게 진짜 숨겨진 게 맞을까?
OOP에서는 타 객체의 세부 정보를 알 필요가 없다. 타 객체에게 `요청`할 뿐이다.
*요청하지 않고 직접 비즈니스 로직을 수행한다면 이건 더 이상 객체 지향이 아니다.
Setter를 지양해야 하는 이유
Setter는 값을 바꾸는 이유를 드러내지 않는다.
한 마디로 Setter는 편하다. 필요할 때마다 객체의 상태를 바꿀 수 있기 때문에 정말 편하다.
그럼에도 불구하고 Setter가 무서운 이유는, 값을 바꾸는 이유를 드러내지 않기 때문이다.
class Account {
private long balance;
public Account(long balance) {
this.balance = balance;
}
public void setBalance(long balance) {
this.balance = balance;
}
}
Account myAccount = new Account(500);
myAccount.setBalance(1000);
위 코드는 myAccount의 balance를 1000으로 설정하고 있다. 우리는 전체 코드를 통해, 원래 500원이 있던 내 계좌에 500원이 추가로 들어와 1000원이 되었음을 감히 추측해 볼 수 있다.
그런데 `myAccount.setBalance(1000);`만 본다고 가정하면 어떠한가? 계좌에 입금이 돼서 잔액이 1000원이 된 건지, 출금이 돼서 1000원이 된 건지 알 수가 없다.
객체의 책임이 타 객체들에게 분산된다.
Setter를 사용하면, 마땅히 해당 객체가 해야 할 일, 즉 해당 객체의 책임이 타 객체에게 분산될 여지가 있다. 당연한 얘기다. Setter가 존재한다는 것 자체가 외부에서 변경할 수 있게 허용한다는 의미이기 때문이다.
객체의 불변성이 깨지고, 내부 규칙을 유지하기 어렵다. 객체 간 결합도가 증가해 유지보수가 어려워진다. 유지보수가 어렵다는 것은 개발자에게 지옥이나 다름없다.
Getter를 지양해야 하는 이유
Getter는 단순히 조회로 끝나지 않는다.
Getter는 단순히 값을 조회할 뿐인데 왜 지양하라는 건지 의문이 들 것이다. 하지만 실제 Getter는 단순히 조회에만 쓰이지 않을 수 있다.
아래 코드는 Getter로 값을 조회한 뒤, 그 값이 조건에 맞는지 확인하는 검증 로직까지 수행한다.
public void withdraw(long id, long amount) {
Account account = accountRepository.findById(id).orElseThrow();
long newBalance = account.getBalance() - amount;
if (newBalance < 0) {
throw new IllegalArgumentException("잔액이 부족합니다.");
}
account.setBalance(newBalance);
}
충분한 잔액이 있는지 요청해서 물어보면 되는데, 잔액을 직접 확인하는 실수를 저지른 셈이다.
유지보수가 어렵다.
모델 계층에서 특정 모델의 필드가 변경되었다고 하자. 관련 서비스 계층에서는 이 사실을 알지 못한다.
이해를 돕기 위해 이번에도 예시를 들어보자.
아래는 고객 객체에 대한 코드이며, 고객은 이름과 이용 요금 필드를 지닌 상태다.
class Customer {
private String name;
private double usageFee; // 이용 요금
public Customer(String name, double usageFee) {
this.name = name;
this.usageFee = usageFee;
}
public double getUsageFee() {
return usageFee;
}
}
class CustomerService {
public double calculateTotalFee(Customer customer) {
// 이용 요금 Getter
return customer.getUsageFee();
}
}
이용 요금과 관련하여 Getter를 사용한 메서드를 볼 수 있다.
그런데 만약, 고객 객체에서 `이용 요금`이 사라지고, `사용량`과 `단가`로 대체된다면 어떻게 될까?
class Customer {
private String name;
private double usage; // 사용량
private double unitPrice; // 단가
public Customer(String name, double usage, double unitPrice) {
this.name = name;
this.usage = usage;
this.unitPrice = unitPrice;
}
// 사용량과 단가로 요금을 계산함
public double calculateUsageFee() {
return usage * unitPrice;
}
}
class CustomerService {
public double calculateTotalFee(Customer customer) {
// -> 컴파일 에러 발생!
return customer.getUsageFee(); // getUsageFee()는 더이상 없음
}
}
더이상 이용요금 필드도, 관련 메서드도 없다.
변경 사항을 알 수 없어 컴파일 에러를 터트렸을 뿐 아니라, 수정해야 할 것이 늘어났다.
우리는 어떻게 해야 할까?
Setter 대신 명확한 의도를 가진 메서드를 사용하자.
예를 들어, setBalance(long balance) 대신, deposit(long amount) 및 withdraw(long amount) 메서드를 사용하자. 입금이나 출금이라는 구체적인 행동을 나타내기 때문에, 의도가 명확하고 비즈니스 규칙도 따른다.
public class Account {
private long balance;
// 입금 메서드
public void deposit(long amount) {
if (amount > 0) {
balance += amount;
} else {
throw new IllegalArgumentException("입금 금액은 0보다 커야 합니다.");
}
}
// 출금 메서드
public void withdraw(long amount) {
if (amount > 0 && balance >= amount) {
balance -= amount;
} else {
throw new IllegalArgumentException("출금 금액이 유효하지 않거나 잔액이 부족합니다.");
}
}
}
Getter가 결과를 반환하게 하자.
public class Account {
private long balance;
...
// 잔액 조회 메서드
public long getBalance() {
return balance;
}
}
Getter로 조건을 검사하는 등, 책임을 벗어나는 비즈니스 로직을 수행하지 않도록 한다.
위 코드처럼, Getter가 단순히 결과만을 반환한다거나, 앞서 Setter 파트에서 입출금 메서드를 다룬 것처럼, Getter도 명확한 의도가 담긴 계산된 값을 반환하는 메서드를 사용하도록 한다면, 충분히 훌륭한 Getter로써 기능할 수 있다.
참고자료
'성장 과정 > 인사이트' 카테고리의 다른 글
RESTful API에서 '응답 설계'가 반이라고? (0) | 2025.04.13 |
---|---|
비즈니스 로직이니까 서비스에 있어야지! (1) | 2025.04.09 |
DDD에서는 private을 안 쓴다면서? (0) | 2025.04.08 |