글을 작성하게 된 계기
스프링 코드를 보다 보면 래퍼 클래스(Wrapper Class)를 사용하는 것을 자주 볼 수 있습니다. 평소 이를 자주 사용하고 있었지만 래퍼 클래스의 장/단점에 대해 한 두 문장으로 요약하지 못했기에, 생각을 정리하고 싶어 글을 작성하게 되었습니다.
1. Wrapper Class
래퍼 클래스(Wrapper Class)는 특정 타입을 캡슐화하여 객체 인스턴스와 메소드에서 사용할 수 있도록 하는 클래스 입니다.
래퍼 클래스를 사용하면 어떤 장단점이 존재 하는지, 스프링에서는 이를 왜 사용하는지 에 대해 살펴보겠습니다.
1
2
3
4
5
6
7
8
9
10
11
public class Number {
private final int value;
public Number(int value) {
this.value = value;
}
......
}
컬렉션을 래핑하는 경우 일급 컬렉션(First Class Collection)이라고도 하는데, 이번 포스팅에서는 래퍼 클래스라고 부르도록 하겠습니다.
1
2
3
4
5
6
7
public class LottoNumbers {
private final List<Number> numbers;
......
}
2. 장점
래퍼 클래스를 사용하면 표현력이 풍부해지고 응집성/재사용성이 높아지며, 결합도가 낮아 집니다. 또한 래퍼 클래스를 통해 부가 작업도 할 수 있습니다.
- 표현력
- 응집성/재사용성
- 변경에 유연하게 대처
- 부가 작업
2-1. 표현력
래퍼 클래스를 사용하면 표현력이 풍부 해집니다. 래퍼 클래스에 의미를 부여 할 수 있기 때문입니다. 예를 들어, 로또에 사용되는 전체 번호를 표현할 때, 1부터 45까지의 int 리스트를 사용할 수 있습니다.
1
2
3
4
5
6
7
8
public class LottoNumbers {
public static void main(String[] args) {
final List<Integer> lottoNumbers = IntStream.rangeClosed(1, 45)
.boxed()
.toList();
}
}
하지만 아래와 같이 래퍼 클래스를 사용하면, 타입 만으로 1부터 45까지의 숫자가 로또에 사용되는 번호라는 것을 알 수 있습니다. 이를 통해 객체가 어떤 역할을 맡고 있는지, 어떤 동작을 할지 조금 더 명확히 알 수 있는 것이죠.
1
2
3
4
5
6
7
8
9
10
11
public class LottoNumbers {
private final List<Number> numbers;
public LottoNumbers() {
this.numbers = IntStream.rangeClosed(1, 45)
.boxed()
.map(Number::new)
.toList();
}
}
1
2
3
4
public static void main(String[] args) {
// 로또번호에 관한 역할/책임을 맡고 있다는 것을 조금 더 명확히 알 수 있다.
final LottoNumbers lottoNumbers = new LottoNumbers();
}
2-2. 응집성/재사용성
래퍼 클래스를 사용하면 응집성이 높아집니다. 예를 들어, 1부터 45까지의 숫자 중 랜덤으로 6개의 번호를 추출하는 경우를 생각해 보겠습니다. 래퍼 클래스를 사용하지 않는다면 다음과 같이 나타낼 수 있습니다.
1
2
3
4
5
6
7
public static void main(String[] args) {
List<Integer> numbers = IntStream.rangeClosed(1, 45)
.boxed()
.toList();
Collections.shuffle(new ArrayList<>(numbers));
List<Integer> randomNumbers = numbers.subList(1, 6);
}
이를 래퍼 클래스를 사용할 경우, 다음과 같이 나타낼 수 있습니다. 객체 내부에 연관된 문맥의 메서드가 존재하기 때문에, 객체의 응집성이 올라가는 것입니다. 참고로 응집성이란 관련 있는 책임의 집합을 말합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class LottoNumbers {
private static final int START_INDEX = 0;
private static final int END_INDEX = 45;
private final List<Number> numbers;
private final Map<Integer, Number> numbersMap;
public LottoNumbers() {
this.numbers = IntStream.rangeClosed(START_INDEX, END_INDEX)
.boxed()
.map(Number::new)
.toList();
this.numbersMap = numbers.stream()
.collect(Collectors.toMap(Number::getValue, number -> number));
}
// 로또 게임과 연관된 메서드
public List<Number> getRandomNumbers() {
Collections.shuffle(numbers);
return this.numbers.subList(0, 6);
}
}
1
2
3
4
5
6
public static void main(String[] args) {
LottoNumbers lottoNumbers = new LottoNumbers();
// 객체 내부에 존재하는 메서드 활용
List<Number> randomNumbers = lottoNumbers.getRandomNumbers();
}
만약 6개의 수동 번호를 뽑고 싶다면, 다음과 같이 LottoNumbers 내부에 getManualNumbers 메서드를 추가해주면 됩니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
public class LottoNumbers {
private static final int START_INDEX = 0;
private static final int END_INDEX = 45;
private final List<Number> numbers;
private final Map<Integer, Number> numbersMap;
public LottoNumbers() {
this.numbers = IntStream.rangeClosed(START_INDEX, END_INDEX)
.boxed()
.map(Number::new)
.toList();
this.numbersMap = numbers.stream()
.collect(Collectors.toMap(Number::getValue, number -> number));
}
public List<Number> getRandomNumbers() {
Collections.shuffle(numbers);
return this.numbers.subList(0, 6);
}
public List<Number> getManualNumbers(List<Integer> manualNumbers) {
List<Number> result = new ArrayList<>();
for (Integer manualNumber : manualNumbers) {
result.add(numbersMap.get(manualNumber));
}
return result;
}
}
또한 한 번 만들어둔 객체를 계속해서 사용할 수 있으므로 재사용성이 높아집니다. 객체 생성 비용은 꽤 비싼 편인데, 이를 캐싱해 두고 사용한다면 성능상 이점을 가져갈 수 있습니다.
1
2
3
4
5
6
7
public class Main {
private static final LottoNumbers lottoNumbers = new LottoNumbers();
public static void main(String[] args) {
List<Number> randomNumbers = lottoNumbers.getRandomNumbers();
}
}
2-3. 변경에 유연한 대처
래퍼 클래스로 변경에 유연하게 대처할 수 있습니다. 예시를 통해 이를 살펴보겠습니다. 아래와 같이 로또 결제 수단(Card)과 결제 방식(PayMethod)이 있으며, 로또는 상점(LottoStore)에서 구매할 수 있다고 가정해보겠습니다.
1
2
3
4
5
6
7
public class PayMethod {
private final Card card;
......
}
1
2
3
4
5
public class LottoStore {
......
}
이렇게 되면 로또를 구매할 때, 상점(Store)은 결제 방법(Card)을 직접적으로 알지 않아도 됩니다. 즉, 래핑 된 PayMethod만 알면 구매를 할 수 있으므로 결제 수단/방법이 바뀌더라도 직접적인 연관관계가 없이 변경에 유연하게 대처할 수 있게 됩니다.
1
2
3
4
5
6
public class LottoStore {
public BigDecimal pay(PayMethod payMethod) {
......
}
}
2-4. 부가 작업
래퍼 클래스를 사용하면 부가 작업 을 기존 코드에 변경 없이 처리할 수 있습니다. 스프링의 HandlerExecutionChain은 내부에 컨트롤러(handler)를 필드로 가진 래퍼 클래스입니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
public class HandlerExecutionChain {
private static final Log logger = LogFactory.getLog(HandlerExecutionChain.class);
private final Object handler;
private final List<HandlerInterceptor> interceptorList = new ArrayList<>();
private final int interceptorIndex = -1;
......
}
이를 통해 컨트롤러가 호출되기 전/후로 부가 작업을 할 수 있습니다. 우리가 사용하는 인터셉터가 이렇게 동작하죠.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
public class DispatcherServlet extends FrameworkServlet {
......
private final Object handler;
......
protected void doDispatch(
HttpServletRequest request,
HttpServletResponse response
) throws Exception {
// 래퍼 클래스
HandlerExecutionChain mappedHandler = null;
try {
try {
......
/***
* 컨트롤러가 호출되기 전/후 래퍼 클래스의 메서드를 호출.
*/
if (!mappedHandler.applyPreHandle(processedRequest, response)) {
return;
}
......
mappedHandler.applyPostHandle(processedRequest, response, mv);
} catch (Exception ex) {
dispatchException = ex;
} catch (Throwable err) {
dispatchException = new ServletException("Handler dispatch failed: " + err, err);
}
......
}
}
}
즉, 래핑된 HandlerExecutionChain의 메서드를 호출하기 때문에, 실제 메서드가 호출되기 전/후로 부가 작업을 처리할 수 있는 것입니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
public class HandlerExecutionChain {
......
private final Object handler;
......
boolean applyPreHandle(
HttpServletRequest request,
HttpServletResponse response
) throws Exception {
for (int i = 0; i < this.interceptorList.size(); i++) {
HandlerInterceptor interceptor = this.interceptorList.get(i);
if (!interceptor.preHandle(request, response, this.handler)) {
triggerAfterCompletion(request, response, null);
return false;
}
this.interceptorIndex = i;
}
return true;
}
void applyPostHandle(
HttpServletRequest request,
HttpServletResponse response,
@Nullable ModelAndView mv
) throws Exception {
for (int i = this.interceptorList.size() - 1; i >= 0; i--) {
HandlerInterceptor interceptor = this.interceptorList.get(i);
interceptor.postHandle(request, response, this.handler, mv);
}
}
......
}
3. 단점
물론 단점도 존재하는데요, 래퍼 클래스를 사용하면 복잡도 증가, 성능 오버헤드와 같은 점이 있습니다.
- 복잡도 증가
- 성능 오버헤드
3-1. 복잡도 증가
int 값을 Number 객체로 래핑했다고 가정해 보겠습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public class Number {
private final int value;
public Number(int value) {
this.value = value;
}
public int getValue() {
return value;
}
@Override
public boolean equals(Object object) {
if (this == object) {
return true;
}
if (object == null || getClass() != object.getClass()) {
return false;
}
Number number = (Number) object;
return value == number.value;
}
@Override
public int hashCode() {
return Objects.hash(value);
}
}
이때 값을 얻기 위해서는 Getter를 사용해 객체 내부의 값을 가져와야 합니다.
1
2
3
4
5
6
public class Main {
public static void main(String[] args) {
Number number = new Number(3);
int value = number.getValue();
}
}
간단히 코드 한 줄로 얻을 수 있는 값인데도 말이죠.
1
2
3
4
5
public class Main {
public static void main(String[] args) {
int number = 3;
}
}
만약 Number 내부의 값이 int가 아닌 Integer이라면, null인 경우를 대비해 추가적인 코드를 작성해야 합니다. 즉, 복잡도가 증가 하는 것이죠.
1
2
3
4
5
6
7
8
9
10
11
public class Number {
private final Integer value;
public Number(Integer value) {
this.value = value == null ? 0 : value;
}
......
}
이는 QueryDSL을 사용할 때도 문제가 되는데, 추가적인 메서드 체이닝이 필요하기 때문입니다.
1
2
3
4
5
6
7
8
9
10
@Entity
public class User {
......
@Embedded
private Username username;
......
}
1
2
3
4
5
6
7
8
9
10
11
@Repository
public class UserQueryRepository {
......
public List<User> findUsersByUsername(String username) {
return queryFactory.selectFrom(user)
.where(user.username.username.eq(username))
.fetch()
}
}
3-2. 성능 오버헤드
래퍼 클래스를 사용하면 반드시 객체를 생성해줘야 합니다.
1
2
3
4
5
6
public class Main {
public static void main(String[] args) {
// int 3을 사용하기 위해 Number 객체를 생성
Number number = new Number(3);
}
}
객체를 생성하면 힙(Heap)으로 가고요. 즉, 모든 것이 메모리 관리 대상 이 되기 때문에, 오버헤드가 발생할 수 있습니다. 성능이 중요한 프로그램이라면 문제가 될 수 있겠죠?
하지만 이미 스프링은 내부적으로 정말 많은 객체를 생성해 사용하고 있으며, 현대 하드웨어는 이를 뒷받침할 정도로 발전했습니다. 성능이 정말 중요한 프로그램이 아니라면, 모니터링 시스템이 갖춰져 있다면 과연 이 부분이 크게 문제 될까? 하는 개인적인 생각이 있습니다.
이는 개인이 처한 상황마다 다를 수 있기 때문에, 하나의 의견으로 듣고 넘어가셔도 무방합니다.
4. 정리
래퍼 클래스를 사용하면 다음과 같은 장점이 있습니다.
- 표현력 증가
- 응집성/재사용성 증가
- 변경에 유연한 대처
- 부가 작업
하지만 복잡도 증가와 성능 오버헤드라는 단점도 존재하는데요, 래퍼 클래스의 특징을 잘 이해하고, 이를 실제 어떻게 잘 사용할지 고민해 보셨으면 합니다.
- 복잡도 증가
- 성능 오버헤드