해당 내용은 아래의 유튜브 강의 시리즈를 정리한 내용이다.

https://www.youtube.com/watch?v=vcCaSBJpsHk&list=PLS8gIc2q83OjStGjdTF2LZtc0vefCAbnX

 

 

 

1.1 파이썬 데이터 타입

list [ ]

- 다른 언어의 array와 비슷. 마이너스 인덱스를 지원하기 때문에 리스트의 역순부터 참조 가능하다.

A = [1,2,3,4,5] 다음과 같은 경우, A[0] = 1, A[-1] = 5이다.

 

- 리스트의 각 요소마다 데이터 타입을 다르게 생성할 수 있다

A = [1, 2, "hello", [True, 3]]

 

- 콜론 (:) 을 이용한 슬라이싱 기능이 존재

A = [1,2,3,4,5] 다음과 같은 리스트의 경우

* A[0:2] → [1,2]

* A[:3] → [1,2,3]

* A[:-2] → [1,2,3]

* A[:] → [1,2,3,4,5]

 

tuple ( )

리스트와 거의 비슷하지만 한번 생성된 튜플은 데이터를 변경할 수 없는 read-only 데이터 타입이다.

리스트와 동일하게 슬라이싱 등의 기능 사용 가능

 

dictionary { }

다른 언어의 해시, 맵 등과 구조가 비슷하다.

key-value 데이터 타입이고 데이터가 순차적으로 들어가지 않는다.

score = {"KIM" : 90, "LEE" : 85, "JUN" : 95}

 

- 딕셔너리 key, value 접근

keys(), values(), items() 메서드를 통해서 딕셔너리 값을 원하는 포맷으로 가져올 수 있다.

 

자주 사용하는 함수

* type(data) → data의 데이터 타입 반환

* len(data) → data의 요소의 개수(데이터길이) 반환

* size(data) → data의 모든 원소 개수 반환

A = [ [1,2], [3,4], [5,6] ] 다음과 같은 경우, len(A)는 3이지만 size(A)는 6이다. * list(data) → 입력 data를 리스트로 만들어서 반환

* str(data) → 입력 data 문자열 반환

* int(data) → 입력 data(문자열, 실수) 형태를 정수로 반환

 

 

1.2 파이썬 조건문, 반복문

파이썬은 코딩블럭을 표현하기 위해서 indentation을 사용 (공백 수가 동일해야함)

 

조건문

* if condition :

a=1
if a > 0:
	do
elif a == 0:
  do
else:
  do

 

* if condition in list, dict :

list_data = [1,2,3,4,5]
dict_data = {'k1': 1, 'k2' : 2}

if 4 in list_data:
else:

if 'k1' in dict_data
else:

 

 

반복문

* for variable in range(...):

⇒ range(10) : 0~9

⇒ range(0,10) : 0~9

⇒ range(0,10,2) : 0,2,4,6,8

 

* for variable in list, dict:

⇒ 딕셔너리를 for문으로 표현할 때 다음과 같이 사용할 수 있다.

for key, value in dict_data.items():

 

* list comprehension (머신러닝에서 많이 사용되는 방법)

리스트의 [ ] 괄호 안에 for루프를 사용해서 반복표현으로 리스트 요소 정의

row_data = [ [1, 10], [2,15], [3,30], [4,55] ]

all_data = [x for x in raw_data ]   => [ [1, 10], [2,15], [3,30], [4,55] ]
x_data = [x[0] for x in raw_data ]  => [1,2,3,4]
y_data = [x[1] for x in raw_data ]  => [10,15,30,55]

 

* while, break, continue

 

1.3 파이썬 함수, 람다

def 함수명 (입력1, 입력2...): 다음과 같이 정의해서 사용 → 입력 파라미터 데이터타입 기술 X

 

- 파이썬에서는 1개 이상의 함수 반환값을 받을 수 있다.

def multi_ret_func(x):
	return x+1, x+2, x+3

x = 100
y1, y2, y3 = multi_ret_func(x) # 101, 102, 103

 

디폴트 파라미터, mutable, immutable 파라미터

숫자, 문자, tuple등 원래 immutable한 데이터 타입은 함수내에서 변형이 일어나지 않는다

#디폴트 파라미터
def print_name(name, count=2):
	for i in range(count):
		print(name)
print_name("DAVE") # count가 디폴트로 2로 초기화 되기 때문에 두번 출력


#mutable immutable 파라미터
def func(x, input_list):
	x += 1
	input_list.append(100)

x=1
test_list = [1,2,3]
func(x, test_list) # x는 변하지 않고, list는 100이 추가됌 

 

 

람다 (익명함수)

한줄로 함수를 작성할 수 있다. 다른 함수의 파라미터로 함수값을 넣을때 주로 사용하며, 머신러닝에서는 미분을 계산하기 위해 필요한 수치 미분과 활성화 함수 등을 표현할 때 사용한다.

함수명 = lambda 입력1, 입력2... : 대체되는 표현식 ⇒ 입력 값이 모두 대체되는 표현식에서 사용되지 않아도 된다.

f = lambda x : x+10

for i in range(3):
	print(f(i))  # 10, 11, 12

 

1.4 파이썬 클래스

class 클래스명:
	def __init__(self, 인수, ...): # 생성자
	def 메서드명(self, 인수, ...): #메서드, 파이썬에서는 메서드 첫번째 인수로 self 필수!!

파이썬에서는 기본적으로 모든 메서드와 속성이 public이다.

파이썬에서는 기본적으로 멤버변수를 따로 선언하지 않는다.

 

클래스변수, 클래스 메서드

클래스 변수와 메서드는 해당 클래스로 생성된 모든 인스턴스가 공통적으로 사용

class Person:
	count = 0 #클래스 변수 (클래스 내에서 선언과 초기화 작업)
 
	@classmethod
	def getCount(cls): #클래스 메서드 (인수로 self가 아니라 class를 나타내는 cls를 받는다)
		return cls.count

 

파이썬에서의 멤버변수, 메서드 private 선언

기본적으로는 모두 public 접근자로 선언된다. 다만 멤버변수, 메서드 선언을 __멤버변수, __메서드 형태로 하면 private설정이 가능하다

class Test:
	def __init(self, name1, name2):
		self.name1 = name1
		self.__name2 = name2
	
	def getNames(self):
		self.__printNames()
		return self.name1, self.__name2

	def __printNames(self):
		print(self.name1, self.__name2)

#생성된 객체를 통해서 __printNames 메서드를 호출하려고 하면 error발생

 

 

외부함수명과 클래스 내부의 메서드명이 같은 경우

self의 유무에 따라서 호출되는 메서드가 달라진다.

def print_name(name):
	print("OUTTER")

class Test:
	def __init__(self):
		pass

	def print_name(self, name):
		print_name("name") # 외부의 함수 호출
		self.print_name("name") # 내부 메서드 호출 

 

 

1.5 Numpy 라이브러리

 

import numpy as np Numpy를 임포트해와서 사용하도록 한다. Numpy는 벡터(행과 열을 구분하지 않는 1차원 배열), 행렬 등의 표현, 연산을 할때 필요한 라이브러리이다.

 

np.array([1,2,3]) ⇒ 해당 명령어는 list와 비슷해보이지만 다른 1차원 벡터를 생성한다.

A.shape → 해당 벡터, 행렬의 형상 출력 (3,)

A.ndim → 해당 벡터, 행렬의 차원 출력 1

A.reshape → 형 변환, 벡터를 행렬로 변경하거나 행렬을 다른 형상의 행렬로 변경하기 위해서 사용

 

 

행렬곱(dot product)

np.dot(A,B)

A의 열 벡터와 B의 행 벡터가 같아야 행렬곱을 수행할 수 있다. 같지 않다면 reshape() 혹은 transpose를 사용해서 형 변환 이후에 행렬곱을 실행하면 된다.

A = np.array([ [1,2,3], [4,5,6] ]) # 2X3
B = np.array([ [-1,-2], [-3,-4], [-5,-6] ]) # 3X2

C = np.dot(A,B)
/*
[[-22 -28]
 [-49 -64]] 
*/

 

* 그렇다면 왜 행렬곱이 머신러닝, 이미지 프로세싱 분야에서 자주 사용되는가?

 

 

기본적으로 사칙연산은 행렬의 원소의 개수가 같아야 연산이 가능한 반면에, 행렬곱은 A*B 연산 시, A의 열 벡터, B의 행 벡터가 같으면 연산이 가능하므로 위의 예시처럼 다양한 특성을 갖는 필터를 붙여 계산을 할 수 있다.

 

브로드캐스트

보통 행렬의 사칙연산은 두 행렬의 크기가 같은 경우에만 수행할 수 있지만 Numpy에서는 크기가 다른 두 행렬간에도 사칙연산을 수행할 수 있도록 지원한다. ⇒ 브로드캐스트

차원이 작은 쪽이 큰 쪽의 행 단위로 반복적으로 크기를 맞춘 후, 계산을 진행한다.

A = np.array([ [1,2], [3,4] ])
B = np.array([4,5])
b = 5

/* A+b
[[6 7]
 [8 9]]
*/

/* A+B
 [[5 7]
  [7 9]]
*/

 

전치행렬(transpose)

원본 행렬의 열은 행으로, 행은 열로 바꾸는 연산

vector의 경우, transpose 연산이 불가능하므로 이를 matrix로 변경 후, 연산을 하도록 한다.

A = np.array([ [1,2], [3,4], [5,6] ])
B = A.T

/* B
[[1 3 5]
 [2 4 6]]
*/

C = np.array([1,2,3]) #행렬이 아닌 벡터이다
C = C.reshape(1,3) #1X3 행렬로 변환
E = C.T
/*E
[[1]
 [2]
 [3]]
*/

 

행렬 인덱싱/슬라이싱

행렬의 원소에 명시적으로 접근하기 위해서는 list와 비슷하게 인덱싱/슬라이싱을 통해서 할 수 있다.

A가 np.array([1,2,3,4,5,6]).reshape(3,2) 연산의 결과라고 가정한다.

* A[0][0] → 1

* A[2][1] → 6

* A[0:-1 , 1:2] →

[ [2]

[4] ]

 

행렬 iterator

위의 인덱싱/슬라이싱을 통한 명시적 행렬 원소 접근 이외에 모든 원소를 액세스하는 경우에는 iterator를 사용할 수 있다.

A = np.array([[1,2,3,4],[5,6,7,8]])

#iterator 생성
it = np.nditer(A, flags=['multi_index'], op_flag=['readwrite'])

while not it.finished:
	idx = it.multi_index
	#A[idx]로 행렬 원소 액세스 가능
	it. iternext()

 

 

concatenate 함수

행렬에 행, 열을 추가

⇒ 머신러닝의 regression코드 구현 시 가중치, bias를 별도로 구분하지 않고 하나의 행렬로 취급하기 위해서 사용한다.

