Apache Lucene에서 사용된 Incremental indexing기법의 기반이 된 Doug Cutting의 Optimizations for dynamic inverted index maintenance. 논문을 읽고 정리한 글입니다.
https://dl.acm.org/doi/10.1145/96749.98245

Apache Lucene

Apache Lucene은 오픈소스 정보 검색 라이브러리로, 트위터, 위키피디아, 링크드인, 넷플릭스, 아이튠즈 스토어, 세일즈포스 등 구글과 페이스북을 제외한 대부분의 검색 엔진에서 사용되며, 솔라(Solr), 엘라스틱 서치(ElasticSearch) 등의 검색엔진이 Apache Lucene을 기반으로 합니다.

Apache Lucene은 문서를 빠르게 색인하고 검색할 수 있는 기능을 제공하는데, 특히 증분식 색인(incremental indexing) 기법을 통해 문서가 추가될 때마다 효율적으로 색인을 갱신하는 방식을 사용하고 있습니다.

여기서 증분식 색인(incremental indexing) 기법은 기존 문서 집합에 새로운 문서가 추가될 때, 전체 문서를 다시 색인하지 않고 추가된 문서만 색인하는 방식입니다.

Lucene에서는 이를 위해 병합 색인(merge indexing) 기법을 사용합니다. 주요 특징은 다음과 같습니다:

  1. 새 문서가 추가되면, 해당 문서만을 위한 작은 색인을 따로 생성함
  2. 일정 개수의 작은 색인들이 쌓이면, 이들을 하나의 큰 색인으로 병합함
  3. 병합 과정은 백그라운드에서 이루어지므로 검색 성능에 영향을 주지 않음
  4. 이 과정을 반복하면서 전체 문서 집합의 색인을 증분식으로 갱신해 나감
  5. 검색 시에는 모든 색인을 동시에 검색하므로, 안정적인 검색 성능을 보장함

증분식 색인 기법을 통해 대용량 문서 집합에 대해서도 빠르게 색인을 갱신하고 일관된 검색 결과를 얻을 수 있습니다. 이는 Lucene이 실시간 검색 엔진으로 널리 사용되는 데 크게 기여한 핵심 기술입니다.

Doug Cutting이 개발한 루씬에서 사용된 증분식 색인 기법은 그의 1990년대 후반 연구에 기반합니다. 이에 대한 자세한 내용은 다음 논문에서 확인할 수 있습니다.

Cutting, D., & Pedersen, J. (1989). Optimizations for dynamic inverted index maintenance. In Proceedings of the 13th annual international ACM SIGIR conference on Research and development in information retrieval (pp. 405-411).

이 논문에서는 동적 색인 유지보수를 위한 최적화 기법에 대해 설명하고 있습니다. 특히 증분식 색인 구축 과정에서 발생하는 오버헤드를 최소화하기 위한 방법으로, 작은 색인들을 주기적으로 병합하는 기법을 제안하였습니다. 이는 Lucene의 증분식 색인 기법의 핵심 아이디어가 되었습니다.

논문 읽기

배경

많은 문서 검색 시스템에서는 사용자가 자유롭게 키워드를 입력하면 그에 맞는 문서를 찾아주는 자유 텍스트 검색(free-text search) 기능을 제공합니다. 이때 시스템 내부적으로는 역색인을 사용하여 검색을 수행하게 됩니다.

그런데 이런 시스템에서 다루는 문서들의 집합, 즉 코퍼스(corpus)는 끊임없이 변화합니다. 새로운 문서들이 추가되기도 하고, 기존 문서들이 삭제 또는 수정되기도 합니다. 이렇게 빠르게 변화하는 코퍼스에 대해 자유 텍스트 검색 서비스를 제공하려면, 역색인을 실시간으로 갱신할 수 있어야 합니다.

가령 어떤 문서가 새로 추가되었다면, 해당 문서에 출현한 단어들을 곧바로 역색인에 반영해야 그 문서도 검색될 수 있을 것입니다. 만약 색인 갱신이 지연된다면 사용자는 검색 결과에서 누락을 경험하게 될 것입니다.

따라서 빠르게 변화하는 코퍼스를 대상으로 자유 텍스트 검색을 제공하기 위해서는, 역색인을 동적으로 갱신하는 것이 기본적으로 요구됩니다. 단순히 주기적으로 색인을 다시 만드는 정적 방식으로는 한계가 있기 때문입니다.

이 논문은 바로 이러한 배경에서, B-tree를 기반으로 역색인을 효과적으로 동적 갱신하는 방안을 연구한 것입니다. 제안된 여러 최적화 기법들은 역색인 검색 시스템의 성능과 확장성을 높이는 데 기여할 수 있습니다.

역색인(Inverted Indices)

역색인은 기본적으로 단어(word)를 키로 하고, 그 단어가 출현한 문서들의 식별자(document ID)를 값으로 가지는 일종의 맵핑 테이블입니다. 논문에서는 이때 값에 해당하는 문서 식별자들의 집합을 포스팅(posting)이라고 지칭합니다.

예를 들어 "word1"이라는 단어가 문서1, 문서2, 문서3에 출현했다면, 이에 대한 역색인 엔트리는 다음과 같은 형태가 될 것입니다.

"word1" -> {1, 2, 3}

여기서 {1, 2, 3}이 "word1"의 포스팅이 됩니다.

이러한 역색인을 실제로 구현할 때는 다양한 성능 기준을 고려해야 합니다. 논문에서는 특히 다음 다섯 가지 기준을 강조하고 있습니다.

  1. 색인 구축 속도 (index build speed)
  2. 색인 검색 속도 (access speed)
  3. 색인의 크기 (index size)
  4. 동적 갱신의 용이성 (dynamics)
  5. 확장성 (scalability)

한편 실제 포스팅에는 문서 식별자 외에도 추가적인 정보가 포함될 수 있습니다. 예를들어 단어가 문서 내에서 출현한 위치(offset)라든가, 출현 빈도(frequency) 같은 것이 될 수 있습니다. 다만 이는 역색인을 어떤 검색 알고리즘에 활용하느냐에 따라 달라질 수 있습니다.

역색인에서 가장 빈번하게 수행되는 연산은 특정 단어가 주어졌을 때 그 단어가 포함된 문서들을 찾아내는 것입니다. 이를 위해서는 단어를 키로 하여 색인에 접근할 수 있어야 합니다. 따라서 역색인은 단어들에 대해 정렬되어 있거나, 단어를 키로 하는 해시 테이블 형태로 구성되는 것이 일반적입니다.

이러한 역색인을 실제로 구현할 때는 다양한 요인들을 고려해야 합니다. 먼저 새로운 문서가 추가되었을 때 이를 색인에 반영하는 데 걸리는 시간, 즉 블록 단위 갱신 속도가 중요합니다. 새 문서의 색인 생성이 너무 오래 걸리면 검색 시스템의 실시간성이 떨어질 수 있기 때문입니다.

또한 사용자가 특정 단어를 검색했을 때 그 결과를 가져오는 데 걸리는 시간, 즉 접근 속도도 매우 중요한 성능 지표입니다. 사용자는 검색 결과를 빨리 받아보기를 원하므로, 색인 구조는 빠른 검색을 뒷받침할 수 있어야 합니다.

아울러 색인을 저장하는 데 드는 공간 비용, 즉 색인 크기도 무시할 수 없는 요소입니다. 색인이 지나치게 커지면 저장 공간 뿐 아니라 입출력 비용도 커지게 되므로, 적절한 압축이나 최적화를 통해 색인 크기를 적정 수준으로 유지할 필요가 있습니다.

한편 문서 집합이 동적으로 변한다는 점도 역색인 구현에 많은 영향을 미칩니다. 새 문서의 추가나 기존 문서의 삭제, 수정이 빈번히 일어난다면 이를 색인에 반영하는 증분 갱신 작업이 용이해야 합니다. 그렇지 않으면 색인 유지에 너무 큰 비용이 들 수 있습니다.

특히 문서 삭제의 경우, 해당 문서에 대한 모든 색인 항목을 빠짐없이 제거해야 합니다. 그런데 문서 내용을 알 수 없다면 색인 전체를 뒤져가며 관련 항목을 찾아내야 할 수도 있습니다. 따라서 가능하다면 문서 식별자와 문서 내용을 함께 관리하는 것이 색인 관리에 유리합니다.

문서 집합의 크기가 커짐에 따라 이러한 요소들이 어떻게 변화하는지, 즉 확장성도 역시 중요하게 고려해야 할 부분입니다. 문서가 많아질수록 색인의 크기가 커지고 색인 연산의 비용도 증가할 수밖에 없는데, 이를 얼마나 효과적으로 감당할 수 있느냐가 확장성을 좌우하기 때문입니다.

