파일시스템 2편 - RAID

이 글은 학부 System Programming 수업을 듣고 다른 자료들과 함께 공부한 내용을 정리한 글입니다.
이전 포스트의 하드디스크에 이어 이번 포스트에서는 RAID에 대해 알아볼 것입니다.

이번 편은 이전의 운영체제 3편 - 컴퓨터 구조와 I/O 글의 I/O 부분을 이해하고보면 도움이 됩니다.


What is RAID?

RAIDRedundant Array of Inexpensive Disks의 약자이다. 즉 여러개의 disk를 사용해서 더 빠르고, 더 크고, 더 신뢰성 있는 disk 시스템을 구축하는 방식이다.
외부적으로 보는 관점에서는 RAID는 그냥 disk와 동일하다. 외부에서는 그저 큰 disk처럼 바라볼 뿐이다. disk와 동일하게 block의 묶음을 읽고 쓸수있다. 하지만 RAID 내부적으로는 시스템을 관리하는 프로세서도 존재하고 메모리도 존재하며 무엇보다 여러개의 disk로 구성되어 있다.

RAID는 왜 쓰는 것일까?
RAID를 사용하면 먼저 성능상으로 이득을 볼 수 있다. 여러개의 disk에 병렬로 I/O를 할 수 있다. 그리고 용량(capacity)측면에서도 이득을 볼 수 있다. 한개의 disk 크기를 넘어서는 크기를 저장할 수 있기 때문이다.
그리고 신뢰성(reliability)도 더 높은데 RAID를 구성하는 방식에 따라 다를 수 있지만 데이터를 여러 disk에 중복해서 저장함으로서 single disk failure에 대해 극복할 수 있고 외부에서는 single disk failure가 일어나도 아무 일도 일어나지 않은 것처럼 수행할 수 있다.


RAID Internals

파일시스템 밑에서 RAID는 그저 매우 크고 신뢰성있고 빠른 disk 처럼 보일뿐이다. single disk처럼 linear array of blocks로 보이고 각 block은 파일시스템에 의해 읽고 쓰여질 수 있다.
파일시스템이 RAID에 logical block에 대해 I/O 요청을 하면 RAID는 내부적으로 여러 disk를 가지고 있는데 어떤 disk에 접근해서 physical I/O를 할 지 결정하고, 수행한 후 그 결과를 반환한다. 이 physical I/O를 어떻게 수행할 것인가는 밑에서 볼 RAID level에 따라 다르다.
간단하게 RAID가 각 disk의 copy본을 들고있는 mirrored RAID 라고 해보자. 그러면 write을 수행할 때 각 block들을 disk에 2번씩 써주어야 한다.


RAID Evaluation

RAID를 구성하는 방법은 여러가지가 있다. 구성하는 방법에 따라 특징 그리고 장단점이 존재하는데 이런 특징을 수치로 측정할 수 있으로면 비교하기 쉽다. 그래서 우리는 3가지 측면에서 RAID의 특징을 측정할 것이다.

capacity

먼저 capacity이다. B개의 block을 저장할 수 있는 N개의 disk가 있다. 클라이언트는 RAID에 얼마나 많은 용량을 저장할 수 있을까?
중복없이 저장한다면 N * B가 되겠다. 각 block마다 copy본을 둔다면 N * B / 2가 되겠다.

reliability

두번째는 reliability 즉 신뢰성이다. RAID가 몇개의 disk failure 까지 허용할 수 있을까에 대해 이야기한다.

performance

마지막은 performance이다. Performance는 측정하기 조금 힘들 수 있는데 RAID에 요청되는 작업의 종류에 의존하는 경우가 많기 때문이다.
RAID performance를 측정할때는 크게 2가지 측면을 고려할 것인데 첫번째는 single-request latency이다. RAID에서 single I/O 요청에 대해 어떤 latency를 가지는지를 측명하면 얼마나 해당 RAID가 병렬성을 가지고 있는지 그대로 이해할 수 있다.
두번째는 steady-state throughput 이다. 어떤 규칙적인 요청이 왔을때 그때의 처리량을 의미한다. 예를들어 여러개의 요청이 동시에 왔을때 그때의 RAID 전체의 bandwidth를 측정할 수 있겠다.
이들을 조금 더 자세히 측정하기 위해 각 요청에 대한 내용들이 두가지 종류가 있다고 가정한다. sequentialrandom이다.
sequential workload는 요청들이 큰 단위의 연속된 block을 읽는 요청들로 이루어져 있다고 가정한다. 이런 sequential workload는 큰 파일을 읽어 특정 키워드를 찾고싶을때처럼 자주 오는 요청들이다.
random workload는 각 요청은 매우 작은 크기의 block을 읽는 요청들이고 각 요청들은 서로 다른 disk location을 대상으로 한다. 예를들어 처음 4KB에 대해 logical address 10에 접근하고 그다음은 logical address 55,000 그 다음은 logical address 4,500 에 접근하는 방식이다. 이런 random workload는 database에서 빈번하게 일어난다.

위의 sequential, random workload는 disk의 특성으로 인해 각각 다른 performance 특징을 가진다.
sequential access에서는 disk는 가장 효율적인 방식으로 동작한다. seek time과 rotational delay에 매우 적은 시간을 사용하므로 대부분의 시간을 data transfer에 활용할 수 있다.
다만 random access의 경우는 대부분의 시간을 seek time과 rotational delay에 사용하므로 상대적으로 data transfer에는 작은 시간을 할애할 수밖에 없다.
그래서 이런 차이점을 더 명확히 확인하기 위해 sequential workload 에서는 S MB/s 로 data transfer가 가능하다고 하고, random workload 에서는 R MB/s 속도로 data transfer가 가능하다고 하자. 보통은 S가 R보다 훨씬 크다. 이 측정치를 밑에서 RAID level 별로 계산해보며 성능을 비교해볼 것이다.

밑에서는 몇가지 RAID 종류들을 보게될 것이다. RAID Level 0(striping), RAID Level 1(leveling), RAID Levels 4, 5(parity-based redundancy)이다.


RAID Level 0: Striping

RAID 0은 striping으로 더 잘 알려져 있다. 이 방식은 capacity와 performance 측면에서 높은 결과를 낸다.
striping은 말그대로 줄무늬 방식이라고 이해해도 좋다. striping 방식에서는 각 block들을 여러 disk에 걸쳐 줄무늬처럼 배열한다. 다음 그림을 보자.

RAID 0: Striping

여기서는 4개의 disk를 사용했다. striping의 기본 아이디어는 block의 array를 라운드로빈 방식으로 disk에 하나씩 할당한다. 이 방식은 large sequential read 요청에 대해 병렬로 처리할 수 있도록 설계한 방식이다.
위의 예에서는 1개의 block(4KB) 기준으로 라운드로빈으로 disk에 할당하였는데 다음과 같이 할당할수도 있다.

RAID 0: Bigger chunk size

여기서는 다음 disk로 넘어가기 전에 2개의 block(8KB)를 할당하고 다음 disk로 넘어갔다. 이 단위를 chunk size라고 한다. 여기서는 8KB의 chunk size를 사용하였다.

Chunk Size

chunk size는 performance에 영향을 많이 끼친다. 작은 chunk size를 사용한다면 많은 파일들이 여러 disk에 striped 되어 저장될 것이다. 그러므로 파일 read write 시에 병렬성을 증가시킬 수 있을것이다.
다만 block에 접근하기 위한 positioning time(seek time + rotational delay)가 증가한다. 왜냐하면 여러 disk에 병렬로 읽거나 쓰게될때 결국 완료시간은 가장 오랜시간이 걸린 positioning time에 의해 결정되기 때문이다.

chunk size를 크게잡으면 어떨까?
작은 크기의 파일에 대해선은 read, write에 병렬성은 떨어질 것이다. 그러나 positioning time이 줄어들 것이다. 만약 작은 크기의 파일의 크기가 chunk size보다 작아 single disk에 저장된다면 그 single disk 에서의 positioning time이 결정한다.

그러므로 최적의 chunk size를 찾기 위해서는 어떤 요청들을 위주로 처리할지에 대한 지식이 먼저 있으면 결정하기 좋다.
대부분은 큰 chunk size(64KB)를 사용하고는 한다.

RAID 0 Evaluation

RAID 0 에서 capacity, reliability, performance를 측정해보자.
먼저 capacity는 간단하다. B개의 block들로 이루어진 N개의 disk가 있다면 N * B의 block을 저장할 수 있다.
reliability도 간단한데 striping 방식에서는 reliability가 좋지 않다. 하나의 disk가 fail이 나더라도 바로 data loss로 이어진다.

performance는 위에서 본 sequential workload의 S와 random workload의 R을 구해보며 비교해보자.
sequential transfer size는 평균적으로 10MB, random transfer size는 평균적으로 10KB라고 가정하자. 이들을 전송하는데 속도가 얼마인지 계산해보자.
Disk의 spec은 다음과 같다.

  • Average seek time: 7ms
  • Average rotational delay: 3ms
  • Transfer rate of disk: 50MB/s

S(Amound of Data) / (Time to access) 이므로 10MB / (7ms + 3ms + 200ms) = 47.62 MB/s 와 같다. Time to access는 seek time + rotational delay와 10MB를 전송하는데 200ms가 걸리므로 이를 합치면 계산할 수 있다.
R10KB / 10.195ms = 0.981 MB/s이다. 10KB를 전송하는데 0.195ms가 걸린다.
SR은 disk 1개에서 고려한 속도이다.

