항해99 10주차 WIL - SSE 와 ConcurrentHashMap

Ming-Gry·2022년 11월 27일
1

항해99 WIL

목록 보기
10/12

실전 프로젝트의 3주차 과정을 거치며 MVP 중간 발표까지 마쳤다. 발표 자료는 현재의 아키텍쳐, 기술적 의사 결정과 추후 도전 계획에 대해 발표하였다. 발표 내용은 아래와 같다.

중간 발표 시연 영상 : https://youtu.be/r061p4BKMb4

3주차에 가장 신경 쓴 부분은 SSE 에 대한 내용이었다. 그러나 발표 내용에는 들어가지 않은 이유는 사실 내가 설명할 자신이 없어서였다... 물론 WebSocket 과 SSE 의 차이에 대한 것은 충분히 발표할 수 있었지만 내부 구현과 ConcurrentHashMap 에 대해 자세히 설명해보라고 하면 자신이 없었다. SSE 가 어떤 것이고, SSE 객체에 대해서는 어느 정도 이해를 한 것 같은데, 솔직히 말하면 코드를 거의 가져다 쓴 수준이고 내부 구현은 아직도 설명이 어렵다.

그래서 다시 공부하는 겸 WebSocket 과 SSE 의 차이, ConcurrentHashMap 에 대해서 포스팅해보고자 한다. SSE 알림 기능 구현은 아래의 포스팅을 참고하자. 다른 버전도 있으나 ConcurrentHashMap 을 쓴 이 버전이 공부하는 사람 입장에서는 더 좋다고 생각한다.

알림 기능을 구현해보자 - SSE(Server-Sent-Events)! : https://gilssang97.tistory.com/69

1) SSE

1-1) SSE 란?

SSE 란 Server Sent Event 의 약자로, 서버에서 보내는 이벤트라는 뜻이다. 서버에서 클라이언트로 일방적으로 보내주는 방식인데 실시간 알림 기능이라고 하면 폴링, 스트리밍, 웹 소켓 등으로도 구현이 가능하지만 우리가 구현하려는 기능은 클라이언트로부터 따로 받아서 처리할 데이터가 없으므로 SSE 로 알림 기능을 구현하였다. 사실 이것도 프론트엔드에서 시간 부족으로 백엔드에서 코드 짜서 Postman 테스트 정도만 했지 서비스에서 구현하진 못했다ㅠㅠ

내가 생각한 SSE 알림 기능의 플로우는 다음과 같다.

  1. 유저의 활동에 따라 뱃지가 지급됨
  2. 뱃지 생성 실시간 알림 팝업이 뜸 (SSE 로 구현, 알림은 저장되지 않음)
  3. 알림 팝업에 이벤트 피드 생성 여부를 확인함
  4. 이벤트 피드 확인 버튼을 누르면 생성된 뱃지에 따라 이벤트 피드가 생성됨

물론 이벤트 피드 생성을 위해 팝업에는 관련 API 가 포함된 버튼이 있어야되긴 할 것이다. 그러나 그 버튼을 누른다면 http 로 받으면 된다는 생각이었고, 굳이 WebSocket 을 쓸 필요는 없다고 생각했다. 일방적으로 서버에서 실시간 알림을 생성해서 띄워주면 된다는 생각이었다.

1-2) 다른 방식과 비교

http 프로토콜의 특징 중 하나는 비연결성이다. 아예 연결이 되어있지 않다라는 뜻이 아니라 클라이언트가 1회 요청하면 1회 응답하고 연결이 종료된다는 뜻이다. 그렇기 때문에 실시간 알림 기능에서는 그 외에 다른 방식을 사용해야 한다.

위의 블로그에 워낙 정리가 잘 되어 있어 사진도 가져왔다.

출처 - 알림 기능을 구현해보자 - SSE(Server-Sent-Events)! : https://gilssang97.tistory.com/69

폴링

위에서 말했듯이 http 는 비연결성을 띈다. 이를 해결하기 위해 주기적으로 서버에 요청을 날려 어떤 이벤트가 발생한다면 이에 대해 응답해주는 방식이 폴링이다. 서버에 요청을 날리는 순간에는 Connection 이 생성되어 연결성이 생기기 때문이다.