역색인을 설계하고 구현할 때는 이처럼 다양한 요인들을 복합적으로 고려해야 합니다. 단순히 검색 성능만을 최적화할 것이 아니라, 갱신 용이성, 저장 비용, 확장성 등을 두루 살펴가며 절충안을 찾아내는 것이 중요합니다.

Naive B-tree의 단점

B-tree는 디스크와 같은 2차 저장소에 저장되는 균형 잡힌 트리 구조입니다. 각 노드는 디스크 페이지로 표현되며, 하나의 노드에는 여러 개의 엔트리(키-값 쌍)가 담길 수 있습니다. 이때 하나의 노드가 담을 수 있는 엔트리의 수를 B-tree의 분기 계수(branching factor)라고 합니다.

B-tree에서는 이 분기 계수를 b라고 할 때, 노드 간 균형을 맞추기 위한 알고리즘을 통해 삽입, 검색, 삭제 연산의 시간 복잡도가 O(log_b N)으로 보장됩니다. 여기서 N은 B-tree가 담고 있는 총 엔트리의 수입니다. 또한 B-tree의 높이, 즉 루트 노드에서 리프 노드까지의 거리를 'B-tree의 깊이(depth)'라고 하는데, 이는 log_b N과 같습니다.

B-tree에 저장된 엔트리는 키 순으로 정렬되어 있으므로, 순차 접근 시에는 전체 엔트리를 키 순으로 읽어올 수 있습니다. 이때의 시간 복잡도는 O(N)이며, 대략 N/b번의 디스크 접근이 필요합니다.

B-tree의 분기 계수 b는 보통 100 정도로 크게 잡습니다. 이렇게 하면 100만 개의 엔트리를 담은 B-tree의 깊이는 3 정도가 되므로, 최대 3번의 디스크 접근으로 원하는 엔트리에 도달할 수 있습니다.

만약 B-tree의 일부 노드를 메모리에 캐싱한다면 접근 속도를 더욱 높일 수 있습니다. 예를 들어 루트 노드를 항상 메모리에 들고 있으면 모든 연산에서의 디스크 접근 횟수를 1만큼 줄일 수 있는데, 이때 b개의 엔트리를 메모리에 추가로 저장하는 대신 디스크 접근을 1회 아낄 수 있게 되는 셈입니다.

이를 일반화하면, 메모리에 C개의 엔트리를 저장할 수 있다고 할 때 B-tree 연산에 필요한 디스크 접근 횟수는 대략 log_b N - log_b C가 됩니다 (N ≥ C 인 경우). 즉, 캐시 크기를 늘리면 디스크 접근 횟수를 줄일 수 있습니다.

나이브한 B-tree 기반 역색인 구현에서는 (단어, 위치) 쌍을 B-tree의 엔트리로 삼습니다. 단어 단위로 인접하게 배치하므로 특정 단어에 대한 포스팅 리스트를 얻으려면 B-tree를 차례로 읽으면서 관련 엔트리들을 뽑아내면 됩니다. 이때 b개 엔트리마다 한 번씩 디스크 접근이 일어나게 됩니다.

그러나 새로운 문서의 색인을 만들 때는, 그 안의 모든 단어에 대해 (단어, 위치) 엔트리를 B-tree에 삽입해야 합니다. n개의 단어를 새로 색인한다면 대략 n * (log_b N - log_b C)번의 디스크 읽기가 필요할 것입니다.

문서 내용을 알고 있다면 기존 문서에 대한 포스팅을 제거하는 것도 같은 비용으로 가능합니다. 그러나 문서 내용을 모른다면 B-tree 전체를 훑어가며 관련 엔트리를 찾아 지워야 하므로 비용이 크게 증가할 수 있습니다.

이상의 내용을 정리하면, B-tree는 역색인을 구현하는 데 있어 접근 속도, 갱신 지원, 확장성 측면에서 장점을 가지지만, 엔트리 중복으로 인한 공간 낭비와 문서 단위 색인 갱신의 비효율성이 단점으로 지적될 수 있습니다. 이러한 단점을 극복하기 위해 몇가지 최적화 방안을 도입할 수 있습니다.

Speed Optimization

앞서 살펴본 나이브한 B-tree 역색인에서는 새 문서의 색인을 추가할 때 문서 내 모든 단어에 대해 (단어, 위치) 엔트리를 역색인에 삽입해야 했습니다. 이때 각 삽입 연산마다 B-tree의 루트에서부터 리프까지 탐색해 내려가야 하므로 디스크 접근이 많이 발생하게 됩니다.

이를 개선하는 한 가지 방법은 B-tree의 상위 노드들을 메모리에 상주시켜 캐싱하는 것인데, 이렇게 하면 삽입 시 디스크 접근 횟수를 줄일 수 있습니다. 그런데 논문에서는 이것보다 더 나은 방법을 제시하고 있습니다.

바로 새 문서의 (단어, 위치) 정보들을 메모리 버퍼에 임시로 저장했다가, 버퍼가 가득 차면 이를 한꺼번에 정렬하여 B-tree에 병합하는 것입니다. 이 '병합을 통한 갱신(merge update)' 최적화를 적용하면 삽입 연산에 필요한 디스크 접근을 대폭 줄일 수 있습니다.

n개의 새로운 포스팅 정보를 메모리 버퍼에 받아두면, 실제로는 중복을 제외한 w개의 단어에 대한 색인 엔트리만 B-tree에 삽입하면 됩니다. 이때 각 단어의 엔트리는 인접한 위치에 저장될 가능성이 높으므로, 삽입을 위한 B-tree 탐색 비용이 줄어듭니다.

한편 단어 w에 대한 포스팅 리스트의 길이를 f(w)라 하고, 이것이 Zipf 분포를 따른다고 가정하면 f(w)와 w 사이의 관계식을 세울 수 있습니다. 이를 바탕으로 n, w, N(전체 색인의 크기) 사이의 관계를 분석해 볼 수 있는데, 결론적으로 병합 방식이 캐싱 방식보다 디스크 접근 횟수가 훨씬 적다는 것을 보일 수 있습니다.

수식의 자세한 전개 과정은 후술하겠습니다만, 논문에서 주어진 예시를 보면 병합 방식이 캐싱 방식 대비 약 1/5 수준의 디스크 접근만으로 갱신을 처리할 수 있음을 알 수 있습니다. 이는 버퍼링과 배치 처리가 역색인 갱신 성능 향상에 매우 효과적임을 보여주는 사례입니다.

[수식의 전개 과정]
먼저 캐싱 방식을 적용했을 때 n개의 새 포스팅 정보를 삽입하는데 필요한 디스크 읽기 횟수의 기댓값은 다음과 같이 주어집니다 (수식 2).

X = n(log_b N - log_b C)

여기서 b는 B-tree의 분기 계수, N은 전체 색인의 크기, C는 캐시의 크기입니다.

한편 병합 방식에서는 n개의 포스팅이 w개의 단어에 대한 포스팅 리스트로 변환된 후 B-tree에 삽입됩니다. 이때 필요한 디스크 읽기 횟수의 기댓값은 다음과 같습니다 (수식 3).

Y = w(log_b N - log_b w)

이제 두 방식의 비용을 비교하기 위해 C = n 이라 가정합시다. 즉, 캐시의 크기와 새로 삽입할 포스팅의 수가 같다고 볼 수 있습니다. 그러면 식 (2)와 (5)로부터 다음이 성립합니다.

X = z ln z (log_b N - log_b (z ln z)) (6) Y = z (log_b N - log_b z) (7)

여기서 z는 n개의 포스팅이 포함하는 고유 단어의 수입니다.

식 (7)을 약간 변형하면,

Y = X / ln z + z log_b(ln z) (8)

입니다.

만약 X > Y 라면 식 (8)에 의해,

X > X / ln z + z log_b(ln z)

이고, 이를 정리하면

z(ln z - 1) > log_b(ln z)

을 얻게 됩니다.

식 (6)을 이용해 위 부등식의 좌변을 X에 관한 식으로 바꾸고 이를 정리하면 다음과 같은 부등식을 얻게 됩니다.

(ln z - 1)(log_b N - log_b(z ln z)) > log_b(ln z)

이를 다시 정리하면,

ln^2 z log_b N > log_b z + log_b(ln z) ln z

입니다.

부등식의 양변에 지수함수를 취하면,

N > z (ln z)^(ln z / (ln z - 1)) (10)

이 됩니다. 즉, N과 z가 부등식 (10)을 만족할 때 X > Y 임을 알 수 있습니다.

