소프트웨어 개발을 하다 보면 동시성(Concurrency)이란 용어를 많이 듣게 된다. 그런데 동시성이 무엇인가요?, 병렬성과 어떤 차이가 있나요? 라고 물어보면 쉽게 답하기 어렵다. 동시성은 꽤나 복잡한 개념이기 때문이다.
이 글에서는 동시성의 개념과 성능을 정량화하는 방법, 그리고 동시성이 성능을 개선하지는 않는 두 가지 상황을 살펴본다. 글의 상당 부분은 <그로킹 동시성>과 <주니어 백엔드 개발자가 반드시 알아야 할 실무 지식>을 참고했다.
동시성
동시성(Concurrency)은 여러 작업을 동시에 처리하는 개념을 의미한다. 흔히 동시에 처리한다고 하면 여러 작업을 같은 순간에 실행하는 병렬성(Parallelism)을 떠올린다. 하지만 두 개념은 엄밀히 다르다.
병렬성은 멀티코어를 가진 프로세서가 각각 다른 작업을 동시에 수행하는 물리적인 개념이다. 반면 동시성은 한 번에 여러 작업을 다루는 논리적인 개념이다.
그래서 시스템은 동시적이면서 병렬적이지 않을 수 있고, 병렬적이면서 동시적일 수 있다.
동시성 프로그래밍은 왜 등장했는가?
과거에는 하드웨어 성능을 높이는 것만으로도 시스템의 성능을 개선할 수 있었다. 하지만 2005년 C++ 전문가 Herb Sutter가 작성한 "The Free Lunch Is Over"와 같이, 단일 코어의 클럭 속도 향상은 사실상 한계에 부딪혔다. 따라서 하드웨어 성능 향상만으로 소프트웨어가 저절로 빨라지는 시대는 끝나게 되었다.
이후 프로세서 구조는 멀티 프로세서 형태의 수평 확장의 형태로 발전되었고, 소프트웨어 엔지니어도 시스템의 성능을 높이기 위해 하드웨어 자원을 효율적으로 사용할 수 있는 방법을 고민하게 되었다.
"The Free Lunch Is Over" 는 아래 링크에서 확인할 수 있다. https 연결이 아니기 때문에 링크 연결에 주의하길 바란다.
- The Free Lunch Is Over: http://www.gotw.ca/publications/concurrency-ddj.htm
- Welcome to the Jungle(The Free Lunch Is Over 후속 및 번역글): https://d2.naver.com/helloworld/70808
성능은 어떻게 정량화할 수 있을까
성능(Performance)은 어떻게 수치로 변환할 수 있을까? 주로 응답시간(Response Time)과 처리량(Throughput) 개념을 사용한다.
응답시간은 작업을 처리하는 데 걸리는 시간을 의미한다. 처리량은 단위 시간당 처리하는 작업의 수를 의미하며 TPS(Transaction per second) 또는 RPS(Request per second)와 같은 용어로 불리기도 한다.
우리가 아침 출퇴근길에 강남역에서 판교역으로 출퇴근한다고 생각해보자. 편도로 15km 거리이고 걸어서는 4시간 정도 걸린다. 걸어서는 꽤나 곤란한 거리이다.
도보가 아닌 자가용 운전이나 지하철을 고민해보자. 자가용 운전은 60분, 지하철은 23분 정도 소요되며, 출퇴근길의 도로의 혼잡도와 지하철의 배차 간격을 고려하였다(물론 실제 측정치와 다를 수 있다). 이를 응답시간과 처리량으로 표현하면 다음과 같다.
- 자가용 운전: 응답시간 60분, 인원: 1명 (처리량: 1분당 약 0.02명)
- 지하철: 응답시간: 23분, 인원: 1,000명 (처리량: 1분당 약 43명)
이제 우리는 지하철이 자가용 운전보다 응답시간이 짧고 처리량이 높은 시스템이라고 말할 수 있다.
동시성 프로그래밍과 성능 개선
일반적으로 시스템에 동시성을 적용하면 성능을 개선할 수 있다. 여러 작업을 동시에 처리하니, 작업의 대기시간(Wait Time)이 줄어 처리량을 높일 수 있기 때문이다.
하지만 모든 상황에서 동시성이 성능을 개선하는 것은 아니다. 두 가지 상황을 살펴보자.
상황1: 스레드를 과도하게 생성하는 경우
작업을 처리할 때마다 스레드를 생성한다고 생각해보자. OS가 여러 스레드를 스케줄링하여 여러 작업이 동시에 실행되니 동시성을 만족한다.
하지만 작업의 수가 많아지면 문제가 된다. OS가 스레드를 생성하고 스케줄링하고, 컨텍스트 스위칭하는 작업 모두 CPU 비용이 발생하기 때문이다.
그래서 작업의 수가 많은 경우에는 무작정 스레드를 늘려도 성능이 개선되지 않을 수 있다. 관련해서 1999년 Dan Kegel의 C10K 문제가 있다. 올리브영 테크블로그에서도 설명한 글이 있으니 같이 읽어봐도 좋을 것 같다.
- 고전 돌아보기, C10K 문제 (C10K Problem): https://oliveyoung.tech/2023-10-02/c10-problem/
상황2: I/O 장치 자체가 느린 경우
프로세서의 연산 속도가 빠르게 성장한 것과 달리, 디스크에서 파일을 읽거나 네트워크를 통해 데이터를 주고받는 I/O 장치의 성능은 비교적 느리게 성장하고 있다.
만약 작업이 블로킹 I/O 형태이고 장치의 성능이 느린 경우라면, 단순히 스레드 수를 늘려서 성능을 개선하기는 어려울 수 있다. 장치의 물리적인 응답시간 자체가 길기 때문이다.
또한 자원 낭비 문제도 발생한다. I/O 작업이 완료될 때까지 스레드는 아무 일도 하지 않으면서 메모리를 점유한다. 대기 중인 I/O 작업이 많아질수록 실제로 처리되는 작업은 없는데 메모리만 늘어나는 상황이 생긴다.
결론
동시성은 자원의 효율성과 성능을 높이는 방법이다. 하지만 항상 해답은 아닐 수 있다.
느린 I/O 앞에서 아무리 많은 스레드를 투입해도 성능이 나아지지 않는 상황이 그 예다.
결국 중요한 것은 병목 지점이 어디에 있는지 먼저 파악하는 것이다. CPU 연산이 병목인지, I/O 대기가 병목인지에 따라 접근법이 완전히 달라지기 때문이다.
다음 글에서는 병목 지점에 따른 해결 방식에 대해 살펴보려고 한다.
댓글