그러나 클라이언트가 늘어나고, 요청이 많아지면서 서버에는 많은 부하가 갈 수밖에 없다. 또한 지속적으로 Connection 을 맺고 끊는 것에 대한 서버의 부담이 커진다. 특히나 Connection 이 끊기고 새로 생기는 도중에 Event 가 발생하면 클라이언트는 해당 Event 를 놓치게 된다. 실시간성을 보장받을 수 없는 것이다.

롱폴링

폴링과 마찬가지로 주기적으로 서버에 요청을 날리는 것은 같으나, Connection 을 열어두는 시간을 더 늘리는 것을 말한다. 아무래도 Connection 시간이 길다보니 요청의 숫자가 폴링에 비해 줄어들 수 있다.

그러나 결국 롱폴링도 폴링이기 때문에 폴링이 가지는 모든 단점을 갖는다. 그리고 Connection 을 유지하는 시간이 짧다면 폴링과 사실상 다를게 없다. 게다가 Connection 이 지속적으로 연결되어 있기 때문에 이에 대한 서버의 부하도 줄어들지 않는다.

스트리밍

사람들이 흔히 아는 그 스트리밍과 비슷한 부분도 있고 다른 부분도 있다. 스트리밍은 요청에 대한 응답으로 Connection 을 종료하지 않고 Connection 을 유지한 상태에서 Event 가 발생하면 지속적으로 응답을 보내주는 형식이다.

폴링 / 롱폴링 / 스트리밍 모두 http 를 통해 통신하기 때문에 Request 와 Response 헤더가 모두 불필요하게 크다.

웹소켓

웹소켓 또한 프로토콜의 일종으로, 위의 방식들의 단점을 보완하면서도 클라이언트와 서버 간의 효율적인 양방향 통신을 구현하기 위한 기술이다.

웹소켓은 최초 접속에는 http 를 이용해 handshaking 후 양방향 통신이 이루어진다. 실시간으로 양방향 통신이 가능하기 때문에 Connection 을 지속적으로 맺고 끊는 서버의 부담을 줄일 수 있다. 또한 불필요하게 컸던 Request 와 Response 헤더의 크기를 줄일 수 있다.

웹소켓은 양방향 통신에 적합하므로, 채팅과 같이 실시간으로 데이터를 주고 받는 데에는 용이할 수 있다. 하지만 서버에서 단순히 알림을 보내는 데에는 적합하지 않다.

SSE

위에서 썼듯이, SSE 는 웹소켓과 다르게 서버로부터 데이터만 받을 수 있게 된다. 웹소켓과 달리 별도의 프로토콜을 사용하지 않고 http 만 사용하기 때문에 훨씬 가볍다.

종합해봤을 때 우리 프로젝트의 뱃지 생성 알림에 가장 알맞은 기술은 SSE 라고 생각이 들어 이를 사용했다.

2) ConcurrentHashMap

이걸 공부하면서 스레드와 동기화 관련 내용을 정말 많이 공부할 수 있었다. 물론 이해하기 정말 어려웠고 시간도 정말 오래 걸렸지만, 앞으로 개발자로 사는 데에 많은 도움을 줄 수 있을 것이라고 생각해 더 깊이 더 열심히 공부했던 것 같다. 개발 입문 이래 이해하기 가장 어려웠던 내용이 아닌가 싶다... 나중에 Database Lock 도 사용해보고 추가 포스팅 해야겠다.

아무래도 포스팅 전에 ConcurrentHashMap 제대로 이해하고 싶어서 스레드와 Concurrency Control, Java 에서 Concurrency 를 어떻게 관리하는지에 대한 기본 지식이 필요해 이에 대해 먼저 공부를 했다. 공부했던 내용과 순서는 아래와 같다. 댓글은 남기지 못했지만 여기에서나마 정말 좋은 영상을 만들어주신 유튜브 쉬운코드님께 감사의 말씀을 전하고 싶다.

