비지니스 로직에 사용되는 Getter를 지양하자

김민우·2023년 12월 12일
0

잡동사니

목록 보기
14/22

지난 우아한테크코스 6기 프리코스를 진행하면서 Getter를 지양하는 것을 항상 목표로 삼았다.

물론, 도메인을 조회를 하는 경우 Getter는 가히 필수적이다. 그럼 어떤 상황에서 Getter를 지양해야 될까? 더 나아가 왜 사용하면 안될까? (사용하면 진짜 편하던데...)

캡슐화, Getter/Setter


객체 지향 원칙 중 하나는 캡슐화(Encapsulation)이다. 이를 위해 JAVA는 필드를 private 접근 지정자를 통해 직접적인 접근을 막고 public 메서드를 통해 간접적으로 상태를 판단하게 한다.

public 메서드에 사용되는 대표적인 메서드가 Getter, Setter라 볼 수 있다. 여기서 이게 과연 캡슐화가 이뤄지는 건가? 라는 의문이 들 수 있다. 객체에 대한 Getter, Setter를 다 열어두면 그냥 필드를 public으로 열어두면 되지 않을까?

Setter의 경우 이에 대한 답을 간단히 정리 할 수 있다. 바로 대입되는 값에 대한 검증을 진행할 수 있다.

public class User {
    public int age;

    public User(final int age) {
        this.age = age;
    }
}
public class UserService {
    public void setUserAge(final User user) {
        user.age = -10;
    }
}

나이는 음수가 되면 안되지만 필드를 public 으로 설정한다면 이와 같은 상황을 통제할 수 없을 것이다.

public class User {
    private int age;

    public User(final int age) {
        this.age = age;
    }

    public void setAge(final int age) {
        if (age < 0) {
            // 예외 처리...
        }
        this.age = age;
    }
}

Setter를 통해 필드값을 변경한다면 대입되는 값에 대한 검증이 가능하므로 위험성이 적어질 것이다.

이제 필드를 private으로 두고 public 메서드를 통해 이를 간접적으로 사용해야 된다는 것을 알 수 있다. 본격적으로 Getter를 지양해야 되는 이유에 대해 살펴보자.

Getter를 통해 비지니스 로직을 수행하지 말자


아래는 Student 객체의 score 필드를 통해 성적을 출력하는 코드다.

public class Student {
    private final int score;

    public Student(final int score) {
        this.score = score;
    }

    public int getScore() {
        return score;
    }
}
public class StudentService {
    public void printGrade(final Student student) {
        if (student.getScore() >= 90) {
            System.out.println("A");
        } else if (student.getScore() >= 80) {
            System.out.println("B");
        } else if (student.getScore() >= 70) {
            System.out.println("C");
        } else if (student.getScore() >= 60) {
            System.out.println("D");
        } else {
            System.out.println("F");
        }
    }
}

코드 자체는 크게 문제되는 것 없이 잘 동작한다. 그런데, 요구사항이 변경되어 아래와 같이 Student 필드가 수정된다면 어떨까?

public class Student {
    private final int englishScore;
    private final int mathScore;
    ...
}

StudentService 에선 이를 알아차리지 못하고 기존 score 필드에 의존하여 학점을 출력하려 하므로 컴파일 에러가 발생하게 된다.

이 외에 Student.getScore() 을 사용하는 모든 코드를 수정해야할 것이다.

이렇듯 Getter에 의존하는 비지니스 로직들은 유지보수 및 확장에 대응하기 힘들다. 이는 한 객체의 상태를 외부에서 판단하는 경우라 볼 수 있다. 이러한 상황을 막기 위해선 getter를 사용하기 보단 해당 객체에서 상태를 판단하여 다른 객체에 전달하는게 더 유리하다.

조회를 위한 Getter 사용


앞서 언급했듯이 조회를 위한 Getter의 사용은 필수다. 특정 필드값을 다른 계층으로 전달한다거나 출력하는 경우가 이에 해당된다.

중요
다시 한번 강조하지만 .getXXX() 메서드 자체가 문제되는 것은 아니다. 객체의 구성 요소를 외부에서 판단하여 조작하게 만드는 설계 구조가 문제다.

아래 클래스를 예로 들겠다.

@Getter
public class User {
    private final Long id;
    private final String name;
    private final String email;
    private final String password;

    public User(final Long id, final String name, final String email, final String password) {
        this.id = id;
        this.name = name;
        this.email = email;
        this.password = password;
    }
}

특정 User 객체들에 대해 조회를 수행할 것이다. 이러한 경우 외부에서 컬렉션을 사용하여 값을 저장한다면 add(), remove() 등의 메서드로 부터 불변성을 보장받지 못한다. 이를 위해 일급 컬렉션을 사용하겠다.

public class Users {
    private final List<User> users;

    public Users(final List<User> users) {
        this.users = users;
    }
}

