본문 바로가기
시리즈/소프트웨어 공학

[OOP] Getter, Setter 를 지양하는 이유에 대해서 ①

by 되고싶은노력가 2024. 11. 4.

들어가며.

객체지향에 대해 공부하다보면 반드시 듣게 되는 내용이 아닌가 싶다. 하지만 왜 Getter, Setter를 지양하는 지에 대해서 스스로에게 물어보면 바로 이유가 생각나지 않거나, 막상 개발에 적용하려고하면 어쩔 수 없이 사용해야 하는 상황에서 누군가는 사용하지 말라고는 하는데 어떻게 대처를 해야하는 지 막막한 경우가 참 많다는 것을 느꼈다.

 

그래서 왜 우리가 Getter와 Setter를 지양해야하는 지, 또한 사용해야하는 상황은 어떤 경우인지 정리하고자 한다.


Getter와 Setter를 사용하는 이유.

사용을 지양하기 이전에 왜 우리가 Getter와 Setter를 사용했는 지를 먼저 알아야 한다고 생각한다.

 

객체 지향의 원칙 중 하나는 정보 은닉으로 객체의 구체적인 정보를 외부에 노출하지 말라는 것이다. 이러한 이유로 자바는 멤버 변수를 private로 숨기고 public 메서드를 통해 간접적으로 외부에 전달하게 된다.

 

하지만 위 내용을 보면 단순히 private로 멤버 변수를 숨기고 모든 변수에 대해 Getter, Setter로 열어준다면 정보 은닉 효과를 본다고 말할 수 있을까? 이렇게 모든 걸 열어준다면 public 메서드로 간접적으로 전달하는 의미가 퇴색되게 되고 쓸대없는 코드만 늘어나는 상황이 될 것이다.

 

이러한 경우를 막고 무분별한 Getter, Setter 사용을 줄이기 위해 우리는 Getter로 값을 가져와 처리하는 것이 아닌 상태를 가지는 객체 내부에서 명령에 의해 스스로 변경하는 것이 옳다고 할 수 있지 않을까? 라는 생각을 가질 수 있다.


Setter를 지양해야 하는 이유.

Setter는 객체의 상태를 외부에서 쉽게 바꿀 수 있기에 굉장이 편해보이지만 많은 문제점을 가진다.

 

첫 번째, 변경의 의도를 알 수 없다.

개발은 혼자서 하는 것이 아니다. 우리가 단순하게 Setter를 넣으면 다른 개발자는 어떤 의도로 넣은 것인지 명확하게 알 수 없다.

public class Lottos {

    public Lottos(List<Lotto> lottos, int amount) {
        this.lottos = lottos;
        this.amount = amount;
    }
    
    public void setLottos(List<Lotttos> lottos) {
        // ...    
    }
}

 

위와 같이 내부에서 보면 lottos 를 어떻게 변경하는지 알 수 있겠지만 외부에서 바라보면 lottos의 값을 추가한건 지 아예 주소를 변경한건 지 명확히 알 수가 없다.

 

두 번째, 책임이 분산된다.

Setter를 사용하게 되면 언제든지 외부에서 객체 상태를 바꿀 수 있게 된다. 그렇게 되면 어디서 어떤 상태가 변경되는지 파악하지 힘들어지고, 해당 객체가 해야할 일을 다른 객체가 대신 해주고 있는 도중에 객체 구조가 변경된다면 사용된 모든 코드를 수정해야하는 일이 발생하고만다. 즉, 코드의 가독성과 유지보수가 매우 어려워진다.


Getter를 지양해야 하는 이유.

Setter는 쉽게 알겠지만 단순히 값을 조회하는 Getter는 왜 지양해야하는 지 알기 어렵지만 답은 책임에 있다.

 

첫 번째, 객체가 객체스럽지 못하게 된다.

객체는 캡슐화된 상태와 외부에 노출되어 있는 행동을 갖고 있으며, 다른 개체와 메시지를 주고 받으면서 협력한다. 객체는 메시지를 받으면 객체에 맞는 로직을 수행하게 되고, 필요하다면 객체 스스로 내부의 상태값도 변경한다.

 

그런데 모든 멤버변수에 getter를 생성하고 그 값으로 객체 외부에서 로직을 수행한다면, 객체가 로직을 갖고 있는 형태가 아니고 메시지를 주고 받는 형태도 아니게 된다. 또한, 객체 스스로 상태값을 변경하는 것이 아니고, 외부에서 상태 값을 변경할 수 있는 위험성도 생긴다.

 

무분별한 Getter의 사용은 디미터 법칙을 위반할 수도 있는데, 디미터 법칙을 위반한 코드는 아래와 같다.

object.getChild().getContent().getItem().getTitle();

 

 

두 번째, Getter를 통해 조건을 검사하면 변경에 취약하다.

로또를 예시로 들어보자면,

