1. 개요

실무에 적용해본 RabbitMQ가 무엇인지를 알고, 관련된 개념들도 함께 정리해보자.

 

 


2. RabbitMQ 무엇인가요?

AMQP를 구현한 오픈소스 메시지 브로커.

 


3. AMQP 란 무엇인가요?

Advanced Message Queueing Protocol의 약자로 최적화된 방식의 메시지 큐잉 프로토콜을 말한다. 그럼 최적화된 방식은 뭘까? 바로 메시지 브로커를 사용해 클라이언트 간의 메시지 전송을 표준화한 프로토콜이다.

 

AMQP 구조

메시지 브로커
메시지 브로커의 사전적 의미는 "메시지를 중개하는 사람"이다. AMQP에서 말하는 메시지 브로커는 송신자(Publisher)와 수신자(Consumer) 사이에서 메시지를 안전하고 효율적으로 전달해주는 중간자를 의미한다.

 

 클라이언트는 Consumer만이 아니다. 메시지 브로커 입장에서는 Publisher, Consumer 모두 클라이언트이다.

Publisher
브로커와 연결을 맺고, 메시지를 발행하는 클라이언트

Consumer
브로커와 연결을 맺고, 메시지를 수신하는 클라이언트

 


중간자가 있다면 뭐가 달라지나요??

 메시지 브로커와 같은 중간자가 있다면 송신자와 수신자의 통신 방식은 어떻게 될까?

 송신자는 수신자를 직접 찾아갈 필요가 없고, 수신자가 언제 받을 수 있는지 체크할 필요도 없다. 그냥 보내고 싶을때 중간자에게 메시지를 전송하면 된다. 수신자에게 전달하는 건 중간자가 알아서 처리하기 때문이다.

 수신자도 마찬가지이다. 메시지를 누가, 언제 보낼 예정인지 알 필요가 없고, 메시지를 받을 준비도 필요 없다. 나에게 온 메시지가 있다면 중간자에게 바로 보내달라고 요청해놓으면 되기 때문이다. 택배가 도착하면 도착 알림 메시지가 오는것처럼 말이다. 참고로 수신자가 메시지를 받는 방식은 두가지이다. 수신자가 메시지 브로커에게 주기적으로 메시지를 확인하는 Pull 방식, 메시지가 오면 브로커가 수신자에게 메시지를 즉시 전달하는 Push 방식이 있다. 이를 '소비 모델'이라고 한다.

 

소비 모델
메시지 브로커를 통해 소비자가 메시지를 가져가는 전략을 말한다.

Push 방식이 더 좋은거 아니야? 왜 굳이...Pull??

 

 수신자가 메시지 브로커에서 메시지를 확인하는 Pull 방식보다는 브로커가 즉각, 알아서 수신자에게 메시지를 전달하는 Push 방식이 효율적으로 보인다. 당연히 속도도 빠를거고, 메시지 브로커에게 '확인'하는 단계가 없기때문에 네트워크 리소스 측면에서도 효율적으로 보인다. 하지만, 상황에 따라서 Pull 방식이 좋은 케이스도 있다.

 

집이 좁아요...

 전세사기를 당해 3평짜리 원룸으로 이사했다고 가정해보자. 이를 불쌍하게 여긴 가족, 친구, 지인들이 음식, 아이스크림, 전자제품 등을 막 보내기 시작했다. 처음엔 너무 감사했지만, 냉장고가 꽉 차 음식과 아이스크림을 보관할 수가 없고, 전자제품은 더 이상 집에 들일 수 만큼 비좁았다. 음식과 아이스크림은 곧 상해버렸고, 전제제품은 집 밖에 내놨다가 비를맞고 고장이 나버렸다.

 만약, 택배 회사에 큰 창고가 있고, 내가 필요할때마다 택배 회사로 연락해 내 집이 수용할 수 있을 만큼만 전달받으면 어떨까? 내 집에 수용할 수 없는 전자제품, 음식, 아이스크림은 모두 택배회사에 안전하게 보관될것이기 때문에, 버려지지 않을것이다.

 그렇다. 일반적으로 메시지를 즉시 받아야 하는 상황이 있는 반면, 수신자의 환경에 따라 메시지를 일정량만 받아야 하는 상황도 있다. 특히 대량의 데이터라면 수신자는 데이터를 한번에 처리하는 것이 매우 부담될것이다.

 

바빠요...

 만약 우리가 보낸 물건을 택배회사에서 모아서가 아닌, 실시간/즉각적으로 처리해주면 어떻게될까? 예를들어 필자가 택배 발송 신청을 하자마자 택배회사가 가져가고, 택배회사에서 받자마자 수신자에게 전달하는 것이다.

 그럼 수신자 입장에서는 아주 빨리 택배를 전달받을 수 있다. 그런데, 1시간동안 1000명의 사람이 동일한 수신자에게 택배 발송 신청을 하면 어떨까? 택배회사에 물건이 도착하지마자 택배 기사를 불러 물건을 배송시킬것이고 수신자에게 1000번의 발송이 필요하게 된다. 택배 기사는 죽어날것이다. 만약 한 시간 단위로 수신자가 택배회사로 택배 발송 요청을 했다면, 1시간동안 모인 1000개의 택배를 1번의 발송으로 처리할 수 있게 된다.

 

결국 수신자가 실시간 데이터를 감당할 수 있느냐에 따라 권장되는 방식이 달라지는 것이다.

 


RabbitMQ vs Kafka

RabbitMQ 는 AMQP 구현체이지만 Kafka는 아니다. Kafka는 Pull 기반 소비 모델을 채택한 분산 로그 시스템으로, 큐가 아닌 로그파일을 기반으로 데이터를 가져간다. 메시지를 큐에서 바로 소비하고 사라지는 방식이 아닌, 디스크에 append-only log로 저장하면, 클라이언트는 이 로그파일의 offset 기준으로 데이터를 읽어간다. 둘 다 브로커라는 중간자를 사용하여 데이터를 처리한다는 점에서 RabbitMQ와 함께 언급되곤 한다.

 

 기본 처리 방식은 곧 내부 설계를 말한다. RabbitMQ는 Push 방식, 즉 실시간 처리에 특화 설계되어있고, Kafka는 Pull 방식,대용량 처리에 특화 설계되었다. 때문에 실시간 처리가 중요하다면 RabbitMQ를, 실시간 처리보다는 통계 데이터와 같이 대용량 데이터 처리가 중요하다면 Kafka를 선택하는 것이 좋다고 생각한다. 

 필자의 경우 실시간 처리가 중요했고, 데이터의 양은 1분에 10건 정도였기 때문에 RabbitMQ를 선택했다.

 


메시지 전송 표준화 / 구조

 처음 AMQP를 "메시지 브로커를 이용해 클라이언트간의 메시지 전송을 표준화한 프로토콜"이라 했다. 표준화는 바로 Exchange, Queue, Binding 구조를 통해 표준화시켰다.

 

Exchange

 

 발행자가 보낸 메시지를 받아서, Binding 규칙에 따라 Queue로 전달.

 

Binding

 Exchange와 Queue를 연결하는 규칙

 

Queue

 메시지를 저장하는 버퍼로, 소비자가 메시지를 읽고 ACK를 회신할때까지 보관

 

AMQP 동작 흐름

1) Producer가 메시지를 메시지 브로커의 Exchange로 전달

2) Exchange가 Binding 규칙에 따라 메시지를 Queue에 전달

3) Queue에서 메시지를 가져와 Customer에게 전달

4) 처리 완료 후 ACK(확인 응답)를 회신

5) Queue에 있는 해당 메시지를 삭제

 

AMQP의 특징

ACK 검증

 소비자 <-> 메시지 브로커 사이의 확인 응답으로 메시지를 소비자가 잘 처리했는지를 보장한다.

 

라우팅 유연성

 다양한 Exchange 타입으로 복잡한 메시지 흐름을 구현할 수 있다.

 

Confirm 모드

 발행자 <-> 메시지 브로커 사이의 확인 응답으로 메시지가 브로커에 잘 도착했는지를 보장한다.

 

Transaction 모드

 발행자가 여러 메시지를 보낼 때, 원자적으로 처리하기 위한 모드로, 메시지 발행 작업의 원자성을 보장한다. 

 

 

 

반응형

개요

 다중 컬럼 인덱스를 인덱스 설계 원칙, Index Range Scan과 함께 이해하고, 실무에서 무엇을 기준으로 인덱스를 설계해야하는지 판단할 수 있는 눈을 길러보자.


다중 컬럼 인덱스(Multi-column Index)란?

 다중 컬럼 인덱스란 2개 이상의 컬럼으로 구성된 인덱스를 말한다. 인덱스를 사용하면 내부적으로 B+Tree가 생성됨과 동시에 인덱스 컬럼 기준으로 정렬된다는 사실을 알것이다. 다중 컬럼 인덱스도 마찬가지로 정렬된다. 단, 각 컬럼은 앞 컬럼에 의존되어 정렬된다. 첫번째 컬럼을 기준으로 1차 정렬되고, 인덱스의 두번째 컬럼은 첫번째 컬럼을, 세번째 컬럼은 두번째 컬럼을 의존해 정렬되는 것이다. 예시를 통해 이해해보자.

 

CREATE TABLE TBL_TEST (
    id integer NOT NULL AUTO_INCREMENT,
    dept_no varchar(100) NOT NULL,
    emp_no varchar(100) NOT NULL,
    PRIMARY KEY ('id')
);

 

위 테이블에 대해 (dept_no, emp_no) 를 다중 컬럼 인덱스로 설정하면 첫번째 컬럼인 dept_no으로 1차 정렬되고, 두번째 컬럼인 emp_no은 첫번째 컬럼인 dept_no에 의존되어 정렬된다. order by dept_no, emp_no 쿼리를 날린것처럼 말이다.

 

(dept_no, emp_no) Multi Column Index 구조

 


인덱스 설계 원칙

인덱스 설계 원칙 몇가지가 있는데, 그 중 두 가지 설계 원칙이 오늘 공부하는 다중 컬럼 인덱스와 관련이 있어 가져왔다. 이왕이면 설계 원칙과 함께 이해하는것이 좋지않겠는가?! 

 

1. 선택도가 높은 컬럼을 선두 컬럼에 배치해라.

2. 비교 컬럼은 선두 컬럼에, 범위 컬럼은 뒤쪽 컬럼에 배치해라.

 

선택도 (Selectivity)
특정 컬럼의 값이 얼마나 다양하게 분포되어 있는지를 나타내는 지표. 선택도가 높다는 건 카디널리디가 많다는 뜻.

비교 컬럼은 선두 컬럼에, 범위 컬럼은 뒤쪽 컬럼에 배치해라.

1번 설계원칙을 잘 이해하기 위해서는 2번 설계 원칙부터 이해하는 것이 중요하다 생각되어 먼저 설명하도록 하겠다. 이 설계 원칙은 다중 컬럼 인덱스와 직접적으로 연관된 설계 원칙이다. '비교 연산자 컬럼 > 범위 연산자 컬럼' 순서로 인덱스를 설계해야한다는 것인데, 아래 예시를 봐보자.

gender = 비교 컬럼
created_at = 범위 컬럼

 

 gender는 성별을 나타낸다. M과 F 값으로만 구성된다. (카디널리티는 2개, 선택도는 낮다). 이 컬럼을 조회 쿼리에 사용할때 일반적으로 비교(=)조건을 사용하므로 비교 컬럼으로 구분된다.

SELECT * FROM TBL_USER WHERE gender = 'M' -- 비교 조건 컬럼
SELECT * FROM TBL_USER WHERE gender IN ('M') -- 비교 조건 컬럼

 

 created_at는 생성 일시를 나타낸다. 매우 다양한 값으로 구성된다. (카디널리티 다양, 선택도도 높음). 이 컬럼을 조회 쿼리에 사용할땐 일반적으로 범위(>=) 건을 사용하므로 범위 컬럼으로 구분된다.

SELECT * FROM TBL_USE WHERE created_at >= '2025-01-01' -- 범위 조건 컬럼

 

 

이때 두 컬럼을 인덱스로 구성해야한다면, 비교 컬럼인 gender를 앞쪽, 범위 컬럼인 created_at를 뒤쪽으로 하여 (gender, created_at) 형태로 인덱스를 설계해야 한다는 것이다. 정말 그럴까?

 


(gender, created_at) 인덱스 설정

CREATE INDEX idx_example ON TBL_USER (gender, created_at);

 

인덱스를 설정하면 아래와 같이 gender로 선 정렬된 후, created_at가 gender에 의존되어 정렬된다.

 

(F, 2025-01-01)

(F, 2025-01-01)

(F, 2025-01-02)

(F, 2025-01-02)

(F, 2025-01-03)

(M, 2025-01-01)

(M, 2025-01-01)

(M, 2025-01-02)

(M, 2025-01-02)

(M, 2025-01-03)

 

이 상태에서 조회쿼리를 날려보자.

SELECT * FROM TBL_USER WHERE gender = 'M' AND created_at >= '2025-01-02'

 

그럼 gender = 'M'인 레코드를 찾기 위해 트리 탐색을 진행할것이다. 정렬되어 있으니 M 으로 시작하는 레코드는 금방 찾을 수 있을 것이다. 그렇게 탐색한 데이터는 아래와 같은 범위에 해당할것이다.

 

(M, 2025-01-01)

(M, 2025-01-01)

(M, 2025-01-02)

(M, 2025-01-02)

(M, 2025-01-03)

 

이후 created_at >= '2025-01-02' 인 레코드를 찾는다. 그런데 created_at 데이터를 보면 정렬이 이미 되어있다. 정렬되어있으니 내부적으로 트리탐색이 쓰일것이라 생각할 수도 있지만, 그렇지않다. 순차적으로 스캔한다. 다중 컬럼 인덱스에서는 첫 인덱스에 대해서는 트리탐색을, 그 이후부터는 순차 탐색을 하는데, 이러한 방식을 Index Range Scan이라 한다. 

 위 예시에서 첫번째 인덱스인 gender에 대해서는 트리탐색을, created_at에 대해서는 순차 탐색이 진행된다.

 

Index Range Scan
트리 탐색을 통해 인덱스의 일부 구간을 탐색하고, 탐색된 범위에 해당하는 리프 노드들만을 순차적으로 스캔하는 방식이다. 다중 컬럼 인덱스에서 사용된다.

 


