보통 getter()는 멤버 변수의 값을 호출하는 메서드로 사용되고, setter()는 멤버 변수의 값을 변경시키는 메서드이다.
상태 값을 갖는 객체에서는 상태 값을 외부에서 직접 접근해 변경하지 못하도록 메서드만 노출시킨다. 즉 멤버 변수는 접근 제한자를 private으로 설정해 직접적인 접근을 막는다.
그런데 getter, setter는 private으로 설정해놓은 멤버 변수에 접근하여 그 값을 활용하거나 값을 변경하게 된다. 특히 setter는 값을 변경할 수 있으므로 위험하다. 캡슐화가 깨지게 되는 것이다.
setter는 우선 사용하지 말라고 했을 때 할 수 있는 방법이 바로 떠오르기도 한다. 그런데 getter는 대체하기가 까다롭다. 따라서 읽기 기능에 대한 메서드인 getter에 대해 알아보려고 한다.
무분별한 getter?
객체는 캡슐화된 상태와 외부에 노출되어 있는 행동들을 가지고 있으며, 다른 객체와 메시지를 주고 받으며 협력한다. 객체는 메시지를 받으면 객체에 따른 로직을 수행하게 되고 필요하다면 객체 스스로 내부의 상태 값도 변경한다.
만약 모든 멤버변수에 getter를 생성해놓고 상태 값을 꺼내 그 값으로 객체 외부에서 로직을 수행하게 된다면 객체가 로직을 갖고있는 형태가 아니고, 메시지를 주고받는 형태도 아니게 된다. 또한 객체 스스로 상태값을 변경하는 것이 아니고 외부에서 상태값을 변경할 수 있는 위험성도 생긴다.
객체 답지 못한 것이다. 더해 가독성도 떨어질 수 있다.
상태를 가지는 객체를 추가했다면 객체가 제대로 된 역할을 하도록 구현해야 한다.
캡슐화가 깨지면 어떤 일이?
1
2
3
4
public class 출석부 {
public List<Name> studentNames = new ArrayList<>();
}
//리스트에 등록된 순서가 학번이 된다고 가정
위와 같이 public으로 된 학생 이름들을 가지고 있는 어떤 출석부 객체가 있다고 가정해보자.
그렇다면 출석부.studentNames
로 학생부에 있는 학생 이름들 필드에 바로 접근할 수 있다. 이걸 가지고 어떤 로직을 만들어 본다고 가정하자.
1
2
3
4
5
6
7
8
9
10
11
public class 학생서비스 {
public 출석부 출석부 = new 출석부();
public void registerStudent(Name name) {
출석부.studentNames.add(name);
}
public void removeStudentByNumber(int 학번) {
출석부.studentNames.remove(학번);
}
/...
출석부의 studentNames는 List로 되어 있으므로 List Collection을 활용한 서비스 코드가 만들어졌다.
그런데 만약 교칙을 학번을 순서대로 정하지 않고 무작위로 배정된 숫자를 사용한다고 결정했다고 하자. 그렇다면 원래 List의 index를 학번으로 사용했었는데 이제는 사용할 수 없게 된다.
그래서 Map 자료구조로 된 학번 : 학생이름 구조로 출석부를 다시 만들기로 했다.
1
2
3
public class 출석부 {
public Map<Integer, Name> studentNames = new HashMap<>();
}
그리고 다시 서비스 코드를 보자. 기존 List에 맞추어 작성했던 코드들이 과연 사용이 될까?
되지 않는다. Map 자료구조에는 add같은 메서드는 없다.
현재 구조는 학생서비스가 studentNames 필드에 의존하고 있다. 따라서 studentNames가 이렇게 바뀌게 된다면 학생서비스도 바로 영향을 받게 된다. 지금은 예시로 메서드 2개만 사용했지만 만약 메서드가 많은 서비스라면?
엄청난 양의 코드를 수정해야 하게 되는 것이다. 문제는 이뿐만이 아니다.
도메인 규칙 무시
학생서비스에는 학생의 이름을 가지고 등록할 수 있는 메서드가 있었다.
그런데 현재 코드에는 어떠한 제약사항도 걸지 않았기 때문에 실제 이름이 아닌 이상한 특수기호 같은 것도 이름에 등록될 수 있다. 빈 값 등도 들어올 수 있는 것이다.
출석부에는 이름에 대한 검증 메서드를 작성할 수 없다.
그래서 검증 코드를 서비스의 이름 등록부분에 작성한다면?
출석부를 사용하는 객체들이 새로 생겨났을 때 제약 사항이 들어있는 메서드를 사용한다는 보장이 없다. 하나하나 제약사항을 추가해주거나, 아니면 제약사항이 없는 값을 받아들여야 하게 되는 것이다.
도메인 객체를 사용할 때 객체의 값을 외부의 사용자가 가져다 쓰게 됐을 때 도메인 객체에 필요한 규칙들이 무시되고 코드의 안정성이 훼손되는 것이다.
그렇다고 getter을 쓰면 나아지나?
public으로 해놔서 그런게 아니다. getter을 설명하기 위해 public을 사용한 것이다.
getter로 똑같이 값을 가져와도 나중에 Map으로 바뀐다면 또 다 수정해야 한다. getter로 가져온 값도 결국 List이고 Map으로 바뀐다면 .add() 메서드는 활용할 수 없다. 컴파일 오류가 발생한다.
그래서 어떻게 대체해야 하나?
객체의 상태 데이터를 꺼내 로직을 처리하도록 하지 말고 객체에 메시지를 보내 일을 하도록 해야한다.
위의 상황을 가장 간단히 해결하자면
1
2
3
4
5
6
7
public class 학생서비스 {
public 출석부 출석부 = new 출석부();
public void registerStudent(Name name) {
출석부.saveStudent(name);
}
}
1
2
3
4
5
6
7
8
9
10
11
public class 출석부 {
private List<String> studentNames = new ArrayList<>();
public void saveStudent(Name name) {
if (이름에 안되는 조건 검증용) {
throw new Exception();
}
studentNames.add(name);
} //수신한 메시지를 처리하는 구체적인 방법을 객체 안에서만 다루도록 한다.
}
객체에 이름을 등록해라 라는 행동을 요청하는 메시지를 보내면 출석부 객체에서 그 메시지를 받고 이름을 등록하는 행동을 객체 스스로 하게 하는 것이다.
그리고 또 다른 예시로 만약 아래와 같이 Student와 Student를 담는 일급 컬렉션 Students가 있다고 가정해보자.
1
2
3
4
5
6
7
8
9
public class Student {
private int score;
private String name;
public int getScore() {
return this.score;
}
/...
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class Students {
private final List<Student> students;
public Students(List<Students> students) {
this.students = students;
}
public List<String> findFirstScoreStudents() {
int maxScore = students.stream()
.map(student -> student.getScore())
.max(Integer::compareTo)
.get();
return students.stream()
.filter(student -> student.getScore() == maxScore)
.map(Student::getName)
.collect(Collectors.toList());
}
}
Students 컬렉션에서는 성적이 1등인 학생들을 찾는 메서드를 가지고 있다.
Student 객체에서 getScore()를 사용해 상태 값을 직접 꺼내 비교하는 상태이다.
그런데 Students에서 각각의 학생의 Score 값을 비교하는 로직이 수행되는게 맞을까? 를 생각해야 한다.
문제
위 로직은 Student의 멤버 변수인 Score의 값들을 비교하고 있다. 그런데 이 멤버 변수는 private이다.
private이라는 것은 Students가 아닌 Student 객체에서 일을 해야한다는 뜻이다.
즉, score 값을 비교할 또 다른 Student 객체를 Student 객체에 넘겨 Score를 비교해야 한다. Students가 아난 Student에서 일을 하도록 수정하는 것이다.
해결
Student 객체 내에서 학생 객체끼리 score 값을 비교하도록 하고, Student 객체 내에서 maxScore값과 일치하는 지 비교하도록 Students의 로직을 Student 내부로 옮기면 된다.
Student 객체에게 score 값을 비교할 수 있도록 메시지를 보내고, Student 객체에게 maxScore 값과 자신의 score 값이 같은지 물어보는 메시지를 보내는 것이다.
이렇게 객체에게 스스로 비교하라는 메시지를 던지면 getScore()를 사용하지 않을 수 있다.
코드로 봐보자.
1
2
3
4
5
6
7
8
9
public class Student implements Comparable<Student> {
/...
public boolean isSameScore(Student other) {
return this.score == other.score;
}
public int compareTo(Student other) {
return this.score - other.score;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class Students {
/...
public List<String> findFirstScoreStudents() {
Student maxScoreStudent = findMaxScoreStudent();
return findSameScoreStudents(maxScoreStudent);
}
private Student findMaxScoreStudent() {
Student maxScoreStudent = students.stream()
.max(Student::compareTo) //메시지를 보낸다.
.orElseThrow(() -> new IllegalArgumentException());
}
private List<String> findSameScoreStudents(Student maxScoreStudent) {
return students.stream()
.filter(maxScoreStudent::isSameScore) //메시지를 보낸다.
.map(Student::getName())
.collect(Collectors.toList());
}
getter를 무조건 사용하지 말라는 뜻은 아니다.
모든 getter를 사용하지 않고 기능을 구현하기란 쉽지 않다.
출력을 위한 값과 같이 순수 값 프로퍼티를 가져오기 위한 것이라면 일부 getter는 허용될 수 있다.
단, Collection 인터페이스를 사용하는 경우 외부에서 getter 메서드로 얻은 Collection의 내부 값을 변경할 수 있기 때문에 주의해서 사용해야 한다.
1
Collections.unmodifiableList(~);
unmodifiable 문제?
1
2
3
4
5
6
7
8
9
10
11
public class 출석부 {
private final List<Name> studentNames;
public 출석부(List<Name> studentNames) {
this.studentNames = studentNames;
}
public List<Name> getStudentNames() {
return Collections.unmodifiableList(studentNames);
} //필드 값들을 구조를 수정할 수 없는 불변 리스트에 담아 반환한다.
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class Name {
private String name;
public Name(String name) {
this.name = name;
}
public void setName(String name) {
this.name = name;
}
public String getName() {
return name;
}
}
불변 리스트에 담긴 Name은 getter와 setter가 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class 출석부Test {
@Test
@DisplayName("원본 이름과, getter 메소드를 통해 가져온 조회용 이름은 '동일(identical)' 하다.")
void given출석부_ComparingIdentity() {
Name fox = new Name("여우");
Name maco = new Name("마코");
System.out.println("원본 여우 이름의 메모리 주소 : " + fox);
System.out.println("원본 마코 이름의 메모리 주소 : " + maco);
List<Name> names = Arrays.asList(fox, maco);
출석부 출석부 = new 출석부(names);
List<Name> 조회용Names = 출석부.getStudentNames();
System.out.println("조회용으로 가져온 여우 이름의 메모리 주소 : " + 조회용Names.get(0));
System.out.println("조회용으로 가져온 마코 이름의 메모리 주소 : " + 조회용Names.get(1));
}
}
Name을 만들었을 때의 객체 메모리 주소와, 출석부에 담아 불변 컬렉션으로 반환한 컬렉션 내부에 있는 같은 Name 객체 메모리 주소를 비교해보는 코드이다.
각각의 Name의 객체 주소가 똑같은 결과를 볼 수 있다.
이렇게 객체 주소가 같은데 해당 객체에 setName()같이 내부 필드의 값을 수정할 수 있는 메서드가 있다면 복사본의 setName() 메서드를 호출해 이름을 바꿔도 원본 name까지 이름이 바뀌는 문제가 발생한다.
이 문제를 막기 위해서는 getter 메서드를 호출해 복사한 조회용 값이 원본의 메모리 주소를 향하지 않도록, 원본과 같지만 완전히 새로운 복사체를 만들어주어야 한다.
문제 해결
1
2
3
public List<Name> getStudentNames() {
return Collections.unmodifiableList(studentNames);
}
현재의 getter 메서드는 변경이 불가능한 불변 자료구조를 반환한다. 하지만 위 처럼 담겨있는 내부 객체가 변경 가능하다면 원본이 바뀔 수 있다.
이를 개선하기 위해 원본 값인 Name 하나하나 새로운 객체로 만들어준 뒤 리스트에 담는다면 원본과는 전혀 상관없는 새로운 조회용 값을 담아 반환할 수 있다.
1
2
3
4
5
6
7
8
public List<Name> getStudentNames() {
List<Name> clonedStudentsNames = new ArrayList<>();
for (Name studentName : studentNames) {
clonedStudentsNames.add(new Name(studentName.getName())); //
같은 이름을 가진 새로운 Name 객체를 만들어 리스트에 담는다.
}
return Collections.unmodifiableList(clonedStudentsNames);
}
필드의 값을 그대로 리스트에 담는 원래 구조에서 Name 값 하나하나에 새로운 객체를 생성해 복사한 후 리스트에 담겨 반환되도록 변경되었다.
다시 테스트해보면 새로운 객체를 생성해 담았기 때문에 값만 같은 서로 다른 메모리 주소를 참조하는 Name객체들이 된 것을 볼 수 있다.
원본에는 영향이 가지 않기 때문에 getter 메서드 호출로 인해 캡슐화가 깨지는 것을 방지할 수 있는 것이다.