public String printRank(List<Integer> winningNumbers, int bonusNumber) {
    final int matchedCount = lotto.getMatchedCount(winningNumbers);
    final boolean isBonus = lotto.hasBonusNumber(bonusNumber);
    
    if (matchedCount == 6) {
    	return "FIRST_PLACE"
    }
    
    if (matchedCount == 5 && isBonus) {
    	return "SECOND_PLACE"
    }
    
    if (matchedCount == 5 && !isBonus) {
    	return "THIRD_PLACE"
    }
    
    if (matchedCount == 4) {
    	return "FOURTH_PLACE"
    }
    
    if (matchedCount ==3) {
    	return "FIFTH_PLACE"
    }
}

 

외부에서 해당 Lotto 상태를 가져와 비교를 하다가 matchedCount 를 다른 Rank라는 클래스에서 처리하도록 리팩토링 하면 어떻게 될까? 위 코드를 전부 고쳐야하는 상황이 오게 될 것이다.


무조건 사용하지 말라는게 아니다.

우리가 프로젝트를 진행하다보면 객체의 상태를 바꿔야하거나 조회해야 하는 경우가 분명히 존재하는데 Getter, Setter를 사용하지 않고 어떻게 해야할까?

 

Setter 대신 명확한 의도를 가진 메서드를 사용하라.

로또 미션에서는 값을 변경할 필요가 없는 요구 사항이기에 다른 분의 블로거를 참고하겠습니다.

@Setter
class Account {
    private long balance;
}
 
@Service
public class AccountService {
    ...
    
    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);
    }
    
    ...
}

 

이 코드에서는 AccountService에서 계좌의 잔액이 충분한지 확인하고 계좌의 잔액을 변경하고 있다. 이렇게 하지 말고 계좌에 출금할 금액을 전달해서 출금하라고 시키면 다음과 같은 코드가 된다.

class Account {
    private long balance;
    
    public void withdraw(long amount) {
        if (amount > balance) {
            throw new IllegalArgumentException("잔액이 부족합니다.");
        }
        
        balance -= amount;
    }
}
 
@Service
public class AccountService {
    ...
    
    public void withdraw(long id, long amount) {
        Account account = accountRepository.findById(id).orElseThrow();
        account.withdraw(amount);
    }
    
    ...
}

 

코드도 간결해졌고, 출금을 하겠다는 의도도 명확해졌다. 혹시나 출금 로직이 변경되더라도 Account 클래스의 withdraw()의 내용만 수정하면 됩니다.

 

값을 변경해야하는 경우에는 외부에서 Setter로 변경하는게 아닌 객체 내부에 명확한 로직을 만들어두면 외부에서는 단순히 호출만 하면 되기에 다른 개발자는 신경쓸 필요가 없어지게 된다.

 

객체가 가진 상태를 스스로 책임지게 해라.

방법은 외부에서 비즈니스 로직을 처리하는 것이 아닌 내부에서 처리하도록 하고 결과만을 반환하도록 하면 자연스럽게 해결된다.

 

위 코드를 Rank 라는 등수에 대한 데이터 집합 Enum 을 작성하고 비교하는 로직을 내부로 넣었다.

public enum Rank {
    NOTHING(0, 0),
    FIFTH_PLACE(3, 5000),
    FOURTH_PLACE(4, 50000),
    THIRD_PLACE(5, 1500000),
    SECOND_PLACE(5, 30000000),
    FIRST_PLACE(6, 2000000000);

    private final int matchedCount;
    private final long winningAmount;

    Rank(int matchedCount, long winningAmount) {
        this.matchedCount = matchedCount;
        this.winningAmount = winningAmount;
    }

    public static Rank findRank(int matchedCount, boolean isBonus) {
        Rank find = Arrays.stream(Rank.values())
                .filter(rank -> rank.matchedCount == matchedCount)
                .findFirst()
                .orElse(NOTHING);

        if (find == Rank.THIRD_PLACE && isBonus) {
            return Rank.SECOND_PLACE;
        }

        return find;
    }
}

 

Rank에서 상태를 관리하면서 각자 객체가 가진 책임을 처리하도록 하면 의도가 명확해지고 분산되어 있던 수정에 취약한 문제도 해결 할 수 있게 됩니다.

public Rank getRank(List<Integer> winningNumbers, int bonusNumber) {
    final int matchedCount = getMatchedCount(winningNumbers);
    final boolean isBonus = hasBonusNumber(bonusNumber);
    return Rank.findRank(matchedCount, isBonus);
}

 

이러한 코드를 Lotto 클래스 내부에 구현하면 결과만을 반환하는 메서드를 작성할 수 있게 됩니다.


마치며.

무분별한 Getter와 Setter의 사용은 객체 지향의 핵심인 정보 은닉을 해치게 된다. 물론 무조건 사용하지 말라는 것이 아닌 지양이다. 결국 출력을 위한 값이나 순수 프로퍼티 값을 가져오기 위해서라면 어느정도의 Getter가 필요할 것이다.

 

단, 결과를 가져오더라도 함부로 변경하지 못하도록 final 이나 불변 컬렉션을 적극적으로 활용하는 것을 권장한다.


참고자료

https://colabear754.tistory.com/173

https://seoarc.tistory.com/52

https://tecoble.techcourse.co.kr/post/2020-04-28-ask-instead-of-getter/