Home 왜 Wrapper Class를 왜 사용할까?
Post
Cancel

왜 Wrapper Class를 왜 사용할까?

글을 작성하게 된 계기


스프링 코드를 보다 보면 래퍼 클래스(Wrapper Class)를 사용하는 것을 자주 볼 수 있습니다. 평소 이를 자주 사용하고 있었지만 래퍼 클래스의 장/단점에 대해 한 두 문장으로 요약하지 못했기에, 생각을 정리하고 싶어 글을 작성하게 되었습니다.





1. Wrapper Class


래퍼 클래스(Wrapper Class)는 특정 타입을 캡슐화하여 객체 인스턴스와 메소드에서 사용할 수 있도록 하는 클래스 입니다.

In object-oriented programming, a wrapper class is a class that encapsulates types, so that those types can be used to create object instances and methods in another class that needs those types.



래퍼 클래스를 사용하면 어떤 장단점이 존재 하는지, 스프링에서는 이를 왜 사용하는지 에 대해 살펴보겠습니다.

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. 장점


래퍼 클래스를 사용하면 표현력이 풍부해지고 응집성/재사용성이 높아지며, 결합도가 낮아 집니다. 또한 래퍼 클래스를 통해 부가 작업도 할 수 있습니다.

  1. 표현력
  2. 응집성/재사용성
  3. 변경에 유연하게 대처
  4. 부가 작업



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. 단점


물론 단점도 존재하는데요, 래퍼 클래스를 사용하면 복잡도 증가, 성능 오버헤드와 같은 점이 있습니다.

  1. 복잡도 증가
  2. 성능 오버헤드



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)으로 가고요. 즉, 모든 것이 메모리 관리 대상 이 되기 때문에, 오버헤드가 발생할 수 있습니다. 성능이 중요한 프로그램이라면 문제가 될 수 있겠죠?

image




하지만 이미 스프링은 내부적으로 정말 많은 객체를 생성해 사용하고 있으며, 현대 하드웨어는 이를 뒷받침할 정도로 발전했습니다. 성능이 정말 중요한 프로그램이 아니라면, 모니터링 시스템이 갖춰져 있다면 과연 이 부분이 크게 문제 될까? 하는 개인적인 생각이 있습니다.

이는 개인이 처한 상황마다 다를 수 있기 때문에, 하나의 의견으로 듣고 넘어가셔도 무방합니다.







4. 정리


래퍼 클래스를 사용하면 다음과 같은 장점이 있습니다.

  1. 표현력 증가
  2. 응집성/재사용성 증가
  3. 변경에 유연한 대처
  4. 부가 작업





하지만 복잡도 증가와 성능 오버헤드라는 단점도 존재하는데요, 래퍼 클래스의 특징을 잘 이해하고, 이를 실제 어떻게 잘 사용할지 고민해 보셨으면 합니다.

  1. 복잡도 증가
  2. 성능 오버헤드

This post is licensed under CC BY 4.0 by the author.

디미터의 법칙에 대한 오해

Custom Response