Hash Table은 내부적으로 배열을 사용하여 조회,삽입,삭제 모두 O(1)안에 수행하기 위한 특별한 자료구조다. 배열의 인덱스를 유일하게(혹은 그에 가깝게) 지정하기 위해서 데이터와 연관된 고유한 숫자를 만들어낸 후 그것을 인덱스로 사용한다.

또 일반적으로 순서를 보장하지 않기 때문에, 순서, 관계가 있는 목적에는 적합하지 않다.

Hash funciton

데이터에 연관된 고유한 값을 만들기 위해서 해시 함수를 사용한다. 이 해시 함수를 통해서 나온 결과값을 해시 값(혹은 해쉬 코드,해쉬)라고 하고 이것을 이용해 데이터에 대한 접근 연산을 수행한다.

가장 많이 쓰이는 해시 함수는 나머지 연산(modulo)를 이용한다. 키 k 를 어떤 정해진 수 D 로 나눈 나머지를 k 를 저장하는 버킷의 인덱스로 사용하는 것이다.
h(k)=k

일반적으로 D 는 적절히 큰 소수(prime number)를 사용하는데 이유는 다음과 같다.

만약 D를 소수가 아닌 값이라 하면, D의 모든 약수는 자신의 배수가 곧 키값이 된다. 해시충돌이 많이 일어나는것이다.

만약 이 해시 함수가 엄밀하지 못해서 여러개의 객체가 서로 같은 값을 가지게 된다면 이것을 해시 충돌(hash collision)이라고 한다.

일반적인 경우에서 가능한 키들의 집합을 U라고 하고, 버킷들의 개수를 m이라고 할때 U>>m인 경우가 대부분이므로 충돌은 필연적으로 발생한다. 이것을 해결하기 위해서 버킷의 사이즈를 단순히 키우는것은 좋은 해결책이 아니다. 메모리 사용량에서 치명적이다.

좋은 해시 함수를 고안해도, 여전히 해시 충돌은 불가피하다. 해시충돌이 늘어나게되면 O(1)의 시간복잡도 장점을 잃어버리고 O(n)에 가깝게 되니, 적절한 해결책을 세워야 한다.

Open Addressing

개방주소법(Open Addressing)은 간단히 말해서 해시충돌이 발생하면(계산된 인덱스로 접근한 버킷이 이미 사용중이면) 단순히 다른 인덱스에 데이터를 저장하는 방식이다.

개방주소법 안에서도 여러개로 나뉜다.

  • Linear Probing
    • 계산된 해시값에서 해시충돌이 발생한다면, 고정폭만큼 건너뛰어 비어있는 해시에 저장하는 방법이다. 만약 그 자리에도 차있다면, 그 다음 고정폭의 위치를 탐색한다.
    • 이 방법은 단순해서 계산을 하기 쉽지만, 최악의 경우 탐색을 시작한 위치까지 돌아오게 되어 종료할 수 있다. O(n)이 걸리는 것이다.
    • 또 primary clustering이라는 특정 해쉬 값 슬롯 근처에 값들이 뭉치게 되는 문제도 생길 수 있다. x 라는 해쉬 값을 공유하는 객체들이 x+1,x+2,x+3 등으로 모이기 때문이다.
    • 클러스터의 크기가 커질수록, 비슷한 해쉬값들이 적절히 배치되지 못하고 다음을 probing하니 클러스터가 더 빠르게 자라나고*, 이는 성능적으로 이슈를 불러일으킨다.
    • 다만 값들이 클러스터링 되어있기 때문에 cache hit 적인 측면에서는 유리하다. 처음 키에 대해서 접근을 하면 다음 키도 캐쉬에 올라와 있기 때문이다.
  • Quadratic Probing
    • Linear Probing과 비슷하게 , 해시충돌이 발생한다면 다음 슬롯을 찾는다. 다른 점은 idx=(hash(key)+i^2) mod m 의 꼴을 취하는 것이다.
    • 이 방법도 primary clustering보다는 덜 하지만 성능에 영향을 주는 secondary clustering 문제를 일으킨다.
    • 초기 hash 포지션이 아닌 좀 더 광범위하게 퍼져있는 것이다.
  • Double hashing
    • 이름 그대로 해시 충돌이 생기면, 2차 해시함수를 이용해서 다시 해시를 하는 방법.
    • 값이 퍼지게 되어서 캐쉬의 측면에서는 비효율적이고 연산량이 많이 들지만, 클러스터링에는 큰 영향을 받지 않는다.

장점과 단점

이처럼 개방주소법 내에서도 여러가지 충돌 처리방식이 있다. 일반적으로 개방주소법은 적은 양의 데이터에는 효과를 보이고 메모리 효율도 분리연결법에 비해 상대적으로 좋고, 메모리 할당에 대한 오버헤드도 없는 편이다.

또 일반적으로 연결리스트를 사용하는 분리연결법에 비하여 캐쉬 효율이 좋기 때문에 (특히 Linear Probing) Python에서 hashtable을 구현할때 사용된다.

하지만 데이터의 삭제에서 좋지 않은 퍼포먼스를 보인다.
예를 들어 A,B,C 가 연속적으로 있을때(linear probing) A 를 삭제한다고 해보자. 그럼 NULL,B,C 라고 변경될텐데, 이때 C 에 대해서 조회를 한다면, NULL 을 만나게 된다. 이것을 원래부터 비어있는 공간인지 혹은 삭제되어서 비어있는 공간인지 알 수 없기 때문에 C 를 조회하지 못하고 탐색이 종료된다.
이를 극복하기 위해서 삭제된 공간은 삭제되었음을 표시해주는 DEL 같은 표기자를 사용해 다음 index를 조회할수 있게끔 해야한다.
물론 이러한 DEL 표시자가 늘어난다면, 조회할 데이터가 없어도 계속적인 탐색을 수행해줘야 하니 표시자의 개수에 따라 해시테이블 전체에 대해서 rehashing을 해줘야 한다.

load factor를 l이라고 하였을때 삽입과 조회, 삭제 모두 O(\frac{1}{1-l})의 성능을 보여준다.

Seperate Chaining

분리연결법(Separate Chaining)은 일반적인 상황에서 개방주소법보다는 빠른데, 개방주소법의 경우 load factor가 커질수록 최악의 경우( O(n))의 발생 빈도가 높아지기 때문이다.

분리연결법은 해시충돌이 잘 발생하지 않게끔 하기 위해서 보조 해시 함수를 이용해 최악의 경우로 가는 상황을 줄이려고 한다.

분리연결법에도 두가지 방법이 존재한다.

  • Linked List
    • 각각의 버킷들을 연결리스트로 두어 충돌이 발생하면 해당 버킷의 리스트에 추가하는 방식.
    • 단, 연결리스트의 단점을 그대로 가지고 있다. 메모리 주소 상에서 연속적이지 않기 때문에 캐시의 효율이 나쁘고, 아주 적은 데이터가 있을때의 메모리 오버헤드가 있다.(개방주소법과 비교해서)
    • 또 Traverse를 할 때 최악의 경우에는 O(n)의 시간복잡도를 보인다.
  • Tree
    • 연결리스트의 단점을 개선하기 위해 나온 것으로 연결리스트가 아닌 Tree 구조를 이용해 데이터를 저장한다.
    • 단, Tree에서도 데이터가 편향되게 들어오면 O(n)의 시간복잡도를 가질 수 있으니 Red-black Tree와 같은 Balanced Binary Tree를 사용함으로써 O(logn)의 연산을 보장시킨다.
    • 하지만 적은 데이터 수에서 RB Tree를 유지하는데 드는 메모리 사용량이 연결리스트보다 크니, 적은 수의 데이터보다는 어느정도 데이터가 들어왔을때 연결리스트에서 트리로 전환한다.
    • Java 8에서부터는 데이터가 8개가 넘어가면 트리로 전환하고, 6개가 되면 다시 연결리스트로 전환한다. 두개의 차이가 2가 나는 이유는 데이터의 잦은 삽입,삭제로 1개단위로 전환하게 되면 오버헤드가 더 크기 때문에 일정 수준을 유지하는것이다.
    • AVL 트리도 균형이진트리인데 사용하지 않는 이유는, 일반적으로 hashtable 같은 경우 데이터의 조회만 intensive하게 일어나지 않기 때문에, AVL 트리를 사용하면 rotation과 같은 balance를 유지하는데 드는 오버헤드가 너무 크다.
    • 이에 반해 RB 트리는 조금 더 느슨하게 균형을 유지함으로써 조회,삽입,삭제에 평균적으로 좋은 퍼포먼스를 보여주기 때문에 hashtable의 내부 자료구조로 사용되는 것이다.

장점과 단점

분리연결법은 load factor에 크게 민감하게 반응하지 않아도 된다. 일반적으로 개방주소법에서 load factor가 커지면 성능이 기하급수적으로 나빠지는것에 비해서
분리연결법은 조금 linear한 나쁜 성능을 보여준다.

또 개방주소법에서는 hash table의 resize가 필연적으로 일어나게 되는데, 이것은 O(m) , (m은 key의 개수)의 시간복잡도를 요구하니 꽤 치명적이다.
하지만 분리연결법에서는 하나의 버킷에 대해 지속적으로 사용하기 때문에 테이블의 확장이 개방주소법보다는 더디게 일어나는 편이다.

다만 일반적으로 개방주소법에서 버킷들의 캐시 효율이 좋은 반면 분리연결법은 링크드리스트나 트리를 이용하기에 캐시의 효율이 좋지 않다.

해시 테이블 자체의 단점

데이터가 pseudo-random 위치에 저장되기 때문에, 데이터를 정렬된 순서로 접근하는 것에 있어서 엄청난 비용이 발생한다. Self-balancing binary tree와 같은 자료구조에서는 O(logn)의 조회를 보장해 조금 느리고 구현이 더 복잡하지만 데이터는 정렬되어 있다.

또 데이터를 loop하면서 traverse하는 능력도 떨어지는데, 데이터가 흩뿌려질(산재된) 확률이 높은 해쉬테이블의 특성상 빈 슬롯도 모조리 체크해가면서 순회해야 하기 때문이다.

일반적으로 해시 테이블은 지역참조성에 취약한데, 해시 테이블의 조회 자체가 버킷들을 건너띄면서 확인하는 방식이기 때문이다. 그렇기에 프로세스가 계속해서 캐시 미스를 발생시키고 이는 오버헤드로 이어진다. 데이터가 적고 type이 간단한(Integer...) 경우에는 배열을 이용한 자료구조가 더 나은 성능을 보일 수 있다.

'자료구조' 카테고리의 다른 글

B 트리  (0) 2022.09.18
Stack과 Queue  (0) 2022.09.18
Array와 Linked List  (0) 2022.09.18
Hash Table에 대해서 완전 자세하게 알아보자.  (0) 2022.04.09

일단 프로세스 스케쥴링 알고리즘에 대해서 언급하기 전에, 프로세스의 상태와 Context-Switching에 대해서 알아야 한다.

여러가지 프로세스의 상태를 기반으로 전이(Transition)되며 이를 바탕으로 스케쥴링 알고리즘이 이루어진다.

프로세스의 상태

프로세스의 상태는 OS에 따라서 개수도 다르고, 명칭도 조금 다르지만, 가장 대표적인 그림은 위의 상태와 같다.

  • new : 프로세스가 생성된 상태이다. 이때는 Readuy Queue 안에 들어 있지 않기 때문에 CPU를 받을 대상이 아니다.
  • ready : 프로세스가 CPU를 할당받기 위해 대기하는 상태이다. 보통 Ready Queue 안에 들어와있는 상태라고 얘기한다. 스케쥴링의 대상이 된다.
  • running : 현재 CPU를 할당받아 작업중인 상태다. Single core 시스템에서는 1개다.
  • wating : 프로세스가 입출력이나 이벤트가 끝나기를 기다리는 상태이다.
  • terminated : 프로세스가 종료된 상태다. 단 프로세스 구조체가 사라진 것은 아니다.

그림에서 프로세스의 상태가 특정 작업들로 인하여 변하는 것을 볼 수 있는데 이것을 프로세스의 상태 전이(Transition) 라고 한다.

  • admitted : 생성된 프로세스가 CPU를 받을 준비가 되어서 Long-term Scheduler(Job Scheduler) 에 의해서 선택받아 Reaudy queue로 옮겨지는 작업이다.
    • Long Term Scheduer : 어떠한 프로세스를 레디큐로 옮길지 선택하는 스케쥴러다. 자주 일어나지 않고, 멀티프로그래밍의 정도(DOM) 가 이것에 영향을 받는다.
      • 일반적으로 많은 프로세스가 한꺼번에 메모리에 올라오면, 일부는 디스크에 저장된다.
      • 존재하는 이유는 기존의 프로세스들이 자원을 확보하기 위해서 존재한다. 무턱대고 다 RQ로 올리면 기존의 프로세스는 작업이 진행되지 않는다.
  • scheduler dispatch : ready 상태에 있는 프로세스를 Short term Scheduler가 선택해서 자원을 할당한 후 실행시키는 행위다.
    • Short Term Scheduler : RQ에 있는 프로세스 중에서 어떠한 것이 다음으로 실행될지, CPU를 할당할지 선택하는 스케쥴러다.
      • 빈번하게 발생한다. 그렇기에 이 스케쥴러의 오버헤드를를 줄여야 전체 오버헤드를 줄일 수 있다.
      • 스케쥴링 알고리즘과 아주 깊은 연관이 있다.
  • interrupt : 인터럽트는 조금 논란이 있는데, 저 그림상에서는 입출력 이벤트는 포함하지 않는다. Context-switching이 일어난다.
    • 선점식(preemptive) 방식에서는 타이머(Timer) 이벤트가 있다. 인터럽트 핸들러가 인터럽트를 일으켜서 STS에게 다른 프로세스를 선택할지, 지금 프로세스를 그대로 둘지 결정하게끔 한다. 만약 바뀐다면 전이가 일어난다.
    • 비선점식(non-preemptive 혹은 cooperative) 방식에서는 양보(Yield)가 있다. 이 방식에서는 OS가 프로세스를 스케쥴링할때 인터럽트를 사용,허용하지 않는다. 대신에 작동중인 프로세스가 자발적으로 양보를 해서 스케쥴러가 다른 프로세스를 스케쥴링할 수 있는 방식을 쓴다.
  • I/O or event wait : 실행 중인 프로세스가 입출력이나 이벤트를 처리해야 하는 경우, 입출력/이벤트가 끝날때까지 대기 상태로 만드는 것. Context-switching이 일어난다.
    • 여기서 왜 ready로 안보내는지가 중요하다. 왜냐면, 이 프로세스는 ready로 가서 자원을 할당받을 자격이 없다. 이벤트나 입출력이 끝나지 않았으니 자원을 받아도 사용하지 못하기 때문이다.
  • I/O or event completion : 입출력/이벤트가 끝난 프로세스를 ready 상태로 만들어 STS에게 선택될수 있게끔 한다. wait 과는 다르게 자원을 할당받기만 하면 작업을 이어나갈 수 있기 때문이다.