(created_at, gender) 인덱스 구조 이해하기

CREATE INDEX idx_example ON TBL_USER (created_at, gender);

 

다음은 인덱스 설계 원칙과 반대로 (created_at, gender) 순으로 인덱스를 설정했다. 아래와 같이 created_at로 선 정렬된 후, gender가 created_at에 의존되어 정렬된다.

 

(2025-01-01, F)

(2025-01-01, F)

(2025-01-01, M)

(2025-01-01, M)

(2025-01-02, F)

(2025-01-02, F)

(2025-01-02, M)

(2025-01-02, M)

(2025-01-03, F)

(2025-01-03, M)

 

이 상태에서 아래 쿼리를 실행한다. (인덱스를 사용을 위해 WHERE 절의 조건 컬럼 순서를 변경했다.)

SELECT * FROM TBL_USER WHERE created_at >= '2025-01-02' AND gender = 'M'

 

최초로 created_at >= '2025-01-02' 인 레코드를 찾기 위해 트리 탐색을 진행할것이다. 정렬되어 있으니  '2025-01-02' 으로 시작하는 레코드들은 금방 찾을 수 있을 것이다.

 

(2025-01-02, F)

(2025-01-02, F)

(2025-01-02, M)

(2025-01-02, M)

(2025-01-03, F)

(2025-01-03, M)

 

이후 gender = 'M' 인 레코드를 찾는다. 마찬가지로 순차 탐색이 진행될것이다.


데이터를 늘려보자!

 데이터가 많아지면 어떨까? gender는 카디널리티가 2개인데 반해, created_at는 수천, 수만개이다. 설계 원칙에 따라 (gender, created_at)순으로 설계하면, 첫번째 트리 탐색을 통해 탐색된 레코드가 굉장히 많아진다. 레코드가 10만개일 때, 성별 분포도가 반반이라면 첫번째 트리 탐색을 통해 탐색된 레코드가 그 중 절반인 5만개 일것이다. 그럼 5만개에 대해서는 순차 탐색이 진행된다. 5만번의 스캔이 발생하는것이다.

 

반대로 (created_at, gender) 순으로 설계하면 어떨까? created_at를 통해 첫번째 트리 탐색을 통한 레코드 개수를 확 줄일 수 있다. 순차 탐색 횟수를 줄이게되는 것이다. 결국 비교 컬럼을 선두 컬럼에 두는 것보다, 범위 컬럼을 선두 컬럼에 두는 것이 더 좋은 케이스인것이다.

 


그럼 잘못된 원칙이야?

 

그렇지 않다. 일반적으로는 비교 컬럼을 선두 컬럼에 두는 것이 좋다. 단, 조건이 있는데 선두 컬럼이 너무 낮은 카디널리티를 갖지 않아야 한다는것이다. gender 의 경우 카디널리티가 2로 매우 낮기때문에 선두컬럼으로 부적합하다. 다른 예로 email을 생각해보자. email은 비교 컬럼이다. 카디널리티도 매우 많다. 이런 경우에는 (email, created_at)가 당연히 효율적이다. 1차 트리탐색을 통해 필터링된 레코드의 양이 적기 때문이다.

 


결국 중요한건 선택도

 email이 인덱스의 선두 컬럼에 위치하는게 효율적인 이유는 단순 비교 컬럼이기 때문이 아니다. 카디널리티가 많은, 즉, 선택도가 높은 컬럼이기 때문이고, 선택도가 높기 때문에 순차 스캔하는 부담이 줄어들게 된다. 즉, 비교 컬럼이냐 범위 컬럼이냐보다 선택도가 높은것인가가 더 중요하다. 이 설계 원칙이 첫번째 설계원칙 "선택도가 높은 컬럼을 선두컬럼에 배치해라" 이다.

 


회고

인덱스를 공부하면 함께 딸려오는 말이 바로 카디널리티이다. 이번 포스팅을 통해 카디널리티와 선택도, 다중 컬럼 인덱스, Index Range Scan 을 연계해서 이해할 수 있었고, 인덱스에서 카디널리티가 얼마나 중요한지를 확실히 알게된 경험이었다.

반응형

개요

MySQL의 인덱스 구조는 B+Tree이다. 그럼 이미 등록된 인덱스 키를 테이블에서 추가, 삭제, 변경했을 때 내부적으로 어떻게 처리되는지를 이해해보자. 그리고 더 나아가 검색을 했을 때는 어떤 메커니즘으로 키를 찾아가는지도 이해해보자.

 


인덱스 키 추가

B+Tree에 키를 추가하기위해 가장 먼저 해야할 일은 B+Tree 상의 적절한 위치를 검색하는 것이다. 검색은 B+Tree 트리탐색을 통해 진행되며, 저장될 위치가 결정되면 인덱스 키와 데이터 레코드의 주소 정보(또는 데이터)를 리프노드에 저장한다. 만약 클러스터드 인덱스의 키 였다면 [key, 데이터]를, 세컨더리 인덱스의 키 였다면 [key, 클러스터드 인덱스의 리프노드 주소]가 추가될것이다.

클러스터드 인덱스 구조

 

세컨더리 인덱스 구조

 


인덱스 키 삭제

키 삭제는 간단하다. 해당 키 값이 저장된 리프 노드를 찾아서 삭제 마킹만 하면 끝이다. 삭제 마킹된 인덱스 키 공간은 방치되거나 재사용된다.

 

그냥 삭제하면 되지, 왜 삭제 마킹을??

만약 인덱스가 즉시 삭제된다면 B+Tree 구조에서 재정렬 작업이 발생하게 된다. InnoDB 엔진은 이러한 오버헤드를 줄이기 위해 삭제 마킹만 해놓고 purge thread가 백그라운드에서 일괄 삭제한다.

 


인덱스 키 변경

인덱스 키 값에 따라 B+Tree 상의 위치가 결정되므로 키 값이 변경되는 경우에는 B+Tree 상에서 인덱스 키 값만 달랑! 변경할 수 없다. 먼저 키 값을 삭제한 후(이때도 마킹이다), 다시 새로운 키 값을 추가하는 형태로 처리된다.

 

 


인덱스 키 검색

인덱스를 검색하는 작업은 B+Tree의 루트 노드부터 시작해 브랜치 노드를 거쳐 리프 노드까지 이동한다. 이를 트리 탐색이라 하는데, 내가 호출한 쿼리가 인덱스를 활용할 수 있는 상황에서 사용된다. 또한 상황에 따라 SELECT 절 뿐 아니라 UPDATE, DELETE 에도 사용된다는 것을 알 수 있을것이다. (값을 찾아야하니까!) 그럼 실제 조회 쿼리를 날렸을 때 어떤 메커니즘으로 데이터가 조회되는지 알아보자.

 

Integer 타입의 인덱스 키 조회

TBL_TEST 라는 테이블에 1~15 까지의 값을 INSERT 한 상황에서 아래 쿼리를 날렸다면 어떻게 조회될까? 이해를 돕기위해 필자가 사용하는 B+Tree 시뮬레이션 사이트를 활용했다.

SELECT * FROM TABLE WHERE ID = 10;

 

WHERE ID =10

 

 

위와 같이 트리 탐색이 실시되고, 실제 데이터 노드가 있는 리프노드까지 이동한다. 위는 가장 일반적인 상황으로, 테이블의 ID 컬럼이 Integer 타입의 PK 라고 생각하면 된다.

 

String 타입의 인덱스 키 조회

String 타입의 값을 세컨더리 인덱스의 키로 설정하고, 이를 조회했을 땐 어떨까?

WHERE NAME = '영암'

 

마찬가지로 B+Tree에 가나다 순으로 순서대로 저장될것이기에 트리 탐색을 통해 조회된다.

 

인덱스는 키 값이 100% 일치하는 조건절에만 사용되나요?

키 값이 100% 일치하는 상황 뿐 아니라 아래와 같이 값의 앞부분을 비교하거나 부등호를 통해 비교할 때에도 사용된다. 단, 뒷부분을 비교할 때는 트리 탐색. 즉, 인덱스가 사용되지 않는다. 이 이유는 아래서 설명하겠다.'

 

앞부분이 일치하는 인덱스 키 조회

SELECT * FROM TABLE WHERE NAME LIKE '서%';

 

B+Tree 구조를 활용하여 '서'로 시작하는 인덱스 키의 위치를 찾아낼 수 있다. 시뮬레이션을 돌며보면 해당 단어가 위치할만한 예상 지점을 찾아가고 있다. 실제 InnoDB 엔진에서는 아래와 같이 시작점을 찾은 후 리프노드의 포인터를 거쳐가며 '서'로 시작하는 데이터를 검증해나갈것이다.

앞부분을 비교했을 때 노드를 찾아갈수 있다.

 

 

왜 꼭 앞부분 비교할때만 트리 탐색. 즉, 인덱스가 사용되는거에요?

책을 예시로 들면 바로 이해할 수 있다. 책에는 페이지 번호가 있다. 그런데 만약 페이지 번호가 숫자가 아닌 문자열(?)이라면 어떨까? 첫페이지는 광주, 두번째 페이지는 대구, 세번째 페이지는 대성... 이렇게 해서 마지막 페이지에 화천이 있는것이다. 

 

만약 이 상황에서 '서'로 시작하는, 즉 LIKE '서%' 를 찾아야한다면 어떻게 찾아갈까? 가장 먼저 페이지의 중간을 펼칠것이다. '대천' 페이지가 나왔다면 '대천'이라는 단어보다 '서' 가 더 뒤에있으니 뒷 페이지에 어딘가에 있다는 것을 알 수 있다. 범위를 좁혀가며 페이지를 찾아가다보면 금방 '서' 로 시작하는 '서산' 이라는 페이지를 찾게될것이다. 그 이후부터는 한페이지씩 넘기면서 '서'라는 글자가 페이지 앞에 포함되어 있는지만 확인하면 된다. 그리고 '안산' 이라는 페이지가 나왔을때, 다음 페이지부터는 '서'로 시작하는게 페이지가 없다는 걸 알 수 있다.

 

 

B+Tree 도 똑같다. 문자열이든 숫자든 정렬되어 있으니 앞부분을 통해 값의 위치를 찾아갈 수 있다. 인덱스를 활용할 수 있는 것이다.

 

그럼 왜 뒷부분 비교에는 인덱스 사용이 안돼요?

그럼 뒷부분을 직접 비교해보자. 방금 말한 책에서 뒤가 '수'로 끝나는 페이지를 찾을 땐 어떻게 찾아야할까? 펼친 페이지 값을 통해 '수'로 끝나는 데이터의 위치를 유추할 수 있을까?

SELECT * FROM TABLE WHERE NAME LIKE '%수';

 

펼친 페이지에 어떤 문자열이 있건간에 유추할 수 없다. 페이지 번호(문자열)가 정렬되어 있더라도 뒷부분에 대한 비교에는 활용할 수 없는것이다. 첫번째 페이지부터 마지막 페이지까지 모두 스캔해가며 뒤에 '수'가 붙어있는지 확인하는 방법밖에 없다. 바로 이것이 Full Scan이다. 인덱스를 활용하지 못하면 Full Scan이 발생한다.

 

부등호에도 인덱스가 사용되는 이유는 알겠어요!

위 내용을 이해했다면 부등호를 사용했을 때 인덱스가 사용되는 이유도 알 수 있다. 65 < x < 70 인 값을 찾아야한다면 인덱스를 통해 B+Tree 상에서 65라는 키의 위치를 탐색한 후, 70이 되기 전까지 페이지를 앞으로 넘겨가면 된다.

 


WHERE 절에서 인덱스가 활용되는 조건

1) 등호(100% 일치) 비교 

 - ID 인덱스 활용

SELECT * FROM TABLE WHERE ID = 123;

 

2) IN 절

 - ID 인덱스 활용

 * 단, 너무 많은 값이 들어가면 옵티마이저가 무시할 수 있음.

SELECT * FROM TABLE WHERE ID IN (1,2,3);

 

 

3) 범위조건 (부등호, BETWEEN)

 - CREATE_AT 인덱스 활용

SELECT * FROM TABLE WHERE CREATE_AT BETWEEN '2025-01-01' AND '2025-12-31';

 

4) LIKE 'ABC%'

 - NAME 인덱스 활용

 * 뒷부분을 검색하는 LIKE '%승'은 인덱스가 적용되지 않음

SELECT * FROM TABLE WHERE NAME LIKE '승%';

 

5) AND 조건에서 복합 인덱스

 - 복합 인덱스 (COL1, COL2) 활용

 * 복합 인덱스는 왼쪽부터 순서대로 사용하는 조건에만 적용

 * COL1만 써도 인덱스 적용, COL2 만 쓰면 인덱스 적용 안됨

SELECT * FROM TABLE WHERE COL1 = 'A' AND COL2 = 'B';

 


회고

인덱스의 삽입, 삭제, 변경, 검색 방법에 대해 내부 메커니즘을 이해할 수 있었다. 이로 인해 인덱스가 사용되는 케이스, 사용되지 않는 케이스에 대해서도 그렇게 되는 이유를 생각할 수 있었다. 인덱스 사용에 대한 기준을 잡아가고 있고, 그 기준을 통해 서비스에도 적용할 수 있다는 자신감이 든다. 다음 게시글에서는 인덱스의 종류에 대해 알아보도록 하겠다.

 

 

 

 

 

 

반응형

개요

 MySQL의 인덱스를 공부하면 가장 처음 등장하는 내용이 바로 클러스터링 인덱스논 클러스터링 인덱스다. 필자는 인덱스를 공부하기 전 데이터가 적재되는 구조를 이해하고 싶었다. 이를 이해해야 인덱스를 설정했을 때 내부 구조, 성능과 같은 것들을 쉽게 유추할 수 있다고 생각했기 때문이다. 그리고 B-Tree 가 등장했다. 유투브 영상을보며 어떤 메커니즘으로 데이터들이 적재되고 구조화되는지 이해했다.

 마지막으로 MySQL의 인덱스 구조에 대한 아래 이미지를 보고 필자가 공부했던 B-Tree를 적용시켜 이해하려했다. 그런데 이게 웬걸? MySQL의 인덱스구조와 B-Tree의 구조가 매칭되지 않았다. 이번 글에서는 MySQL의 인덱스 구조와 실제 B-Tree의 구조가 다른 이유를 이해하고, 클러스터링 인덱스와 논 클러스터링 인덱스에 대해 알아보도록 하겠다.