그런데 z ln z = n = C 이고 식 (2)가 성립하려면 N ≥ C 이어야 하므로, N > z ln z 임을 알 수 있습니다. 충분히 큰 z에 대해 ln z / (ln z - 1)는 1에 가까우므로, 갱신 연산의 경우 대체로 X > Y 라 예상할 수 있습니다.

논문의 예시에서는 b = 100, N = 1,000,000, z ln z = 10,000 일 때 z ≈ 1,383 이고,

X ≈ 10,000, Y ≈ 1,977

로 계산됩니다. 이는 식 (11)에 의해서도 확인할 수 있는데,

X = ln z (Y - z log_b(ln z)) (11)

이므로 Y 값으로부터 계산된 X 값은 대략 10,000에 근접합니다.

따라서 병합 기반 갱신이 캐싱 기반 갱신보다 디스크 접근 비용 측면에서 훨씬 효율적임을 이론적으로 확인할 수 있습니다. 논문에서는 이처럼 Zipf 분포의 특성을 활용하여 현실적인 최적화 기법의 우수성을 입증하고 있습니다.

Space Optimization

가장 단순한 형태의 역색인에서는 색인의 각 엔트리가 (단어, 위치) 쌍으로 구성되므로, 같은 단어에 대한 엔트리들 사이에 단어 정보가 중복되는 문제가 있습니다. 이를 해결하기 위해 제안된 방법이 '그룹핑(grouping)'으로, 같은 단어에 대한 포스팅 정보를 (단어, 빈도, 위치 리스트) 형태로 묶는 것입니다.

이렇게 하면 엔트리 하나당 단어 정보를 한 번만 저장하므로 중복이 제거됩니다. 다만 위치 리스트의 길이를 별도로 저장해야 하는 작은 오버헤드가 발생하는데, 이는 동시에 해당 단어의 문서 내 출현 빈도를 나타내는 데에도 활용될 수 있습니다.

단순 색인 방식과 그룹핑 방식의 공간 효율성을 분석해 보면, N개의 포스팅에 대해 전자는 2N 개의 저장 공간을 사용하는 데 비해, 후자는 W(2 + ln W) 개의 공간을 사용합니다 (W는 고유 단어 수). 보통 W ln W ≪ N 이므로 그룹핑이 더 공간 효율적임을 알 수 있고, 이는 대략 50% 가량의 절감 효과가 있는 것으로 나타납니다.

한편 그룹핑을 적용한 B-tree 역색인에서는 하나의 엔트리에 들어갈 수 있는 위치 정보의 수가 제한되는 문제가 있습니다. 극단적으로 출현 빈도가 높은 단어의 경우 위치 리스트가 B-tree 노드 크기를 초과할 수 있습니다.

이를 해결하기 위해 제안된 것이 '힙 파일(heap file)'입니다. B-tree 노드에는 (단어, 빈도, 포인터) 형태의 엔트리를 저장하고, 포인터가 가리키는 힙 파일에 위치 정보를 별도 저장하는 것입니다. 힙 파일 내에서는 가변 길이 청크를 동적 할당하여 위치 리스트를 저장하게 됩니다.

힙 파일을 사용하면 하나의 단어에 대한 색인 엔트리를 읽어들이는 데 최대 log_b W + 1 회의 디스크 읽기가 필요합니다 (트리 탐색 + 힙 파일 접근). 그리고 새로운 문서에 대한 색인을 추가할 때에는 힙 파일의 청크 크기가 딱 맞지 않을 경우 재할당이 일어날 수 있는데, 이에 따른 추가 비용은 무시할 만한 수준입니다.

결과적으로 N개의 새 포스팅에 대한 역색인 갱신 시간은 n(log_b w - log_b c + 1) 에 비례하게 되며, 앞서 소개된 병합 최적화 기법을 함께 적용하면 이를 더욱 개선할 수 있습니다.

힙 파일을 사용한 역색인의 공간 복잡도는 단순 그룹핑 대비 힙 파일 포인터 정보가 추가되는 것을 제외하면 거의 동일합니다. 즉 공간 효율성의 큰 저하 없이 B-tree 노드 크기 제한 문제를 해결할 수 있습니다.

Pulsing

Pulsing은 B-tree 노드 공간을 더 효율적으로 활용하기 위한 방법입니다. 앞서 힙 파일을 사용하면 모든 단어의 위치 정보를 B-tree 노드 밖으로 빼냈지만, 사실 대부분의 단어는 출현 빈도가 그리 높지 않아 노드 안에 충분히 저장할 수 있습니다.

따라서 Pulsing에서는 각 단어별로 B-tree 노드 안에 저장할 수 있는 위치 정보의 수를 제한합니다. 이 제한값을 초과하는 단어들에 대해서만 힙 파일을 사용하게 됩니다. 이렇게 하면 B-tree를 통한 직접 접근이 가능한 단어가 많아지므로 전반적인 검색 속도 향상을 기대할 수 있습니다.

Pulsing을 적용한 B-tree의 엔트리는 (단어, 총빈도, 노드 내 위치 수, 위치 리스트, 힙 포인터) 형태가 됩니다. 노드 내 위치 수가 제한값 이하일 때는 힙 포인터가 생략되어 공간이 절약됩니다.

Pulsing의 효과는 'hit ratio'로 분석할 수 있는데, 이는 검색 시 B-tree 노드만으로 처리 가능한 비율을 뜻합니다. Hit ratio가 높을수록 힙 파일 접근 없이 검색이 완료될 확률이 높아집니다. 노드 내 위치 수 제한값인 t를 적절히 설정함으로써 원하는 수준의 hit ratio를 얻을 수 있습니다.

  • 각 단어마다 B-tree 노드 안에 저장할 수 있는 위치 정보의 수를 t개로 제한합니다. 이를 'pulsing threshold'라고 합니다.
  • 어떤 단어의 색인 엔트리를 B-tree에 추가할 때, 그 단어의 총 출현 횟수가 t 이하면 위치 정보를 노드 안에 직접 저장합니다.
  • 만약 이미 t개의 위치 정보가 노드 안에 있다면, 기존의 위치 정보를 모두 힙 파일로 옮기고 새 위치 정보도 힙 파일에 저장합니다. 이 과정을 'pulsing out'이라고 합니다.
  • 즉, B-tree 노드 안에는 언제나 출현 빈도가 높은 단어들의 최신 위치 정보 t개만 저장되고, 나머지는 힙 파일로 overflow됩니다.
  • 이때 B-tree 노드의 엔트리는 (단어, 총빈도, 노드 내 위치 수, 위치 리스트, 힙 포인터) 형태가 됩니다. 총빈도가 t 이하면 힙 포인터는 생략됩니다.
  • 검색할 때는 먼저 B-tree를 탐색해 해당 단어의 엔트리를 찾고, 노드 안의 위치 정보를 읽습니다. 만약 총빈도가 t보다 크다면 힙 파일에서 나머지 위치 정보를 읽어옵니다.
  • Pulsing의 효과는 'hit ratio'로 측정할 수 있습니다. 이는 B-tree 탐색만으로 검색이 완료될 확률입니다. Hit ratio는 threshold t에 의해 결정되는데, t가 클수록 hit ratio도 높아집니다.

Delta Encoding

Delta Encoding은 위치 정보 자체를 압축하는 방법입니다. 역색인 검색에서는 대개 위치 정보를 순차적으로 읽어가므로, 각 위치를 이전 위치와의 차이(delta)로 표현해도 무방합니다. 실제 위치 값 대신 delta를 저장하면 그 값이 상당히 작아지게 되어 압축 효과를 얻을 수 있습니다.

또한 delta 값을 저장할 때 크기에 따라 가변 바이트 수를 사용하면 공간 효율을 더욱 높일 수 있습니다. 이러한 인코딩을 적용하면 색인 크기를 크게 줄일 수 있습니다.

Delta Encoding은 힙 파일의 위치 리스트뿐 아니라 Pulsing의 노드 내 위치 리스트에도 활용 가능합니다. 다만 개별 위치에 대한 직접 접근은 어려워지므로 문서 삭제 연산 등에는 부적합하다는 단점이 있습니다.

'논문 읽기' 카테고리의 다른 글

[LFS] Log-Structured File system  (1) 2022.08.22
  • 지금까지 그 누구도 할 수 없던 엄청난 기능을 개발했더라도, 이 기능이 회사의 KPI 달성에 전혀 기여할 수 없다면, 이건 시간과 돈 낭비
  • 비즈니스와 무관한 “좋은 코드”라는것은 존재하지 않는다.
 

개발자도 회사의 조직원이다

나는 개발을 못 해서 프로그래밍 코드를 들여다보면 나에겐 이건 마치 단어만 몇 개 알고 있는 외국어랑 비슷하다. 하지만, 사람들을 자주 만나고 이야기하다 보니, 좋은 개발력을 가진 창업가