이처럼 striping의 performace를 보게되면 single-block request에 대해서는 single disk 성능과 동일하다.
하지만 sequential, random workload 관점에서 볼때 sequential workload는 하나의 disk가 S의 속도를 낼때 N개의 disk가 있다면 전체 처리량은 S * N이 되겠다.
randon workload 에서의 전체 처리량은 R * N이 된다.


RAID Level 1: Mirroring

RAID Level 1은 mirroring으로 잘 알려져있다. Mirrored System에서는 각 block에 대해 copy본을 같이 저장함으로서 disk failure를 극복할 수 있다. 전형적인 mirroring 방식은 다음과 같다.

RAID 1: Mirroring

위의 방식에서는 RAID는 물리적으로 두개의 물리적 copy를 저장한다. disk 0은 disk 1과 동일한 내용을 들고있고 disk 2는 disk 3과 동일한 내용을 들고있다. 데이터들은 이 mirror pair들에 걸쳐 striped 된다.
disk들에 copy본들을 어디에 위치시킬지는 여러가지 방법이 있는데 위에서 본 방식은 가장 일반적인 방식으로 이런 방식을 RAID-10이라고 부르기도 한다. stripe of mirror로 RAID1+0 라는 의미이다.

disk read를 할때는 복제본 disk 두개중 어디에서 읽어도 상관없다. 다만 write은 두개의 disk에 모두 써주어야 한다. 이 2번의 write은 병렬로 처리할 수 있다.
또 위의 경우 복제본을 2개 저장했는데 이를 mirroring level이라고도 한다. 여기서는 mirroring level이 2이다.

RAID 1 Evaluation

RAID 1 에서 capacity를 먼저보자. capacity 관점에서 RAID 1은 비용이 비싸다. mirroring level 2 에서는 전체 용량의 절반만 저장할 수 있으므로 capacity는 N * B / 2가 되겠다.

reliability의 관점에서는 좋다. 아무 disk 1개의 failure를 허용할 수 있다. 사실 정확히는 최대 N/2개의 disk failure 까지 허용가능하다. 위에서 본 예제에서도 만약 운이 좋게도 disk 0과 disk 2가 동시에 fail 했다고 하더라도 data loss가 발생하지 않는다. 운이 좋게 각 copy 본을 저장하는 disk가 중복없이 fail이 발생했기 때문이다.

performance 관점을 살펴보자. single read request는 single disk와 성능이 동일하다.
다만 single write request는 살짝 더 latency가 늘어날 수 있는데, 각 copy 본을 병렬로 write한다고 하더라도 완료되는 시점은 두개의 disk중 더 오래걸린 시간이기 때문이다.

steady-state throughput을 살펴보자. sequential write에서는 각 logical write은 두개의 physical write으로 나누어진다. 그러므로 mirroring 방식에서 전체 bandwidth는 (N / 2) * S가 된다. 최대 bandwidth의 절반밖에 안된다.
sequential read도 똑같은데 얼핏 생각하면 각 logical read를 모든 disk에 나누어 처리하면 상황이 더 나아질 것 같지만 disk의 물리적 특성을 생각하면 그렇지 않다. 예를들어 위의 그림에서 block 0 부터 7까지 읽는다고 했을때 block 0은 disk 0에서, block 1은 disk 2에서, block 2는 disk 1에서 block 3은 disk 3에서 읽는다고 해보자. disk 0에서는 block 0을 읽고 그다음 block 4를 읽으면 될 것 같지만 어차피 중간에 block 2가 존재하기 때문에 rotation 하면서 이를 지나가야한다. 그러므로 성능향상이 없다. 그러므로 sequential read 에서의 전체 bandwidth도 N / 2) * S와 같다.

Random read는 mirroring 방식에서 best case이다. read를 전체 disk에 분산시킬 수 있으므로 N * R MB/s의 대역폭을 가진다.
Random write은 sequential write과 동일하게 두개의 physical write로 나누어지므로 (N / 2) * R MB/s 이다.


RAID Level 5: Rotating Parity

RAID Level 5에서는 parity 정보를 활용한다.
Parity bit은 오류가 생겼는지 검사하는 bit 인데 2가지 종류가 존재한다. 짝수 parity와 홀수 parity이다.
개념은 간단한데 짝수 parity에서는 데이터의 각 bit의 값에서 parity bit를 포함한 1의 개수가 짝수가 되도록 하는 것이다. 홀수 parity는 홀수가 되도록 하는것이다. 예를들어 다음과 같다.

짝수 parity라면 C0, C1, C2, C3의 bit에서 1의 개수가 2개이므로 parity bit을 0으로 둔다. 그래야 짝수인 2개로 유지되기 때문이다. parity bit을 활용하면 C0, C1, C2, C3 중 한개가 유실되어도 그 값을 bit 계산으로 알아낼 수 있다.

다시 RAID로 돌아와서 RAID Level 5 방식을 그림으로 보자.

RAID 5: Rotating Parity

여기서는 parity bit을 disk 별로 돌아가며 설정한다. 즉 위의 block 0, 1, 2, 3의 각 block의 bit를 계산해서 그에 대한 parity bit을 disk 4에 저장한다.

RAID 5 Evalutation

capacity 부터 확인해보자. stripe 당 1개의 parity block을 두기때문에 (N - 1) * B의 용량을 가지게 된다.
reliability 는 1개의 disk failure를 허용한다. parity bit를 활용해 recovery 가능하다. 다만 2개이상의 disk failure가 나면 복구할 방법이 없다.
그렇다면 performance를 계산해보자.
single read request는 1개의 disk로 매핑되기 때문에 single disk와 성능이 동일하다.
다만 single write request는 다른데 위 예제에서 block 0에 write을 하려면 block 0을 읽고 parity block도 읽어서 parity bit를 다시 계산해야한다. 즉 read 2번, write 2번이 필요하고 read, write 각각 병렬로 처리할 수 있으므로 single disk의 latency의 약 2배정도 걸린다.

sequential read는 parity block으로 인해 (N - 1) * S MB/s의 대역폭을 가진다. sequential write도 parity bit을 같이 써주어야 하므로 (N - 1) * S MB/s가 되겠다.
random read는 모든 disk를 활용할 수 있다. 다만 random write은 (N / 4) * R MB/s을 가진다.
왜냐하면 만약 random write가 위 예제에서 block 1과 block 10에 대해 요청이 왔다면 parity bit 계산을 위해 disk 1과 disk 4를 읽어 block 1을 처리하고, disk 0과 disk 2를 읽어 block 10을 처리한다. 각 요청안에서 block data write과 parity block write은 병렬로 처리할 수 있므으로 총 4개의 I/O 가 필요하다.(disk 1의 read/write + disk 0의 read/write)

RAID Level 별로의 evaluation 결과는 다음과 같다.

RAID Evaluation


Summary

reliability는 고려하지 않고 performance가 중요한 상황이라면 RAID 0 striping이 좋은 선택이 될 수 있다.
반대로 reliability가 중요하고 random I/O 성능이 중요하다면 RAID 1 mirroring이 좋은 선택이다. 다만 비용이 비싸다.
capacity와 reliability가 중요하다면 RAID 5 가 좋은 선택이다. 다만 small-write의 성능이 좋지않은면이 있다.
만약 sequential I/O가 주된 접근이고 capacity를 최대화 해야하는 상황이라면 이 경우에도 RAID 5가 좋은 선택이다.

여기서 살펴본 RAID 디자인 말고도 다른 여러가지 디자인이 존재한다. 예를들어 RAID 6은 multiple disk failure를 허용한다.
RAID는 hardware 자체로 구현되어 제공되기도 하고 software로도 구현되어 제공될 수 있다.


References





Maven(메이븐) 이란?

이 글은 박재성님이 쓰신 자바세상의 빌드를 이끄는 메이븐이라는 책과 메이븐 공식문서를 보고 정리한 글입니다.
책이 있으신분은 메이븐 개념을 한번 머릿속에 정리하고 싶을때 읽으시면 매우 좋습니다. 책에 스토리로 이끌어가는 부분이 있어 매우 재밌게 읽을 수 있습니다.
다만, 책이 절판되어 책을 구하고싶으신 분들은 아마 알라딘이나 다른 곳에서 중고서적으로 구매를 하셔야 합니다.

Maven

메이븐은 자바기반 프로젝트를 빌드하고 관리하기 위한 툴이다.
요즘은 Gradle이 많이 쓰이지만 아직 maven을 사용하고 있는 프로젝트도 많다.
메이븐은 빌드 프로세스를 최대한 쉽게 하는것을 목표로 하고 이 뿐만 아니라 프로젝트에 질높은 정보를 제공하고, 단일 빌드시스템을 제공하는 것을 목표로 한다.

다른 build tool 없이 자바프로젝트를 개발하게 되면 의존성관리 등 신경써야할게 한두가지가 아니다. 메이븐이 이를 도와준다.
메이븐의 장점은 다음과 같다.

  • 편리한 의존관계 관리를 지원한다.
  • 모든 프로젝트가 일관된 프로젝트 디렉토리 구조, 빌드 프로세스를 유지할 수 있다.
  • 다양한 메이븐의 플러그인을 활용할 수 있다.
  • 프로젝트의 template을 만들수있다.