Context-switching?

정확한 정의는 "CPU다른 프로세스로 스위치할때, 시스템(OS)이 기존의 프로세스의 상태를 저장하고, 다음 진행될 프로세스의 저장된 상태값을 불러오는것"이다.

  • 이 때 중요한것이 PCB 등인데, PCB 안에는 CPU 레지스터에 대한 정보, PC,페이지 테이블과 관련된 정보가 있다. 그렇기에 PCB는 프로세스의 문맥을 보관하고 있는 것이다.
  • 다시 작업을 수행할때 이것을 참조해서 어디부터 다시 시작할지 참고 할 수 있다. 그렇기에 새로운 프로세스로 선택될때도 PCB를 불러와야하고, ready 상태나 waiting 상태로 프로세스가 전이될때도 PCB를 저장해야한다.

일반적으로 context switch하는 시간은 순전한 오버헤드다. 대부분의 시스템은 이 전환 시간동안 어떤 의미있는 작업을 하지 않는다.

또 가장 중요한 부분이 context-swtichg을 하면서 Cache flushing이 일어난다. 그렇기에 그 다음에는 연속적인 캐시 미스(successive cache misses)가 발생한다. 이 과정이 대부분의 오버헤드를 차지한다.

💡 캐시를 날리는 이유는 새로운 프로세스는 새로운 virtual adrress를 가지는 새로운 메모리 주소다. 그렇기 때문에 기존의 캐시된 메모리의 주소 영역을 사용할 필요가 없기에 기존의 캐시된 메모리를 Flush 하는 것.

참고로 스레드도 컨텍스트스위칭을 한다. 그치만 쓰레드들끼리는 주소공간을 공유하기 때문에 캐시를 flush할 필요가 없다.

Process context switching vs Thread context switching

프로세스만 context switching을 하는 것이 아닌 쓰레드끼리도 context switching을 한다. 다만 아래와 같은 차이점이 있다.

  • 프로세스간 context switching
    • 커널 단에서 스위칭이 일어난다.
    • 메모리 주소 공간의 전환이 일어난다.
    • 프로세서의 캐시와 TLB 모두 Flush 된다. → 연속적인 캐시 미스와 TLB 미스가 일어난다.
    • 메모리 주소공간이 전환되고, 캐시미스가 필연적으로 동반되므로 훨씬 비용이 비싸다.
  • 쓰레드간 context switching
    • 커널 단에서 스위칭이 마찬가지로 일어난다.
    • 같은 프로세스 내에서 일어나는 스위칭이기에 메모리 주소공간을 그대로 사용한다.
    • 프로세서의 캐시와 TLB 정보가 유지된다.
    • 레지스터의 값과 스택 포인터 전환 정도만 일어나니 훨씬 저렴하다.

CPU 스케쥴링 알고리즘이란?

스케쥴링 알고리즘은 레디 큐에 있는 프로세스들을 대상으로 다음으로 실행할(CPU를 할당받을) 프로세스를 고르는 작업이다.

일단 프로세스의 상태를 다이어그램으로 먼저 보자.

레디큐 뿐만 아니라, 새로 만들어지는 큐를 담는 큐도 있고, block(I/O wait) 큐도 있다. 일반적으로 CPU 스케쥴링 알고리즘은 Short-term scheduling을 얘기한다.

스케쥴링 알고리즘을 얘기하기 전에 멀티 프로그래밍(Multi Programming)시분할(Time sharing) (혹은 멀티 태스킹(Multi Tasking)) 에 대해서 먼저 얘기를 해야한다.

  • 멀티 프로그래밍
    • 작업들을 적절히 구성해 항상 CPU가 프로세스를 실행하도록 한다.
    • 어떤 프로세스가 CPU를 할당받아 사용하다 I/O 작업등 CPU를 필요로 하지 않는 순간이 오면,
      다른 프로세스로 교체해서 그 프로세스가 CPU를 계속 사용할 수 있도록 한다.
    • 이것의 목적은 CPU 사용률(CPU Utilization)을 최대화함에 있다.
  • 시분할(멀티 태스킹)
    • CPU가 일할때 매우 빈번하게 수행중인 작업을 교체해서 사용자가 상호작용할 수 있는것이다.
    • 반응하는데 걸리는 시간(Response time)이 충분히 작아야 프로그램들이 동시에 진행하는것처럼 보인다.

스케쥴링의 발생과 목적

CPU 스케쥴링은 아래와 같은 상황에서 일어난다.

  • running 에서 waiting 으로 갈 때 (I/O Request)
  • running 에서 ready 로 갈 때 (timeout), Preemptive
  • waiting 에서 ready 로 갈 때 (I/O Finish, 이때 스케쥴링 해주는 이유는, I/O가 끝난 이후에 핸들링을 해줘야 하기 때문이다. 기존의 running 프로세스는 고려하지 않음),Preemptive
  • running 에서 terminated 로 갈 때 (프로세스의 종료)

만약 스케쥴링이 1번과 4번에서만 일어난다면 , non-preempitve지만, 그렇지 않다면 preemptive 스케쥴링이다.

스케쥴링 알고리즘에 대한 기준은 몇가지가 있다

  • CPU 이용률 : CPU를 가능한 바쁘게 해야 한다. 높을 수록 좋음
  • Throughput : 단위시간당 프로세스 처리 수,batch 시스템에선 중요함, 높을 수록 좋음
  • Turnaround time : 특정 프로세스가 도착시간에서부터 끝날때까지 걸린 시간, 낮을 수록 좋음
  • Waiting time : 레디큐에서 기다린 시간의 합 , 범용적으로 중요하고, 낮을 수록 좋음
  • Response time : 요청이 들어왔을때 첫번째 응답까지 걸리는 시간,일반적인 PC에서 중요하다. Throughput과는 반대의 관계이며 낮을수록 좋다.

스케쥴링 알고리즘

  • FCFS (First-Come, First-Served)

    • 비선점
    • 먼저 CPU를 요청한 프로세스에게 먼저 CPU가 할당된다. 공평하다.
    • 큐를 이용하면 쉽게 구현이 가능하다
    • 도착순서를 바꿈으로써 전체 waiting time이 바뀐다. CPU bound job을 뒤로 하면 확 줄어든다
    • Convoy Effect : 시간이 짧게 걸리는 프로세스가 시간이 오래 걸리는 프로세스 뒤에 있으면 CPU 전체 사용률이 줄어든다.
  • SJF (Shortest Job First)

    • 기본적으로 비선점 방식
    • CPU burst time이 가장 작은 프로세스를 먼저 CPU 할당시킨다.
    • 선점방식으로 구현하는것을 SRTF(Shortest Remaining Time First) 방법은, 현재 프로세스의 남은 시간보다 더 짧은 프로세스가 새로 들어오면, 선점해버리는 방식이다.
      • 컨텍스트 스위칭에 드는 오버헤드를 고려하지 않을시에는 SJF보다 성능이 좋다.
      • 만약 선점방식이라면, CPU burst time이 긴 프로세스는 계속 실행되지 못하는 기아(starvation) 현상이 발생 할 수 있다.
    • 하지만 현실적으로 SJF는 구현하기 어려운데, CPU의 남은 시간을 예측불가하기 때문이다. 그래서 평균 waiting time이 최소인 Optimal한 알고리즘이다.
    • 그나마 유사하게 접근 하는 방식은 이전 CPU burst time을 기반으로 예측하는 것이다.
  • Priority Scheduling

    • 각 프로세스에게 우선순위 지시자가 연결되어 있다.
    • 가장 높은 우선순위(일반적으로 가장 낮은 숫자)를 가진 프로세스에게 CPU가 할당된다
    • SJF도 CPU burst time이라는 우선순위를 가지는 우선순위 스케쥴링 방식이다.
    • 선점과 비선점 방식 모두 구현이 가능하다.
      • 선점 : 새로 도착한 프로세스의 우선순위가 기존의 것보다 높으면 CPU 교체 (SRTF)
      • 비선점 : 신경쓰지 않고 새로운 프로세스를 그저 레디큐 맨 앞에 배치
    • 우선순위가 낮은 프로세스는 계속 실행되지 못하는 기아(starvation) 현상이 발생하는데, 시간이 지날때마다 프로세스의 우선순위를 올려주는 에이징(Aging) 방식으로 해결 가능
  • RR(Round-Robin) Scheduling

    • 각각의 프로세스는 Time quantum이라 불리는 짧은 시간을 부여받음.
    • 이 시간이 끝나면, 프로세스는 다른 프로세스로 무조건 교체되고(선점) 레디큐의 마지막으로 돌아간다.(FCFS의 측면)
    • q초의 quantum을 가지는 n개의 프로세스가 있으면, q초마다 한번씩은 1/n의 CPU 타임을 보장받는다.
      최대 (n-1)*q 초까지만 기다리면 무조건 보장받는다. 이를 통해서 waiting time의 upper bound를 보장받게 된다.
    • q의 사이즈를 적절히 고르는 것이 중요하다.
      • q가 너무 크면FCFS랑 다를게 없다.
      • q가 너무 작으면 → 잦은 컨텍스트 스위칭, 그로 인한 오버헤드가 커지기 때문에 throughput이 감소한다.
  • Multi-Level Queue

    • 레디큐를 두개 이상으로 구분해 I/O bound jobCPU bound job으로 구분한다. 그 이유는 I/O bound job은 interaction이 많아서 response를 빨리 얻어야 하기 때문이다. (우선순위를 높여야겠지?)
    • 크게 두 분류로 나누는데 foreground와 background로 나눈다.
      • foreground : interactive하고 , I/O job 위주이며, RR 알고리즘으로 동작하여 response time을 높이는데 주력으로 둔다.
      • background : batch job(CPU bound job)위주이며, FCFS로 동작하며 throughput을 높이는데 집중한다.
    • 각 큐들은 독립적인 스케쥴링을 가지고 있고, foreground가 다 수행되어야 background를 선택한다.
      • 그렇기 때문에 background job들은 기아현상이 발생할 수 있다. (계속 foreground로 들어오면)
  • MLFQ

    • 프로세스들은 큐들 사이에서 서로 이동이 가능하다.

      Untitled

    • 새로운 프로세스는 q=8인 큐로 진입하고, 큐 내부적으로는 FCFS의 행동을 취한다. Q1과 Q2의 작업이 있으면 선점한다.

    • CPU를 얻으면 8ms동안 수행되고, 그 안에 끝나지 않으면 Q1으로 강등당한다.

    • Q2에서 16ms동안 수행되고, 그 안에 끝나지 않으면, Q2로 강등당한다. 이런 방식을 취함으로써 Convoy Effect를 방지할 수 있다. (CPU bound job은 우선순위가 낮으니)

    • 단, yield와 같이 I/O 작업으로 인해 q가 소진되기전에 CPU를 포기하면 강등당하지 않고 우선순위를 그대로 유지한다.

    • MLFQ는 burst time이 짧은 대화형 작업들을 빨리 처리하려고 해 response time에서 강점을 보이지만 , CPU bound job들은 기아 현상을 겪을수 있다.

      • 주기적으로 모든 작업의 우선순위를 상향조정 할 수 있다. 예를 들어 모두를 Q0로 올려보낸다. 이 과정에서 CPU bound job들도 조금이나마 작업을 진행할 수 있다.
    • 의도적인 I/O 작업 호출우선순위를 유지하는 프로세스가 있을 수 있다. 이 프로세스는 예를 들어 7ms가 지난 후 의미 없는 I/O 작업으로 우선순위를 유지한다.

      • 누적적인 CPU 사용 시간을 기록해서 방지한다. PCB 안에 기록하지 않을까..?