프로세스, 스레드, 멀티태스킹, 멀티스레딩, 멀티프로세싱, 멀티프로그래밍 : https://www.youtube.com/watch?v=QmtYKZC0lMU&list=PLcXyemr8ZeoT-_8yBc_p_lVwRRqUaN8ET&index=6
스레드 풀(thread pool)은 왜 쓰는 걸까요? : https://www.youtube.com/watch?v=B4Of4UgLfWc&list=PLcXyemr8ZeoT-_8yBc_p_lVwRRqUaN8ET&index=19
컨텍스트 스위칭 뽀개기 : https://www.youtube.com/watch?v=Xh9Nt7y07FE&list=PLcXyemr8ZeoT-_8yBc_p_lVwRRqUaN8ET&index=7
동기화(synchronization), 경쟁 조건(race condition), 임계 영역(critical section) : https://www.youtube.com/watch?v=vp0Gckz3z64&list=PLcXyemr8ZeoT-_8yBc_p_lVwRRqUaN8ET&index=9
스핀락(spinlock) 뮤텍스(mutex) 세마포(semaphore) : https://www.youtube.com/watch?v=gTkvX2Awj6g&list=PLcXyemr8ZeoT-_8yBc_p_lVwRRqUaN8ET&index=10
모니터가 어떻게 동기화에 사용되는지 아주 자세히 설명합니다! : https://www.youtube.com/watch?v=Dms1oBmRAlo&list=PLcXyemr8ZeoT-_8yBc_p_lVwRRqUaN8ET&index=11
데드락(교착상태) : https://www.youtube.com/watch?v=_dzRW48NB9M&list=PLcXyemr8ZeoT-_8yBc_p_lVwRRqUaN8ET&index=15

2-1) Thread-Safe

ConcurrentHashMap 의 가장 큰 특징은 Thread Safe 하다는 것이다. 물론 HashTable 도 Thread Safe 하지만 그래서 이에 대해 먼저 짚고 넘어가도록 하겠다.

Thread Safe멀티 스레드 프로그래밍에서 어떤 함수나 변수, 혹은 객체가 여러 스레드로부터 동시에 접근이 이루어져도 프로그램의 실행에 문제가 없음을 뜻한다. 라고 위키피디아는 정의했다.

결국 두 개 이상의 스레드가 Race Condition 에 들어가거나 동시에 접근해도 연산 결과의 정합성이 보장되는 것이 Thread Safe 라고 할 수 있겠다.

그렇다면 내가 개발한 SpringBoot 서버는 멀티 스레드 환경인가? 맞다. SpringBoot 에서는 Tomcat 을 사용하는데, Tomcat 이 자동으로 스레드 풀을 생성해주고 각 Request 별 스레드를 할당해 요청을 처리한다.

2-2) HashMap

HashMap 은 Thread Safe 하지 않아 싱글 스레드 환경에서 사용하는 것이 좋다. 동기화 처리는 하지 않기 때문에 데이터 탐색하는 속도는 빠르지만 신뢰성과 안정성이 떨어진다.

public class HashMap<K,V> extends AbstractMap<K,V> implements Map<K,V>, Cloneable, Serializable {

    ...

    public V put(K key, V value) {
        return putVal(hash(key), key, value, false, true);
    }

    final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
        Node<K,V>[] tab; Node<K,V> p; int n, i;
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);
        else {
            Node<K,V> e; K k;
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                e = p;
            else if (p instanceof TreeNode)
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
            else {
                for (int binCount = 0; ; ++binCount) {
                    if ((e = p.next) == null) {
                        p.next = newNode(hash, key, value, null);
                        if (binCount >= TREEIFY_THRESHOLD - 1)
                            treeifyBin(tab, hash);
                        break;
                    }
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        break;
                    p = e;
                }
            }
            if (e != null) {
                V oldValue = e.value;
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value;
                afterNodeAccess(e);
                return oldValue;
            }
        }
        ++modCount;
        if (++size > threshold)
            resize();
        afterNodeInsertion(evict);
        return null;
    }

    ...
}

코드에서 보다시피 volatile, syncronized, CAS 중 어떤 것도 들어있지 않다.

2-3) HashTable