이게 B-Tree 구조가 맞아?

 

 

 


MySQL의 데이터 적재 구조는 B-Tree 가 맞긴 해?

 다양한 DBMS들이 존재하고, 이들의 데이터 적재 구조도 모두 다르다. 이 중 MySQL은 B-Tree 방식의 데이터 구조를 채택했다고 하는데(실제 MySQL 공식문서에서도 말이다.) 위 이미지를 보면 '이게 B-Tree가 맞아?' 라는 생각이 든다.

 결론부터 말하면 B-Tree가 맞긴하다. 다만 필자가 공부했던 기본 형태의 B-Tree 가 아니다. 변형된 형태의 B-Tree 이다. MySQL에서는 변형된 B-Tree인 B+Tree 를 사용한다!!


B+Tree 란

B-Tree의 변형된 형태의 트리이다. 필자가 공부하고 느낀 4가지의 주요 특징들을 정리해보았다.

 

B+Tree

 

1. 실제 데이터는 리프 노드에만 저장된다. 

2. 리프 노드들은 포인터로 연결된 연결 리스트 형태이다.

3. 내부 노드는 오직 탐색을 위해 존재한다. 즉, 실제 데이터(== 레코드 데이터)가 없다.

4. 노드가 꽉 찰 경우 상위노드로 격상시킨다. 단, 복제된 ID를 격상시킨다.

 

위 특징을 이해하면 B+Tree 와 MySQL의 데이터 적재 구조를 이해할 수 있고, 더 나아가 인덱스와도 연계시킬 수 있다. 익숙한 B-Tree 를 보고 B+Tree와 뭐가 다른지 알아보자.

 

B-Tree 구조 살펴보기

4차 B-Tree에 1~10까지의 ID를 넣은 구조이다. 살펴보면, 루트 노드, 내부 노드, 리프 노드에 ID 값이 중복없이 들어있다. 만약 실제 DB였다면, ID 값과 함께 실제 데이터도 들어갔을것이다. 루트 노드, 내부 노드, 리프 노드에도 ID 와 함께 실제 데이터가 들어갔을것이란 말이다.

B-Tree 구조

 

 

 이제 MySQL 데이터 구조와 대조해보자. 먼저 눈에 띄는 건 ID이다. 루트 노드, 내부 노드, 리프 노드에 ID 값이 중복되어 들어있다. 하나 더있다. 루트 노드(or 내부 노드)의 데이터에는 실제 데이터가 아닌 리프노드의 주소 값이 들어있고, 리프 노드에 실제 데이터가 들어있다는 것이다.

 B-Tree는ID 중복을 허용하지 않고, 루트 노드(or 내부노드)에는 실제 데이터가 들어있던것과 상반되는 내용이다. 이를 통해 B-Tree 구조가 실제 데이터 페이지의 트리 구조와 다를 수 있다는 것을 직감할 수 있다.

 

MySQL 데이터 구조

 

 


B+Tree 구조 살펴보기

마찬가지로 4차 B+Tree에 1~10까지의 ID를 넣은 구조이다. B-Tree와 마찬가지로 루트 노드, 내부 노드, 리프 노드에 ID 값이 들어있다. 단, 중복을 허용하지 않았던 B-Tree와는 다르게 중복된 ID가 많다. 여기서 하나 짚고 넘어갈게 있다. B-Tree 와 B+Tree의 노드들이 분리된 이유는 뭘까? 바로 '격상' 때문이다. B-Tree는 균형잡힌 트리로써 균형을 맞추기 위해 값을 격상시킨다.

 그럼 B-Tree는 중복 ID가 없고, B+Tree는 있는 이유가 뭘까? 4번 특징인 '노드가 꽉 찰 경우 상위노드로 격상시킨다. 단, 복제된 ID를 격상시킨다.' 때문이다. 격상된 노드의 ID에 대한 데이터는 자신의 하위노드에 대한 포인터가 저장된다. 

B+Tree

 

 

 

격상된 노드에 포인터를 저장하는 이유는 뭘까? B+Tree의 3번 특징인 '내부 노드는 오직 탐색을 위해 존재한다. 즉, 실제 데이터(== 레코드 데이터)가 없다.' 때문이다. 이정표 역할만 하는것이다. 그럼 실제 데이터는 어디에 저장될까? 1번 특징에 따라 실제 데이터는 리프 노드에만 저장된다.

 

 마지막으로 리프 노드들은 모두 오른쪽 리프노드의 포인트를 갖는 것을 볼 수 있다. 이말은 뭘까? 특정 리프노드에서 다음으로 큰 ID를 가진 리프노드로 바로 이동할 수 있다는 뜻이다. 2번 특징인 '리프 노드들은 포인터로 연결된 연결 리스트 형태이다.'에 대한 내용이다. MySQL InnoDB 엔진에서는 이러한 특성을 살려 Next Key Lock을 걸 수 있고, 팬텀 리드 현상을 방지할 수 있다. 이에 대한 내용은 아래 글을 참고하면 좋다.

 

https://tlatmsrud.tistory.com/196

 

[MySQL] InnoDB 스토리지 엔진 잠금 / 레코드, 갭, 넥스트 키 락 / 팬텀리드를 방지할 수 있는 이유

개요 InnoDB 스토리지 엔진은 MySQL 엔진에서 제공하는 잠금과는 별개로 레코드 기반의 잠금 방식을 탑재하고 있다. 테이블 기반의 잠금 방식을 채택하고 있는 MyISAM 엔진보다 동시성 처리 측면에서

tlatmsrud.tistory.com

 

 

B+Tree 와 MySQL 데이터 구조 살펴보기

이제 다시 MySQL의 실제 데이터 구조를 보자. ID 값이 여러 노드에 중복되어 있고, 루트노드(or 내부노드)는 실제 데이터가 아닌 리프노드의 주소 값을 갖고 있다. 리프 노드는 실제 데이터를 갖고있으며, 이 이미지에 표시되지 않았지만, 101 번 노드는 102번 노드, 102번 노드는 103번 노드에 대한 포인터 정보도 갖고있다. 그렇다. 앞서 설명했던 B+Tree 구조와 MySQL의 실제 데이터 구조가 동일한 것을 느낄것이다. 

 이제 MySQL에서 데이터가 B+Tree 구조로 적재되는 것을 이해했다. 다음으로 클러스터링 인덱스와 논 클러스터링 인덱스를 이해해보자.

 

MySQL 데이터 트리

 

 


클러스터링이란 ?

 클러스터링은 "여러개를 하나로 묶는다"라는 의미로 사용된다. 인덱스의 클러스터링도 이 의미를 벗어나지 않는다. 클러스터링 인덱스는 PK가 비슷한 레코들끼리 묶어 저장시키는 인덱싱 방식을 말한다. 방금 B+Tree 구조를 보면 알 수 있듯이 실제 데이터가 저장된 리프노드에는 비슷한 ID를 가진 데이터들이 모여있는 것을 알 수 있다.  

 

클러스터링 인덱스란 뭔가요? 🤔

 클러스터링 인덱스는 '키'에 대해서만 적용되는 인덱스이다. 즉, '키'가 비슷한 레코드끼리 묶어서 저장하는 것을 클러스터링 인덱스라고 한다. 클러스터링 인덱스는 테이블에 PK가 존재할 경우 자동 적용된다. 즉, 테이블을 설계할 때 PK를 설정하면 클러스터링 인덱스가 적용될것이고, 실제 레코드가 저장될 때 PK가 비슷한 레코드끼리 묶여서 저장된다. 클러스터링 인덱스를 설정하면 인덱스로 설정한 컬럼을 키로 하는 B+Tree 구조로 데이터를 정렬 저장하겠다는 뜻이다.

 

키 값이 변경되면 어떻게되나요? 🤔

 PK가 비슷한 레코드들끼리 묶여 저장된다는 뜻은 PK가 비슷하지 않은 레코드들은 비교적 떨어져있다는 뜻이다. 키가 변경된다면, 그리고 그 키가 클러스터링 인덱스였다면 PK에 대한 레코드의 위치, 즉, 물리적인 저장 위치가 바뀌게된다.

 

InnoDB 엔진에서 클러스터링 인덱스는 반드시 있어야한다!

 B+Tree의 구조 상 B+Tree의 키가 되는 값이 반드시 필요하다. 그런데 PK를 설정하지 않는다며 어떻게될까? 이 경우 InnoDB 스토리지 엔진은 다음 우선순위에 따라 클러스터링 인덱스를 자동으로 설정한다.

 

1. PK가 있으면 기본적으로 PK를 클러스터링 인덱스 키로 설정

2. NOT NULL 옵션의 유니크 인덱스 중에서 첫 번째 인덱스를 클러스터링 키로 설정

3. 자동으로 유니크한 값을 가지도록 증가되는 컬럼을 내부적으로 추가 후 클러스터링 키로 설정

 

유니크 인덱스
저장되는 값들이 중복되지 않도록 강제하는 인덱스로, UNIQUE 제약 조건을 설정하면 내부적으로 유니크 인덱스가 생성된다.

 

정리하면, '프라이머리 키 > 유니크 키 > 자동 생성 값' 순서로 클러스터링 인덱스를 설정한다.

 

자동 생성 값은 쓸모 없지 않아요? 🙁

자동 생성 값은 사용자 입장에선 쓸모없는게 맞다. 사용자가 볼 수 없고, 이 컬럼을 조건절로 조회 쿼리를 날릴수도 없다. B+Tree 구조를 전혀 활용하지 못하는 상태이다. 하지만 이 값이 없다면 B+Tree 구조에서 사용할 ID가 없는 상황이 된것과 같다. 자동 생성 값은 이런 상황때문에 필요하다. 오직 InnoDB의 B+Tree 저장구조를 유지하기 위해 만들어진 것이다. 사용자에게는 쓸모없고 불필요해도 데이터 구조를 유지하기 위해서 반드시 필요한 값인것이다.

 


논 클러스터링 인덱스가 뭔가요?

 테이블의 기본 데이터 정렬 순서와 무관하게 별도의 B+Tree로 관리되는 인덱스이다. 여기서 말하는 기본 데이터 정렬 순서란 클러스터링 인덱스 기반으로 정렬된 데이터의 순서이다. 풀어 말하면 클러스터링 인덱스 기반으로 정렬된 데이터의 순서와 무관하게 별도로 관리되는 인덱스이다.

 논 클러스터링 인덱스는 실제 데이터 페이지의 물리적 위치를 이동시키지 않는다. 너무 당연한 말이다. 실제 데이터 페이지는 클러스터링 인덱스에 의해 생성된 B+Tree에 있고, 이와 별개로 논 클러스터링 인덱스는 별도의 B+Tree를 생성/관리하기 때문이다. 아까 클러스터링 인덱스의 리프노드에는 실제 레코드 데이터가 들어있다고 했다. 논 클러스터링 인덱스의 데이터에는 실제 레코드 데이터가 아닌 데이터 레코드의 주소가 들어있다.

 

 아래 이미지는 논 클러스터링 인덱스의 원리 설명을 위해 여러 블로그에서 많이 사용하는 이미지이다. 여기서 Index Page는 논 컬러스터링 인덱스 구조, Data Page는 클러스터링 인덱스 구조라고 생각한다면 몇가지 의문점이 들것이다.

 

논 클러스터링 인덱스 구조 설명 이미지

 

첫째, Index Page의 ID가 1 ~ 15, Data Page의 ID도 1 ~ 15로 동일하다.

 논 클러스터링 인덱스의 ID가 DataPage의 ID와 어떤 연관성이 있는걸까? 아니다! 이는 카디널리티가 1~15 인 age와 같은 컬럼을 논 클러스터링 인덱스로 설정했다고 생각하면 된다. 만약 name 컬럼을 인덱스로 설정했다면 IndexPage의 각 인덱스 ID는 andrew, bob, sean, tom과 같은 문자열 값이 들어갔을것이다.

 

둘째, Data Page 의 ID가 정렬되어 있지 않다.

 Data Page는 클러스터링 인덱스의 리프노드라고 생각할것이다. 일반적인 방식으로 테이블을 설계한다 치면 PK로 ID를 넣었을 것이고, 그 말은 ID 가 정렬되어 저장되는게 맞다. PK를 설정하지 않았다고 하더라도 유니크 인덱스 ID 혹은 자동 생성 ID 기반으로 정렬되어 있을것이다.

 이 이미지의 Data Page는 클러스터링 인덱스로 생성된 리프노드 아닌 것이다. INSERT 한 순서대로 데이터들이 적재된 데이터 페이지였다. 논 클러스터링 인덱스의 데이터 값에 데이터 페이지의 주소가 들어갔다는 것을 중점으로 설명하기 위한 예시로 이해하자.

 

위 두 내용을 인지하고 논 클러스터링 인덱스 구조 설명 이미지를 다시봐보자. 왜 저렇게 설명했는지를 이해할 수 있을것이다.


회고

이번 포스팅을 통해 MySQL 데이터 구조와 B+Tree , 인덱스 개념을 연관시켜 이해할 수 있었다. 다음에는 인덱스 기반으로 조회 쿼리를 날렸을 때 어떻게 데이터를 스캔하는지에 대해 알아보도록 하겠다.

반응형

개요

 InnoDB 스토리지 엔진은 MySQL 엔진에서 제공하는 잠금과는 별개로 레코드 기반의 잠금 방식을 탑재하고 있다. 테이블 기반의 잠금 방식을 채택하고 있는 MyISAM 엔진보다 동시성 처리 측면에서 뛰어나다.  레코드 락 뿐 아니라 레코드와 레코드 사이의 간격을 잠그는 갭 락(GAP LOCK), 이 두 락을 합쳐놓은 형태의 넥스트 키 락(NEXT KEY LOCK)도 지원한다. 이번 게시글에서는 이 세가지 락에 대해 이해하고, 팬텀 리드와 연관지어 생각해보도록 하자.

 

 아마 트랜잭션 격리 수준에 대해 공부했던 경험이 있다면 MySQL에서는 팬텀리드를 방지할수 있다는 얘기를 들어봤을것이다. 이를 넥스트 키락과 연계시켜 이해해볼것이다.

* 참고로 갭 락은 타겟 레코드를 잠그지 않는다. 레코드와 레코드 사이의 빈 공간(GAP)만을 잠근다.

 

 