www.thestartupbible.com

 

분산 시스템의 안정성과 일관성을 보장하기 위해, 분산락(distributed lock)은 필수적인 메커니즘 중 하나입니다. Spring 프레임워크에서 분산락 구현은 주로 Aspect-Oriented Programming(AOP)를 통해 이루어집니다. 그러나 Spring AOP를 활용할 경우, 몇 가지 제약 사항과 단점이 존재합니다.

Spring AOP의 한계

  1. Pointcut 표현식 사용: Spring AOP는 pointcut 표현식을 통해 어드바이스(Advice) 적용 대상을 지정합니다. 이 표현식을 정확히 작성하는 것은 복잡하고, 오류가 발생하기 쉬운 작업입니다.
  2. 적용 여부 확인: Spring AOP를 적용한 후, 해당 AOP가 정상적으로 적용되었는지 런타임에서만 확인할 수 있습니다. 이는 개발 과정에서 시간을 소모하게 만듭니다.
  3. 내부 메서드 적용 불가: 클래스 내부에서 호출되는 private 메서드에는 Spring AOP가 적용되지 않습니다. 이는 내부 로직에 분산락을 적용하려 할 때 문제가 됩니다.
  4. SpEL 사용의 복잡성: 분산락의 키값을 지정하기 위해 Spring Expression Language(SpEL)를 사용하게 되는데, 이는 컴파일 타임에서는 오류를 확인할 수 없으며, 잘못된 값이 지정되면 런타임 예외를 발생시킵니다.

Kotlin Trailing Lambdas

이러한 Spring AOP의 한계를 극복하기 위해, Kotlin의 Trailing Lambdas를 활용한 방식을 도입할 수 있습니다. Kotlin에서 함수는 일급 객체이며, Trailing Lambdas를 사용하면, 함수를 더 직관적이고 유연하게 다룰 수 있습니다. 이를 통해 분산락 구현에 있어 AOP의 한계를 해결할 수 있습니다.

Kotlin에서 Trailing Lambdas는 함수형 프로그래밍의 강력한 특성 중 하나입니다. 이 개념을 이해하기 위해서는 먼저 Kotlin에서의 람다식과 고차 함수에 대한 이해가 필요합니다.

람다식(Lambda Expressions)

람다식은 간단히 말해 익명 함수입니다. 이는 함수를 간결하게 표현할 수 있게 해 주며, 다른 함수의 인자로 전달되거나 변수에 저장될 수 있습니다. Kotlin에서 람다식은 { }로 둘러싸여 표현됩니다. 예를 들어, 다음은 두 수의 합을 반환하는 람다식입니다:

val sum: (Int, Int) -> Int = { x, y -> x + y }

고차 함수(Higher-Order Functions)

고차 함수는 다른 함수를 인자로 받거나 함수를 결과로 반환하는 함수를 말합니다. Kotlin에서 함수는 일급 객체이므로, 변수에 할당될 수 있고 다른 함수의 인자나 반환 값으로 사용될 수 있습니다. 예를 들어, 다음 함수 calculate는 함수를 인자로 받고, 두 개의 정수와 함께 이 함수를 호출합니다:

fun calculate(x: Int, y: Int, operation: (Int, Int) -> Int): Int {
    return operation(x, y) 
}

여기서 operation 파라미터는 람다식을 받는 고차 함수의 예입니다.

Trailing Lambdas

Kotlin에서는 함수의 마지막 인자가 람다식인 경우, 람다식을 괄호 밖으로 빼내어 코드의 가독성을 높일 수 있습니다. 이를 Trailing Lambdas라고 합니다. 예를 들어, 위의 calculate 함수를 호출할 때, 다음과 같이 Trailing Lambdas를 사용할 수 있습니다:

val result = calculate(10, 20) { a, b -> a + b }

여기서 { a, b -> a + b }calculate 함수의 마지막 인자로 전달된 람다식입니다. 이 문법을 사용함으로써, 코드가 훨씬 자연스럽고 읽기 쉬워집니다.

Trailing Lambdas와 분산락

Trailing Lambdas의 이러한 특성을 분산락 구현에 적용하면, Spring AOP와 비슷하게, 비스니스 로직과 분산락 이라는 횡단 관심사를 분리할 수 있습니다. 특히, 분산락을 적용해야 하는 비즈니스 로직을 람다식으로 정의하고, 이를 고차 함수에 전달함으로써, 분산락 로직과 비즈니스 로직을 명확히 분리할 수 있습니다. 이는 코드의 가독성과 유지보수성을 크게 향상시킵니다.

Spring AOP를 활용한 분산락

@DistributedLock("UsePointDomainService.incrementByUserId:#{#userId}")  
override fun incrementByUserId(  
    userId: UUID,  
    amount: Long  
): UserSil {  
    return userPointRepository  
        .getByUserId(userId)  
        .increment(amount)  
        .also { userSilRepository.save(it) }  
}

 

Trailing Lambdas를 활용한 분산락

fun incrementByUserId(  
    userId: UUID,  
    amount: Long  
): UserSil = distributedLock("userPointDomainService:$userId") {  
    return@distributedLock userPointRepository  
        .getByUserId(userId)  
        .increment(amount)  
        .also { userSilRepository.save(it) }  
}

 

구현과정

우선 분산락 함수를 지원하기 위한 Aspect를 정의합니다.

@Component  
class DistributedLockAspect(  
    innerRedissonClient: RedissonClient,  
    innerDistributedLockTransactionProcessor: DistributedLockTransactionProcessor,  
) {  

    init {  
        redissonClient = innerRedissonClient  
        distributedLockTransactionProcessor = innerDistributedLockTransactionProcessor  
    }  

    companion object {  

        val logger = KotlinLogging.logger { }  

        lateinit var redissonClient: RedissonClient  
            private set  

        lateinit var distributedLockTransactionProcessor: DistributedLockTransactionProcessor  
            private set  

        const val REDISSON_LOCK_PREFIX = "LOCK:"  
    }  
}

Aspect는 스프링 빈으로 정의하여, RedissonClient, DistributedLockTransactionProcessor를 주입받아 분산락 함수에서 사용할 수 있도록 정적 멤버로 제공합니다.

RedissonClient의 경우 분산락획득을 위한 별도 인터페이스를 제공하기때문에 선택하였고, 분산락 모듈의 Config파일에서 빈으로 등록해 주었습니다.

@Configuration  
@ComponentScan(basePackages = ["com.studentcenter.support.lock"])  
class DistributedLockConfig (  
    private val redisProperties: RedisProperties  
){  

    @Bean  
    fun redissonClient(): RedissonClient {  
        val redisConfig = Config()  
        redisConfig  
            .useSingleServer()  
            .apply {  
                address = "redis://${redisProperties.host}:${redisProperties.port}"  
            }  
        return Redisson.create(redisConfig)  
    }  

}

락의 해제가 트랜잭션 커밋 이전에 이루어질 경우 동시성 문제가 발생할 수 있습니다. DistributedLockTransactionProcessor의 경우 락의 해제가 트랜잭션 커밋이후에 이루어지도록, 별도의 트랜잭션을 만들어 동작하게 만들었습니다.

단 이경우에는 자식 트랜잭션 커밋 이후 부모 트랜잭션에서 예외가 발생하면, 자식 트랜잭션이 롤백되지는 않기 때문에 유의해서 사용해야 합니다.

@Component  
class DistributedLockTransactionProcessor {  

    @Transactional(propagation = Propagation.REQUIRES_NEW)  
    fun <T> proceed(function: () -> T): T {  
        return function()  
    }  

}

이후 프로젝트 내에서 전역적으로 활용할 수 있도록 패키지레벨의 분산락 함수를 구현합니다.

/**
 * Distributed Lock
 * 적용 대상 함수는 별도의 트랜잭션으로 동작하며 커밋 이후 락을 해제한다.
 * @param key       락 식별자
 * @param waitDuration  락 대기 시간
 * @param leaseDuration 락 유지 시간
 * @param function  적용 대상 함수
 * @return          함수 실행 결과
 */
fun <T> distributedLock(
    key: String,
    waitDuration: Duration = 5.seconds,
    leaseDuration: Duration = 3.seconds,
    function: () -> T,
): T {
    val rLock: RLock = (DistributedLockAspect.REDISSON_LOCK_PREFIX + key)
        .let { DistributedLockAspect.redissonClient.getLock(it) }

    try {
        val available: Boolean = rLock.tryLock(
            waitDuration.inWholeSeconds,
            leaseDuration.inWholeSeconds,
            TimeUnit.SECONDS,
        )
        check(available) {
            throw IllegalStateException("Lock is not available")
        }

        return DistributedLockAspect.distributedLockTransactionProcessor.proceed(function)
    } finally {
        try {
            rLock.unlock()
        } catch (e: IllegalMonitorStateException) {
            DistributedLockAspect.logger.info {
                "Redisson Lock Already UnLock Key : $key"
            }
        }
    }

}