A = np.array([[1,2,3],[4,5,6]])
row_add = np.array([7,8,9]).reshape(1,3) #추가하려는 행
col_add = np.array([11,22]).reshape(2,1) #추가하려는 열

B = np.concatenate((A, row_add), axis=0) #행 추가
C = np.concatenate((A, col_add), axis=1) #열 추가

 

 

유용한 함수(1) : loadtxt()

seperator로 구분된 파일에서 데이터를 읽기 위한 함수, ex) np.loadtxt("파일이름", seperator=",")

반환값이 행렬이기 때문에 인덱싱/슬라이싱을 통해서 원하는 데이터를 분리한다. 보통 머신러닝 구현 시, 입력데이터와 정답데이터 분리할 때 자주 사용한다.

# 25X4행렬로 가정
loaded_data = np.loadtxt('./data.csv', delimiter=',', dtype=np.float32)

x_data = loaded_data[ :, 0:-1] #모든 row의 0~ -2인덱스까지 (0,1,2)
t_data = loaded_data[ :, [-1]] #모든 row의 -1인덱스만 (3)

 

 

유용한 함수(2) : rand(), sum(), exp(), log()

* rand()

랜덤 값 발생. np.random.rand( shape값 넣는다 )

ex) np.random.rand(1,3) → 1X3행렬에 랜덤값 생성

 

* sum()

np.sum(X) → X의 모든 요소의 합 반환

 

유용한 함수(3) : max(), min(), argmax(), argmin()

min, max는 데이터가 벡터인 경우에 원소중 max,min값을 반환한다.

 

* argmax, argmin (이후 classification에서 사용)

벡터에서 최대, 최소값이 있는 곳의 인덱스를 리턴 해준다.

행렬에서는 최대, 최소값을 찾는 기준을 정해야 한다(행 → axis=1 or 열 → axis=0)

ex) np.argmax(X, axis=0)

 

유용한 함수(4) : ones(), zeros()

해당 함수들은 파라미터로 주어진 shape에 따라서 요소가 모두 1 혹은 0인 행렬을 만든다

ex) A = np.ones([3,3]) 인 경우, 3X3의 요소가 모두 1인 행렬이 반환된다.

 

'머신러닝' 카테고리의 다른 글

[머신러닝/딥러닝] 0. 유튜브 강의 선택, 인트로  (0) 2020.06.10

 특별한 계기가 있지는 않았지만 새로운 기술에 대한 탐구가 부족하다고 느껴서 천천히 공부해볼 토픽으로 머신러닝 딥러닝을 골랐다.

 다음과 같이 3가지 강의 정도를 학습자료로 선택했다.

 

 

  각 강의마다 각자의 특색이 있다고 느끼는데 1번과 2번은 텐서플로우 라이브러리 사용여부 차이라고 느꼈고 전체적인 이론적인 부분은 비슷한 면이 있어서 두 강의를 돌아가며 들으면 복습하는 차원에서 효율이 좋을 것 같다.

 

  블로그에 정리하는 내용은 1번 강의를 기준으로 정리해보고자 한다.

 

0. 인트로

인공지능 > 머신러닝 > 딥러닝, 다음과 같은 포함관계를 가지고 있다

  • 인공지능 : 인간의 학습능력, 추론능력 등을 컴퓨터를 통해 구현하는 포괄적 개념

  • 머신러닝 : 데이터를 이용하여 명시적으로 정의되지 않은 패턴을 학습하여 미래 결과(값, 분포)를 예측

    ex) Regression, Classification, Neural Network 

    ⇒ 여기서 데이터마이닝은 머신러닝과 비슷한 개념을 볼 수 있지만 정확히는 데이터간의 상관관계나 속성을 찾는 것이 주목적이다.

  • 딥러닝 : 머신러닝의 한 분야로서, 신경망(Neural Network)를 통해서 학습하는 알고리즘의 집합

     ex) CNN, RNN
    • 해당 강의에서는 머신러닝 프레임워크를 사용하지 않고 바닐라 파이썬으로 머신러닝의 개념, 예제를 구현

    ⇒ 머신러닝 알고리즘을 API로 추상화하기 때문에 동작 내부원리를 알 수 없음

    ⇒ 동작원리, 알고리즘에 대한 깊은 이해 (학습용)

    • 강의 커리큘럼

커리큘럼

Chpater 8. 컬렉션 API 개선

8.1 컬렉션 팩토리
8.2 List와 Set 처리
8.3 Map 처리
8.4 개선된 ConcurrentHashMap

8.1. 컬렉션 팩토리

자바9 부터는 작은 컬렉션 객체를 쉽게 만들 수 있는 몇 가지 방법을 제공한다. 기존에는 이를 구현하기 위해서 다음과 같은 방식을 사용하지만 내부적으로 불필요한 할당을 하거나 요소를 컨트롤 할 수 없는 등의 불편함이 있다.

  • List

    간단하게 적은 요소의 리스트를 만들때 Arrays.asList("A","B","C") 다음과 같은 형식을 많이 사용한다. 하지만 asList()를 이용해서 생성된 리스트는 고정된 크기를 가지기 때문에 요소를 add할 수 없다.

  • Set

    Set은 아예 팩토리 메서드가 없으므로 List를 이용하는 HashSet 생성자를 사용하거나 스트림API를 사용한다.

//HashSet생성자 사용
Set<String> set = new HashSet<>(Arrays.asList("A","B","C"));

//스트림 사용
Set<String> set = Stream.of("A","B","C").collect(Collectors.toSet());

다음은 자바 9에서 제공하는 컬렉션 팩토리이다.

  • List 팩토리

    List.of 팩토리 메서드를 이용해서 간단한 리스트를 생성한다.

List<String> list = List.of("A","B","C"); 

해당 팩토리 메서드를 통해서 생성된 List객체는 변경 불가능하다. 이렇게 제약을 줌으로써 컬렉션이 의도치 않게 변하는 것을 막도록 한다.

List인터페이스의 of() 메서드는 전달받는 파라미터의 개수에 따라서 다양한 오버로드 버전이 있는데 왜 굳이 아래와 같이 다중 요소를 받도록 API를 만들지 않았을까? 이유는 가변인수를 파라미터로 받을 때는 추가적으로 배열을 할당하고 초기화하며 나중에 가비지 컬렉션을 하는 비용이 추가적으로 발생하기 때문이다. Set, Map 팩토리도 동일한 패턴이 등장한다.

static <E> List<E> of(E e1, E e2)
static <E> List<E> of(E e1, E e2, E e3)

//왜 아래와 같이 사용하지 않을까?
static <E> List<E> of(E... elements)
  • Set 팩토리

    List.of()와 비슷한 메서드를 지원한다.

// 중복된 요소를 파라미터로 넘겨서 Set객체를 생성하려고 하는 경우 익셉션 발생
Set<String> set = Set.of("A","B","C");
  • Map 팩토리

    • Map.of() 사용
Map<String, Integer> map = Map.of("key1",1, "key2",2);
  • Map.ofEntries(), Map.entry() 사용 (Map.Entry 객체를 만드는 팩토리 메서드)
Map<String, Integer> map = Map.ofEntries(
                entry("key1", 1),
                entry("key2", 2));

8.2. List와 Set 처리

자바 8 에서 List, Set 인터페이스에 몇 가지 메서드가 추가되었다.

이 메서드들은 새로운 결과를 생성하는 스트림과 달리, 호출한 기존 컬렉션을 변경시킨다. 보통 컬렉션에 대한 처리를 할 때 for-each 문을 자주 사용하게 되는데 이때 Iterator 객체와 Collection 객체에 대한 혼동 때문에 ConcurrentModificatinException이 발생하는 케이스가 생긴다. 이를 방지하기 위해서 Collection 객체 자체를 변경시키는 메서드가 필요하다.

  • removeIf

    Predicate를 인수로 받아서 이에 만족하는 요소를 제거한다.

// List<String> list 요소 A, B, C
list.removeIf(item -> item.equals("A"));
  • replaceAll

    List의 각 요소를 새로운 요소로 변경할 수 있다. 스트림 API를 사용할 수 있지만 그렇게 되면 새로운 컬렉션 객체가 생성된다. replaceAll()은 기존 객체를 변경한다

// codes List의 요소의 첫 번째 char를 대문자로 변경
codes.replaceAll(code -> Character.toUpperCase(code.charAt(0)) + code.substring(1));

8.3. Map 처리

Map에 대한 반복 처리 시, Map.Entry<K,V>의 반복자를 이용해서 처리할 수 있다.

for(Map.Entry<String, Integer> entry : ageOfFriends.entrySet()){
    String friend = entry.getKey();
    Integer age = entry.getValue();
}

자바 8부터는 Map 인터페이스는 BiConsumer(key, val을 인수를 받음)를 인수로 받는 forEach 메서드를 지원한다.

ageOfFriends.forEach( (friend,age) -> _____처리______);
  • 정렬 메서드

    Map의 항목을 key, val 기준으로 정렬할 수 있다.

  • Entry.comparingByValue

  • Entry.comparingByKey

//Map객체 생성
Map<String, String> map = Map.ofEntries(
        entry("A", "A val"),
        entry("B", "B val"));

map.entrySet().stream()
        .sorted(Entry.comparingByKey())
        .forEachOrdered(System.out::println);
  • getOrDefault 메서드

    기존에는 Map에서 찾으려는 key에 대한 값이 없으면 null을 반환했기 때문에 null체크가 필수였다. 이런 경우 default값을 설정해서 받으면 NPE를 쉽게 피할 수 있다.

//map => {"A" : 1, "B" : 2}
map.getOrDefault("A", 0); // 1
map.getOrDefault("C", 0); // 0
  • 계산 패턴

    Map의 key 존재여부에 따른 계산 여부 등을 판단한다.

  • computeIfAbsent() : key가 없는 경우, 계산 후 Map에 추가, key 존재 시 현재 val 반환

  • computeIfPresent() : key가 존재하면 계산 후 Map에 추가

  • compute() : key의 존재여부 판단 없이 계산 후 Map 추가

//map "A" : 1, "B" : 2, "C" : 3
map.computeIfAbset("D", key -> new ArrayList<>()).add(4); //"D" : 4 추가
  • 삭제 패턴

    • remove() : map.remove(key, value)로 쉽게 구현
  • 교체 패턴

    • replaceAll() : List의 replaceAll과 동일하게 모든 Map요소를 변경
// {"A":"a val", "B":"b val"}    =>     {"A":"A VAL", "B":"B VAL"}
maps.replaceAll((key, val) -> val.toUpperCase());
  • replace() : 키가 존재하면 Map의 값을 변경
  • 합침

    • putAll()

    • merge()

    두 메서드 모두 서로 다른 Map을 하나로 합치지만 중복된 키가 있는 경우에는 merge() 를 활용해서 다음과 같이 활용한다.