이제 Users에 있는 객체들의 상태를 출력하고 싶은 경우를 생각해보자. 아래와 같이 List 필드에 대한 getter를 제공하는 경우 컬렉션의 불변성이 지켜지지 않는다. (일급 컬렉션을 사용해도 메서드를 잘못 제공한다면 쓰는 의미가 없어지니 주의하자!)

public class Users {
    ...
    public List<User> getUsers() {
        return users;
    }
}

이제 일급 컬렉션에서 객체의 특정값을 어떻게 줘야 안전한지 살펴보자.

1. 하나의 필드만 필요한 경우

외부에서 특정 User의 이름을 출력해야되는 경우, 기존 값을 그대로 복사한 새로운 List를 반환하면 된다.

여기서 중요한 건 리스트에 있는 값을 그대로 전달하는 것이 아닌 이를 복사한 새로운 값을 전달한다는 점이다.

public class Users {
    private final List<User> users;

    public Users(final List<User> users) {
        this.users = users;
    }

    public List<String> getNames() {
        return users.stream()
                .map(User::getName)
                .collect(Collectors.toUnmodifiableList());
    }
}

Stream의 toList() 를 사용하면 더 간단히 할 수 있다.

public class Users {
    private final List<User> users;

    public Users(final List<User> users) {
        this.users = users;
    }

    public List<String> getNames() {
        return users.stream()
                .map(User::getName)
                .toList();
    }
}

cf) toList() 구현부

default List<T> toList() {
        return (List<T>) Collections.unmodifiableList(new ArrayList<>(Arrays.asList(this.toArray())));
}
  • new 연산자를 통해 새로운 리스트를 반환하는 것을 알 수 있다.

2. 여러 필드가 필요한 경우

User의 이메일, 이름과 같이 여러 필드가 필요한 경우를 생각해보자.

값이 2개라면 Map<K, V>, Tuple 등의 자료 구조를 사용할 수 있지 않을까? 라는 생각도 드는데 이는 당장의 필드 개수에 대해 너무 의존적인 설계라 판단하여 고려하지 않았다. (확장에 불리하다고 생각)

그렇다면 별도의 DTO 클래스를 두는 방법은 어떨까? 아래 코드를 보자.

public class UserDto {
    private final String name;
    private final String email;

    public UserDto(final String name, final String email) {
        this.name = name;
        this.email = email;
    }

    public static UserDto from(final User user) {
        return new UserDto(user.getName(), user.getEmail());
    }
}
public class Users {
    ...
    
    public List<UserDto> getUserDtos() {
        return users.stream()
                .map(UserDto::from)
                .toList();
    }
}

보자마자 느꼈겠지만 도메인이 DTO에 의존하고 있다. 도메인이 변경 가능성이 큰 DTO 클래스를 의존한다는 건 매우 좋지 않은 설계다.

고민 끝에 2가지 방법을 생각해봤다.

2-1. 대화식을 사용하여 원하는 필드값을 얻어오기

인덱스를 활용하여 특정 원소의 필드값을 얻어오는 메서드를 생각했다.

public class Users {
    ...
    public String getNameFromIndex(final int index) {
        return users.get(index).getName();
    }
    
    public String getEmailFromIndex(final int index) {
        return users.get(index).getEmail();
    }
}

이 방법은 출력 요구 사항이 변경될 수록 도메인에 여러 메서드가 추가/수정되므로 결과적으로 도메인이 View에 의존한다 생각했다.

또한, 리스트의 크기를 알아야 외부에서 이 메서드를 사용할 수 있어 캡슐화가 이뤄지지 않고 필터링을 하기 힘들다는 단점이 있다.

2-2. 새로운 도메인 객체를 만들어 반환

특정 값만 가져오거나 이를 별도의 객체로 반환하는 건 힘든거 같으니 User를 그대로 반환하기로 결정했다. 여기서 중요한 것은 기존 객체를 그대로 복사한 새로운 객체를 만들어 반환 해야된다는 점이다.

public class Users {
    ...
    public List<User> getUsers() {
        return users.stream()
                 .toList();
    }
}

Service 레이어에서 이를 별도의 Service DTO로 변환하여 사용하면 필터링도 용이할 것이다. 추가로, 반환값에 add(), remove() 등의 메서드가 수행되더라도 기존값에 아무 영향을 주지 않으므로 불변성이 보장된다.

결론


지금까지 프로젝트를 진행하면서 비지니스 로직도 Getter 메서드를 통해 진행하도록 구현한게 많았다. 도메인을 생성하면 @Getter 를 붙이는게 습관이 됐을 정도로 Getter에 대한 의존성이 심했다.

이번 프리코스에서 Getter를 사용하지 않고 로직을 구현하니 시간이 오래걸렸는데, 이를 통해 어떤 상황에서 Getter를 왜 지양해야 되는지 구체적으로 알 수 있었다.

0개의 댓글