Home 동시성을 통한 다중 접속자 관리
Post
Cancel

동시성을 통한 다중 접속자 관리

동시성을 통한 다중 접속자 관리

image

동시성(Concurrency)은 단일 코어에서 여러 스레드가 번갈아가면서 실행되는 것처럼 보이는 개념이다.

이는 실제로는 하나의 코어에서 각 스레드가 번갈아가면서 실행되기 때문에 동시에 실행되는 것은 아니다.

이때 thread들은 작업을 진행하다가 일시적으로 중단되고 다른 스레드가 실행되는 형태로 동작한다.

이런 상황에서 Context Switching이 발생하여 thread 간 전환되어 작업을 수행한다.



접속자 수

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
private Map<String, Integer> connectUsers;

@PostConstruct
private void setUp() {
    this.connectUsers = new HashMap<>();
}

public void connectUser(String status, String roomId, ChatMessage chatMessage) {
    if (Objects.equals(status, "Connect")) {
        connectUsers.putIfAbsent(roomId, 0);
        int num = connectUsers.get(roomId);
        connectUsers.put(roomId, (num + 1));
        saveFile(chatMessage);
    } else if (Objects.equals(status, "Disconnect")) {
        int num = connectUsers.get(roomId);
        connectUsers.put(roomId, (num - 1));
    }
    log.info("현재 인원 : " + connectUsers.get(roomId));
}

많은 사람이 채팅을 시작하고 종료하면서 접속자 수가 제대로 count 되지 않는 문제가 생겼다.

여러 사용자가 동시에 접속 및 접속 해제하게 되면서 문제가 발생한 것 같다.



여러 스레드가 동시에 connectUser()를 호출하면 접속자 수가 정확히 유지되지 않고 오류가 발생할 수 있다.

이로 인해 Map(connectUsers)에 대한 동기화가 보장되지 않고,

여러 스레드에서 동시에 맵을 수정하면 예기치 않은 결과가 발생할 수 있다.





코드 수정

현재 로직은 사용자 A와 사용자 B가 각각 다른 roomId를 가지고 ConcurrentHashMap에 저장이 된다.

같은 ConcurrentHashMap에는 관리자와 사용자 A, 혹은 사용자 B와 관리자로 저장된다.




1. 동기화된 맵 사용

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
private Map<String, Integer> connectUsers;

@PostConstruct
private void setUp() {
    this.connectUsers = new HashMap<>();
}

public void connectUser(String status, String roomId, ChatMessage chatMessage) {
    int num = 0;
    log.info("[ ConnectUser ] roomId : " + roomId);
    if (Objects.equals(status, "Connect")) {
        num = connectUsers.getOrDefault(roomId, 0);
        connectUsers.put(roomId, (num + 1));
        saveFile(chatMessage);
    } else if (Objects.equals(status, "Disconnect")) {
        log.info("[ DisconnectUser ] roomId : " + roomId);
        num = connectUsers.get(roomId);
        connectUsers.put(roomId, (num - 1));
    }
    log.info("현재 인원 : " + connectUsers.get(roomId));
}

Java에서는 ConcurrentHashMap 클래스를 사용하여 동시성 문제를 해결할 수 있다.



Hashtable 클래스의 대부분의 API를 보면 메소드 전체에 synchronized가 존재하여

동시에 여러 작업을 해야할 때 병목현상이 발생할 수 밖에 없다.

Thread-safe 하다는 특징이 있지만, Multi-Thread 환경에서 사용하기에 살짝 느리다는 단점이 있다.


HashMap 클래스를 보면 synchronized가 존재하지 않기 때문에 단일 스레드 환경에서는 성능이 우수히디.

하지만 synchronized가 존재하지 않기 때문에 Multi-Thread 환경에서 동시성 문제가 발생할 수 있다.


Hashtable 클래스의 단점을 보완하면서 Multi-Thread 환경에서 사용할 수 있도록 나온 클래스가 바로 ConcurrentHashMap이다.



ConcurrentHashMap은 get()은 여러 thread가 동시에 읽을 수 있지만,

put()은 일부 세그먼트 or 버킷에 대한 Lock을 사용하여 동시성을 제어한다.


image


image

image

get()에는 synchronized가 존재하지 않고, put()에는 중간에 synchronized가 존재하는 것을 볼 수 있다.


읽기 작업에는 병렬적인 액세스가 허용되므로 읽기 작업의 성능이 향상된다.

쓰기 작업에는 일부 세그먼트 또는 버킷에 대해 lock을 사용하여 동시성을 제어하므로

여러 thread가 동시에 쓰기 작업을 시도할 때 성능이 저하되는 것을 방지한다.




image

ConcurrentHashMap 클래스를 보면 위와 같이 DEFAULT_CAPACITY, DEFAULT_CONCURRENCY_LEVEL이 16으로 설정되어 있다.

DEFAULT_CAPACITY는 HashMap 및 ConcurrentHashMap에서 사용되는 초기 용량(capacity)을 나타낸다.

DEFAULT_CONCURRENCY_LEVEL는 동시성 수준을 나타내며 동시에 작업 가능한 스레드 수를 말한다.


ConcurrentHashMap은 버킷 단위로 lock을 사용하기 때문에 같은 버킷만 아니라면 Lock을 기다릴 필요가 없다는 특징이 있다.

(버킷당 하나의 Lock을 가지고 있다라고 생각하면 된다.)



코드로 예를 들자면

