티스토리 뷰

728x90

현 회사를 다니며 진행한 프로젝트 중에 동시성과 병렬성 문제를 만나고 이를 해결한 경험을 기록하고자 한다.

 

상황

해당 프로젝트에서 외부 API 서버에 어떤 값을 request하고 외부 서버에 의해 처리된 데이터를 response로 받는 일종의 어댑터 패턴의 서버 개념으로 동작하는 API 서버를 빌드하는 것이 목표였다.

 

이때 외부 API를 호출하는 기능은 제공하는 SDK를 이용해서 구현하였으나 한번의 API 호출에 하나의 값만을 전달할 수 밖에 없는 구조로 되어있어 성능이 썩 만족스럽지 못 했고, n건의 요청을 한 경우 n건의 응답이 반드시 와야 하는 consistency가 중요했다.

 

여기서 SDK API의 부족한 기능은 Java의 parallelStream을 통해 해결하였고 (병렬성) 100% 확실한 consistency를 위해서 서버 전역에서 공유하는 state를 만들어서 해결하였다 (동시성).

 

개발 환경은 Java 11, 스프링부트, 스프링 MVC이다.

 

동시성

n건의 요청을 보내고 n건의 응답을 받는 것을 확신하기 위해서 처음에는 다음과 같이 global state를 갖는 map을 선언했다.

 

public static Map<String, Set<String>> map = new HashMap<>();

 

하지만 외부 API 서버를 호출해야 하는 프로젝트이었기에 어떤 request가 global state의 map에 write를 하려고 하는 동시에 다른 response가 들어오면서 map을 read하려고 하는 동시성 문제가 필연적으로 발생할 수 밖에 없었다.

그래서 동시성 문제는 concurrent 패키지의 자료구조를 이용해서 해결한다는 얘기를 언젠가 들은 기억이 있어 코드를 다음과 같이 변경해주었다.

 

public static Map<String, Set<String>> map = new ConcurrentHashMap<>();

 

하지만 동시성 문제는 여전히 발생했는데 그 이유는 다음과 같은 기존 코드의 형태에 있었다.

 

for (var element : elements) {
    Set<String> set = map.get(key);
    set.add(element);
    map.put(key, set);
}

 

concurrent 패키지의 자료 구조는 write하는 순간, read하는 순간의 동시성은 보장하지만 value를 꺼내오고(위의 예제에서는 Set<String>) 새로운 값을 넣고 key에 value를 다시 put하는 동안에는 동시성을 보장해주지 않는다. 이 문제를 해결하기 위해서는 externel synchronization이 필요했다.

 

external synchronization을 제공하는 방법으로는 synchronized block을 사용하거나, primitive type의 경우 atomic 패키지를 이용하거나, concurrent 패키지에서 제공하는 메서드를 사용하는 방법 등이 있다.

Set의 값을 조작하는 것이었으므로 atomic은 사용할 수 없었고 simplicity를 위해 synchronized block을 사용하기 보다는 concurrent 자체에서 제공하는 메서드를 사용해서 구현하기로 결정하였고 구현한 코드는 다음과 같다.

 

//add
map.computeIfPresent(key, (k, set) -> {
    set.add(element);
    return set;
});

//remove
map.computeIfPresent(key, (k, set) -> {
    set.remove(element);
    return set;
});

 

위와 같이 구현한 이후 어떤 멀티 쓰레딩 환경에서도 동시성을 보장하는 결과를 얻을 수 있었다.

기존의 코드에서는 1000건 정도를 기준으로 3~5건의 동시성 문제가 발생하였었다.

 

병렬성

동시성 문제를 해결하며 기존의 싱글 쓰레드 구조에서 쓰레드간 state를 공유하는 멀티 쓰레드 구조로 변경하여도 괜찮을 것같다는 생각이 들었다. 일단 Iterate를 하며 CPU, RAM 리소스를 많이 잡아먹는 어떤 복잡한 작업을 하는 것이 아닌 단순 외부 API 호출이 주 로직이었던 점과 request 순서나 response 순서가 상관없는 요구 조건 등과 같은 이유로 멀티 쓰레드 구조로 변경하기로 결정하였다.

 

위에서 동시성 문제를 해결해두었기 때문에 병렬 작업을 적용하는 것은 매우 쉬웠고 적용 결과는 매우 놀라웠다.

 

//병렬 적용 코드
AtomicInteger count = new AtomicInteger();
elements.parallelStream().forEach(element -> {
    keyMap.computeIfPresent(key, (k, set) -> {
        set.add(element);
        return set;
    });
    //do Something more..
    
    count.getAndIncrement();
});


//기존 코드
var count = 0;
for (var element : elements) {
    map.computeIfPresent(key, (k, set) -> {
        set.add(element);
        return set;
    }
    //do Something more..
    
    count++;
});

 

기존 for loop를 parallelStream을 적용한 forEach문으로 변경해주었고 stream 내에서의 state를 확인하기 위해 이용하던 primitive type인 int는 AtomicInteger로 변경 후 .getAndIncrement()와 같은 CAS 메서드를 이용하여 increment를 진행해주었다.

 

싱글 쓰레드에서 병렬 구조로 변경한 결과 기존 성능에서 300% 가까운 성능 상승의 효과를 볼 수 있었다. (자세한 수치를 기록해두었지만 공개하는 것은 피하고자 한다.)

 

결론

이전에 멀티 쓰레딩 문제를 만나보거나 이를 해결해볼만한 상황이 마땅치 않아 항상 공부하고 싶은 마음이 있었는데 이번 프로젝트를 통해 동시성과 병렬성 둘 다 한 번에 만나볼 수 있는 기회가 생겨 즐겁게 공부할 수 있었다.

 

이번 문제를 해결하면서 언제나 해결 방법은 존재하지만 내가 그 방법의 존재라도 알고 있는지 여부가 접근 방법과 방식에 상당한 영향을 미친다는 생각을 하게 되었다. 

이전에 멀티 쓰레딩 해결 방법에서 synchronized, cas, atomic, volatile, transient, concurrent 등과 같은 키워드와 이 키워드가 필요한 상황 등에 대해 가볍게 익혀둔 적이 있는데 이 경험이 문제 해결에 많은 도움을 주었다고 생각된다. 

댓글
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2024/05   »
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
글 보관함