InnoDB 엔진 락

 


레코드 락 (Record Lock)

 말 그대로 레코드에 대한 락이다. 특정 레코드를 수정하거나 잠금과 함께 조회할 때, 그 레코드만을 잠그고, 나머지 레코드에는 자유롭게 접근할 수 있다. 단, 일반적인 레코드 락과 다른점이 하나 있는데, 레코드 자체가 아닌 인덱스의 레코드를 잠근다는 것이다. 인덱스가 없는 테이블이더라도 자동 생성된 클러스터드 인덱스를 이용해 잠금을 설정한다. 아래 쿼리는 id = 1 인 프라이머리 키(인덱스)를 통해 레코드를 찾음과 동시에 인덱스의 레코드에 락을 거는 것이다.

 

SELECT * FROM [table] WHERE id = 1 FOR UPDATE;

 

클러스터드 인덱스의 레코드 락

 

 

인덱스 레코드를 잠근다? 🤔

MySQL의 데이터들은 B+트리 구조로 관리된다. 즉, 인덱스의 레코드는 리프 노드를 의미한다. 클러스터드 인덱스의 경우 리프 노드에 실제 레코드 데이터들이 들어가있다. 즉, 리프노드에 잠금을 건다는 건 실제 레코드에 락을 획득해 접근할 수 없도록 잠그는것과 같다.

 

논 클러스터드 인덱스라면? 🤔

마찬가지로 논 클러스터드 인덱스의 리프 노드를 잠근다. 단, 실제 데이터 레코드에 접근하기 위해 클러스터드 인덱스를 사용해야 하는 상황이 발생하면, 클러스터드 인덱스의 리프 노드 또한 잠근다. 아래와 같이 users 테이블이 있고, email 컬럼에 대해 논 클러스터드 인덱스를 설정한 상태에서 실제 레코드 인덱스가 잠기는지 테스트해보자.

CREATE TABLE users (
  id INT PRIMARY KEY, -- 클러스터드 인덱스
  name VARCHAR(100),
  email VARCHAR(100)
);

CREATE INDEX idx_email ON users(email); -- 논 클러스터드 인덱스

-- 데이터 추가
insert into users values (1,'andrew','andrew@tistory.com');
insert into users values (2,'test','test@tistory.com');
insert into users values (3,'maple','maple@tistory.com');
insert into users values (4,'andrew2','andrew@tistory.com');

 

start transaction; -- 트랜잭션 1 시작

SELECT name FROM users WHERE email = 'andrew@tistory.com' FOR UPDATE; -- 논클러스터드 인덱스를 조건으로 검색



start transaction; -- 트랜잭션 2 시작

update users set name = 'andrew.sim' where email = 'andrew@tistory.com'; -- 논 클러스터드 인덱스 잠금으로 인한 대기
update users set name = 'andrew.sim' where id = 1; -- 클러스터드 인덱스 잠금으로 인한 대기
update users set name = 'andrew.sim' where id = 2; -- 클러스터드 인덱스가 잠기지 않았으므로 실행 완료

 

그 이후 email 컬럼을 잠금과 함께 조회한다면, 논 클러스터드 인덱스의 리프노드가 잠길것이다. 그런데 조회하는 컬럼 name이므로 이를 조회하기 위해서는 클러스터드 인덱스를 사용하여 데이터 레코드에 접근해야한다. 이때 클러스터드 인덱스의 리프노드도 함께 잠기게 된다.

 

더보기

B Tree와 B+Tree 시뮬레이션 해보기

 

B Tree와 B+Tree 가 실제로 어떻게 동작하는지 확인할 수 있는 시뮬레이션 사이트이다. 

 

https://www.cs.usfca.edu/~galles/visualization/BTree.html

 

B-Tree Visualization

 

www.cs.usfca.edu

 

https://www.cs.usfca.edu/~galles/visualization/BPlusTree.html

 

B+ Tree Visualization

 

www.cs.usfca.edu

 


갭 락 (GAP LOCK)

레코드와 레코드 사이를 잠그는 락이다. 갭 락의 역할은 레코드와 레코드 사이에 새로운 레코드가 생성(INSERT)되는 것을 제어하는 것이다. 갭 락은 단독으로 거의 사용되지 않고 넥스트 키 락의 일부로 사용된다.

 

레코드 락은 인덱스 레코드로 잠그는데... 갭락은 뭘로 잠그죠?  🤔

 레코드 락은 인덱스 레코드를 잠그는 것이라고 했다. 하지만 갭 락은 실제로 존재하는 뭔가가 없다. 레코드와 레코드 사이는 텅 빈 값인데, 도대체 어떻게 이 범위에 잠금을 거는걸까? 바로 메타데이터를 활용해 잠금을 건다. 인덱스 레코드에 존재하는 메타데이터에는 자신과 인접한 인덱스 레코드의 정보도 포함되어 있다. 이 정보를 활용해 갭 락을 거는 것이다.

 

 만약 책장에 5번 책과 10책이 꽂혀있다고 해보자. 5번 책과와 10번 책 사이에 무언가를 끼우는 행위를 막으려면 10번 책에에 포스트잇을 붙여 '이전 책은 5번 책입니다.' 또는 5번 책에 '다음 책은 10번 책입니다' 라는 정보를 기재해주면 된다. 그럼 5번 책을 보면 다음 책은 10번 책이니 5 ~ 10 사이에는 갭이 있다는 것을 알 수 있다.

 


넥스트 키 락 (NEXT KEY LOCK)

 레코드 락과 갭 락을 합쳐 놓은 형태의 잠금으로 락 기반의 조회 쿼리(SELECT ... FOR UPDATE)범위 조건(<, >, BETWEEN)을 걸었을 때 설정되며, 해당 레코드 뿐 아니라 갭(GAP)에도 락이 걸리게 된다. 레코드 락과 갭 락이 함께 걸리는 이유를 이해하기 위해서는 InnoDB 엔진의 B+트리 구조를 이해해야한다. B+트리 구조에 대해서는 따로 설명하지 않겠다. 여기서는 B+트리 구조와 넥스크 키 락의 관계와 팬텀리드를 방지할 수 있는 이유를 알아볼것이다.

 

넥스트 키 락이 팬텀 리드를 방지할 수 있는 이유? 🤔

 MySQL에서는 넥스트 키 락에 의해 팬텀리드 현상을 방지한다. 이 넥스트 키락이 존재할 수 있게 하는 것이 B+트리이다. B+트리의 특성을 활용해 넥스트 키 락을 걸고, 이 락에 의해 팬텀 리드 현상을 막게 되는 것이다. B+트리의 가장 큰 특성 중 하나는 리프노드가 오른쪽 리프노드의 포인터를 갖는 연결 구조로 되어있다는 것이다. 이를 활용한다면 특정 리프 노드는 다음으로 큰 리프노드에 빠르게 접근할 수 있다.

 그럼 아래와 같이 10 단위의 ID 가 있는 B+ 트리 구조에서 특정 범위에 대한 SELECT ... FOR UPDATE 쿼리를 날리면 어떻게 될까?

 

B+트리 구조

 

SELECT * FROM USERS WHERE ID >= 10 AND ID <= 55 FOR UPDATE;

 

 먼저 ID가 10 이상인 인덱스 레코드(리프노드)를 찾고, 인덱스 레코드를 잠근다. 그 후 포인터를 통해 오른쪽 리프노드인 20으로 이동한다.

 그 후 현재 인덱스 레코드의 ID가 55 이하인지를 체크한다. 55 이하일 경우 마찬가지로 인덱스 레코드를 잠그고 오른쪽 리프노드로 이동하기를 반복해나간다. 그러다 ID가 60인 값을 만났을 때 55 이하라는 조건에 만족하지 않아 멈추게 된다.

 최종적으로 10, 20, 30, 40, 50 인덱스 레코드가 잠기고(레코드 락), 각 인덱스 레코드의 메타데이터를 통해 갭도 잠기게 된다.(갭 락) 레코드 락과 갭 락이 동시에 걸렸다. B+트리의 특성을 활용해 넥스트 키 락을 건 것이다.

 

이제 넥스트 키 락이 걸렸을 때, 팬텀 리드를 방지할 수 있을지를 생각해보자. 넥스트 키락은 레코드와 갭을 잠근다. 해당 범위에 인덱스 레코드를 삽입할 수 없다는 뜻이다. 그럼 너무나 당연하게도 팬텀리드 현상을 방지할 수 있게된다.

 

넥스트 키 락이 팬텀 리드를 방지할 수 있는 이유! 🤩

 정리하면, MySQL은 InnoDB 엔진을 사용하기에 B+트리 구조로 데이터가 저장되고, B+트리 구조에서 넥스트 키 락을 걸 수 있으며, 넥스트 키 락에 의해 팬텀 리드 현상이 방지되는 것이다. 

 

MySQL에서는 팬텀 리드가 발생하지 않는다고도 하던데... 🤔

이건 잘못된 말이다. MySQL에서도 팬텀 리드 현상이 발생할 수 있다. 다만, 잠금을 사용하여 조회한다면 B+트리를 사용하는 InnoDB엔진 특성 상 넥스트 키 락이 걸릴거고, 넥스트 키 락에 의해 레코드와 갭이 잠기면서 팬텀리드 현상을 방지하게 되는것이다. 잠금을 사용하여 조회하지 않는다면?? 팬텀리드 현상은 당연히 발생한다.

 


자동 증가 락 (AUTO INCREMENT LOCK)

 자동 증가하는 숫자 값을 채번하기 위한 잠금으로 테이블 락과 함께 동작한다. AUTO_INCREMENT 속성이 사용된 컬럼의 테이블에 레코드가 INSERT 될때 잠기며, UPDATE나 DELETE 쿼리에서는 잠금이 걸리지 않는다. AUTO_INCREMENT 락을 명시적으로 획득하는 방법은 없으며, 아주 짧은 시간동안 걸리는 잠금이기에 대부분의 경우 문제가 되지 않는다.

 MYSQL 5.1 이상부터는 innodb_autoinc_lock_mode 라는 시스템 변수를 사용해 자동 증가 락의 작동 방식을 변경할 수 있다.

 

1. innodb_autoinc_lock_mode = 0

테이블 락 기반의 기본적인 자동 증가 락을 사용한다.

 

2. innodb_autoinc_lock_mode = 1

여러 건의 레코드를 INSERT 하는 중, MySQL 서버가 INSERT 되는 레코드의 건수를 정확히 예측할 수 있을 때 자동 증가 락을 사용하지 않고, 훨씬 가볍고 빠른 래치(Latch)를 이용해 처리한다. 자동 증가 락보다 훨씬 짧은 시간동안만 잠금을 걸고, 필요한 자동 증가 값을 한번에 가져온다. 다만, INSERT ... SELECT 와 같이 MySQL 서버가 건수를 예측할 수 없을 때는 자동 증가 락이 사용된다.

 

3. innodb_autoinc_lock_mode = 2 (MySQL 8.0 기본 값)

자동 증가 락은 사용하지 않고 래치만을 사용한다. 이 방식은 연속된 자동 증가 값을 보장하지 않지만, INSERT ... SELECT와 같이 대량 INSERT 문이 설정되는 중에도 다른 커넥션에서 INSERT를 수행할 수 있다.

 

래치(Latch)가 뭔가요? 🤔

InnoDB 내부에서 데이터 구조를 보호하기 위해 사용하는 경량의 잠금이다. 자동 증가 락은 락의 범위가 테이블로, 자동 증가 값인 AUTO_INC 값을 가져올때INSERT가 완료될때까지 잠금을 유지한다. 여러 트랜잭션이 INSERT 시 잠금이 끝날때까지 대기하며 순차적으로 처리되는 구조이다.
 이에 반해 래치(Latch)는 AUTO_INC 값을 가져올때만 잠금을 유지한다. 여러 트랜잭션이 INSERT 시 병렬 INSERT 처리가 가능한 것이다.

 

반응형

개요

 MySQL의 잠금은 크게 두가지로 MySQL 엔진, InnoDB 스토리지 엔진 잠금이 있다. 이번 포스팅에서는 먼저 MySQL 엔진에 대한 잠금을 알아보자.

 


MySQL 엔진과 InnoDB 스토리지 엔진이 뭔가요?

MySQL 엔진과 InnoDB 스토리지 엔진은 MySQL 서버의 구성요소이다. MySQL 엔진은 쿼리 파싱, 최적화, 실행 뿐 아니라 캐시, 커넥션과 같은 부가 기능들을 관리하고, InnoDB 스토리지 엔진은 실제 데이터의 저장, 디스크 I/O, 인덱스 관리와 같은 하드웨어 처리를 담당한다. 즉, MySQL 엔진이 쿼리를 실행하면, InnoDB 스토리지 엔진은 데이터를 읽고, 쓰는 역할을 하는것이다.

 


MySQL 엔진 잠금

 

1.  글로벌 락

 글로벌 락은 SELECT를 제외한 DDL이나 DML 문장에 대한 락으로, MySQL 서버에 존재하는 모든 테이블과 DB에 영향을 미치는 락이다. FLUSH TABLES WITH READ LOCK 명령으로 락을 획득한다.

 다만, 이미 UPDATE와 같은 DML 트랜잭션이 진행중이라면 끝날때까지 대기해야하는데, 읽기 잠금을 획득해야하기 때문이다. 또한 다른 트랜잭션에서 장시간 SELECT 쿼리를 실행하는 상황에서도 대기해야한다.

 글로벌 락은 mysqldump와 같이 일관된 백업을 받아야할 때 사용하곤 한다.

 

DDL (Data Definition Language)
데이터 구조(스키마)를 정의/변경하는 명령어이다.

 

DML (Data Manipulation Language)
데이터 자체 조작하는 명령어로 SELECT, INSERT, UPDATE, DELETE 가 있다.

 

 

SELECT 쿼리를 기다려야 하는 이유?

FLUSH TABLES WITH READ LOCK을 끊어 읽어보면 FLUSH, READ LOCK 이라는 단어가 눈에 띈다. FLUSH는 버퍼 풀의 레코드를 디스크에 저장하는 작업을, READ LOCK은 읽기 잠금을 뜻한다. 읽기 잠금인데 다른 트랜잭션에서 발생하는 SELECT 쿼리에 영향을 받는 이유는 뭘까? 글로벌 락은 당연하게도 READ LOCK 뿐 아니라 METADATA LOCK을 획득해야 하고, 더 나아가 MySQL의 테이블 핸들이 닫혀있어야 한다. SELECT 쿼리는 테이블 핸들을 필요(열어야하는)로 하는 작업이다.

 