테스트

분산락 미적용

override fun incrementByUserId(  
    userId: UUID,  
    amount: Long  
): UserSil {  
    return userSilRepository  
        .getByUserId(userId)  
        .increment(amount)  
        .also { userSilRepository.save(it) }  
}
@DisplayName("UserSilDomainService 통합 테스트")  
class UserSilDomainServiceIntegrationTest(  
    private val userSilDomainService: UserSilDomainService,  
) : IntegrationTestDescribeSpec({  

    describe("유저 실 증가 동시성 테스트") {  
        context("분산락 적용 X") {  
            it("동시성 테스트") {  
                // arrange  
                val userId = UuidCreator.create()  
                userSilDomainService.create(userId)  

                val threadCount = 10  
                val incrementAmount = 10L  

                // act  
                runBlocking {  
                    repeat(threadCount) {  
                        launch(Dispatchers.Default) {  
                            userSilDomainService.incrementByUserId(userId, incrementAmount)  
                        }                    }  
                }  


                // assert  
                val userSil: UserSil = userSilDomainService.getByUserId(userId)  
                userSil.amount shouldBe incrementAmount * threadCount  
            }  
        }  
    }  

})

img2

락이 적용되어있지 않을때에는 동시성 문제가 발생해 포인트가 기댓값에 미치지 못해 테스트가 실패했습니다.

분산락 적용

override fun incrementByUserId(  
    userId: UUID,  
    amount: Long  
): UserSil = distributedLock("UseSilDomainService.incrementByUserId:$userId") {  
    return@distributedLock userSilRepository  
        .getByUserId(userId)  
        .increment(amount)  
        .also { userSilRepository.save(it) }  
}

img1

분산락을 적용했을때는 기댓값만큼 포인트가 증가해 테스트가 성공하는것을 확인할 수 있었습니다.

분산락이 적용된 함수의 단위테스트는 어떻게 처리해야하나?

이렇게 trailing lambdas문법을 통해 분산락을 적용하게 되면, DistributedLockAspect에 의존하고 있어 해당 컴포넌트가 스프링 빈으로 등록되지 않는 유닛테스트 환경에서는 에러가 발생하게 됩니다. 이때는 mockk라이브러리를 활용해 DistributedLock에 대한 static 모킹을 통해 해결할 수 있습니다.

    beforeTest {
        mockkStatic("com.studentcenter.weave.support.lock.DistributedLockKt")
        every {
            distributedLock<Any?>(any(), any(), any(), captureLambda())
        } answers {
            val lambda: () -> Any? = arg<(()-> Any?)>(3)
            lambda()
        }
    }

마치며

Kotlin의 Trailing Lambdas를 활용한 분산락 구현은 Spring AOP의 한계를 극복하고, 더 안정적이고 유지보수가 쉬운 코드를 작성할 수 있게 만들어 주었습니다. 또한 Kotlin의 풍부한 언어 기능을 활용하여 보다 Kotlin 스럽게 분산 시스템에서의 동시성 관리를 수행할 수 있었습니다.

Reference

우리는 종종 Primitive type이 도메인 객체를 모델링 하기에는 충분한 정보를 제공하지 못하기에 VO(Value Object)를 정의합니다.

이때 primitive 타입을 wrapping해 VO를 정의하곤 하는데, 추가적인 힙 할당으로 인한 런타임 오버헤드가 발생합니다. Primitive타입은 런타임에 최적화 되어있지만, data class로의 wrapping으로 인해 primitive의 성능 최적화를 의미없게 만듭니다. 이러한 문제를 해결하기 위해, Kotlin은 inline value class고 불리는 특별한 종류의 클래스를 제공합니다.

Kotlin의 value 클래스는 JDK 15부터 도입된 record 클래스의 특성을 가져와서, 불변성(immutability)과 데이터 홀딩(data holding)에 최적화되어 있습니다. value 클래스는 주로 다음과 같이 간결한 구문으로 VO 클래스를 정의하는데 사용됩니다.

@JvmInline 
value class Password(private val s: String)

val securePassword = Password("Don't try this in production")

위와 같이 inline value class를 통해 VO를 정의하게 되면 객체 초기화시에 검증 로직을 수행할 수도 있고, 래핑된 primitive type이 런타임에서는 기저에 있는 타입으로 컴파일되어 추가적인 힙 할당으로 인한 런타임 오버헤드가 발생하지 않기도 합니다.

하지만 항상 기저 타입으로 컴파일되지는 않습니다

코틀린 공식문서를 확인하면 다음과 같이 기저타입이 사용되지 않는 경우들에 대해 설명하고 있습니다.

interface I  

@JvmInline  
value class Foo(val i: Int) : I  

fun asInline(f: Foo) {}  
fun <T> asGeneric(x: T) {}  
fun asInterface(i: I) {}  
fun asNullable(i: Foo?) {}  

fun <T> id(x: T): T = x  

fun main() {  
    val f = Foo(42)  

    asInline(f)    // unboxed: used as Foo itself  
    asGeneric(f)   // boxed: used as generic type T  
    asInterface(f) // boxed: used as type I  
    asNullable(f)  // boxed: used as Foo?, which is different from Foo  

    // below, 'f' first is boxed (while being passed to 'id') and then unboxed (when returned from 'id')    // In the end, 'c' contains unboxed representation (just '42'), as 'f'    val c = id(f)  
}

1. asInline(f):

  • asInline 함수는 인라인 벨류 클래스 타입의 매개변수를 받도록 선언됩니다.
  • 컴파일러는 f를 직접 사용하여 기저 타입인 Int 값에 접근합니다.
  • 따라서 boxing/unboxing 없이 값을 효율적으로 처리할 수 있습니다.

2. asGeneric(f):

  • asGeneric 함수는 제네릭 타입 T를 매개변수로 받습니다.
  • 컴파일러는 Foo 인스턴스를 T 타입으로 변환해야 하기 때문에 boxing이 발생합니다.

3. asInterface(i):

  • asInterface 함수는 I 인터페이스 타입의 매개변수를 받습니다.
  • FooI 인터페이스를 구현하지만, 컴파일러는 여전히 Foo 인스턴스를 I 타입으로 변환해야 하기 때문에 boxing이 발생합니다.

4. asNullable(f):

  • asNullable 함수는 널 가능한 Foo 타입의 매개변수를 받습니다.
  • Foo는 기본 타입이 아닌 참조 타입이기 때문에 널 가능합니다.
  • asNullable 함수는 f가 null인지 확인하고, null이 아닌 경우 boxing을 수행합니다.
  • 결과적으로 asNullable 함수는 널 가능한 Int 값을 널 가능한 Integer 객체로 감싼 형태로 받게 됩니다.

5. id(f):

  • id 함수는 제네릭 타입 T를 매개변수로 받고, 그 타입의 값을 그대로 반환하는 항등 함수입니다.
  • fid 함수에 전달하면 컴파일러는 Foo 인스턴스를 T 타입으로 변환해야 하기 때문에 boxing이 발생합니다.
  • id 함수는 반환 값으로 T 타입을 요구하기 때문에, 반환하기 전에 boxing된 값을 unboxing합니다.
  • 결과적으로 id(f)Int 값을 반환합니다.

 

JPA Entity에서의 Value class 사용

저의 경우, 4번 nullable한 value class를 사용할때 기저타입이 사용되지 않는 현상을 경험했었습니다.

@Entity  
@Table(name = "`user`")  
class UserJpaEntity(  
    ...
    height: Height? = null,
    ...
) {  

    ...

    @Column(nullable = true, updatable = true, columnDefinition = "integer")  
    var height: Height? = height  
        private set

    ...  

}

@JvmInline  
value class Height(val value: Int) {  

    init {  
        require(value in 1..300) {  
            "키는 1cm 이상 300cm 이하여야 합니다."  
        }  
    }  

}

User 엔티티는 Height 라는 속성을 value class로 설정해주었는데, 이때 Height는 nullable하게 다뤄야 하는 속성이었기에 ?로 nullable한 타입임을 명시해 두었습니다.

이때 IDE에서 다음과 같은 오류를 보여주었습니다.

그대로 실행하게 되면 애플리케이션 실행시 Hibernate에서 다음과 같은 에러를 발생시키며 애플리케이션이 종료되었습니다

Caused by: org.hibernate.type.descriptor.java.spi.JdbcTypeRecommendationException: Could not determine recommended JdbcType for Java type 'com.studentcenter.weave.domain.user.vo.Height'