// 중복된 key를 가지고 있는 Map family, friend가 있다고 가정
Map<String, String> everyone = new HashMap<>(family);
friends.forEach((k, v) -> everyone.merge(k, v, (val1, val2) -> va1 + val2));
//BiFunction을 통해서 중복되는 key에 대한 값 처리를 해준다.

merge() 를 이용해서 Map의 초기화 검사를 쉽게 구현할 수도 있다. 존재하는 key에 대해서는 val에 +1을 해주고 없는 key에 대해서는 val에 1을 넣어줘야 하는 경우에 다음과 같이 구현 가능하다.

map.merge(key , 1L, (key, count) -> count + 1L); 

8.4. 개선된 ConcurrentHashMap

이 내용을 정리전에 ConcurrentHashMap에 대한 내용 정리가 필요했다.

ConcurrentHashMap은 내부 자료구조의 특정 부분만 잠궈서 동시에 추가, 갱신 작업을 허용한다. 기존의 동기화된 Hashtable 버전에 비해서 읽기 쓰기 연산 성능이 월등하다.

http://jdm.kr/blog/197 해당 링크에서 Map인터페이스의 구현체에 대한 비교가 잘 나와있다.

  • forEach() : (K, V) 쌍에 주어진 액션 실행

  • reduce() : (K, V) 쌍을 제공된 리듀스 함수를 이용해서 결과로 합침

  • search() : null이 아닌 값을 반환할 때까지 각 (K, V) 쌍에 함수를 적용

    해당 연산은 ConcurrentHashMap의 상태를 잠그지 않고 수행되기 때문에 이 연산에 제공되는 함수는 계산이 진행되는 동안 변경되는 객체, 값, 순서 등에 의존하지 않아야 한다. ( 어느 값이 어느 순서로 바뀔지 모르기 때문에?)

    또한 연산에 병렬성 기준값을 지정해야하는데 기준값을 1로 지정하면 스레드 풀을 이용해서 병렬성을 극대화하고, Long.MAX_VALUE 로 지정하면 단일 스레드 연산을 실행한다.

Optional<Integer> maxVal =
        Optional.ofNullable(map.reduceValues(parallelismThreshold, Long::max));

※ 해당 내용은 토비의 스프링3을 보고 정리한 내용이다.

2.0.

저자는 스프링의 가장 중요한 가치를 객체지향과 테스트라고 말한다. 계속해서 변화하는 요구사항에 유연하고 자신감있게 대응하기 위해서 테스트 만드는 것이 중요하다.

2.1 UserDaoTest

1장 정리본에서는 따로 코드를 적지 않아서 여기서 말하는 UserDaoTest코드를 첨부한다.

해당 테스트 코드의 내용을 정리하면

→ main() 메서드에 작성

→ 테스트 대상인 UserDao 오브젝트를 applicationContext에서 꺼내온다.

→ 테스트에 사용하는 입력값을 코드에서 넣는다.

→ 테스트 결과를 콘솔에 출력한다.

웹을 통한 DAO 테스트 방법의 문제점

보통 웹 개발 시, 테스트 코드를 작성하는건 불편하다. DAO의 간단한 기능을 테스트하려고 해도 서비스 계층, MVC 프레젠테이션 계층까지 포함함 입출력 기능까지 구현을 해야한다. 그리고 이 테스트용 웹앱을 서버에 올려서 실제 기능 테스트를 진행한다.

해당 방식은 실제 테스트하고자 하는 부분 이외에 셋팅해야하는 것이 너무 많다. 이는 하나의 테스트를 수행하는데 참여하는 클래스와 코드가 많기 때문이다.

작은 단위의 테스트

테스트를 하고자 하는 대상을 명확히 설정한 이후에 해당 대상에 집중된 테스트코드를 작성하는 것이 바람직하다. 테스트는 가능한한 작은 단위로 쪼개서 분리된 관심사 별로 진행하는 것이 좋다.

  1. 코드가 의도대로 동작하는지 개발자가 빠르게 확인할 수 있다.

  2. 각 단위별 테스트를 진행했다면 전체적인 오류를 훨씬 줄일 수 있다.

    이렇게 작은 단위의 코드에 대해 테스트를 수행하는 것을 단위 테스트라고 한다. 위의 UserDaoTest는 테스트를 하기 위해서 서비스, MVC 계층, 웹 화면에 대해서 구현할 필요가 없었으므로 단위테스트라고 명명할 수 있겠다.

자동수행 테스트 코드

UserDaoTest는 main() 메서드에서 코드상으로 필요한 입력값 등을 자동으로 코드에서 처리한다. 보통 웹앱에서 기능을 테스트하기 위해서 버튼 클릭, 폼 입력 등 반복되는 작업을 개발자가 화면에서 처리하는 경우가 많다. 하지만 효율적인 테스트를 위해서는 테스트를 자주 수행해도 부담이 없도록 테스트가 자동으로 수행되도록 코드를 작성하는 것이 좋다.

최근에 개발한 데이터추출 자동화 같은 경우, xlsx파일 read, write, download 등을 테스트하기 위해서 무거운 웹앱을 서버에 올려서 테스트를 진행했어야했다. 코드의 조그만 부분(파일경로)이 수정된다고 해도 웹앱을 서버에 다시 올려야 하기 때문에, 이런 부분에서 테스트코드를 통해서 더 나은 생산성이 발휘될 수 있다.

UserDaoTest를 더 개선한다면

UI까지 동원되는 번거로운 수동 테스트에 비해 장점은 있으나 부족한 부분이 있다.

  • 수동 확인 작업의 번거로움

  • 실행 작업의 번거로움

    많은 부분을 코드에서 자동화해놓았지만 결과적으로 콘솔에 출력되는 결과를 사람이 보고 P/F을 판단해야 한다. 또한 main()메서드에 테스트 코드가 있기 때문에 테스트해야하는 요소가 많아진다면 이를 모두 main() 메서드에 구현해야 하기 때문에 체계적으로 테스트를 진행하기 어렵다.

2.2 UserDaoTest 개선

직전에 보았던 UserDaoTest의 두 가지 문제를 개선한다면 어떻게 할 수 있을까?

  • 수동 확인 작업의 번거로움 ⇒ 테스트 검증의 자동화

    테스트 결과를 검증하는 코드를 추가해서 테스트의 성공여부를 판단하도록 한다. 이후에는 출력값을 보고 개발자가 판단할 필요가 없이 "성공" 혹은 "실패" 메시지만 확인할 수 있다.

    코드 리팩토링, 서비스 개선건 등을 개발한 이후에 이전과 동일하게 작동하는지 테스트를 하기 위해서 많은 시간을 소요했다. 이를 포괄적으로 테스트 할 수 있는 테스트코드를 작성해두고 필요할때마다 테스트 결과를 확인하는것으로 생산성을 늘릴 수 있을 것이다.

  • 실행 작업의 번거로움 ⇒ JUnit 프레임워크를 사용

    이전의 main() 메서드에 테스트코드를 작성했던 버전은 개발자가 직접 제어권을 가지고 관리한다는 의미이기 때문에 프레임워크의 IoC 관점에 부합하지 않는다. JUnit 프레임워크를 사용함으로써 프레임워크에서 동작할 수 있는 테스트 코드를 작성할 수 있다.

    조건 ⇒ 메서드가 public이어야하고 메서드에 @Test 어노테이션을 붙여야한다.

    다음은 main()메서드에 구현했던 if/else, 콘솔출력 등을 통해서 테스트 통과여부를 확인하는 코드를 JUnit에서 실행될 수 있는 테스트코드로 변환한 버전이다.

2.3. 개발자를 위한 테스팅 프레임워크 JUnit

기본적으로 JUnit자체가 스프링의 표준 테스팅 프레임워크로 볼 수 있고 IDE 측면에서도 이를 손쉽게 사용할 수 있는 테스트 지원 기능을 내장하고 있기 때문에 쉽게 JUnit 테스트를 활용할 수 있도록 한다.

ex) 한 번에 여러 테스트 클래스를 동시에 실행할 수 있고 전체 프로젝트 기준으로도 모든 테스트를 한 번에 실행할 수 있다.

  • 빌드 툴

    개발자 개인별로는 IDE에서 제공하는 JUnit 도구를 활용할 수 있지만, 여러 개발자가 만든 코드를 통합적으로 테스트해야하는 경우 빌드 이후에 테스트를 수행하도록 하는 것이 좋다. 이때는 빌드 스크립트를 통해서 JUnit 테스트를 실행하도록 한다.

테스트 결과의 일관성

코드의 변화가 없는데 상황에 따라서 테스트가 성공하기도하고 실패하기도 한다면 이는 좋은 테스트라고 하기 어렵다. 위의 UserDaoTest에서는 한번의 테스트 이후에 동일한 중복데이터가 DB에 인서트 되어 있으므로 다시 테스트를 진행하면 오류가 날 수 있다.

해당 경우는 addAndGet() 테스트를 진행한 이후에 인서트된 USER 테이블의 레코드를 삭제하는 액션이 필요하다. 1) USER테이블의 모든 레코드 삭제, 2) USER테이블의 레코드 수 반환 에 대한 메서드를 신규로 구현하고 addAndGet() 테스트 시작 전에 해당 로직을 수행하는 것으로 일관된 테스트 결과를 얻을 수 있다.

포괄적인 테스트

테스트 코드를 작성함에 있어서 한 가지의 케이스만 검증하고 마는 것은 위험할 수 있다. 추가적으로 어떠한 테스트를 통해서 좀 더 완성도 있는 테스트 코드를 작성할 수 있는지 생각해보자.

  • USER테이블의 레코드 수 반환 메서드 테스트 (getCount())

    위의 addAndGet() 테스트에서는 add() 메서드를 한번만 호출함으로써 USER 정보를 하나만 인서트한다. 이때 getCount()의 값은 항상 1이 되는데, USER 정보를 여러개 인서트하는 경우에도 getCount()가 문제없이 나오는지 테스트 코드를 작성할 수 있다.

  • andAndGet() 테스트의 보완

    → user의 ID를 통해서 get 액션에 대한 테스트를 진행할 때, userId 를 파라미터로 받아서 데이터를 가져오는지, 아무 데이터나 가져오는지에 대한 확인

 get() 액션 시, 파라미터로 넘긴 userId에 대한 사용자 정보가 없다면 해당 USER정보를 null로 받거나 예외를 발생시킨다. 여기서 문제는 예외발생 시, 테스트가 실패로 귀결되는 것이 아닌, 테스트 에러로 간주된다. 이러한 상황을 위해서 JUnit은 예외조건을 테스트하는 방법을 제공한다.

테스트 주도 개발

바로 위의 getUserFailure() 테스트 코드는 기능을 먼저 만들지 않고 테스트 코드를 먼저 작성한 케이스이다. 만약 해당 테스트가 실패하면 다시 코드를 수정하고 테스트가 성공하도록 한다. 테스트 성공 시, 코드 구현과 테스트라는 두 가지 작업이 동시에 끝나게 된다.