메이븐은 저장소를 지원해서 메이븐만 설치하면 프로젝트 build에 필요한 라이브러리, plugin을 저장소에서 우리의 PC로 자동으로 다운로드한다. 다운로드한 라이브러리들은 특정 디렉터리에 위치하게 되는데 이를 localRepository(로컬저장소)라고 부른다. 기본적으로는 ~/.m2/repository 에 위치하고 settings.xml로 설정을 변경할 수도 있다.
또 메이븐은 처음 생성하는 프로젝트 종류에 따라 기반이 되는 template을 제공한다. 이를 이용해서 메이븐 기반 프로젝트를 생성할 수 있는데, 그러면 프로젝트의 기본적인 뼈대를 자동으로 생성할 수 있다. 메이븐의 이 같은 기능을 archetype이라고 한다.

메이븐 공식문서의 getting started에서 처음 메이븐 프로젝트를 만들게 될때도 archetype을 사용한다.
다음 명령어로 메이븐 프로젝트를 생성해보자.

1
mvn -B archetype:generate -DgroupId=com.mycompany.app -DartifactId=my-app -DarchetypeArtifactId=maven-archetype-quickstart -DarchetypeVersion=1.4

이를 실행하면 my-app 프로젝트의 디렉터리가 만들어지고 그 안에 pom.xml 파일이 생성된다.
메이븐은 source code와 test code를 분리해서 관리하는데 source code는 src/main/java 에 위치하고, test code는 src/test/java 에 위치한다.
여기서 사용한 groupId, artifactId는 뒷부분에서 다루겠다.

메이븐 기본명령어

메이븐 명령어는 다음과 같은 형태를 가진다.
mvn [options] [goal] [phase] 위의 명령어에서도 사용했던 -D 옵션들은 메이븐 설정파일(pom.xml)에 인자를 전달한다.
예를 들어 단위테스트를 실행하지 않으려면 mvn -Dmaven.test.skip=true [<phase>]와 같이 실행할 수 있다. 메이븐에는 phase와 goal 개념이 있는데 이들을 이용하며 빌드를 실행할 수 있고, 빌드를 실행할 때 여러개의 phase와 goal을 실행할 수 있다. 예를들어 다음과 같이 다양한 형태로 실행이 가능하다.
mvn clean test: clean phase와 test phase를 실행한다.
mvn clean compiler:compile: clean phase와 compiler plugin의 compile goal을 실행한다.
phase와 goal은 밑에서 자세히 다루겠다.

Pom.xml

위의 메이븐 archetype:generate 명령어로 메이븐 프로젝트를 생성했으면 pom.xml 파일이 생성된다. POM은 Project Object Model을 의미한다. 그러면 pom.xml의 각 element 들을 살펴본다.

먼저 생성된 pom.xml은 다음과 같다.

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
<?xml version="1.0" encoding="UTF-8"?>

<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>

<groupId>com.mycompany.app</groupId>
<artifactId>my-app</artifactId>
<version>1.0-SNAPSHOT</version>
<packaging>jar</packaging>

<name>maven-app</name>
<!-- FIXME change it to the project's website -->
<url>http://www.example.com</url>

<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<maven.compiler.source>1.7</maven.compiler.source>
<maven.compiler.target>1.7</maven.compiler.target>
</properties>

<dependencies>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>4.11</version>
<scope>test</scope>
</dependency>
</dependencies>

.. 이하생략 ..

  • project: pom.xml의 최상위 element
  • modelVersion: POM version. 최근버전이 4.0.0이다.
  • artifactId: project를 식별하는 id를 의미한다. groupId 안에서 여러개의 project가 있을 수 있다.
  • groupId: project를 생성하는 조직의 고유 id를 결정한다. 도메인 이름을 많이 사용하는데 꼭 그럴필요는 없다.
    groupId + artifactId는 값이 유일해야한다. 그렇지 않으면 중앙저장소에서 충돌한다.
  • packaging: 어떤 방식으로 패키징할지 결정한다. jar, war 등을 설정가능하다.
  • version: project의 현재 버전을 의미한다. 프로젝트 개발중에는 SNAPSHOT을 suffix로 사용가능하다.
    SNAPSHOT은 maven의 예약어이며 SNAPSHOT을 사용하면 라이브러리를 다른방식으로 관리한다.
  • name: project 이름이다.
  • url: project url이 있다면 이를 기입한다.
  • dependencies: 프로젝트와 의존관계에 있는 라이브러리들을 관리한다.

각 프로젝트의 pom.xml은 기본적으로 최상위 POM이라고 불리는 설정을 상속한다. 그래서 pom.xml의 설정내용이 단순하더라도 메이븐의 기본 규약들을 전부다 따르는 것이 가능하다. 실제 정의된 설정들을 보려면 다음 명령어를 사용하면 된다.

mvn help:effective-pom

설정되어있는 repository 정보를 담고있는 repositories 태그, plugin 설정정보를 담는 태그등 기존 pom.xml에 보이지 않았던 태그들을 볼 수 있다. 이 내용들이 기본적으로 최상위 POM에 존재했기 때문에 우리가 만든 project의 pom.xml은 단순하게 가져갈 수 있다.

Lifecycle

메이븐은 모든 빌드 단위가 이미 정의되어 있으며 이는 개발자가 임의로 변경할 수가 없다. 여기서 말하는 빌드 단위란 compile, test, package, deploy 등을 말한다.
메이븐은 이와같이 미리 정의되어 있는 빌드 순서를 lifecycle이라고 하며 메이븐은 3개의 lifecycle을 제공한다.

  1. compile, test, package, deploy를 담당하는 기본 lifecycle
  2. 빌드 결과물 제거를 위한 clean lifecycle
  3. project document site를 생성하는 site lifecycle이다.

메이븐은 기본적으로 빌드후의 모든 산출물을 target 디렉터리에서 관리한다.

target 디렉터리에 생성되는 하위디렉터리는 다음과 같다.

  • target/classes: src/main/java의 소스코드가 컴파일된 class 파일들과 src/main/resources 디렉터리의 자원이 복사된다.
  • target/test-classes: src/test/java의 소스코드가 컴파일된 class 파일들과 src/test/resources 디렉터리의 자원이 복사된다.
  • target/surefire-reports: report 문서들이 위치한다.

기본 Lifecycle

기본 lifecycle을 활용해 source code를 compile, test 등을 할 수 있는데 각 phase들을 살펴보면 다음과 같다.

  • process-resources: src/main/resources의 모든 자원을 target/classes 로 복사한다.
  • compile: src/main/java에 있는 source code를 compile한다.
  • process-test-resources: src/test/resources의 모든 자원을 target/test-classes 로 복사한다.
  • test-compile: src/test/java에 있는 source code를 compile한다.
  • test: Junit 같은 unit test framework로 test를 진행하고 test가 실패하면 빌드실패로 간주한다.
    결과물을 target/surefire-reports 디렉터리에 생성한다.
  • package: pom.xml의 packaging 값에 따라 압축한다.(jar, war)
  • install: local repository에 압축한 파일을 배포한다.
  • deploy: 원격저장소에 압축한 파일을 배포한다.

이처럼 maven 기본 lifecycle은 여러개의 phase로 구성되어 있으며, 각 phase는 의존관계를 가진다.
process-resources ← compile ← process-test-resources ← test-compile ← test ← package
이 순으로 의존관계를 가지고 있어서 package phase를 실행(mvn package)하면 의존관계에 있는 test phase가 먼저 실행되고, test phase는 compile phase에 의존관계가 있기때문에 compile phase가 먼저 실행된다.
따라서 package phase를 실행하면 process-resources → compile → process-test-resources → test-compile → test → package 순으로 빌드가 진행된다.

process-resources phase는 src/main/resources 에 있는 모든 자원을 test/classes 디렉터리로 복사하는데, 만약 다른 디렉터리에도 자원이 존재한다면 pom.xml에 따로 설정할 수 있다.

package phase는 jar나 war형태로 압축하여 target 디렉터리에 위치시킨다.
<build>/<finalName>에 값이 설정되어 있으면 {finalName}.{packaging} 형태로 압축파일이 생기고,
값이 설정 안되어있다면 {artifactId}-{version}.{packaging} 형태로 된다.

clean phase는 빌드한 결과물들을 제거하는 phase인데 이는 다른 phase와 관련이 없다.
clean phase를 실행하지 않고 다른 phase를 실행할 때 불필요한 산출물들 때문에 오류가 날 수 있으므로 clean을 실행하고 빌드하는 습관을 가지면 좋다.

Clean Lifecycle

clean lifecycle은 빌드를 통해 나온 산출물을 모두 삭제한다.
target directory를 삭제하는 것과 동일하다.

Site Lifecycle

site lifecycle은 사용안하는 경우가 많은데 핵심만 짚고 넘어가자면,
site, site-deploy phase를 사용해 실행가능하다. site lifecycle은 메이븐에 설정되어 있는 기본설정, 플러그인 설정에 따라 target/site directory에 문서 사이트를 생성한다. site-deploy는 이를 배포한다.

Plugin

메이븐에서 제공하는 모든 기능은 plugin을 기반으로 동작한다.
메이븐 phase 또한 메이븐 plugin을 통하여 실질적인 작업이 실행된다. 따라서 phase가 실행되는 과정을 이해하려면 maven plugin을 먼저 이해해야 한다.

사용하고자 하는 maven plugin이 있다면 pom.xml에 다음과 같이 설정한다.

1
2
3
4
5
6
7
8
9
10
11
<project>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>2.1</version>
</plugin>
</plugins>
</build>
</project>

이와 같이 사용하고자 하는 plugin의 groupId, artifactId, version을 명시하면 된다.
version을 생략하면 가장 최신버전의 plugin이 설치된다.
메이븐 plugin은 하나의 plugin에서 여러 작업을 실행할 수 있도록 지원하는데 여기서 각각 실행할 수 있는 작업을 goal이라고 정의한다.
위의 compiler plugin은 하나지만 이 플러그인이 지원하는 goal은 compile(source directory의 compile), testCompile(test directory의 compile) 등이 있다.