선점식 vs 비선점식

  • 선점식은 프로세스에게 한번 실행될때 제한된 시간만큼 CPU를 할당한다. 작업 도중에 자원을 반납당하고 ready 상태로 진입한다. 비선점식은 프로세스가 종료되거나, yield를 통해서나 I/O를 위해서 자발적으로 ready에 가지 않는 한 자원을 반납하지 않는다.
  • 선점식만 실행 중에 인터럽트를 허용한다.
  • 선점식은 프로세스의 우선순위에 따라 스케쥴링을 하는데, 우선순위가 낮은 프로세스가 선택받지 못하는 기아(starvation)현상이 일어날 수 있다. 비선점식은 수행시간이 긴 프로세스(CPU-bound job)이 스케쥴링되면, 그 뒤의 작업들이 오랜 기간동안 선택받지 못한다.
  • 선점식은 context-swtiching이 자주 일어나 이로 인한 오버헤드가 크고, 비선점식은 필수적인 context-switching을 제외하면 자주 일어나지 않아 오버헤드가 비교적 적다.
  • 선점식은 우선순위에 따른 유연성을 가지지만(예를 들어 우선순위 promotion을 진행하는 MLFQ) 비선점식은 그렇지 않다.

이러한 특징때문에 interactive한 시스템에선 반응성 때문에 선점식을, batch job을 자주 수행하는 시스템에선 비선점식을 사용하곤 한다.

프로세스란, 실행중인 프로그램을 의미한다.
프로그램을 실행하기 위해서는 주소공간,파일,메모리 등이 필요한데 운영체제로부터 이런 것을 할당받은 프로그램을 프로세스라 한다.

프로그램은 어떤 작업을 수행하기 위한 파일로써 정적인 상태이고, 프로세스는 그 작업을 수행하는 동적인 상태다.

프로세스의 메모리 구조

프로세스는 아래 그림과 같은 메모리 구조를 띄고 있다.


프로세스는 각자 본인이 사용하는 메모리 영역과 레지스터 값을 가진다.

프로세스의 메모리 영역은 코드,데이터,힙,스택 영역으로 구성된다.

  • 코드 : 사용자가 작성한 프로그램 함수들의 코드가 기계어 명령 형태로 변경되어 저장되는 공간
  • 데이터 : 전역 변수 또는 static 변수 등 프로그램이 사용하는 데이터를 저장하는 공간
  • 스택 : 함수의 복귀주소와 지역변수,매개변수,반환값을 저장하는 공간. 재귀함수가 반복되거나 지역변수가 너무 많으면 stack overflow 발생. 가변적이다.
  • : 프로세스 실행 중에 런타임에 할당되는 영역. 이곳에 메모리를 할당하는 것을 동적 할당이라고 한다. 가변적이다.

    이처럼 프로세스는 각자의 메모리 영역을 가지기에 프로세스간의 메모리는 서로 침범 할 수 없다.

프로세스 제어 블록 (Process Control Block, PCB)

PCB(Process Control Block)는 특정 프로세스에 대한 중요한 정보를 저장하고 있는 운영체제의 자료구조이다. 그렇기에 PCB는 일반적으로 보호된 메모리 영역,예를 들면 커널 스택등에 위치한다.

OS는 프로세스를 관리하기 위해 프로세스를 생성함과 동시에 고유한 PCB를 생성한다.

프로세스는 CPU를 할당받아 작업하다가도 context-switching이 일어나면, 진행하던 작업을 저장하고 CPU를 반환해야하는데, 이때의 작업 진행상황이 PCB에 저장된다.
그 후 다시 CPU를 할당받게 되면 PCB 안에 있던 정보들을 불러와서 작업이 멈추었던 시점에서부터 다시 시작한다.

  • 프로세스 식별자 : PID
  • 프로세스 상태 : new,ready,running,waiting,terminated

  • 프로그램 카운터,PC : 프로세스가 다음에 실행할 명령어 주소
  • CPU 레지스터
  • CPU 스케쥴링 정보 : 우선순위, 스케쥴 큐에 대한 포인터 ← 이것을 바탕으로 MLFQ나 RR이 동작한다.
  • 메모리 관리 정보 : 페이지 테이블 또는 세그먼트 테이블에 대한 정보 포함
  • 입출력 상태 정보 : 프로세스에 할당된 입출력 장치들과 열린 파일 목록
  • 어카운팅 정보 : 사용된 CPU 시간, 시간제한, 계정번호

이 PCB들은 커널 스택과 같은 보호된 메모리 영역 내에서 Linked List 형태로 관리된다. PCB List Head에 PCB들이 생성될때 붙는 방식으로 관리가 된다.

스레드(Thread)

스레드는 프로세스가 할당받은 자원을 이용하는 실행 단위다.

한 프로세스 내에서 동작되는 여러 실행 흐름으로 프로세스 내의 주소 공간이나 자원을 공유할 수 있다.

스레드는 스레드 ID,PC,레지스터 집합, 그리고 각자의 스택으로 구성된다(독립적인 메모리는 아니고, 스택 포인터로 표시한다).

이를 통해 멀티 쓰레딩을 달성할 수 있는데 멀티쓰레딩은 하나의 프로세스를 여러개의 실행단위(스레드)로 나누어서 자원을 공유하며 자원의 생성과 관리의 중복을 줄여 수행능력을 높이는 것이다.

  • 공유
    • 같은 프로세스에 속하는 스레드들은 힙,데이터,코드 영역을 공유한다. 추가적으로 open된 파일등도 공유한다.
  • 공유하지 않는 것
    • 각자의 스택과 레지스터(PC 포함)은 공유하지 않는다.

이렇게 하나의 프로세스 내에서 다수의 실행 단위인 스레드로 구분하여 공유할 자원은 공유하고, 독립적인것은 따로 두어 수행 능력을 향상시키는 것을 멀티스레딩이라 한다.

스레드에서의 메모리 구조

스레드 역시 프로세스와 단짝인 개념인데, 짧게 말하면 프로세스가 할당받은 자원을 이용하는 실행의 단위 이다. 위의 그림처럼, 여러개의 스레드는 각자의 레지스터와 스택을 가지지만, 나머지 영역은 가지지 않는다. 대신에 코드,힙,데이터 영역을 공유해서 병렬적인 수행이 가능하다.

  • TMI : 왜 스레드는 각자의 스택을 가지고 있을까?

    스레드가 하나의 실행의 context라는 것을 생각하면 자명하다. 그 context내에서 아주 간단하게 여러개의 지역변수,파라미터,반환값,복귀주소등을 가지는데, 그 스레드들이 만약 서로의 스택을 공유하게 된다면, 그 context가 서로 섞이게 된다. 사실 각자의 스택을 가지기 때문에 스레드가 종종 Lightweight Process라고 불리는 이유다. 그리고 각자의 스택을 가지고 있다고 얘기하지만 사실은 하나의 메모리공간을 stack pointer를 이용해서 분리하는 것이다.

  • TMI2 : 그럼 스레드는 왜 각자의 PC와 register도 있을까?

    위의 답과 비슷한 이유인데, PC(Program Counter)나 레지스터의 역할은 현재 명령어가 어디까지 수행되었는지,수행될때 쓰던 데이터는 무엇이었는지 알려주는 것이다. 스레드는 CPU를 할당받을때 프로세스처럼 스케쥴러에 의해 결정되는데, 그렇기에 명령어가 연속적으로 실행됨을 보장하지 못하기에 어디까지 실행되었는지 기록할 필요가 있는데 그래서 스레드가 각자의 PC를 가지는 것이다.

스레드의 장점과 단점

  • 장점
    • 반응성
      • 특정 스레드가 I/O 작업을 처리중이거나(blocked) 긴 작업을 수행중이여도 다른 스레드는 본인의 일을 계속 할 수 있다.
    • 자원 공유
      • 프로세스는 shared memory나 message passing과 같은 기법을 이용해서 자원을 공유할 수 있지만, 스레드끼리는 프로세스의 자원을 서로 공유한다.
    • 경제적
      • 프로세스의 자원을 공유하기 때문에 새로운 메모리 주소공간을 할당받을 필요가 없어 생성에 자원이 적게 들어간다.
      • 스레드 간에 컨텍스트 스위칭을 할 때 캐시 메모리 안비워도 된다. 어차피 같은 메모리 주소공간을 사용하니까 오버헤드가 적다.
      • 근데 이것도 극복하려고 thread pool이라는 기술도 있다.
    • 확장가능성
  • 단점
    • 공유자원을 사용하기 때문에 동기화 문제를 고려해야 한다.
      • lock을 함으로써 공유자원에 대한 동기화를 해결할 수 있는데 이게 병목현상이 될 수 있다.
      • 그러므로 적절한 부분만 lock을 하는 것이 필요하다. → Critical Section Only

멀티 프로세싱 vs 멀티 스레드

  • 멀티 프로세싱
    • 두 개 이상의 다수의 프로세스가 협력적으로 하나의 작업을 동시에 병렬적으로 처리하는 것
    • 프로세스간 메모리 공유는안되기 때문에, IPC를 이용해 소통
    • 하나의 프로세스에서 문제가 생겨도 다른 프로세스는 계속 진행할 수 있음.
    • 컨텍스트 스위칭 비용이 높음
  • 멀티 스레딩
    • 단일 프로세스 내에서 여러 쓰레드로 나누어 동시에 실행
    • 컨텍스트 스위칭 비용이 거의 없음
    • 스택을 제외한 데이터,코드,힙 영역을 공유하기 때문에 공유하는데 편리
    • 임계영역의 문제가 있음

GC를 수행하는 Garabage Collector는 아래와 같은 일을 한다.

  • 메모리 할당
  • 사용 중인 메모리 인식
  • 미사용 메모리 인식

Stop-the-World

  • 자바 애플리케이션은 GC 실행시 GC 실행 스레드를 제외한 모든 스레들르 멈추고, GC 완료 후 다시 스레드들을 실행 상태로 변경
  • Stop the World는 모든 애플리케이션 스레드들의 작업이 멈추는 상태
  • 어떤 GC 알고리즘을 사용해도 Stop-the-World는 불가피하며 대개의 GC 튜닝이란 이 Stop-the-World 시간을 줄이는 것이다.

전제

가비지 컬렉터는 두가지 전제 조건 하에서 만들어졌다.

  • 대부분의 객체는 금방 접근 불가능 상태(unreachable)가 된다.
  • 오래된 객체에서 젊은 객체로의 참조는 아주 적게 존재한다.

이것을 'weak generational hypothesis'라고 하는데 이것을 살리기 위해서 Young 영역Old 영역으로 나누었다.

  • Young 영역 (Young Generation 영역) : 새롭게 생성한 객체 대부분이 여기에 위치하고, 대부분이 금방 접근불가능 상태가 되기 때문에 매우 많은 객체가 Young 영역에 생성되었다가 사라진다. 이 영역에서 객체가 사라질때 Minor GC가 발생한다고 말함.
  • Old 영역 (Old Generation 영역) : 접근 불가능 상태로 되지 않아 Young 영역에서 살아남은 객체가 여기로 복사된다. 대부분 Young 영역보다 크게 할당하며, 크기가 큰 만큼 Young 영역보다는 GC가 적게 발생한다.(쉽게 가득차지 않으니) 이 영역에서 객체가 사라질 때 Major GC가 발생한다고 말한다.

객체의 데이터 흐름은 아래와 같다. PermG는 Java 8에서 Metaspace로 교체되었다.

PermG에서 GC가 발생해도 MajorGC의 횟수로 친다.

전제의 두번째가 "오래된 객체에서 젊은 객체로의 참조는 아주 적게 존재한다"인데, 만약 실제로 Old 영역의 객체가 Young 영역의 객체를 참조하는 경우가 생긴다면 Old 영역에 512Byte의 chunck로 되어 있는 card table을 따로 두어 해결한다.

카드 테이블에는 Old 영역에 있는 객체가 Young 영역의 객체를 참조할 때마다 정보가 표시된다. Young 영역의 GC를 실행할 때에는 Old 영역에 있는 모든 객체의 참조를 확인하지 않고, 이 카드테이블만 확인해 GC 대상인지 식별한다.

카드 테이블은 write barrier를 사용하여 관리한다. write barrier는 Minor GC를 빠르게 할 수 있도록 하는 장치인데, 이것 때문에 약간의 오버헤드는 있지만 (Old가 Young을 참조하는지 Old 영역 전체를 일일이 확인하지 않아도 되기에) 전반적인 GC시간은 줄어든다.

Young 영역의 구성

객체가 제일 먼저 생성되는 Young 영역은 크게 3가지로 나뉜다.

  • Eden 영역
  • Survivor 영역(2개,From과 To)

각 영역의 처리 절차를 순서에 따라 기술하면 다음과 같다.

  • 새롭게 생성한 대부분의 객체는 Eden 영역에 위치한다.
  • Eden 영역에서 GC가 한 번 발생한 후 살아남은 객체는 Survivor 영역 중 하나로 이동한다.
  • Eden 영역에서 GC가 발생하면 이미 살아남은 객체가 존재하는 Survivor 영역으로 객체가 계속 쌓인다.
  • 하나의 Survivor 영역이 가득차게 되면, 그 중에서 살아남은 객체를 다른 Surivor 영역으로 이동한다. 그리고 가득찬 Survivor 영역은 이제 아무 데이터가 없는 상태가 된다.
  • 이 과정을 반복하다 계속해서 살아남아 있는 객체는 Old 영역으로 이동하게 된다.

이 절차에 따라서 Survivor 영역 중 하나는 반드시 비어있는 상태로 남아 있어야 한다.

💡 오래되었다고 하는 기준은 Young Generation 영역에서 Minor GC 가 발생하는 동안 얼마나 오래 살아남았는지로 판단한다. 각 객체는 Minor GC에서 살아남은 횟수를 기록하는 age bit 를 가지고 있으며, Minor GC가 발생할 때마다 age bit 값은 1씩 증가 하게되며, age bit 값이 MaxTenuringThreshold 라는 설정값을 초과하게 되는 경우 Old Generation 영역을 객체가 이동 되는 것이다. 또는 Age bit가 MaxTenuringThreshold 초과하기 전이라도 Survivor 영역의 메모리가 부족할 경우에는 미리 Old Generation 으로 객체가 옮겨질 수도 있다. JVM 옵션 : -XX:MaxTenuringThreshold