HashTableThread Safe 하기 때문에 멀티 스레드 환경에서 사용할 수 있다. get(), put(), remove() 등 데이터를 다루는 메소드에 syncronized 키워드가 들어있어 메소드를 호출하기 전에 스레드 간 동기화 락을 건다.

이 덕분에 HashTable 은 Thread Safe 할 수 있지만, 메소드 레벨에 syncronized 키워드가 들어있기 때문에 비교적 동작이 느리다.

public class Hashtable<K,V> extends Dictionary<K,V> implements Map<K,V>, Cloneable, java.io.Serializable {

    ...

    public synchronized V put(K key, V value) { //접근제한자 뒤에 synchronized 키워드를 사용했다.
        if (value == null) {
            throw new NullPointerException();
        }
        
        Entry<?,?> tab[] = table;
        int hash = key.hashCode();
        int index = (hash & 0x7FFFFFFF) % tab.length;
        @SuppressWarnings("unchecked")
        Entry<K,V> entry = (Entry<K,V>)tab[index];
        for(; entry != null ; entry = entry.next) {
            if ((entry.hash == hash) && entry.key.equals(key)) {
                V old = entry.value;
                entry.value = value;
                return old;
            }
        }

        addEntry(hash, key, value, index);
        return null;
    }

    ...
}

2-4) ConcurrentHashMap

ConcurrentHashMap 또한 Thread Safe 하기 때문에 멀티 스레드 환경에서 사용할 수 있다. HashTable 과 다른 점은 메소드 단에서 syncronized 키워드를 쓰지 않고 특정 상황에만 syncronized 키워드를 사용하기 때문에 Thread Safe 하면서도 HashTable 보다 비교적 빠르다는 특징을 가지고 있다.

정확히 말하면, get() 에는 아예 syncronized 키워드가 존재하지 않고, put() 중간에 syncronized 키워드가 존재한다. 물론 remove(), replace() 등에도 syncronized 키워드는 들어가긴 하지만 마찬가지로 메소드 중간에 syncronized 키워드가 존재한다.

ConcurrentHashMap 의 put() 메소드의 구현 방식을 보면 어떻게 동작하는지 더 자세히 알 수 있다. 이를 크게 두 가지 경우로 나눌 수 있는데, 빈 해시 버킷에 노드를 삽입하는 경우와 이미 기존 노드가 있는 경우이다.

이것도 마찬가지로 너무 정리가 잘되어 있어서 사진을 가져왔다.

출처 - [java] ConcurrentHashMap 동기화 방식 : https://pplenty.tistory.com/17

빈 해시 버킷에 노드를 삽입하는 경우

(1) table 은 ConcurrentHashMap 에서 내부적으로 관리하는 Node 의 가변 Array 이며, 무한 루프를 통해 삽입될 bucket 을 확인한다.
(2) 새로운 노드를 삽입하기 위해 해당 버킷 값을 tabAt() 를 통해 해당 bucket 을 가져오고 bucket == null 로 비어있는지 확인한다.
(3) bucket 이 비어있는 경우 casTabAt() 을 통해 Node 를 담고 있는 volatile 변수 (ConcurrentHashMap 클래스에 필드에 선언되어 있음) 에 접근하고 Node 와 기대값 null 을 비교하여 같으면 Node를 생성해 넣고, 아니면 (1) 로 돌아가 재시도한다.

volatile 변수에 2번 접근하는 동안 원자성(atomic) 을 보장하기 위해 기대되는 값과 비교(Compare) 하여 맞는 경우에 새로운 노드를 넣는다. (Swap)

결국 여기서는 lock 을 사용하지 않고 Compare And Swap 을 이용하여 새로운 노드를 해시 버킷에 삽입하는 것이다.

Volatile 과 Compare And Swap 관련된 정보는 아래의 포스팅을 참고하자.

Java volatile이란? : https://nesoy.github.io/articles/2018-06/Java-volatile
CAS(Compare And Swap) 알고리즘 : https://chickenpaella.tistory.com/97

이미 기존 노드가 있는 경우

기존 노드가 있는 경우에 드디어 synchronized 키워드가 등장한다. HashMap 과 구현이 비슷한데, 동일한 Key 이면 Node 를 새로운 노드로 바꾸고, 해시 충돌(hash collision) 인 경우에는 Separate Chaining 에 추가 하거나 TreeNode 에 추가한다. TREEIFY_THRESHOLD 값에 따라 체이닝을 트리로 바꾼다.