테스트 주도 개발(TDD)은 만들고자 하는 기능의 내용을 담고 있으면서 만들어진 코드를 검증도 해줄 수 있도록 테스트 코드를 먼저 만들고 테스트를 성공하게 하는 코드를 작성하는 방식의 개발 방법이다. 최종적으로 이 원칙을 따라서 만들어진 코드는 테스트를 통해서 검증된 코드로 볼 수 있다.

⇒ 서버에 올려서 테스트를 하면 한줄 코드를 수정해도 많은 시간이 소요되지만 테스트 코드를 미리 만들어서 필요할때마다 단위 테스트를 진행하는 것은 훨씬 효율적이다.

테스트 코드 개선

서비스 로직 뿐 아니라 테스트 코드도 좀 더 이해하기 쉽고 변경 용이하게 리팩토링 할 수 있다. 위의 테스트 코드 예제에서는 공통적으로 애플리케이션 컨텍스트에서 UserDao 를 받아와서 테스트를 진행하고 있다. 즉 다음과 같은 코드가 중복된다.

ApplicationContext context = new GenericXmlApplicationContext("applicationContext.xml");
User dao = context.getBean("userDao", UserDao.class);
  • @Before, @After 등의 어노테이션

    JUnit에서는 테스트를 실행할때 반복되는 준비 작업을 별도의 메서드에 넣고 이를 테스트 메서드 실행전에 선행시켜주는 기능이 있다. @Before 어노테이션을 통해서 중복되는 준비 코드를 별개의 메서드에 구현한다. 

JUnit은 다음과 같은 순서로 테스트 메서드를 실행한다.

@Before
public void setup(){
    //필요한 준비 코드가 테스트코드 호출 전에 먼저 실행된다
}

2.4. 스프링 테스트 적용

직전에 @Before을 통해서 테스트 메서드에서 공통적으로 필요한 부분에 대해서 하나의 setup() 메서드에 정의해놓을 수 있었다. 하지만 위와 같은 경우, 애플리케이션 컨텍스트가 테스트 메서드 수 만큼 반복되어서 만들어진다. 이게 왜 문제가 되는가?

→ 애플리케이션 컨텍스트가 만들어질 때 모든 싱글톤 빈 오브젝트를 초기화한다.

→ 특정 빈은 오브젝트가 생성될 때 자체적인 초기화 작업을 진행해서 시간이 필요하다.

→ 애플리케이션 컨텍스트가 초기화 되면 특정 빈은 리소스를 많이 잡아먹거나 독립적인 스레드를 띄운다.

그렇다면 이를 어떻게 해결하는 것이 좋은가?

테스트를 위한 애플리케이션 컨텍스트 관리

JUnit에서는 어노테이션 설정만으로 테스트에서 필요한 애플리케이션 컨텍스트를 모든 테스트가 새로 만들지 않고 공유할 수 있도록 해준다.

이전에 @Before 메서드 내에서 정의했던 애플리케이션에 대한 정의를 각 테스트 클래스 레벨로 빼도록 한다. 다음과 같은 방법으로 애플리케이션 컨텍스트를 한번만 생성해서 사용할 수 있다.

@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(locations="/applicationContext.xml")
public class UserDaoTest {
    @Autowired
    private ApplicationContext context;

    private UserDao dao; // 공통적으로 사용하는 DAO 클래스

    @Before
    public void setup(){
        this.dao = this.context.getBean("userDao", UserDao.class);
    }    
}

테스트를 위한 별도의 DI 설정

테스트 진행 시에만 다른 DataSource를 사용해야 하는 경우, 이를 테스트 코드에서 수동으로 DI 할 수 있다. 테스트 클래스에 @DirtiesContext 어노테이션을 사용함으로써 테스트 메서드에서 애플리케이션 컨텍스트 구성, 상태를 변경하는다는 것을 프레임워크에 알리고 테스트 코드에서 사용할 Datasource 오브젝트를 직접 생성할 수 있다. 이렇게 함으로써 xml설정파일을 수정하지 않고 테스트용으로 준비된 DataSource 설정을 변경할 수 있다.

@DirtiesContext
public class UserDaoTest {
    @Autowired
    UserDao dao;

    @Before
    public void setup(){
        DataSource ds = new SingleConnectionDataSoruce( ___ ); 
    }
}

하지만 위와 같이 테스트 코드에서 빈 오브젝트에 수동으로 DI하는 방법보다는 테스트 전용 설정파일을 따로 만들어서 빈 관리를 하는 것이 더 좋은 방법이다. test-applicationContext.xml 처럼 테스트용으로 설정파일을 새로 만들고 @ContextConfiguration 어노테이션을 통해서 실제 사용하고자 하는 설정파일을 붙이면 된다.

2.5. 학습 테스트로 배우는 스프링

학습 테스트란?

학습 테스트는 개발자 본인이 만든 코드가 아닌 프레임워크 코드, 제공된 라이브러리에 대해서도 테스트 코드를 작성하는 것을 의미한다. 테스트 코드를 만들어봄으로써 API나 프레임워크의 사용법 등을 익히는데 도움이 된다. 장점을 정리해보면 다음과 같다.

  1. 다양한 조건에 따라서 기능이 어떻게 동작하는지 쉽게 확인할 수 있다.
    다양한 조건에 대한 테스트를 진행할때 수동으로 값을 입력하거나 코드를 계속 수정하면서 예제를 다시 실행해야 하는 경우가 많다. 학습 테스트는 테스트 코드르 생성되기 때문에 조건에 따른 기능 동작을 빠르게 확인할 수 있다.
  2. 학습 테스트 코드를 개발 중에 참고할 수 있다.
    수동으로 예제를 만드는 것은 코드를 계속 수정하면서 진행되기 때문에 결국 최종적으로 수정한 예제코드만 남아있게 된다. 하지만 테스트 코드는 다양한 버전과 조건에 대한 코드를 남길 수 있기 때문에 이를 이후에 참고할 수 있다.
  3. 프레임워크나 제품 업그레이드 시, 호환성 검증을 돕는다.
  4. 테스트 작성에 대한 좋은 연습, 훈련이 된다.

1.0.

  스프링은 자바를 기반으로 한 기술이기 때문에 기본적으로 객체지향에 대한 가치를 중요하게 생각한다. 즉 오브젝트에 큰 중요도를 둔다. 오브젝트의 설계, 구현, 관계설정 등에 대한 내용을 좀 더 깔끔하게 구현하는 것이 중요하기 때문에 1장에서는 스프링의 관심대상인 오브젝트의 설계와 구현, 동작원리에 좀 더 집중한다.

 

1.1. DAO 예시 (DATA ACCESS OBJECT)

JDBC를 이용하는 작업의 일반적인 순서

  • DB 연결을 위한 커넥션을 가져온다.

  • sql을 담은 statement를 만든다.

  • 만들어진 statement를 실행

  • 조회의 경우, 쿼리 실행 결과를 resultSet에 담아서 저장할 오브젝트에 옮긴다(ex. dto)

  • 작업 이후에 생성된 Connection, Statement, ResultSet과 같은 리소스는 반드시 닫아준다.

  • JDBC API가 만드는 예외를 직접 처리하거나 메소드에 throws를 선언해서 메소드 밖으로 던지게 한다.

    위의 내용을 하나의 메서드에서 처리하도록 한다면 여러 문제가 발생한다. 단편적인 예로는 Connection을 가져오는 부분이 DB에 액세스하고자 하는 메서드마다 중복으로 구현되어야 한다. 객체지향에 맞는 코드를 작성하기 위해서 어떤 처리를 해줄 수 있는가?

 

1.2. DAO의 분리

  결국 코드를 작성할때는 지속적으로 변화하는 요구사항에 최소한의 공수를 들여서 대응할 수 있도록 대비가 되어 있어야 한다. 즉 분리와 확장을 고려한 설계가 필요하다.

  • 분리

    예를 들어서 DB가 오라클에서 MySql로 바꾸면서 DAO클래스 수백 개를 모두 수정해야 한다면 이는 제대로 분리되어 있는 코드로 보기 어렵다. 중요한 것은 동일한 관심사를 가진 것들은 모으고 아닌 것은 서로 분리를 해야 한다는 점이다.

1.2.2. 커넥션 만들기의 추출

  기본적으로 위에서 서술한 JDBC를 이용한 일반적인 처리를 하나의 메서드 A()에서 구현했다고 하면, 해당 메서드내에서만 여러 관심사를 확인할 수 있다.

  • DB연결을 위한 커넥션 가져오기

  • 데이터 처리를 위한 SQL을 담은 Statement를 만들고 실행하는 부분

  • 작업 완료 후 리소스를 닫는 부분

    이 중에서 커넥션 만들기가 분리되어있지 않으므로 이후 다른 DAO를 구현할 때 동일한 코드를 다시 작성해야 한다. 만약 DAO가 100개이고 DB커넥션에 대한 정보를 바꿔야하는 요구사항이 생긴다면 이는 비효율적이다. 즉 공통적으로 사용될 수 있는 커넥션 만드는 부분은 따로 분리되어야 맞다.

1.2.3. 커넥션 만들기의 독립

위와 같이 메서드 추출만으로도 어느정도 변화에 좀 더 유연하게 대처할 수 있다. 하지만 만약 A, B환경이 서로 다른 DB를 사용하고 있고, 이에 대한 대응이 필요하다면 어떻게 하는가? DB커넥션을 만드는 쪽에서 픽스된 코드를 제공하지 않고 사용하는 쪽에서 필요한 내용을 수정해서 사용하도록 하면 된다.

DAO 클래스에서 커넥션을 만드는 메서드 getConnection()부분의 구현부를 제거하고 이를 추상 메서드로 만들면 된다. 그리고 해당 DAO를 사용하는 쪽에서 getConnection()메서드를 필요에 맞게 구현해서 사용하면 된다. 즉 클래스 상속을 통한 확장으로 더 고차원적인 관심사 분리가 가능하다.

해당 내용과 같이, 슈퍼클래스에 기본적인 로직의 흐름을 만들고 그 기능의 일부를 추상메서드 등으로 만든 뒤, 서브클래스에서 이런 메서드를 필요에 맞게 구현해서 사용하는 방법을 템플릿 메서드 패턴이라고 한다. 그리고 getConnetion() 메서드는 Connection 타입의 오브젝트를 어떻게 생성할지를 서브클래스에 위임한다. 이러한 패턴을 팩토리 메서드 패턴이라고 한다.

위와 같이 관심사를 더 세밀하게 분리했지만 여전히 상속을 사용한다는 단점이 있다.

  • 자바는 다중상속을 허용하지 않기 때문에 해당 DAO는 다른 목적을 위해서 상속받지 못한다.

  • 상속관계 자체도 밀접도가 있어서 여전히 다른 관심사에 대한 결합을 허용

  • 슈퍼클래스의 변화가 서브클래스에 영향을 미칠 수 있다.

 

