근접성 서비스(Proximity Service)란 특정 위치를 기반으로 가까운 시설을 찾는 서비스를 의미합니다. 예를 들어 주변 맛집 찾기 서비스를 설계한다고 할 때, 2차원 형태(위도, 경도)로 표현되는 공간 데이터를 빠르게 검색하는 기술에 대한 이해를 바탕으로 아키텍쳐를 설계할 필요가 있습니다. 공간 데이터를 검색하는 기술들을 설펴보고, 그 중 지오해시(Geohash)를 기반으로 한 아키텍처 설계 방법에 대해 소개하겠습니다.
공간 데이터 검색 기술
다음과 같이 특정 위치와 반경을 기반으로 주변의 시설들을 검색하는 방법들에 대해 살펴보겠습니다.
1) 가장 단순한 공간 데이터 검색 : 2차원 검색
가장 단순한 방법입니다. 주어진 반경값을 토대로 검색해야 하는 위도와 경도의 범위를 계산하여 검색에 활용하는 방법입니다. 그러나 별도의 인덱스 설정이 없으면 테이블 전체를 풀 스캔해야 하는 단점이 있습니다. 위도와 경도에 인덱스를 달아둔다고 해도, 각 인덱스를 통해 얻어낸 두 집합의 교집합을 구해야 하는 데서 많은 비용이 발생합니다. 이런 문제들은 기본적으론 DB 인덱스가 한 차원의 검색 속도를 개선시키는 것에서 비롯되므로, 2차원의 공간 데이터에 대한 인덱스 생성 방법을 고려할 필요가 있습니다.
공간 데이터에 대해 인덱스를 만드는 방법은 다음과 같이 분류 가능합니다.
Hash 기반
균등 격자 (even grid)
지오해시 (Geohash)
카르테시안 계층 (Cartesian tiers)
트리 기반
쿼드트리 (Quadtree)
구글 S2
R-tree
각 기술들의 세부 구현 방법은 서로 다르지만, 기본적으로는
공간을 여러 작은 영역들로 분할
공간 데이터들을 분할된 영역들로 매핑 (2차원 데이터가 1차원 데이터로 매핑되는 효과)
"분할된 영역"들을 빠르게 검색
하는 형태로 인덱스가 활용된다고 보면 됩니다. 여기서는 균등 격자와 지오 해시, 쿼드 트리 및 R-tree에 대해 소개하겠습니다.
2) Hash 기반 공간 데이터 검색 : 균등 격자
2차원 공간을 다음과 같이 균일한 크기를 가진 격자로 나누는 접근법입니다.
각 격자들에 1부터 시작하는 식별자 번호를 할당할 수 있고, 공간 데이터들을 각 격자들로 매핑시켜 활용할 수 있게 됩니다. 그러나 뒤에서 소개할 다른 방법들과는 달리 격자 식별자를 할당하는 방법에 명확한 체계가 있는 방식은 아니기 때문에, 특정 격자 주변에 있는 격자를 찾는 것이 어렵다는 단점이 있습니다. 우리의 목적인 "특정 위치와 반경을 기반으로 주변의 시설을 검색하기"를 위해서는 인접한 격자를 쉽게 찾을 수 있어야 하지만 그 부분에 제약이 걸리는 것으로 볼 수 있습니다. 또한 각 격자별로 갖는 공간 데이터들의 분포가 균등하지 않다는 단점도 있습니다.
3) Hash 기반 공간 데이터 검색 : 지오해시
균등 격자와 비슷하게 2차원 공간을 균일한 크기를 가진 격자로 나누고 격자들에 식별자를 할당합니다. 다만 식별자 할당에 체계가 있다는 차이점이 있습니다.
2차원 공간을 그림처럼 4개로 나눈 후, 위도 경도의 범위를 기준으로 4개 영역에 비트값을 할당합니다. 다시 각 격자를 4개로 나눈 후, 각 영역에 비트값을 추가하는 과정을 원하는 정밀도(즉 격자의 크기)가 나올 때까지 반복합니다. 이렇게 나온 비트값을 base 32 표현법으로 인코딩해 나온 문자열을 지오해시라고 부릅니다.
지오해시의 길이가 길수록 정밀도가 높은 것(사이즈가 작은 격자들로 분할된 것)을 의미하며, 지오해시의 길이에 따라 총 12 레벨로 분류할 수 있습니다. 길이 1짜리인 1레벨은 지구 전체에 해당(즉 격자 1개)하고 길이 12짜리인 12레벨은 격자 하나의 사이즈가 3.7cm X 1.9cm입니다. 일반적인 경우 4 ~ 6레벨을 사용하며, 사용자가 지정한 반경으로 그린 원을 덮는 최소 크기 격자를 만드는 지오해시 길이를 최적 정밀도로 계산할 수 있습니다.
ex) 지정한 반경이 1km일 때, 지오해시 레벨이 5인 격자의 크기가 4.9km X 4.9km으로 지정된 반경으로 그린 원을 덮을 수 있는 최소 크기 격자가 됩니다. 레벨 6짜리 격자의 크기는 1.2km X 0.6km입니다.
앞서 살펴봤듯이, 지오해시는 격자를 4개로 나눈 후 원래 가지던 비트값 뒤에 01, 11, 00, 10을 추가하는 식으로 식별자 할당이 진행됩니다. 따라서 이 비트값들을 base 32로 인코딩한 지오해시값들은 비슷할 수밖에 없고, 이를 통해 "공통접두어가 긴 격자들은 인접해있다"를 도출할 수 있습니다. 또한 뒤에 붙어있는 비트값을 떼주면, 해당 격자를 포함하는 좀 더 넓은 격자로 범위를 확장시킬 수 있습니다. 따라서 지오해시는 이 점들을 활용해범위 기반 지역 검색을 쉽게 할 수 있게 됩니다.
다만 하나의 격자만 볼 때는 균등 격자와 마찬가지로 각 격자별로 갖는 공간 데이터들의 분포가 균등하지 않다는 단점은 남아있으며, 공통접두어가 긴 격자들은 인접하다라는 명제는 성립하나 "인접한 격자들은 공통접두어가 같다"는 성립하지 않음에 유의해야 합니다. 이유는 다음 그림과 같이 인접한 격자임에도 비트값들은 서로 완전히 다를 수 있기 때문입니다.
4) 트리 기반 공간 데이터 검색 : 쿼드트리
지오해시는 격자들에 할당된 식별자들을 DB에 저장해 가지는 방식이었다면, 쿼드트리는 격자를 재귀적으로 4개로 쪼개가며 만든 트리를 메모리에 둔 채로 활용하는 방식입니다. 구체적으로는 트리의 리프노드들에 담긴 공간 데이터가 내가 원하는 수(k) 이하가 될 때까지 분할하며, 리프노드에는 해당 격자에 포함된 공간 데이터의 정보들을 갖도록 구성합니다.
모든 격자를 균일한 사이즈로 해야했던 지오해시와는 달리, 쿼드트리는 맘만 먹으면 원하는 영역에 대해 세밀하게 격자를 분할하는 것이 쉽습니다. 또한 트리 구성 시 특정 숫자(k)를 기준으로 만들어 나가는 점을 활용해 "현재 내 위치에서 가까운 공간 데이터 x개 찾기"를 쉽게 할 수 있게 됩니다. 하지만 트리 구조인 만큼 공간 데이터 추가 / 삭제로 인한 인덱스 변경이 좀 더 까다롭고, 서버 시작 시 트리를 구축해서 메모리에 둬야 하기 때문에 서버 시작 시간이 길어질 수 있음을 유의해야 합니다.
5) 트리 기반 공간 데이터 검색 : R-tree
asd
ㅁㄴㅇ
설계
위에서 살펴본 기술 중, 지오해시를 사용할 때의 일반적인 설계 방법에 대해 살펴보겠습니다. 특정 위치와 반경을 기준으로 주변 시설을 조회하는 기능과 특정 시설의 상세 정보를 제공하는 서비스로, 다음과 같은 기능적 요구사항이 있다고 가정합니다.
사용자의 경도와 위도, 반경에 매치되는 시설 목록을 반환해야 함
시설 정보가 추가/갱신/삭제될 수 있으나 실시간으로 반영될 필요는 없음
시설의 상세 정보를 조회할 수 있어야 함
그리고 주변 시설을 빠르게 검색 가능해야 하고 트래픽이 급증해도 감당할 수 있어야 한다는 비기능적 요구사항이 있다고 하겠습니다. 또한 1초에 5,000번 정도의 검색이 발생하는 읽기 연산 위주의 시스템으로 가정하며, 다음과 같은 API들이 있다고 하겠습니다.
Method
API
설명
GET
/v1/search/nearby
인자로 위도, 경도, 반경을 받은 뒤 검색 기준에 맞는 사업장 목록 반환
GET
/v1/businesses/:id
특정 사업장의 상세 정보 반환
POST
/v1/businesses
신규 사업장 추가
PUT
/v1/businesses/:id
사업장 상세 정보 갱신
DELETE
/v1/businesses/:id
특정 사업장 정보 삭제
1) 서버 및 로드밸런서 설계
해당 시스템을 위치 기반으로 시설들을 검색하는 서비스(LBS)와 시설 서비스(시설 상세조회, 등록 등을 담당)로 구분할 수 있습니다. 사용자가 보내는 요청들을 각각의 알맞는 서비스로 전달하기 위해 로드밸런서에서 URL 경로를 분석해 다음과 같이 설정해줄 수 있습니다.
또한 LBS와 시설 서비스는 이전 요청의 상태나 데이터를 유지하지 않는 무상태 서비스이므로 확장성이 좋습니다. 따라서 트래픽이 몰릴 때는 서버를 추가하여 대응하고, 트래픽이 줄어들면 서버를 삭제하도록 유연하게 구성할 수 있습니다. 또한, 클라우드에 시스템을 띄운다면 여러 지역 또는 여러 가용 영역에 둠으로써 고가용성도 확보할 수 있겠습니다. 여러 지역에 둘 경우 DNS 라우팅을 통해 사용자와 가까운 서버에서 트래픽을 처리하도록 하여 응답 시간을 줄이는 효과 등을 기대할 수 있으며, 한 지역 안에서도 여러 가용 영역을 활용한 부하 분산을 기대할 수 있습니다.
2) 데이터베이스 설계
이 시스템은 앞서 가정한대로 읽기 연산이 많이 발생하는 특징이 있습니다. 따라서 데이터베이스를 master-slave 형태로 구성하여 master가 쓰기 요청을, slave가 읽기 연산을 처리하도록 구성하여 부하를 어느 정도 분산시키도록 구성할 수 있습니다.
그리고 테이블을 위치 정보 관련 연산의 효율을 위해 시설의 상세 정보를 담는 테이블(business 테이블)과 공간 인덱스 테이블(지오해시값과 시설id만 가지는)로 분리할 수 있습니다.
먼저 business 테이블의 경우 시설 데이터가 방대하다면 한 서버에 담기지 못할 수 있으므로, 샤딩을 통해 수평적으로 분리 확장시킬 수 있겠습니다.
공간 인덱스 테이블은 서버 한 대에 충분히 수용 가능할 것으로 예상되나, 읽기 연산이 많은 경우 가용한 리소스(CPU, 네트워크 대역폭 등)의 한계가 있으므로 부하 분산이 필요할 수 있는데요. 보통 부하 분산이 필요한 경우 Replica를 늘리는 것과 샤딩을 고려할 수 있으나, 지오해시는 격자별 공간 데이터 분포가 고르지 않아 샤딩이 까다롭고 공간 인덱스 테이블은 서버 한 대에 충분히 수용 가능할 것으로 예상되므로 Replica를 활용하는 것이 더 나은 방안이 될 수 있습니다.
3) 캐시 설계
다중 서버 환경이므로 로컬 캐시보다는 캐시 서버를 구축하는 형태의 글로벌 캐시를 사용하는 것이 좋습니다. 가장 직관적으로 떠올릴 수 있는 캐시 키는 사용자의 위도 경도입니다. 그러나 위도 경도는 사용자가 조금만 움직여도 달라지기 때문에, 캐시 키로 설정할 경우 캐시 히트가 발생하길 기대하기가 매우 어렵습니다. 이때 아까 배웠던 지오해시나 쿼드트리 등을 사용한다면 같은 격자 내의 시설들이 같은 격자 식별자 값을 갖도록 만들 수 있으므로 이 문제를 효과적으로 해결 가능합니다.
구체적으로 다음 데이터들을 캐시에 보관하도록 할 수 있습니다.
키
값
지오해시
해당 격자 내의 시설 id 목록
시설 id
시설 상세 정보 객체
특정 지오해시에 대응되는 시설 목록을 요청받은 경우, 다음과 같이 설계할 수 있겠습니다.
캐시 먼저 조회
없으면 DB에서 해당 지오해시에 대응되는 시설 id 목록 가져와서 캐싱
시설 서비스를 통해 시설의 상세 정보를 조회하는 경우도 위 플로우대로 설계할 수 있겠습니다. 새로운 시설을 추가하거나 기존 시설 정보를 편집 또는 삭제하는 경우, DB를 그에 맞춰 갱신하고 캐시에 보관된 항목은 무효화시킬 수 있습니다. 만약 검색 반경으로 가능한 값들이 정해진 경우, 각 반경에 맞춘 적절한 지오해시 레벨들을 계산 가능하므로 해당하는 레벨에 대한 검색 결과들을 미리 캐싱해둘 수도 있겠습니다. 또한 고가용성 보장을 위해 캐시 서버도 클러스터를 구성하는 것을 고려할 수 있으며, 서버들을 여러 지역에 둔 것과 마찬 가지로 캐시 서버 클러스터를 각 지역별로 구성하여 가까운 캐시 서버를 사용하도록 구성할 수 있겠습니다.
4) 최종 설계도
앞서 설명한 것들을 토대로 다음과 같은 아키텍처를 설계할 수 있게 됩니다.
해당 아키텍처에서의 플로우는 다음과 같이 설계할 수 있습니다.
a) 주변 시설들을 검색하는 경우
클라이언트는 위치(위도, 경도)와 검색 반경을 로드밸런서로 전송합니다.
로드밸런서는 URL을 보고 해당 요청을 LBS로 보냅니다.
LBS는 요청에 포함된 위치와 반경 정보를 토대로 최적 정밀도를 갖는 지오해시 길이를 계산합니다.
LBS는 사용자의 위치를 담는 지오해시와 인접한 지오해시들을 계산하여 목록으로 가집니다.
LBS는 목록에 있는 각 지오해시값에 대해, 글로벌 캐시 서버로부터 해당 지오해시에 매핑된 시설들의 id목록을 가져옵니다. 만약 캐싱된 게 없다면 DB의 공간 인덱스 테이블에서 가져와서 캐싱합니다.
각 시설id들에 대해, 글로벌 캐시 서버로부터 해당 id에 매핑된 상세 정보들을 가져옵니다. 만약 캐싱된 게 없다면 DB의 business 테이블에서 가져와서 캐싱합니다.
b) 시설의 상세정보를 조회하는 경우
클라이언트는 조회할 시설의 id를 로드밸런서로 전송합니다.
로드밸런서는 URL을 보고 해당 요청을 시설 서비스 서버로 보냅니다.
시설 서비스 서버는 글로벌 캐시 서버로부터 해당 시설id에 대한 상세 정보를 가져옵니다. 만약 캐싱된 게 없다면 DB의 business테이블에서 가져와서 캐싱합니다.
시설 정보가 추가/갱신/삭제된 경우, 해당 정보가 실시간으로 반영될 필요는 없음을 처음에 가정했으므로 캐시에 보관된 시설 정보의 갱신은 밤 사이에 배치를 수행하는 방식으로 처리할 수 있습니다.
최근 사내에서 Virtual Thread에 대한 기술 세미나를 열었는데요. 사내 세미나 한 번으로 해당 내용을 묵혀두기는 아까워서 유튜브 영상으로도 공유하고.. 블로그에도 남겨볼려고 합니다. 다만 사내 세미나와 영상에서는 비IT 직무 인원들도 이해할 수 있게끔 최대한 비유를 들며 쉽게 설명하려고 했던 것과 다르게 블로그에는 최대한 기술적으로(?) 작성해보고자 합니다.
Virtual Thread는 JDK21버전부터 공식 Feature로 포함된 기술로, 블록체인이나 AI같이 산업의 변화를 이끌고 있는 기술보다는 성능 최적화를 위해 쓰이는 뒷단의 기술에 가깝습니다. Java를 기반으로 하는 플랫폼이 기존에 어떤 컨셉의 모델을 사용해왔고, 이게 어떤 한계가 있었고, 이를 바탕으로 Virtual Thread가 어떤 목적으로 나온 기술인지 이번 포스트에서 살펴보겠습니다. 그리고 Virtual Thread의 Context Switching, Throughput에 대한 테스트 내용과 사용 시 주의사항에 대해 소개하겠습니다.
목차는 다음과 같습니다.
Thread per Request & Thread Pool (Java가 전통적으로 사용해 온 방식)
Reactive Programming의 등장 및 한계
Virtua Thread의 개념과 장점
Virtual Thread의 동작 방식 및 원리
Virtual Thread 테스트 - Context Switching
Virtual Thread 테스트 - Throughput
Virtual Thread 주의사항 - Pinning
정리
1. Thread per Request & Thread Pool
Spring으로 대표되는 Java 기반 플랫폼은 사용자로부터 하나의 Request가 들어오면 하나의 스레드로 이를 처리하는 Thread per Request라는 방식을 옛날부터 사용해왔습니다. 그러나 매 Request마다 새로운 스레드를 생성하는 것은 비용 부담이 크기 때문에, 미리 일정량의 스레드를 만들어두고 Request가 들어올 때마다 만들어둔 스레드에게 해당 Request의 처리를 맡기는 Thread Pool이란 개념을 사용해왔습니다.
그러나 시간이 흐르며 서비스의 규모가 커지고 Request의 양이 늘어남에 따라, 서비스의 처리량을 늘려야 하는 미션이 생기게 됩니다. 가장 심플한 방법은 스레드풀의 사이즈, 즉 스레드의 개수를 늘리는 것이지만 공간적인 한계 때문에 스레드의 개수가 제한될 수밖에 없었고, 이는 서비스의 처리량이 일정 수준으로 한정되는 결과로 이어졌습니다. 그리고 Java는 자바 스레드(유저 스레드)와 커널 스레드가 1 : 1로 매핑되는 스레딩 모델을 사용하기 때문에, 스레드 간 전환은 곧 커널 스레드간의 전환을 의미하고 이는 Context Switching 비용 부담이 점점 부각되는 문제점을 보이게 됐습니다. 또한 자바 스레드가 I/O 작업 등으로 인해 Blocking되면 매핑된 커널 스레드까지 함께 Blocking됐기 때문에, 만들어둔 스레드들이 실질적으로 task를 수행하는 시간보다 대기하는 시간 즉 idle time이 훨씬 더 많아지는 문제점도 부각되기 시작했습니다.
이런 상황에서 어쨌거나 처리량을 올려야 했고, 가장 단순한 방법인 "스레드 늘리기"는 공간적 제약으로 할 수 없는 상황입니다. 전통적으로 사용해 온 Thread per Request는 하나의 스레드가 하나의 요청을 처리하는 방식인데, "하나의 스레드가 두 개 이상의 요청을 동시에 처리하게 한다면 처리량이 올라가지 않는가?"라는 아이디어가 나오게 됩니다.
※ 이 때의 "동시"는 시간적으로 동시에 한다는 개념이 아니라, 스레드가 대기할 동안 다른 일을 시킨다는 개념입니다.
이 아이디어를 바탕으로 Reactive Programming이 등장하게 됩니다.
2. Reactive Programming의 등장 및 한계
"하나의 스레드가 동시에 두 개 이상의 Request를 처리하게 한다"라는 생각을 바탕으로 등장한 비동기 + Non-Blocking 방식의 프로그래밍 패러다임입니다.
컴퓨터 자원을 좀 더 효율적으로 사용할 수 있었기 때문에, Reactive Programming을 통해 더 적은 스레드로 더 많은 Request를 처리할 수 있었습니다. 즉 주어진 미션대로 처리량을 높일 수 있었습니다.
그러나.. Reactive Programming은 다음과 같은 단점들이 있었습니다.
1. 높은 진입장벽
"하나의 스레드가 동시에 두 개 이상의 Request를 처리하게 한다"라는 것은 기존의 동기적인 관점에서 벗어나 비동기라는 관점에서 프로그래밍을 해야 했습니다. 그렇다보니 러닝커브가 높아지게 됐고, 이는 기존에 사용하던 동기적인 방식의 코드와 로직을 전환하는 것에도 부담이 되는 요소로 작용하게 됐습니다. 여러 로직이 맞물릴 때, 하나만 비동기로 바꾸면 되는 게 아니라 전체를 비동기로 바꿔야 하기 때문인 것도 있습니다.
2. 직관적인 코드 이해의 어려움
기존의 동기적인 관점에서 코드를 작성할 땐, 주어진 비즈니스 요구사항 즉 비즈니스 프로세스가 이 코드에 잘 반영됐는지 파악하는 것이 상대적으로 쉬웠습니다. 또한 새로운 기능을 추가해야 할 때, 기존 코드의 어느 부분에 추가해야 할지 판단하는 것도 어렵지 않았습니다. 하지만 Reactive Programming에서는 우리의 비즈니스 프로세스가 이 코드에 잘 반영됐는지, 새로운 기능을 추가한다면 어느 부분에 추가해야 하는지를 판단하는 것이 상대적으로 어려웠습니다. 1번과 이어지는 얘기지만, 결국 Reactive Programming은 프로그래밍 패러다임, 즉 프로그래밍을 하는 관점 자체를 달리 하는 것이 원인인 것이죠.
3. 어려워진 디버깅, 예외처리
기존에는 Thread per Request, 하나의 스레드가 하나의 Request을 처리하는 컨셉이었기 때문에, 문제가 발생하면 콜스택 등을 통해 디버깅하거나 예외처리를 해주는 것이 상대적으로 쉬웠습니다. 그러나 Reactive Programming은 Request(다른 말로는 task)를 여러 스텝으로 쪼개고 각 스텝들을 파이프라인(다른 말로는 스트림) 형태로 엮어서 여러 스레드에서 처리하게 됩니다. 그렇다보니 콜스택을 통해 디버깅하거나 예외처리하는 것이 상대적으로 어려워지게 됐습니다.
정리하면, Reactive Programming을 통해 분명 처리량을 높일 수 있었습니다. 하지만 그에 반비례해서 개발 생산성이 떨어진다는 리스크가 있었죠. "Reactive Programming 이거 분명 잘 쓰면 좋아. 근데 잘 쓰기가 너무 어려워!"라는 문제점이 점점 부각되기 시작했습니다.
그래서 다시 Thread per Request & Thread Pool 이라는 원점으로 돌아옵니다. 주어진 미션은 여전히 "처리량을 높이는 것"입니다. 하지만 여기에 한 스푼 더 얹어서 "기존의 동기적인 코드 흐름을 유지할 순 없을까?" 라는 욕심이 들기 시작합니다. 이를 충족시키는 가장 간단한 방법은 역시나 "스레드 개수 늘리기"이지만, 공간적인 제약 때문에 그렇게 할 수 없었죠. (스레드는 메모리에서 2MB정도까지 공간을 차지할 수 있다고 합니다) 근데 여기서, "그러면 스레드를 가볍게 만들면 엄청 많이 만들 수 있는 거 아냐?" 라는 관점이 나오기 시작합니다.
이 아이디어를 바탕으로, 스레드를 가볍게, "경량"으로 만들어서
서비스의 처리량 향상
기존의 동기적인 코드 흐름 유지
라는 2개의 목표를 달성하기 위해 본 포스트의 메인 주제인 Virtual Thread가 등장하게 됩니다.
3. Virtual Thread의 개념과 장점
앞서 말씀드렸듯이 Java가 사용하는 스레딩 모델은 자바 스레드와 커널 스레드의 1 : 1로 매핑 모델입니다. CPU의 실질적인 스케쥴링 대상은 커널 스레드로, 커널 스레드가 CPU를 점유하면 그에 매핑된 자바 스레드가 수행되는 구조로 이해할 수 있습니다. Virtual Thread는 기존의 자바 스레드보다 더 가벼운 스레드를 만들어 하나의 자바 스레드에 다수의 Virtual Thread가 매핑시키는 구조로,커널 스레드가 CPU를 점유하면 매핑된 자바 스레드에 mount된 Virtual Thread가 실행되는 개념입니다.
동작 과정도 간단히 살펴보면, 기존에는 자바 스레드가 Blocking되면 매핑된 커널 스레드까지 Blocking되는 구조였습니다. 그러나 Virtual Thread는 실행 중 Blocking된다면 mount되어 있던 자바 스레드에서 unmount되고, 다른 Virtual Thread가 자바 스레드에 mount되어 실행되는 구조입니다. 따라서 실질적으로 CPU를 점유하고 있는 커널 스레드는 Blocking되지 않게 됩니다.
이러한 특징 덕분에, Virtual Thread 사용 시 기존에 사용하던 동기적인 코드 흐름을 그대로 유지할 수 있으면서도, 내부적으론 비동기 방식과 비슷하게 동작하게 되어 Non-Blocking 처리가 가능해져 처리량을 높일 수 있게 됩니다. 또한 기존 스레드 간의 Context Switching은 커널 레벨에서 발생하지만 Virtual Thread간의 Context Switching은 유저 레벨에서 발생한다는 특징이 있는데요, 이를 통해 Context Switching 비용을 좀 더 아낄 수 있게 된다는 장점도 있습니다.
구체적인 동작 방식을 통해 좀 더 살펴보겠습니다.
4. Virtual Thread의 동작 방식 및 원리
0. Virtual Thread 주요 구성 요소
동작 방식 설명 전 Virtual Thread의 주요 구성 요소들을 살펴보면 다음과 같습니다.
1) ForkJoinPool
Virtual Thread들의 static 멤버(즉 모든 Virtual Thread가 공유)로, Virtual Thread들을 스케쥴링하는 스케쥴러 역할을 합니다.
2) Carrier Thread
Virtual Thread이 mount되어 실행되는 실질적인 자바 스레드이며, Carrier Thread들은 저마다 하나씩 WorkQueue라 불리는work-stealing 방식으로 동작하는 큐를 가집니다. 논리적인 관점에서 Carrier Thread는 Virtual Thread와 1 : N의 매핑 관계를 가지며, Carrier Thread와 커널 스레드는 1 : 1의 매핑 관계를 가집니다.
※ work-stealing : Carrier Thread들이 자신의 workQueue에 아무 것도 없으면 다른 Carrier Thread의 workQueue에 있는 걸 훔쳐와서 사용하는데, 이를 work-stealing이라고 표현합니다.
3) Continuation
Virtual Thread가 실행해야 하는 작업과 그 작업에 대한 정보(어디까지 실행했는지 등)을 가지는 객체입니다.
4) runContinuation
Continuation을 실질적으로 실행시키는 일종의 람다식입니다.
1. 동작 방식 - Virtual Thread가 Carrier Thread에 mount되는 과정
기존 자바 스레드는 start 메서드를 호출하면 start0라는 native 메서드를 거쳐 JNI를 통해 해당 자바 스레드와 매핑될 새로운 커널 스레드를 생성하게 됩니다. 반면 Virtual Thread는 start메서드 호출 시 submitRunContinuation 메서드가 호출되고, 스케쥴러(ForkJoinPool)의 execute 메서드가 호출됩니다. 그러면 스케쥴러가 적당한 Carrier Thread를 하나 골라 runContinuation을 해당 Carrier Thread의 WorkQueue에 넣어주게 됩니다. 이후 Carrier Thread가 본인의 WorkrQueue에 들어있는 runContiation을 뽑아 실행시키면 Virtual Thread와 Carrier Thread가 mount되고 Virtual Thread가 실행됩니다.
2. 동작 방식 - Virtual Thread가 실행 중 Blocking될 때 unmount되는 과정
Virtual Thread가 실행되다가 I/O 요청 등으로 인해 Blocking되면 내부적으로 park메서드가 호출됩니다. park메서드는 doYield라는 native 메서드를 거쳐 JNI를 통해 jvm단에서 memcpy라는 표준 C 라이브러리 함수를 호출하게 됩니다. Virtual Thread가 사용 중이던 스택 프레임을 Heap에다가 복사해두는 기능을 수행하는 것이며, 이를 통해 나중에 Blocking이 끝나고 Virtual Thread가 재개될 때 중단 지점부터 재개하는 것이 가능해집니다. 참고로 CPU에서 사용되던 PC 레지스터 값 등도 함께 Heap에 복사되는 과정도 코드를 분석해보면 보실 수 있습니다. (저는 GitHub에 있는 openjdk를 뜯어봤습니다)
이런 과정을 거치며 unmount가 진행되고, Carrier Thread는 다른 Virtual Thread와 mount되게 됩니다.
3. 동작 방식 - Blocking됐던 Virtual Thread가 다시 재개되는 과정
Blocking상태가 끝나면 내부적으로 unpark() 메서드가 호출되며, 다시 submitRunContinuation이 호출되면서 Carrier Thread의 WorkQueue에 runContinuation필드가 들어갑니다. 이후 Carrier Thread가 이를 뽑아내서 mount할 때 아까 Heap에 복사해뒀던 스택 프레임을 다시 복구시켜서 중단 지점부터 작업을 재개합니다.
4. Virtual Thread 동작 방식 최종 정리
Virtual Thread는 Carrier Thread의 WorkQueue에서 work-strealing 방식으로 mount되어 실행됩니다.
mount된 Virtual Thread는 실행이 끝나면 다른 Virtual Thread로 갈아끼워집니다. (Context Switching)
mount된 Virtual Thread는 실행 중 Blocking되면 현재까지의 실행 정보(스택 프레임)를 Heap에 저장하고 다른 Virtual Thread로 갈아끼워집니다. (Context Switching)
Blocking이 끝난 Virtual Thread는 mount될 때 Heap에 저장해둔 스택 프레임을 불러와 중단 지점부터 다시 작업을 재개합니다.
5. Virtual Thread 테스트 - Context Switching
앞서 살펴본 것처럼 Virtual Thread 간의 Context Switching은 Virtual Thread의 실행이 끝났을 때 또는 Virtual Thread가 실행되다가 Blocking됐을 때 발생합니다. 그리고 이 과정은 코드 레벨에서 살펴봤듯 스택 프레임을 Heap에 저장하는 과정 등을 거치며 유저 레벨에서 발생됩니다. (참고 : 스택 프레임을 Heap에 복사할 때 사용되는 memcpy는 표준 C 라이브러리 함수로 시스템콜이 아닙니다)
코드 상에선 그렇게 확인했지만, 실제로 Virtual Thread간의 Context Switching이 유저 레벨에서 발생하는지를 직접 검증하고 싶었습니다. 그래서 다음과 같은 코드를 준비했습니다. 코드 상에서 회사명이 나오는 부분들은 가렸습니다.
총 200개(THREAD_COUNT)의 자바 스레드를 만들고, 각각의 스레드가 10ms 동안 대기하는 과정을 100번(ITERATIONS)만큼 반복하여, 프로그램 실행 중 최소 20,000번(THREAD_COUNT X ITERATIONS) 이상의 Context Switching이 발생하도록 합니다. 동일한 코드를 Virtual Thread를 활용한 방식으로도 작성해줍니다.
이 각각의 프로그램들을 컴파일한 뒤, Linux에서 제공하는 커널 퍼포먼스 측정 도구인 perf를 활용해 각 프로그램을 실행하는 과정에서 Context Switching이 몇 번 발생했는지 측정해줍니다. 이때 커널 레벨에서 발생한 Context Switching이 측정되는 것이므로, 정말 Virtual Thread가 Context Switching이 유저 레벨에서 발생한다면 기존 스레드를 사용했을 때가 Context Switching 횟수가 더 높게 측정될 것임을 예측할 수 있습니다.
총 3번씩 측정했으며, 결과는 다음과 같습니다.
기존 스레드
Virtual Thread
Context Switching 횟수
약 19,900 / s
약 5,300 / s
유저 모드, 커널 모드 실행 시간 비율
약 1.3 : 1
약 6.2 : 1
물론 이 테스트에서 기존 스레드는 커널 스레드가 생성되는 과정이 함께 측정되는 부분도 감안해야 합니다. 그러나 Virtual Thread를 사용한 프로그램이 기존 스레드를 사용한 프로그램보다 3배 이상 커널 레벨에서 Context Switching이 덜 발생했고, 유저 모드와 커널 머드에서의 실행 시간 비율을 볼 때도 Virtual Thread를 사용했을 때가 유저 레벨에서 실행된 비율이 더 높음을 쉽게 파악할 수 있습니다. 이를 통해 Virtual Thread간의 Context Switching이 유저 레벨에서 발생한다는 것을 실질적으로 확인할 수 있었고, 따라서 Virtual Thread를 통해 Context Switching 비용을 줄일 수 있음을 도출할 수 있습니다.
6. Virtual Thread 테스트 - Throughput
이번에는 Virtual Thread 사용시 정말 처리량을 높일 수 있는지를 테스트해봤습니다. 실제 업무에서 쓰이는 API에 테스트해보면 좋겠지만 그럴 수 없던 관계로.. 다음과 같이 Spring Boot를 활용해 간단히 2개의 API를 만들어줬습니다.
첫 번째로는 500ms씩 2번 Sleep하게 하는 API를 만들어주고, 두 번째로는 1부터 1억까지의 연산을 2번 반복하는 API를 만들어줘서 각각 I/O 작업 위주로 비즈니스 로직을 처리하는 상황과 CPU 연산 위주로 비즈니스 로직을 처리하는 상황을 가정해줍니다. 이를 토대로 I/O Bound 상황과 CPU Bound 상황에 대해, 기존 스레드 사용 시와 Virtual Thread의 사용 시의 처리량을 측정해봅니다.
테스트 환경은 다음과 같습니다.
Ubuntu 24.04
2 core CPU / 4GB RAM
Spring Boot 3.3.4 / OpenJDK21
K6로 VU를 2,000까지 늘리며 진행, 각 3회씩 측정
먼저 I/O Bound 상황에 대한 결과입니다.
TPS
Response Time (avg)
Virtual Thread - 1회
911
1.06 s
Virtual Thread - 2회
881
1.07 s
Virtual Thread - 3회
839
1.08 s
기존 스레드 - 1회
189
5.27 s
기존 스레드 - 2회
188
5.27 s
기존 스레드 - 3회
189
5.27 s
물론 간단한 코드를 기반으로 테스트한 것이므로 해당 결과가 실제 운영 환경을 대변하진 않습니다. 그래도 결과를 해석해보면, I/O Bound 상황에서 Virtual Thread는 Non-Blocking 처리가 가능하기 때문에 기존 스레드보다 더 높은 처리량을 보이고 있음을 눈으로 확인해볼 수 있습니다. 또한 Spring Boot에 내장된 톰캣의 스레드풀 사이즈를 별도로 설정해주지 않아 스레드풀 사이즈가 디폴트값인 200으로 만들어졌었는데요. 500ms씩 2번, 총 1초 정도를 대기하도록 API를 구성했기 때문에 기존 스레드를 사용한 결과가 TPS가 200 가까이는 올라가지만 200을 넘지 못하고 있는 것도 확인해볼 수 있었습니다.
다음으론 CPU Bound 상황에 대한 결과입니다.
TPS
Response Time (avg)
Virtual Thread - 1회
21
28.19 s
Virtual Thread - 2회
20
28.40 s
Virtual Thread - 3회
20
28.38 s
기존 스레드 - 1회
23
27.10 s
기존 스레드 - 2회
24
26.91 s
기존 스레드 - 3회
23
27.12 s
마찬가지로 이 결과가 운영 환경을 대변하진 않지만, 결과를 보면 CPU Bound 상황에서는 Virtual Thread 사용시 오히려 처리량이 낮아지는 걸 볼 수 있습니다. Virtual Thread는 앞서 살펴본 것처럼 실행 시 Carrier Thread의 WorkQueue에 들어갔다가 mount되고, 다 끝난 뒤엔 unmount를 하는 과정 등을 거칩니다. 따라서 작업을 실행하는 데 드는 비용 자체는 기존 스레드가 Virtual Thread보다 더 저렴합니다. Blocking을 처리하는 비용이 Virtual Thread가 기존 스레드보다 훨씬 더 저렴한 것이죠. 따라서 작업을 실행하는 도중에 Blocking이 발생하지 않는 경우라면 오히려 Virtual Thread를 사용하는 것이 안티 패턴이 될 수도 있음을 도출할 수 있습니다. 이 테스트에서 사용된 CPU Bound 상황에 대한 것을 그 예로 볼 수 있습니다.
7.Virtual Thread 주의사항 - Pinning
Virtual Thread는 Carrier Thread에 mount되어 수행되다가, Blocking되면 Carrier Thread로부터 unmount된다고 소개했습니다. 근데 Virtual Thread가 Carrier Thread에 mount된 상태로 고정되어, Blocking이 발생해도 unmount가 되지 않는 상황이 발생할 수 있습니다. 이를 Pinning현상(Pinned 현상이라고도 부릅니다)이라고 부르며, 다음 상황에서 발생합니다.
Virtual Thread에서 synchronized 블록 사용 시
Virtual Thread에서 native method 사용 시
synchronized 블록은 모니터 락을 사용하여 동시 접근을 제어하는데, Virtual Thread가 아닌 Carrier Thread 수준(jvm 단)에서 이 락을 잡다보니 락을 획득한 Carrier Thread가 연관된 작업을 처리하는 동안 다른 작업을 처리할 수 없도록 고정되는 것이 원인입니다. Native method 역시 비슷한 이유로 고정됩니다. 이렇게 Pinning 현상이 발생하면 Virtual Thread의 장점이자 존재 이유(?)인 "Blocking 시 갈아끼우기"를 시전할 수 없게 되므로 성능 저하의 직접적인 원인이 될 수 있습니다.
Pinning 현상을 직접 테스트하기 위해 다음과 같은 코드를 준비했습니다.
2개의 Virtual Thread를 만들어 실행하는데, 각 Virtual Thread는 현재 스레드의 정보를 로그로 남기고, 2초 대기하다가 다시 스레드의 정보를 로그로 남기는 메서드를 실행하도록 합니다. 이 때 run이란 메서드를 synchronized로 묶지 않았을 때와 묶을 때 각각 어떻게 결과가 나오는지를 테스트합니다.
참고로 Carrier Thread는 여러 개가 만들어질 수 있는데, 제가 만든 2개의 Virtual Thread가 서로 다른 Carrier Thread에 mount되어 실행된다면 이 테스트는 의미가 없습니다. 따라서 다음과 같이 옵션을 줘서 Carrier Thread가 하나만 만들어지게 하고 테스트를 진행했습니다.
우선 로그에 남아있는 스레드 정보가 ForkJoinPool-1-worker-1로 동일하기 때문에, 하나의 Carrier Thread에서 Virtual Thread들이 실행됐음을 볼 수 있습니다. Pinning이 발생하지 않은 경우 VT1이 먼저 로그를 찍고 sleep에 들어가면서 VT2로 갈아끼워지게 되고, VT2도 로그를 찍은 뒤 sleep에 들어가게 된 것을 볼 수가 있습니다. 2초 뒤에 VT1, VT2 둘 다 깨어나면서 로그를 찍게 되므로 전체 실행 시간이 약 2초임을 볼 수 있습니다. 반면 Pinning이 발생한 경우 VT1이 먼저 로그를 찍고 sleep에 들어가지만 VT2로 갈아끼워지지 못하게 되고, 2초 뒤에야 VT2로 갈아끼워지면서 전체 실행 시간이 4초 정도로 나오는 것을 볼 수 있습니다.
이를 통해 실질적으로 Virtual Thread 사용 중 Pinning이 발생한다면 성능 저하의 원인이 될 수 있음을 도출했습니다. Pinning이 발생된 구간에서 Blocking이 발생하지 않는다고 해도, 앞서 말씀드린 것처럼 작업을 실행하는 비용 자체는 기존 스레드가 더 저렴하기 때문에 Virtual Thread 사용 시 Pinning이 발생하지 않도록 주의할 필요가 있습니다.
글을 작성하는 시점인 24년 10월 9일 현재, Pinning에 대해 다음과 같이 조치할 수 있습니다.
synchronized block은 ReentrantLock으로 대체
native method 사용은 최애한 지양
참고로 현재 여러 Java Library들이 Virtual Thread와의 호환을 위해 내부적으로 사용하던 synchronized를 ReentrantLock으로 바꾸는 작업을 진행하고 있습니다.
8. 정리
1) 그래서 언제 쓰면 좋을까?
우선 다시 한 번 상기할 점은 작업을 실행하는 데 드는 비용 자체는 기존 스레드가 더 저렴하나, Blocking 처리 비용이 Virtual Thread가 훨씬 저렴하다는 것입니다. 또한 Virtual Thread는 지연시간이 아닌 처리량을 높이는 기술이므로, API 응답 시간을 줄이는 것 등이 주어진 미션일 때는 Virtual Thread 도입을 권장하지 않습니다.
현재 시스템이 Thread per Request를 기반으로 하는 Spring mvc 등을 사용 중일 때 Virtual Thread를 도입을 고려할 수 있습니다. 만약 현재 시스템이 Reactive Programming의 대표 격인 Spring Webflux 등을 사용 중이라면, Virtual Thread와는 패러다임 자체가 다르므로(Reactive Programming인 비동기, Virtual Thread는 동기) Virtual Thread 도입은 안티 패턴이 될 수 있습니다.
또한 현재 시스템이 받는 워크로드가 I/O 작업 위주이며, 시스템의 주 병목 원인이 이 I/O 작업들 위주인 것이 분명하다면 Virtual Thread 도입 시 처리량 향상을 꾀할 수 있습니다. 만약 워크로드가 CPU 연산 위주라면, 아까 살펴본 것처럼 Virtual Thread의 도입은 안티 패턴이 될 수 있습니다.
2) Virtual Thread 도입 시 검토해볼 것들
Pinning 발생을 방지하기 위해, 사용 중인 코드나 사용 중인 라이브러리 또는 프레임워크 내부에 synchronized 등을 사용하는 곳이 있는지 검토해야 합니다. -Djdk.tracePinnedThreads 옵션을 통해 Pinning을 trace할 수 있으며, 특히 Pinning이 발생한 구간에서 Blocking되는 부분이 있는지도 검토해야 합니다.
그리고 Virtual Thread 사용 시, 시스템과 연동되는 타 시스템으로 폭발적인 트래픽 전파가 발생할 수 있음을 주의해야 합니다. 처리량이 올라가게 되면, 평소에 다른 시스템으로 10개의 요청을 보내다가 갑자기 100개 1,000개 10,000개 이상의 트래픽을 보내게 될 수 있습니다. Virtual Thread에는 배압을 조절하는 기능이 아직 없다보니, 상대 시스템도 이러한 트래픽을 처리할 수 있는 능력(?)이 되는지를 검토해볼 필요가 있습니다. 특히 DB connection같은 한정된 개수의 자원에 접근하는 경우, 내가 보낸 요청들이 되려 timeout이 나면서 떨어질 수 있는 가능성도 있습니다. 현재는 세마포어를 통해 배압을 조절하는 것이 권장되고 있으나, 결국 개발자 본인들이 Virtual Thread를 통해 해당 내용에 대해 인지하고 있을 필요가 있습니다.
이 외에도, Virtual Thread는 기존 스레드와 달리 매우 많은 수가 생성될 수 있으므로 ThreadLocal 등을 사용하는 곳이 있는지 검토할 필요가 있습니다.
Java Native Interface의 약자로, 자바 코드에서 네이티브 코드(하드웨어와 운영체제가 직접 실행할 수 있는 기계어 또는 바이너리 코드를 의미)를 호출하거나, 반대로 네이티브 코드에서 Java 코드를 호출할 수 있게 해주는 프레임워크를 말합니다. C언어 또는 C++언어로 작성된 코드는 컴파일 시 해당 하드웨어에서 직접 구동될 수 있는 기계어로 컴파일되기 때문에 네이티브 코드에 해당되는데요(자바는 플랫폼에 독립적인 바이트코드로 컴파일된다는 점을 참고하면 좋습니다). 따라서 자바 코드에서 JNI를 통해 C언어 또는 C++언어로 작성된 코드를 실행할 수 있게 됩니다.
주로 다음과 같은 상황들에 JNI를 활용할 수 있습니다.
성능 최적화 : 성능이 중요한 부분을 C/C++로 구현하고, 이를 자바 코드에서 호출할 때 사용합니다.
기존 라이브러리 사용 : 이미 C/C++로 작성된 기존 라이브러리나 API를 자바 애플리케이션에서 활용하고자 할 때 사용
플랫폼 종속 기능 : 플랫폼에 특화된 기능(시스템콜 호출, 하드웨어 제어 등)이 필요한 경우 사용
사용 방법
메서드 앞에 native를 붙임으로써 네이티브 메서드임을 명시할 수 있습니다.
public class Jofe {
public native void nativeMethod();
static {
System.loadLibrary("jofe");
}
public static void main(String[] args) {
new Jofe().nativeMethod();
}
}
native로 선언된 nativeMethod는 해당 메서드가 네이티브 라이브러리에서 구현됨을 의미합니다. Jofe클래스가 처음 로드될 때 네이티브 라이브러리인 jofe를 로드하는 것도 확인 가능합니다. main 메서드에서 Jofe 객체를 생성하고 nativeMethod를 호출하면, JVM은 네이티브 코드로 연결되어 해당 네이티브 메서드가 실행되게 됩니다.
다음 명령어의 실행을 통해 JNI를 사용하기 위한 헤더 파일을 만들 수 있습니다.
javac -h {헤더파일을 둘 위치} {Jofe.java의 경로}
# ex : javac -h . Jofe.java
그러면 다음과 같이 헤더파일이 만들어집니다.
/* DO NOT EDIT THIS FILE - it is machine generated */
#include <jni.h>
/* Header for class Jofe */
#ifndef _Included_Jofe
#define _Included_Jofe
#ifdef __cplusplus
extern "C" {
#endif
/*
* Class: Jofe
* Method: nativeMethod
* Signature: ()V
*/
JNIEXPORT void JNICALL Java_Jofe_nativeMethod
(JNIEnv *, jobject);
#ifdef __cplusplus
}
#endif
#endif
이 헤더파일을 기반으로, c언어로 해당 네이티브 메서드를 다음과 같이 구현할 수 있습니다.
대표적으로 스레드의 생성 등에 활용하고 있습니다. 다음과 같이 Thread의 start()메서드를 까보면, 내부적으로 start0()이란 네이티브 메서드를 호출하고 있음을 볼 수 있습니다.
// Thread.java
public void start() {
synchronized (this) {
// zero status corresponds to state "NEW".
if (holder.threadStatus != 0)
throw new IllegalThreadStateException();
start0();
}
}
private native void start0();
// jobject : 여기선 Java 스레드 객체(java.lang.Thread)를 나타냄. 이를 통해 스레드를 시작
JVM_ENTRY(void, JVM_StartThread(JNIEnv* env, jobject jthread))
#if INCLUDE_CDS
// .. 생략
#endif
// JVM 내에서 자바스레드(유저스레드)를 표현하고 관리하는 핵심 클래스
// 자바스레드(유저스레드)와 커널 스레드를 연결하는 역할
JavaThread *native_thread = NULL;
// 이미 시작된 스레드를 다시 시작하려고 하는 경우 예외를 던지는지 여부를 결정하는 플래그
bool throw_illegal_thread_state = false;
{
// 뮤텍스 잠그고 시작
MutexLocker mu(Threads_lock);
// 해당 Java 스레드 객체가 이미 시작된 스레드라면 => 플래그를 설정
if (java_lang_Thread::thread(JNIHandles::resolve_non_null(jthread)) != NULL) {
throw_illegal_thread_state = true;
} else {
// 스레드가 이미 시작되지 않은 경우, 스택 크기를 가져와서 새로운 JavaThread 객체 생성
jlong size =
java_lang_Thread::stackSize(JNIHandles::resolve_non_null(jthread));
//
NOT_LP64(if (size > SIZE_MAX) size = SIZE_MAX;)
size_t sz = size > 0 ? (size_t) size : 0;
// 여기서 커널스레드가 새로 만들어짐
native_thread = new JavaThread(&thread_entry, sz);
// 커널스레드가 만들어졌다면, 자바스레드(유저스레드)를 매핑
if (native_thread->osthread() != NULL) {
native_thread->prepare(jthread);
}
}
}
// 플래그 설정에 따른 예외 던지기
if (throw_illegal_thread_state) {
THROW(vmSymbols::java_lang_IllegalThreadStateException());
}
assert(native_thread != NULL, "Starting null thread?");
// 일종의 예외 처리
if (native_thread->osthread() == NULL) {
ResourceMark rm(thread);
log_warning(os, thread)("Failed to start the native thread for java.lang.Thread \"%s\"",
JavaThread::name_for(JNIHandles::resolve_non_null(jthread)));
native_thread->smr_delete();
if (JvmtiExport::should_post_resource_exhausted()) {
JvmtiExport::post_resource_exhausted(
JVMTI_RESOURCE_EXHAUSTED_OOM_ERROR | JVMTI_RESOURCE_EXHAUSTED_THREADS,
os::native_thread_creation_failed_msg());
}
THROW_MSG(vmSymbols::java_lang_OutOfMemoryError(),
os::native_thread_creation_failed_msg());
}
JFR_ONLY(Jfr::on_java_thread_start(thread, native_thread);)
// 스레드 시작
Thread::start(native_thread);
JVM_END
주석에도 명시했지만, new JavaThread()에서 다음과 같이 커널스레드가 생성되는 과정도 볼 수 있습니다.
자바의 함수형 인터페이스(Functional Interface)는 람다식과의 호환성을 위해 하나의 추상 메서드만 가져야 합니다. 람다식은 실제론 "익명 클래스(Anonymous Class)의 객체"와 동등하기 때문에, 람다식이 함수형 인터페이스에 작성된 메서드와 1 : 1로 매핑되기 위해서 함수형 인터페이스는 하나의 추상 메서드만 가져야 한다는 것이죠. 실제로 다음과 같이 함수형 인터페이스에 두 개 이상의 추상 메서드를 작성하면 오류가 발생합니다.
그러나, 자바에서 함수형 인터페이스로 정의된 Comparator는 다음과 같이 두 개의 추상 메서드를 가지고 있습니다.
@FunctionalInterface
public interface Comparator<T> {
int compare(T o1, T o2);
boolean equals(Object obj);
// ..생략
}
equals가 추상메서드로 취급되지 않는 모습을 볼 수 있었던 거죠. 뭔가 해서 직접 실험해보니, 함수형 인터페이스를 작성할 때 다음과 같이 equals를 추상메서드로 작성해도 오류가 나지 않는 걸 볼 수 있었습니다. equals는 Object 클래스에 정의된 메서드로, 마찬가지로 toString같은 Object클래스에 정의된 함수를 함수형 인터페이스에서 추상메서드로 작성해도 오류가 없었습니다.
함수형 인터페이스는 하나의 추상메서드만 가져야 한다는데, 위에서 분명 저는 3개의 추상메서드를 함수형 인터페이스에 작성했음에도 괜찮았습니다. 왜 이런 결과가 나오는지를 조사해봤고, 이 글에서 소개하려 합니다.
Object 클래스의 메서드이기 때문이다
이유는 간단하게, equals 등의 메서드는 Object 클래스에 작성된 메서드이기 때문입니다. 그리고 자바의 모든 객체는 Object 클래스를 상속(extends)하죠. 그렇기 때문에 괜찮았습니다.
조금 다른 얘기를 먼저 해보겠습니다. 자바에서는 다중 상속을 기본적으로 지원하지 않습니다. A라는 클래스와 B라는 클래스를 상속받을 때 A와 B 둘다 같은 이름의 메서드를 가진다면 모호성이 생기기 때문이죠. 하지만 인터페이스라면, 어차피 구현을 개발자가 원하는대로 할 수 있기 때문에 여러 인터페이스를 구현할 수 있습니다.
MyInterface를 구현하는 클래스들은 someMethod1과 someMethod2를 둘 다 구현해야 합니다. 이 상황에서, YourInterface라는 인터페이스와 이를 구현한 YourClass라는 클래스를 보겠습니다.
interface YourInterface {
void someMethod2();
}
class YourClass implements YourInterface {
public void someMethod2() {
System.out.println("someMethod2");
}
}
YourInterface는 MyInterface와 마찬가지로 someMethod2라는 이름의 추상 메서드를 가지며, YourClass가 YourInterface를 구현한 모습입니다. 이때 다음과 같이 MyInterface를 구현하는 MyClass라는 클래스를 만들되, MyClass가 YourClass를 상속받게 하면서 someMethod1만 오버라이딩하게 한다면 어떨까요?
분명 MyClass는 MyInterface를 구현하면서 someMethod2라는 추상 메서드도 오버라이딩해야 하지만, 오류가 나지 않습니다. 왜냐하면 MyClass가 상속하는 YourClass에서 이미 someMethod2라는 메서드를 오버라이딩했기 때문이죠. 따라서 MyClass의 인스턴스를 만들어서 someMethod2를 호출하면, YourClass에서 오버라이딩된 메서드가 호출되게 됩니다.
public class Main {
public static void main(String[] args) {
MyClass myClass = new MyClass();
myClass.someMethod2();
}
}
이때, MyClass에서 별도로 someMethod2를 오버라이딩하면, MyClass의 인스턴스에서 someMethod2 실행시 오버라이딩된 메서드가 실행되게 되겠죠. (실행결과는 생략..)
여기서 힌트를 얻을 수 있었습니다. 자바에서 모든 클래스는 Object를 상속받습니다. 즉 별도로 구현하지 않아도, equals 등의 메서드를 가지고 있습니다. 따라서 Object 클래스에 정의된 메서드를 추상메서드로 작성해서 인터페이스를 만든 경우, 해당 인터페이스를 구현하는 클래스에서 별도로 그 메서드를 오버라이딩하지 않아도 문제가 생기지 않는 것이죠.
interface MyInterface {
void myMethod();
boolean equals(Object obj);
}
// Object로부터 상속받은 equals를 가지고 있다
class MyClass implements MyInterface {
public void myMethod() {
System.out.println("MyClass");
}
}
따라서 equals는 MyInterface에서 추상메서드로 작성되긴 했으나, MyInterface에게 본질적인 추상메서드는 1개(myMethod)인 셈입니다. 따라서 함수형 인터페이스를 만들 때 Object 클래스에서 정의된 메서드를 추상메서드로 작성해도, 본질적인 추상메서드만 1개라면 오류가 발생하지 않는 겁니다.
그렇다면, 인터페이스에 Object클래스에 정의된 메서드를 추상메서드로 작성하는 건 어떤 의미를 가질까요? 바로 개발자들로 하여금, 해당 인터페이스를 구현하는 클래스를 만들 때 필요하다면 해당 메서드를 오버라이딩해 작성해야 함을 알려주는 기능과, 이 인터페이스를 구현하는 클래스들이 반드시 이 메서드들을 올바르게 구현해야 한다는 의도를 명확히 전달하는 기능을 합니다.
스레드란 하나하나의 실행 단위를 말하며, 흔히 프로세스를 차를 만드는 공장에 비유한다면 스레드를 바퀴를 만드는 일꾼, 모터를 만드는 일꾼 등에 비유하곤 합니다.
이 스레드는 커널 공간에서 구현하는 커널 스레드와 유저 공간에서 구현하는 유저 스레드 두 종류로 나눌 수 있습니다.
커널 스레드 (Kernel Threads)
: 운영 체제의 "커널"이 관리하는 스레드입니다. 프로세스가 생성되면 커널은 하나의 커널 스레드를 만드는데, "프로세스"가 아닌 이 "커널 스레드"가 실질적인 스케쥴링 대상이 되며 하나의 프로세스는 하나 이상의 커널 스레드를 가집니다.
유저 스레드 (Kernel Threads)
: 유저 공간에서 라이브러리에 의해 관리되는 스레드입니다. 커널은 유저 스레드의 존재를 모르며, 모든 유저 스레드의 관리는 유저 공간에서 이루어집니다. 유저 스레드의 실질적인 수행은 자신이 속한 프로세스의 커널 스레드와 연결돼서 수행됩니다.
좀 더 이해하기 쉽도록, A라는 프로세스에 대해 커널 스레드를 2개 만들 때와 유저 스레드를 2개 만들 때를 도식화해서 보겠습니다.
이때, 실질적으로 스케쥴링 대상이 되는 것은 커널 스레드이며, 커널은 유저 스레드의 존재를 모른다고 했습니다. 따라서 프로세스 A, B가 있는 상황에서 A에 커널 스레드만 2개 있는 경우와 A에 유저 스레드가 2개 있는 경우는 다음과 같이 스케쥴링됩니다.
프로세스 A에 커널 스레드만 2개인 경우, A의 2개 커널 스레드 각각이 스케쥴링 대상이 됩니다. 따라서 A의 0번 스레드와 1번 스레드, 프로세스 B(얘도 B의 커널 스레드라고 볼 수 있겠죠)가 스케쥴링되는 걸 볼 수 있습니다. 그러나 프로세스 A에 유저 스레드가 2개인 경우, 커널은 유저 스레드를 모르며 오직 커널 스레드만이 스케쥴링 대상이 되기 때문에 A의 커널 스레드와 B의 커널 스레드만이 스케쥴링 대상이 됩니다. 이 때 A의 유저 스레드가 2개인 상황에서 A의 커널 스레드가 실행될 때, 0번 스레드(유저 스레드임)와 1번 스레드(유저 스레드임)를 어떻게 배분할지는 스레드 라이브러리가 책임집니다. 즉 유저 스레드는 자신이 속한 프로세스의 커널 스레드가 CPU에 올라왔을 때, 그 커널 스레드와 연결돼서 수행되는 스레드라고 볼 수 있는 것이죠.
"커널 스레드가 실질적인 스케쥴링 대상이다"로 비롯되는 다른 차이점은 어떤 것들이 있을까요? 우선 커널 스레드의 경우 같은 프로세스의 커널 스레드라 할지라도 다른 프로세서에 할당할 수 있는 반면, 같은 프로세스의 유저 스레드들은 다른 프로세서로 할당될 수 없을 수도 있습니다(커널 스레드가 CPU에 올라갔을 때 연결돼서 수행되기 때문). 즉 커널 스레드는 멀티프로세서 시스템에서 유저 스레드보다 더 효율적으로 작동한다고 볼 수 있죠. 그리고 커널 스레드가 시스템콜을 호출하면 해당 스레드만 블로킹되고, 다른 커널 스레드들은 계속 실행될 수 있습니다. 반면 유저 스레드는 하나의 유저 스레드가 시스템콜을 호출하면 같은 프로세스의 다른 유저 스레드들도 블로킹될 수 있습니다. 커널은 그 유저 스레드에 연결된 커널 스레드가 시스템콜을 호출한 거라고 여기기 때문에, 해당 커널 스레드가 블로킹되면서 물려있던다른 유저 스레드들도 블로킹될 여지가 있다는 거죠. 컨텍스트 스위칭 비용의 경우, 커널 스레드의 컨텍스트 스위칭이 유저 스레드의 컨텍스트 스위칭보다 가볍습니다.
표로 정리하면 다음과 같겠습니다.
커널 스레드
유저 스레드
관리 주체
커널
유저 수준 라이브러리
생성 및 관리 비용
상대적으로 높음
상대적으로 낮음
블로킹 시스템콜
한 스레드가 블로킹되어도 다른 스레드에 영향 없음
한 스레드가 블로킹되면 전체가 블로킹될 수 있음
멀티프로세서 지원
효율적
비효율적
컨텍스트 스위칭 비용
상대적으로 높음
상대적으로 낮음
그럼 이제 유저 스레드가 커널 스레드와 연결되는 3가지 스레드 모델을 살펴보겠습니다.
1. One-to-One Model
각 유저 스레드가 하나의 커널 스레드와 연결되는 모델입니다. 각 커널 스레드는 독립적으로 스케쥴링되므로, 같은 프로세스 내의 유저 스레드들은 각각 다른 프로세서에서 병렬로 실행될 수 있으며 하나의 유저 스레드에서 시스템콜을 호출해도 다른 유저 스레드가 블로킹되지 않습니다. 그러나 유저 스레드의 컨텍스트 스위칭 이점을 볼 수 없는 모델이기도 합니다.
2. Many-to-One Model
여러 유저 스레드가 하나의 커널 스레드와 연결되는 모델입니다. 따라서 같은 프로세스의 유저 스레드들이 서로 다른 프로세서에서 병렬로 실행될 수 없으며, 하나의 유저 스레드에서 시스템콜을 호출한 경우 다른 유저 스레드들도 블로킹될 수 있습니다. 그러나 유저 스레드의 컨텍스트 스위칭 이점을 얻을 수 있는 모델입니다.
3. Many-to-Many Model
여러 유저 스레드가 여러 커널 스레드와 연결되는 모델로 One-to-One과 Many-to-One을 짬뽕한 모델입니다. 같은 프로세스의 유저 스레드가 여러 프로세서에서 병렬로 실행될 수 있으며, 한 유저 스레드가 시스템콜을 호출해도 다른 커널 스레드에 연결된 유저 스레드들이 있으니 유저 스레드들이 모두 블로킹되는 불상사를 막을 수 있습니다. 유저 스레드의 컨텍스트 스위칭 이점도 챙길 수 있으나, 설계와 구현이 매우 복잡하며 유저 스레드와 커널 스레드 간 연결 관계의 관리 등에 오버헤드가 들어감을 감안해야 합니다.
참고로 자바에서 21버전부터 Virtual Thread가 도입됐는데요. 원래는 자바에서 유저 스레드를 만들면 그에 연결되는 커널 스레드도 함께 만들어졌습니다. 즉 기존에는 One-to-One 모델을 사용했던 것이죠. 그러나 Virtual Thread는 Many-to-One과 비슷한 컨셉을 차용해서 컨텍스트 스위칭 비용을 낮췄습니다. 관심있는 분은 다음 글을 보면 될 것 같습니다.