plugin은 다음과 같이 실행할 수 있다.
mvn groupId:artifactId:version:goal

예를들어 앞의 compiler plugin의 compile goal은 다음과 같이 실행한다.
mvn org.apache.maven.plugins:maven-compiler-plugin:2.1:compile

만약 settings.xml에 pluginGroup이 설정이 되어있다면 groupId를 생략이 가능하고,
version을 생략하면 local repository에 있는 가장 최신 버전의 플러그인을 사용하며,
plugin 이름이 maven-$name-plugin 이나 $name-maven-plugin 형식을 따른다면 $name 값만 명시할 수 있다.
앞에서 실행했던 compile 플러그인을 다음과 같이 실행할 수 있다.
mvn compiler:compile

앞부분에서 실행했던 mvn archetype:generate 명령도 mvn org.apache.maven.plugins:maven-archetype-plugin:generate를 축약한 것이다.

메이븐은 매우 많은 플러그인들을 활용할 수 있는게 큰 장점이다. 다양한 플러그인을 제공하고 있어서 원하는 개발환경을 얼마든지 만들어 나갈 수 있다.

Phase와 Goal

메이븐에서 phase는 build lifecycle에서 각 단계와 순서를 정의하는 개념으로 실제로 빌드작업을 하지는 않는다.
실제 빌드작업은 해당 phase와 연결되어 있는 plugin의 goal에서 진행한다.
mvn compile은 compile phase를 실행한 것인데 이는 compile phase와 연결되어 있는 compiler plugin의 compile goal이 실행되면서 컴파일 작업을 진행한다.
기본 lifecycle에서 phase를 실행할 때 기본으로 연결된 plugin의 goal을 실행하는 구조이다.
기본 lifecycle에서 phase에 연결되어 있는 plugin을 실행할 때에는 자동으로 메이븐 중앙저장소에서 plugin을 다운로드 한다.

phase와 goal과의 관계를 보여주는 그림이다.

phase와 goal의 관계

각 핵심 phase 별로 구체적인 내용을 알아보자.

mvn compile