테이블 핸들 (Table Handle)
접근하는 테이블을 가리키는 내부 객체이다. 쿼리를 통해 테이블을 액세스 하면 테이블 핸들이 열리고, 트랜잭션이 끝나면 핸들이 닫힌다.

 

 

글로벌 락 테스트해보기

총 두가지를 테스트해볼 것이다. 첫번째는 글로벌 락이 걸렸을 때 읽기 잠금이 걸리는지, 두번째는 테이블 핸들이 열렸을 때 글로벌 락을 획득하지 못하고 대기하는지이다.

 

1) 글로벌 락이 걸렸을 때 읽기 잠금 

 

1번 트랜잭션에서 글로벌 락 획득

# transaction 1

start transaction; // 트랜잭션 시작

FLUSH TABLES WITH READ LOCK; // 글로벌 락

 

 

2번 트랜잭션에서 읽기 잠금 테스트

# transaction 2

start transaction;

SELECT * FROM TBL_USER; //성공 : 테이블 데이터 조회됨

UPDATE TBL_USER SET NAME = '승경' WHERE USER_ID = 1; // 실패 : 글로벌 락으로 인함

 

 

 

2) 테이블 핸들이 열렸을 때 글로벌 락 획득 대기

 

1번 트랜잭션에서 select sleep from [table] 쿼리를 통해 테이블 핸들 오픈

# transaction 1 

start transaction;

select sleep(10) from tbl_user; // tbl_user 에 로우 개수만큼 10초씩 sleep (테이블 핸들을 열기 위함)

 

2번 트랜잭션에서 글로벌 락 획득 시도

# transaction 2

start transaction;

FLUSH TABLES WITH READ LOCK; // 실패 또는 SELECT 작업이 끝난 후 성공

 

 

 

2.  테이블 락

 

테이블 락은 테이블 단위로 설정되는 잠금으로 명시적, 묵시적으로 락을 획득할 수 있다. 명시적 테이블 락은 'LOCK TABLES [TABLE NAME] [ READ or WRITE ]' 명령으로 획득할 수 있다. 단, 테이블 락은 어플리케이션에 상당한 영향을 끼치므로 실무에서는 거의 사용되지 않는다.

 묵시적 테이블 락은 DDL 쿼리 실행 시 발생하며, 읽기/쓰기를 모두 차단한다. DML 쿼리에 대해서는 스토리지 엔진에 따라 달리 동작하는데, MyISAM 또는 MEMORY 스토리지 엔진의 경우 자동으로 테이블 락이 발생한다. 데이터가 변경되는 테이블에 잠금을 설정하고 데이터를 변경한 후, 즉시 잠금이 해제되는 프로세스이다. InnoDB 스토리지 엔진은 엔진 차원에서 레코드 기반의 잠금을 제공하기 때문에 단순 DML 쿼리로 테이블 락이 설정되지 않는다.

 

DDL 쿼리도 트랜잭션처럼 Commit 해야 반영되나요?

맞다. Commit을 해야 실제로 반영된다. 다만, DDL 쿼리는 Auto Commit 으로 처리된다.

 

테이블 락  테스트해보기

테이블에 쓰기 잠금을 걸었을 때 테이블 데이터를 조회, 수정하는 쿼리를 날려보자.

 

1) 1번 트랜잭션에서 WRITE LOCK 실행

start transaction;

LOCK TABLES tbl_user WRITE; // tbl_user에 대한 Write Lock

 

 

2) 2번 트랜잭션에서 SELECT 및 UPDATE 쿼리 실행

start transaction;

select * from tbl_user; // 실패 : 테이블 Write Lock 으로 인함
update tbl_user set name = '승경' where user_id = 1; // 실패 : 테이블 Write Lock 으로 인함

 

 

 

3.  네임드 락

 네임드락은 임의의 문자열에 대한 잠금으로, GET_LOCK() 함수를 사용한다. 이 잠금은 테이블이나 레코드, 데이터 베이스 객체가 아닌 단순히 사용자가 지정한 문자열에 대한 잠금을 획득하고 반납하는 잠금이다. 반환은 RELEASE_LOCK() 함수를 사용한다.

 

 네임드 락을 사용하는 예는 다중 어플리케이션에서의 배치 실행이다. 다중 어플리케이션에서 한 번의 배치만 돌게 하기 위해 네임드 락을 사용하기도 한다.

SELECT GET_LOCK('batch_lock', 0); // batch_lock 획득 시도, 획득 대기시간 없음(0초)

 

 여러 어플리케이션에서 동시에 batch_lock 이라는 네임드 락 획득을 시도하면 최초 획득한 트랜잭션의 어플리케이션만 배치가 돌고, 나머지는 대기 시간이 없어 실패와 함께 배치 실행을 하지 않게 된다.

 

 네임드 락을 사용하는 또 다른 예는 비지니스 로직에 대한 잠금 범위를 지정하고 싶을때이다. 특정 로직이 실행되기 전 네임드 락을 획득하고, 로직이 끝난 후 네임드 락을 반환한다면, 멀티 스레드 환경에서 해당 로직이 순차적으로 처리될것이다.

// 비지니스 로직 시작
...
SELECT GET_LOCK('service_1', 100); // 네임드 락 획득
...
// 비지니스 로직 종료
...
SELECT RELEASE_LOCK('service_1'); // 네임드 락 반환

 

단, 락을 획득한 후 해제해주지 않으면 시스템이 멈추는 위험을 초래할 수 있고, 락 이름 충돌 가능성이 있으므로 구분할 수 있는 prefix를 붙여주는 것이 좋다.

 

 

4.  메타 데이터 락

 메타 데이터 락은 테이블이나 뷰 등 DB 객체의 이름이나 구조를 변경하는 경우에 획득하는 잠금이다. 이 락은 자동으로 획득/반환되는 묵시적 잠금이다. 앞서 설명한 글로벌 락, 테이블 락이 발생할 경우 메타 데이터 락도 함께 획득한다.

 

 

 

 

반응형

개요

슬로우 쿼리 개선을 위해 스프링 부트 환경에서 AOP 기반으로 슬로우 쿼리 로그를 남기는 인터셉터를 구현했다. 다만, JPA 환경이냐 Mybatis 환경이냐에 따라 다른 방식으로의 구현이 필요했고, Interceptor 타겟을 설정하는 부분에 따라 로깅을 남기는 구조였기 때문에 '정말 모든 쿼리를 다 체크하고 있을까?' 라는 의문이 들었다.

 

 그러던 중 Real MySQL의 슬로우 쿼리 관련 챕터를 읽게 되었고, MySQL 자체에 슬로우 쿼리 관련 설정이 있다는 것을 알게 되었다. 이 설정들을 적용해보고, 어떤 형태로 슬로우 쿼리가 추출되는지 확인해보았다.

 

MySQL 슬로우 쿼리 로깅 활성화

 

먼저 MySQL에 접속 후 여러 옵션값들을 확인하고 설정해야한다.

 

1) slow_query_log

슬로우 쿼리 로깅 활성화 여부를 나타내는 설정값이다. 기본값은 OFF이므로 ON 또는 1로 설정한다.

show variables like '%slow_query_log%'; // 슬로우쿼리 활성화 여부 확인

set global slow_query_log = 'ON'; // 슬로우쿼리 활성화

 

2) long_query_time

슬로우 쿼리에 대한 기준 시간을 나타내는 설정값이다. 기본값은 10초로 되어있어 1초로 수정하였다.

show variables like '%long_query_time%'; // 슬로우 쿼리 기준 시간 확인

set global long_query_time = 1; // 슬로우 쿼리 기준 시간을 1초로 변경

 

 

set global 명령어를 통해 변수들을 설정할 경우 MySQL이 재시작될 때 설정값들이 초기화된다. 영구 설정을 하고자하면 my.ini나 my.cnf 파일을 다음과 같이 수정하면 된다.

// my.ini 파일
[mysqld]
slow_query_log = 1
slow_query_log_file = /var/log/mysql/mysql-slow.log
long_query_time = 1

 

 

3) log_output

슬로우 쿼리를 남길 수 있는 방법은 두 가지로, 테이블 혹은 디스크이다. 온프레미스 환경에서는 디스크에 저장하는 것을 선호한다. 테이블로 설정할 경우 슬로우 쿼리가 발생할때마다 쓰기 작업이 발생하며, 데이터가 많아질 경우 부하가 발생할 수 있기 때문인데, 이를 방지하기 위해 슬로우 쿼리에 적재된 데이터를 주기적으로 삭제해주는 작업이 필요하다.

 AWS RDS 환경일 경우 파일 시스템에 직접 접근하지 못하므로 테이블에 저장하기도 한다. 참고로, 디스크로 설정한 후 RDS 설정에 SlowQuery Logging 을 활성화하면 Cloud Watch 의 로그 그룹에 저장된다. 필자의 경우 테이블에 적재되도록 설정하였다.

 

show variables like '%log_output%'; // 슬로우 쿼리 저장 타입 확인 (TABLE or FILE)

set global log_output = 'TABLE'; // 슬로우 쿼리를 테이블에 저장하도록 설정

 

 

슬로우 쿼리 테이블 확인

슬로우 쿼리 테이블은 mysql.slow_log 이다. 아래 명령어를 사용해 데이터가 쌓였는지 확인해보자. 아마 비어있을건데, 다음은 슬로우 쿼리를 직접 발생시켜 적재되는지를 확인해볼 것이다.

select * from mysql.slow_log order by start_time desc;

 

 

컬럼 설명
query_time 쿼리 실행 시간 (실질적 시간)
lock_time 테이블 락 시간
rows_sent 클라이언트에 전송된 행 수
rows_examined 스캔한 전체 행 수

* rows_examined 값이 매우 크면 인덱스가 제대로 안 쓰이고 있을 가능성이 높다.

 

슬로우 쿼리 발생시키기

 

슬로우 쿼리가 발생했을 때, mysql.slow_log 테이블에 적재되는지 확인하기 위해 select sleep(2) 쿼리를 날려보자.

 만약 슬로우 쿼리에 로그가 남지 않는다면, 아까 설정했던 global 설정 값들이 현재 세션에는 적용되지 않은 상태일 가능성이 있다. 이 경우 재접속하면 된다.

select sleep(2);
select * from mysql.slow_log order by start_time

mysql.slow_log 조회 결과

 

sql_text 값이 BLOB 형태로 출력된다면 아래 쿼리를 통해 조회하면 된다.

SELECT 
  start_time,
  user_host,
  CAST(sql_text AS CHAR) AS sql_text,
  query_time,
  lock_time,
  rows_sent,
  rows_examined
FROM mysql.slow_log;

 

 

 

 

 

 

반응형

개요

트랜잭션 격리수준을 공부하면 꼭 나오는 MVCC 에 대해 확실히 이해해보자.

 

MVCC란? 

Multi Version Concurrency Control 의 약자로, 직역하면 멀티 버전 동시 제어를 말한다. 단순히 MySQL에만 한정된 기술이 아닌, DB에서 데이터를 관리하는 방법론 중 하나이다. 그럼 DB 입장에서 여러 버전의 데이터를 관리한다는 건 무슨 말일까? 그리고 여러 데이터를 관리해야하는 이유는 뭘까? 그건 바로 여러 트랜잭션들! 동시에! 일관된 읽기를 제공해야하기 때문이다.

 

일관된 읽기? 📕

일관된 읽기란 뭘 말하는 걸까? 수준이 가장 낮은 트랜잭션 격리수준인 READ UNCOMMITED를 생각해보자. 트랜잭션 격리 수준 중 하나로 트랜잭션이 어떤 레코드의 값을 수정하면 굳이 커밋하지 않아도 다른 트랜잭션에서 수정된 값을 볼 수 있다. 당연하게도 해당 트랜잭션이 롤백될 경우 데이터 정합성 문제가 발생하는 매우 낮은 수준의, 일반적으로는 사용하지 않는 격리수준임을 알것이다.

 

 이 정합성 문제를 해결하기 위해서는 레코드를 수정한 트랜잭션이 끝날때까지 다른 트랜잭션이 이 레코드의 데이터에 접근하지 못하도록 잠궈 (Exclusive Lock) 버리는 것이다. 읽기든, 쓰기든말이다. 그럼 데이터 정합성 이슈는 해결되지만, 동시에 접근하지 못하는 이슈가 발생한다.

 

여러 트랜잭션들이? 동시에 ? 🙌

 방금 말했던 동시성 이슈는 여러 트랜잭션이 동시에 수정/조회를 할때 발생한다. 잠금을 갖는 트랜잭션이 끝나야 비로소 접근 가능하기 때문이다. 이러한 동시성 이슈로 인해 트랜잭션의 처리속도는 전체적으로 저하된다. 결국 잠금 대신 다른 방법을 사용해야했다. 원본 데이터와 별개로 다른 버전의 데이터를 관리하는, 즉, 멀티 버전의 데이터를 제어할 수 있는 방법을 사용해 동시성 이슈를 해결하는 것이다.

 데이터들이 변경될때마다 스냅샷처럼 버전을 관리한다면, 상황에 따라 수정 전 데이터를 줄 수 있을것이다. 예를 들어 레코드 데이터를 수정중인 트랜잭션이 Commit 되지 않았을 때, 조회 트랜잭션이 발생한다면, "수정중인 데이터가 롤백될 수 있으니 끝날때까지 기다리라고 하자" 가 아닌 "수정 전 데이터를 주자"와 같이 컨트롤이 가능해진다.

 

바로 이것이 Multi Version Concurrency Control. MVCC이다. 그리고 MVCC와 함께 나오는 용어가 하나 있다. 바로 언두 로그이다. 언두 로그에 대해 먼저 이해하고, MVCC와 어떤 관계를 갖는지도 연결해보자.

 

MVCC 공부하면 언두 로그도 꼭 나오던데...🤔

 MVCC는 멀티 버전의 데이터를 관리하는 방식의 동시성 제어 전략이자 트랜잭션 격리 방법론 중 하나이다. 그럼 MySQL에서는 무엇을 통해 데이터를 다중 버전으로 관리하는 걸까? 그 무엇이 바로 언두 로그이다. MySQL에서는 언두 로그를 활용해 다중 버전의 데이터를 관리하는 것이다.

 

 