이러한 문제의 원인을 찾기위해 바이트 코드로 변환한 후 자바 코드로 디컴파일을 해보았는데, 결과는 다음과 같이 기저 타입이 아닌 Height Type을 사용하고 있었습니다.

@Column(  
   nullable = true,  
   updatable = true,  
   columnDefinition = "integer"  
)  
@Nullable  
private Height height;

이후 kotlin코드를 nullable하지 않게 해두고 실행해 보았는데, 기저타입으로 컴파일 된것을 확인할 수 있었습니다.

@Column(  
   nullable = true,  
   updatable = true,  
   columnDefinition = "integer"  
)  
private int height;

이러한 이슈로 인해 value class가 항상 기저타입으로 컴파일되어 런타임에 사용되지는 않음을 확인할 수 있었습니다.

결론

value class의 사용은 도메인 객체 모델링에 도움을 주고 애플리케이션 로직을 직관적으로 파악할 수 있게 도움을 준다는 장점이 있지만, 외부 인프라 레이어에서 사용하기에는 애매한 부분이 있다고 생각이 들었습니다.

deserializing시 리플렉션을 통해 초기화 되어 value class의 init 블럭에 있는 validation이 수행되지 않는다던가, nullable한 value class사용으로 인해 jpa entity에서 원치않는 타입이 사용되는 문제등 외부 인프라 연동시 발생하는 문제점들이 존재했습니다.

이러한 문제를 해결하기 위해 application layer에서는 value class를 적극적으로 사용하되, 외부 인프라 레이어에서는 unboxing해 기저 타입을 사용하도록 컨벤션을 두었습니다.

선착순 쿠폰 시스템을 만드는 과정에서, Producer와 Consumer를 분리해 서버를 설계하면서 도메인을 중복으로 작성하게 되어 관리포인트가 늘어난다는 느낌을 받았습니다. 이를 해결하기 위해 헥사고날 아키텍처를 활용해 도메인 모듈을 따로 분리하고 하나의 도메인 의존성을 Producer모듈과 Consumer모듈에서 의존하게 하여 도메인 관리포인트를 하나로 줄이는 방식으로 설계해 보았습니다.

아래 내용을 주로 담고있습니다.

  • 헥사고날 아키텍처와 멀티모듈을 활용해 설계한 선착순 쿠폰 시스템의 구조를 설명합니다.
  • 멀티모듈, 헥사고날 아키텍처의 개념
  • 구현 방법
    • Component Scan의 패키지 범위설정
    • Component Scan의 LazyInit 옵션
    • application.yaml 파일의 include

전체 코드는 https://github.com/waterfogSW/coupon-service 에서 확인하실 수 있습니다.

헥사고날 아키텍처

헥사고날 아키텍처는 계층형 아키텍처의 대안으로 Alistair Cockburn에 의해 고안되었습니다. 기존 계층형 아키텍처의 경우 모든 계층이 영속성 계층을 토대로 만들어 지기 때문에 비즈니스 로직의 변경이 어렵고, 테스트 또한 영속성 컴포넌트에 의존성이 생기기 때문에 테스트의 복잡도의 높이는 등 여러 문제점이 존재합니다.

업무규칙은 사용자 인터페이스나 데이터베이스와 같은 저수준의 관심사로 인해 오염되어서는 안되며, 원래 그대로의 모습으로 남아 있어야 한다. 이상적으로는 업무 규칙을 표현하는 코드는 반드시 시스템의 심장부에 위치해야 하며, 덜 중요한 코드는 이 심장부에 플러그인 되어야 한다. 업무 규칙은 시스템에서 가장 독립적이며 가장 많이 재사용할 수 있는 코드여야 한다.
- 로버트 C. 마틴, 클린 아키텍처

 

헥사고날 아키텍처는 이러한 문제점을 의존역전을 통에 의존성이 도메인을 향하게 하면서 이러한 문제를 해결합니다. 애플리케이션의 핵심 로직을 외부 시스템으로 부터 격리시켜 외부 요소의 변화에 의해 핵심 로직이 영향을 받지 않도록 합니다. 이를 통해 핵심 로직을 테스트하기 쉽고, 변경하기 쉽게 만듭니다.

헥사고날 아키텍처는 포트(Port)와 어댑터(Adapter) 아키텍처라고도 불리며, 포트와 어댑터는 다음과 같은 특징을 가지고 있습니다.

포트 (Port)

포트는 애플리케이션의 핵심 로직과 외부 세계 사이의 인터페이스 역할을 합니다.

Primary/Driving Ports

외부 시스템(예: 사용자 인터페이스, 웹 요청 등)으로부터 애플리케이션의 핵심 로직으로의 데이터 흐름을 다룹니다. 외부 요소들은 외부 포트를 통해 애플리케이션의 핵심 기능을 활용하게 됩니다.

Secondary/Driven Ports

애플리케이션 핵심 로직으로부터 외부 시스템이나 인프라스트럭처(예: Database, Message Queue, 외부 API)로의 데이터 흐름을 다룹니다. 애플리케이션은 Secondary Port를 통해 외부 자원을 필요로 할 때 접근합니다.

어댑터 (Adapter)

어댑터는 포트와 외부 세계 사이에서 데이터 형식을 변환하고, 호출을 중개하는 역할을 합니다. 포트와 외부 시스템 간의 중간자로서, 서로 다른 시스템 간의 통신을 가능하게 합니다.

Primary/Driving Adapters

애플리케이션의 핵심 로직에 접근하는 외부 시스템(예: 웹 서버, GUI 클라이언트 등)을 다룹니다. 외부 요청을 애플리케이션의 포트에 맞는 형식으로 변환합니다.

Secondary/Driven Adapters

애플리케이션에서 필요한 외부 자원(예: 데이터베이스, 파일 시스템, 외부 API 등)을 다룹니다. 핵심 로직의 요청을 외부 자원에 맞는 형식으로 변환합니다.

쿠폰 시스템 멀티모듈 구성

 

앞서 설명한 헥사고날 아키텍처의 각 레이어를 차용하여 모듈을 설계하였습니다.

Domain Hexagon

도메인 모델을 정의하는 모듈이므로, Domain Hexagon으로 명명하였습니다.

애플리케이션의 핵심 로직을 담당하는 모듈로, 도메인을 정의하고 있습니다.

  • POJO로 구현되어 있습니다.
  • common 모듈내 라이브러리 외 의존성을 가지지 않습니다.

Use Case Hexagon

도메인에 대한 유스케이스를 정의하는 모듈이므로, UseCase Hexagon으로 명명하였습니다.

도메인에 대한 Use Case를 정의하는 모듈입니다.

  • 외부 시스템과의 통신을 위한 Port 인터페이스를 정의합니다.
  • Domain 외 Spring Boot, Common 모듈내 라이브러리 의존성을 가집니다.

Infrastructure Hexagon

외부 인프라에대한 의존성을 정의하는 모듈이므로, Infrastructure Hexagon으로 명명하였습니다.

  • 외부 인프라와의 통신을 위한 Secondary Adapter를 정의합니다.
    • Kafka Producer Adapter, Persistence Adapter, Redis Adapter 등
  • Domain, Use Case 외 Spring Boot, Common 모듈내 라이브러리 의존성을 가집니다.
  • 외부 인프라별로 Module을 분리해 관리합니다
    • coupon-infrastructure/kafka
    • coupon-infrastructure/persistence
    • coupon-infrastructure/redis
  • 각 모듈별로 config class를 정의하며, application-{module name}.yaml 파일을 통해 각 모듈별로 설정을 관리합니다.
    • application-kafka.yaml
    • application-persistence.yaml
    • application-redis.yaml

Bootstrap Hexagon

여러 의존성을 조합해 하나의 애플리케이션 서버를 구성하는 모듈이므로 Bootstrap Hexagon으로 명명하였습니다.

  • 외부 요청을 받아 Use Case를 실행하기 위한 Primary Adapter를 정의합니다.
    • RestController, Kafka Consumer 등
  • Domain, Use Case, Infrastructure 외 Spring Boot, Common 모듈내 라이브러리 의존성을 가집니다.
  • Spring Boot Application을 정의합니다.
  • UseCase Hexagon과 Infrastructure Hexagon을 의존합니다.

Infrastructure 모듈과 같이 애플리케이션이 제공할 각 서비스별로 Module을 분리해 제공하는 방법도 고려해보았지만, 애플리케이션 서버마다 제공하는 API가 서로 다른경우가 훨씬 많기 때문에 모듈분리의 효용성이 떨어진다고 판단하여 Bootstrap 모듈에 Primary Adapter를 정의하였습니다.

구현 과정