compile phase를 실행하면 먼저 의존관계에 있는 process-resources phase가 먼저 실행된다. process-resources phase는 src/main/resources 디렉터리에 있는 모든 자원을 target/classes 디렉터리로 복사한다.
만약 src/main/java 안에서도 소스와 같은 패키지로 관리하는 리소스들이 있고 이들또한 target/classes에 복사되기를 원한다면 다음과 같은 설정을 하면된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
<project>
<resources>
<resource>
<directory>src/main/resources</directory>
</resource>
<resource>
<directory>src/main/java</directory>
<excludes>
<exclude>**/*.java</exclude>
</excludes>
</resource>
</resources>
</project>

그러면 compile phase 실행시 src/main/java에 있는 *.java 파일을 제외한 모든 설정파일을 target/classes 로 복사한다.
resource plugin과 compiler plugin 에 대한 자세한 정보는 다음 공식문서에서 확인할 수 있다.
resources plugin : resources-plugin
compiler plugin : compiler-plugin

mvn test

test phase를 실행하면 process-test-resources phase가 먼저 실행되면서 src/test/resources 디렉터리의 자원복사를 먼저 진행한다.
그리고 test-compile phase에서 src/test/java 디렉터리의 test code들을 컴파일한다.
test phase는 target/test-classes 에 컴파일한 단위 테스트 클래스를 실행하고 그 결과물을 target/surefire-reports 디렉터리에 생성한다.
기본적으로 test phase는 target/test-classes 에 있는 모든 단위 테스트 클래스를 실행하는데 특정 테스트 suite 별로 실행할 필요가 있다면 test option을 사용할 수 있다.

mvn -Dtest=MyUnitTest test

이와 같이 특정 테스트 클래스만 실행할 수 있고 여러개의 test 클래스 들을 실행하고 싶다면 쉼표로 여러개를 정의하면된다.

mvn package

package phase는 compile, test-compile, test, package 순으로 실행된 후 jar, war 파일이 target 디렉터리 하위에 생성된다.
<build>/<fileName> 에 값이 설정되어 있고 jar로 패키징을 하게되면 {finalName}.jar 형태로 jar 파일이 생성된다. 만약 finalName element가 설정되어 있지 않다면 {artifactId}-{version}.{packaging} 이 압축파일 그리고 디렉터리 이름이 된다.
예를들어, finalName element가 설정되어있지 않고 pom.xml 설정이 다음과 같다면

1
2
3
4
5
6
<project>
<groupId>io.github.tk-one</groupId>
<artifactId>myapp</artifactId>
<packaging>war</packaging>
<version>1.0-SNAPSHOT</version>
</project>

파일은 다음 위치에 생성된다.
target/myapp-1.0-SNAPSHOT/myapp-1.0-SNAPSHOT.war

mvn install

install phase는 package phase와 의존관계에 있기때문에 package phase를 먼저 실행한다. package phase에서 jar or war 파일로 압축을 완료하면 이를 local repository에 배포한다.

mvn deploy

deploy phase는 jar or war 파일을 원격저장소에 등록한다.


라이브러리 의존관계

메이븐은 의존관계에 있는 라이브러리를 관리하기 위해 의존 라이브러리 관리기능을 제공한다. 이는 메이븐의 lifecycle과 더불어 메이븐의 핵심기능이기 때문에 반드시 이해하는게 좋다.

메이븐 저장소는 로컬저장소와 원격저장소로 나뉜다.

로컬저장소

로컬저장소는 개발자 PC에 있는 저장소로 메이븐을 빌드할때 다운로드하는 라이브러리나 플러그인을 관리 및 저장한다.
로컬저장소는 기본값으로는 ~/.m2/repository 에 위치한다.

원격저장소

원격저장소는 외부에 위치하는 저장소로 사내에서 사용하는 저장소도 있고 중앙저장소라고 불리는 오픈소스 라이브러리나, 메이븐 플러그인 등을 저장하고 있는 저장소도 있다. 중앙저장소는 원격저장소 중 하나라고 생각하면 된다.

메이븐은 빌드를 할때 로컬저장소에 이미 다운로드한 라이브러리가 있으면 원격저장소에서 다운로드 하지않고 로컬저장소에 있는 라이브러리르 사용한다. 메이븐이 다운로드 하고자 하는 저장소는 repositories 태그로 설정할 수 있다.
기본적으로 우리가 repositories 태그로 설정을 안하고 다운로드할 수 있는 이유는 최상위 POM에 이미 정의가 되어있기 때문이다.

1
2
3
4
5
6
7
8
9
<project>
<repositories>
<repository>
<id>central</id>
<name>Central Repository</name>
<url>https://repo.maven.apache.org/maven2</url>
</repository>
</repositories>
</project>

여러개의 repository들을 추가할 수 있는데 그러면 메이븐은 라이브러리를 다운로드할때 repositories 태그에 있는 저장소 순서대로 다운로드를 시도한다.

위에서 생성한 myapp에서는 다음과 같이 dependencies 태그로 라이브러리를 관리한다.

1
2
3
4
5
6
7
8
<dependencies>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>4.11</version>
<scope>test</scope>
</dependency>
</dependencies>

이와 같이 설정하고 빌드하면 메이븐은 먼저 로컬저장소에 해당 라이브러리가 있는지 확인한다.
없다면 메이븐은 중앙저장소에서 junit 4.11 버전이 있는지 확인하고 있다면 jar 파일을 로컬저장소에 다운로드한다.
중앙저장소에 해당 라이브러리와 버전이 존재하는지 확인할 때에는 위에 repository 설정에 적힌 url을 바라보고
https://repo.maven.apache.org/maven2/junit/junit/4.11/junit-4.11.jar 파일이 있는지를 파악한다.
이와 같이 설정하고 빌드하면 메이븐은 중앙저장소에서 junit 4.11 버전의 jar 파일을 로컬저장소에 다운로드한다.
로컬저장소에 다운로드 받는 위치는 기본적으로 다음과 같다.
~/.m2/repository/junit/junit/4.11/junit-4.11.jar

그리고 메이븐은 로컬저장소에 다운로드한 라이브러리를 활용해 src/main/java 그리고 src/test/java 에 있는 source code들을 컴파일한다.

version을 LATEST 혹은 RELEASE로 설정할 수도 있는데 그러면 항상 가장 최신버전의 라이브러리와 의존관계를 갖게된다.
또, 한번 로컬저장소에 다운로드한 라이브러리는 다시 원격저장소에서 다운로드하지 않는데 이부분에서 애플리케이션이 개발단계에 있어 코드가 지속적으로 변경되는 상황이라면 SNAPSHOT을 활용하자.
version 정보에 SNAPSHOT을 포함하게되면 빌드할 때마다 가장 최근에 배포한 라이브러리가 있는지 확인하고 로컬저장소에 있는것보다 최신일경우 이를 다운로드한다.

scope

메이븐에서는 사용하는 라이브러리의 성격에 따라 scope를 지정할 수 있다.
JUnit 라이브러리의 경우 실제 배포할때는 필요없고 테스트를 진행할때만 필요하다. 이런경우 scope를 test로 주면된다.

scope는 6가지 종류가 있다.

  • compile: default scope이다. compile 및 deploy시 같이 제공해야하는 라이브러리이다.
  • provided: compile 시점엔 필요하지만 deploy에 포함할 필요는 없는경우 사용한다.
  • runtime: compile에는 필요없지만 runtime에는 필요한 경우 사용한다.
  • test: test 시점에만 사용하는 라이브러리에 설정한다.
  • system: provided scope와 비슷한데 로컬저장소에서 관리되는 jar파일이 아닌 우리가 직접 jar 파일을 제공해야한다.
  • import: 다른 pom.xml 에 정의되어있는 의존관계설정을 가져온다.

Dependency Mechanism

Dependency Mechanism은 메이븐의 핵심중 하나이다.
그러므로 메이븐이 어떻게 의존성을 관리하는지는 꼭 이해하고 넘어가는게 좋다.

Dependency Transitive(의존성 전이)

프로젝트에서 의존하는 라이브러리들의 숫자는 제한이 없지만 의존성 cycle이 있으면 문제가 발생한다.
프로젝트에 외부 라이브러리를 하나씩 추가할때마다 그 라이브러리가 또 의존하고있는 라이브러리를 또 추가해야 하므로 의존관계에 있는 라이브러리 숫자가 증가한다.
예를들어, project A가 B, C에 의존하고있다면 B가 의존하고있는 D, E, F 라이브러리가 또 필요할 것이고 또 D 가 의존하는 G 라이브러리도 필요할 것이다. 연쇄작용으로 의존하는 프로젝트는 점점 커진다.
참고로 메이븐은 의존성이 있는 라이브러리가 또 어떤 라이브러리에 의존성을 가지고있는지 알기위해 jar 파일을 다운로드 하는 동시에 해당 라이브러리의 pom파일도 같이 다운로드 한다.
메이븐은 위처럼 프로젝트 라이브러리 숫자가 급격히 증가하는 문제점을 해결하기 위해 라이브러리 제한이 가능하도록 의존성 전이 설정을 지원한다.

  • Dependency mediation: 같은 의존성의 여러버전을 마주치게 되었을때 artifact의 어떤 버전을 사용할지 결정한다.
    메이븐은 이때 더 가까운 의존관계에 있는 버전의 의존관계를 선택한다.
    예를들어 다음과 같은 의존성이 있다고 가정한다.

    1
    2
    3
    4
    5
    6
      A
    ├── B
    │ └── C
    │ └── D 2.0
    └── E
    └── D 1.0

    이 예에서는 A를 build할때 D 1.0이 사용된다. 왜냐하면 A -> B -> C -> D 2.0 보다 A -> E -> D 1.0 이 더 가깝기 때문이다.
    여기서 서로 depth 가 같은 상황이라면 먼저 명시된 라이브러리의 버전이 사용된다.
    만약 project A의 pom.xml에 직접 version을 적어주면 그 버전을 사용한다.

    1
    2
    3
    4
    5
    6
    7
    8
      A
    ├── B
    │ └── C
    │ └── D 2.0
    ├── E
    │ └── D 1.0

    └── D 2.0

    이처럼 A에 직접 D 2.0 의 의존성을 추가하면 D 2.0 을 사용한다.

  • Dependency management: 메이븐의 <dependencyManagement> element로 의존관계에 있는 artifact의 버전을 직접 명시랄 수 있다.

  • Dependency scope: 현재 빌드상태에 맞는 라이브러리만 의존관계를 포함한다.
    즉, test scope를 가지는 경우 최종 배포산출물을 빌드하는 시점에는 포함되지 않는다.
  • Excluded dependencies: 만약 A -> B -> C와 같이 의존성이 있으면 project A에서 명시적으로 project C에 대한 의존성을 <exclusion>태그를 사용해 명시적으로 제외시킬 수 있다.
  • Optional dependencies: 만약 A -> B -> C와 같이 의존성이 있고 project B에 C가 optional로 설정이 되어있으면 project A를 빌드할때 project C에 대한 의존관계를 가지지 않는다.

메이븐의 Dependency Transitive가 의존관계를 최대한 잘 설정해 주겠지만 pom.xml에 항상 라이브러리의 명확한 version을 명시하는게 좋다.
현재 프로젝트에서 의존하고있는 라이브러리의 tree를 보고싶다면 다음 plugin의 goal을 사용하면 좋다.
mvn dependency:tree

Property

pom.xml에서 발생하는 중복설정은 속성(property)를 정의하여 개선할 수 있다.
보통 공통된 버전관리에 많이 사용하고는 하는데 예제를 보면 바로 이해할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
<project>
<properties>
<spring.version>3.0.1.RELEASE</spring.version>
</properties>

<dependencies>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-core</artifactId>
<version>${spring.version}></version>
</dependency>
</dependencies>
</project>

속성은 <properties> element에서 <property.name>value</property.name> 형태로 정의한다.
그리고 이렇게 정의한 내용은 pom.xml 파일내에서 ${property.name} 으로 접근할 수 있다.


인터넷 메일시스템 (SMTP)


이 포스트에서는 Application Layer의 SMTP (Simple Mail Transfer Protocol) 에 대해 알아본다.
먼저, SMTP를 보기전에 인터넷 전자메일 시스템이 어떤식으로 동작하는지 알고있어야 한다.

Mail System

인터넷 메일 시스템은 크게 user agent, 메일서버, SMTP 이 3가지 요소로 구성되어 있다.
아래의 그림을 보면서 이해하면 쉽다.

mail_system

  • user agent
    MS의 Outlook을 생각하면 쉽다. user agent는 사용자가 메일을 읽고, 작성하고, 전송할 수 있도록 해준다.

  • 메일서버
    사용자가 메일 작성을 끝내면 user agent는 메시지를 메일서버로 보내게되고, 여기서 메시지는 메일서버의 output 메시지큐에 들어가게 된다.
    여기서의 메일서버는 송신자의 메일서버를 의미한다.
    송신자의 메일서버에서 수신자의 메일서버로 메시지가 전송되면, 메일들은 수신자의 메일서버안의 메일박스(mailbox)안에 저장되고 유지된다.
    만약, 수신자의 메일서버가 다운된 상황에서 송신자가 메일을 보내면 어떻게 될까?
    송신자가 메일을 전송하면, 먼저 메일이 송신자의 메일서버에 도착한다. 그리고 송신자의 메일서버는 메일을 수신자의 메일서버로 전송할 수 없을때, 메시지 큐(message queue)에 보관하고 있다가, 주기적으로 메일전송을 시도한다.

  • SMTP
    SMTP는 Application layer에서 작동하는 메일전송 프로토콜이다. 위의 메일서버 설명에서, 한 메일서버에서 다른 메일서버로 메시지(메일)을 전송할때 사용하는 프로토콜이 SMTP이다.
    이뿐만 아니라, 송신자의 user agent에서 본인의 메일서버로 메일을 전송할때도 SMTP가 사용된다.
    SMTP는 TCP위에서 작동한다. 참고로 SMTP는 HTTP보다 훨씬 더 오래전부터 사용되었다.

이 설명을 기반으로 메일을 전송하는 간단한 시나리오를 보자.
A가 B에게 메일을 전송하는 상황이다.

  1. A가 user agent를 통해 B에게 메일 내용을 작성하고 전송버튼을 누른다.
  2. A의 user agent는 메시지를 A의 메일서버에 보내게 되고, 메시지는 메일서버의 output message queue에 위치한다.
  3. A의 메일서버에서 동작하는 SMTP 클라이언트는 output message queue에 쌓여있는 메시지를 B의 메일서버로 전송하기 위해 먼저 TCP연결을 맺는다.
  4. TCP가 맺어진 후, SMTP 핸드쉐이킹을 하고 SMTP 프로토콜에 따라 B의 메일서버로 전송한다.
  5. B의 메일서버는 메시지를 수신한 후, 그 메시지를 B의 메일박스(mailbox)에 놓는다.
  6. B는 이후에 user agent를 실행하여 메일을 읽을 수 있다.

인터넷 메일 시스템은 대충 이런식으로 작동한다.
(2번에서 A의 user agent가 메시지를 A의 메일서버로 보낸다고 되어있는데, 사실 여기서도 SMTP 프로토콜을 통해 전달된다.)
그러면 이제 SMTP를 좀 더 자세히 보도록 한다.

SMTP (Simple Mail Transfer Protocol)

대부분의 Application layer protocol 처럼 SMTP는 송신자의 메일서버에서 수행하는 클라이언트와, 수신자의 메일서버에서 수행되는 서버를 가지고 있다. 메일서버가 상대 메일서버로 전송할때는 SMTP의 클라이언트로 동작하는 것이고, 메일서버가 상대 메일서버로 부터 메일을 받을때는 SMTP 서버로 동작하는 것이다. HTTP를 떠올리면 쉽다.

메일서버에서 상대 메일서버로 메일을 보내는 상황에서, 먼저 클라이언트 SMTP는 서버 SMTP의 25번 포트로 TCP연결을 맺는다. 만약 서버가 죽어있으면 클라이언트는 나중에 다시 시도한다.
TCP 연결이 맺어지면, 클라이언트와 서버는 SMTP 핸드쉐이킹을 수행한다. 이 SMTP 핸드쉐이킹 과정에서 클라이언트는 송신자와 수신자의 email 주소를 제공한다.

핸드쉐이킹 과정을 마치면, 클라이언트는 메시지를 보낸다.

SMTP 클라이언트와 SMTP 서버 사이의 메시지 전달과정을 예를들어 살펴보자.
클라이언트 호스트네임은 github.io 이고, 서버 호스트네임은 korea.ac.kr 이라고 하자.
C는 클라, S는 서버를 나타내고 TCP 연결 직후의 상황을 가정한다.
메일 내용은 “Hello, this is TK-one. Can I know the result of the interview?” 이다.

S: 220 korea.ac.kr C: HELO github.io S: 250 Hello github.io, pleased to meet you C: MAIL FROM: tk-one@github.io S: 250 tk-one@github.io … Sender ok C: RCPT TO: kim@korea.ac.kr S: 250 kim@korea.ac.kr … Recipient ok C: DATA S: 354 Enter mail, end with “.” on a line by itself C: Hello, this is TK-one. (메일내용) C: Can I know the result of the interview? (메일내용) C: . S: 250 Message accepted for delivery C: QUIT S: 221 korea.ac.kr closing connection

클라이언트는 5개의 명령(HELO, MAIL FROM, RCPT TO, DATA, QUIT)을 내리며 하나의 점(.)으로 된 라인을 송신하면 이는 메시지의 끝을 의미한다.
서버는 각 명령에 대해 답하며, 각 응답은 응답코드와 옵션 설명을 갖고 있다.
그리고 주의할 점이 있는데, SMTP는 메시지의 body와 header를 포함하여 전부 7bit ASCII 코드로 작성되어야 한다.
HTTP는 이런 제한이 없는 반면, SMTP는 한글이나 binary 데이터 처럼 ASCII가 아닌 문자를 포함한다면 반드시 이 메시지는 전송되기 전에 7bit ASCII로 인코딩이 되어야한다.

이렇게 SMTP를 통해 한 메일서버에서 다른 메일서버로 메시지가 전달된다.
그렇다면 수신자는 자신의 PC에서 user agent를 통해 자신의 메일서버에 있는 메시지들을 어떻게 얻을 수 있을까?
수신자의 user agent는 메일을 가져오기위해 SMTP를 사용할 수는 없다. 왜냐하면 SMTP는 푸시(push)용 프로토콜인 반면, 메시지를 가져오는 것은 풀(pull) 동작이기 때문이다.
여기서는 메일서버로부터 자신의 user agent로 메시지를 가져오기 위해 특별한 메일 접속 프로토콜을 사용한다. 이들중엔 POP3(Post Office Protocol - Version 3), IMAP(Internet Mail Access Protocol), HTTP 등이 있다.


참고: Computer Networking - A Top-Down approach

시스템 버스(System Bus)란?

System Bus는 간단하게 digital data를 이동시키기 위한 통로이다.

Bus에는 3가지 종류가 있다. Control Bus, Address Bus, Data Bus 이다. 이 3가지가 System Bus를 구성한다.

System Bus

시스템버스는 internal bus로서 프로세서와 내부 internal 하드웨어 장치들과 연결하도록 고안된 버스이다. 시스템버스는 메인보드에 존재한다.

Bus의 종류를 하나씩 보자.

Control Bus

Control Bus는 CPU가 다른 internal device들과 통신하는데 사용된다. Control Bus는 이름부터 알 수 있듯이 CPU의 command를 전달하고 device의 status signal을 반환한다. Control Bus 안에는 line 이라는 것이 있는데, control bus마다 line의 개수와 종류가 각각 다르지만 공통으로 가지고 있는 line이 있다.

  • READ line: device가 CPU에게 읽히면 active된다.
  • WRITE line: device가 CPU에게 쓰여지고 있으면 active된다.

메모리에 읽고 쓸때에는 Control Bus의 Read, Write line이 활성화된다.
Control Bus는 양방향으로 작동한다.

Address Bus

Address Bus는 memory에 읽고 쓸때 memory의 physical address를 전달하는데 사용한다. Memory에 read, write를 할때 address bus에는 memory location이 담긴다. read, write 할 메모리 값 그 자체는 밑에서 볼 data bus에 실린다.
Address Bus는 Memory Address Register와 연결되어 있으며, 단방향이다.
Address bus는 중요한게 bus width(폭)가 시스템이 다룰 수 있는 address를 결정한다. 예를들어, 32bit address bus라면 시스템은 최대 232가지의 주소공간을 다룰 수 있겠다. Byte-addressable이면 가능한 memory space는 4GB가 되겠다.
다만 모든 경우에 address bus의 width가 꼭 시스템이 사용하는 address와 동일하게 매칭되는 것은 아니다.
CPU 칩에서 pin수는 엄청난 비용이다. 따라서 예전 시스템들에서는 16 bit address bus를 사용하지만 32 bit address space를 사용할 수 있다. 다만 이 경우 16bit로 주소를 나누어 두번에 걸쳐 전송해야한다.

Data Bus

Data Bus는 데이터를 전송하는데 사용하는 버스이다. Data Bus는 당연히 읽고 쓸수 있어야 하므로 양방향이다.

32bit, 64bit CPU

32bit 운영체제, 64bit 운영체제를 결정하는 것은 무엇일까? 이는 data bus가 결정하지 않는다. 이를 결정하는것은 프로세서의 정수 register 크기이다. Data bus의 폭은 정수 register와 다를수도 있다. 예전 컴퓨터 머신들의 초기설계에는 data bus의 폭과 정수 register의 크기는 같았으나 꼭 그럴 필요는 없다.

실제로 8080 IBM PC는 16bit CPU이나 data bus는 8bit width이다. 그러므로 프로세서의 register에서 RAM으로 전송하려면 8bit씩 두번을 전송해야 했다.

그리고 프로세서의 정수 register와 address bus의 폭도 다를 수 있다. address bus의 폭은 이보다 더 클수도 작을 수도 있다.

실제로 original AMD Operaton은 64bit 시스템이지만 address bus는 memory subsystem을 단순화하기위해 40bit로 설계되었다. 64bit가 주소로 전부 활용되기는 현실적으로 힘들기 때문이다.
지금 현재의 대부분의 64bit AMD CPU도 48bit의 address bus를 가지고있다.

Modern designs

현재의 디자인들은 우리가 잘 알고있는 bit 기반의 paradigm과는 맞지않는 더 복잡한 bus들을 사용한다. Modern CPU들은 매우 복잡하게 설계되어 있으며 이들은 memory 및 다른 CPU들과의 통신을 위해 특수한 bus들을 사용하기도 한다. 더이상의 단일로 존재하는 address bus나 data bus는 없으며 우리가 알고있는 bit-width 기반으로 처리하는 방식이 아닌 다른 signaling 방식으로 작동할 수도 있다.

References





운영체제 9편 - 메모리

이 글은 학부 운영체제 수업을 듣고 정리한 글입니다.
맥락없이 운영체제에 대한 내용들이 불쑥 등장하니 양해부탁드립니다.

이번 편은 메모리에 대한 내용입니다.

자세히 보기

운영체제 8편 - 동기화의 고전적인 문제들

이 글은 학부 운영체제 수업을 듣고 정리한 글입니다.
맥락없이 운영체제에 대한 내용들이 불쑥 등장하니 양해부탁드립니다.

이번 편은 동기화(synchronization)에 이어 동기화의 고전적인 문제들에 대한 내용입니다.

동기화에 대한 고전적인 대표적인 문제들이 몇가지 있는데 이중 2가지를 알아볼 것이다.

  • Bounded-buffer problem
  • Readers and Writers problem
자세히 보기

운영체제 7편 - 동기화(Synchronization)

이 글은 학부 운영체제 수업을 듣고 정리한 글입니다.
맥락없이 운영체제에 대한 내용들이 불쑥 등장하니 양해부탁드립니다.

이번 편은 동기화(synchronization)에 대한 내용입니다.

자세히 보기

운영체제 5편 - CPU 스케줄링

이 글은 학부 운영체제 수업을 듣고 정리한 글입니다.
맥락없이 운영체제에 대한 내용들이 불쑥 등장하니 양해부탁드립니다.

이번 편은 CPU 스케줄링에 대한 내용입니다.

자세히 보기

Memory False Sharing 이란?

Memory False Sharing이란 무엇일까? 말 그대로 직역하면 메모리 거짓 공유이다. 이게 무엇을 뜻하는지 알아보자.

Cache Coherence

먼저 Cache Coherence를 알아야 한다. 멀티코어 환경에서 코어마다 cache가 각각 존재한다. 흔히 말하는 캐시의 개념으로 자주 사용하는 데이터들을 메모리보다 더 빠른 캐시에 저장함으로서 메모리에서 읽지 않고 바로 캐시에서 가져옴으로 성능적으로 큰 이득을 볼 수 있다. 이런 멀티코어 환경에서 각 코어들에 있는 cache들의 일관성을 유지하는 것을 Cache Coherence라고 말한다.
만약에 Core 1에서 메모리 주소 X에 있는 값을 읽기 위해 먼저 메모리에서 읽고 이를 Core 1 캐시에 저장하였다. 다음으로 Core 2에서 메모리 주소 X에 있는 값을 읽기 위해 메모리에서 이를 읽고 이를 Core 2의 캐시에 저장하였다. 만약 Core 1에서 add 연산으로 해당 변수를 원래 값인 1에서 5로 증가시켰다고 해보자. 그러면 Core 1의 캐시는 5로 업데이트 된다. 여기서 Core 2가 이 변수를 읽으면 무슨 값이 반환되어야 할까? 1일까 5일까?
Cache Coherence는 캐시에서 공유하고 있는 데이터의 값의 변경사항이 적시에 시스템 전체에 전파될 수 있도록 하는 원칙이다.
Cache Coherence는 다음 2가지가 필요하다.

  • Write Propagation(쓰기 전파)
    어떠한 캐시에 데이터가 변경이 되면 이 cache line을 공유하고 있는 다른 캐시에도 이 변경사항이 전파되어야 한다.
  • Transaction Serialization
    특정 메모리 주소로의 read/write은 모든 프로세서에게 같은 순서로 보여야 한다.

두번째의 Transaction Serialization은 다음 예를 보면 이해하기 쉽다.
Core 1,2,3,4 가 있을때 이들 모두 초기값이 0인 변수 S의 캐시된 복사본을 각 캐시에 가지고있다. 프로세서 P1은 이 S의 값을 10으로 변경한다. 그리고 프로세서 P2가 이어서 이 S의 값을 20으로 변경한다. 위의 Write Propagation를 보장한다면 P3와 P4가 이 변경사항을 볼 수 있다. 다만 프로세서 P3는 P2의 변경사항을 본 후, P1의 변경사항을 봐서 변수 S의 값으로 10을 반환받는다. 그리고 프로세서 P4는 원래의 순서에 따라 P1의 변경사항을 보고, P2의 변경사항을 그 다음으로 봐서 20을 반환받는다. 결국 프로세서 P3, P4는 캐시의 일관성을 보장할 수 없는 상태가 되었는데 이처럼 Write Propagation 하나만으로는 Cache Coherence가 보장이 안된다.
이를 위해 변수 S에 대한 Write는 반드시 순서가 지정이 되어야한다. Transaction Serialization이 보장이 된다면 S는 위의 예제에서 10으로 write하고 그리고 20으로 write 했기 때문에, 절대 변수 S에 대해 값 20으로 읽고 그다음 값 10으로 읽을 수가 없다. 반드시 값 10으로 읽고 그 다음 20으로 읽는다.

이처럼 Cache Coherence를 유지하기 위해서는 다른 프로세서에서 갱신한 캐시 값을 곧바로 반영을 하든 지연을 하든 해서 다른 프로세서에서 사용할 수 있도록 해주어야 한다. 캐시 일관성을 유지하기 위한 다양한 프로토콜들이 존재하며 대표적으로 MESI 프로토콜이 있다.

Cache Coherence에 대한 내용은 여기까지만 보도록 하고 cache line 이라는 것을 알아보자.

Cache Line

메인 메모리의 내용을 읽고 캐시에 이를 저장하는 과정에서 메모리를 읽을때에 이를 읽어들이는 최소 단위를 Cache Line이라고 한다. 메모리 I/O의 효율성을 위해서이며 spatial locality(공간 지역성)을 위해서이다. 보통의 cache line은 64byte 혹은 128byte로 이루어져 있으며 위에서 설명한 Cache Coherence도 cache line의 단위로 작동한다. 이렇게 cache line으로 읽어들인 데이터들로 캐시의 data block을 구성하게 된다.
또 cache line은 고정된 주소단위(보통은 64byte)로 접근하고 가져온다. 예를들면 다음과 같다.

1
2
3
4
5
6
7
8
9
10
11
+---------+-------+-------+
| address | x1000 | int a |
+---------+-------+-------+
| address | x1004 | int b |
+---------+-------+-------+
| address | x1008 | int c |
+---------+-------+-------+
| address | x100B | int d |
+---------+-------+-------+
| ....... | ..... | ..... |
+---------+-------+-------+

위와 같은 메모리 구조가 있다고 할때 변수 a를 읽을때는 주소 x1000부터 cache line의 크기인 64byte만큼 가져오고, 변수 c를 읽을때에는 주소 x1008부터 64byte를 읽는게 아니다. 고정된 주소단위로 변수 c를 읽을때에도, write를 할때에도 주소 x1000으로 읽는다는 의미이다.

이제 cache line을 알았으니 다시 Memory False Sharing으로 돌아가자.

Memory False Sharing

Memory False Sharing은 동일한 cache line을 공유할때 Cache Coherence로 인해 성능이 느려지는 안티패턴을 의미한다. 위에서 본 예제로 다시 이해해 보자.

1
2
3
4
5
6
7
8
9
10
11
+---------+-------+-------+
| address | x1000 | int a |
+---------+-------+-------+
| address | x1004 | int b |
+---------+-------+-------+
| address | x1008 | int c |
+---------+-------+-------+
| address | x100B | int d |
+---------+-------+-------+
| ....... | ..... | ..... |
+---------+-------+-------+

메모리 구조가 위와 같을때 스레드 2개가 있고 스레드 1은 int 변수 a를 1씩 계속 더하는 일을 하고, 스레드 2는 int 변수 c를 1씩 계속 더하는 일을 한다고 해보자.

  • Thread 1: while (true) { a++ }
  • Thread 2: while (true) { c++ }

더하기를 시작하기 전 이미 해당 cache line이 캐시에 올라와있다면 CPU 캐시의 상태는 다음과 같을 것이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
Core 1 Cache
+-------------+---------+----------------------+
| mem address | invalid | data block (64 byte) |
+-------------+---------+----------------------+
| ..... | ..... | .................... |
+-------------+---------+----------------------+
| x1000 | false | a | b | c | d | .... |
+-------------+---------+----------------------+
| ..... | ..... | .................... |
+-------------+---------+----------------------+

Core 2 Cache
+-------------+---------+----------------------+
| mem address | invalid | data block (64 byte) |
+-------------+---------+----------------------+
| ..... | ..... | .................... |
+-------------+---------+----------------------+
| x1000 | false | a | b | c | d | .... |
+-------------+---------+----------------------+
| ..... | ..... | .................... |
+-------------+---------+----------------------+

상황을 쉽게 이해하기 위해 두 스레드는 서로 다른 프로세서에서 실행되지만 시간상으로 볼 때 서로 사이좋게 번갈아 가며 add를 한다고 해보자.

  1. Thread 1: a++
  2. Thread 2: c++
  3. Thread 1: a++
  4. Thread 2: c++ 이런 순서대로 실행이 된다고 하자. 먼저 1번의 a++가 발생했을때는 Core 1의 cache에서 a에 해당하는 부분이 1을 증가시킨 값으로 write가 일어나게 된다. 하지만 여기서 문제가 발생한다. 바로 다음 2번이 실행되기를 원하지만 그 사이에는 많은 일이 발생한다. 1번을 실행하였을때 Core 1 Cache의 data block 값이 변하였고, Cache Coherence protocol에 의하여 2가 실행되기 전에 하드웨어 병목이 생긴다. MESI protocol에 의해 Core 2의 해당 cache line의 상태가 invalid 상태로 바뀌고 Core 2가 다시 데이터를 읽으려면 해당 cache line이 invalid 이기 때문에 Core 1에서 읽거나 해야한다.
    즉 cache line단위로 관리되기 때문에 Thread 2는 변수 a와는 전혀 상관이 없는 작업임에도 불구하고 변수 a에 대한 변경때문에 성능저하가 급격하게 나타나게 된다.
    두 변수 a와 c가 서로는 전혀 상관이 없는 데이터임에도 불구하고 같은 cache line에 있기때문에 CPU는 특정 변수가 변경될때마다 캐시 일관성을 맞추기 위해 작업을 하게된다. 이는 성능하락으로 이어진다.

어떻게 해결할 수 있을까?

어떻게하면 이를 해결할 수 있을까?
일종의 cache line size에 맞추어 padding을 넣어 서로 다른 cache line에 속하게할 수 있다. 예는 다음과 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
Core 1 Cache
+-------------+---------+----------------------+
| mem address | invalid | data block (64 byte) |
+-------------+---------+----------------------+
| ..... | ..... | .................... |
+-------------+---------+----------------------+
| x1000 | false | a | padding |
+-------------+---------+----------------------+
| ..... | ..... | .................... |
+-------------+---------+----------------------+

Core 2 Cache
+-------------+---------+----------------------+
| mem address | invalid | data block (64 byte) |
+-------------+---------+----------------------+
| ..... | ..... | .................... |
+-------------+---------+----------------------+
| x1040 | false | c | padding |
+-------------+---------+----------------------+
| ..... | ..... | .................... |
+-------------+---------+----------------------+

이처럼 변수뒤에 padding을 붙여줌으로서 서로 다른 cache line에 속하게 하면 위 같은 False Sharing 문제를 해결할 수 있다.
C++에서는 alignas 함수를 사용하여 padding을 넣어줄 수 있다.

1
2
alignas(64) int a = 0;
alignas(64) int c = 0;

자바도 이와 비슷한 방법으로 자바 8부터 @jdk.internal.vm.annotation.Contended 라는 어노테이션을 지원한다.
먼저 클래스 내부필드에 어노테이션을 적용하는 방법을 알아보자. 클래스 내부 필드에 이를 적용하게 되면 해당 필드는 앞뒤로 empty bytes로 패딩을 추가함으로서 object 안의 다른 필드들과 다른 cache line을 사용하도록 해준다.

1
2
3
4
5
public class Counter1 {
@jdk.internal.vm.annotation.Contended
public volatile long count1 = 0;
public volatile long count2 = 0;
}

@Contended에는 group tag라는 것도 지원하는데 이 group tag는 필드단위에 적용되었을때에만 작동한다. Group은 서로 다른 모든 그룹과 독립된 cache line을 가지게 된다.

1
2
3
4
5
6
7
8
9
10
public class Counter1 {
@jdk.internal.vm.annotation.Contended("group1")
public volatile long count1 = 0;

@jdk.internal.vm.annotation.Contended("group1");
public volatile long count2 = 0;

@jdk.internal.vm.annotation.Contended("group2");
public volatile long count3 = 0;
}

위의 예처럼 group tag를 지정해주면, count1 변수와 count2 변수는 같은 그룹으로 지정이 되어있고 count3는 다른 그룹으로 지정되어있다.
이런 경우 count1과 count2는 count3과는 다른 cache line을 가지게 되며 count1과 count2는 그룹이 같으므로 같은 cache line으로 될 수 있다.

Contended 어노테이션은 클래스에도 적용할 수 있는데, 클래스에 적용하게되면 모든 field들이 같은 group tag를 가지는 것과 동일하다. 하지만 JVM 구현체에 따라서 다른 isolation 방법을 사용할 수 있다. 전체 object를 isolate 기준으로 할수도 있고 각 field 들을 isolate 기준으로 할수도 있다. (HotSpot JVM 기준으로는 class에 Contended 어노테이션이 적용되어있다면 모든 field 앞에 padding을 적용하는 것 같다. implementation in HotSpot JVM)

1
2
3
4
5
@jdk.internal.vm.annotation.Contended
public class Counter1 {
public volatile long count1 = 0;
public volatile long count2 = 0;
}

Contended 어노테이션은 이 용도에 맞게 각 object들이 서로 다른 스레드에서 접근하는 상황일때 사용하면 성능향상을 가져올 수 있을것이다.
실제 Contended 어노테이션은 ConcurrentHashMap 구현이나 ForkJoinPool.WorkQueue 등에서 사용하고 있다.

Reference





자바 ForkJoin Framework(포크조인)

이번 글은 자바 7에 도입된 Fork/Join Framework에 대한 내용입니다.

Fork/Join Framework

자바 7에는 Fork/Join Framework가 도입되었는데 이는 ExecutorService의 구현체로서 이를 활용하면 작업들을 멀티코어를 사용하도록 작업할 수 있습니다. 기본적으로 Fork/Join은 하나의 병렬화할 수 있는 작업을 재귀적으로 여러개의 작은 작업들로 분할하고 각 subtask들의 결과를 합쳐서 전체 결과를 반환합니다.
Fork/Join은 divide-and-conquer 알고리즘과 굉장히 비슷하다. 다만 Fork/Join Framework는 한가지 중요한 개념이 있는데 이상적으로는 worker thread가 노는경우가 없다. 왜냐하면 Fork/Join Framework에서는 work stealing이라는 기법을 사용해서 바쁜 worker thread로 부터 작업을 steal, 즉 작업을 훔쳐온다.
먼저 ForkJoin의 thread pool에 있는 모든 thread를 공정하게 분할한다. 각각의 스레드는 자신에게 할당된 task를 포함하는 double linked list를 참조하면서 작업이 끝날때마다 queue의 헤드에서 다른 task를 가져와서 처리한다. 다만 아무리 공정하게 태스크들을 분할한다고 해도 특정 한 스레드는 다른 스레드보다 자신에게 할당된 태스크들을 더 빠르게 처리 할 수 있는데, 이렇게 자신에게 주어진 태스크들을 다 처리해서 할일이 없어진 스레드는 다른 스레드의 queue의 tail에서 작업을 훔쳐(steal)온다. 모든 태스크가 다 끝날때까지 이 과정을 반복하여 스레드간의 작업부하를 균등하게 맞출 수 있다.

ForkJoinPool

java.util.concurrent.ForkJoinPool은 위에서 설명한 work stealing 방식으로 동작하는 ExecutorService의 구현체이다. 우리는 ForkJoinPool의 생성자로 작업에 사용할 processor number를 넘겨줌으로서 병렬화 레벨을 정할 수 있다. 기본값은 Runtime.getRunTime().availableProcessors() 결과로 결정된다. 또 다른 특징으로는 ExecutorService들의 구현체와는 다르게 ForkJoinPool은 모든 워커 스레드가 데몬스레드로 명시적으로 program을 exit할 때 shutdown을 호출할 필요가 없다. ForkJoinPool의 내부에서 worker thread를 등록하는 과정에서 daemon 스레드로 설정한다.

ForkJoinTask

java.util.concurrent.ForkJoinTask는 ForkJoinPool에서 실행되는 task의 abstract class이다. ForkJoinTask<V>Future<V>를 구현한다. ForkJoinTask는 일종의 light 한 스레드라고 생각하면 쉽다. 여러개의 task 들이 생성되면 이들은 ForkJoinPool의 설정된 스레드들에 의해 실행되게 된다.
RecursiveActionRecursiveTask<R>가 ForkJoinTask의 서브클래스들인데 이들 또한 abstract class들이다. 그래서 이들을 구현한 서브클래스를 만들어서 사용한다. RecursiveActionRecursiveTask<R>의 차이점은 RecursiveAction은 태스크가 생성하는 결과가 없을때 사용하고 결과가 있을때에는 RecursiveTask<R>을 사용한다. 두 클래스 모두 abstract method인 compute() 를 구현해야한다.

ForkJoinTask는 현재 실행상태를 확인하기 위한 몇가지 메서드를 제공한다.
isDone()은 태스크가 완료되었는지의 여부를 반환한다. isCompletedNormally()는 태스크가 cancellation이나 exception 없이 완료되었는지의 여부를 반환하고 이외에도 isCancelled(), isCompletedAbnormally() 등의 메서드를 제공한다.

RecursiveTask 활용

스레드 풀을 이용하기위해 RecursiveTask<R>의 서브클래스를 만들어보자. parameter type R은 결과 형식을 의미한다. 우리는 RecursiveTask의 compute 메서드를 구현해야 한다.
protected abstract R compute();
compute 메서드는 태스크를 서브태스크로 분할하는 로직과 더이상 분할할 수 없을때 개별 서브태스크의 결과를 생산할 알고리즘을 정의한다. 따라서 대부분의 compute 메서드의 구현은 다음과 같다.

1
2
3
4
5
6
7
8
if (태스크가 충분히 작거나 분할할 수 없으면) {
태스크 계산
} else {
태스크를 두 서브태스크로 분할한다.
태스크가 다시 서브태스크로 분할되도록 이 메서드를 재귀적으로 호출한다.
모든 서브태스크의 연산이 완료될때까지 기다린다.
각 서브태스크의 결과를 합친다.
}

그렇다면 1부터 N까지의 합을 구하는 프로그램을 Fork/Join Framework를 사용하여 작성해보자. 코드는 다음과 같다.

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
43
44
import java.util.concurrent.RecursiveTask;

public class ForkJoinSumCalculator extends RecursiveTask<Long> {
private final long[] numbers;
private final int start;
private final int end;
private static final int THRESHOLD = 10_000;

public ForkJoinSumCalculator(long[] numbers) {
this(numbers, 0, numbers.length);
}

private ForkJoinSumCalculator(long[] numbers, int start, int end) {
this.numbers = numbers;
this.start = start;
this.end = end;
}

@Override
protected Long compute() {
int size = end - start;
if (size <= THRESHOLD) {
return computeSequentially();
}

ForkJoinSumCalculator leftTask = new ForkJoinSumCalculator(
numbers, start, start + size / 2);
leftTask.fork();
ForkJoinSumCalculator rightTask = new ForkJoinSumCalculator(
numbers, start + size / 2, end);
long rightResult = rightTask.compute();
long leftResult = leftTask.join();
return leftResult + rightResult;
}

private long computeSequentially() {
long sum = 0;
for (int i = start; i < end; i++) {
sum += numbers[i];
}

return sum;
}
}

그리고 다음과 같이 ForkJoinPool의 invoke 메서드를 사용해 실행시켜보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import java.util.concurrent.ForkJoinPool;
import java.util.concurrent.ForkJoinTask;
import java.util.stream.LongStream;

public class Main {
private static final long N = 30_000_000L;

public static void main(String args[]) {
long[] numbers = LongStream.rangeClosed(1, N).toArray();
ForkJoinTask<Long> task = new ForkJoinSumCalculator(numbers);
long sum = new ForkJoinPool().invoke(task);
System.out.println(sum);
}
}

Fork/Join을 사용할 때 왼쪽 작업과 오른쪽 작업에 모두 fork를 호출하는게 자연스러운것 처럼 보이지만 한쪽에는 fork를 호출하는 것 보다 compute를 호출하는게 더 효율적이다. 한 태스크에는 이 Fork/Join 스레드를 실행시킨 스레드를 재사용할 수 있으므로 불필요한 태스크를 다른 스레드에 할당하는 오버헤드를 피할 수 있다.
또 멀티코어에 Fork/Join을 사용하는게 무조건 순차처리보다 빠르지 않다. 각 서브태스크의 실행시간이 새로운 태스크를 forking하는데 드는 시간보다 충분히 길수록 좋다.

위의 예제에서는 덧셈을 수행할 숫자가 만개 이하면 분할을 더이상 하지 않고 계산했다. 그러면 현재는 태스크가 3천개가 생성되는데 어차피 코어의 수는 정해져있으므로 코어가 3개라면 각 코어마다 1천만개씩 덧셈을 수행하면 딱 알맞게 효율적으로 동작하지 않을까?
그렇지는 않다. 실제로는 코어 개수와 관계없이 적절하게 작은 크기로 분할된 많은 태스크를 forking 하는것이 바람직하다. 1천만개씩 덧셈을 수행하도록 한다고 해도 각 3개의 코어에서 이루어지는 작업이 동시에 끝나지는 않는다. 각 태스크에서 예상치못하게 지연이 생길 수 있어 작업완료시간이 크게 달라질 수 있다. 다만 Fork/Join Framework는 work-stealing 기법으로 idel한 스레드는 다른 스레드의 workQueue로 부터 작업을 훔쳐오기 때문에 모든 스레들에게 작업을 거의 공정하게 분할할 수 있다. 그러므로 태스크의 크기를 작게 나누어야 스레드 간의 작업부하 수준을 비슷하게 맞출 수 있다.

References