언두 로그 개념 이해하기

 

먼저 Undo 라는 단어의 사전적 의미는 다음과 같다. 

Undo
어떤 행위를 되돌리거나 취소하는 것을 의미

 

 언두 로그는 트랜잭션이 삽입, 수정, 삭제한 행위를 되돌리기 위해 사용하는 로그를 말한다. 트랜잭션으로부터 삽입, 수정, 삭제 행위가 발생하면, 이에 대한 로그를 언두 로그에 저장해놓고, 트랜잭션이 실패했을 때, 이 로그를 참조하여 원래의 데이터로 되돌려 놓는것이다. 예제를 통해 이해해보자.

 

먼저 다음과 같은 테이블에 한 건의 레코드를 INSERT 한 후 UPDATE 했을 때 언두 로그가 어떻게 활용되는지를 알아보자.

CREATE TABLE member{
	m_id INT NOT NULL,
    m_name VARCHAR(20) NOT NULL,
    m_area VARCHAR(100) NOT NULL,
    PRIMARY KEY (m_id),
    INDEX ix_area (m_area)
};

 

1) INSERT 문 실행 및 커밋

INSERT INTO member (m_id, m_name, m_area) VALUES (12, '홍길동', '서울');
COMMIT;

 

INSERT 문 실행후 커밋되면 데이터베이스의 상태는 아래와 같이 바뀔것이다. InnoDB 버퍼 풀에 데이터를 선 적재한 후, 내부 알고리즘에 의해 디스크에도 데이터가 저장될것이다. 참고로 커밋을 하고 언두 로그의 데이터도 삭제된 상황을 가정한거라 언두 로그에 데이터가 없는 것이다.

 

 

* 혹시 Insert 쿼리는 커밋을 안해도 언두 로그에 데이터가 없는걸까?

 언두 로그는 트랜잭션이 데이터를 변경하기 전 상태를 저장해둔다고 했다. INSERT 하기 전에는 해당 데이터가 없는 상태이므로, 언두 로그에도 데이터가 없을 것이라 생각할 수 있지만, 실제로는 INSERT 로그가 들어간다. INSERT 한 데이터가 기록되지 않는다면 트랜잭션을 롤백했을 때, "어떤 데이터를 INSERT 했었는지"를 찾을 수 없기 때문이다. 

 

2) UPDATE 문 실행 후 미 커밋상태

UPDATE member SET m_area = '영암' WHERE m_id = 12;

 

새로운 트랜잭션에서 UPDATE 쿼리가 실행되면 커밋 실행 여부와 관계 없이 InnoDB의 버퍼 풀은 새로운 값인 '경기'로 업데이트된다. 마찬가지로 디스크의 데이터 파일은 InnoDB 내부 알고리즘에 의해 추후 저장될것이다. 디스크에 저장된 m_area는 '경기'가 될수도, '서울'이 될수도 있으므로 '?' 로 기재하였다. 마지막으로 언두 로그에는 수정 전 데이터에 대한 로그가 들어갈것이다.

 

3) Commit 전 다른 트랜잭션의 SELECT

그럼 이렇게 언두 로그에 데이터가 있을 때 다른, 혹은 여러 트랜잭션에서 해당 로우에 대한 SELECT 쿼리를 날리면 어떻게될까?

 

SELECT * FROM member WHERE m_id = 12;

 

이 질문의 답은 MySQL 서버의 격리 수준에 따라 다르다.

 격리 수준이 READ_UNCOMMITTED인 경우 InnoDB 버퍼 풀이 가진 데이터를 읽으므로 '경기' 로 조회될것이다.

 READ_COMMITTED나 그 이상의 격리 수준인 REPEATABLE_READ, SERIALIZABLE 인 경우에는 아직 커밋되지 않았기 때문에 언두 영역의 데이터를 참조해 조회한다. 

 

* 갑자기 언두 영역 데이터를 왜 조회해?

커밋되지 않는 데이터를 다른 트랜잭션에서 조회해버린다면 처음 말했던 정합성 문제가 발생하게 된다. 이 문제를 해결하려면 수정 전 데이터가 조회되도록 하면 된다. 그럼 여기서 지금까지의 내용을 다시 짚어보자. 정합성 문제를 해결하기 위해 Lock 을 도입했다가, Lock의 동시 접근 이슈가 있어 MVCC를 통해 멀티 버전의 데이터를 관리하여 조회되는 데이터를 제어할 수 있다고 했다. 

 

 언두 로그를 보자. Lock을 걸지 않으니 여러 트랜잭션에서 언두 로그에 동시 접근 가능하다. 수정 트랜잭션이 끝나지 않은 상황에서 해당 레코드에 조회 트랜잭션이 발생할 때 언두 로그를 통해 수정 전 데이터를 보여지도록 하고있다. 즉, MySQL은 언두 로그 기반의 MVCC 방식을 채택하고 있는 것이다. MVCC와 언두 로그는 뗄레야 뗄수 없는 관계인 것이다.

 

 언두 로그를 통해 하나의 레코드(회원 번호가 12인 레코드)에 대해 2개의 버전을 유지하고, 필요에 따라 보여지는 데이터를 제어하고 있다. 위 예제는 한 개의 데이터만 가지고 설명했지만 관리해야 하는 예전 버전의 데이터는 무한히 많아질 수 있다. 즉, 트랜잭션이 길어지면 언두 로그에서 관리하는 예전 데이터가 오랫동안 관리되어야 하며, 그만큼 메모리를 많이 차지하게 될것이다.

 

4) Commit

 UPDATE 트랜잭션이 COMMIT 되면 InnoDB는 이상의 변경 작업 없이 지금의 상태를 영구적인 데이터로 만든다. 하지만 롤백될 경우 언두 로그에 있는 데이터를 InnoDB 버퍼 풀로 다시 복구하고, 언두 로그의 내용을 삭제해버린다.

 참고로 트랜잭션이 끝난다고 언두 영역의 백업 데이터가 항상 바로 삭제되는 것은 아니다. 이 언두 로그을 필요로 하는 트랜잭션이 더는 없을 때 비로소 삭제된다.

 

언두 로그가 삭제될 수 있는 조건?

다음 두 조건을 모두 만족하면 백그라운드에서 실행되는 Purge Thread에 의해 언두 로그가 제거된다.

 

1) 변경을 발생시킨 트랜잭션이 끝났을 때

 해당 언두 로그를 생성한 트랜잭션이 커밋 또는 롤백이 되어야한다. 너무 당연한 얘기이다. 트랜잭션이 끝나지도 않았는데 언두 로그에 데이터가 날아간다면, 롤백이 불가능해진다. 또한 트랜잭션이 끝나지 않은 상태에서 다른 트랜잭션이 조회 쿼리를 날린다면 언두 로그를 참조하여 정합성을 지켜줘야한다.

 

2) 해당 언두 로그를 참조하는 모든 트랜잭션이 종료되었을 때

 변경을 발생시킨 트랜잭션이 끝났다고 하더라도, 이미 다른 트랜잭션이 언두 로그를 참조하고 있었다면, 그리고 언두 로그를 삭제할 수 없다. Repeatable Read 격리 수준을 위해서도 삭제시키면 안된다. 언두 로그를 어떻게, 얼마나 활용하냐에 따라 Read Commited와 Repeatable Read가 나뉠정도로 매우 중요한 로그이다.

Read Commited 와 MVCC

 

Read Commited 단계에서의 핵심은 커밋된 데이터만을 조회하는 것이다. 커밋된 데이터를 조회하는 방법은 "최신의 커밋 데이터"를 읽어오는 것이다. 그런데 언두 로그 기반의 MVCC 생각하면 수정 전 데이터는 관리하지만 최신의 커밋 데이터는 관리하지 않는다. 그럼 최신의 커밋 데이터는 뭘 말하는걸까? 이건 바로 버퍼풀 + MVCC 를 함께 활용해 얻는 최신의 커밋 데이터를 말한다. 만약 MVCC 만을 사용한다면 가장 마지막으로 커밋된, 즉 언두 로그에 존재하는 수정 전 데이터를 로드할 수도 있지만, 로드하지 못할 수도 있다.

 

 

 

아까 m_id가 12인 레코드의 m_area의 값을 '서울' 에서 '경기'로 변경했던 걸 다시보자. 커밋하지 않은 상태라면 위처럼 언두 로그에는 '서울'이, 버퍼 풀에는 '경기'가 들어갈 것이다. 이때 다른 트랜잭션이 m_id = 12 인 데이터를 조회한다면 Read Commited 레벨에서는 언두 로그를 참조하여 '서울'이라는 데이터가 조회될것이다.

 

여기서 중요한 점은 언두 로그를 참조하기 전에, '조회된 데이터가 마지막으로 커밋된 데이터가 맞는지'를 체크해야한다는 것이다. 언두 로그는 커밋과 동시에 삭제되지 않는다고 했다. 즉, '경기'로 수정시킨 트랜잭션이 커밋됐다 하더라도, 언두 로그에 '서울'이라는 데이터가 남아있을 수 있다. Read Commited 격리 수준이 아무런 체크 과정 없이 언두로그의 데이터를 읽는 것이었다면 '서울' 로 조회될것이고, 커밋된 데이터가 조회되는 Read Commited 의 원칙에 어긋난다.

 

마지막으로 커밋된 데이터가 맞는지?

이를 이해하기 위해서는 레코드에 저장된 몇가지 메타 데이터를 알아야 한다. 레코드에는 단순 데이터 뿐 아니라, 트랜잭션 정보를 포함한 여러 메타 데이터들이 저장되어 있다.

 

* DB_TRX_ID

 이 레코드를 최종적으로 수정한 트랜잭션의 ID

 trx_sys라는 시스템 정보엔 트랜잭션 ID에 대한 커밋 여부를 확인할 수 있다.

 

* DB_ROLL_PTR

 이 레코드가 수정되기 전의 Undo Log를 가리키는 포인터

 

이제 최근 마지막으로 커밋된 데이터를 체크하는 방법을 알아보자.

 

1) 현재 레코드 확인 (버퍼 풀)

 트랜잭션이 읽으려는 레코드를 버퍼 풀에서 먼저 확인한 후, DB_TRX_ID가 커밋된 트랜잭션인지 확인한다.

 

2) 트랜잭션 커밋 여부 확인

 trx_sys를 참고하여 해당 트랜잭션의 커밋 여부를 확인한다.

 

3) 커밋 여부에 따른 읽기

 커밋된 트랜잭션이라면 버퍼 풀에 저장된 값을 그대로 읽고, 그렇지 않다면 DB_ROLL_PTR 를 통해 Undo Log로 접근 후 가장 마지막으로 커밋된 데이터인 수정 전 데이터를 읽어온다.

 

Repeatable Read 와 MVCC

 

그럼 Repeatable Read 에서는 어떨까? 가장 마지막으로 커밋된 데이터를 읽어오는게 아니라, 현재 트랜잭션이 시작되기 이전 시점을 기준으로 가장 마지막으로 커밋된 데이터를 읽어와야한다. 즉, 아래와 같은 상황에서 수정된 트랜잭션이 커밋됐다 하더라도, 조회 트랜잭션 이후에 발생한 커밋이므로, 이를 무시하고, 언두로그를 참조해 이전 커밋된 버전을 찾아나가게 된다.

 

정리

MVCC란 동시성을 보장하는 트랜잭션 격리 방법론중 하나로, MySQL에서는 언두 로그 기반의 MVCC를 사용한다.

단순히 트랜잭션이 끝난다고 해서 언두 로그가 삭제되지 않는다.

언두 로그를 어떻게 활용하느냐에 따라 트랜잭션의 격리 수준이 달라진다.

 

반응형

개요

 최근 사내망에서의 SSL Certification 오류가 발생하거나, SSL 인증서를 갱신하는 작업을 하면서 SSL 인증서가 도대체 어떤 녀석이길래 '신뢰할 수 있는 사이트'라는 타이틀을 쥐어주는지, 암호화는 어떻게 가능한지에 대한 의구심이 들었다. SSL 인증서의 메커니즘을 알아보기 전 HTTPS와 암호화 방식에 대해 알아보고, 왜 굳이 CA 기관을 통해 SSL 인증서를 발급받아야하는지도 분석해보았다.

 

HTTPS 란?

HTTP는 Hypertext Transfer Protocol의 약자로 웹서비스에 데이터를 전송하는 통신 규약을 말한다.

HTTPS는 HTTP에 보안을 더한 프로토콜로 암호화 기반의 프로토콜인 SSL(Secure Socket Layer) 이 적용된 프로토콜을 말한다. 즉, HTTPS는 HTTP에 대한 암호화 기반의 데이터 통신규약을 말한다.

 

암호화? 우리가 아는 암호화??

맞다. 네트웤 구간에 떠다닐 우리의 요청, 응답 데이터들을 공격자가 알아볼 수 없는 형태로 암호화하는 것이다. 그럼 잠깐 암호화에 대해 알아보자.

 

대칭키?! 비대칭키?! 공개키?! 개인키??!! 😱

 암호화는 데이터를 특정한 키 기준으로 연산하는 것을 말한다. 또 키를 이용해 복호화한다. 동일한 키로 암호화와 복호화를 둘다 할 수 있는 방식을키가 대칭된다 하여 대칭키 방식이라한다. 반대로 암호화하는 키와 복호화하는 키가 다른 방식을 비대칭키 방식이라 한다.

 

암호화 키와 복호화 키 중 어느 키가 더 중요할까? 

당연히 복호화 키가 더 중요하다! 암호화 키가 유출되어 모든 사람들이 갖게된다고 하여도, 복호화 키가 없다면 그 누구도 암호화된 데이터를 볼 수 없기 때문이다.

 

대칭키는 암호화 키와 복호화 키가 똑같으니 모두 공개되어서는 안된다. 그럼 비대칭키는 어떨까? 공개되어도 되는 암호화 키와, 공개되어선 안되는 복호화키로 구성된다고 했다. 여기서 공개되어도 되는 키를 '공개키', 공개되어선 안되는 키를 '개인키'라고 한다. 그럼 HTTPS에 사용되는 암호화 방식은 대칭키일까? 비대칭키일까?

 