그럼 왜 Survivor 영역이 두개인가?

퍼포먼스와 연관있는데, fragmentation(단편화)를 줄이기 위함이다.
예를 들어 새로운 객체는 Eden에 생성된다. Eden이 가득차면 GC가 수행되고 살아있는 객체는 Survivor로 옮겨진다. 근데 그다음에 Eden이 가득차면, Eden과 Survivor 영역의 메모리를 정리하지만 이 영역은 연속적이지 않게 된다. 이런 현상을 방지하기 위해 두가지 Survivor 영역을 두어서 위의 예시에서 두번째 GC시에 Eden과 Survivor 안에 있는 reachable한 객체들은 비어 있는 새로운 Survivor로 옮겨지거나 특정 객체(Old enough한)는 Old로 Promotion된다. 그리고 두 Survivor space는 역할을 바꾼다. 하나는 텅텅 비어있고 하나는 Eden에서 올라오는 것을 수용하는 공간. 이 과정을 통해 Heap에서의 연속적인 메모리 사용을 가능하게 한다.

다시 말하면 Eden에서도 빈 공간 생기고, Survivor에서도 드문드문 빈 공간이 생기게 되는것. Memory internal Fragmentation과 비슷한일이 일어나는것)

Mark and Copy

SerialGC에서 Young Generation에게 쓰는 GC 방식이다.

  • Fragmentation(단편화) 방지에는 효과적이다.
  • Heap의 절반 밖에 사용하지 못하니 공간 활용의 비효율성
  • Suspend 현상(Copy할때), Copy에 대한 Overhaed 존재

Mark and Copy algorithms are very similar to the Mark and Compact as they too relocate all live objects. The important difference is that the target of relocation is a different memory region as a new home for survivors. Mark and Copy approach has some advantages as copying can occur simultaneously with marking during the same phase. The disadvantage is the need for one more memory region, which should be large enough to accommodate survived objects.

Old 영역에 대한 GC

Old 영역은 기본적으로 데이터가 가득 차면 GC를 실행한다. GC 방식에 따라 처리 절차가 달라지므로 GC 방식에 따라 접근하고 이해해야 한다. JDK 7 기준 5가지 방식

  • Serial GC (싱글코어를 상정하고 만든 방식이라 운영서버 사용금지)
  • Parallel GC
  • Parallel Old GC(Parallel Compacting GC)
  • Concurrent Mark & Sweep GC(CMS)
  • G1(Garbage First) GC (도입은 JDK7, JDK9부터 기본 GC)

Serial GC

Young 영역에서의 GC는 앞에서 설명한 방식을 사용하고,(Mark and Copy) Old 영역의 GC는 mark-sweep-compact 알고리즘을 사용한다. 디스크 조각모음과 비슷하다. 두 GC 모두 Stop-the-World를 트리거한다.

  1. Old 영역에 살아 있는 객체를 식별(Mark)한다.
  2. Heap의 앞 부분부터 확인하여 살아있는 것만 남긴다.(Sweep)
  3. 각 객체들이 연속되게 쌓이도록 Heap의 가장 앞 부분부터 채워서 객체가 존재하는 부분과 존재하지 않는 부분으로 남긴다.(Compaction)

적은 메모리와 CPU 코어 개수가 적을때 적합한 방식이다.

Parallel GC

기본적인 알고리즘은 Serial GC와 같다. 그러나 SerialGC가 GC를 처리하는 스레드가 하나인 것에 비해 Parallel GC는 GC를 처리하는 스레드가 여러개로 SerialGC보다 빠르게 수행된다. 메모리가 충분하고 코어의 개수가 많을 때 유리하다. Throughput GC라고도 부른다.


더 빠르게 동작하니 Stop-the-World의 시간도 줄여주는 효과를 얻을 수 있고 Java 애플리케이션 전체가 매끄럽게 동작한다.

Parallel Old GC

JDK5u6부터 제공한 GC 방식이고, Parallel GC와 비교하여 Old 영역의 GC 알고리즘만 다르다. 이 방식은 Mark-Summary-Compaction 단계를 거친다. Summary 단계는 앞서 GC를 수행한 영역에 대해 별도로 살아 있는 객체를 식별한다는 점에서 Mark-Sweep-Compcation 알고리즘의 sweep 단계와는 다르며, 약간 더 복잡하다.

  • Sweep단일 스레드가 Old 영역 전체를 훑어 살아있는 객체만 찾는다.
  • Summary여러 스레드가 Old 영역을 분리하여 훑는다. 또 효율성을 위해 Compaction된 영역도 별도로 훑는다.

CMS GC (Concurrent Mark-Sweep Garbage Collector)

GC 과정에서 발생하는 Stop-the-World의 시간을 최소화하는데 초점을 맞춘 GC 방식으로 GC의 과정이 복잡하다.

GC 대상을 최대한 자세히 파악한후, 정리하는 시간(STW가 발생하는 시간)을 짧게 가져가는 컨셉으로, 과정이 복잡한 만큼 다른 GC 대비 CPU 사용량이 높다.

아래의 그림은 Serial GC와 CMS GC를 비교한 그림이다. 엄청 복잡.


Young 영역에서는 Mark and copy방식을 그대로 사용하고 Old 영역은 Concurrent Mark-Sweep 알고리즘을 사용한다.

CMS GC는 Initial Mark → Concurrent Mark → Remark → Concurrent Sweep 과정이다.

  • Initial Mark
    • GC 과정에서 살아남은 객체를 탐색하는 시작 객체(GC Root)에서 참조 Tree상 가장 가까운 객체만 1차적으로 찾아가며 객체가 GC대상(참조가 끊긴)인지를 판단한다. 이때는 STW 현상이 발생하지만, 탐색 깊이가 얕아 STW 발생 기간이 매우 짧다.
  • Concurrent Mark
    • STW 현상없이 진행되며, Initial Mark 단계에서 GC 대상으로 판별된 객체들이 참조하는 다른 객체들을 따라가며 GC 대상인지를 추가적으로 확인한다.
    • 이 단계의 특징은 다른 스레드가 실행중인 상태에서 동시에 진행된다는 것.
  • Remark
    • Concurrent Mark 단계의 결과를 검증하며, 이전 단계에서 GC 대상으로 추가 확인되거나 참조가 제거되었는지 등을 확인한다. 이 과정은 STW를 유발하기 때문에 STW 지속시간을 최대한 줄이기 위해 멀티스레드로 검증 작업을 수행한다.
  • Concurrent Sweep
    • STW 없이 Remark 단계에서 검증 완료된 GC 객체들을 메모리에서 제거한다.

*Initial Mark -> Concurrent Mark -> Remark -> Concurrent Sweep ,* CMS의 상황
Initial Mark -> Concurrent Mark -> Remark -> Concurrent Sweep , CMS의 상황

CMS GC는 Compaction 작업을 필요한 경우에만 수행한다. 즉, 연속적인 메모리 할당이 어려울 정도로 메모리 단편화과 심한 경우에만 Compaction 과정을 수행하는 것이다.

또 이러한 단계로 진행되는 것이기에 STW 시간이 매우 짧고, 모든 애플리케이션의 응답 속도가 매우 중요할 때 CMS GC를 사용하며, Low Latency GC라고도 부른다.

G1 GC (Garbage First)

G1 GC는 기존의 Young 영역과 Old 영역을 구분하던 방식과는 다른 접근을 한다.

아래 그림과 같이 G1 GC는 바둑판처럼 영역을 구분하고 그 영역에 객체를 할당하고 GC를 실행한다. 해당 영역이 꽉 차면 다른 영역에서 객체를 할당하고 GC를 실행한다.

기존의 Young의 Eden/Survivor 영역에서 데이터가 Old 영역으로 이동하는 단계가 사라진 GC방식이다. 또 G1 GC에선 각각의 바둑판 모양의 영역이 Eden,Survivor,Old 영역의 역할을 동적으로 바꿔가며 GC가 일어난다.

G1 GC는 지금껏 얘기한 어떤 GC 방식보다 빠른 성능을 장점으로 가진다. 다시 말해 짧은 STW를 지향한다는 것이다.

G1 GC는 굉장히 크기가 큰 Heap에서도 짧은 STW 시간은 보이는데 왜 그런것일까?

G1 GC의 비밀

Heap의 용량이 커지면 커질수록, 객체의 갯수가 많아지고, 자연스럽게 GC 수행시간이 길어지며 STW 시간도 늘어난다. 하지만 G1 GC는 다르다.

  1. GC시에 전체 Heap에 대해서 GC를 수행할 필요가 없다. GC 해야하는 영역만 GC를 수행하면 되기 때문이다.
  2. Old 영역에 대한 Compaction을 할때, 전체 Old 영역에 대해서 Compaction을 할 필요가 없다. 특정한 영역에 대해서만 Compaction을 하면 된다.
  3. Garbage를 먼저 수집해간다. G1 GC는 살아있는 객체를 마킹한후에 영역 별로 얼만큼 살려줘야 하는지를 알 수 있다. 그 후 영역 중에 모든 객체가 죽은 리전(유효한 객체가 없는,Garbage만 있는 영역)부터 먼저 회수를 한다. 메모리 회수를 먼저하기에 빈 공간 확보를 더 빨리 할 수 있다. 그러면 GC가 낮은 빈도로 일어난다.

왜 G1GC가 JDK9부터 default GC로 선정되었을까?

G1 GC의 목표는 일시정지 시간 (STW)을 최소화하는데 있다. 영역별로 나누어서 GC를 수행하기 때문에 전체 Old 영역에 대한 GC를 수행하는 일이 생기지 않아 긴 시간의 STW를 가지는 Major GC의 빈도를 낮출수 있어서 선택되었다고 생각한다.

출처 :

JVM GC

네이버 D2 블로그

전체적인 GC에 대한 통계 제공

'Java' 카테고리의 다른 글

JVM의 Java 8에서의 변화  (0) 2022.09.18

MappingRegistry

MappingRegistry는 아까 살펴본 AbstractHandlerMethodMapping의 내부 클래스다. MappingRegistryhandler method에 대한 모든 mapping을 유지 관리하고 lookup을 수행하는 method를 가지고 있고 동시성을 가진 접근을 가능하게 해주는 레지스트리다.

A registry that maintains all mappings to handler methods, exposing methods to perform lookups and providing concurrent access.
Package-private for testing purposes.

가장 중요한 부분이 handler method에 대한 모든 mapping을 유지 관리하고 lookup을 수행하는 method를 가지고 있다는 것이다.

class MappingRegistry {
  private final Map<T, MappingRegistration<T>> registry = new HashMap<>();

  private final Map<T, HandlerMethod> mappingLookup = new LinkedHashMap<>();

  private final MultiValueMap<String, T> urlLookup = new LinkedMultiValueMap<>();

  private final Map<String, List<HandlerMethod>> nameLookup = new ConcurrentHashMap<>();

  private final Map<HandlerMethod, CorsConfiguration> corsLookup = new ConcurrentHashMap<>();

  private final ReentrantReadWriteLock readWriteLock = new ReentrantReadWriteLock();
  ...
  ...

  // methods
}

MappingRegistry 안에서 Map 자료구조를 가진 멤버 변수들이 있다. 그 중에서LinkedMultiValueMap이라는 자료구조를 사용한다. 이건 한개의 key에 여러 value들을 저장하는 MultiValueMapLinkedHashMap으로 감싼 자료구조로 Spring이 만든 자료구조다.

urlLookupLinkedMultiValueMap의 자료구조인데, key는 url을 가지고, value는 RequestMappingInfo를 가진다. LinkedMultiValueMap을 쓰는 이유는 하나의 url에 여러 handlerMethod들에 대한 정보가 담기기 때문이다.

예를 들어 "/app/user"라는 url 아래 user에 대한 정보를 조회하는 GET,user를 추가하는 POST가 매핑될때, 아래처럼 RequestMappingInfo가 들어가는 것이다.

key : "/app/user/ 
value : [GET /app/user,POST /app/user]

위와 같은 구조를 통해 MappingRegistryurl에 해당하는 handlerMethod를 구별할 수 있게 된다. 코드로 보자.