1.3. DAO의 확장

  커넥션을 만드는 부분을 추상클래스를 통해서 이를 상속받는 서브클래스는 서로 영향받지 않고 각각 독립적으로 사용할 수 있도록 했지만 여전히 상속을 사용함으로써 생기는 불편함을 확인했다.

관심사가 다르고 변화의 성격이 다른 두 부분을 아예 클래스를 분리하는 것은 어떨까?

기존에 DAO클래스 내부에 커넥션을 가져오는 부분을 추상메서드로 구현했었다. 하지만 좀 더 확장성을 가지기 위해서 ConnectionMaker 클래스를 따로 정의해서 DAO클래스에서는 ConnectionMaker 클래스를 오브젝트로 만들어두고 사용하도록 한다.

 

  하지만 다음과 같이 클래스를 분리했을때, 이전에 직면했던 문제에 다시 직면하게 된다.

  1. 커넥션을 만드는 부분인 SimpleConnectionMaker의 makeNewConnection() 메서드가 이미 구현되어 있기 때문에, 다른 시스템에서 다른 DB를 사용하는 요구사항을 만족시키지 못한다.
  2. DB 커넥션을 제공하는 클래스가 어떤 것인지 DAO클래스가 구체적으로 알고 있어야 한다는 점이다. 따라서SimpleConnectionMaker클래스에서 제공하는 방식이 아닌 다른 클래스에서 제공하는 방식으로 변경되는 경우, DAO클래스도 같이 수정되어야한다.

1.3.2. 인터페이스의 도입

클래스를 분리하면서 위와 같은 문제를 어떻게 해결하는가? 해결책으로는 두 클래스가 긴밀하게 연결되어 있지 않도록 추상화해서 중간에 인터페이스를 두는 방법이 있다. 하지만 인터페이스로 커넥션생성에 대한 기능을 추상화한다고해도 DAO 클래스에서 결국 해당 인터페이스를 구현하는 구체화 클래스 인스턴스를 다음과 같이 생성해야하기 때문에 인터페이스를 이용한 분리에도 불구하고 여전히 DAO 클래스의 변경 없이는 DB 커넥션 기능의 확장이 자유롭지 못하다.

connectionMaker = new DConnectionMaker(); // 특정 구체화 클래스에 대해서 알고 있어야함

1.3.3. 관계설정 책임의 분리

  인터페이스 구현을 했음에도 불구하고 결국 DAO 클래스가 어떤 ConnectionMaker 구현 클래스의 오브젝트를 이용하게 할지를 결정하는 관심사를 분리하지 않으면 독립적으로 확장가능한 클래스가 될 수 없다.

DAO클래스에와 ConnectionMaker의 구현 클래스의 관계를 결정해주는 코드를 DAO클래스 외부(클라이언트쪽에서 정하게 한다.)로 빼고 DAO클래스에서는 인터페이스와의 관계만 가지도록 한다면 다음과 같은 구조가 된다. 이는 객체지향프로그램의 다형성이라는 특징 때문에 가능하다.

 

최종적으로 실제 ConnectionMaker의 구현 클래스와의 관계는 클라이언트에게 위임한 구조는 다음과 같다.

 

 

1.3.4. 원칙과 패턴

  여태까지 DAO클래스를 리팩토링 작업을 통해서 얻는 이점을 개방폐쇄원칙(OCP)으로 설명할 수 있다.

OCP는 객체지향 설계 원칙 중 하나이고 클래스나 모듈은 확장에는 열여 있어야 하고 변경에는 닫혀있어야 한다는 원칙이다.

 

  DAO클래스는 DB 커넥션 생성이라는 관심사를 완전히 분리함으로써 DAO클래스에는 전혀 영향을 주지 않고 기능을 확장할 수 있게 되었다. 이는 변경에는 닫혀 있다고 말할 수 있다. 보통 인터페이스를 이용한 API는 보통 이 개방폐쇄원칙을 따른다.

  • 높은 응집도와 낮은 결합도

    응집도가 높다 ⇒ 하나의 모듈, 클래스가 하나의 책임, 관심사에 집중되어 있다.

    결합도가 낮다 ⇒ 책임과 관심사가 다른 오브젝트, 모듈이 변경될때 관계를 맺고 있는 다른 오브젝트에게 변화를 요구하는 정도가 낮다.

  • 전략 패턴

    개선된 클라이언트 - DAO클래스 - ConnectionMaker 인터페이스 구조는 디자인 패턴으로 봤을때 전략 패턴으로 볼 수 있다.

 

1.4. 제어의 역전(Inversion of Control)

1.4.1. 오브젝트 팩토리

  1.3에서 DAO클래스의 구조를 리팩토링하면서 어물쩡 넘어간 것은 ConnetionMaker 인터페이스의 구현 클래스를 정하는 역할을 클라이언트에 넘겼다는 것이다. 이를 개선하기 위해서 팩토리 클래스를 생성해서 ConnectionMaker 객체 생성 방법을 정하고 객체를 반환하는 역할을 수행하도록 한다.

  팩토리클래스를 정의함으로써 서비스로직과 애플리케이션 구조를 결정하는 오브젝트(팩토리)를 분리할 수 있다.

 

 

 

1.4.2 오브젝트 팩토리 활용

위의 구조는 ConnetionMaker인터페이스의 구현 클래스가 하나밖에 없지만, 보통 팩토리 클래스를 통해서 여러 구현 클래스 중 적당한 클래스 인스턴스를 반환받아서 사용할 수 있다.

1.4.3. 제어권의 이전을 통한 제어관계 역전

보통 프로그램의 흐름은 main()함수에서 사용할 오브젝트를 지정, 생성하고 만들어진 오브젝트의 메서드를 호출하는 식으로 작업이 반복된다. 이때 오브젝트는 능동적으로 자신이 사용할 클래스를 정하고 오브젝트의 생성 등을 관장한다.

하지만 제어의 역전에서는 모든 오브젝트가 제어권을 위임받은 특별한 오브젝트에 의해서 생성, 관리 된다.

 

1.5. 스프링의 IoC

1.5.1. 오브젝트 팩토리를 이용한 스프링 IoC

  • 애플리케이션 컨텍스트와 설정정보

    위에서 생성한 팩토리클래스를 스프링에서 사용가능하도록 변경하려면 이를 스프링이 제어권을 가지고 직접 만들고 관계를 부여하는 오브젝트인 bean으로 생성해야 한다.

    ⇒ 스프링 컨테이너가 생성, 관계설정, 사용 등을 제어해주는 IoC가 적용된 오브젝트

    스프링에서는 빈의 생성과 관계설정 같은 제어를 담당하는 IoC 오브젝트를 빈 팩토리를 사용하고 이를 보통 애플리케이션 컨텍스트로 이해하고 있으면 된다.

1.5.2 애플리케이션 컨텍스트 동작방식

  기본적으로 애플리케이션 컨텍스트 = IoC 컨테이너 = 스프링 컨테이너 = 빈 팩토리 이렇게 생각하자.

  앞에서 추가한 팩토리클래스의 기능을 애플리케이션 컨텍스트가 어떻게 대체할 수 있는가?

  DaoFactory는 DAO 오브젝트를 생성하고 DB 커넥션 생성 오브젝트와 관계를 맺어주는 제한적인 역할을 하지만 애플리케이션 컨텍스트는 IoC를 적용해서 관리할 모든 오브젝트의 생성, 관계설정 등을 담당한다.

  애플리케이션 컨텍스트 동작 방식은 다음과 같다.

 

 

그러면 이전 스텝에서 구현했던 팩토리클래스에 비해서 무엇이 나은가?

  • 클라이언트에서 구체적인 팩토리 클래스를 알 필요가 없다

    ⇒ IoC를 적용한 오브젝트가 추가된다고 해도 클라이언트 쪽에서는 어떤 팩토리 클래스를 사용해야 하는지, 필요할 때마다 팩토리 클래스 오브젝트를 생성할 필요가 없다. 또한 어노테이션 등을 통해서 간편하게 bean등록이 가능하다.

  • 애플리케이션 컨텍스트는 오브젝트 생성 뿐만이 아닌 전체적인 IoC 서비스를 제공한다.

    ⇒ 오브젝트 생성, 관계 설정 뿐만 아니라, 오브젝트가 만들어지는 방식, 후처리, 인터셉팅 등 지원

  • 애플리케이션 컨텍스트는 bean을 검색하는 다양한 방법을 제공한다.

    ⇒ bean의 이름을 통해서 찾거나, 특정타입, 특정 어노테이션으로 등록되어 있는 bean만 검색 가능

 

1.6. 싱글톤 레지스트리와 오브젝트 스코프

  DAO클래스를 직접 사용하는 것과 @Configuration, @Repository 등의 어노테이션을 통해서 bean등록을 하고 스프링의 애플리케이션 컨텍스트를 통해서 사용하는 것은 결과적으로 비슷해보일 수 있지만 다른점이 있다.

먼저 간략하게 오브젝트의 동일성과 동등성을 비교할 수 있어야한다.

  • 동일성 : 두 오브젝트가 동일한 주소값을 가리키는 아예 동일한 오브젝트

  • 동등성 : 두 오브젝트가 동일하지 않을 수 있지만 오브젝트의 값이 같은 것

    DAO클래스를 직접 new() 메서드를 통해서 인스턴스화해서 사용하면 생성되는 오브젝트는 호출시마다 달라진다. 하지만 bean으로 등록되어 있는 오브젝트를 애플리케이션 컨텍스트를 통해서 가져오면 항상 동일한 오브젝트를 반환받는다. 애플리케이션 컨텍스트가 어떤 방식으로 동작하기에 이런 결과를 얻는가?

1.6.1 싱글톤 레지스트리로서의 애플리케이션 컨텍스트

