Programming Language/Common

[OOP] Getter와 Setter는 지양하는게 좋다

개발하는 곰돌이 2023. 6. 7. 08:40

목차

    들어가기 전에

    얼마 전 사내에서 Getter와 Setter를 함부로 사용하면 안되는 이유에 대한 세미나가 있었다. Setter에 대한 이야기는 워낙 많이 알려져있었지만 Getter에 대한 이야기는 잘 하지 않다보니 꽤 흥미로운 주제였다.

     

    그렇다면 왜 Getter와 Setter를 함부로 사용하면 안될까? 이 이유를 한 번 알아보자.

    Getter와 Setter를 사용하는 이유

    객체 지향의 원칙 중 하나는 정보 은닉(Information Hiding)이다. 객체의 구체적인 정보를 외부에 노출하지 말라는 것이다. 이러한 이유 때문에 자바에서는 클래스를 작성할 때 모든 필드를 private으로 숨기고 public 메소드를 통해 간접적으로 필드를 다루게 된다. 코틀린의 경우에는 프로퍼티를 private으로 숨기지는 않지만, 이 역시 내부적으로 보면 필드는 private으로 숨겨져있고 public인 Getter와 Setter를 통해 간접적으로 접근하게 된다.

    근데 왜 Getter / Setter를 쓰지 말라는거야?

    엄밀히 말해서 필드를 private으로 숨겨놓고 Getter와 Setter를 모두 public으로 열어서 사용하는 것은 정보 은닉의 효과를 볼 수 없다. 필드 자체는 잘 숨겨놓고 그 값을 Getter로 조회할 수 있고 Setter로 수정할 수 있으면 이 정보가 정말로 숨겨졌다고 할 수 있을까?

     

    또한 객체 지향 프로그래밍은 다양한 객체들이 협력하여 하나의 애플리케이션으로 동작하도록 하는 프로그래밍 방법이다. 이런 객체 지향 프로그래밍의 장점 중에는 상대 객체의 세부 정보를 알 필요 없이 그저 객체에 무언가를 요청만 하면 된다는 점이 있다.

     

    그런데 객체에 요청을 하지 않고 객체의 정보를 조회하여 직접 비즈니스 로직을 수행하거나 객체의 속성을 다짜고짜 바꾸라고 한다면? 객체 지향의 장점은 퇴색되고 말 것이다.

     

    그러면 Getter / Setter를 쓰면 안되는 자세한 이유를 Setter부터 알아보자.

     

    다만 이 글에서 언급하는 Getter는 메소드의 인자로 넘기기 위한 경우처럼 정말로 값이 필요해서 사용하는 경우가 아니라 Getter로 값을 가져와서 비즈니스 로직을 수행하는 경우를 다룬다.

    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의 잔액을 1000으로 설정하고 있다. 그런데 잔액이 왜 1000이 되었는가? 이 전체 코드를 보면 계좌에 입금을 해서 잔액이 1000으로 늘었다는 것을 알 수 있다. 하지만 myAccount.setBalance(1000)만 봤을 때는 계좌에 입금을 해서 잔액이 1000이 되었는지, 계좌에서 인출을 해서 잔액이 1000 남았는지 알 수 없다.

     

    이렇게 Setter를 사용하면 객체의 속성이 갖는 값을 바꾼 이유를 명확하게 알 수 없다.

    다른 객체들로 책임이 분산된다.

    Setter를 사용하면 해당 객체가 해야할 일(책임)을 다른 객체가 해야할 수도 있다.

    @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);
        }
        
        ...
    }

    원래라면 Account 객체에서 자신의 잔고를 관리하는 책임을 져야하지만 이 코드에서는 AccountService가 출금하려는 계좌의 잔고가 충분한지까지 확인하고 있다. Account가 책임을 다하지 않았기 때문에 AccountService에서 Account가 할 일을 대신 해주고 있는 것이다.

     

    이렇게 어떤 객체가 해야할 일을 다른 객체가 대신 해주고 있는데 객체의 구조가 변경된다면 그 객체의 일을 대신 해주는 모든 코드를 수정해야 한다. 이 얼마나 끔찍한 일인가!

    Getter를 지양해야 하는 이유

    Setter를 지양해야 하는 이유는 확실하게 와닿는 편이다. 그런데 단순히 값을 조회만 하는 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);
    }

    여기서는 계좌에서 인출을 하기 위해 Account 객체에서 잔액을 조회해서 인출할 금액만큼 차감해보고 그 금액이 음수가 되는지 확인하고 있다. 즉, 아래 과정을 수행하고 있는 것이다.

    1. 계좌에 있는 잔액을 알려줘.
    2. 계좌에서 금액을 인출했을 때 잔액을 계산해보자.
    3. 금액을 인출했을 때 잔액이 0보다 작은지 확인해볼까?

    뭔가 이상하다. 그냥 인출할 금액을 전달해서 잔액이 충분한지만 물어보면 되는데 그걸 직접 확인해보는 셈이다.

    Getter를 통해 조건을 검사하면 변경에 취약하다

    다음 예제 코드는 고객의 도시가스 이용 요금에 따라 고객 등급을 출력하는 예제이다.

    @Getter
    class Customer {
        private long charge;
    }
    
    @Service
    public class CustomerService {
        ...
        
        public void printMembershipGrade(Customer customer) {
            if (customer.getCharge() >= 100000) {
                System.out.println("Gold");
            } else if (customer.getCharge() >= 50000) {
                System.out.println("Silver");
            } else {
                System.out.println("Bronze");
            }
        }
        
        ...
    }

    코드 자체는 별 문제가 없어보인다. 그런데 요구사항이 변경되어 고객이 요금 대신 사용량과 단가만 갖게 된다면 어떻게 될까?

    @Getter
    class Customer {
        private long usages;
        private long unitPrice;
    }

    이제 고객 객체에는 이용 요금에 대한 정보가 사라지고 사용량과 단가에 대한 정보가 담기게 되었다. 하지만 CustomerService에서는 이러한 사실을 알지 못한다. 더 이상 존재하지 않는 이용 요금을 조회하려고 할테니 당연히 컴파일 에러가 발생하게 된다.

     

    이 뿐만 아니라 기존에 charge의 값을 조회해서 사용하고 있던 코드를 모두 수정해야 한다. 역시 매우 끔찍한 일이다.

    이 역시 객체가 고객 등급을 결정하는 책임을 지지 않아서 발생한 문제이다.

    그럼 Getter / Setter를 안 쓰고 어떻게 하라고?

    지금까지 Getter와 Setter의 취약점에 대해 알아보았다. 하지만 프로젝트를 진행하다 보면 객체의 상태를 바꿔야하거나 조회해야 하는 경우가 분명히 존재한다. 그런데 Getter와 Setter를 쓰지 말라니?

     

    아무런 대안도 없이 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()의 내용만 수정하면 된다. 다른 코드들은 Account의 withdraw()를 호출하고만 있어서 신경 쓸 필요가 없다.

     

    이렇게 Setter 대신 명확한 의도를 가진 메소드를 사용했을 뿐인데 로직의 의도가 확실해졌고 계좌가 져야 할 책임이 분산되어 수정에 취약했던 문제도 해결되었다.

    Getter로 조건을 검사하지 말고 결과를 반환하게 하라

    앞서, 도시 가스 이용 요금에 따라 고객 등급을 출력하는 예제에서는 고객의 정보가 바뀌면서 문제가 발생했다. 이를 방지하기 위해 고객 객체에게 고객 등급을 반환하라고 시키면 아래와 같은 코드가 된다.

    class Customer {
        private long usages;
        private long unitPrice;
        
        public String calcMembershipGrade() {
            long charge = usages * unitPrice;
            
            if (charge >= 100000) {
                return "Gold";
            } else if (charge >= 50000) {
                return "Silver";
            } else {
                return "Bronze";
            }
        }
    }
    
    @Service
    public class CustomerService {
        ...
        
        public void printMembershipGrade(Customer customer) {
            System.out.println(customer.calcMembershipGrade());
        }
        
        ...
    }

    이제 더 이상 CustomerService에서 고객의 등급을 출력하기 위해 고객의 상태를 알 필요가 없다. 그저 고객 객체에게 고객 등급을 계산해 달라고 시키기만 하면 되는 것이다.

    마치며

    이상으로 Getter와 Setter를 지양해야 하는 이유와 그 대안에 대해 알아보았다. 

     

    무분별한 Getter와 Setter의 사용은 객체 지향의 핵심인 정보 은닉을 해치게 된다. 이로 인해 외부에서 객체의 상태를 알게 되거나 객체의 상태를 그대로 수정하게 되고, 객체의 상태가 변경되면 의도하지 않은 동작을 수행하여 문제가 발생할 수 있다.

     

    따라서 Getter와 Setter는 꼭 필요한 경우가 아니라면 사용하지 않고, 대신 객체에 직접 요청할 수 있는 메소드를 작성해서 사용하는게 좋을 것이다.