마무리

싱글 스레드 환경이면 HashMap 을, 멀티 스레드 환경이라면 HashTable 보다는 ConcurrentHashMap 이 더 좋다. 위에도 서술했듯이 ConcurrentHashMap 은 특정 상황을 제외하고는 lock 을 걸지 않기 때문에 HashTable 보다 비교적 빠르게 작동할 수 있다.

참고 :
[Spring + SSE] Server-Sent Events를 이용한 실시간 알림 : https://velog.io/@max9106/Spring-SSE-Server-Sent-Events%EB%A5%BC-%EC%9D%B4%EC%9A%A9%ED%95%9C-%EC%8B%A4%EC%8B%9C%EA%B0%84-%EC%95%8C%EB%A6%BC
알림 기능을 구현해보자 - SSE(Server-Sent-Events)! : https://gilssang97.tistory.com/69
웹소켓 과 SSE(Server-Sent-Event) 차이점 알아보고 사용해보기 : https://surviveasdev.tistory.com/entry/%EC%9B%B9%EC%86%8C%EC%BC%93-%EA%B3%BC-SSEServer-Sent-Event-%EC%B0%A8%EC%9D%B4%EC%A0%90-%EC%95%8C%EC%95%84%EB%B3%B4%EA%B3%A0-%EC%82%AC%EC%9A%A9%ED%95%B4%EB%B3%B4%EA%B8%B0
[Java] ConcurrentHashMap 이란 무엇일까? : https://devlog-wjdrbs96.tistory.com/269
HashMap vs HashTable vs ConcurrentHashMap : https://tecoble.techcourse.co.kr/post/2021-11-26-hashmap-hashtable-concurrenthashmap/
[java] ConcurrentHashMap 동기화 방식 : https://pplenty.tistory.com/17
[Java] ConcurrentHashMap는 어떻게 Thread-safe 한가? : https://velog.io/@alsgus92/ConcurrentHashMap%EC%9D%98-Thread-safe-%EC%9B%90%EB%A6%AC
[OS] 쓰레드 세이프(Tread Safe)란? : https://wooono.tistory.com/523
[OS] Thread Safe란? : https://gompangs.tistory.com/entry/OS-Thread-Safe%EB%9E%80
Thread safe와 동기화 객체 : https://velog.io/@hoo00nn/Thread-safe%EC%99%80-%EB%8F%99%EA%B8%B0%ED%99%94-%EA%B0%9D%EC%B2%B4
스레드 안전이란 무엇인가? : https://m.blog.naver.com/PostView.naver?isHttpsRedirect=true&blogId=complusblog&logNo=220985528418
스레드 안전(Thread-Safety)란? : https://developer-ellen.tistory.com/205
[Java] 멀티 스레드 : https://velog.io/@sezzzini/Java-%EB%A9%80%ED%8B%B0-%EC%8A%A4%EB%A0%88%EB%93%9C
JAVA 쓰레드란(Thread) ? - JAVA에서 멀티쓰레드 사용하기 : https://honbabzone.com/java/java-thread/
스프링부트는 어떻게 다중 유저 요청을 처리할까? : https://velog.io/@sihyung92/how-does-springboot-handle-multiple-requests
SpringBoot 멀티스레드 공부 및 실험 : https://4whomtbts.tistory.com/118
Compare and Swap(CAS) Algorithm : https://itchallenger.tistory.com/entry/Compare-and-SwapCAS-Algorithm
Java Atomic Type 이해하기(AtomicBoolean, AtomicInteger) : https://readystory.tistory.com/53
CAS(Compare And Swap) 알고리즘 : https://chickenpaella.tistory.com/97
Java volatile이란? : https://nesoy.github.io/articles/2018-06/Java-volatile

profile
항상 진심이지만 뭔가 안풀리는 개발 (주의! - 코린이가 배우고 이해한 내용을 끄적이는 공간이므로 실제 개념과 일부 다를 수 있음!)

0개의 댓글