ConcurrentHashMap에 두 개의 키가 저장되어 있다면, 일반적으로 내부적으로는 두 개의 버킷이 있을 것이다.

a 사용자와 관리자가 한 버킷에 접근하고 b 사용자와 관리자가 다른 버킷에 접근하는 상황에서

a 사용자와 b 사용자가 동시에 접근해도 서로 다른 버킷에 접근하므로 lock을 기다릴 필요가 없다


→ 여러 thread에서 ConcurrentHashMap 객체에 동시에 데이터를 삽입, 참조하더라도

그 데이터가 다른 세그먼트에 위치하면 서로 락을 얻기 위해 경쟁하지 않는다.

따라서 ConcurrentHashMap은 여러 thread가 안전하게 동시에 Map을 수정할 수 있도록 지원한다.




2. 동기화 블록 사용

synchronized를 적용하는 방법에는 4가지가 있다.

  • synchronized method
  • synchronized block
  • static synchronized method
  • static synchronized block



Synchronized 이해

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
public class Prac {
    private String msg;

    public static void main(String[] agrs) {
        BasicSynchronization temp = new BasicSynchronization();
        System.out.println("Test start!");
        new Thread(() -> {
            for (int i = 0; i < 10; i++) {
                temp.callMe("Thread1");
            }
        }).start();
        new Thread(() -> {
            for (int i = 0; i < 10; i++) {
                temp.callMe("Thread2");
            }
        }).start();
        System.out.println("Test end!");
    }

    public synchronized void callMe(String name) {
        msg = name;
        try {
            long sleep = (long) (Math.random() * 100);
            Thread.sleep(sleep);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        if (!msg.equals(name)) {
            System.out.println(name + " : " + msg);
        }
    }
}

위 코드를 실행시키면 로그가 찍히지 않는다.

image

즉, 함수에 synchronized를 걸면 그 함수가 포함된 해당 객체(this)에 lock을 거는것과 같다.

그래서 synchronized block이 존재한다.



Java에서는 thread를 동기화 하기 위해서 synchronized를 제공한다.

thread는 synchronized()에 들어가기 위해 lock을 얻고 메소드가 끝나면 lock을 반환한다.

어떠한 thread가 lock을 얻어 synchronized()를 사용중이면 다른 메소드는 lock이 없으므로

synchronized에 접근할 수 없고 다른 thread가 lock을 반환할 때까지 기다려야 한다.



ex) synchronized → 🏠 , lock → 🗝️

synchronized(🏠)에 들어가기 위해서는 lock(🗝️)이 필요한데 lock(🗝️)은 단 1개만 존재한다.

A가 synchronized(🏠)에 lock(🗝️)을 들고 들어가면

B는 synchronized(🏠)에 들어갈 lock(🗝️)이 없기 때문에 A가 synchronized(🏠)에 나올 때까지 기다려야 한다.





1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
private Map<String, Integer> connectUsers;

@PostConstruct
private void setUp() {
    this.connectUsers = new HashMap<>();
}

public void connectUser(String status, String roomId, ChatMessage chatMessage) {
    log.info("[ connectUser ] roomId : " + roomId);
    int num = 0;
    synchronized (lock) {
        if (Objects.equals(status, "Connect")) {
            connectUsers.putIfAbsent(roomId, 0); 
            num = connectUsers.get(roomId);
            connectUsers.put(roomId, (num + 1));
            saveFile(chatMessage);
        } else if (Objects.equals(status, "Disconnect")) {
            num = connectUsers.get(roomId);
            connectUsers.put(roomId, (num - 1));
        }
        log.info("현재 인원 : " + connectUsers.get(roomId));
    }
}

위 코드는 block이 method 전체에 적용되어있기 때문에 mehtod 단위로 lock을 거는 것과 같다.


참고로 synchronized는 임계 영역에 접근하는 모든 요청(Thread)들을 동기화하기 때문에

같은 roomId가 아닌 다른 roomId라도 Blocking이 발생해서 성능 저하로 이어질 수 있다고 한다.

→ 사용자 A, B가 동시에 채팅을 접속한다면 Blocking이 발생할 수 있다.




동시성을 적용하고 나서

  • 여러 사용자가 동시에 같은 자원(예: 데이터베이스의 특정 레코드)을 추가하려고 할 때

    동시성을 사용하여 한 번에 한 사용자만 해당 자원에 접근하도록 제어할 수 있다.

    이렇게 하면 다중 사용자 간의 충돌을 방지하고 데이터의 일관성을 유지할 수 있다.

  • 한 사용자가 여러 번 추가되는 것을 방지

    사용자가 동시에 같은 작업을 실행하더라도 해당 작업을 한 번만 실행하도록 보장해서

    한 사용자가 여러 번 추가되는 것을 방지할 수 있다.



처음 동시성을 적용할 때는 synchronizedConcurrentHashMap을 둘 다 적용했었다.

이후, ConcurrentHashMap을 알아보게 되면서

ConcurrentHashMap은 동시에 다른 버킷에 접근하는 경우

별도의 lock 경합이 발생하지 않기 때문에 lock을 기다릴 필요가 없지만

synchronized은 동시에 여러 스레드가 접근하는 경우 lock을 기다려야하는 상황이 생기기 때문에

synchronized와 같이 사용하게 된다면 ConcurrentHashMap을 활용하지 못한다고 생각이 들어서

2개 중에 하나를 적용하려고 했고

나는 사용자들끼리 동시에 접속하는 횟수가 많을 것이라고 판단하여 ConcurrentHashMap을 적용했다.



REFERENCE

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