애플리케이션은 이전 단계에서 임의로 생성했던 오브젝트 팩토리 클래스와 비슷한 역할을 하지만 동시에 싱글톤을 저장하고 관리하는 싱글톤 레지스트리이기도 하다. 기본적으로 별 다른 설정이 없으면 스프링 내부에서는 bean 오브젝트를 싱글톤으로 생성한다. 왜 싱글톤이 디폴트인가?

  • 서버 애플리케이션과 싱글톤

    기본적으로 스프링을 사용하는 환경은 대규모 엔터프라이즈 서버환경이다. 초당 수십, 수천건의 요청을 처리해야하는 환경에서 클라이언트 요청 시 마다 새로운 오브젝트를 생성하는 것은 엄청난 부하를 일으킬 수 있다. 그래서 가장 기본이 되는 서블릿 (서비스 오브젝트) 클래스당 오브젝트를 하나씩만 만들어두고 멀티스레드 환경에서 여러 스레드가 하나의 오브젝트를 공유해서 동시에 사용한다.

  • 싱글톤 패턴의 한계

    서버환경에 따라서 싱글톤 사용이 권장되지만 한계가 뚜렷한 패턴이기도 하다.

    • 보통 private 생성자를 가지고 있기 때문에 상속할 수 없다.

      싱글톤 패턴은 생성자를 private으로 제한하기 때문에 상속, 다형성 등 객체지향의 장점을 사용할 수 없다.

  • 테스트가 어렵다.

    싱글톤은 생성되는 방식이 제한적이어서 이를 mock 오브젝트 등으로 대체하기 어렵다. 초기화 과정에서 생성사를 이용해서 다이나믹하게 주입하는 방식도 불가능하다.

  • 서버환경에서는 싱글톤이 하나만 만들어지는걸 보장하지 못한다.

    서버에서 클래스 로더를 어떻게 구성하는지에 따라서 하나 이상의 오브젝트가 생성될 수 있고, JVM에 분산되어서 설치되는 경우에도 각각 독립적으로 오브젝트가 생성된다.

  • 싱글톤 사용은 전역 상태를 만들 수 있기 때문에 바람직하지 않다.

    전역상태, 아무 객체나 자유롭게 접근하고 수정, 공유하는 상태를 가지는건 객체지향 프로그래밍에서 권장되지 않는 모델이다.

    • 싱글톤 레지스트리

    위와 같이, 싱글톤 패턴을 직접 구현해서 사용하는 것에는 여러 단점이 있기 때문에 스프링에서는 해당 기능을 제공하는데 그것이 싱글톤 레지스트리이다. 싱글톤 레지스트리는 private 생성자, static 메서드를 사용해야 하는 비정상적인 방법이 아니라 평벙한 자바 클래스를 싱글톤으로 사용하게 관리해준다. 이 클래스의 생성, 사용 등에 대한 제어권은 컨테이너로 넘어간다.

1.6.2. 싱글톤과 오브젝트의 상태

멀티스레드 환경에서는 하나의 싱글톤 오브젝트에 여러 스레드가 동시에 접근, 사용할 수 있기 때문에 상태 관리에 주의를 기울여야 한다. 동시 접근을 통해서 상태값이 무분별하게 수정될 수 있기 때문에 싱글톤은 기본적으로 상태에 대한 값을 가지지 않는 stateless 방식으로 구성되어야 한다.

초기에 셋팅해서 사용하는 읽기 전용 값에 대해서는 멀티스레드 환경에서도 크게 문제가 되지 않는다. 보통 static final이나 final 등으로 선언해서 사용하는게 일반적이다.

1.6.3. 스프링 빈 스코프

스프링이 관리하는 오브젝트 bean이 생성되고, 존재하고, 적용되는 범위를 스코프라고 한다. 대부분의 스프링 bean은 싱글톤 스코프를 가진다. 경우에 따라서 프로토타입 스코프, 요청 스코프, 세션 스코프 등이 있다.

이에 대한 내용은 이후 10장에서 다시 정리한다.

 

1.7. 의존관계 주입 DI(Dependency Injection)

1.7.1. IoC와 DI

IoC는 소프트웨어에서 자주 사용되는 일반적인 개념이지만 폭넓게 사용되는 용어이기 때문에 스프링을 IoC컨테이너라고만 설명하는 것은 부족하다.

DI는 스프링 IoC 기능의 대표적인 동작원리를 설명한다.

1.7.2 런타임 의존관계 설정

의존관계는 보통 두 클래스의 관계 표현으로 설명할 수 있다. A가 B에 의존하고 있다면 B의 변화가 A에 영향을 미친다는 뜻이다.

 

 * DAO클래스의 의존관계

  위에서 설명한 DAO클래스는 DB 커넥션을 만드는 ConnectionMaker 인터페이스에 의존하고 있다. 인터페이스 자체가 변경된다면 DAO클래스에 영향을 미치겠지만 이 인터페이스를 구현하는 DConnectionMaker 등의 구현체가 변경되는 것은 영향을 미치지 않는다.

즉 인터페이스에 대해서만 의존관계를 만들어두면 결합도가 낮아지고 변경에 대해서 자유로워진다.

 

 

 

  인터페이스를 통한 느슨한 의존관계를 가지는 경우, DAO클래스, ConnectionMaker 등의 설계와 코드에서는 사용할 ConnectionMaker오브젝트가 드러나지 않고 런타임 시에 의존관계를 맺고 실제 사용대상인 오브젝트를 선정한다. 의존관계 주입은 다음 세 가지 조건을 충족해야하는 작업이다.

  1. 클래스 모델, 코드 상에 런타임 시점의 의존관계가 나타나지 않는다. 그러기 위해서는 인터페이스에만 의존하고 있어야 한다.

  2. 런타임 시점의 의존관계는 팩토리, 컨테이너 등의 제 3의 존재가 결정한다

  3. 의존관계는 사용할 오브젝트에 대한 레퍼런스를 외부(팩토리 등)에서 주입해줌으로써 만들어진다.

    • DAO클래스의 의존관계 주입

    DAO클래스에서 사용하고자 하는 ConnectionMaker의 구체클래스를 정의하는 것은 위의 조건 중 (2)를 지키지 않아서 일어나는 문제였다. 이를 해결하기 위해서 팩토리 클래스를 구현했고 이 클래스는 두 오브젝트 (DAO, ConnectionMaker) 사이의 런타임 의존관계를 생성하주는 DI 작업을 주도하는 존재이며 동시에 IoC 방식으로 오브젝트를 생성, 초기화, 제공 관리하는 컨테이너이다. ⇒ DI 컨테이너

    DI는 자신(DAO클래스)이 사용할 오브젝트(ConnectionMaker)에 대한 선택과 생성제어권을 외부(팩토리클래스)로 넘기고 자신은 수동적으로 주입받은 오브젝트를 사용한다는 점에서 IoC의 개념과 잘 맞는다.

1.7.3. 의존관계 검색과 주입

  스프링에서는 런타임시 의존관계를 주입받아서 사용하는 방법이 아닌, 스스로 검색을 통해서 의존관계를 검색해서 자신이 필요한 오브젝트를 능동적으로 찾아올 수 있다. 자신이 어떤 오브젝트를 사용할지 스스로 선택하지는 않지만 메소드나 생성자를 통한 주입 대신 스스로 컨테이너에 요청해서 사용할 오브젝트를 가져온다.

 

이후에 추가적으로 XML, java 설정 부분은 추후에 실제로 개발하고, 프로젝트를 확인하면서 다시 보면 좋을 듯 하다.

'Spring' 카테고리의 다른 글

[토비 스프링3] 6. AOP (1)  (0) 2023.03.04
[토비 스프링3] 5. 서비스 추상화  (0) 2023.02.13
[토비 스프링3] 4. 예외  (0) 2023.01.23
[토비 스프링 3] 3. 템플릿  (0) 2023.01.08
[토비 스프링 3] 2. 테스트  (0) 2020.06.09

의도

  객체를 생성하기 위해서 인터페이스를 정의하고, 어떤 클래스의 인스턴스를 생성할지에 대한 결정은 서브클래스에서 내리도록 한다.

 

동기 (사용 예시)

  사용자에게 다양한 종류의 문서를 표현하는 응용프로그램 프레임워크가 있다고 가정했을때 다음 두 가지 추상화가 필요하다

  • Application 클래스

  • Document 클래스

    어떤 응용프로그램을 만드는지에 따라서 인스턴스로 만들어지는 Document의 서브 클래스가 달라지기 때문에 Application 클래스에서 어떤 Document의 인스턴스를 생성하는지 미리 예측할 수 없다.

    이러한 상황에서 이 패턴은 Document의 서브클래스 중 어느 것을 생성하는지에 정보를 캡슐화하고, 이를 Application 클래스와 분리한다.

    Application 클래스의 서브클래스는 추상화된 CreateDocument() 연산을 재정의해서 적당한 Document 클래스의 서브클래스를 반환하도록 한다.

참고할만한 예시

https://jdm.kr/blog/180

 

활용성

  팩토리 메서드 디자인 패턴을 사용하는 상황

  • 어떤 클래스가 자신이 생성해야 하는 객체의 클래스를 예측할 수 없을 때

  • 생성할 객체를 기술하는 책임을 자신의 서브클래스가 지정했으면 할 때

  • 객체 생성의 책임을 몇 개의 서브클래스 중 하나에 위임하고 어떤 서브클래스가 위임자인지에 대한 정보를 포괄적으로 관리하고 싶을 때 (반환받은 Document 클래스의 서브클래스를 사용하면 됌)

 

구조

  • Product(Document) : 팩토리 메서드가 생성하는 객체의 인터페이스 정의

  • ConcreateProduct(MyDocument) : Product 클래스(추상/인터페이스)를 실제로 구현한 내용

  • Creator(Application) : Product 타입의 객체를 반환하는 팩토리 메서드 정의, 위의 예시에서는 팩토리 메서드를 통해서 ConcreateProduct 객체를 반환한다.

  • ConcreateCreator(MyApplication) : 팩토리 메서드를 재정의하여 적당한 ConcreteProduct의 인스턴스 생성

    • 협력 방법

    Creator는 자신의 서브클래스를 통해서 실제 필요한 팩토리 메서드를 정의하고, 이를 통해서 상황에 맞는 적절한 ConcreteProduct의 인스턴스를 반환할 수 있게 한다.

 

결과

  팩토리 메서드 패턴을 적용함으로써 구상 클래스에 코드가 종속되지 않는다. 위의 예시에서 보면 Product 클래스에 정의된 인터페이스와 동작하기 때문에 서브클래스 ConcreateProduct가 추가되었을때 코드레벨에서 대응해야하는 양을 줄일 수 있다.

  • 클래스간의 결합도를 낮추기(클래스 변경점이 생겼을 때 다른 클래스에 영향도 최소화)

  • 직접 객체를 생성해 사용하는 것을 방지하고 서브클래스에 생성을 위임함으로써 의존성 제거, 확장에 용이

  • 인터페이스에(Product) 맞춰서 코딩을 함으로써 다형성으로 얻을 수 있는 장점 (여러 변화에 대처)

    다만 주의해야할 점은 팩토리 메서드는 새로운 concreateProduct를 추가하려고 할때마다 서브클래싱이 필요하다는 점이다. 불필요한 클래스 재정의 발생 가능성이 있다.

 

구현

  구현 시 고려해야하는 상황

  1. 구현 방법 정하기

    • Creator 클래스를 추상클래스로 정의하고 정의한 팩토리 메서드에 대한 구현은 제공 X

    • Creator를 구체 클래스로 정의하고 팩토리 메서드에 대한 기본 구현을 제공

    1. 팩토리 메서드 매개변수화

    팩토리 메서드가 매개변수를 받아서 어떤 종류의 Product를 생성할지 식별하게 할 수 있다.

    1. 언어별 구현 방법 상이

    2. 템플릿을 사용해서 서브클래싱

    팩토리 메서드를 사용함으로써 발생할 수 있는 단점은 Product 클래스를 추가하려고 할때마다 서브클래싱을 해야 한다는 점이다. Creator의 서브클래스가 되는 템플릿 클래스를 정의하고 이것이 Product 클래스로 매개변화되도록 구현한다.

    1. 명명 규칙의 중요성

    팩토리 메서드를 사용한다는 사실을 명확하게 할 수 있도록 Creator 클래스의 이름 등을 통일 (-Factory.java)

