2024. 4. 12. 17:53ㆍJava/OOP
글의 작성 배경
Private로 된 내부 변수를 외부에서 값을 가져오거나 변경할 수 있는 방법이 있다. 바로 Getter와 Setter이다. 여담이지만 작년 4월까지 이걸 몰라서 안드로이드 강의 실습시간에 private 객체를 외부로 가져오는 방법이 뭐였죠?라는 말에 답변을 하지 못했었다.
아무튼, 사용자체도 쉽고 개발의 편의를 위해서 Getter, Setter를 열고 이 때까지 프로젝트를 진행했었다. 그런데 유튜브에서 다른 사람 코드리뷰 혹은 코드 작성 가이드라인을 보면 자주 등장하는 말이 "Getter 쓰지 마세요.", "Setter 쓰지 마세요."라는 말을 진짜 많이 본다. 솔직히 해당 내용을 보는데 항상 설명은 안 해줘서 "뭐 어쩌라고?"라는 생각이 많이 들었다.
그래서, 몇 번 이 주제로 글을 작성하고자 시도를 했었지만 setter에 대한 것은 어느정도 머리에서 정리가 되었는데 getter에 대해서는 전혀 이해가 되지 않았었다. 이번에 추천으로 최범균님의 "개발자가 반드시 정복해야 할 객체 지향과 디자인 패턴"을 읽으면서 어느정도 이해가 된 것 같아 글을 남긴다.
1. Getter를 사용했을 때의 주의사항
코드를 작성하기 전 상황에 대해서 설명을 하려고 한다. 카페에 가면 키오스크로 음료나 음식을 주문한다. 이 상황을 생각해보자. 주문을 할 때 키오스크에서 상품을 찾고 구체적인 상품을 골라, 주문을 한다. 이를 표현하기 위해 아래처럼 코드를 짜보았다.
public class Client {
private Kiosk kiosk;
private Scanner scanner = new Scanner(System.in);
private List<Food> foods = new ArrayList<>();
public Client(Kiosk kiosk) {
this.kiosk = kiosk;
}
public void order() {
foods = kiosk.getFoodList();
String foodRequest = askUserChoice();
Optional<Food> orderedFood = foods.stream().filter(food ->
food.getName().equals(foodRequest)).findFirst();
if(orderedFood.isPresent()) {
Food findFood = orderedFood.get();
System.out.println("요청하신 " + findFood.getName() + "에 대한 주문이 완료되었습니다.");
}
else
System.out.println("요청하신 주문이 존재하지 않습니다.");
}
위의 코드만 보았을 때 논리상으로 맞을 수도 있겠다는 생각이 들기도 하겠지만, 의도는 클라이언트가 음식 리스트에 관해서 너무 많은 것을 알고 있음을 표현하는 것이었다.
말하고자 하는 바는 Kiosk가 본인의 자원을 넘겨서 Client가 모든 음식을 알게 하는 설계가 문제이다. 이 말을 이해하기 위해서 만약에 저기서 주문 예상 시간을 추가해달라는 요청이 들어왔다고 해보자. 그러면 저 설계 그대로라면 Client가 시간까지 찾아오는 이상한 현상이 일어난다.
public void order() {
foods = kiosk.getFoodList();
String foodRequest = askUserChoice();
Optional<Food> orderedFood = foods.stream().filter(food ->
food.getName().equals(foodRequest)).findFirst();
if(orderedFood.isPresent()) {
Food findFood = orderedFood.get();
System.out.println("요청하신 " + findFood.getName() + "에 대한 주문이 완료되었습니다.");
System.out.println("예상 시간은 " + findFood.getEstimatedTime() + "입니다.");
}
else
System.out.println("요청하신 주문이 존재하지 않습니다.");
}
선택 권한이 Client에게 있는 건 맞지만 Client는 단지 주문하고 싶은 상품을 보고 결정만 하고 나머지는 모르고 싶다. 그런데, 이런 현상이 일어나는 이유는 저 getter 하나가 캡슐화를 망쳤기 때문이다.
getter를 잘못 사용하면 생기는 문제는 특정 객체만 알아야 할 자원을 공유함으로써 역할이 모호해진다. 그리고 사실 Client는 몰라도 되는 Food를 앎으로써 Food에서 변경이 일어날 때, Client에서도 변경이 일어난다. 따라서 Kiosk에 있는 getFoodList를 지우고 Kiosk에게 주문응답과 시간을 말하는 역할을 넘기자. Kiosk 내부에 함수를 다음처럼 작성했다.
public void showFoodList() {
for (Food food : foods) {
System.out.println(food.getName());
}
}
public void handleOrder(String foodRequest) {
Optional<Food> orderedFood = foods.stream().filter(food ->
food.getName().equals(foodRequest)).findFirst();
if(orderedFood.isPresent()) {
Food findFood = orderedFood.get();
System.out.println("요청하신 " + findFood.getName() + "에 대한 주문이 완료되었습니다.");
System.out.println("요청하신 주문의 예상 시간은 " + findFood.getEstimatedTime() + "입니다.");
}
else
System.out.println("요청하신 주문이 존재하지 않습니다.");
}
그럼 이제, Kiosk가 그 역할을 가져가고 getter를 없애버리면 다음처럼 Client는 Kiosk 목록을 보고 주문하면 Kiosk가 주문을 처리해준다는 역할이 order라는 메서드 내부에 자연스럽게 드러나게 된다.
public class Client {
private Kiosk kiosk;
private Scanner scanner = new Scanner(System.in);
public Client(Kiosk kiosk) {
this.kiosk = kiosk;
}
public void order() {
kiosk.showFoodList();
String foodRequest = askUserChoice();
kiosk.handleOrder(foodRequest);
}
}
단순한 예제라 문제가 심각하게 잘 보였겠지만 다음을 설명하기 위한 예제였다. 정리를 해보면 getter를 사용하면 객체 간 자원을 공유하게 된다. 자원을 공유하면 역할 경계가 모호해진다. 위의 예시에선 Client의 역할이 커지고 Food에 관한 불필요한 의존관계가 생김을 알 수 있었다. 이는 Food에 대한 변경이 Client에게도 영향이 감을 의미하며, 따라서 getter는 캡슐화를 깨는 존재임을 알 수 있다.
캡슐화의 관점에서 getter가 나쁜 메서드임을 알 수 있는 법칙이 있다. 데미테르의 법칙이라는 것이 있는데, 이 법칙은 아래의 세 개의 규칙을 언급한다.
○ 메서드에서 생성한 객체의 메서드만 호출
○ 파라미터로 받은 객체의 메서드만 호출
○ 필드로 참조하는 객체의 메서드만 호출
첫 번째와 세 번째는 위의 예시와 관련이 되어있으니 어느정도 이해가 될 것이다. 하지만 두 번째는 예시가 위로는 부족하다. 그래서 다른 예시를 들고 왔다. 이번에는 getter로 체인이 생겨버리는 예시이다. 책의 예시가 가장 와닿아서 약간만 변형했다.
신문 배달부는 고객으로부터 배송료를 받는다. 이 때 금액이 충분하면 완료 메시지를 남기고 아니라면 예외를 던진다. 이를 표현하기 위해 다음처럼 코드를 짰다. 이 코드는 작동에는 문제가 없겠지만 예시는 고객으로부터 돈을 받는게 아니라 신문 배달부가 고객의 지갑을 뺏어서 돈을 확인한 뒤 됐다 안됐다를 얘기하는 상황이다.
public class Paperboy {
Customer currentCustomer = null;
public void payDeliveryFee(Customer customer, int deliveryFee) {
currentCustomer = customer;
if (customer.getWallet().getMoney() >= deliveryFee) {
System.out.println("배송 비용 지불이 완료되었습니다.");
} else {
throw new IllegalArgumentException();
}
}
}
이런 문제가 발생하는 이유는 getter가 체인 형태로 파라미터에 있는 객체의 필드의 메서드를 호출했기 때문이다. 이는 데미테르의 법칙 중 파라미터로 받은 객체의 메서드만 호출하라는 규칙을 어긴 것이다. 이 또한 역할 분리를 제대로 하지 않고 객체 간의 캡슐화를 제대로 하지 않은 채로 객체끼리 불필요한 자원 공유를 했기 때문에 일어난 일이다.
캡슐화가 제대로 일어나지 않는 코드들의 전형적인 특징은 getter를 호출하면서 생기는 자원 공유임을 알 수 있다. 따라서, getter를 사용하지 말라는 이야기를 한 것인데, 이유를 설명하지 않은 것 같다.
2. Getter를 어떻게 바라보아야 할까?
사실, getter 메서드를 무조건 사용하지 말라는 것은 의미파악을 잘못한 것이라고 생각한다. 특히나 스프링에서 컨트롤러 단에서의 Json의 직렬화와 역직렬화에는 객체 값을 채우고 응답을 내보내기 위해 getter가 필요하고 스펙상 그렇게 정의가 되어있다. 또한 컨테이너 같은 곳에서는 이름은 다를 수 있겠지만 상황에 맞는 객체를 받아와 필드에 넣는 것도 중요하다.
정말 코드를 잘 짜는 사람이라서, 정확하게 역할을 분리할 줄 아는 사람이라면 객체에게 적절하게 자원을 할당해줄 것이고 이런 문제가 발생하지 않을 것이다. 일단, 필자는 그런 자신이 없다.
해답으로 책에서는 데미테르의 법칙을 지키지 않는 전형적인 증상 두 가지로 아래를 소개한다.
○ 연속된 get 메서드 호출
○ 임시 변수의 get 호출이 많음
당연히 get메서드가 체인으로 호출되고 get메서드가 반복돼서 호출되면 개발자는 이상함을 깨달을 것이다. 하지만 실제로 겪을 문제는 다른 곳에 있지 않을까 싶었다. 여러 코드를 보면서 느낀 건, get메서드를 어딘가에 할당하는 행위가 문제라고 생각했다.
보통, get메서드로 가져온 값은 필드가 아니라면 어딘가에 할당하여 이름을 줄이려고 한다. 보통 get메서드로 가져온 값들은 변수명 + 메서드 + 파라미터라 길이가 길기 때문이다. 이는 아래의 코드를 보면 알 수 있다. 위에서는 가독성을 위해 wallet이라는 곳에 get메서드로 받은 값을 할당해서 실제로는 get 체인이 있음에도 보이지 않는다.
// 문제가 잘 보이지 않는 코드
public void payDeliveryFee(Customer customer, int deliveryFee) {
currentCustomer = customer;
Wallet wallet = customer.getWallet();
if (wallet.getMoney() >= deliveryFee) {
System.out.println("배송 비용 지불이 완료되었습니다.");
} else {
throw new IllegalArgumentException();
}
}
// 문제가 잘 보이는 코드
public void payDeliveryFee(Customer customer, int deliveryFee) {
currentCustomer = customer;
if (customer.getWallet().getMoney() >= deliveryFee) {
System.out.println("배송 비용 지불이 완료되었습니다.");
} else {
throw new IllegalArgumentException();
}
}
뿐만 아니라, get호출이 많음에도 위의 경우에는 get호출이 잦아 문제가 있는 게 보이지만 아래는 get메서드가 자주 호출되지 않는 느낌이 들어 문제가 없어보인다.
// 문제가 잘 보이지 않는 코드
public boolean isExpired() {
if (gender.equals(Gender.MALE) && expireDate.getDate() < System.currentTimeMillis())
return false;
else if (gender.equals(Gender.FEMALE) && (expireDate.getDate() + 30 < System.currentTimeMillis()))
return false;
else if(gender.equals(Gender.MALE) && expireDate.getDate() >= System.currentTimeMillis())
return true;
else if (gender.equals(Gender.FEMALE) && (expireDate.getDate() + 30 >= System.currentTimeMillis()))
return true;
else
return false;
}
// 문제가 잘 보이는 코드
public boolean isExpired() {
int date = expireDate.getDate();
if (gender.equals(Gender.MALE) && date < System.currentTimeMillis())
return false;
else if (gender.equals(Gender.FEMALE) && (date + 30 < System.currentTimeMillis()))
return false;
else if(gender.equals(Gender.MALE) && date >= System.currentTimeMillis())
return true;
else if (gender.equals(Gender.FEMALE) && (date + 30 >= System.currentTimeMillis()))
return true;
else
return false;
}
따라서 완전히 종료되기 전까지 값을 따로 할당하지 않거나 아니면 get메서드 호출 여부를 메서드 작성 후 한번 getter의 측면에서만 조사를 해보는게 좋지 않을까 싶다. 또한 무조건 getter가 나쁘고 호출하면 안되는 것이야가 아니라 어떤 상황에서는 반드시 써야하고 아닌가를 구분하는 과정을 거쳐보고 사용하면 좋을 것 같다.
[ 참고자료 ]
개발자가 반드시 정복해야 할 객체 지향과 디자인 패턴 - 최범균
'Java > OOP' 카테고리의 다른 글
스프링 컨테이너와 서비스 로케이터가 필요한 이유 (0) | 2024.04.16 |
---|---|
자바와 SOLID 원칙(TIL) (0) | 2024.04.13 |
자바의 상속, 추상, 다형에 관하여(TIL) (0) | 2024.04.12 |