Component Scan에 Lazy 옵션을 적용하기

UseCase 모듈의 UseCaseConfig

@Configuration
@ComponentScan(basePackages = ["com.waterfogsw.coupon.usecase"], lazyInit = true)
class UseCaseConfig

UseCase 모듈은 API Server, Worker Server모두 공통적으로 의존하는 모듈입니다.

하지만, API서버의 경우 Persistence모듈 의존성이 존재하지 않아, 쿠폰을 생성하고 DB에 저장하는 CreateCouponUseCase를 컴포넌트 스캔으로 등록하면 에러가 발생합니다. 마찬가지로 Worker 서버의 경우 Redis 모듈 의존성이 존재하기 않기 때문에 발행된 쿠폰의 개수를 Redis에서 조회하고 발행 이벤트를 Kafka에 전달하는 IssueCouponUseCase 를 컴포넌트 스캔으로 등록하면 에러가 발생합니다.

이러한 문제를 해결하기 위해 Usecase 모듈의 ComponentScan에 lazyInit 속성을 true로두어, UseCase를 사용하는 시점에 Bean을 생성하도록 하였습니다. 이렇게 하면 API서버는 CreateCouponUseCase에 의존성을 갖는 클래스가 존재하지 않기때문에 CreateCouponUseCase를 빈으로 등록하지 않습니다. 마찬가지로 Worker 서버는 IssueCouponUseCase를 의존성을 갖는 클래스가 존재하지 않아 IssueCouponUseCase를 빈으로 등록하지 않습니다.

Component Scan을 각 모듈내에서 수행되도록 하기위해서 각 모듈의 ComponentScan어노테이션의 패키지 위치를 잘 지정해 주어야 합니다.

@SpringBootApplication어노테이션이 @ComponentScan어노테이션을 내장하고 있기 때문에 위치를 잘 지정해 두어야 합니다. 예를들어 @SpringBootApplication어노테이션이있는 Application 클래스를 com.waterfogsw.coupon 패키지에 위치시키면, 다른 모듈에 있는 클래스라 하더라도 com.waterfogsw.coupon.* 패키지 하위의 모든 클래스들은 컴포넌트 스캔의 대상이 되기 때문에 의도치 않은 빈이 등록될 수 있습니다.

모듈별 Config, application.yaml

@Configuration
@Import(
  value = [
    KafkaProducerConfig::class,
    RedisConfig::class,
    UseCaseConfig::class
  ]
)
class ApiConfig 
@Configuration
@Import(
    PersistenceConfig::class,
    UseCaseConfig::class,
)
class WorkerConfig

위와 같이 각 모듈별로 Config 클래스를 정의하고, BootStrap Hexagon에 위치하는 API, Worker모듈의 Config는 각 모듈이 의존하고있는 모듈의 Config를 Import하도록 구현하였습니다.

또한 application-{module name}.yaml 파일을 통해 각 모듈별로 설정을 관리하기 때문에 API 모듈과 Worker 모듈의 application.yaml 파일은 다음과 같이 각 하위 의존성의 application.yaml을 include하도록 구현하였습니다.

API 모듈의 application.yaml

server:
  port: 8080
  shutdown: graceful
spring:
  profiles:
    active: local
    include:
      - kafka
      - redis
      - usecase

Worker 모듈의 application.yaml

server:
  port: 8081
  shutdown: graceful
spring:
  profiles:
    active: local
    include:
      - persistence
      - usecase

 

DB 채번을 줄이기 위해 Ulid 사용하기

보통 JPA를 사용하면 Primary Key를 @GeneratedValue 어노테이션을 통해 자동으로 생성합니다. 이런 전략을 사용하면 데이터베이스에서 자동으로 채번을 해주기 때문에 개발자는 신경쓰지 않아도 됩니다. 하지만 이런 전략은 데이터 베이스에 대한 채번을 유발하며, 영속화 되기 전까진 id값을 null로 유지해야한다는, 다소 데이터 베이스에 의존적으로 코드를 작성하게 되는 단점이 있습니다.

이런 단점을 해결하기 위해 UUID를 사용하는 방법이 있습니다. UUID는 데이터베이스에 의존적이지 않고, 영속화 되기 전까지 id값을 null로 유지할 필요가 없습니다. 하지만 UUID는 생성 순서를 보장하지 않기 때문에 목록 조회 시 정렬기준으로 삼기에는 적합하지 않아 성능적인 이점을 가져갈 수 없습니다. (UUIDv6, v7의 경우 시간순 정렬이 가능합니다)

이때 ULID를 활용할 수 있습니다. ULID는 UUID와 호환성을 가지면서 시간순으로 정렬할 수 있는 특징을 가지고 있습니다. 물론 ULID도단점이 있습니다. UUID가 나노초까지 시간순을 보장해주는 반면 ULID는 밀리초까지만 시간순을 보장해줍니다. 이를 보완하기위해 ULID Creator 라이브러리는 Monotonic ULID를 제공합니다. Monotonic ULID는 동일한 밀리초가 있다면 다음에 생성되는 ULID의 밀리초를 1 증가시켜서 생성하여 앞서 말한 단점을 보완합니다.

DB에 Primary Key를 채번하지 않고 도메인에서 직접 생성해서 사용하는 이러한 방식이 도메인이 외부에 의존하지 않고 직접 식별자를 생성할 수 있어서 클린 아키텍처에서는 더 큰 장점으로 느껴졌습니다.

 

부록

카프카를 사용하는 이유

선착순 이벤트의 경우 수많은 유저가 동시에 요청을 보내게 됩니다. 이때 API Server 에서 DB에 쿠폰 row를 생성하는 작업을 직접 처리를 한다면 DB에 부하가 몰리게 되어 다른 요청을 처리할 수 없는 상태가 됩니다.

이러한 문제점을 해결하기 위해 API서버가 직접 DB에 생성을 요청하는 대신 쿠폰 생성 이벤트를 발행해 카프카에 전달하고 이벤트를 작업 서버가 전달받아 DB에 쿠폰 row를 생성하게 합니다.

API 서버는 DB에 직접 접근하지 않고 카프카에 이벤트를 발행하고 응답을 전달하는 역할만 하게 되어 API 서버의 부하를 줄일 수 있으며, 작업서버는 카프카에 발행된 이벤트를 순차적으로 처리하기 때문에 DB에 동시에 부하가 몰리는 것을 방지할 수 있습니다.

Reference

최근에 지인에게 테스트코드에서 Transactional 어노테이션을 붙이면 테스트가 성공하고, 어노테이션을 제거하면 테스트가 실패하는데 그 이유를 모르겠다는 질문을 받았다.

문제상황

당시 문제상황을 간단한 샘플코드로 재현해 보았다.

@Entity  
@Table(name = "orders")  
class Order(  
    id: Long? = null,  
) {  

    @Id  
    @GeneratedValue(strategy = GenerationType.IDENTITY)  
    var id: Long? = id  
        private set  

    var isParcelRegistered: OrderParcelStatus = OrderParcelStatus.PADDING  
        private set  


    fun registerParcel() {  
        isParcelRegistered = OrderParcelStatus.REGISTERED  
    }  

    fun registerParcelFailed() {  
        isParcelRegistered = OrderParcelStatus.REGISTER_FAILED  
    }  


}

@Service  
class OrderService(  
    private val orderRepository: OrderRepository,  
) {  

    @Transactional  
    fun applyParcelEvent(parcelEvent: ParcelEvent) {  
        when (parcelEvent) {  
            is ParcelEvent.Success -> {  
                val order: Order = orderRepository.findById(parcelEvent.orderId).get()  
                order.registerParcel()  
            }  

            is ParcelEvent.Failure -> {  
                val order: Order = orderRepository.findById(parcelEvent.orderId).get()  
                order.registerParcelFailed()  
            }  
        }  
    }  

}

@SpringBootTest  
class TestCode(  
    @Autowired  
    private val orderService: OrderService,  
    @Autowired  
    private val orderRepository: OrderRepository,  
) {  

    @Test  
    @DisplayName("택배 등록 실패 이벤트가 발생하면, 주문의 택배 등록 상태가 실패로 변경된다.")  
    fun checkOrderStatus() {  
        // given  
        val order = Order()  
        val savedOrder: Order = orderRepository.save(order)  
        val failedParcelEvent = ParcelEvent.Failure(savedOrder.id!!)  


        // when  
        orderService.applyParcelEvent(failedParcelEvent)  

        // then  
        Assertions.assertThat(savedOrder.isParcelRegistered).isEqualTo(OrderParcelStatus.REGISTER_FAILED)  
    }  
}

 