Chapter 7. 병렬 데이터 처리와 성능

ToC

  • 병렬 스트림으로 데이터를 병렬 처리하기
  • 병렬 스트림 성능 분석
  • 포크/조인 프레임워크
  • Spliterator로 스트림 데이터 쪼개기

 

7.0.

이전부터 스트림에 대해서 정리할때 계속해서 확인 했었던 내용중에서 스트림을 이용해서 내부 반복으로 처리하면 멀티코어를 이용한 병렬처리가 용이하다는 내용이 반복 됐었다.

자바 7 이전에는 병렬처리를 하기 위해서는 컬렉션 데이터를 서브파트로 분할하고 각 서브파트를 스레드로 할당, 각 스레드별 데이터의 동기화, 결과 aggregation을 직접 구현해야 했었다.

이 작업을 쉽게 하는 방법과 주의해야될 점에 대해서 정리 해본다.

 

7.1. 병렬 스트림

병렬 스트림은 스트림을 각 스레드에서 처리할 수 있도록 스트림 요소를 여러 청크로 분할한 스트림이다. 만약 1부터 n까지의 합을 sum하는 연산이 있다고 했을때, n이 큰 수 이면 순차적인 처리보다 스레드별로 task를 나눠서 병렬적으로 처리하는 것이 효과적이다.

Stream.iterate(1L, i -> i + 1)
            .limit(n)
      .parallel() // 스트림을 병렬스트림으로 변환
            .reduce(0L, Long::sum);

 

* 스트림 성능 측정

실제로 병렬화를 통하면 순차나 반복 형식에 비해 성능이 좋아지는가? 이 책에서는 Java Microbenchmark Harness(JHM)이라는 라이브러리를 이용한 작은 벤치마크를 구현해서 성능을 비교한다. 책에서 공유한 성능테스트는

for루프 > 순차적 스트림 > 병렬 스트림 순서로 성능이 좋았다.

위와 같은 결과는

  1. 반복 결과로 박싱된 객체가 만들어지므로 스트림을 이용한 처리 시 언박싱을 해야 한다.

  2. 반복 작업은 병렬로 수행할 수 있는 독립 단위로 나누기가 어렵다.

    2번과 같은 문제는 병렬 스트림을 무분별하게 사용할 시, 더 안좋은 성능을 야기할 수 있다. 해당 예시코드의 경우, reduce연산을 하는 시점에 전체 숫자 리스트가 준비되어 있지 않으므로 스트림 병렬처리를 하기 위한 청크로 분리할 수 없다. 그래서 병렬 처리의 이점을 보지 못하고 스레드를 할당하는 오버헤드만 증가하게 된다.

 

* 위의 예시를 발전시킨다면?

LongStream.rangeClosed() 를 사용해서 요소를 생성해낸다면

  1. 기본형 long을 직접 사용하므로 박싱과 언박싱 오버헤드가 사라진다.

  2. 생성된 요소를 청크로 분할할 수 있다.

    LongStream.rangeClosed(1, N)

     .parallel() // 스트림을 병렬스트림으로 변환
           .reduce(0L, Long::sum);

    해당 예제코드는 순차 실행보다 빠른 성능을 갖는다.

결과적으로 병렬화를 위해서 스트림 분할, 서로 다른 스레드에서의 리듀싱 연산, 결과값 합치기 등의 오버헤드가 발생하기 때문에 최소한 멀티코어간의 데이터 이동 오버헤드보다 훨씬 오래 걸리는 작업을 처리할 때 효과를 볼 수 있다.

 

 

* 병렬 스트림의 잘못된 사용법

보통 병렬 스트림을 잘못 사용해서 발생하는 문제는 공유된 상태를 바꾸는 알고리즘을 사용하기 때문에 일어난다.

public long sideEffectSum(long n){
    Accumulator acc = new Accumulator();
    LongStream.rangeClosed(1, n).forEach(accumulator::add);
    return acc.total;
}

public Class Accumulator{
    public long total =0;
    public void add(long val) { total += val; }
}

위와 같은 예시는 만약 병렬로 처리하게 된다면 문제가 생긴다. total에 접근할때마다 다수의 스레드에서 동시에 데이터에 접근하는 데이터 레이스 문제가 발생한다. 여러 스레드에서 동시에 total += val을 실행하면서 각 스레드가 공유된 가변 상태를 가지기 때문에 정확한 값을 도출할 수 없다.

 

 

* 병렬 스트림의 효과적인 사용법

  1. 기본형 특화 스트림(IntStream, LongStream)을 사용해서 박싱, 언박싱에 대한 오버헤드를 줄이자.

  2. limit, findFirst처럼 요소의 순서에 의존적인 연산은 병렬 스트림을 느리게 만든다.

  3. 소량의 데이터에서는 병렬화 과정에서 생기는 오버헤드 만큼의 이득을 얻지 못한다.

  4. 스트림을 구성하는 자료구조의 중요성

    ex. ArrayList, LinkedList중에서 LinkedList는 분할하려면 무조건 모든 요소를 탐색해야하기 때문에 비효율

  5. 최종 연산의 병합과정의 오버헤드가 크다면 병렬 처리의 이점이 줄어든다.

 

7.2. 포크/조인 프레임워크

포크/조인 프레임워크는 병렬화할 수 있는 작업을 재귀적으로 작은 작업으로 분할한 다음 서브태스크의 결과를 합쳐서 전체 결과를 반환한다.

서브태스크를 스레드 풀의 worker 스레드에 분산 할당하는 ExecutorService 인터페이스를 구현한다.

  • RecursiveTask 활용

    스레드 풀을 이용하려면 RecursiveTask의 서브클래스를 만들어야 한다. 여기서 R은 병렬화된 태스크가 생성하는 결과 값 혹은 결과가 없을때 RecursiveAction 형식이다. 추상메서드 compute()는 태스크를 서브태스크로 분할하는 로직과 더 분할할 수 없을때 개별 서브태스크에서 돌아가는 로직을 구현한다.

  아래의 예시는 충분히 작아진 크기의 태스크가 될때까지 divide & conquer 알고리즘을 이용해서 태스크를 분할하고 forking프로세스로 만들어진 이진트리의 태스크를 루트에서 역순으로 방문하면서 계산한다. 각 서브태스크의 결과를 합쳐서 최종 결과를 반환한다.

ForkJoinSum 클래스는 RecursiveTask<Long>의 구현체이다.

@Override
protected Long compute(){
    int lngth = end - start;
    if (length <= THRESHOLD) return computeSequentially(); // 기준값보다 작으면 순차적 결과 계산
  ForkJoinSum leftTask = new ForkJoinSum(numbers, start, start + length/2);
    leftTask.fork();
    ForkJoinSum rightTask = new ForkJoinSum(numbers, start + length/2, end);
    rightTask.fork();

  Long rightRst = rightTask.compute();
    Long leftRst = leftTask.join();
    return leftRst + rightRst;
}
private long computeSequentially(){ 더 이상 분할이 불가능한 경우에 수행하는 로직 }
  • 작업 훔치기(work stealing)

    ForkJoinPool을 통해서 각 스레드별로 나눠진 태스크는 제대로 분할이 되지 않았거나, 외부 API를 콜 하는 등의 작업을 통해서 지연이 생길 수 있다. 결과적으로 전체 테스크에 대한 최종 결과 반환이 늦어질 수 있다.

    포크/조인 프레임워크에서는 작업 훔치기 기법으로 이를 해결한다. 각 스레드는 자신엑 할당된 태스크를 포함하는 이중 연결 리스트를 참조하면서 큐의 head에서 다른 태스크를 가져와서 처리한다. 이때 태스크의 크기를 작게 나누어야 worker 스레드 간의 작업부하를 비슷한 수준으로 유지할 수 있다.

 

7.3. Spliterator 인터페이스

자바 8 부터 제공되는 Spliterator 인터페이스는 Iterator와 소스의 요소 탐색 기능을 제공하는 점에서 같지만 병렬 작업에 특화되어 있다.

public interface Spliterator<T> {
    boolean tryAdvance(Consumer<? super T> action );
  Spliterator<T> trySplit();
    long estimateSize();
    int characteristics();
}   

여기서 T는 탐색하는 요소의 타입이다.

  • tryAdvance() : Spliterator의 요소를 순차적으로 소비하면서 더 탐색할 요소가 있으면 true 반환

  • trySplit() : Spliterator의 일부 요소를 분할해서 두 번째 Spliterator 생성

    재귀적으로 분할이 일어나고 trySplit()의 결과가 null이 되는 건 더 이상 분할이 불가능하다는 뜻이므로 모든 Spliterator의 trySplit()의 결과가 null이면 분할을 중지한다.

시간을 내서 이후 커스텀 Spliterator구현 관련 내용도 보면 좋을 것 같다.

Chapter 6. 스트림으로 데이터 수집

ToC

  • Colletors 클래스로 컬렉션 만들고 활용
  • 하나의 값으로 데이터 스트림 리듀스하기
  • 특별한 리듀싱 요약 연산
  • 데이터 그룹화의 분할
  • Collector 인터페이스

6.0.

스트림을 이용해서 SQL 질의를 통한 DB 연산과 비슷하게 데이터집합에 대한 연산을 수행하는 것을 보았다. 또한 스트림의 연산은 중간 연산, 최종 연산으로 나눠져 있다는 것도 확인했다. 중간 연산은 스트림 파이프라인을 구성하지만 스트림의 요소를 소비(계산)하지는 않는다. 반면 최종 연산은 스트림 요소에 대한 계산을 수행하고 최종 결과를 도출한다.

최종 결과를 도출하는 collect 메서드를 통해서 이전에 본 reduce처럼 다양한 요소 누적 연산을 최종 결과도 도출할 수 있다.

  • 컬렉션(Collection), 컬렉터(Collector), collect 메서드 에 대한 용어 혼동 조심

 

6.1. 컬렉터란 무엇인가?

스트림 연산에서 최종 연산인 collect메서드에서 인수로 받는 요소 누적 방식으로 Collector 인터페이스에 정의되어 있다 .

ex) Collector 인터페이스에 정의되어 있는 toList, groupingBy 등은 스트림 최종 연산에 대한 특정 동작을 수행한다.

.toList ⇒ 각 요소를 리스트로 만들어라

.groupingBy ⇒ 각 키, 그리고 키에 대응하는 요소 리스트를 값으로 포함하는 Map을 만들어라 
  • 컬렉터에서 제공하는 메서드의 기능
  1. 스트림 요소를 하나의 값으로 리듀스(요소 누적 계산)하고 요약

  2. 요소 그룹화

  3. 요소 분할

 