protected HandlerMethod lookupHandlerMethod(String lookupPath, HttpServletRequest request) throws Exception {
  List<Match> matches = new ArrayList<>();
  List<T> directPathMatches = this.mappingRegistry.getMappingsByUrl(lookupPath);
  if (directPathMatches != null) {
    addMatchingMappings(directPathMatches, matches, request);
  }
  ...
  ...
  if (!matches.isEmpty()) {
    ...
    ...
    matches.sort(comparator);
    Match bestMatch = matches.get(0);
    ...
    ...
    request.setAttribute(BEST_MATCHING_HANDLER_ATTRIBUTE, bestMatch.handlerMethod);
    handleMatch(bestMatch.mapping, lookupPath, request);
    return bestMatch.handlerMethod;
  }

위는 아까 잠깐 언급한 lookupHandlerMethod이다. 적절한 handlerMethod를 가져온 후 return 한다 고 했는데 그 과정이 담겨있다.
길다고 겁먹지 말고 한줄씩 보자. (match되는 것이 없거나, 2개 이상인 경우는 제외함)

List<Match> matches = new ArrayList<>();

match를 담는 matches라는 리스트가 있다.

List<T> directPathMatches = this.mappingRegistry.getMappingsByUrl(lookupPath);

현재 url에 mapping되는 handler method들의 RequestMappingInfo들을 getMappingsByUrl로 가져온 후 directPathMatches에 저장한다. 예를 들어 url/app/user이면 directPathMatches에는 [GET /app/user, POST /app/user] 와 같은 정보가 들어오는 것이다.

if (directPathMatches != null) {
    addMatchingMappings(directPathMatches, matches, request);
}

그 후 [GET /app/user, POST /app/user] 중에서 request 정보와 일치하는 것들을 addMatchingMappings을 통해서 matches에 추가한다.

matches.sort(comparator);
Match bestMatch = matches.get(0);

matches들을 우선순위에 맞게끔 정렬하고, request와 가장 일치하는 0번째 matchbestMatch에 저장한다.

request.setAttribute(BEST_MATCHING_HANDLER_ATTRIBUTE, bestMatch.handlerMethod);
handleMatch(bestMatch.mapping, lookupPath, request);
return bestMatch.handlerMethod;

bestMatch의 멤버인 handlerMethod를 return해서 최종적으로 적합한 handler method를 찾게 된다.

private class Match {

  private final T mapping;

  private final HandlerMethod handlerMethod;

}

Reflection

이제 마지막 궁금증만이 남았다.

출처

MappingReigstry javadoc
LinkedMultiValueMap javadoc

문제 상황

백준 빵집를 풀때 이상한 점이 확실히 생겼다. 일반적으로 python에서 방문 여부를 확인하기 위해 setlist중에 하나를 사용한다.
set같은 경우는 잘못된 접근 같은 행동에서 안전한 편이고 대부분의 기능에서 O(1)의 시간이 보장된다고 알고 있기 때문에 set을 이용한 풀이를 종종했다.

List에서는 미숙한 코드로 indexError를 경험할 수 있다.

이 문제는 naive 하게 set을 사용하면 시간초과를 당한다. 처음엔 논리를 잘못 구성해 recursionDepth가 커져서 시간초과가 나는건가 라고 생각했지만 논리에는 문제가 없었다. 그래서 set만을 list로 바꿔주었더니 통과했다.

분명 lookup도 똑같이 O(1)이고 set에서는 addO(1)이라고 알고 있는데 왜 차이가 나는 것일까? 이 이해 안되는 상황을 지금부터 알아보자.

메모리 사용량

먼저 간단하게 두 자료구조의 메모리 사용량을 비교해보았다.

아래 코드는 10000개의 행 , 500개의 열을 가진 2차원 board에서 사용되는 방문을 기록하는 일반적인 구현이다.

하나는 List로 하나는 set으로 구현한 뒤 sys.getsizeof(object)를 이용해서 각각의 객체의 메모리 사용량을 체크해보았다.

getsizeof(object [, default]) -> int : Return the size of object in bytes.

R=10000 # ROW
C=500    # COL

a=[[True for _ in range(R)] for _ in range(500)]
sys.getsizeof(a)
>>> 4264

b=set()
for i in range(R):
    for j in range(C):
        b.add((i,j))
sys.getsizeof(b)
>>> 134217944

놀라운 결과다. 같은 기능을 수행하는 서로 다른 자료구조인데 메모리차이가 대략 31,000배 정도의 차이를 보였다.

list4264 Byte, set134217944 Byte를 사용한다. 대략적으로 두 자료구조는 각각 4KB,134MB을 사용한다는 것이다.

List와 Set의 내부구조

왜 이렇게 차이가 나는 것일까?

listset이 내부적으로 데이터를 저장하는 방식에서 차이가 있어서 그렇다. stackoverflow의 한 답변에 따르면

List

List는 그저 원래의 객체들에 대한 참조를 모아놓은것에 불과하다. 만약 1000개의 integer를 만든다고 하면 , 그것들이 만들어지고 list는 오직 그 integer들에 대한 참조만을 담고 있다.

list는 참조들의 collection이고 일종의 주솟값을 가지고 있는것이다. 그렇기에 아래와 같은 코드를 수행해도 여전히 4264 Byte다.

c= [[[1,2,3] for _ in range(10000)] for _ in range(500)]
sys.getsizeof(c)
>>> 4264

list가 세번 중첩되어 있지만 [1,2,3]에 대한 참조만을 가지고 있기 때문에 메모리 사용량이 같은 것이다.

내부적으로는 dynamic array를 사용하고 있다.

Set

반대로 set은 어떨까? Python에서의 set은 내부적으로 key가 dummy value인 dict 를 상당 부분 재사용함으로써 hash table 의 구조를 가지고 있다.

여전히 위의 같은 답변을 참고하자면

반면에, set이나 dictionary는 1000개의 integer들의 hash value를 모두 계산해야하고 그에 따라 메모리 사용량이 증가한다.

예를 들어 set이나 dict나 가장 작은 크기의 기본값은 8이다.(즉, 오직 3개의 값만 저장한다해도 python은 8개를 지정해준다.) resize를 할때, buckets들의 개수는 요소가 50,000개가 되기 전까지는 4배씩 증가한다. 그런 다음에는 2배씩 증가한다.

Set에서의 resize

과연 정말 그렇게 작동할까? resizeset에 일반적으로 값을 추가할 때 호출되기 때문에 add를 할때 호출되는 CPython의 setset_add_entry의 구현 코드를 직접 보자.

/* CPython */
/* https://github.com/python/cpython/blob/main/Objects/setobject.c */ 
static int
set_add_entry(PySetObject *so, PyObject *key, Py_hash_t hash)
...
...
  found_unused:
    so->fill++;
    so->used++;
    entry->key = key;
    entry->hash = hash;
    if ((size_t)so->fill*5 < mask*3)
        return 0;
    return set_table_resize(so, so->used>50000 ? so->used*2 : so->used*4);

...
...

위의 코드에서 load factor가 일정 부분(아래서 다시 언급할 것이다.)을 넘어갈때를 보자.
(if ((size_t)so->fill*5 < mask*3) 의 아랫부분이다.)
so(set object)의 크기에 따라 set_table_resize인자를 다르게 주고 있음이 보인다.

** Load factor ** : Hash table 전체에서 얼마나 원소가 차 있는지를 나타내는 수치.
m개의 bucket의 Hash tablen에 n개의 원소가 저장되어 있다면 load_factor = n/m 이다

so->used가 50,000이 넘어가면 현재 used의 2배를, 그렇지 않으면 4배만큼 큰 값으로 resize를 하는 것이다.

여기서 mask는 hash_table의 크기보다 1 작다. modulo의 역할처럼 hash에 쓰이는데 AND 연산을 이용한다.

resize의 내부동작 방식

이제 set_table_resize가 내부적으로 어떻게 동작하는지를 살펴보자.

요약하자면 set_table_resizehash table의 구조를 가지는 setentry 구조체를 기존 oldtable의 2배 혹은 4배의 크기인 newtable이라는 이름으로 동적할당 받은 후 oldtable의 모든 entry들을 newtable에 넣어주는 과정을 거친다.

typedef struct {
    PyObject *key;
    Py_hash_t hash;             /* Cached hash code of the key */
} setentry;

이해가 안가도 당황하지 마세요... 아래서 차근차근 설명해줄거에요...😅

단계별로 살펴보자. 아래 코드부터다.

/* CPython */
/* https://github.com/python/cpython/blob/main/Objects/setobject.c */ 
static int
set_table_resize(PySetObject *so, Py_ssize_t minused)
{
    setentry *oldtable, *newtable, *entry;
    ...
    /* Find the smallest table size > minused. */
    /* XXX speed-up with intrinsics */
    size_t newsize = PySet_MINSIZE;
    while (newsize <= (size_t)minused) {
        newsize <<= 1; // The largest possible value is PY_SSIZE_T_MAX + 1.
    }

일단, newtable의 크기를 정해야 한다. set_table_resize의 두번째 인자만큼
newsize를 left shift를 사용해 Pyset_MINSIZE에서 minused만큼 증가시킨다.
(PySet_MINSIZE는 위에 언급한 것처럼 8이다.)

/* CPython */
/* https://github.com/python/cpython/blob/main/Objects/setobject.c */
    ...
    /* Get space for a new table. */

    if (newsize == PySet_MINSIZE) {
        ...
        /* A large table is shrinking, or we can't get any smaller. */
        ...
        ...
        }
    }
    else {
        newtable = PyMem_NEW(setentry, newsize);
        if (newtable == NULL) {
            PyErr_NoMemory();
            return -1;
        }
    }

newsizePySet_MINSIZE(8)가 아닐때, PyMem_New(type,n)를 통해서 위에서 계산된 newsize의 크기만큼 setentry의 객체를 생성하는 것이다.

좀 더 엄밀하게 얘기하면 PyMem_new(type,n)PyMem_Malloc(n)를 이용해서 (n)*sizeof(type) Byte만큼의 메모리를 동적할당(malloc) 하는 macro다. (cpython/include/pymem.h 참조)

이제 거의 다 왔다. oldtable들의 값들을 newtable에 옮겨주기만 하면 된다.

/* CPython */
/* https://github.com/python/cpython/blob/main/Objects/setobject.c */
    ...
    so->mask = newsize - 1;
    so->table = newtable;
    ...

hashing에 필요한 mask 값을 갱신해주고 (modulo와 비슷한 역할을 한다. 다만 AND 연산자를 사용할뿐이다.)

/* CPython */
/* https://github.com/python/cpython/blob/main/Objects/setobject.c */
    /* Copy the data over; this is refcount-neutral for active entries;
       dummy entries aren't copied over, of course */
    newmask = (size_t)so->mask;
    if (so->fill == so->used) {
        for (entry = oldtable; entry <= oldtable + oldmask; entry++) {
            if (entry->key != NULL) {
                set_insert_clean(newtable, newmask, entry->key, entry->hash);
            }
        }
    } else {
        so->fill = so->used;
        for (entry = oldtable; entry <= oldtable + oldmask; entry++) {
            if (entry->key != NULL && entry->key != dummy) {
                set_insert_clean(newtable, newmask, entry->key, entry->hash);
            }
        }
    }

newmask 를 이용해서 oldtable안의 기존의 값들을 newtableset_insert_clean을 이용해 넣어준다.

** 이런 복잡한 과정들을 거쳐서 set에서 resize가 진행되는 것이다. **

그래서 얼마나 걸리는데?

b=set()
for i in range(R):
    for j in range(C):
        b.add((i,j))

위 문제에선 set이 최대 몇 번 resize를 할까? resize 전 후로 b의 메모리 크기가 달라지니 아래와 같은 코드로 확인 할 수 있다.

resize_count=0
prev=cur_size=sys.getsizeof(b)

for i in range(R):
    for j in range(C):
        b.add((i,j))
        if prev!=sys.getsizeof(b):
            prev=cur_size
            resize_count+=1

print(resize_count)
>>>13

13번 resize를 한다. 그럼 그때마다 내부 구조인 hash table의 bucket은 언제 , 그리고 얼만큼 늘어날까?

Set의 load factor

아래와 같은 코드로 확인 할 수 있다.

element_count=0
prev=cur_size=sys.getsizeof(b)

for i in range(R):
    for j in range(C):
        s=time.time()
        b.add((i,j))
        element_count+=1
        if prev!=sys.getsizeof(b):
            cur_size=sys.getsizeof(b)
            print("resize at "+str(element_count))
            prev=cur_size

>>>
resize at 5
resize at 19
resize at 77
resize at 307
resize at 1229
resize at 4915
resize at 19661
resize at 78643
resize at 157286
resize at 314573
resize at 629145
resize at 1258291
resize at 2516582
  1. 처음 bucket의 크기는 8이니 5/8만큼 찼을때 늘어난다.
  2. 19/32만큼 찼을때 늘어난다.
  3. 77/128만큼 찼을때 늘어난다.
    ...

이걸 바탕으로 우린 위에서 언급한 load factor를 계산 할 수 있는데, 미리 계산해왔다. 😅
대략 0.6에 근접하면 resize가 호출된다.

** Python에서, 최소한 CPython의 setload factor는 0.6에 근접하는것 같다.**

직접 계산 해보면 hash table의 bucket의 크기는 8->32->128->...->32768->131072->262144->...->4194304 순으로 늘어난다. ** 정말 50,000을 넘어서부터는 4배가 아니라 2배씩 늘고 있다.**

아까 위에서 언급한 resize를 할지 말지 결정하는 부분을 다시 한번 보자.

    if ( (size_t)so->fill*5 < mask*3)
        return 0;
    return set_table_resize(so, so->used>50000 ? so->used*2 : so->used*4);

masksize보다 1 작은 값이니 bucketsize-1로 바꿔쓸 수 있다.. 식을 보기 좋게 살짝 정리해보자.

    if ( (so->fill) < (size-1) * 0.6 )
        return 0;
    return set_table_resize(so, so->used>50000 ? so->used*2 : so->used*4);

해석해보자. size-1개의 bucket들 중에서

  • 60%보다 덜 차있다면, resize를 진행하지 않고 add 함수를 종료한다.
  • 60%보다 더 차있다면 resize를 진행한다.

** 최소한 CPython의 set의 load factor는 0.6에 아주 근사하다고 말할 수 있게 된 것이다.**

결론

우리는 긴 과정을 거쳐서 setadd를 할 때 사실은 resize를 할 때가 있기 때문에 Overhead를 동반한다는 것을 알게 되었다.

그렇기에 아주 naive하게 setaddO(1)이라는 건 엄밀하지 않다. add하는 과정에서 resize를 호출할때가 있고 , 그 과정에서 현재 element의 약 60% 개의 element들을 옮겨야 하니 , amortized O(1)이다.

우리가 이 상황을 왜 분석했을까? BFS 탐색 문제를 푸는데 시간초과가 나서이다.

그 원인은 add를 반복할때 resize에 있다는 것을 확실하게 알 수 있다.

그럼 어떻게?

List도 index 기반 접근을 하기 때문에 lookup에 대한 시간이 O(1)이다. 또 List를 처음 선언할때 메모리를 할당해주기 때문에, append를 쓰지 않는다면, resize가 필요하지 않다.

List도 내부 구조는 dynamic array이기 때문에 resize가 발생할 수 있다. appendamortized O(1)인 이유가 있다.

정말 특수한 상황을 제외하고는 List를 사용하는게 의문의 시간 초과 오류를 보지 않는 방법이다.

경험상 특수한 상황은 탐색에서 조건적으로 매우 크고 sparse한 범위의 방문을 할 때다.

모든 코드는 Python 3.8.10 [GCC 9.4.0] on linux 버전을 사용했습니다

참고자료

CPython Source Code
Python 메모리 관리 공식문서
Memory consumption of a list and set in Python
Load Factor and Rehashing
Time complexity for adding elements to list vs set in python

'Python' 카테고리의 다른 글

GIL 훔쳐보기  (1) 2022.09.18

문제 제목

경주로 건설

문제 설명


건설회사의 설계사인 죠르디는 고객사로부터 자동차 경주로 건설에 필요한 견적을 의뢰받았습니다.
제공된 경주로 설계 도면에 따르면 경주로 부지는 N x N 크기의 정사각형 격자 형태이며 각 격자는 1 x 1 크기입니다.
설계 도면에는 각 격자의 칸은 0 또는 1 로 채워져 있으며, 0은 칸이 비어 있음을 1은 해당 칸이 벽으로 채워져 있음을 나타냅니다.
경주로의 출발점은 (0, 0) 칸(좌측 상단)이며, 도착점은 (N-1, N-1) 칸(우측 하단)입니다. 죠르디는 출발점인 (0, 0) 칸에서 출발한 자동차가 도착점인 (N-1, N-1) 칸까지 무사히 도달할 수 있게 중간에 끊기지 않도록 경주로를 건설해야 합니다.
경주로는 상, 하, 좌, 우로 인접한 두 빈 칸을 연결하여 건설할 수 있으며, 벽이 있는 칸에는 경주로를 건설할 수 없습니다.
이때, 인접한 두 빈 칸을 상하 또는 좌우로 연결한 경주로를 직선 도로 라고 합니다.
또한 두 직선 도로가 서로 직각으로 만나는 지점을 코너 라고 부릅니다.
건설 비용을 계산해 보니 직선 도로 하나를 만들 때는 100원이 소요되며, 코너를 하나 만들 때는 500원이 추가로 듭니다.
죠르디는 견적서 작성을 위해 경주로를 건설하는 데 필요한 최소 비용을 계산해야 합니다.

예를 들어, 아래 그림은 직선 도로 6개와 코너 4개로 구성된 임의의 경주로 예시이며, 건설 비용은 6 x 100 + 4 x 500 = 2600원 입니다.

또 다른 예로, 아래 그림은 직선 도로 4개와 코너 1개로 구성된 경주로이며, 건설 비용은 4 x 100 + 1 x 500 = 900원 입니다.

도면의 상태(0은 비어 있음, 1은 벽)을 나타내는 2차원 배열 board가 매개변수로 주어질 때, 경주로를 건설하는데 필요한 최소 비용을 return 하도록 solution 함수를 완성해주세요.

제한 사항

  • board는 2차원 정사각 배열로 배열의 크기는 3 이상 25 이하입니다.
  • board 배열의 각 원소의 값은 0 또는 1 입니다.
  • 도면의 가장 왼쪽 상단 좌표는 (0, 0)이며, 가장 우측 하단 좌표는 (N-1, N-1) 입니다.
    원소의 값 0은 칸이 비어 있어 도로 연결이 가능함을 1은 칸이 벽으로 채워져 있어 도로 연결이 불가능함을 나타냅니다.
  • board는 항상 출발점에서 도착점까지 경주로를 건설할 수 있는 형태로 주어집니다.
    출발점과 도착점 칸의 원소의 값은 항상 0으로 주어집니다.

예제 입출력

입력

[[0,0,0],[0,0,0],[0,0,0]]    
[[0,0,0,0,0,0,0,1],[0,0,0,0,0,0,0,0],[0,0,0,0,0,1,0,0],[0,0,0,0,1,0,0,0],[0,0,0,1,0,0,0,1],[0,0,1,0,0,0,1,0],[0,1,0,0,0,1,0,0],[1,0,0,0,0,0,0,0]]    
[[0,0,1,0],[0,0,0,0],[0,1,0,1],[1,0,0,0]]    
[[0,0,0,0,0,0],[0,1,1,1,1,0],[0,0,1,0,0,0],[1,0,0,1,0,1],[0,1,0,0,0,1],[0,0,0,0,0,0]]    

입출력 예 #4

붉은색 경로와 같이 경주로를 건설하면 직선 도로 12개, 코너 4개로 총 3200원이 듭니다.
만약, 파란색 경로와 같이 경주로를 건설한다면 직선 도로 10개, 코너 5개로 총 3500원이 들며, 더 많은 비용이 듭니다.

출력

900
3800
2100
3200

풀이

처음 든 생각은 카카오다운 문제였다. BFS/DFS와 같은 어렵지 않은 탐색 풀이를 요구하지만 , naive하지 않고 여러가지 제약을 주고 문제가 귀엽고 문제 설명이 굉장히 친절하다는 것이다. 예시도 굉장히 많고 각각의 예시에 대해서 설명이 나와있기 때문에 코너케이스 방어에 쉽고 논리구조 쌓기가 수월하다.

그렇다고 마냥 쉬운 문제는 아니다.

사고의 과정

문제 독해를 천천히 해보면, 최단경로를 구하는 문제처럼 포장을 해놓았다. 하지만 바로 위에 언급한 입출력 #4를 보면 파란색 경로가 최단경로임을 알 수 있지만, 최단경로가 아닌 빨간색 경로가 가장 적은 비용으로 목표지점에 도달할 수 있는 경로다. naive하게 최단경로를 구하는 문제는 아닌것이다.

그렇다고 트리의 지름 같은 최장경로 문제일까? 전혀 아니다. 최소한 우리가 구하는 경로는 최단경로에 근접하지만, 그 안에서 비용을 고민해야 하는 문제다.

문제내에 명시된 비용의 제약사항은 아래와 같다.

인접한 두 빈 칸을 상하 또는 좌우로 연결한 경주로를 직선도로라고 합니다. 또한 두 직선 도로가 서로 직각으로 만나는 지점을 코너라고 부릅니다.

쉬운 말로 바꾸면, 경주로가 계속 오른쪽으로 가다가 위,아래 방향으로 바꾸면 되면 코너가 생긴다는 것이다. (왜 왼쪽이 제외되었는가는 아래에서 설명하겠다.) 다시 말해서 탐색의 방향이 전환되는 시점에서 코너가 생긴다는 것이고, 직전의 탐색의 방향이 중요하니 기록 할 필요가 있다.

이걸 본 순간 바로 떠오른 비슷한 문제가 있다. 백준의 로봇이라는 문제인데, 단순히 탐색이 아닌 이전 탐색에서의 방향이 지금의 탐색의 명령개수에 영향을 주는 문제다.

visit 배열에 이전 방향이 포함되어야하나?

직전 탐색의 방향이 중요하다는 것은, 직전 탐색에서의 상황이 단순 x,y좌표가 아니라 상,하,좌,우인 방향 역시 포함한다는 것이다. 일차원 DFS 탐색에 관한 이전 글에서 명시한 내용 중에서 BFS와 DFS든 노드의 방문여부 배열의 차원을 구성하는것은 노드를 방문할때의 상황의 개수에 따라 나뉘어 진다는것 이라는 부분이 있다.

그러면 (x,y)를 방문했는지 체크하는 visit 배열이 직전 방향까지 포함한 3차원이여야 할것같다.

visit[y][x][prev_dir] =이전 방향이 prev_dir인 상태로 (x,y)를 방문했는지

아마 탐색할때 이런 식으로 검사하면 같은 방향 접근시 중복 방문을 막을 수 있다.

if 0<=ny<n and 0<=nx<n and board[ny][nx]!=1 and not visit[ny][nx][dir]:
    visit[ny][nx][dir]=True
    q.append((ny,nx,dir))

확실하게 틀리는 반례

문제 해결!!! 이면 참 좋겠지만, 예시조차 통과하지 못한다. 그 이유는 아래와 같다.

위의 그림에서 파란색 경로와 빨간색 경로가 있다고 할때 빨간색 경로가 최소비용을 가지는 경로다. 주의 깊게 봐야할 부분은 초록색 박스를 친 부분이다.

만약 파란색 경로가 빨간색 경로보다 먼저 수행된다고 한다면 , (3,1)에서 (3,2) 으로 내려올때 visit[2][3][up]의 값은 False에서 True로 바뀌게 된다. (3,2) 를 위에서 아래로 내려오는 방문은 추후 탐색에서 방문 불가능한 상태인것이다.

그런데 빨간색 경로는 정확하게 (3,2) 로 내려오는 경로를 포함하고 있다. visit[2][3][up]을 체크하게 되는데, 이전 탐색인 파란색 경로에서 이미 True로 저장했기 때문에 방문할수 없게 되는 것이다. 최소 비용이 아닌 경로가 미리 방문했기 때문에 최소 비용을 가지는 경로가 정상적인 탐색을 진행할 수 없게 되는 것이다.

그러면 어떻게?

그러면 어떻게 해야하는 걸까 ? 모든 가능한 경로마다 방문했던 좌표를 기록해야 하나? 그러면 확실하게 답을 구할 수 있다. 파란색이 방문한 (3,2) 와 빨간색이 방문한 (3,2) 는 다르기 때문이다. 하지만 공간 복잡도가 지수만큼 증가하기에 적절하지 않다.

문제를 다시 천천히 읽어보면, 각각의 좌표들에 대해서 최소 비용경로로 접근해야하는 것이다. 위의 예시를 다시 보면, (3,2) 를 방문할때 빨간색 경로의 비용과 파란색 경로의 비용을 비교하면 빨간색 경로의 비용이 더 적다. 그 말은 이미 파란색 경로에서 (3,2) 를 방문했더라도, 빨간색 경로가 더 적은 건설비용을 계산 할 수 있으면 (3,2) 를 재방문하게 해야 한다. 조건적인 재방문을 허용할 필요가 있고 visit 배열을 재정의해야 한다.

visit 배열의 DP적 특성

visit[y][x] = (x,y)를 방문했을때의 비용, (x,y)를 방문할때 비용이 visit[y][x]보다 작거나 같으면 재방문 가능

이렇게 정의하고 다시 문제를 보자. 모든 노드들간의 간선이 양의 가중치를 갖는 그래프에서의 DP와 BFS문제로 볼 수 있다.

결국에 문제의 핵심은 (y,x)로 도착하는 최소 비용 경로가 새로 있다면, 중복 방문을 허용하는 아이디어다. 위에 살짝 언급한 아래 사례는 visit 배열의 특성을 생각하면 자명하게 풀린다.

경주로가 계속 오른쪽으로 가다가 위,아래 방향으로 바꾸면 되면 코너가 생긴다는 것이다. (왜 왼쪽이 제외되었는가는 아래에서 설명하겠다.)

오른쪽으로만 가다가 왼쪽으로 방향을 트는 것은 , 왔던 곳을 되돌아 가는 것이고, 모든 좌표끼리의 이동은 양의 가중치를 갖기 때문에 되돌아 갈때는 왔던 것보다 무조건 큰 비용을 갖기 때문에 왼쪽 방향으로 이동을 고려할 필요가 없는 것이다.

visit[y][x]의 비용보다 같을때도 중복 방문을 허용해야 하는 이유가 뭘까?
간단히 생각해서 최소비용 경로가 무조건 (3,3) 을 방문해야 한다고 해보자. (3,3) 을 직선으로 방문할때가 있고, 꺾어서 방문할때가 있다. 만약 (3,3) 을 방문하기 전까지의 둘의 비용이 같다고 할때 ,먼저 꺾어서 방문하게 되는 경우에는 추후에 추가적으로 코너 건설 비용이 500만큼 더 들기 때문에 최소 비용 거리가 아닐 수 있다. 그렇기에 비용이 같을때 방문을 막게 된다면 일직선으로 (3,3) 을 방문하는 최소비용 거리를 구할수 없다.

나머지 부분은 단순 구현이기 때문에 설명은 생략한다.

풀이코드

from collections import deque
def solution(board):
    n=len(board)
    answer = 1e9
    # 최단거리가 최소값이 아닐 수 있다. 하지만 근접하게 풀어야한다.

    dy=[-1,1,0,0]
    dx=[0,0,-1,1]

    q=deque()
    # visit 배열을 모두 INF로 채움
    visit=[[float('inf') for _ in range(n)]for _ in range(n)]

    q.append((0,0,0,-1))

    while q:
        y,x,cost,prev_dir=q.popleft()

        # 도착했다면
        if y==n-1 and x==n-1:
            answer=min(answer,cost)
            continue


        for k in range(4):
            # 이전 방향과 다르다면 코너 건설비용이 추가로 듬
            curve_cost=0 if prev_dir==k else 500

            new_cost=cost+100+curve_cost

            ny,nx=y+dy[k],x+dx[k]

            # (nx,ny) 방문시 이전 방문보다 적은 비용으로 방문하면
            if 0<=ny<n and 0<=nx<n and board[ny][nx]!=1 and new_cost<=visit[ny][nx]:
                visit[ny][nx]=new_cost
                q.append((ny,nx,new_cost,k))
    # 초기 출발시에는 방향이 없으니 무조건 코너를 돌게 하는데
    # 불필요하게 500의 초과 비용이 발생하니 빼줘야함
    return answer-500

백준 난이도로 환산하면 골드4 정도로 보인다.

아주 깔끔하고 재밌는 문제였다. 30~40분 내로 풀이하면 적절하다.

bottom up DP로 풀어도 충분히 답이 나올것같다.

본 글은 Do I need an interface with Spring boot?을 번역한 글입니다.

잘 쓰여진 글을 정리 하는 겸 한글로 공유하고 싶어서 번역했습니다.

들어가면서

Spring boot를 사용하다보면, 종종 service (@Service annotation을 붙인 bean)을 사용하게 된다. 인터넷 상의 많은 예시에서, 사람들이 service들을 위해서 interface를 사용하는 걸 볼 수 있을것이다. 예를 들어서 , 우리가 todo 어플리케이션을 만든다고 할때, TodoService라는 interfaceTodoServiceImpl이라는 구현체를 만들때가 있다.

이 포스트에서, 우리는 왜 그런 것을 하는지와 필요한가에 대해서 알아볼 것이다.

짧은 결론은

짧은 결론은 꽤나 간단하다. interface를 만들 필요가 없다.
service를 만든다고 하면, class의 자체의 이름을 TodoService라고 하고 autowire를 통해서 bean들에 주입하면 된다.
예를 들어서 이런 코드가 있다고 해보자.

@Service
public class TodoService {
    public List<Todo> findAllTodos() {
        // TODO: Implement
        return new ArrayList<>();
    }
}

@Component
public class TodoFacade {
    private TodoService service;

    public TodoFacade(TodoService service) {
        this.service = service;
    }
} 

위에 있는 예시는 @Autowired를 이용한 field injection을 사용하던 생성자 주입을 사용하던간에 작동할 것이다.

그럼 왜 신경써야할까?

만약, 우리가 그게 필요하지 않다면... 왜 그런 방식(inteface를 이용한 방식)을 종종 쓰곤 할까?
음, 첫 번째 이유는 사실 좀 역사적인것이다. 하지만 그걸 살펴보기 전에 , Spring에서 annotation이 어떻게 작동하는지를 설명해야만 한다.

만약 @Cacheable같은 annotation을 사용한다고 하면, cache에서 결과를 얻을것이라고 예상할 수 있다. Spring에서 그것이 작동되는 방식은 bean들을 위한 proxy를 만들고 그 proxy들에 필요한 로직을 추가해주는것이다. 원래 스프링은 JDK dynamic proxies를 사용했다. 이 dynamic proxies는 오직 interface들만을 위해서 만들어졌고, 이것이 예전에는 interface를 작성해줘야 했던 이유다.

그러나, 10여 년 전부터 , Spring이 CGLIB proxying도 지원하기 시작했다. 이 proxy들은 별도의 interface를 필요로 하지 않는다. 심지어 Spring 3.2 버전부터는 CGLIB가 Spring에 내장되어 있어서 별도로 추가해줄 필요도 없다.

느슨한 결합

아마 두 번째 이유는 두 class 간의 느슨한 결합을 만들기 위해서 일 것이다. interface를 사용함으로써, service에 의존하는 class는 더 이상 service의 구현에 의존하지 않게 된다. 이것이 service를 독립적으로 사용할 수 있게 해준다. 예를 들어서 이런 코드가 있다.

public interface TodoService {
    List<Todo> findAllTodos();
}

@Service
public class TodoServiceImpl {
    public List<Todo> findAllTodos() {
        // TODO: Implement
        return new ArrayList<>();
    }
}

@Component
public class TodoFacade {
    private TodoService service;

    public TodoFacade(TodoService service) {
        this.service = service;
    }
}

그러나 위의 예시에서, 개인적인 의견으로 TodoFacadeTodoServiceImpl이 함께 한다고 생각한다. 여기서 interface를 추가하는건 추가적인 복잡도를 늘릴 수 있다. 개인적으로, 그만한 가치는 없어 보인다.

여러 방식의 구현

느슨한 결합이 유용한 부분은 여러 가지 구현체를 가질 때이다. 예를 들어서 TodoService가 두 가지 구현체를 가진다고 해보자. 하나는 todo 리스트를 메모리에서 가져오는 것이고, 하나는 DB와 같은 곳에서 가져오는 것이다.

public interface TodoService {
    List<Todo> findAllTodos();
}

@Service
public class InMemoryTodoServiceImpl implements TodoService {
    public List<Todo> findAllTodos() {
        // TODO: Implement
        return new ArrayList<>();
    }
}

@Service
public class DatabaseTodoServiceImpl implements TodoService {
    public List<Todo> findAllTodos() {
        // TODO: Implement
        return new ArrayList<>();
    }
}

@Component
public class TodoFacade {
    private TodoService service;

    public TodoFacade(TodoService service) {
        this.service = service;
    }
}

이런 경우에선 느슨한 결합이 매우 유용한데, TodoFacade가 todo가 메모리에 저장되어 있는지 DB에 저장되어 있는지 알 필요 없기 때문이다. 그건 Facade의 책임이 아니라 어플리케이션 설정의 책임이다.

원하는 것에 따라서 구현방식은 달라진다. 만약에 TodoFacade가 모든 구현체를 호출해야 한다면, collection을 주입해야 한다.

@Component
public class TodoFacade {
    private List<TodoService> services;

    public TodoFacade(TodoService services) {
        this.services = services;
    }
}

만약 구현체 중에 하나가 99%의 상황에서 사용되고 나머지들은 아주 특수한 경우에만 사용된다면, @Primary를 사용해라.

@Primary
@Service
public class DatabaseTodoServiceImpl implements TodoService {
    public List<Todo> findAllTodos() {
        // TODO: Implement
        return new ArrayList<>();
    }
}

@Primary를 사용함으로써, Spring container에게 TodoService에 의존성 주입을 해야할때, 이 구현체를 사용하라고 알려주는 것이다. 만약 다른 걸 사용해야 한다면, @Qualifier를 사용하거나 특정 구현체를 주입함으로써 명시적으로 설정해야 한다. 개인적으로 난 이런 방식을 분리된 @Configuration class에서 사용하는데, 그렇지 않으면 , TodoFacade를 또 다시 구현체에 관한 정보들로 오염시키기 때문이다.

예시 코드를 보자.

@Configuration
public class TodoConfiguration {
    @Bean
    // Using @Qualifier
    public TodoFacade todoFacade(@Qualifier("inMemoryTodoService") TodoService service) {
        return new TodoFacade(service);
    }

    @Bean
    // Or by using the specific implementation
    public TodoFacade todoFacade(InMemoryTodoService service) {
        return new TodoFacade(service);
    }
}

제어의 역전

느슨한 결합의 또 다른 방식은 IoC 혹은 제어의 역전이다. 개인적으로 서로에게 의존하는 여러 가지 module을 사용할 때 제어의 역전이 유용했다. 예를 들어서 OrderServiceCustomerService가 있다고 해보자. Customer는 자신의 profile을 삭제할 수 있고 그때 pending 상태의 order들은 취소되어야 한다. interface 없이 구현했다면, 이런 방식으로 할것이다.

@Service
public class OrderService {
    public void cancelOrdersForCustomer(ID customerId) {
        // TODO: implement
    }
}

@Service
public class CustomerService {
    private OrderService orderService;

    public CustomerService(OrderService orderService) {
        this.orderService = orderService;
    }

    public void deleteCustomer(ID customerId) {
        orderService.cancelOrdersForCustomer(customerId);
        // TODO: implement
    }
}

이렇게 한다면, 상황은 매우 나빠질 수 있다. 어플리케이션 내부의 domain들이 모두 결합되게 되고, 결과적으로 강하게 결합된 어플리케이션을 만들게 될것이다.

그러는 대신에, CustomerDeletionListener라는 interface를 만들 수 있다.

public interface CustomerDeletionListener {
    void onDeleteCustomer(ID customerId);
}

@Service
public class CustomerService {
    private List<CustomerDeletionListener> deletionListeners;

    public CustomerService(List<CustomerDeletionListener> deletionListeners) {
        this.deletionListeners = deletionListeners;
    }

    public void deleteCustomer(ID customerId) {
        deletionListeners.forEach(listener -> listener.onDeleteCustomer(customerId));
        // TODO: implement
    }
}

@Service
public class OrderService {
    public void cancelOrdersForCustomer(ID customerId) {
        // TODO: implement
    }
}

@Component
public class OrderCustomerDeletionListener implements CustomerDeletionListener {
    private OrderService orderService;

    public OrderCustomerDeletionListener(OrderService orderService) {
        this.orderService = orderService;
    }

    @Override
    public void onDeleteCustomer(ID customerId) {
        orderService.cancelOrdersForCustomer(customerId);
    }
}

예시를 보면, 제어의 역전이 일어난 것을 볼 수 있다. 첫 번째 예시에서 우리가 OrderService 안에 있는 cancelOrderForCustomer()를 바꾸면, CustomerService 역시 바뀌어야 한다. 이 말은 OrderService가 제어되고 있다는 것을 말한다.

두 번째 예시에서는 OrderService가 제어되고 있지 않다. 우리가 cancelOrderForCustomer()를 변화시키면, 다른 module의 일부인 오직 OrderCustomerDeletionListener만 바뀌어야 한다. 이것은 CustomerService가 제어하고 있음을 말한다. 또, 두 service들은 느슨하게 결합되어 있기 때문에, 하나가 다른 하나에 직접적으로 의존하고 있지 않다.

비록 두 번째 방법이 복잡도를 더 늘리긴 하지만 (classinterface가 각각 한개씩 늘었으니) domain들이 서로 결합되지 않게 해준다. 리팩토링 하기가 쉬워지는 것이다. 이 listenerevent-driven한 구조로 리팩토링 될 수 있다. domain-driven modular design이나 MSA같은 구조로 리팩토링하기 쉽게 해주는 것이다.

Test

마지막으로 말하고 싶은 건 테스트다. 몇몇 사람들은 dummy 구현체를 가지기 위해서 (여러 구현체를 가질 수 있으니) interface가 필요하다고 주장하곤 한다. 하지만 Mockito같은 mocking 라이브러리가 이 문제를 해결해 준다.

단위 테스트를 작성할 때, MockitoExtension을 사용할 수 있다.

@ExtendWith(MockitoExtension.class)
public class TodoFacadeTest {
    private TodoFacade facade;
    @Mock
    private TodoService service;

    @BeforeEach
    void setUp() {
        this.facade = new TodoFacade(service);
    }

    // TODO: implement tests
}

이 방법은 service가 무엇을 하는지 몰라도 facade를 적절히 테스트할 수 있게 해준다. Mockito.when()을 사용함으로써 service mock이 무엇을 반환하게 하는지 제어할 수 있고, Mockito.verfiy()를 사용함으로써 특정 method가 호출되었는지 확인할 수 있다.
예시 코드다.

@Test
void findAll_shouldUseServicefindAllTodos() {
    Todo todo = new Todo();
    when(service.findAllTodos()).thenReturn(todo);
    assertThat(facade.findAll()).containsOnly(todo);
    verify(service).findAllTodos();
}

심지어 Spring container를 필요로 하는 통합 테스트를 작성할때도,@MockBean annotation을 이용해서 bean들을 mock할 수 있다. 실제 구현체가 있는 package를 탐색하지 않게 해라.

@ExtendWith(SpringExtension.class)
@SpringBootTest(classes = TodoFacade.class)
public class TodoFacadeTest {
    @Autowired
    private TodoFacade facade;
    @MockBean
    private TodoService service;
}

그러니까 대부분의 경우에서, 테스트 할때 interface는 필요하지 않다.

결론

만약 개인적으로 interfaceserivce에 사용해야 하냐는 질문을 받는다면, 내 대답은 아니오다. 유일한 예외는 제어의 역전을 사용하거나 여러개의 구현체를 신경써야 하는 경우다.

만약의 경우를 위해서 interface를 만드는 게 좋지 않겠냐고 생각할 수 있다. 개인적으로 여전히 아니오다.
첫 번째로, "You aren't going to need it"(YAGNI) 라는 원칙을 믿는다. 필요할지도 몰라 라는 이유로 복잡성을 높일 이유는 없는데 , 일반적으로 필요하지 않기 때문이다.
두 번째로 필요한 경우라도 전혀 문제 없다. 대부분의 IDE들은 기존의 class에서 method만 추출해서 interface를 만들수 있게 해주고, 모든 코드들을 그 interface를 사용하게끔 순식간에 만든다.

참고하면 좋은 자료

JDK Dynamic Proxy와 CGLIB의 차이점은 무엇일까?
퍼사드 패턴
느슨한 결합 vs 긴밀한 결합
How Mockito Works?
소프트웨어 개발 3대 원칙 : KISS,YAGNI,DRY

스프링 부트에 interface가 필요한가에 대해서는 당연히 YES지만 이 글에선 serviceinterface 가 필요한지, 정확히 말하면 service의 구현체가 필요한지에 대해서 논하고 있습니다.

프로젝트를 시작하면서 Spring boot에서 구현체와 인터페이스를 구분해야하는지 고민이 많았는데 꽤나 자세하고 명쾌해서 도움이 되었습니다.

소프트웨어공학 수업에서 배운 YAGNI를 실제로 보니까 반갑네요. 그냥 무지성으로 외웠는데..

글이 매우 복잡하고 깁니다. 양해 부탁드립니다.

틀린 정보나 이해가 가지 않는 부분은 댓글 남겨주시면 참고하겠습니다.

HandlerMapping의 역할

Spring MVC에 대해서 공부하던 중, HandlerMapping이 request를 처리하기에 적절한 handler를 찾아온다는 설명을 들었다.
좀 더 찾아보니 HandlerMappingrequest의 URL과 매칭되는 handler를 선택하는 역할을 수행한다 는 것을 보았다.

request의 URL만 보고 어떻게 찾아온다는 것일까? 그리고 찾아진 handler는 method인데 어떠한 방식으로 가져온다는 것일까?

한가지만 기억하고 가자.
HandlerMapping은 원하는 handler를 찾아오는 역할을 수행한다.

Spring MVC Request flow

HandlerMapping의 역할에 대해서 살펴보기 전에 Spring MVC에서 request가 어떠한 순서로 처리되는지 먼저 보아야 한다.

처리 순서

  1. 먼저 front-controller의 역할을 하는 DispatcherServlet이 request를 받는다.
  2. DispatcherServlet은 적절한 controller를 선택하는 일을 HandlerMapping에게 요청한다.
  3. HandlerMapping은 적합한 controller를 선택한다.
  4. DispatcherServlet은 선택된 controller의 비즈니스 로직 실행 작업을 HandlerAdapter에게 위임한다.
  5. HandlerAdpater가 controller의 비즈니스 로직을 호출하고 결과를 ModelAndView 객체에 담아서 DispatcherServlet이 에게 return한다.
  6. DispatcherServletViewResolver를 이용하여 결과를 보여줄 View를 가져온다.
  7. View 객체에게 DispatcherServlet이 응답 결과 생성을 요청한다.

이 긴 과정 속에서 이 글에서 살펴볼 과정은 2,3번이다.
Request flow 순서대로 HandlerMapping에 대해서 알아볼 것이다.

DispatcherServlet

먼저 DispatcherServlet에서 부터 출발해야한다. 상속구조부터 보면,

public class DispatcherServlet extends FrameworkServlet
            ↓
public abstract class FrameworkServlet extends HttpServletBean implements ApplicationContextAware
            ↓
public abstract class HttpServletBean extends HttpServlet
            ↓
public abstract class HttpServlet extends GenericServlet

이렇게 상속구조를 통해 DispatcherServlet은 결국 HttpServlet을 상속함을 알 수 있다.
그렇기 때문에 DispatcherServletServlet의 생명주기와 비슷하게 흘러감을 알 수 있다. (init(),doGet(),doPost(),service() 등등)

실제로 디버깅을 해보면, doService가 호출된다.
그 후 DispatcherServletfront-controller 역할을 하기 때문에 doDispatch를 호출한다.

protected void doService(HttpServletRequest request, HttpServletResponse response) throws Exception {
...
...
    try {
    doDispatch(request, response);
    }
...
...
}

doDispatch의 javadoc을 보면 Servlet의 HandlerMapping을 순서대로 처리하여 handler를 가져온다고 되어있다.

Process the actual dispatching to the handler. The handler will be obtained by applying the servlet's HandlerMappings in order. The HandlerAdapter will be obtained by querying the servlet's installed HandlerAdapters to find the first that supports the handler class.

doDispatch의 실제 코드를 보면 아래처럼 request에 대해서 handler를 가져오는 getHandler 함수를 호출하고 있다.

protected void doDispatch(HttpServletRequest request, HttpServletResponse response) throws Exception {
...
...
    try {
    ...
        // Determine handler for the current request.
    mappedHandler = getHandler(processedRequest);
    ...
...
...

getHandler 함수는 DispatcherServlet의 method로 아래와 같다.
이게 실제로 적절한 handler를 가져오는 방식인데 전혀 감이 안온다. 하나하나 풀이해보자.

@Nullable
protected HandlerExecutionChain getHandler(HttpServletRequest request) throws Exception {
  if (this.handlerMappings != null) {
    for (HandlerMapping mapping : this.handlerMappings) {
      HandlerExecutionChain handler = mapping.getHandler(request);
      if (handler != null) {
        return handler;
      }
    }
  }
  return null;
}

DispathcerServlet은 처음 init되는 과정에서 여러가지 handlerMapping들을 등록하고 List를 통해 handlerMappings라는 이름으로 관리하고 있다. handelrMappings안에는 여러가지 handlerMapping들이 등록되어 있는 것이다.

그러므로 아래 코드는 DispatcherServlet 안에 handlerMapping들이 등록되었다면 이라는 뜻이다.

if(this.handlerMappings!=null)

등록되어있는 HandlerMapping들을 loop 하면서

for(HandlerMapping mapping : this.handlerMappings){

HandlerMapping들에게 request에 맞는 handler를 가져오게하고, 가져왔다면 그 handler를 return하는것이다.

  HandlerExecutionChain handler=mapping.getHandler(request);
  if (handler!=null)
    return handler;

핵심 부분은 HandlerMapping에게 request에 맞는 handler를 가져오는 부분이다. 이게 궁금해서 이 먼 길을 돌아온 것이다.

DispatcherServlet부분의 내용을 정리하자면,

  1. doService이 호출된다.
  2. doService내에서 doDispatch가 호출된다.
  3. doDispatch내에서 getHandler가 호출된다.
  4. getHandler내에서 등록된 HandlerMapping 중에서 request에 걸맞는 handler를 가져온다.

이제 거의 다왔다.

HandlerMapping이 handler를 가져오는 과정

HandlerMappinginterface로 함수의 선언부만 가지고 있다.

public interface HandlerMapping {
    HandlerExecutionChain getHandler(HttpServletRequest request) throws Exception;
}

실제로 handler를 가져오는 getHandler는 추상 클래스인 AbstractHandlerMapping에 정의되어 있다.

우리가 흔히 아는 RequestMappingHandlerMapping,SimpleUrlHandlerMapping 같은 것들의 부모(바로 윗단계는 아니지만)가 AbstratHandlerMapping이다.

아래는 AbstractHandlerMappinggetHandler 코드이다.
getHandlerInternal을 통해서 handler을 찾아오고, HandlerExecutionChain을 return하는데,
우리가 원하는건 handler를 찾아오는 방식이므로 getHandlerInternal을 봐야겠다.

HandlerExecutionChain은 간단하게 handler와 handler interceptor들을 모아놓은 것이다.
Handler execution chain, consisting of handler object and any handler interceptors.

public final HandlerExecutionChain getHandler(HttpServletRequest request) throws Exception {
  Object handler = getHandlerInternal(request);
  ...
  HandlerExecutionChain executionChain = getHandlerExecutionChain(handler, request);
  ...
  return executionChain;
}

getHandlerInternalAbstractHandlerMapping을 상속한 AbstractHandlerMethodMapping에 정의되어 있다.
AbstractHandlerMethodMapping은 복잡하지만 이런 구조를 가지고 있다.

아래는 getHandlerInternal의 코드다. 이번에도 차근차근 살펴보자.

public abstract class AbstractHandlerMethodMapping<T> extends AbstractHandlerMapping {
...
...
// Look up a handler method for the given request.
protected HandlerMethod getHandlerInternal(HttpServletRequest request) throws Exception {
  String lookupPath = getUrlPathHelper().getLookupPathForRequest(request);
  this.mappingRegistry.acquireReadLock();
  try {
    HandlerMethod handlerMethod = lookupHandlerMethod(lookupPath, request);
    return (handlerMethod != null ? handlerMethod.createWithResolvedBean() : null);
    }
  finally {
    this.mappingRegistry.releaseReadLock();
  }
}
...
...
}

먼저 javadoc을 보면 주어진 request에 대한 handler method를 찾습니다. 라고 되어있다.
동작원리의 핵심적인 부분인것이다.

Look up a handler method for the given request.

lookupPath는 현재 servlet mapping 안에서의 검색경로인데, request 요청을 분석해서 얻을 수 있다.
그리고 mappingRegistry에 대한 ReadLock을 가져오고 있다.

String lookupPath = getUrlPathHelper().getLookupPathForRequest(request);
this.mappingRegistry.acquireReadLock();

lookupPath를 바탕으로 lookupHandlerMethod를 통해서 적절한 handlerMethod를 가져온 후 return 한다.

handlerMethod가 바로 우리가 직접 Controller 안에 정의한 함수인것이다.

try {
  HandlerMethod handlerMethod = lookupHandlerMethod(lookupPath, request);
  return (handlerMethod != null ? handlerMethod.createWithResolvedBean() : null);
}

정리해보자면, DispatcherServlet 함수 안에서 handlerMapping이 여러 과정을 거쳐서 적절한 handlerMethod 를 가져온다는것은 알 수 있다.

그러나 궁금증이 더 남아있다.

url에 해당하는 적절한 method를 구별하는 방법과, method를 가져오는 것이 여전히 궁금하다.
각각 MappingRegistryReflection이 답이다.

나머지 궁금증은 2편에서 마저 다루도록 한다.

출처

Interceptor 사용법 : Request flow에 대해서 잘 정리되어 있었다.
AbstractHandlerMethodMapping javadoc
MappingReigstry javadoc
LinkedMultiValueMap javadoc

본 글은 Strategy Design Pattern with in Spring Boot application.을 번역한 글입니다.

잘 쓰여진 글을 정리 하는 겸 한글로 공유하고 싶어서 번역했습니다.

전략 디자인 패턴은 실행 중에 알고리즘을 선택하게 해주는 행동 디자인 패턴이다.

전략 디자인 패턴의 의도는 다음과 같다 :
"알고리즘 집합을 선언하고, 각각을 캡슐화하며 그것들을 교체가 가능하게 만든다. 전략 패턴은 알고리즘을 사용하는 유저와는 독립적으로 알고리즘을 다양하게끔 한다."

UML Class와 sequence diagram

전략 패턴의 다이어그램
전략 디자인 패턴을 설명하고 다양한 언어로 그것을 구현하는 많은 글들이 시중에 있다.
이 글의 목적은 스프링 부트 어플리케이션에서 전략 패턴을 어떻게 구현하는지 알려주는 것이다.

스프링 부트

스프링 부트는 Java microservice 개발의 실질적 표준이 되었다. 스프링 부트 어플리케이션에서 자주 쓰이는 디자인 패턴들을 어떻게 구현하는지 아는 것은 유용할 것이다.

스프링은 의존성 주입을 위해 @Autowired annotation을 도입했다. 모든 스프링 구성요소는 주입이 가능하다. 구성요소에는 components, configurations, services, bean들이 있다.

이 글에서 우리는 의존성 주입에 전략 디자인 패턴을 구현할 것이다.

첫번째로, 우리는 전략 패턴에 필요한 알고리즘 집합을 구현하면서 시작한다.
아래가 전략 패턴을 위한 인터페이스다. 그리고 enum으로 정의된 StrategyName을 이용해서 각각의 전략을 구분한다.

public interface Strategy{
    void doStuff();

    StrategyName getStrategyName();
}

public enum StrategyName{
    StrategyA,
    StrategyB,
    StrategyC
}

아래는 전략 패턴을 위한 세가지 알고리즘이다. 각각의 알고리즘은 StrategyName을 기준으로 구분된다. 전략들을 구분하는데 있어서 String보다는 enum을 사용하는 것이 더 좋다.

@Component
public class StrategyA implements Strategy{

    @Override
    public void doStuff(){
        // 알고리즘 A 구현하기
    }

    @Override
    public StrategyName getStrategyName(){
        return StrategyName.StrategyA;
    }
}

@Component
public class StrategyB implements Strategy{

    @Override
    public void doStuff(){
        // 알고리즘 B 구현하기
    }

    @Override
    public StrategyName getStrategyName(){
        return StrategyName.StrategyB;
    }
}

@Component
public class StrategyC implements Strategy{

    @Override
    public void doStuff(){
        // 알고리즘 C 구현하기
    }

    @Override
    public StrategyName getStrategyName(){
        return StrategyName.StrategyC;
    }
}

이제 우리는 StrategyFactory를 다른 스프링 bean으로 만들고, 모든 전략을 factory에 주입한다. 여기서 StrategyFactory를 구성할 때 전략들을 Map을 이용해서 저장하는데, 이건 전략들을 lookup하는데 $$O(1)$$이 걸리게끔 한다.

@Component
public class StrategyFactory {
  private Map<StrategyName, Strategy> strategies;

  @Autowired
  public StrategyFactory(Set<Strategy> strategySet) {
     createStrategy(strategySet);
  }

  public Strategy findStrategy(StrategyName strategyName) {
     return strategies.get(strategyName);
  }
  private void createStrategy(Set<Strategy> strategySet) {
      strategies = new HashMap<StrategyName, Strategy>();
      strategySet.forEach( 
   strategy ->strategies.put(strategy.getStrategyName(), strategy));
  }
}

이제 StrategyFactory@Autowired를 이용해서 주입받을 수 있게 되었다. 아래가 StrategyFactory를 사용한 예제 코드다.

@Service
public class SomeService {
    @Autowired
    private StrategyFactory strategyFactory;

    public void findSome(){
    // 이름을 전달해서 전략을 가져올 수 있다.
    Strategy strategy = strategyFactory.findStrategy(StrategyName.StrategyA);

    // 이제 전략에 정의된 메소드를 호출할 수 있다.
    strategy.doStuff();
    }
}

결론

지금까지 스프링 부트 어플리케이션에서 의존성 주입을 전략 패턴을 이용해서 하는 방법을 살펴보았다.

더 많은 정보는 The Strategy design pattern에서..

첫 번역글인데 영문 어체를 한국어로 어색하지 않게 전달하는게 쉽지 않네요.

전략 패턴을 사용하면 실행 중에 알고리즘을 교체할 수 있는데 원글에서 그런 부분 설명이 빠진점이 아쉽습니다.

+ Recent posts