HTTPS에 사용되는 암호화 키 방식은 대칭키 인가요...? 🧐

 

대칭키 방식이라고 가정하고 생각해보자. 요청을 하는 클라이언트에서는 요청 데이터를 암호화해야한다. 즉, 키가 필요하다. 이 키는 서버로부터 전달받아야 한다. 그럼 최초 통신(핸드 쉐이크) 단계에서 서버가 클라이언트에게 키를 전달해야한다.

 

그런데 만약 이 과정에서 공격자가 요청을 가로챈다면, 그리고 이 대칭키를 탈취한다면 어떨까? 클라이언트와 서버간 대칭키 암호화를 통해 데이터를 주고 받더라도 공격자는 탈취한 키를 이용해 복호화를 할 것이고, 데이터를 감청할 수 있게 된다. 즉, 단순 비대칭키 방식을 사용하는 것은 네트웤 통신 관점에서는 매우 위험한 방식이다.

 

최초 통신 시 전달되는 대칭키를 가로채는 공격자

 

 

아! 그럼 비대칭키 방식이군요?! 😏

 

비대칭키를 사용하면 클라이언트로 전달되는 키, 즉 공개키가 공격자에게 탈취당해도 괜찮다. 복호화를 하지 못하기 때문이다. 하지만 여기서도 큰 문제가 있다. 바로 데이터를 응답할때 암호화가 불가능한 점이다. 예를들어 로그인에 성공 시 클라이언트에게 액세스 토큰을 전달하는 상황을 가정해보자. 액세스 토큰도 마찬가지로 암호화를 해야하는데, 현재 서버는 복호화를 할수있는 개인키밖에 없는 상황이다. 암호화를 위한 키가 없는 상황인 것이다.

 

비대칭키 상황에서의 이슈

 

 

서버도 자신이 사용할 공개키를 발급한 후 암호화하면 되잖아? 😏

 

그럼 서버의 응답 데이터는 암호화 되겠지만 클라이언트는 서버에서 준 데이터를 복호화 하지 못한다. 클라이언트는 복호화를 할 수 있는 개인키가 없기 때문이다

 

클라이언트에게 개인키를 주면 되잖아? 😏

그럼 대칭키를 사용했을 때의 문제 상황과 마찬가지로 공격자가 개인키를 가로챌 수 있는 상황에 처한다. 또한 비대칭키 방식은 대칭키보다 암/복호화 복잡도가 높고 속도도 수 천배 느리다. 즉, 속도와 시스템 자원 낭비 문제를 안게 된다.

 

 

키를 대체할 수 있는 값을 활용하면 되지 않아? 😏

클라이언트에서 키를 대체할 수 있는 랜덤 값을 생성하고, 이 값을 서버의 공개키로 암호화하는것이다. 그럼 서버에서 개인키로 이를 복호화하여 랜덤 값을 얻고, 서버와 클라이언트가 이 값을 대칭키로 삼아 암/복호화 하는것이다. 대칭키 방식이니 클라이언트, 서버 모두 암/복호화 할 수 있고, 키가 되는 랜덤 값을 전달할 때에는 서버의 공개키로 암호화해서 전달할 것이기 때문에 공격자에게 탈취되도 문제되지 않는다.

 

비대칭키 + 대칭키 방식 사용

 

 

 

위에서 말한대로 위 구조에서는 공격자는 클라이언트에서 생성한 대칭키(랜덤 값)를 획득하지 못한다. 서버의 공개키로 암호화됐기에 서버의 개인키로만 복호화되기 때문이다. 

 이제 문제가 없어보일 수 있으나, 이 구조에서도 보안 이슈가 존재한다. 바로 서버가 클라이언트에게 전달하는 공개키를 공격자의 공개키로 바꿔치는 것이다. 이를 중간자 공격이라 한다.

 

중간자 공격 (Man-in-the-middel attack)
공격자가 공개키를 가로채어 변경하고 상대방에게 전달하여 신뢰를 잃게 만드는 방식.

중간자 공격 상황

 

 

서버가 공개키를 전달할 때를 캐치하여 서버의 공개키 대신 공격자의 공개키를 주면, 클라이언트는 공격자의 공개키로 생성한 대칭키를 암호화할것이다.

 그 후 클라이언트가 전달한 암호화된 대칭키를 공격자가 가로채 공격자의 개인키로 복호화한다. 대칭키를 탈취한 것이다. 탈취한 대칭키를 서버가 사용하도록 만들어야하므로 4번과정에서 획득한 서버의 공개기로 대칭키를 암호화 한 후 서버로 전달한다. 서버는 아무것도 모르고 이를 복호화할것이고 대칭키로 사용할 것이다. 이제 데이터가 암호화되더라도 공격자가 복호화할 수 있게 되었다.

 

신뢰할 수 있는 기관을 끼자! 🏢

 위처럼 중간에 공개키가 위변조되는 것을 막는 방법은 위변조를 못하도록 보안을 강화하면 된다. 보안을 위한 여러 프로세스들과 보안 장비를 붙이고, 취약점이 발생할때마다 수시로 업데이트해주며, 모니터링도 철저히 하는 것이다.

 

그렇다... 배보다 배꼽이 더 커진다.

 

 서비스 운용 비용보다 보안을 위한 비용과 기술력이 훨씬 많이 필요할것이다. 그럼 서비스 운용자가 아닌, 믿을 수 있는, 매우 높은 보안성을 갖는 기관에게 서버의 공개키를 전달받으면 어떨까?

 브라우저가 신뢰하는 기관, 신뢰할 수 있는 공개키를 제공하는 기관, 이 기관이 바로 CA이다.

CA가 도입된 구조 (실제로는 공개키를 그대로 주진않아요~)

 

CA (Certificate Authority)
인증기관이라 부르며, 다른 곳에서 사용하기 위한 디지털 인증서를 발급하는 곳.

 

 

CA도 털린적...이 있답니다 :)

실제로 2011년 DigiNotar 이라는 CA 가 해킹당해 Google과 Facebook 등 수많은 사이트가 피해를 입었다. 때문에 브라우저가 신뢰할 수 있는 CA 는 매우 깐깐한 조건으로 선별된다고 한다.

이제 본론으로 들어가 실제 CA와 서버, 클라이언트(브라우저)가 어떻게 상호작용하며 데이터를 암/복호화하는지 알아보자.

 

SSL 인증서 발급부터 통신까지의 과정을 알아보자! ✏️

SSL인증서 발급부터 통신까지의 과정

 

 

1. SSL 인증서 발급 요청

SSL 프로토콜을 적용하고 싶다면 먼저 CA 에게 SSL 인증서 발급을 요청해야한다. 중요한 점은 서버의 공개키를 함께 전달하는 것이다.

 

2. 발급된 SSL 인증서 전달 및 SSL 프로토콜 적용

CA 로부터 SSL 인증서를 전달받으면 사용하는 웹 서비스(웹서버 또는 AWS ALB 등) 에 맞게 SSL 인증서를 적용시킨다.

 

3. Client Hello (Handshake-1)

클라이언트로부터 요청이 들어온다면 바로 요청/응답 데이터를 주고받지 않는다. 핸드쉐이크 과정을 거치는데, 첫번째 과정을 Client Hello 라고 하며, 클라이언트는 서버에게 '클라이언트 랜덤 값(편의를 위해 C Random이라 칭함)'과 클라이언트가 지원 가능한 암호화 메서드 리스트 등을 전달한다. 

 

* 암호화 메서드 리스트들은 왜 전달하나요? 🤔

핸드쉐이크 과정에서 주고받는 데이터들에 대한 암호화 방식을 설정하기 위함이다. 핸드쉐이크 과정에서도 키 교환, 대칭키 암호화, PRF 알고리즘 적용과 같은 프로세스가 발생하는데, 이 때 서버와 클라이언트 모두 지원하는 암호화 방식을 사용해야하기 때문이다.

 

4. Server Hello (Handshake-1)

두번째 과정을 Server Hello 라 하며, '서버 랜덤 값(편의를 위해 S Random이라 칭함)'과 클라이언트에서 전달한 암호화 메서드 리스트 중 채택한 암호화 메서드, SSL 인증서를 전달한다.

 

5-6-7. SSL 인증서 확인 및 서버 공개키 획득

클라이언트는 SSL 인증서를 전달받으면 인증서를 발급한 CA 기관을 확인한다. 그리고 이 기관이 브라우저에 저장되어 있는지를 확인한다. 저장되어 있지 않다면 '신뢰할 수 없는 인증서'라는 경고가 발생할것이고, 저장되어 있다면 함께 저장된 해당 CA 의 공개키를 가져온다.

 공개키를 통해 복호화(SSL 인증서 전자서명 검증)에 성공한다면 공인하는 CA로부터 발급된 위변조 없는 SSL 인증서라는 것을 알게 된다.

그리고 SSL 인증서에 포함되어있던 서버의 공개키를 획득한다.

 

브라우저에 저장된 CA는 브라우저가 신뢰하는 CA
위에서 말했듯이 CA 는 매우 깐깐한 기준으로 선별된다. 브라우저 내에 저장되어 관리되며, CA 리스트와 CA 공개키를 함께 관리한다.
만약 CA 리스트에 대한 업데이트가 발생한다면, 브라우저 업데이트를 통해 진행된다.

 

8. C + S 값 생성

C Random 값과 S Random 값을 조합한 C + S 값(제가 편의를 위해 칭한 이름입니다.) 을 생성한다.

 

9-10. Pre Master Secret 생성 및 전달

C + S 값을 서버에게 전달하기 위해 서버의 공개키로 암호화한다. 암호화된 C + S 값을 Pre Master Secret 이라한다.

이 값을 서버로 전달한다.

 

11. 서버측 C + S 값 획득

Pre Master Secret은 서버의 공개키로 암호화됐기에 서버는 자신의 개인키로 복호화하여 내장된 C + S 값을 획득한다.

 

12. 서버측 Master Secret 생성

획득한 C + S 값과 Pre Master Secret, PRF 알고리즘을 조합하여 Master Secret 을 생성한다.

 

PRF(Pesudo-Random Function)
특정 입력에 대해 항상 동일한 출력을 주는 랜덤 함수.

 

13. 대칭키 발급

Master Secret 을 기반으로 데이터를 암/복호화 하기 위한 대칭키를 발급한다. 여러 대칭키를 발급하는데, 실제 암/복호화에 사용하는 대표적인 키는 Session Key와 Mac Key이다.

 

Session Key
실 데이터를 암/복호화할때 사용하는 키.

 

Mac Key
데이터에 대한 HMAC 값을 구할 때 사용하는 키.
송신자 측에서 Mac Key를 사용해 데이터의 HMAC 값을 계산하여 암호화된 데이터와 함께 전달하면, 수신자측에서는 데이터를 전달받아 Session Key로 복호화한 후, Mac Key를 통해 데이터의 HMAC 값을 구한다. 송신자가 전달한 HMAC 값과 수신자가 연산한 HMAC 값이 동일하면 메시지가 변조되지 않았다는 것을 검증하게 되는 것이다.

 

HMAC
해시 함수와 공유 비밀 키를 사용하여 해시화하여 메시지의 무결성을 확인하는 메시지 인증 코드(MAC)의 한 종류.

 

 

14. 클라이언트측 Master Secret 생성

클라이언트에서도 12 번 단계에서 사용했던 것과 동일한 C+S 값과 Pre Master Secret, PRF 값을 조합하여 Master Secret을 생성한다. 여기서 중요한 점은 위 세개 값들을 서버로부터 다시 전달받는게 아닌, 자신이 이미 갖고 있던 값들을 사용한다는 것이다.

 

15. 대칭키 발급 

13번 과정과 마찬가지로 Master Secret 을 기반으로하여 데이터를 암/복호화 하기 위한 대칭키를 발급한다. 서버와 마찬가지로 Session Key, Mac Key 등을 생성한다.

 

16. 대칭키 암호화 기반 통신 시작!

이 후 서버와 클라이언트간 통신 시 각자 갖고있는 Session Key와 Mac Key를 사용해 데이터를 암/복호화하여 통신한다!

서버와 클라이언트가 갖고있는 Session Key와 Mac Key 동일하므로 대칭키 암호화 기반으로 통신이 된다!



회고

 이 글을 보는 독자들이 SSL 프로토콜의 내부 동작과 CA 의 SSL 인증서를 연결시켜 이해할 수 있길 바란다. 이 글에 적진 않았지만, 중간자 공격의 메커니즘을 이해한다면 사내망에서 SSL Certification 이슈가 발생하는 이유와 조치 방안도 이해할 수 있을 것이다!

반응형

개요

개발 시 무료 버전의 ChatGPT를 사용하곤 한다. 다만 보안 이슈로 인해 실제 프로젝트의 코드 일부분을 발췌하여 질문하긴 힘들다. 우회책으로 코드에 대한 설명을 하거나, 비지니스와 관련된 코드들을 모두 제거한 후 껍데기 형태의 코드를 제공하는 형태로 질의를 했었다.

 그러던 와중, ollama를 사용해 LLM 모델을 로컬 환경에 다운로드받고, 로컬 환경에서 질의하는 방법을 접하게 되었다. 최대의 장점은 보안. 로컬에서 동작하는 방식이다보니 프라이빗하게 사용할 수 있었다. 또한 이러한 방식을 Intellij IDE Plugin 에서 제공한다는 사실을 알게되었다. Plugin을 일주일정도 사용해보고 느낀점들을 리뷰해보도록 하겠다. 2025년 2월 12일 기준, 플러그인 중 Continue와 CodeGPT에 대한 리뷰를 하려했으나, 갑자기 CodeGPT에 대한 플러그인이 삭제되어 Continue만 리뷰한다.

 

먼저 Continue 뭔지에 대해 한번 알아보자.

 

Continue 란 무엇인가요? 

오픈소스 기반의 AI 코드 어시스턴트이다. 다양한 LLM 모델을 지원하며, 자동 완성, 코드 수정, LLM과의 채팅 기능을 제공한다. Intellij Continue Plugin의 공식문서 내용 기반으로 기능들을 테스트 한 후 현업에서 사용해보았다.

 

https://plugins.jetbrains.com/plugin/22707-continue

 

Continue - IntelliJ IDEs Plugin | Marketplace

Continue Continue is the leading open-source AI code assistant. You can connect any models and any context to build custom autocomplete and chat experiences inside VS...