6.2. 리듀싱과 요약 계산

 컬렉터로 스트림의 모든 항목을 하나의 결과로 합칠 수 있다. 다수의 스트림 요소를 하나의 값 최대 최소, 합계, 평균 등의 값으로 반환하는 연산에 자주 사용된다.

 

* counting()

long howManyDishes = menu.stream.collect(Collectors.counting());

 

 

* 최대 최소값

  • Collectors. maxBy

  • Collectors.minBy

위의 두 컬렉터는 스트림의 요소를 비교하는데 사용할 Comparator를 인수로 받는다.

//Comparator 구현
Comparator caloriesComparator = Comparator.comparingInt(Dish::getCalories);

//스트림 연산
Optional mostCalorieDish = menu.steam()
  .collect(maxBy(caloriesComparator));

 

 

* 요약 연산 (합계, 평균 등)

  • Collectors.summingInt

  • Collectors.averaginLong 등..

    int totalCalories = menu.steam().collect(summingInt(Dish::getCalories));

 해당 코드는 스트림 요소에 대해서 변환함수(Dish::getCalories)를 통해서 칼로리값(Integer)을 구하고 이를 리듀싱연산으로 하나씩 누적시켜서 전체 SUM을 반환한다.

 

 

* 문자열 연결

  • Collectors.joining

    스트림의 각 개체에 toString메서드를 호출하고 이를 하나의 문자열로 연결해서 반환한다.

    //Dish클래스가 메뉴명에 대한 toString()을 구현하고 있다면 map함수는 제외할 수 있음
    String shortMenu = menu.stream().map(Dish::getName).collect(joining( 구분값 ));

    * 범용 리듀싱 연산 (커스터마이징 가능, 재사용성 높아짐)

  • Collectors.reducing

    위에서 나열된 연산을 모두 구현할 수 있다. reducing메서드는 세 개의 인자를 받는데

    • 리듀싱 연산의 시작값 혹은 스트림에 인수가 없을 경우 반환값

    • 요소에 대한 map연산이 필요한 경우

      ex. Dish를 실제 계산에 필요한 칼로리 값으로 바꾸는 연산 getCalories()

    • 같은 종류의 요소 두 항목을 하나의 항목으로 더하는 BinaryOperation

     

int totalCalories = menu.stream().collect( reducing(0, Dish::getCalories, (i,j) -> i + j) );
//위에 람다로 표현한 합계함수를 Interger::sum으로 처리 가능

 

6.3. 그룹화

컬렉터의 두 번째 기능인 그룹화는 데이터 집합을 하나 이상의 특성으로 분류해서 그룹핑 하는 기능이다. 아래 콛는 분류함수인 groupingBy메서드 사용 예시이다.

Map<Dish.Type, List> dishesByType = 
 menu.stream().collect(groupingby(Dish::getType));

/* Map 결과
{FISH=[prawns, salmon], MEAT=[pork,beef,chicken] }
*/

분류함수를 통해서 Map의 키 값을 정하고 스트림요소를 해당 키의 value로 넣는다. 위의 예시에서 사용된Dish::getType 등의 함수가 없으면 직접 람다표현식으로 로직을 구현할 수 있다

//ex. 칼로리로 분류하려면?
groupingBy(dish -> {
 if(dish.getCalories() <= 400) return CaloricLevel.DIET;
 else if(dish.getCalories() <=700) return CaloricLevel.NORMAL;
 else return CaloricLevel.FAT;
}));

 

 

* 그룹화된 요소 조작

스트림 요소를 그룹핑한 이후, 각 결과 그룹 요소를 조작하는 연산이 필요하다. 이는 groupingBy 메서드 이전에 Predicate을 이용해서 fileter 메서드를 걸어주면 된다.

Map<Dish.Type, List> dishesByType = 
 menu.stream().filter(dish -> dish.getCalories() > 500)
   .collect(groupingby(Dish::getType));

하지만 위와 같은 경우, filter메서드에서 제외한 요소는 결과 Map에서 key값이 아예 사라진다. 이런 경우 필터 Predicate을 groupingBy 메서드에 두 번째 인자로 주면 해당 문제를 해결할 수 있다.

Map<Dish.Type, List> dishesByType = 
 menu.stream()
   .collect(groupingby(Dish::getType), filtering(dish -> dish.getCalories() > 500), toList());

 

 

* 다수준 그룹화 (두 개 이상의 기준을 적용한 그룹화)

groupingBy는 일반적으로 분류 함수와 컬렉터를 인수로 받는데 groupingBy 메서드가 컬렉터를 반환하는 특성에 따라서 groupingBy 메서드를 중첩시킬 수 있다

menu.stream.collect(
	groupingBy(Dish::getType,    //첫 번째 분류함수
 		groupingBy(dish -> {       //두 번째 분류함수
 			if(dish.getCalories() <= 400) return CaloricLevel.DIET;
 			else if(dish.getCalories() <= 700) return CaloricLevel.NORMAL;
 		})
 	)
); 

 

 

* 컬렉터 결과를 원하는 형식으로 변환하기

 

 - Collectors.collectingAndThen

 

 

 groupingBy(분류함수, 컬렉터) 의 형식에서 요리의 종류를 분류하고 각 key별로 가장 높은 칼로리를 가진 요리를 value에 추가할 수 있다.

Map<Dish.Type, Optional> mostCaloricByType =
	menu.stream().collect(groupingby(Dish::getType), maxBy(comparingInt(Dish::getCalories));
// {FISH=Optional[salmon], MEAT=Optional=[pork]}

 

 

 

 하지만 위의 결과에서 결과값을 optional이 실제 값으로 반환받고 싶다면 collectingAndThen 메서드를 이용해서 컬렉터의 결과 요소를 다른 타입으로 활용할 수 있다.

menu.stream().collect(groupingby(Dish::getType), collectingAndThen(
	maxBy(comparingInt(Dish::getCalories), Optional::get))); // 반환된 Optional의 값을 추출

 

groupingBy 메서드와 같이 자주 사용되는 mapping 컬렉터에 대한 추가 예제

menu.stream().collect(
	groupingBy(Dish::getType, mapping(dish -> {
		if(dish.getCalories() <= 400) return CaloricLevel.DIET;
		else{ return CaloricLevel.FAT; }, toSet() )
     ));

 

6.4. 분할

분할은 분할 함수라 불리는 Predicate를 분류 함수로 사용하는 특수한 그룹화 기능이다. 분할 함수는 boolean을 반환하기 때문에 이를 분류 함수로 사용했을때 결과 Map의 키는 true, false 로 나온다.

 

 - partitioningBy 메서드

Map<Boolean, List> partitionedMenu =
	menu.stream.collect(partitioningBy(Dish::isVegetarian));
/*
{false = [pork, beef, chicken, salmon],
true = [french fries, rice, fruit]}
*/

 

 * 숫자를 소수와 비소수로 분할하기

 정수 n을 인수로 받아서 2에서 n까지의 자연수를 소수와 비소수로 나누기

 

  - 소수 판단 Predicate 구현

public boolean isPrime(int candidate){
	int candidateRoot = (int) MAth.sqrt((double)candidate); // n의 제곱근 이하 수까지만 확인
	return IntStream.range(2, candidateRoot).noneMatch(i -> candidate % i == 0);
}

 

  

  - 소수 판단 후 분류

Map<Boolean, List> result =
	IntStream.rangeClosed(2, n).boxed()
    		 .collect(partitioningBy(candidate -> isPrime(candidate)));

 

6.5. Collector 인터페이스

리듀싱 연산(컬렉터, 누적 계산)을 어떻게 구현할지 제공하는 메서드 집합으로 구성되어있다.

ex. toList(), groupingBy()

public interface Collector<T, A, R> {
    /*
 T -> 수집될 스트림 항목의 제네릭 타입
    A -> 누적자, 즉 수집 과정에서 중간 결과를 누적하는 객체의 형식
    R -> 수집 연산 결과 객체의 형식
 */
    Supplier supplier();

    BiConsumer<A, T> accumulator();

    BinaryOperator combiner();

    Function<A, R> finisher();

    Set characteristics();

* supplier() : 새로운 결과 컨테이너 만들기

수집 연산의 시작값으로 빈 객체를 반환한다. 수집 과정에서 빈 누적자 인스턴스를 만드는 파라미터가 없는 함수이다. 컬렉터에서 결과에 대한 처리를 할 때 빈 객체 대응.

 

 

* accumulator() : 결과 컨테이너에 요소 추가하기

리듀싱 연산을 수행하는 함수를 반환한다. 스트림에서 n번째 요소를 탐색할 때, 두 개의 인수를 받는다.

  1. 누적자 (스트림의 n-1개 항목을 수집한 상태 )

  2. n번째 요소

    위의 인수로 n번째 요소에 함수를 적용한다.

    return (list, item) -> list.add(item);

    • finisher() : 최종 변환값을 결과 컨테이너로 적용하기

    finisher 메서드는 스트림 탐색을 끝내고 누적자 객체를 최종 결과로 변환하고, 누적 계산이 완료될 때 호출할 함수를 반환해야 한다. 이미 누적자 객체 자체가 최종 결과일 경우 finisher는 항등함수를 반환한다.

 

* combiner() : 두 결과 컨테이너 병합

combiner는 스트림의 서로 다른 서브파트를 각각 처리하고 누적자가 이 결과를 어떻게 병합할지 정의한다. 예를 들면, toList()의 경우, 나눠서 계산된 서브파트A, B가 있을 때, 누적자는 A의 결과에 B의 결과를 붙이기만 하면 된다.

해당 메서드를 이용함으로써 스트림의 리듀싱을 병렬로 처리할 수 있다.

public BinaryOperator<List> combiner(){
 return (listA, listB) -> { listA.addAll(listB); return listA; }
}
  • Characteristrics : 컬렉터의 연산을 정의

    • UNORDERED

      리듀싱 결과가 스트림 요소의 방문 순서나 누적 순서에 영향을 받지 않는다.

    • CONCURRENT

      다중 스레드에서 accumulator 함수를 동시에 호출할 수 있고 병렬 리듀싱을 수행할 수 있다.

    • IDENTITY_FINISH

      리듀싱 과정의 최종 결과로 누적자 객체를 바로 사용할 수 있게하고, 누적자 A를 결과 R로 안전하게 형변환

** 전체적인 플로우**

//1. 새로운 결과 컨테이너 생성
A accumulator = collector.supplier().get();

//2. 결과 컨테이너에 누적 계산 결과 추가
collector.accumulator().accept(accumulator, next);

//3. 누적자 객체를 최종 결과로 반환
R result = collector.finisher().apply(accumulator);

return result;

이 장의 마지막 부분인 커스텀 컬렉터 구현은 시간이 날 때 직접 해보면 좋을 것 같다.

+ Recent posts