처음 Order객체를 생성하면 택배 등록 상태를 나타내는 OrderParcelStatus 의 값이 PADDING인채로 생성된다. 이때 OrderService에 외부에서 발생한 택배 등록 이벤트(PercelEvent)를 전달해 주면 이벤트의 성공 실패 종류에 따라 Order객체의 OrderParcelStatus의 값을 변경한다.

테스트 코드는 택배 등록 이벤트가 전달되면 Order 객체의 값을 변경하는지 검증한다. 우선 택배 등록 실패이벤트가 발생한것을 가정하고 이를 orderService에 전달한다. 그러면 기존에 저장되어있던 Order의 택배 등록 상태가 실패로 변경되기를 기대하고 테스트를 수행한다.

이때 테스트는 실패한다. 택배 등록 실패이벤트를 전달했음에도 불구하고, 택배의 등록상태가 변경되지 않는다. 왜 그런걸까?

savedOrder 객체는 영속성 컨텍스트에서 관리되지 않는다

@SpringBootTest  
class TestCode(  
    @Autowired  
    private val entityManager: EntityManager,  
    @Autowired  
    private val orderService: OrderService,  
    @Autowired  
    private val orderRepository: OrderRepository,  
) {  

    @Test  
    @DisplayName("택배 등록 실패 이벤트가 발생하면, 주문의 택배 등록 상태가 실패로 변경된다.")  
    fun checkOrderStatus() {  
        // given  
        val order = Order()  
        val savedOrder: Order = orderRepository.save(order)  
        val failedParcelEvent = ParcelEvent.Failure(savedOrder.id!!)  

        println(entityManager.contains(savedOrder)) // false

        // when  
        orderService.applyParcelEvent(failedParcelEvent)  

        // then  
        Assertions.assertThat(savedOrder.isParcelRegistered).isEqualTo(OrderParcelStatus.REGISTER_FAILED)  
    }  
}

테스트 코드에서 위와같이 entityManager를 통해 현재 영속성 컨텍스트에 savedOrder가 있는지 여부를 조회하면 false값이 나온다. 따라서 JPA의 영속성 컨텍스트에서 관리되지 않고있다는 점을 확인할 수 있다. 영속성 컨텍스트에서 관리되지 않으니, 당연히 savedOrder 객체는 더티체킹의 효과를 볼 수 없다.

영속성 컨텍스트는 트랜잭션내에서 관리된다. 때문에 orderRepository로 가져온 savedOrder객체는 트랜잭션 밖, 즉 영속성 컨텍스트 밖이기에 영속성 컨텍스트에 의해 관리되지 않는 객체이다.

때문에 이후 orderService.applyParcelEvent(...)를 통해 DB에 저장된 order의 OrderParcelStatus를 변경하더라도, savedOrder의 값은 변화가 없다.

영속성 컨텍스트는 트랜잭션 범위 내에서 관리된다


영속성 컨텍스트의 종류는 두가지가 있다.

  • Transaction-scoped persistence context
  • Extended-scoped persistence context

Transaction-scoped persistence context의 경우 트랜잭션 단위로 영속성 컨텍스트가 유지되는 반면, Extended-scoped persistence context의 경우 컨테이너가 관리하는 영속성 컨텍스트로 여러 트랜잭션에 걸쳐 사용될 수 있다.

확장된 퍼시스턴스 컨텍스트를 갖는 EntityManager는 트랜 잭션 스코프의 퍼시스턴스 컨텍스트에서 사용되는 EntityManager 처럼 멀티스레드에서 안전한 프록시 오브젝트가 아니라 멀티스레드에서 안전하지 않은 실제 EntityManager다.
- 토비의 스프링 3.1 Vol.2 289p

public @interface PersistenceContext {  

    ...

    /**  
     * (Optional) Specifies whether a transaction-scoped persistence context     * or an extended persistence context is to be used.  
     */     
    PersistenceContextType type() default PersistenceContextType.TRANSACTION;  
    ...
}

PersistenceContext의 어노테이션을 직접 확인해보면 Transaction-scoped persistence context를 기본값으로 사용한다. Transaction-scoped persistence context를 사용하면 여러 측면에서 다음과 같은 장점이 있다.

효율성
transaction-scoped 영속성 컨텍스트는 트랜잭션이 끝나면 자동으로 종료되므로, 불필요한 리소스 사용을 줄일 수 있다. 반면에 Extended 영속성 컨텍스트는 트랜잭션이 끝나도 종료되지 않고 계속 유지되므로, 리소스 사용이 더 많을 수 있다.

일관성
transaction-scoped 영속성 컨텍스트는 트랜잭션 범위 내에서 일관성을 보장한다. 즉, 트랜잭션 내에서 수행된 모든 데이터베이스 작업은 일관된 상태를 유지한다. 반면에 Extended 영속성 컨텍스트는 여러 트랜잭션에 걸쳐 사용될 수 있으므로, 일관성을 보장하기 어려울 수 있다.

간단함
transaction-scoped 영속성 컨텍스트는 트랜잭션을 시작하고 종료하는 것만으로 영속성 컨텍스트를 관리할 수 있다. 반면에 Extended 영속성 컨텍스트는 수동으로 관리해야 하므로, 사용하기 복잡할 수 있다.

실제 트랜잭션을 부여하는 JpaTransactionManager를 살펴보면 EntityManager를 생성해 트랜잭션 단위로 관리하는것을 볼 수 있다.

우선 PlatformTransactionManager의 기본적인 동작 일부를 구현하고 있는 AbstractPlatformTransactionManager를 살펴보면 startTransaction부분에서 TransactionManager의 doBegin메서드를 호출하고있는것을 볼 수 있다.

private TransactionStatus startTransaction(TransactionDefinition definition, Object transaction,  
       boolean nested, boolean debugEnabled, @Nullable SuspendedResourcesHolder suspendedResources) {  

    boolean newSynchronization = (getTransactionSynchronization() != SYNCHRONIZATION_NEVER);  
    DefaultTransactionStatus status = newTransactionStatus(  
          definition, transaction, true, newSynchronization, nested, debugEnabled, suspendedResources);  
    this.transactionExecutionListeners.forEach(listener -> listener.beforeBegin(status));  
    try {  
       doBegin(transaction, definition);  
    }  
    catch (RuntimeException | Error ex) {  
       this.transactionExecutionListeners.forEach(listener -> listener.afterBegin(status, ex));  
       throw ex;  
    }  
    prepareSynchronization(status, definition);  
    this.transactionExecutionListeners.forEach(listener -> listener.afterBegin(status, null));  
    return status;  
}

doBegin메서드는 TransactionManager를 구현하고 있는 JpaTransactionManager에서 확인할 수 있는데, entityManager를 생성하고 있는것을 볼 수 있다.

@Override  
protected void doBegin(Object transaction, TransactionDefinition definition) {  
    JpaTransactionObject txObject = (JpaTransactionObject) transaction;  

    if (txObject.hasConnectionHolder() && !txObject.getConnectionHolder().isSynchronizedWithTransaction()) {  
       throw new IllegalTransactionStateException(  
             "Pre-bound JDBC Connection found! JpaTransactionManager does not support " +  
             "running within DataSourceTransactionManager if told to manage the DataSource itself. " +  
             "It is recommended to use a single JpaTransactionManager for all transactions " +  
             "on a single DataSource, no matter whether JPA or JDBC access.");  
    }  

    try {  
       if (!txObject.hasEntityManagerHolder() ||  
             txObject.getEntityManagerHolder().isSynchronizedWithTransaction()) {  
          EntityManager newEm = createEntityManagerForTransaction();  
          if (logger.isDebugEnabled()) {  
             logger.debug("Opened new EntityManager [" + newEm + "] for JPA transaction");  
          }  
          txObject.setEntityManagerHolder(new EntityManagerHolder(newEm), true);  
       }  

       EntityManager em = txObject.getEntityManagerHolder().getEntityManager();
       ...

JpaTransactionManager의 doBegin 메서드는 새로운 트랜잭션을 시작할 때 호출된다. 이 메서드에서는 EntityManager를 생성하고 이를 JpaTransactionObject에 저장한다.

JpaTransactionObject는 현재 트랜잭션의 상태를 추적하는 데 사용되며, 트랜잭션 범위 내에서 사용되는 EntityManager를 보유하고 있다. 이렇게 하면 트랜잭션 범위 내에서 동일한 EntityManager 인스턴스가 사용될 수 있다. 이렇게 JpaTransactionManager는 트랜잭션 단위로 EntityManager를 관리한다.

하지만 항상 영속성 컨텍스트의 생존 범위가 무조건 트랜잭션 범위 내 인것은 아니다. open session in view를 사용하면 영속성 컨텍스트의 범위를 트랜잭션 범위 밖까지 확장할 수 있다.

Reference

+ Recent posts