plugins.jetbrains.com

 

 

Chat 기능

Continue Chat UI

 

 IDE 를 벗어나지 않고 LLM 에게 질의할 수 있는 가장 기본적인 기능이다. Plugin 설치 시 우측에 Continue 아이콘이 생기는데, 클릭하면 위와 같은 채팅 UI가 나타난다. 단순 채팅 뿐 아니라, IDE에 있는 코드나 파일, 폴더를 단축키 기반으로 손쉽게 참조시킬 수 있다.

 

 Continue 설정 통해 API 기반 LLM 서비스로 질의를 보내게 할수도 있지만, 로컬 환경에서 실행되는 ollama 서버의 LLM 모델과 연동도 가능하다. 필자의 경우 로컬 환경의 ollama 서버와 연동하여 채팅 기능을 사용하고 있다. 모델 선택도 간단하다. 로컬 ollama 서버와 연동만 되면 모델들이 자동으로 조회된다. 원하는 모델을 선택하기만 하면 된다.

 

ollama 연동 및 model 선택

 

 

특정 코드블럭을 LLM 질의 내용에 참조시키고싶다면 IDE 상에서 블럭씌운 후 [ Command + J ] 단축키를 통해 추가할 수 있다.

코드 블럭 참조

 

 

 참조된 코드는 프롬프트와 함께 질의 가능하며, 응답받은 코드를 실제 코드에 적용하고 싶다면, 우측 상단의 'insert at cursor' 버튼을 누르면 된다. IDE 상에 위치한 커서로 코드가 복사된다.

질의 결과

 

 파일 참조도 가능하다. 프롬프트 입력창에 '@' 입력 후 베이스가 되는 파일 명을 입력하면 된다. 코드에 대한 리뷰 및 리팩토링이 필요할 경우 좋은 옵션으로 사용될 수 있을 것으로 기대된다.

파일 참조

 

Auto Complete

출처 : continue 공식문서

 

 자동완성 기능을 제공한다. 활성화를 위해 IDE 설정 > Continue > Enable Tab Autocomplete 옵션을 활성화해야한다는데, 필자의 경우 해당 옵션을 활성화해도 자동완성 기능이 활성화되지 않았다. Intellij 의 자체 자동완성 기능을 비활성화해도 마찬가지였다.

 기능을 직접 경험해보면 좋았겠지만, 사실 지원하지 않아도 크게 아쉽진 않았다. IDE 자체에서도 준수한 수준의 자동완성을 제공해주기때문이다.

 

Code Edit

출처 : continue 공식문서

 

 LLM 질의 결과로 코드를 리턴받고, 이를 UI 기반으로 편하게 수정 할 수 있는 기능이다. LLM 에서 제안한 코드는 diff 형식으로 IDE 상에 표시되며 개발자가 판단 후 적용할 수 있다. 이 기능은 별도의 설명 없이 수정할 '코드'만 출력되기 때문에 몇번의 대화가 오가는 수준의 질의가 아닌 단순한 메서드를 만들거나, 가벼운 리펙토링, 로깅처리, 예외처리 등 정형화되고 표준화된 부분에 효과적으로 사용 가능할 것 같다.

 

 사용법은 대상 코드 블럭 지정 후 [ Command + i ] 단축키를 누르면 코드 바로 위에 프롬프트 입력 창이 뜬다. 원하는 내용을 입력하면 코드 수정 제안이 diff 형식으로 나타난다. 기존 코드는 빨간색, 제안 코드는 파란색으로 표시되며, 제안 코드로 변경을 원할경우 우측 Y를, 거절할 경우 우측 N 클릭을 통해 가능하다. Rejcet All, Accept All 버튼을 통해 일괄 처리도 가능하다.

 

아래는 메서드 명을 리팩토링해달라는 요청이었는데, 메서드 명명 규칙에 맞게 동사, 카멜케이스 형태로 자동 리팩토링 한 것을 확인할 수 있다.

메서드명 리팩토링 명령

 

 

일주일간의 사용 후기

 당연한 얘기긴 하지만 일단 답변 품질에서의 차이는 기존 API 기반 LLM 서비스와 차이가 없었다. 개발 전용 IDE에 연동되었기에 품질에서도 뭔가 다르지 않을까라는 조그만 기대를 했던것같다. 답변받은 코드들에 대해 개발자의 자체 검증 과정은 기존 LLM 서비스를 사용했을때와 같이 필요했다. 해서 LLM 품질이 아닌 다른 영역에서 느꼈던 부분들을 정리해보았다.

 

* 코드 유출, 외부 학습의 위험이 없다.

API 기반 LLM 서비스를 사용할 경우 질의한 코드를 모델이 학습할 우려인터넷망을 거친다는 점에서 코드 유출 위험을 우려했는데, 로컬 ollama 를 연동할 경우 인터넷 연결 없이 로컬 환경에서 사용 가능하다는 점에서 위 우려가 해소되었다. 코드에 대해 질의가 필요할 경우 설명으로 대체하는 케이스가 많았는데, 코드를 그대로 질의할 수 있다는 점이 좋았다.

 또한 IDE와 연동이 되어있어 빠르고 편리하게 리소스를 참조시킬 수 있었다. 보다 쉽고 빠른 질의가 가능했다.

 

* 자유롭고 빠른 모델선택

 모델마다 특화된 역량이 있다. 때문에 모델을 바꿔가며 질의하는 경우가 있었는데 모델 변경 방식도 매우 간단했다. 필자는 ollama 3.1, qwen2.5-coder, deepseek-r1 모델을 주로 사용했는데 이중 qwen2.5-coder 모델은 코드 기반의 답변을, deepseek는 도출 과정 기반의 답변을 받을 수 있었다. (참고로 qwen2.5-coder 모델은 코딩과 수학에 특화된 모델이다.)

 

qwen2.5-coder Description 일부 발췌

이 분야의 전문화된 전문가 모델 덕분에 코딩 과 수학 에 대한 지식이 상당히 많아 지고 역량이 크게 향상되었습니다 .

 

qwen2.5-coder 모델과 deepseek-r1 모델에게 메서드 명에 대한 리팩토링 질의를 해본 결과,

질의내용

 

qwen2.5-coder 모델은 아래와 같이 리팩토링 코드간단한 설명만 존재했고, (코드적으로 설명이 필요하거나 새로운 개념이 등장할 경우에는 친절하게 설명해준다.)

qwen2.5-coder:7b 모델의 응답

 

 

deepseek-r1 모델은 최종 결과 코드에 대한 도출 과정이 자초지종 설명되어 있다. 아래는 답변에 대해 부분 번역 것인데, 구구절절한 느낌이 없지않아 있다. 이 외에도 코드 리뷰를 요청했는데, 마찬가지로 코드에 대한 도출 과정들을 매우 길게 설명했다. 실제로 모든 응답을 받기까지 5분 이상 걸렸었다. 개발을 시작한지 얼마 되지 않았거나, 과정을 이해하고싶은 개발자들에게는 좋은 옵션이 될 수 있을것같다.

 

enterRoom 내부의 각 줄을 통과하겠습니다. 먼저 ValidAteChattingroom (룸메이트)이 있습니다. 그것은 간단 해 보이지만 어쩌면 더 설명 적으로 만들 수 있습니다. 그것이 검증되기 때문에 아마도 그대로 유지하거나 가능하면 더 나은 동사를 찾으십시오.
...(중략)
어쩌면 "검증"이 여기에서 최고 일 것입니다.

다음은 KickoutDuplicationUserInsameroom (룸메이트, 이메일)입니다. 복제라는 단어는 철자가 잘못 될 수 있습니다. 중복되어야합니다.
...(중략)
어쩌면 "제거"가 더 나을 것입니다. 따라서 삭제 된 UplyserInsameroom 또는 그와 비슷한 것으로 변경하십시오.

그런 다음 SessionUser.of (SessionId, 이메일, 룸메이트)가 있습니다. ()의 메소드 이름은 공장 방법에 맞으므로 다른 곳에 다른 이름 지정 규칙이 없다면이를 변경할 필요가 없다고 생각합니다.

다음 줄은 CachingUsersessionTosessionStore (SessionUser)입니다. 흠, 캐싱은 여기서 올바른 동사가 아닐 수도 있습니다. 저장 또는 저장에 대한 것입니다.
...(중략)
아마도 SavessessionUserInsessionStore가 더 나을 것입니다.

...(중략)
So putting it all together, I'll go through each method call and adjust the names accordingly:
validateChattingRoom(roomId) → keep as is since "validate" is a verb.kickOutDuplicationUserInSameRoom(...) → change to removeDuplicateUserFromRoom or something similar, but maybe better phrased as ensureSingleSessionPerRoom to make it more proactive.cachingUserSessionToSessionStore(sessionUser) → saveSessionUserToSessionStorecachingUserSessionToRoomStore(sessionUser) → saveSessionUserToRoomStoresendRoomStatusToRedis(roomId) → updateRoomStatusOnRedis or sendRoomStatusUpdateToRedissendRoomEnterAlarmToRedis(email, roomId) → notifyRoomEntryToRedis

따라서 최종 리팩토링된 메서드 호출은 다음과 같을 것이라고 생각합니다.

validateChattingRoom(roomId);
ensureSingleSessionPerRoom(roomId, email);
sessionUser = SessionUser.of(...);
saveSessionUserToSessionStore(sessionUser);
saveSessionUserToRoomStore(sessionUser);
sendRoomStatusUpdateToRedis(roomId);
notifyRoomEntryToRedis(email, roomId);

이것은 각 메소드 이름을 Camelcase의 동사로 만들고 각각의 행동을 명확하게 설명합니다.
  public void enterRoom(EnterRoomDto.Request request, String email, String sessionId) {
        Long roomId = request.getRoomId();

        validateChattingRoom(roomId);
        ensureSingleSessionPerRoom(roomId, email);

        SessionUser sessionUser = SessionUser.of(sessionId, email, roomId);
        saveSessionUserToSessionStore(sessionUser);
        saveSessionUserToRoomStore(sessionUser);
        sendRoomStatusUpdateToRedis(roomId);
        notifyRoomEntryToRedis(email, roomId);
    }

 

* 리팩토링, 코드리뷰 활용

 리팩토링과 코드리뷰에 잘 활용될 수 있을것이라 생각한다. 필자의 경우 비지니스 클래스에 대한 코드 리뷰를 요청했더니, 실제로 문제가 될 수 있는 부분에 대한 개선을 제안받았다. 병렬 처리가 필요한 부분에서 SingleThread 를 계속 생성해나가는 방식을 채택했었는데, Thead Pool 방식으로의 개선을 제안받았고, 제안받은 코드도 정상적으로 돌아갔다. 참고로 모델은 qwen2.5-coder 모델을 사용했다.

 

* 다국어 대화 지원 여부 파악 필요

 필자는 영어와 친하지 않아 주로 한글로 질의했다. qwen2.5-coder나 ollama 3.1 모델은 한글로 질의할 경우 한글 답변을 줬는데, deepseek-r1 모델의 경우 영어 답변을 줘서 번역기를 돌려야하는 불편함이 있었다. ollama 공식 사이트에 접속하여 모델 정보를 읽어보니 qwen2.5-coder나 ollama 3.x의 경우 모델 설명 부분에 '다국어를 지원한다', '다국어 대화가 가능하다' 등의 내용이 있는데 반해, deepseek-r1 모델은 다국어 관련 내용이 존재하지 않았다. 영어를 잘 못하는 사람들은 모델에 명시된 다국어 지원 여부를 잘 살펴보는것이 좋을것같다.
(deepseek도 프롬프트에 한국어로 답변해달라고 추가하면 한글 답변이 나오긴 하지만, 추가하는걸 자꾸 까먹...)

 

ollama 3.1 Description 일부 발췌

8B 및 70B 모델의 업그레이드된 버전은 다국어이며 128K의 상당히 긴 컨텍스트 길이, 최첨단 도구 사용 및 전반적으로 더 강력한 추론 기능을 갖추고 있습니다. 이를 통해 Meta의 최신 모델은 장문 텍스트 요약, 다국어 대화 에이전트코딩 어시스턴트와 같은 고급 사용 사례를 지원할 수 있습니다.

 

qwen2.5-coder Description 일부 발췌

중국어, 영어, 프랑스어, 스페인어, 포르투갈어, 독일어, 이탈리아어, 러시아어, 일본어, 한국어, 베트남어, 태국어, 아랍어 등 29개 이상의 언어에 대한
 다국어 지원 제공합니다 .

 

참고로 잠깐 사용했던 CodeGPT는 모델과 별개로 시스템 프롬프트 설정이 가능했는데, 한글로 답변해달라는 내용을 추가하니 deepseek-r1 모델 또한 한글 답변을 주긴 했었다. continue에도 이러한 설정이 있다면 좋을것같다.

  

* 시스템 리소스에 따른 모델 규모 선택

 아무래도 로컬 환경에 설치된 LLM 모델이 동작하는 거라 시스템 리소스를 많이 먹는다. 필자의 컴퓨터 메모리가 18GB인데, 14b 규모의 모델을 사용할 경우 컴퓨터가 심하게 버벅거린다. 7b 규모로 낮추니 버벅거림이 많이 사라졌다. 

 규모가 클수록 더 좋은 답변을 받을 수 있겠지만 본인의 PC 사양을 고려하여 모델 규모를 설정하는 것이 필요해보인다. 모델 크기는 GB 기준 메모리의 절반을 넘지 않는 것이 바람직해보인다. 메모리가 18GB라면 모델 크기는 9B를 넘지 않는것이 정신건강에 좋았다.

 

회고

 일주일동안 현업에 적용하려고 시도해보았으나, LLM의 답변 내용을 검증하기 위해, 또는 믿지못해서 구글링을 하는 자신을 볼 수 있었다. 사용 초기엔 무지성 채팅 기능만 사용했는데, 공식문서를 읽어보니 꽤나 유용한 기능이 있음에 놀랐다. 특히 Edit 기능이 매우 매력적이라 생각됐다.

 사용 기간이 길지 않았고, 기능 경험, 사용 부분에 초점이 맞춰져서인지, 다양하고 구체적인 현업 적용 아이디어는 도출해내지 못했다. 계속해서 현업에 활용하면서 유의미한 포인트를 찾아봐야겠다.

 

 

 

반응형

+ Recent posts