(24)

Docker로 Redis Sentinel 구성하기.

Redis의 고가용성(HA: High Availability) 설계를 위한 위한 Redis Sentinel에 대해 알아보자.주니어 개발자의 Nest + BullMQ 기반 실시간 채팅의 성능/구조 개선기내가 어떤 조직에 속하게 되었을 때, 조직에서 관리하는 애플리케이션을 한 번씩 사용자 관점에서 돌아보고, 개발자 관점에서mag1c.tistory.com 이전 포스팅에 이어서, Sentinel 구성해보자. Redis Master + Replica 구성먼저, Master노드와 Replica 노드를 구성해보자.# docker-compose.ymlredis-master: image: redis:latest command: redis-server container_name: "redis-mas..

Redis의 고가용성(HA: High Availability) 설계를 위한 위한 Redis Sentinel에 대해 알아보자.

주니어 개발자의 Nest + BullMQ 기반 실시간 채팅의 성능/구조 개선기내가 어떤 조직에 속하게 되었을 때, 조직에서 관리하는 애플리케이션을 한 번씩 사용자 관점에서 돌아보고, 개발자 관점에서 돌아보고 문제점을 리스트업하는 습관이 있다. 이를 통해 당장의mag1c.tistory.com 이전 글에서 메시지 큐의 장애 발생 상황을 여러가지로 가정하고, 간단한 해결책들을 생각해서 서술했었다.이번 글에서는 그 중에서도 특히 많은 메시지 큐에서 Redis를 저장소로 사용하거나 지원하는 만큼, Redis의 failover전략 중 하나인 Redis Sentinel에 대해 공식 문서와 실제 사례를 기반으로 공부한 내용을 작성한다. Redis에 장애가 발생한다면?생각해보면 Redis는 애플리케이션을 구성할 때 거..

[Redis] Data Types 정리

https://redis.io/docs/latest/develop/data-types레디스는 캐싱부터 큐잉, 이벤트 처리 등의 비즈니스 문제를 다양하게 해결할 수 있도록 다양한 데이터 타입들을 제공한다.과금 모델인 Enterprise 명령어는 제외했으며, 포스팅 외의 공식문서를 참조하고 싶다면 각 링크들을 참조하자.[자료 구조]                [명령어]    String512MB 미만의 일반적인 문자열을 저장할 수 있다. 직렬화된 객체나 이진 배열을 포함한다.바이너리 데이터를 포함할 수 있어 이미지 저장이 가능하다.HTML Fragments, 페이지 등의 캐싱에 유용하다.증감 연산을 사용할 수 있다. (INCR, DECR)SETEX, PSETEX, SETNX는 2.6.12버전부터 deprec..

[MySQL] Lost Update와 Write Skew

서론트랜잭션의 격리 수준 포스팅에서 다루지 않았던 이상현상 중 Lost Update와 Write Skew같은 일관되지 않은 쓰기 결과를 반환하는 데이터 부정합 문제가 있다. PostegraSQL에서는 격리 수준을 REPEATABLE READ로 설정하는 것만으로도 쓰기 결과를 올바르게 보장할 수 있다. 하지만 MySQL의 MVCC(Multi Version Concurrency Control)은 일관된 읽기(Consistence Read)를 지원하지만 위와 같은 데이터 업데이트의 부정합 문제를 REPEATABLE READ의 격리 수준 만으로는 해결할 수 없다.  아래 그림은 MySQL에서 REPEATABLE READ 격리 수준을 사용했을 때 Lost Update가 발생하는 상황이다. 고객 A가 10,000원..

[데이터베이스] MVCC(다중 버전 동시성 제어 - Multi Version Concurrency Control)

동시성 제어(Concurrency Control)DBMS에서 동시성 제어는 동시에 데이터에 접근하는 여러 사용자, 즉 여러 트랜잭션의 상호작용에서 트랜잭션의 isolation을 보장하고 일관성과 무결성을 유지할 수 있도록 하는 목적으로 사용되는 기술이다.  이러한 동시성 제어를 하는 대표적인 방식 중 가장 대표적인 Lock을 간단하게 알아보자.  공유 잠금(읽기 잠금, shared lock)이나 배타적 잠금(쓰기 잠금, exclusive lock)을 통해 Lock을 획득한 후 트랜잭션 내부의 작업을 수행하는 방식이다. (read로도 쓰기 잠금을 획득할 수 있다)  다른 트랜잭션은 이전 트랜잭션에서 Lock을 반환해야 작업이 수행이 가능하다는 의미이다.    이는 곧 SERIALIZABLE하다는 의미이며,..

[MySQL] 트랜잭션 격리수준(isolation level)과 이상현상 (with 테스트 코드)

기억에 오래남고 이해하기 쉽게 현재 조직의 웨딩 도메인의 적립금을 예시로 간단한 엔터티 설계와 더불어 테스트 코드를 작성하여 각 격리수준과 이에 따른 이상현상을 정리해보았다. 개념들은 MySQL의 공식문서를 활용하여 정리하였고, AUTO_COMMIT은 FALSE를 가정하고 예제들을 작성하였다. (예제에 필요한 기본적인 엔터티와 데이터 세팅은 아래를 참조) CREATE TABLE icash (    no INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,    user_no INT UNSIGNED UNIQUE NOT NULL,    icash INT UNSIGNED DEFAULT 0 NOT NULL,    created_at TIMESTAMP DEFAULT CURRENT_TIMESTA..

[데이터베이스] 인덱스(index) 정리

인덱스   목차, 색인, 책갈피와 같은 기능을 하는 인덱스는, 데이터베이스 분야에서는 어떤 데이터를 검색할 때 속도를 높여주는 자료 구조로메모리 영역에 생성되는 일종의 책갈피이다.    인덱스 구조보통 Hash, B-Tree, B+Tree가 있다. Hash우리가 알고있는 Key, Value형태의 자료 구조다.해시 함수로 키 값을 해시값으로 변환하고, 이 해시 값을 기반으로 데이터를 빠르게 조회할 수 있다. - O(1)   하지만 이런 해시구조의 특성상 범위 검색에 효율이 떨어진다. 키 값이 조금이라도 변하게 되면 완전히 다른 해시 값을 반환하기 때문이다.범위 검색에서의 부등호 연산을 포함한 범위 조건(BETWEEN, LIKE 등)에는 적합하지 않다.   B-TreeB-Tree는 이진 트리를 확장한 트리 ..

[MySQL] 프로시저 (Stored Procedure)

학원 내 모의 프로젝트에서 1. 회원 탈퇴의 경우 일정 기간이 지나면 자동으로 테이블에서 삭제 처리 2. 통계 테이블에 해당 날짜의 자정이되면 자동으로 insert처리 위와 같은 DB 자동화를 구축해 보고 싶었고 강사님께 여쭈어봤더니 CI/CD ?? Jenkins 같은 것들을 학습하면 구현하는 데 도움이 될 거라고 하셨는데 너무 생소한 개념이기도 하고 학습하는데 시간이 다 가서 프로젝트 수행을 못할 것 같아 방법을 찾아보던 중 프로시저를 활용해 MySQL 내에 이벤트를 구현해 간단한 자동화??아닌 자동화를 구현한 적이 있다. 그 때 당시 이해했던 프로시저는 Java의 메서드와 같은 역할을 하는 것인줄 알았다. 함수를 생성하여 이벤트 스케쥴러를 통해 프로시저를 호출했기 때문이다. 지금와서 지난 쿼리를 보니..

[MySQL] 이벤트 생성 - 이벤트 스케쥴러(Event Scheduler)

학원 내 모의 프로젝트 중에서 간단하게 이벤트를 생성 해 통계 테이블에 자정마다 날짜를 입력 해 줬던 적이 있다. 사용했던 경험이 있기 때문에 내 것으로 만들어보고자 포스팅을 하게 되었다. 이벤트 생성 아래의 코드는 프로젝트 내 통계 테이블 중 방문자 테이블의 첫 insert를 위해 사용했다. (해당 날짜 방문자는 00:00:00으로 초기화 되어있는 해당 일자 테이블에 방문횟수가 +1되는 구조였다) create event visitDaily on schedule every 1 hour starts now() --comment 주석 do insert into VISIT(VISIT_DATE, NUMBER) select date_format(now(), "%Y%m%d"), 0 from dual where no..

Docker로 Redis Sentinel 구성하기.

Tech/Database 2025. 6. 16. 12:54
728x90
728x90

 

 

Redis의 고가용성(HA: High Availability) 설계를 위한 위한 Redis Sentinel에 대해 알아보자.

주니어 개발자의 Nest + BullMQ 기반 실시간 채팅의 성능/구조 개선기내가 어떤 조직에 속하게 되었을 때, 조직에서 관리하는 애플리케이션을 한 번씩 사용자 관점에서 돌아보고, 개발자 관점에서

mag1c.tistory.com

 

 

이전 포스팅에 이어서, Sentinel 구성해보자.

 

 

 

 

Redis Master + Replica 구성

먼저, Master노드와 Replica 노드를 구성해보자.

# docker-compose.yml
redis-master:
    image: redis:latest
    command: redis-server
    container_name: "redis-master"
    networks:
        - redis-net

redis-replica-1:
    image: redis:latest
    command: redis-server --replicaof redis-master 6379
    links:
        - redis-master
    container_name: "redis-replica-1"
    networks:
        - redis-net

redis-replica-2:
    image: redis:latest
    command: redis-server --replicaof redis-master 6379
    links:
        - redis-master
    container_name: "redis-replica-2"
    networks:
        - redis-net

 

 

 

 

 

Sentinel 구성

Sentinel은 Redis와는 별도의 구성으로, Redis Sentinel 문서에서 권장하는 최소 Sentinel인 3대의 Sentinel을 띄워 quorum을 만족시키도록 구성했다. 위 포스팅에서도 언급한 바 있지만, Sentinel은 failover 시 quorum을 만족해야 마스터를 전환할 수 있는데, 2개 이상의 Sentinel이 동의해야 객관적 장애(odown)로 판단할 수 있기 때문이다.

 

# Dockerfile
FROM redis:latest

EXPOSE 26379

ADD sentinel.conf /etc/redis/sentinel.conf

RUN mkdir -p /var/lib/redis && \
    chmod 777 /var/lib/redis && \
    chown redis:redis /etc/redis/sentinel.conf

COPY sentinel-entrypoint.sh /usr/local/bin/

RUN chmod +x /usr/local/bin/sentinel-entrypoint.sh

ENTRYPOINT ["entrypoint.sh"]
#entrypoint.sh
#!/bin/sh

sed -i "s/\$SENTINEL_QUORUM/$SENTINEL_QUORUM/g" /etc/redis/sentinel.conf
sed -i "s/\$SENTINEL_DOWN_AFTER/$SENTINEL_DOWN_AFTER_MS/g" /etc/redis/sentinel.conf
sed -i "s/\$SENTINEL_FAILOVER/$SENTINEL_FAILOVER_TIMEOUT/g" /etc/redis/sentinel.conf

exec docker-entrypoint.sh redis-server /etc/redis/sentinel.conf --sentinel
# sentinel.conf
# Example sentinel.conf can be downloaded from http://download.redis.io/redis-stable/sentinel.conf
port 26379
dir /tmp

# 도커 서비스명을 hostname으로 인식,
# 해당 설정이 없으면 `Can't resolve master instance hostname` 오류 발생.
sentinel resolve-hostnames yes

# master redis를 감시.
sentinel monitor mymaster redis-master 6379 $SENTINEL_QUORUM

# 장애 간주 시간 설정 (MS)
sentinel down-after-milliseconds mymaster $SENTINEL_DOWN_AFTER_MS

# 새로운 master가 된 redis에 동기화할 수 있는 slave(repl) 제한
sentinel parallel-syncs mymaster 1

# failover과정 전체의 timeout
sentinel failover-timeout mymaster $SENTINEL_FAILOVER_TIMEOUT

bind 0.0.0.0

 

SENTINEL_DOWN_AFTER_MS=5000
SENTINEL_FAILOVER_TIMEOUT=500
SENTINEL_QUORUM=2

 

 

sentinel.conf를 컨테이너로 복사하고, entrypoint.sh에서 환경변수를 가지고 실시간 구성 파일을 만들어, Sentinel을 실행시킨다.

이 때, reslove-hostnames yes 는 도커 환경에서 서비스명을 호스트로 인식하게 하는 명령어이니, 서비스명을 사용하기 위해 반드시 우선적으로 입력해야한다.

 

 

 

위 코들을 바탕으로 전체 docker-compose 파일은 다음과 같이 구성된다.

version: "3.8"

services:
    redis-master:
        image: redis:latest
        command: redis-server
        container_name: "redis-master"
        networks:
            - redis-net

    redis-replica-1:
        image: redis:latest
        command: redis-server --replicaof redis-master 6379
        links:
            - redis-master
        container_name: "redis-replica-1"
        networks:
            - redis-net

    redis-replica-2:
        image: redis:latest
        command: redis-server --replicaof redis-master 6379
        links:
            - redis-master
        container_name: "redis-replica-2"
        networks:
            - redis-net

    sentinel-1:
        build: sentinel
        ports:
            - "26379:26379"
        env_file:
            - .env
        depends_on:
            - redis-master
            - redis-replica-1
            - redis-replica-2
        container_name: "sentinel1"
        networks:
            - redis-net

    sentinel-2:
        build: sentinel
        ports:
            - "26380:26379"
        env_file:
            - .env
        depends_on:
            - redis-master
            - redis-replica-1
            - redis-replica-2
        container_name: "sentinel2"
        networks:
            - redis-net

    sentinel-3:
        build: sentinel
        ports:
            - "26381:26379"
        env_file:
            - .env
        depends_on:
            - redis-master
            - redis-replica-1
            - redis-replica-2
        container_name: "sentinel3"
        networks:
            - redis-net
networks:
    redis-net:
        driver: bridge

 

 

 

 

 

장애 유도 및 Failover 확인하기

위의 도커 구성대로 컨테이너를 구성한 후, 실제 장애를 발생시켜보았다.

 

$ docker stop redis-master

 

 

1. SDOWN

Sentinel들은 주기적으로 Master 노드에 PING을 보낸다. 일정 시간 응답이 없으면 Master을 SDOWN으로 판단한다. 여기서 일정 시간은, 위에서 설정한 DOWN_AFTER_MS 값이다.

 

 

2. ODOWN

여러 Sentinel이 SDOWN 상태를 보고하면, Quorum 수 이상의 Sentinel이 동의했을 때, ODOWN으로 승격된다.

 

 

3. 리더 Sentinel 선출

Failover을 수행할 리더 Sentinel을 선출한다. 이 과정은 투표 기반으로 진행되며, Sentinel은 자신이 리더가 되겠다는 요청을 다른 Sentinel에게 보내고 과반 투표를 받는다.

 

 

 

4. 새로운 Master 선택

리더 Sentinel은 Replica 중 하나를 Master로 승격시킨다.

선택된 Replica는 MASTER MODE로 전환된다.

 

 

새로운 Master가 선출됨에 따라 나머지 Replica를 재구성하고, 새로운 Master의 정보를 다른 Sentinel과 클라이언트에 전파한다.

 

 

 

 

기타

장애 복구 중간중간에 아래와 같은 에러 로그가 반복적으로 출력되는 것을 볼 수 있다.

Failed to resolve hostname 'redis-master'

 

이는 Sentinel 설정에서 호스트명을 컨테이너명을 사용했기 때문인데, redis-master 컨테이너가 완전히 종료되는 상황을 가정했기 때문에, 도커 내부 DNS - Hostname의 해석이 불가능해서이다. 실제 내부 IP로 바인딩하는 등의 처리로 해결할 수 있다.

 

 

 

 

마무리

Sentinel의 Failover의 과정을 따라 HA를 보장하는 것을 확인했다. 실제 장애를 유도하고 Failover로그를 추적하며 이전 포스팅에서 개념적으로 정리했던 Sentinel의 동작 원리를 간단하게 살펴보고 이해할 수 있었다.

 

다음 포스팅에서는 Sentinel Notificiations을 적용해보고, 장애 발생 시 정상적으로 알림을 발생시킬 수 있는지 확인해보려고 한다. 이 포스팅들을 기반으로, 실제 업무에 Sentinel을 좀 더 견고하게 적용시킬 수 있을 것으로 기대한다.

728x90
300x250
mag1c

mag1c

2년차 주니어 개발자.

Redis의 고가용성(HA: High Availability) 설계를 위한 위한 Redis Sentinel에 대해 알아보자.

Tech/Database 2025. 6. 6. 18:12
728x90
728x90

 

 

 

주니어 개발자의 Nest + BullMQ 기반 실시간 채팅의 성능/구조 개선기

내가 어떤 조직에 속하게 되었을 때, 조직에서 관리하는 애플리케이션을 한 번씩 사용자 관점에서 돌아보고, 개발자 관점에서 돌아보고 문제점을 리스트업하는 습관이 있다. 이를 통해 당장의

mag1c.tistory.com

 

이전 글에서 메시지 큐의 장애 발생 상황을 여러가지로 가정하고, 간단한 해결책들을 생각해서 서술했었다.

이번 글에서는 그 중에서도 특히 많은 메시지 큐에서 Redis를 저장소로 사용하거나 지원하는 만큼, Redis의 failover전략 중 하나인 Redis Sentinel에 대해 공식 문서와 실제 사례를 기반으로 공부한 내용을 작성한다.

 

 

Redis에 장애가 발생한다면?

생각해보면 Redis는 애플리케이션을 구성할 때 거의 대부분 사용했던 것 같다. 질의를 위한 쿼리에 대한 최적화를 수행해도 UX를 저해하는 경우에 캐싱하여 사용하고 있다. 추가로 랭킹 등의 집계 후 자주 변하지 않는 데이터에도 Redis에 올려 사용하고, 주기적으로 갱신하곤 했다. 기타 여러 상황들이 있겠지만, 나의 경우는 이 대부분의 모든 카테고리가 캐싱 이다.

 

 

 

내가 사용하는 Redis사례나 기타 사례 등은 Redis의 빠른 응답 특성을 이용해 최대한 DB 조회를 피하고자 하는 전략이 대부분이다.

 

보통의 이런 캐싱 전략에서, 정해놓은 주기가 만료된 후의 최초 요청에서는 캐싱된 데이터가 Redis에 존재하지 않기 때문에 DB Fetching 후 Redis에 적재하는 일련의 과정을 거친다. 만약에 이 Redis에 문제가 생겨서 Redis 서버가 다운됐다고 가정해보자.

 

 

Redis에 데이터가 없는 것을 포함한 모든 예외 상황 시 DB에서 데이터를 가져오게 만들었다고 가정해보자. 이제 Redis 장애로 인해 모든 캐시 미스 요청이 DB로 직행하게 되고, 이는 곧 DB의 TPS가 급증하게 되어 DB CPU의 과부하로 이어진다. 단일 DB 인프라에서는 특히 감당하지 못하고 서버 전체가 죽는 시나리오로 연결될 수 있다.

 

직전 포스팅에서도 메시지 큐가 레디스의 문제로 동작하지 않는다면, 서비스 직원분들의 업무 알림이 전혀 발생하지 않아 모든 업무가 마비될 것이다. 이는 곧 매출에 심각한 영향이 발생할 수 있다.

 

 

간단히 Redis의 장애 발생 시 여파들에 대해 알아봤다. Redis의 확장을 고려해야할 때가 온다면, Cluster에 대해서도 깊게 다뤄볼 예정이다. 하지만 고가용성만을 목적으로 했기 때문에 아래에서부터는, 현재의 환경에 맞춘 failover 을 구성하기 위한 Sentinel만을 다룬다.

 

 

Redis Sentinel

Redis Sentinel은 Redis의 고가용성(HA: High Availability)을 보장하기 위한 구성방식이다.

 

 

Sentinel의 특징

 

 

Sentinel은 Active-Passive 구조로 동작한다. 즉, 하나의 Master 노드가 활성화되어 있고, 나머지 Replica 노드들은 대기 상태에 있다. Sentinel은 이 Redis 인스턴스들을 모니터링 하며, 장애가 발생했을 때 해당 상태를 감지 하고, 알림을 전송 하며, 필요 시 Replica중 하나를 Master로 승격시켜 자동으로 failover를 수행 한다.

 

 

Sentinel 사용 권장사항

 

 

Sentinel은 단순한 Redis 프로세스가 아니다. 서로 통신하고 감시하며 장애 발생 시 투표를 통해 Failover을 트리거하는 분산시스템의 일부 이다. 이런 Sentinel이 하나만 있다면, 그것이 죽는 순간 전체 시스템의 복구 능력도 함께 사라진다. Sentinel을 1개만 두는 경우, 해당 인스턴스에서 장애 발생 시 아무도 Redis를 감지할 수 없고, 자동 failover도 동작하지 않는다. 가용성을 위한 감시 시스템 자체가 SPOF 이 되는 셈이다.

 

그래서 Redis 공식 문서에서도 항상 3개 이상의 Sentinel 프로세스를 운영할 것을 가장 우선해서 권장한다. 이런 이유로 Sentinel은 독립적으로 장애가 발생할 것으로 예상되는 서버에 배치하는게 좋다. 아래 예시 구성에서는 도커 컨테이너로 세 개의 Sentinel을 띄워 테스트해 볼 예정이다.

 

다시 정리하고 넘어가자면, Sentinel을 분산 배치하는 이유는 장애 감지의 신뢰성 확보와 더불어 자체 장애에 대한 복원력 확보에 있다. 특정 Sentinel의 오탐지로 잘못 바뀌는걸 방지하며, Sentinel이 죽더라도 다른 Sentinel들이 감시를 이어갈 수 있기 때문이다.

 

 

 

Sentinel들은 다음과 같은 방식으로 협업한다.

  • 모든 Sentinel들은 Redis Master의 상태를 독립적으로 모니터링한다.
  • Master에 문제가 생긴 것으로 의심되면, 이를 다른 Sentinel에게 전파한다.
  • 이 상태에서 정해진 정족수(Quorum) 이상이 Master가 죽었다고 합의(Voting) 하면, Failover가 시작된다.

Sentinel들은 Quorum 이라는 설정된 최소 합의 Sentinel 수가 서로 합의되어야 failover를 수행한다. 이 때 Quorum은 기본적으로 과반수를 따르지만, 설정을 통해 지정할 수도 있다.

 

 

Sentinel은 "하나의 Sentinel은 누구도 믿지 않는다" 는 전제를 기반으로
신뢰 기반의 감시 구조를 만들기 위해 반드시 다중 구성과 투표 기반 구조를 요구한다.

 

 

 

그렇다면, Sentinel은 언제 어떤 기준으로 Master가 죽었다고 판단할까? 장애 인식 과정을 간단하게 알아보자.

 

 

장애 인식과 Failover

Sentinel은 SDOWN(주관적 다운)과 ODOWN(객관적 다운)을 통해 Master 노드의 장애를 인식한다.

우선 각 Sentinel은 개별적으로 ping을 보내 상태를 감시하는데, 이 때 응답이 없다면 SDOWN으로 간주한다.

 

잠시 네트워크 이상 등의 일시적인 현상일 수도 있기 때문에, 주관적 다운 상태로 변경된다. 하지만 여러 Sentinel들이 동일하게 이 SDOWN을 감지한다면 Master 노드에 문제가 있는 상태로 간주한다. Quorum 이상의 SDOWN이 감지되는 이 때, ODOWN으로 승격되고 failover 작업이 시작된다.

 

 

가장 먼저 투표 요청을 보내고, 과반수 이상의 동의를 얻은 Sentinel이 리더로 선출된다. 이 때 리더는 다음과 같은 역할을 수행한다.

  • Replica 노드들 중 적절한 노드를 선택하여 새로운 Master로 전환한다 
  • 나머지 Replica를 새 Master를 바라보도록 설정
  • 다른 Sentinel들과 클라이언트에게 새로운 Master의 정보를 전파

 

새로운 Master 노드가 선정되더라도 시스템 전체가 기존처럼 하나의 구성으로 수렴되어야한다. 리더 Sentinel은 새로운 레디스의 구성을 Configuration Epoch 값과 함께 전파한다. 이 값은 일종의 버전 관리를 위한 값으로 가장 최신의 구성이 무엇인지를 구분할 수 있게 해준다.

 

또한, 모든 Sentinel들은 __sentinel__: hello 채널을 통해 주기적으로 구성 정보를 공유 하는데, 이 때 더 높은 epoch를 가진 구성을 선택하여 자연스럽게 일관적인 시스템 구성으로 수된다.

 

$ redis-cli -p 26379 SENTINEL get-master-addr-by-name mymaster
1) "192.168.0.10"
2) "6379"

 

클라이언트는 항상 현재 Master 주소를 요청하기 때문에 구성 변화에도 자동으로 대응할 수 있게 된다.

 

 

 

마무리.

다음 포스팅에서는, 실제 Redis Sentinel을 도커 컨테이너로 구성해서, Failover을 실습해보고 정리한 내용들을 검증해보고자 한다.

 

 

 

 

 

 

 

 

 

Referecnes.

https://redis.io/docs/latest/operate/oss_and_stack/management/sentinel

https://redisgate.kr/redis/sentinel/sentinel_election.php

728x90
300x250
mag1c

mag1c

2년차 주니어 개발자.

[Redis] Data Types 정리

Tech/Database 2025. 2. 9. 21:03
728x90
728x90

https://redis.io/docs/latest/develop/data-types

레디스는 캐싱부터 큐잉, 이벤트 처리 등의 비즈니스 문제를 다양하게 해결할 수 있도록 다양한 데이터 타입들을 제공한다.

과금 모델인 Enterprise 명령어는 제외했으며, 포스팅 외의 공식문서를 참조하고 싶다면 각 링크들을 참조하자.

[자료 구조]                [명령어]

 


 

 

 

String

  • 512MB 미만의 일반적인 문자열을 저장할 수 있다. 직렬화된 객체나 이진 배열을 포함한다.
  • 바이너리 데이터를 포함할 수 있어 이미지 저장이 가능하다.
  • HTML Fragments, 페이지 등의 캐싱에 유용하다.
  • 증감 연산을 사용할 수 있다. (INCR, DECR)
  • SETEX, PSETEX, SETNX는 2.6.12버전부터 deprecated되었고, SET에서 통합하여 사용이 가능하다.
127.0.0.1:6379> SET STRING1 "STRING1"
OK
127.0.0.1:6379> GET STRING1
"STRING1"
127.0.0.1:6379> SET STRING1 "STRING1" NX
(nil)
127.0.0.1:6379> SET STRING1 "STRING2" XX
OK
127.0.0.1:6379> GET STRING1
"STRING2"

127.0.0.1:6379> SET STRING2 "STRING2" EX 1
OK
# 1초 뒤
127.0.0.1:6379> GET STRING2
(nil)

 

 

SET 명령어

명령어 구문 설명 시간복잡도
SET key value [NX | XX] [GET] [EX s | PX ms |
EXAT unix-s |
PXAT unix-ms | KEEPTTL]
데이터를 저장한다. key가 있으면 덮어쓴다. O(1)
SETRANGE key offset value 지정된 offset에서 시작하여 value의 전체 길이로 덮어쓴다. O(1)
MSET key value [key value ...] 여러 데이터를 한 번에 저장한다. O(N)
MSETNX key value [key value ...] 여러 데이터를 한 번에 저장한다.
이미 존재하는 키가 있을 경우, 아무 작업도 수행하지 않는다.
O(N)
APPEND key value 데이터가 존재하는 경우, value를 추가한다.
데이터가 존재하지 않을 경우 데이터를 저장한다 (=SET)
O(1)
  • NX(Not Exists) | XX(Exists): key가 없을 경우에만 저장 | 존재할 경우 업데이트
  • EX | PX: 초 단위, 밀리초 단위 만료 설정
  • EXAT | PXAT: 초 단위의 unixtimestamp 만료 설정, 밀리초 단위의 unixtimestamp 만료 설정
  • KEEPTTL: 업데이트 시 기존 TTL을 유지

 

GET 명령어

명령어 구문 설명 시간복잡도
GET key key에 해당하는 value를 조회한다. O(1)
GETRANGE key start end start ~ end의 idx에 해당하는 value를 조회한다. (substr) O(N)
MGET key [key ...] key에 해당하는 value들을 조회한다. O(N)
STRLEN key value의 길이를 조회한다. O(1)

 

 

INCR 명령어

64비트 Integer 혹은 Float를 증감시킨다.

명령어 구문 설명 시간복잡도
INCR key value를 1 증가시킨다. O(1)
INCRBY key increment increment만큼 value를 증가시킨다. O(1)
INCRBYFLOAT key increment increment만큼 부동소수점을 증감시킨다. O(1)
DECR key value를 1 감소시킨다. O(1)
DECRBY key decrement decrement만큼 value를 감소시킨다. O(1)

 

 

기타

명령어 구문 설명 시간복잡도
GETDEL key key에 해당하는 value를 조회하고, 삭제한다. O(1)
GETEX key [EX s | PX ms |
EXAT unix-s |
PXAT unix-ms | PERSIST]
key에 해당하는 value를 조회하고, 만료일을 설정할 수 있다. O(1)
GETSET key value key에 해당하는 value를 조회하고, value로 덮어씌운다. O(1)
LCS key1 key2 [LEN] [IDX]
[MINMATCHLEN min-match-len]
[WITHMATCHLEN]
O(key1*key2)의 LCS(Longest Common Subsequence)를 가져온다. O(N*M)

 

 

 

활용 예시

회원가입, 정보 변경 등에 사용되는 휴대폰 인증 등의 OTP 등에 사용할 수 있다.

 

 


 

 

List

LinkedList로 저장한다.

  • LinkedList의 특성에 맞게 데이터 추가와 삭제에 최적화되어있다. (O(1))
  • LPUSH + LTRIM으로 List의 크기를 항상 일정하게 고정시킬 수 있다.
  • Queue (LPUSH + RPOP) / Stack (LPUSH + LPOP) 으로 사용할 수 있다.
  • 위의 특성들로 메세지 브로커를 구축하는 데 적합한 구조이다.

 

 

 

 

SET(PUSH) 명령어

명령어 구문 설명 시간복잡도
LPUSH key element [element ...] 리스트의 0번째 index에 element를 저장한다.
리스트가 존재하지 않으면, 생성 후 저장한다.
O(1)
LPUSHX key element [element ...] 리스트가 존재할 경우에만 LPUSH를 수행한다. O(1)
LSET key N element 리스트의 N번째 index를 element로 덮어씌운다.
범위를 벗어난 index의 경우 Out Of Range 에러가 반환된다.
O(N)
LINSERT key <BEFORE | AFTER>
N element
첫 번째로 발견된 N 위치의 앞/뒤에 element를 저장한다. O(N)
RPUSH key element [element ...] 리스트의 마지막 index에 element를 저장한다.
리스트가 존재하지 않으면, 생성 후 저장한다.
O(1)
RPUSHX key element [element ...] 리스트가 존재할 경우에만 RPUSH를 수행한다. O(1)

 

 

GET 명령어

명령어 구문 설명 시간복잡도
LRAGNE key S stop start ~ end의 요소들을 조회한다. O(S + N)
LINDEX key index index의 value를 조회한다. O(N)
LLEN key 리스트의 LENGTH. O(1)

 

  • O(S+N)에서의 S는 작은 리스트의 경우 HEAD에서부터 시작 인덱스 까지의 거리, 큰 리스트의 경우 HEAD / TAIL (가까운 끝지점)으로부터의 거리이다. N은 |stop - start| 값이다.

 

 

POP 명령어

기본적으로 POP이란, GET과 DELETE가 결합된, 데이터를 가져오고 리스트에서 삭제한다고 이해하면 된다.

명령어 구문 설명 시간복잡도
LPOP key [N] 리스트의 0번째 index부터 데이터를 POP한다. O(N)
LMPOP N key [key ...]
<LEFT | RIGHT>
[COUNT M]
여러 개의 리스트에서 여러 개의 요소를 한 번에 POP
(N만큼의 리스트에서 LEFT|RIGHT의 방향에서 M만큼 POP)
O(N + M)
RPOP key [N] 리스트의 마지막 index부터 데이터를 POP한다. O(N)

 

 

 

BLOCK 명령어

명령어 구문 설명 시간복잡도
BLPOP key [key ...] timeout 리스트의 0번째 index를 POP.
리스트에 데이터가 없다면 timeout만큼 BLOCKING된다.
O(N)
BLMOVE source destination
<LEFT | RIGHT>
<LEFT | RIGHT>
 timeout
LMOVE + BLOCKING O(1)
BLMPOP timeout N key [key ...]
<LEFT | RIGHT>
[COUNT M]
timeout만큼 BLOCKING되는 LMPOP O(N + M)
BRPOP key [key ...] timeout 리스트의 마지막 index를 POP.
리스트에 데이터가 없다면 timeout만큼 BLOCKING된다.
O(N)
BRPOPLPUSH source destination
timeout
BRPOP + LPUSH
source 리스트에서 데이터를 꺼내서 destination의 왼쪽에 넣는다.
O(1)

 

  • 리스트에 데이터가 들어올 때 까지 대기 할 수 있다는 특성을 이용하여 Task Queue, 실시간 이벤트 처리, 로드 밸런싱 등으로 활용된다.

 

 

기타 명령어

명령어 구문 설명 시간복잡도
LMOVE source destination
<LEFT | RIGHT>
<LEFT | RIGHT>
source의 POP위치, destination의 SET 위치를 결정하여
source 리스트에서 destination 리스트로 데이터 이동.
O(1)
LPOS key element [RANK rank] [COUNT num-matches] [MAXLEN len] 리스트 내에서 rank번째로 등장하는 element를
최대 count만큼 len 내에서 찾는다.
O(N)
LTRIM key start stop start ~ stop 범위에 속하지 않는 데이터를 삭제한다. O(N)

 

127.0.0.1:6379> RPUSH LPOS A B C B E C B D
(integer) 8
127.0.0.1:6379> LRANGE LPOS 0 -1
1) "A"
2) "B"
3) "C"
4) "B"
5) "E"
6) "C"
7) "B"
8) "D"
127.0.0.1:6379> LPOS LPOS B
(integer) 1
127.0.0.1:6379> LPOS LPOS B RANK 2
(integer) 3
127.0.0.1:6379> LPOS LPOS B RANK 2 COUNT 2
1) (integer) 3
2) (integer) 6
127.0.0.1:6379> LPOS LPOS B RANK 3 COUNT 2
1) (integer) 6
127.0.0.1:6379> LPOS LPOS B MAXLEN 1
(nil)
127.0.0.1:6379> LPOS LPOS B COUNT 2 MAXLEN 4
1) (integer) 1
2) (integer) 3

 

 


 

 

Set

우리가 아는 그 Set 자료구조이다.

  • 집합 연산(교집합, 합집합, 차집합 등)을 수행할 수 있다.
  • 주로 관계를 나타낼 때 사용한다.
  • Set의 저장 가능한 최대 원소 개수는 2^32-1개이다.

 

 

SET 명령어

명령어 구문 설명 시간복잡도
SADD key member [member ...] 데이터를 저장한다. 이미 존재하는 데이터는 무시된다. O(1)
SMOVE source destination
member
source에서 destination으로 데이터를 옮긴다.
이미 존재한다면 추가되지 않는다.
O(1)

 

 

GET 명령어

명령어 구문 설명 시간복잡도
SMEMBERS key SET 전체를 조회한다. O(N)
SISMEMBER key member SET 내부에 member가 있다면 1, 없다면 0을 반환한다. O(1)
SMISMEMBER key member
[member ...]
SISMEMBER의 다중 검사 O(N)
SRANDMEMBER key [N] SET의 임의의 데이터 N개를 반환한다. O(N)
SCARD key 카디널리티를 반환한다.
(SET은 중복이 불가능하므로 SIZE와 동일하다.)
O(1)
SSCAN key cursor
[MATCH pattern]
[COUNT count]
SET 내부의 일정 단위 개수만큼 데이터를 가져온다.  

 

    • SRANDMEMBER의 N
      • 양수일 경우 반복되는 요소는 없으며, SET의 SIZE보다 클 경우 SET 전체를 반환한다.
      • 음수일 경우 반복되는 요소가 존재하며, SET의 SIZE보다 크더라도 N개 만큼의 요소가 완전히 무작위로 반환된다.
    • SSCAN
      • 작은 데이터셋일 경우 COUNT와 무관하게 전체 데이터를 가져올 수 있다.
      • 패턴은 item*처럼 패턴을 사용할 수 있다.

 

 

Redis의 SCAN 명령어(SSCAN, ZSCAN, HSCAN)는 모두 Hash Bucket 기반으로 동작한다.

명령어의 COUNT 옵션은 버킷 개수를 조정하는 힌트 역할을 하며 정확한 개수를 보장하지는 않는다.
데이터셋이 작다면 위 사진처럼 COUNT값을 조정해도 전체 데이터를 한 번에 반환할 가능성이 크다.
이러한 특징으로 대용량 데이터를 순회하는 데 유리하다.

 

 

POP 명령어

명령어 구문 설명 시간복잡도
SPOP key [N] SET의 임의의 데이터 N개를 POP O(N)

 

REMOVE 명령어

명령어 구문 설명 시간복잡도
SREM key member [member ...] SET에서 지정한 요소들을 삭제한다. O(N)

 

 

집합 명령어

명령어 구문 설명 시간복잡도
SUNION key [key ...] SET들의 합집합 O(N)
SUNIONSTORE destination key [key ...] 합집합을 destination SET에 저장 O(N)
SINTER key [key ...] SET들의 교집합 O(N * M)
SINTERSTORE destination key [key ...] 교집합을 destination SET에 저장 O(N * M)
SINTERCARD M key [key ...]
[LIMIT limit]
교집합의 카디널리티(개수)를 반환 O(N * M)
SDIFF key [key ...] SET들의 차집합 O(N)
SDIFFSTORE destination key [key ...] 차집합을 destination SET에 저장 O(N)

 

 

 

활용 예시

좋아요 기능을 Set으로 간단하게 구현할 수 있다. 좋아요 기능처럼 자주 I/O가 발생하고, UNIQUE를 동시에 제어해야하는 상황 등에 알맞게 사용할 수 있다.

 

 


 

 

SortedSet (ZSet)

정렬을 지원하는 SET으로 SET에 저장된 score를 기준으로 자동 정렬된다.

  • Skip List 구조로 정렬된 상태를 유지하며, 탐색에는 O(logN)의 시간이 걸림.
  • 시간 기반 정렬과 범위 삭제(Sliding Window)에 최적화되어있다.
  • 리더보드(랭킹)나 우선순위 큐(Priority Queue)를 쉽게 구현할 수 있다.
  • ZREMRANGEBYSCORE, ZCOUNT를 활용하여 Rate Limiter을 쉽게 구현할 수 있다.

 

 

 

특정 API에 Rate Limit을 설정하고 싶다고 해보자. 내가 설정한 정책은 1분에 5번의 요청만 가능하도록 설정해두었다. 이럴 때 아래처럼 URI:User로 SortedSet을 생성하여, 요청에 대한 시간값을 score로 담는다. 요청을 수행하기 전에 ZCOUNT로 60초 이내에 해당 유저가 몇 번 요청을 하였는지 확인하고 5번을 초과하였다면 429를 반환할 수 있다. 60초가 지났다면 ZREMRANGEBYSCORE를 통해 범위 밖의 데이터들을 초기화해줄 수 있다.

 

 

 

SET 명령어

명령어 구문 설명 시간복잡도
ZADD key [NX | XX] [GT | LT]
[CH] [INCR] score member [score member ...]
score을 가진 요소를 추가한다. O(logN)
  • NX(Not Exists) | XX(Exists): 새로운 요소만 추가 | 기존 요소를 업데이트
  • LT(Less Than) | GT(Greater Than): 요소가 이미 있을 때, 현재 score보다 낮거나 높은 경우에만 추가한다.
  • CH(Changed): 변경된 요소의 개수를 반환한다. (설정하지 않으면 변경되었는지 보이지 않는다.)
  • INCR(Increase): 요소가 있다면 score만큼 누적시킨다. (없다면 요소를 추가)

 

GET 명령어

ZRANGEBYLEX, ZRANGEBYSCORE, ZREVERANGE, ZREVERANGEBYSCORE, ZREVERANGEBYLEX는 6.2.0버전부터 deprecated되었고, ZRANGE에 통합하여 사용이 가능하다.

명령어 구문 설명 시간복잡도
ZRANGE key start stop
[BYSCORE | BYLEX]
[REV] [LIMIT offset count]
[WITHSCORES]
start ~ stop의 인덱스 요소를 반환 O(logN + M)
ZCARD key 카디널리티(개수)를 반환 O(1)
ZCOUNT key min max score 범위의 개수를 반환
'(' 를 사용해서 초과, 미만 표현 가능
O(logN)
ZLEXCOUNT key min max member로 범위의 개수를 반환
'['를 붙여 사용하며, '(' 를 사용해서 초과, 미만 표현 가능
O(logN)
ZSCAN key cursor
[MATCH pattern]
[COUNT count]
SET의 SSCAN과 동일 O(1)
ZSCORE key member member의 score를 반환 O(1)
ZRANK key member
[WITHSCORE]
member의 현재 rank를 반환 O(logN)
ZREVRANK key member
[WITHSCORE]
member의 현재 rank를 DESC로 반환 O(logN)
  • BYSCORE: score기준으로 범위 검색 (ZRANGEBYSCORE)
  • BYLEX: 알파벳 정렬 범위 검색 (ZRANGEBYLEX)
  • REV(Reverse): 내림차순 정렬 (ZREVRANGE)
  • LIMIT: 페이징처리
  • WITHSCORE: score도 함께 반환 (RANK)

 

 

POP 명령어

명령어 구문 설명 시간복잡도
ZPOPMIN key [count] count개 만큼 score가 낮은(rank가 높은) 요소를 POP O(logN * M)
ZPOPMAX key [count] count개 만큼 score가 높은(rank가 낮은) 요소를 POP O(logN * M)
ZMPOP K key [key ...]
<MIN | MAX>
[COUNT count]
여러 개의 SortedSet에서 ZPOPMIN | ZPOPMAX O(K) +
O(logN * M)
BZPOPMIN key [key ...] timeout BLOCKING이 추가된Z POPMIN
비어있으면 새 데이터가 추가될 때까지 timeout만큼
대기 후 POP
O(logN)
BZPOPMAX key [key ...] timeout BLOCKING이 추가된 ZPOPMAX
비어있으면 새 데이터가 추가될 때까지 timeout만큼
대기 후 POP
O(logN)
BZMPOP timeout K key [key ...]
<MIN | MAX>
[COUNT count]
여러 개의 SortedSet에서 BZPOPMIN | BZPOPMAX O(K) +
O(logN * M)

 

 

REMOVE 명령어

명령어 구문 설명 시간복잡도
ZREM key member
[member ...]
member를 REMOVE O(logN * M)
ZREMRANGEBYRANK key start stop rank의 start ~ stop 범위를 REMOVE O(logN + M)
ZREMRANGEBYSCORE key min max score의 min ~ max 범위를 REMOVE
'(' 를 사용해서 초과, 미만 표현 가능
O(logN + M)
ZREMRANGEBYLEX key min max member의 min ~ max 범위를 REMOVE
'['를 붙여 사용하며, '(' 를 사용해서 초과, 미만 표현 가능
O(logN + M)

 

 

INCR 명령어

명령어 구문 설명 시간복잡도
ZINCRBY key increment
member
member의 score를 increment만큼 증가 O(logN)

 

 

집합 명령어

명령어 구문 설명 시간복잡도
ZUNION numkeys key [key ...]
[WEIGHTS weight [weight ...]]
[AGGREGATE <SUM | MIN | MAX>]
[WITHSCORES]
여러 개의 SortedSet의 합집합 O(N) +
O(MlogM)
ZUNIONSTORE destination numkeys key [key ...]
[WEIGHTS weight  [weight ...]]
[AGGREGATE <SUM | MIN | MAX>]
여러 개의 SortedSet의 합집합을 새로운 ZSET에 저장 O(N) +
O(MlogM)
  • WEIGHTS: 가중치 (key들에 대한 곱연산 적용)
  • AGGREGATE: 중복 요소를 합치는 방식 (SUM, MIN, MAX)
  • destination: 새로 생성할 ZSET key

 

 

활용 예시

SortedSet을 활용하여 트래픽 제어를 통한 안정성 확보 뿐 아니라 위처럼 과금별 Rate Limits를 제어하는 등의 비즈니스 모델도 수립할 수 있다. 이와 같은 Window Rate Limiter을 SortedSet을 통해 정책 시간 별 카디널리티가 초과되었다면 429를 반환하여 Rate Limiter을 구현할 수 있다.

Sendbird의 과금 별 RateLimites

 


 

 

Hash

Field - Value형태로 Map / Dictionary 형태와 유사하다.

 

 

SET 명령어

HMSET은 4.0.0버전부터 deprecated되었고, HSET을 사용하여 Multi-Set이 가능하다.

명령어 구문 설명 시간복잡도
HSET key field value
[field value ...]
field와 value를 SET O(N)
HSETNX key field value field가 없을 경우 value를 SET O(1)

 

 

GET 명령어

명령어 구문 설명 시간복잡도
HGET key field field의 value를 반환 O(1)
HMGET key field [field ...] field들의 values 반환 O(N)
HGETALL key Hash의 field, value를 전부 반환 O(N)
HKEYS key Hash의 fields를 반환 O(N)
HVALS key Hash의 values를 반환 O(N)
HLEN key fields 개수를 반환 O(1)
HSTRLEN key field field의 value LENGTH를 반환 O(1)
HSCAN key cursor
[MATCH pattern]
[COUNT count]
[NOVALUES]
Hash 내부의 일정 개수만큼 field / value를 반환
NOVALUES를 사용하면 field만 반환
O(N)
HEXISTS key field field가 있다면 1, 없다면 0을 반환 O(1)

 

 

REMOVE 명령어

명령어 구문 설명 시간복잡도
HDEL key field [field ...] fields REMOVE O(N)

 

 

INCR 명령어

명령어 구문 설명 시간복잡도
HINCRBY key field increment Integer을 increment만큼 증가시킨다. O(1)
HINCRBYFLOAT key field increment Float을 increment만큼 증가시킨다. O(1)

 

 


 

 

Streams

로그 데이터를 처리하기 위해 5.0버전에서 도입된 데이터 타입이다. Append-Only, 즉 데이터의 추가만 가능하다. Redis의 영속성을 지원하는 AOF(Append-Only File)도 Append-Only 로그의 일종이다. 

  • Stream에 추가되는 메세지(이벤트)는 UUID를 가지며 해당 field를 읽을 때 O(1)의 시간 복잡도를 가진다.
  • Consumer Group이 포함되어있다. Consumer Group을 이용하여 분산시스템의 다수의 컨슈머의 메세지 처리 과정에서 중복 문제를 쉽게 해결할 수 있다.
  • Kafka등의 메세지 스트리밍 시스템과 유사하게 동작한다.
  • 주로 이벤트 로그 저장, 실시간 데이터 처리, Pub/Sub 메세지 큐 등의 용도로 활용된다.
  • 데이터 순서 보장이 필요한 경우 적합하다.

 

 

 

SET 명령어

명령어 구문 설명 시간복잡도
XADD key [NOMKSTREAM]
[<MAXLEN | MINID> [= | ~] threshold
[LIMIT count]]
<* | id> field value
[field value ...]
Integer을 increment만큼 증가시킨다. O(N)
  • NOMKSTREAM: 스트림이 존재하지 않으면 생성하지 않음
  • MAXLEN | MINID: 최대 메세지 개수 제한 (FIFO) | 특정 ID보다 작은 메세지를 삭제
    • MINID: 특정 ID보다 작은 메세지를 삭제
    • = | ~: Strict | Approximate
    • threshold: 최대 개수 | 최소 ID를 반환
    • LIMIT count: 한 번에 삭제할 최대 개수
  • * | id: 자동 ID 생성(unix timestamp) | 사용자가 직접 ID를 지정 (timestamp - sequence)

 

 

GET 명령어

XREAD를 사용하여 실시간 데이터 처리가 용이하다.

명령어 구문 설명 시간복잡도
XRANGE key start end
[COUNT count]
start ~ end의 스트림 반환 O(N)
XREVRANGE key start end
[COUNT count]
start ~ end의 스트림 역순 반환 O(N)
XREAD [COUNT count]
[BLOCK milliseconds]
STREAMS
key [key ...] id [id ...]
여러 키의 스트림에서 특정 id 이후의 메세지를 동시에 반환
BLOCKING을 활용한 비동기처리 가능.
 
XLEN key 스트림 내 총 메세지 개수 조회 O(1)

 

 

REMOVE 명령어

명령어 구문 설명 시간복잡도
XDEL key id [id ...] 스트림 삭제 O(1)
XTRIM key
<MAXLEN | MINID> [= | ~] threshold
[LIMIT count]
조건에 따라 스트림의 메세지를 삭제 O(N)

 

 

컨슈머 그룹 명령어

XREADGROUP 컨슈머 그룹을 사용해 메세지를 여러 개의 컨슈머에 분배하는 명령어이다. 한 메세지는 한 컨슈머에게만 전달되며, 그룹 내부에서는 PEL(Pending Entries List)을 관리하여 메세지 상태를 추적할 수 있으며 비동기 방식(BLOCK)으로 사용이 가능하다.

 

PEL(Pending Entries List)

컨슈머 그룹이 메세지를 읽을 때 Redis에서는 PEL에 메세지를 저장하고 XACK를 호출해야 메세지가 PEL에서 제거된다. PEL에 남아있는 메세지는 처리 중이거나 실패한 메세지로 간주된다. 하지만 NOACK 옵션을 통해 자동으로 Acked(확인)된 것으로 간주하여 PEL에 저장하지 않을 수도 있다.

 

명령어 구문 설명 시간복잡도
XACK key group id [id ...] 컨슈머에 메세지 처리 완료 알림 처리.
컨슈머 그룹의 PEL에서 메세지 삭제.
O(1)
XCLAME key group consumer
min-idle-time id [id ...] [IDLE ms]
[TIME unix-mils]
[RETRYCOUNT count] [FORCE] [JUSTID]
[LASTID lastid]
미처리 메세지를 다른 컨슈머에 재할당 O(logN)
XAUTOCLAME key group consumer
min-idle-time start
[COUNT count]
[JUSTID]
오래된 미처리 메세지를 자동으로 재할당
XCLAME + XPENDING
O(1)
XREADGROUP GROUP group
consumer
[COUNT count]
[BLOCK milliseconds]
[NOACK] STREAMS
key [key ...] id [id ...]
컨슈머 그룹을 활용한 메세지 분배 후 POP O(N)
XPENDING key group
[[IDLE min-idle-time]
start end count
[consumer]]
컨슈머 그룹의 미처리 메세지 조회 O(N)
XGROUP CREATE
key group <id | $>
[MKSTREAM]
[ENTRIESREAD
entries-read]
새로운 컨슈머 그룹 생성 O(1)
CREATECONSUMER
key group consumer
특정 컨슈머 그룹에 새로운 컨슈머 추가 O(1)
SETID
key group <id | $>
[ENTRIESREAD
entries-read]
컨슈머 그룹의 마지막 메시지 ID 변경 O(1)
DELCONSUMER
key group consumer
컨슈머 그룹에서 특정 컨슈머 제거  
DESTROY key group 컨슈머 그룹 제거 O(N)
  • min-idle-time: 최소 대기 시간 (이 시간 동안 PEL에 있던 메세지만 가져옴)
  • FORCE: 기존 컨슈머가 존재하지 않아도 강제로 메세지를 가져옴
  • JUSTID: 메세지 ID만 반환

 

 

기타 명령어

명령어 구문 설명 시간복잡도
XINFO GROUPS key 컨슈머 그룹 메타데이터 반환 O(1)
STREAM key
[FULL [COUNT count]]
스트림 메타데이터 반환 O(1)

 

 


 

 

Geospatials

좌표를 저장하고 검색할 수 있는 데이터 타입으로 거리 계산, 범위 탐색 등을 지원한다. SortedSet에 저장되기 때문에, ZSET 명령어를 통한 조회, 삭제 등이 가능하다.

 

 

SET 명령어

명령어 구문 설명 시간복잡도
GEOADD key [NX | XX] [CH]
longitude latitude
member
[longitude latitude
member ...]
지리 정보(경도, 위도, 이름)를 추가한다. O(logN)

 

 

GET 명령어

GEORADIUS 명령어들은 6.2.0버전부터 deprecated되었다. GEOSEARCH에 통합하여 사용이 가능하다.

명령어 구문 설명 시간복잡도
GEOPOS key [member [member ...]] 특정 위치의 좌표(Lat, Lon) 조회 O(1)
GEODIST key member1 member2
[M | KM | FT | MI]
두 지점 간의 거리 계산 O(1)
GEOHASH key [member [member ...]] 특정 위치의 Geohash 값을 반환 O(1)
GEOSEARCH key
<FROMMEMBER member | FROMLONLAT longitude latitude>
<BYRADIUS radius
<M | KM | FT | MI> |
BYBOX width height <M | KM |  FT | MI>> [ASC | DESC]
[COUNT count [ANY]]
[WITHCOORD] [WITHDIST]
[WITHHASH]
지정된 범위 내 위치 검색 O(logN + M)
GEOSEARCHSTORE destination source
<FROMMEMBER member |
FROMLONLAT longitude latitude>
<BYRADIUS radius
<M | KM | FT | MI> |
BYBOX width height <M | KM | FT | MI>> [ASC | DESC] [COUNT count [ANY]]
[STOREDIST]
위치 검색 후 결과를 새로운 GEO에 저장 O(logN + M)
  • FROMMEMBER: 특정 멤버 기준으로 검색
  • FROMLONLAT: 특정 좌표에서 검색
  • BYRADIUS: 특정 반경 내에서 검색
  • BYBOX: width * height 크기의 BOX 내에서 검색
  • COUNT: 가져올 결과 개수 (ANY를 사용 시 성능 최적화)
  • WITHCORD: 위치 좌표를 함께 반환
  • WITHDIST: 거리를 함께 반환
  • WITHHASH: GEOHASH를 함께 반환

 

Geospatial을 이용해 홍대 ~ 강남역 까지의 직선거리를 구해보자.

GEOADD TEST 127.027583 37.497928 GANGNAM
(integer) 1
127.0.0.1:6379> GEODIST TEST GANGNAM HONGDAE KM
"11.2561"

 

 

추가로, 반경과 x*y 박스의 범위검색 또한 가능하다.

 

 

 


 

 

Bitmaps

실제의 데이터 타입은 아니며, String 타입을 기반으로 비트 단위 연산을 지원한다. 비트 단위로 값을 설정하고 읽을 수 있기 때문에, 매우 작은 메모리 공간으로도 데이터를 표현할 수 있으며, 효율적인 공간 활용 및 빠른 연산이 특징이다. 사용자 로그인 여부, 출석 체크, 중복 체크 등의 비트로도 수행할 수 있는 연산에 활용하면 효율적이다.

 

# 로그인 기록으로 사용
SETBIT user:1001 0 1  # 첫날 로그인
SETBIT user:1001 1 0  # 둘째 날 미로그인
SETBIT user:1001 2 1  # 셋째 날 로그인

 

 

명령어

명령어 구문 설명 시간복잡도
SETBIT key offset value offset에 value를 저장 O(1)
GETBIT key offset offset의 비트 값을 반환 O(1)
BITCOUNT key [start end
[BYTE | BIT]]
1로 설정된 비트 개수를 반환 O(N)
BITOP <AND | OR |
XOR | NOT>
destkey key [key ...]
비트 연산 결과를 destkey의 새로운 Bitmaps에 저장 O(N)
BITPOS key bit [start [end
[BYTE | BIT]]]
지정한 비트가 처음으로 등장하는 위치를 반환 O(N)
BITFIELD key [subcommand
[argument] ...]
더 작은 크기의 정수 필드 단위로 읽고 수정하는 명령어. O(1)

 

 

 

활용 예시

온라인 상태 표시 등의 수시로 변경되는 값에 사용한다.

 

 

 


 

 

 

HyperLogLogs

집합의 카디널리티를 추정할 수 있는 확률형 자료구조이다.

  • member의 값을 해싱하여 버킷에 해시 값에 맞게 표시한다.
  • 동일한 아이템이 추가 될 경우 카디널리티를 일정하게 계산할 수 있다.
  • Set과 유사하지만 실제 값을 저장하지 않기 때문에 매우 작은 저장 공간(12kb)을 사용한다.
  • 실제 값을 저장하지 않기 때문에 저장된 데이터를 다시 확인할 수 없다.
  • 확률적인 계산식을 사용하기 때문에 결과값이 실제와 일정 부분 오차가 발생할 수 있다.
  • 정확성을 일부 포기하는 대신 저장 공간을 효율적으로 사용할 수 있으며, 평균 에러율은 0.81%이다.

 

명령어

명령어 구문 설명 시간복잡도
PFADD key [element [element ...]] 요소를 저장 O(1)
PFCOUNT key [key ...] 저장된 요소의 개수(카디널리티)를 반환 O(1)
PFMERGE destkey [sourcekey
[sourcekey ...]]
로그들을 destkey로 병합 O(N)

 

 

 

활용 예시

일일 방문자 수를 Hyerploglog로 구현해보자. 유의할 점은 100% 정확한 수치가 아닐 수도 있다는 점이다.

 

 

 

 


 

 

 

 

참조

https://redis.io/docs/latest/develop/data-types

https://redis.io/docs/latest/commands

728x90
300x250
mag1c

mag1c

2년차 주니어 개발자.

[MySQL] Lost Update와 Write Skew

Tech/Database 2024. 12. 12. 11:07
728x90
728x90

 

서론

트랜잭션의 격리 수준 포스팅에서 다루지 않았던 이상현상 중 Lost Update와 Write Skew같은 일관되지 않은 쓰기 결과를 반환하는 데이터 부정합 문제가 있다. PostegraSQL에서는 격리 수준을 REPEATABLE READ로 설정하는 것만으로도 쓰기 결과를 올바르게 보장할 수 있다. 하지만 MySQL의 MVCC(Multi Version Concurrency Control)일관된 읽기(Consistence Read)를 지원하지만 위와 같은 데이터 업데이트의 부정합 문제를 REPEATABLE READ의 격리 수준 만으로는 해결할 수 없다.

 

 

아래 그림은 MySQL에서 REPEATABLE READ 격리 수준을 사용했을 때 Lost Update가 발생하는 상황이다. 고객 A가 10,000원을 사용한 뒤 관리자가 곧바로 20,000원을 환불했지만 결과적으로 고객의 잔액은 30,000원 이 되어버렸다. 이는 두 트랜잭션이 같은 데이터를 읽고 각각의 연산 결과를 덮어버린 탓에 발생한 문제다.

 

 

 

 

 

 

 

격리 수준을 SERIALIZABLE로 설정하면 모든 데이터 부정합 문제를 해결할 수 있지만 그만큼 동시성이 보장되지 않는다. 결제, 재고, 선착순 이벤트 등과 같은 상황에서 특히 데이터 정합성이 필수적이다. 또한 그런 기능은 생각보다 보편화되어 널리 사용되고 있다.

 

위처럼, Lost Update와 Write Skew는 동시에 실행된 트랜잭션에서 같은 데이터를 조회하면서 데이터에 일관성이 깨져버린다. 그렇다면 MySQL에서 동시성을 보장하면서 Lost Update, Write Skew같은 부정합 문제도 해결하기 위해서 어떤 방법을 사용해야할까?

 

 

 

 

 

Lost Update

Lost Update란 위 예시처럼 두 트랜잭션이 동일한 레코드를 읽고 각각 수정한 결과를 커밋할 때 발생하는 문제이다. 첫 번째 트랜잭션의 업데이트가 두 번째 트랜잭션의 결과로 덮여버리며, 데이터의 정합성이 깨지게 된다.

 

 

it('REPEATABLE READ:: LOST UPDATED.', async () => {
    runnerA = dataSource.createQueryRunner();
    runnerB = dataSource.createQueryRunner();

    // 트랜잭션 A 시작
    await runnerA.startTransaction('REPEATABLE READ');
    const repoA = runnerA.manager.getRepository(ICashEntity);

    // 트랜잭션 B 시작
    await runnerB.startTransaction('REPEATABLE READ');
    const repoB = runnerB.manager.getRepository(ICashEntity);

    // 업데이트를 위해 조회
    const readA = await repoA.createQueryBuilder().where('no = :no', { no: 1 }).getOneOrFail();
    const readB = await repoB.createQueryBuilder().where('no = :no', { no: 1 }).getOneOrFail();
    
    //초기 값 테스트
    expect(readA.icash).toBe(10000);
    expect(readB.icash).toBe(10000);

    // 캐시 사용
    await repoA.update({ no: 1 }, { icash: readA.icash - 10000 });
    await runnerA.commitTransaction();

    // 캐시 적립
    await repoB.update({ no: 1 }, { icash: readB.icash + 20000 });
    await runnerB.commitTransaction();

    const result = await repoA.findOneOrFail({ where: { no: 1 } });
    expect(result.icash).toBe(20000);
});

 

 

 

 

 

위 그림을 테스트 코드로 작성했다. 거의 비슷한 시간에 고객이 캐시를 사용하면서 관리자가 캐시를 환불해주었다. 우리는 캐시 사용과 환불을 통해 2만원이라는 결과를 기대하지만, 고객의 캐시 사용에 대한 커밋은 이후의 트랜잭션에 의해 덮어졌다. 이를 개선해서 올바른 업데이트를 반영시켜보자.

 

 

 

 

 

 

Optimistic Lock(낙관적 잠금)

Optimisitic Lock은 데이터를 동시에 수정하지 않을 것이라고 가정하는 방식이다. 예를 들어, 회원 정보 수정처럼 동시에 수정될 가능성이 낮은 작업에서 효과적이다. 데이터에 Lock을 걸지 않기 때문에 동시 요청에 대해 처리 성능이 뛰어나다. 대신 충돌이 발생하면 이를 감지하고 롤백하거나 재시도해야한다.

 

it.only('REPEATABLE READ:: LOST UPDATED.', async () => {
    runnerA = dataSource.createQueryRunner();
    runnerB = dataSource.createQueryRunner();

    // 트랜잭션 A 시작
    await runnerA.startTransaction('REPEATABLE READ');
    const repoA = runnerA.manager.getRepository(ICashEntity);

    // 트랜잭션 B 시작
    await runnerB.startTransaction('REPEATABLE READ');
    const repoB = runnerB.manager.getRepository(ICashEntity);

    const readA = await repoA.createQueryBuilder().where('no = :no', { no: 1 }).getOneOrFail();
    const readB = await repoB.createQueryBuilder().where('no = :no', { no: 1 }).getOneOrFail();

    expect(readA.icash).toBe(10000);
    expect(readB.icash).toBe(10000);

    const updateResultA = await repoA
        .createQueryBuilder()
        .update(ICashEntity)
        .set({ icash: readA.icash - 10000, version: readA.version + 1 })
        .where('no = :no', { no: 1 })
        .andWhere('version = :version', { version: readA.version })
        .execute();

    if (updateResultA.affected === 0) {
        throw new Error('Optimistic Lock Conflict: Transaction A failed to update');
    }

    await runnerA.commitTransaction();

    const updateResultB = await repoB
        .createQueryBuilder()
        .update(ICashEntity)
        .set({ icash: readB.icash + 20000, version: readB.version + 1 })
        .where('no = :no', { no: 1 })
        .andWhere('version = :version', { version: readB.version })
        .execute();

    await runnerB.commitTransaction();

    const result = await repoA.findOneOrFail({ where: { no: 1 } });

    // Optimistic Lock Conflict: Transaction B failed to update
    expect(result.icash).toBe(0);
    expect(updateResultB.affected).toBe(0);
});
@Entity('icash')
export class ICashEntity {
    @VersionColumn()
    version!: number;
}

 

 

version 컬럼을 활용해 Optimistic Lock을 구현했다. Optimisitic Lock을 사용하면 version을 통해 데이터의 상태 변화를 감지하고, 트랜잭션 충돌을 방지할 수 있다. 트랜잭션 A가 업데이트하면서 version을 증가시키면, 트랜잭션 B는 자신의 version 조건이 충족되지 않아 업데이트에 실패한다.

 

하지만, 이 방식 테이블에 추가 컬럼이 필요하고, 충돌 시 롤백이나 재시도 로직이 필요하다.

위의 테스트 코드에서처럼, B에서 실패 후처리를 하지 않았기 때문에 캐시는 환불되지 않았다.

 

따라서 데이터 충돌 가능성이 높은 경우에는 배타적 잠금(Pessimistic Lock)을 활용하는 것이 더 적합할 수 있다.

 

 

 

 

 

 

Pessimistic Lock(비관적 잠금)

SERIALIZABLE은 쓰기 상황에서 동시 요청에 대해 데이터 정합성이 깨지지 않도록 보장한다. 이는 SERIALIZABLE 격리 수준의 특징으로  MySQL에서는 SERIALIZABLE 격리 수준에서 데이터를 읽을 때 항상 Shared Lock(읽기 잠금)을 건다.

 

 

 

 

 

 

비관적 잠금은 데이터가 동시에 수정될 것이라고 가정하고, 데이터를 읽는 시점에 Lock을 걸어 트랜잭션이 완료되면 반납하는 방식이다. 이를 통해 동시성 이슈를 방지하면서도 데이터 무결성을 유지할 수 있다. 이러한 비관적 잠금에는 크게 공유 잠금과 배타 잠금이 있다.

  • 공유 잠금(Shared Lock - S Lock / 읽기 잠금) - 다른 트랜잭션의 읽기는 허용
  • 배타적 잠금(Exclusive Lock - X Lock / 쓰기 잠금) - 다른 트랜잭션의 읽기 쓰기 모두 차단

 

 

아래 코드는 REPEATABLE READ 격리 수준에서 비관적 잠금을 사용하는 실제 비즈니스 로직의 예시를 단위로 분리하고, 데이터를 읽으면서 잠금을 걸었다. 이를 통해 동시 업데이트 시에도 성공적으로 데이터를 보호할 수 있다.

@Injectable()
export class ICashService {
    constructor(private readonly icashRepo: ICashRepository) {}

    async getICash(userNo: number) {
        return this.icashRepo.findICashByUserNo(userNo);
    }

    @Transactional({ isolationLevel: IsolationLevel.REPEATABLE_READ })
    async useICash(userNo: number, amount: number) {
        const icash = await this.icashRepo.findOneOrFail({ where: { userNo }, lock: { mode: 'pessimistic_write' } });
        await this.icashRepo.update(icash.no, { icash: icash.icash - amount });
    }

    @Transactional({ isolationLevel: IsolationLevel.REPEATABLE_READ })
    async refundICash(userNo: number, amount: number) {
        const icash = await this.icashRepo.findOneOrFail({ where: { userNo }, lock: { mode: 'pessimistic_write' } });
        await this.icashRepo.update(icash.no, { icash: icash.icash + amount });
    }
}


//test code
it.only('REPEATABLE READ:: LOST UPDATED WITH BUSINESS LOGIC', async () => {
    await Promise.all([iCashService.useICash(1, 10000), iCashService.refundICash(1, 20000)]);
    const result = await iCashService.getICash(1);

    expect(result!.icash).toBe(20000);
});

 

 

 

하지만 이런 방식에도 문제점이 없지는 않다. 데이터를 읽는 시점에 Lock을 획득했기 때문에 동시성을 떨어뜨릴 수 있다. 두 트랜잭션 모두 동시에 Lock을 획득했다고 가정해보자. 결국 쓰기 작업에서 타임아웃 등이 발생할 수도 있다. 그렇기 때문에 대기 시간을 관리하여야 한다. wait(nowait) 옵션을 활용하여 락 대기 시간을 조절하지 않으면, 락 대기 시간이 길어질 경우 시스템 성능에 영향을 미칠 수 있다.

 

 

 

 

 

 

Write Skew

Write Skew은 두 트랜잭션이 동일한 데이터를 읽지만 커밋된 데이터가 일관적이지 않은 것을 말한다.

 

it('REPEATABLE READ:: Write Skew - Event Registration', async () => {
    // 초기 데이터: 현재 99명 등록
    await eventRepo.save({
        eventId: 1,
        maxParticipants: 100,
        currentParticipants: 99
    });

    runnerA = dataSource.createQueryRunner();
    runnerB = dataSource.createQueryRunner();

    await runnerA.startTransaction('REPEATABLE READ');
    await runnerB.startTransaction('REPEATABLE READ');

    // 두 트랜잭션이 동시에 현재 참가자 수를 확인
    const eventA = await runnerA.manager.findOne(EventEntity, { 
        where: { eventId: 1 } 
    }); // 99명 읽음
    const eventB = await runnerB.manager.findOne(EventEntity, { 
        where: { eventId: 1 } 
    }); // 99명 읽음

    // 각 트랜잭션이 독립적으로 참가 가능 여부 확인
    if (eventA.currentParticipants < eventA.maxParticipants) {
        // 참가자 A 등록
        await runnerA.manager.insert(ParticipantEntity, { 
            eventId: 1, 
            userId: 'userA' 
        });
        await runnerA.manager.update(EventEntity, 
            { eventId: 1 }, 
            { currentParticipants: eventA.currentParticipants + 1 }
        );
    }

    if (eventB.currentParticipants < eventB.maxParticipants) {
        // 참가자 B 등록
        await runnerB.manager.insert(ParticipantEntity, { 
            eventId: 1, 
            userId: 'userB' 
        });
        await runnerB.manager.update(EventEntity, 
            { eventId: 1 }, 
            { currentParticipants: eventB.currentParticipants + 1 }
        );
    }

    await runnerA.commitTransaction();
    await runnerB.commitTransaction();

    const result = await eventRepo.findOne({ 
        where: { eventId: 1 } 
    });
    
    // Write Skew: 101명이 등록됨 (제한 100명 초과)
    expect(result.currentParticipants).toBe(101);
});

 

 

위와 같은 선착순 이벤트 시나리오에서, 100명의 정원이 있다. 두 트랜잭션에서 99라는 같은 데이터를 읽었고, 두 트랜잭션은 아직 신청이 가능하다고 판단했다. 그래서 각자 다른 레코드를 추가하여 100명 제한이 초과되어버렸다. 이는 비즈니스 규칙을 위반하는 심각한 문제가 되었다.

 

Write Skew의 경우에도, 여러 해결책이 있겠지만 데이터베이스 관점에서는 SERIALIZABLE 격리 수준을 사용하거나, 비관적 잠금과 같은 Locking Reads를 사용하는 방법으로 해결할 수 있다. 위의 비관적 잠금 예시와 동일하니 예제는 생략하도록 하자.

 

 

 

 

 

정리

Lost Update는 Write Skew의 부분집합이겠구나, 라는 생각을 하면서 차이점들을 정리해보았다.

  • Lost Update: 단일 레코드에서 발생하며 단순한 데이터 덮어쓰기 현상
  • Write Skew: 여러 레코드 또는 관련 데이터에 발생할 수 있으며 비즈니스 규칙이나 데이터 정합성을 위반한다.

 

 

동시성을 향상시키면서 데이터에도 문제가 생기지 않도록 비즈니스 로직을 구현할 때 생각을 많이 해야할 것 같다. 성능을 버리고 안정성에 몰빵하는 SERIALIZABLE부터 DB 레벨에서의 락을 활용하거나 Redis 등으로 동시성을 제어하는 등 구현하고자 하는 상황에 맞는 기술을 선택하는 것이 중요해 보인다.

 

 

 

 

 

 

 

 

 

참조

https://stackoverflow.com/questions/27826714/lost-update-vs-write-skew

https://www.cockroachlabs.com/blog/what-write-skew-looks-like/

https://dev.mysql.com/doc/refman/8.4/en/innodb-locking-reads.html

https://www.geeksforgeeks.org/transaction-isolation-levels-dbms/

 

 

 

 

 

728x90
300x250
mag1c

mag1c

2년차 주니어 개발자.

[데이터베이스] MVCC(다중 버전 동시성 제어 - Multi Version Concurrency Control)

Tech/Database 2024. 12. 2. 22:47
728x90
728x90

 

동시성 제어(Concurrency Control)

DBMS에서 동시성 제어는 동시에 데이터에 접근하는 여러 사용자, 즉 여러 트랜잭션의 상호작용에서 트랜잭션의 isolation을 보장하고 일관성과 무결성을 유지할 수 있도록 하는 목적으로 사용되는 기술이다.

 

 

이러한 동시성 제어를 하는 대표적인 방식 중 가장 대표적인 Lock을 간단하게 알아보자.

 

 

공유 잠금(읽기 잠금, shared lock)이나 배타적 잠금(쓰기 잠금, exclusive lock)을 통해 Lock을 획득한 후 트랜잭션 내부의 작업을 수행하는 방식이다. (read로도 쓰기 잠금을 획득할 수 있다)  다른 트랜잭션은 이전 트랜잭션에서 Lock을 반환해야 작업이 수행이 가능하다는 의미이다.

 

 

 

 

이는 곧 SERIALIZABLE하다는 의미이며, 읽기 작업과 쓰기 작업이 서로 방해를 일으키기 때문에 병목 지점이 발생하며 데드락과 같은 동시성 문제가 발생할 수도 있다.

 

 

동시성을 제어하는 이유는 DML의 데이터 무결성을 보장하면서, 트랜잭션의 수를 최대화하는데 있다. 하지만 Lock을 사용한 동시성 제어에는 SERIALIZABLE하다는 특성 때문에 Lock이 커밋/롤백될 때까지 기다려야 한다. 이로 인해 동시 요청 처리 속도가 상당히 떨어지게 된다. 이를 해결하기 위해 등장한 방식이 MVCC이다.

 

 

 

 

MVCC(다중 버전 동시성 제어 - Multi Version Concurrency Control)

MVCC는 Lock과 같이 동시성을 제어하기 위해 사용하는 방법 중 하나이다.

MVCC는 쓰기 작업이 진행중인 레코드에 대해 원본과 변경 중인 데이터를 동시에 유지하는 방식이다. 원본 데이터에 대한  스냅샷(Snapshot)을 백업하여 보관한다. 이 스냅샷을 이용해 하나의 레코드에 대해 여러 버전을 관리하며, 데이터에 대한 변경 사항이 반영되기 전까지 다른 사용자가 변경 사항을 볼 수 없도록 보장한다. 

 

 

 

 

 

 

 

MySQL에서는 Undo Log라는 롤백 세그먼트(백업 공간)을 통해 스냅샷을 구현한다. 데이터 변경 요청이 들어오면 변경된 레코드의 이전 정보를 Undo Log에 저장한다. 이 롤백 세그먼트를 통해 트랜잭션 롤백에 필요한 실행 취소 작업을 수행한다. 마찬가지로 변경 사항이 반영되기 전에 데이터를 읽더라도 이 Undo Log를 활용하여 일관된 읽기(Consistent Read)를 수행한다.

 

 

이러한 MVCC는 Lock을 필요로 하지 않기 때문에 일반적인 RDBMS보다 매우 빠르게 작동한다. 또한 롤백 세그먼트를 활용해 데이터를 읽기 시작할 때 누군가 데이터를 변경하더라도 영향을 받지 않는다. 하지만 메모리 영역에 사용하지 않는 공간이 계속 늘어나게 되므로 데이터를 정리하는 작업이 필요하다. 하나의 레코드, 데이터에 대한 여러 버전이 생길 수 있기 때문에 데이터 버전 충돌 시 애플리케이션 영역에서 해결해야한다.

 

 

 

 

 

 

 

 

 

참조

https://dev.mysql.com/doc/refman/8.4/en/innodb-multi-versioning.html

https://www.youtube.com/watch?v=0PScmeO3Fig&list=PLcXyemr8ZeoREWGhhZi5FZs6cvymjIBVe&index=18

 

728x90
300x250
mag1c

mag1c

2년차 주니어 개발자.

[MySQL] 트랜잭션 격리수준(isolation level)과 이상현상 (with 테스트 코드)

Tech/Database 2024. 11. 27. 14:56
728x90
728x90

기억에 오래남고 이해하기 쉽게 현재 조직의 웨딩 도메인의 적립금을 예시로 간단한 엔터티 설계와 더불어 테스트 코드를 작성하여 각 격리수준과 이에 따른 이상현상을 정리해보았다. 개념들은 MySQL의 공식문서를 활용하여 정리하였고, AUTO_COMMIT은 FALSE를 가정하고 예제들을 작성하였다.
 

(예제에 필요한 기본적인 엔터티와 데이터 세팅은 아래를 참조)

 

CREATE TABLE icash (
    no INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    user_no INT UNSIGNED UNIQUE NOT NULL,
    icash INT UNSIGNED DEFAULT 0 NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP NOT NULL
)


CREATE TABLE icash_transaction (
    no INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    icash_no INT UNSIGNED NOT NULL,
    amount INT UNSIGNED NOT NULL,
    type ENUM('GRANT', 'USE', 'REFUND') NOT NULL,
    referenced_transaction_no INT NULL COMMENT '환불 시 트랜잭션 번호',
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
    INDEX idx_icash_no (icash_no),
    INDEX idx_type (type)
)


CREATE TABLE user (
    no INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    id VARCHAR(100) NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL
)

 
 

icash
icash_transaction

 
 
 
 

트랜잭션 격리 수준(Isloation Level)

트랜잭션의 격리(ACID의 I)는 여러 트랜잭션이 동시에 데이터를 변경하는 등의 쿼리를 수행할 때 다른 트랜잭션의 작업이 끼어들지 못하도록 보장하며, Lock을 통해 다른 트랜잭션이 접근하지 못하도록 격리할 수 있지만, 잘못 사용하게 되면 교착 상태인 데드락(DeadLock)에 빠질 수 있다.
 
트랜잭션의 격리 수준은 트랜잭션의 격리를 어디까지 허용할 것인지 설정하는 것이다. 설정한 격리 수준에 따라 여러 트랜잭션이 동시에 처리될 때, 다른 트랜잭션에서 데이터를 변경하거나 조회하는 데이터를 볼 수 있게 허용할 수 있다.

  • SERIALIZABLE
  • REPEATABLE READ
  • READ COMMITTED
  • READ UNCOMMITTED
1. 우리는 트랜잭션을 관리해야하기 때문에 아래의 예제들에서는 AUTO_COMMIT이 FALSE인 상황만을 다룬다.
2. InnoDB의 기본값은 REPEATABLE_READ이다.
3. 격리 수준 별 이상 현상은 아래 격리 수준이 위의 격리 수준의 이상 현상을 포함하고 있다.

 
 
 
 
 
 
 

SERIALIZABLE

SERIALIZABLE은 가장 엄격한 격리수준으로, 트랜잭션을 직렬화된 순서와 동일하도록 보장한다. REPEATABLE READ와 유사하지만, 모든 SELECT문을 SELECT ... FOR SHARE로 간한다. 다시 말해 모든 조회에도 넥스트 키 락(Next-Key Lock)이 읽기 잠금(Locking Reads)으로 걸린다는 의미이고 이는 곧 조회 조건에 따라 인덱스 레코드와, 레코드 간의 간격(GAP)에 대해 읽기 잠금을 설정한다는 뜻이다.
 

넥스트 키 락(Next-Key Lock)
조건에 일치하는 인덱스 레코드에 락을 설정하며, 해당 레코드와 연관된 레코드 간의 간격(GAP)에도 잠금을 걸어 다른 트랜잭션이 GAP 내에서 새로운 데이터를 삽입하거나 수정하지 못하도록 방지한다.
SELECT ... FOR SHARE
읽은 행에 공유 모드 잠금(SHARED LOCK)을 설정한다. 다른 트랜잭션이 행을 읽을 수는 있지만, 커밋될 때 까지 변경할 수 없다. 커밋하지 않은 다른 트랜잭션에 의해 레코드가 변경된 경우, 쿼리는 트랜잭션이 끝날 때 까지 기다렸다가 최신 값을 조회한다.
읽기 잠금(Locking Reads)
InnoDB 테이블에 대해 잠금 작업을 수행하는 SELECT문으로, 트랜잭션의 격리 수준에 따라 데드락이 발생할 가능성이 있다.
MySQL의 락 매커니즘
 - MySQL에서는 테이블의 레코드가 아닌, 인덱스의 레코드를 잠근다.
 - 락이 걸리는 인덱스는 클러스터 인덱스 및 논클러스터 인덱스를 모두 포함한다.
 - 만약 PK가 없는 테이블이라면 내부적으로 자동 생성되는 PK를 이용하여 설정한다.
 - 테이블에 적절한 인덱스가 없다면, 풀 스캔을 통해 참조되는 모든 레코드에 락을 걸 수 있기 때문에 적절한 인덱스를 설정하여 성능 저하를 고려하여 적절한 인덱스를 설정해야 한다.

 
 
이를 통해 다른 트랜잭션에서 해당 레코드나 갭에 대해 절대 쓰기 작업을 할 수 없다. 동시 접근을 차단하여 부정합 문제를 절대 발생시키지 않는다. 가장 안전함과 동시에 가장 성능이 떨어져 데드락에 빠지기 쉽다.
 

1. 교착 상태(DEADLOCK)

테스트 코드를 통해 SERIALIZABLE을 구현해보고, 데드락을 발생시켜보았다.

it('SERIALIZABLE:: DEAD LOCK', async () => {
    runnerA = dataSource.createQueryRunner();
    runnerB = dataSource.createQueryRunner();

    let deadLock = false;

    try {
        // 트랜잭션 A 시작
        await runnerA.startTransaction('SERIALIZABLE');
        const repoA = runnerA.manager.getRepository(ICashEntity);
        // 트랜잭션 A에서 no = 1에 Lock
        await repoA.findOne({ where: { no: 1 } });

        // 트랜잭션 B 시작
        await runnerB.startTransaction('SERIALIZABLE');
        const repoB = runnerB.manager.getRepository(ICashEntity);
        // 트랜잭션 B에서 no = 2에 Lock
        await repoB.findOne({ where: { no: 2 } });

        // 서로 다른 트랜잭션에서 락을 건 레코드에 쓰기 작업 요청
        await Promise.all([
        	repoA.update({ no: 2 }, { icash: 3000 }),
            repoB.update({ no: 1 }, { icash: 2000 })
        ]);
    } catch (e: any) {
        console.error(e);
        if (e.code == 'ER_LOCK_DEADLOCK') {
            deadLock = true;
        }
    } finally {
        // 트랜잭션 정리
        if (runnerA.isTransactionActive) await runnerA.rollbackTransaction();
        if (runnerB.isTransactionActive) await runnerB.rollbackTransaction();
    }

    // 데드락 발생 여부 검증
    expect(deadLock).toBe(true);
}, 10000);

 
 

1. 조회를 통한 읽기 잠금 발생

await runnerA.startTransaction('SERIALIZABLE');
const repoA = runnerA.manager.getRepository(ICashEntity);
// 트랜잭션 A에서 no = 1에 Lock
await repoA.findOne({ where: { no: 1 } });

// 트랜잭션 B 시작
await runnerB.startTransaction('SERIALIZABLE');
const repoB = runnerB.manager.getRepository(ICashEntity);
// 트랜잭션 B에서 no = 2에 Lock
await repoB.findOne({ where: { no: 2 } });

 
우선, A와 B 트랜잭션에서, 각각 다른 레코드를 조회한다.
 
위에서 언급한 것 처럼 SERIALIZABLE에서는 단순한 SELECT문도 SELECT ... FOR SHARE로 간주된다.
때문에 트랜잭션 A가 no = 1에 대해 SELECT를 실행하면, 해당 레코드에 대해 읽기 잠금이 발생하며 동일하게 트랜잭션 B가 no = 2에 대해 읽기 잠금을 발생시킨다.
 

 
이 상황에서는, 두 트랜잭션이 서로 다른 레코드에 대해 잠금을 걸기 때문에, 충돌이 발생하지 않는다. 위의 그림처럼, 서로 다른 레코드에 락을 걸게 된다. 만약 같은 레코드를 조회했더라도, 읽기 잠금은 참조(읽기)가 가능하기 때문에 아무런 이상현상이 발생하지 않는다.
 
 
 

2. 쓰기(UPDATE) 발생

// 서로 다른 트랜잭션에서 락을 건 레코드에 쓰기 작업 요청
await Promise.all([
    repoA.update({ no: 2 }, { icash: 3000 }),
    repoB.update({ no: 1 }, { icash: 2000 })
]);

 
이제, 서로 다른 트랜잭션에서 락을 획득한 레코드에 대해 업데이트해보자.
 

 
트랜잭션 A는, B가 획득한 자원에 대해, 트랜잭션 B는 A가 획득한 자원에 대해 쓰기 작업을 시도한다.
각 트랜잭션들은 이미 하나의 락을 획득하였고, 다른 트랜잭션에서 할당된 자원에 대해 쓰기 작업을 시도하여 배타적 잠금을 요청하며 이전 락이 해제될 때 까지 기다리게 된다. 이로 인해 데드락이 발생하게 된다.
 
 
 

2. LOCK_WAIT_TIMEOUT

이번엔, 교착 상태는 아니지만 다른 상황을 만들어보았다.

it('SERIALIZABLE:: TIMEOUT', async () => {
    runnerA = dataSource.createQueryRunner();
    runnerB = dataSource.createQueryRunner();
    
    // InnoDB의 DEFAULT LOCK WAIT TIMEOUT = 50이기 때문에
    // 테스트를 위해 5초로 변경
    await runnerB.manager.query('SET SESSION innodb_lock_wait_timeout = 5');

    let timeout = false;

    try {
        // 트랜잭션 A 시작
        await runnerA.startTransaction('SERIALIZABLE');
        const repoA = runnerA.manager.getRepository(ICashEntity);
        // 트랜잭션 A에서 no = 1에 Lock
        await repoA.findOne({ where: { no: 1 } });

        await runnerB.startTransaction('SERIALIZABLE');
        const repoB = runnerB.manager.getRepository(ICashEntity);

		await repoB.update({ no: 1 }, { icash: 2000 });
    } catch (e: any) {
        if (e.code == 'ER_LOCK_WAIT_TIMEOUT') {
            timeout = true;
        }
    } finally {
        // 트랜잭션 정리
        if (runnerA.isTransactionActive) await runnerA.rollbackTransaction();
        if (runnerB.isTransactionActive) await runnerB.rollbackTransaction();
    }

    // 타임아웃 발생 여부 검증
    expect(timeout).toBe(true);
}, 10000);

 

// 트랜잭션 A 시작
await runnerA.startTransaction('SERIALIZABLE');
const repoA = runnerA.manager.getRepository(ICashEntity);
// 트랜잭션 A에서 no = 1에 Lock
await repoA.findOne({ where: { no: 1 } });

await runnerB.startTransaction('SERIALIZABLE');
const repoB = runnerB.manager.getRepository(ICashEntity);

await repoB.update({ no: 1 }, { icash: 2000 });

 
트랜잭션 A가 락을 획득한 레코드에 대해, B가 쓰기 작업을 시도하였다.

 
 
마찬가지로 트랜잭션 B는 트랜잭션 A가 커밋을 하기 전까지 기다리게 된다. 만약 설정된 LOCK_WAIT_TIME을 초과하게되면, LOCK_WAIT_TIMEOUT 에러가 발생하게 되고, 작업을 수행할 수 없다.
 
 
 

정리

SERIALIZABLE은 모든 SELECT에도 잠금을 발생시기 때문에 다른 트랜잭션에서 절대로 쓰기 작업을 수행할 수 없고 이전 트랜잭션에서 작업이 완료되기를 기다려야만 한다. 가장 안전할 것처럼 보이나, 작업이 오래 걸리는 트랜잭션이 겹겹이 쌓여 병목 현상이 발생하고, 원하는 작업이 수행되지 못하는 등 가장 성능이 떨어지기 때문에 극단적으로 안전한 작업이 아니라면 사용을 기피하는 것으로 알려져 있다.
 
 
 
 
 
 

REPEATABLE READ

REPEATABLE READ는 InnoDB의 기본 트랜잭션 격리 수준으로, 하나의 트랜잭션 내에서 같은 SELECT 결과가 항상 동일함을 보장한다. 이는 MVCC(Multi-Version Concurrency Control, 다중 버전 동시성 제어) 매커니즘에 의해 트랜잭션이 시작되면 그 시점의 스냅샷이 생성된다. 이후 해당 트랜잭션 내의 SELECT는 이 스냅샷을 기반으로 데이터를 읽게 된다.
 

 
 
 
REPEATABLE READ는 트랜잭션의 실행 순서를 참고하여(트랜잭션 번호) 자신보다 먼저 실행된 트랜잭션의 데이터만을 조회한다. 이 때 테이블에 자신보다 이후에 실행된 트랜잭션의 데이터가 존재할 경우 백업된 언두 로그를 활용하여 데이터를 조회한다.
 
따라서 위 그림과 같이, 트랜잭션 A보다 늦게 시작된 트랜잭션에서 데이터를 변경하더라도 조회 결과는 동일하다. 즉 REPEATABLE READ에서는 다른 트랜잭션에서 데이터를 변경하더라도 일관된 읽기(Consistent Read) 결과를 보장한다. 아래 테스트 결과를 보면, 일관된 읽기가 보장됨을 알 수 있다.
 

it.only('REPEATABLE READ:: 데이터 변경 시 팬텀리드가 발생하지 않는다.', async () => {
    runnerA = dataSource.createQueryRunner();
    runnerB = dataSource.createQueryRunner();

    // 트랜잭션 A 시작
    await runnerA.startTransaction('REPEATABLE READ');
    const repoA = runnerA.manager.getRepository(ICashEntity);

    // 트랜잭션 A에서 SELECT
    const beforeTrxBCommited = await repoA.find({ where: { no: LessThan(15) } });

    // 트랜잭션 B 시작
    await runnerB.startTransaction('REPEATABLE READ');
    const repoB = runnerB.manager.getRepository(ICashEntity);

    // 팬텀리드를 발생시키기 위해 update
    await repoB.update({ no: 10 }, { icash: 30000 });
    // 트랜잭션 B 커밋
    await runnerB.commitTransaction();

    const afterTrxBCommited = await repoA.find({ where: { no: LessThan(15) } });

    expect(beforeTrxBCommited.length).toBe(10);
    //A보다 뒤에 실행된 트랜잭션 B가 변경한 레코드는 무시한다.
    expect(afterTrxBCommited.length).toBe(10);
    expect(afterTrxBCommited[9].icash).toBe(38120);
});

 

 
 
 
그러나 REPEATABLE READ 수준에서도 특정 조건에서 데이터 부정합, 팬텀 리드와 같은 이상 현상이 발생할 수 있다. 위에서 강조했듯이 REPEATABLE READ는 데이터를 변경할 때 일관된 읽기를 보장한다고 했다. 즉 새로운 레코드가 추가될 때는 아래 그림처럼 팬텀 리드가 발생할 수 있다.
 

 
그럼 정말로 새로운 레코드가 추가되면 팬텀 리드가 발생할까? 아래 테스트를 보자.
 

it.only('REPEATABLE READ:: 팬텀 리드가 발생할 것이다.', async () => {
    runnerA = dataSource.createQueryRunner();
    runnerB = dataSource.createQueryRunner();

    // 트랜잭션 A 시작
    await runnerA.startTransaction('REPEATABLE READ');
    const repoA = runnerA.manager.getRepository(ICashEntity);

    // 트랜잭션 A에서 SELECT
    const beforeTrxBCommited = await repoA.find({ where: { no: LessThan(15) } });

    // 트랜잭션 B 시작
    await runnerB.startTransaction('REPEATABLE READ');
    const repoB = runnerB.manager.getRepository(ICashEntity);

    // 팬텀리드를 발생시키기 위해 INSERT
    await repoB.save({ userNo: 11, icash: 1000 });
    // 트랜잭션 B 커밋
    await runnerB.commitTransaction();

    const afterTrxBCommited = await repoA.find({ where: { no: LessThan(15) } });

    expect(beforeTrxBCommited.length).toBe(10);
    expect(afterTrxBCommited.length).toBe(11);
});

 

 
팬텀 리드가 발생할 것으로 생각되는 상황을 가정하고, 테스트를 돌려보면 테스트는 실패한다. MySQL에서는 레코드가 추가되는 상황에서도 MVCC 덕분에 팬텀 리드가 발생하지 않는다. 위에서 언급했듯, MVCC를 사용하여 동일 트랜잭션 내에서 같은 데이터를 읽을 때 언두 로그 기반의 일관된 스냅샷을 제공하기 때문이다. 그렇다면 언제 팬텀리드가 발생할까? 여러 테스트를 작성하여 팬텀 리드가 발생하는 상황을 알아보려고 했다.
 

describe('REPETABLE READ', () => {
    it('REPEATABLE READ:: 일반 조회 - 팬텀 리드가 발생하지 않는다.', async () => {
        runnerA = dataSource.createQueryRunner();
        runnerB = dataSource.createQueryRunner();

        // 트랜잭션 A 시작
        await runnerA.startTransaction('REPEATABLE READ');
        const repoA = runnerA.manager.getRepository(ICashEntity);

        // 트랜잭션 A에서 SELECT
        const beforeTrxBCommited = await repoA.find({ where: { no: LessThan(15) } });

        // 트랜잭션 B 시작
        await runnerB.startTransaction('REPEATABLE READ');
        const repoB = runnerB.manager.getRepository(ICashEntity);

        // 팬텀리드를 발생시키기 위해 INSERT
        await repoB.save({ userNo: 11, icash: 1000 });
        // 트랜잭션 B 커밋
        await runnerB.commitTransaction();

        const afterTrxBCommited = await repoA.find({ where: { no: LessThan(15) } });

        expect(beforeTrxBCommited.length).toBe(10);
        //A보다 뒤에 실행된 트랜잭션 B가 추가한 레코드는 무시한다.
        expect(afterTrxBCommited.length).toBe(10);
    });

    it('REPEATABLE READ:: 배타적 잠금을 통한 조회 - 팬텀리드가 발생하지 않는다.', async () => {
        runnerA = dataSource.createQueryRunner();
        runnerB = dataSource.createQueryRunner();
        await runnerB.manager.query('SET SESSION innodb_lock_wait_timeout = 3');

        let timeout: boolean = false;

        try {
            // 트랜잭션 A 시작
            await runnerA.startTransaction('REPEATABLE READ');
            const repoA = runnerA.manager.getRepository(ICashEntity);

            // 트랜잭션 A에서 SELECT (배타적 잠금 발생)
            const beforeTrxBCommited = await repoA
                .createQueryBuilder()
                .where('no < :no', { no: 15 })
                .setLock('pessimistic_write')
                .getMany();

            // 트랜잭션 B 시작
            await runnerB.startTransaction('REPEATABLE READ');
            const repoB = runnerB.manager.getRepository(ICashEntity);

            // 팬텀리드를 발생시키기 위해 INSERT
            await repoB.save({ userNo: 11, icash: 1000 });
            // 트랜잭션 B 커밋
            await runnerB.commitTransaction();

            const afterTrxBCommited = await repoA.find({ where: { no: LessThan(15) } });

            await runnerA.commitTransaction();

            //팬텀리드를 테스트하려고 하지만 타임아웃이 발생할 것이다.
            expect(beforeTrxBCommited.length).toBe(10);
            expect(afterTrxBCommited.length).toBe(11);
        } catch (e: any) {
            if (e.code == 'ER_LOCK_WAIT_TIMEOUT') {
                timeout = true;
            }
        }

        expect(timeout).toBe(true);
    }, 10000);

    it('REPEATABLE READ:: 읽기 잠금을 통한 조회 - 팬텀리드가 발생하지 않는다.', async () => {
        runnerA = dataSource.createQueryRunner();
        runnerB = dataSource.createQueryRunner();
        await runnerB.manager.query('SET SESSION innodb_lock_wait_timeout = 3');

        let timeout: boolean = false;

        try {
            // 트랜잭션 A 시작
            await runnerA.startTransaction('REPEATABLE READ');
            const repoA = runnerA.manager.getRepository(ICashEntity);

            // 트랜잭션 A에서 SELECT (읽기 잠금 발생)
            const beforeTrxBCommited = await repoA
                .createQueryBuilder()
                .where('no < :no', { no: 15 })
                .setLock('pessimistic_read')
                .getMany();

            // 트랜잭션 B 시작
            await runnerB.startTransaction('REPEATABLE READ');
            const repoB = runnerB.manager.getRepository(ICashEntity);

            // 팬텀리드를 발생시키기 위해 INSERT
            await repoB.save({ userNo: 11, icash: 1000 });
            // 트랜잭션 B 커밋
            await runnerB.commitTransaction();

            const afterTrxBCommited = await repoA.find({ where: { no: LessThan(15) } });

            await runnerA.commitTransaction();

            expect(beforeTrxBCommited.length).toBe(10);
            expect(afterTrxBCommited.length).toBe(11);
        } catch (e: any) {
            if (e.code == 'ER_LOCK_WAIT_TIMEOUT') {
                timeout = true;
            }
        }
        expect(timeout).toBe(true);
    }, 10000);

    it('REPEATABLE READ:: 쓰기 이후 배타적 잠금으로 조회 - 팬텀리드가 발생한다.', async () => {
        runnerA = dataSource.createQueryRunner();
        runnerB = dataSource.createQueryRunner();

        // 트랜잭션 A 시작
        await runnerA.startTransaction('REPEATABLE READ');
        const repoA = runnerA.manager.getRepository(ICashEntity);

        // 트랜잭션 A에서 SELECT (배타적 잠금 발생)
        const beforeTrxBCommited = await repoA.find({ where: { no: LessThan(15) } });

        // 트랜잭션 B 시작
        await runnerB.startTransaction('REPEATABLE READ');
        const repoB = runnerB.manager.getRepository(ICashEntity);

        // 팬텀리드를 발생시키기 위해 INSERT
        await repoB.save({ userNo: 11, icash: 1000 });
        // 트랜잭션 B 커밋
        await runnerB.commitTransaction();

        const afterTrxBCommited = await repoA
            .createQueryBuilder()
            .where('no < :no', { no: 15 })
            .setLock('pessimistic_write')
            .getMany();

        await runnerA.commitTransaction();

        expect(beforeTrxBCommited.length).toBe(10);
        expect(afterTrxBCommited.length).toBe(11);
    });

    it('REPEATABLE READ:: 쓰기 이후 읽기 잠금으로 조회 - 팬텀리드가 발생한다.', async () => {
        runnerA = dataSource.createQueryRunner();
        runnerB = dataSource.createQueryRunner();

        // 트랜잭션 A 시작
        await runnerA.startTransaction('REPEATABLE READ');
        const repoA = runnerA.manager.getRepository(ICashEntity);

        // 트랜잭션 A에서 SELECT
        const beforeTrxBCommited = await repoA.find({ where: { no: LessThan(15) } });

        // 트랜잭션 B 시작
        await runnerB.startTransaction('REPEATABLE READ');
        const repoB = runnerB.manager.getRepository(ICashEntity);

        // 팬텀리드를 발생시키기 위해 INSERT
        await repoB.save({ userNo: 11, icash: 1000 });
        // 트랜잭션 B 커밋
        await runnerB.commitTransaction();

            // 트랜잭션 B에서 읽기잠금으로 조회
        const afterTrxBCommited = await repoA
            .createQueryBuilder()
            .where('no < :no', { no: 15 })
            .setLock('pessimistic_read')
            .getMany();

        await runnerA.commitTransaction();

        expect(beforeTrxBCommited.length).toBe(10);
        expect(afterTrxBCommited.length).toBe(11);
    });
});

 

 
 
테스트 케이스의 결과를 통해 REPEATABLE READ에서의 팬텀 리드 발생 상황을 정리해볼 수 있었다.

  • SELECT > DML > SELECT : 팬텀리드가 발생하지 않음
  • SELECT FOR UPDATE(배타적 잠금) > DML > SELECT : 팬텀리드가 발생하지 않음
  • SELECT FOR SHARE(읽기 잠금) > DML > SELECT : 팬텀 리드가 발생하지 않음
  • SELECT > DML > SELECT FOR UPDATE(배타적 잠금) : 팬텀리드 발생
  • SELECT > DML > SELECT FOR SHARE(읽기 잠금) : 팬텀 리드 발생

 
테스트 결과를 보면, 2번 3번 테스트가 유독 긴 테스트 시간이 소요되었다. 타임아웃이 발생한 것이다.
위 테스트의 경우에 no < 15인 인덱스 레코드에 대해 조회와 동시에 잠금을 걸었기 때문에 다른 트랜잭션에서는 쓰기 작업을 위해 해당 트랜잭션에서 잠금을 해제하여야 한다. 즉, 트랜잭션 A가 해당 레코드 범위에 대해 잠금을 해제할 때 까지 트랜잭션 B는 대기상태에 들어가고, 설정해 둔 3초의 시간이 지나 타임아웃이 발생한 것이다.
 
MySQL에서 REPEATABLE READ 수준에서의 팬텀 리드는 데이터가 변경된 후 잠금을 사용하는 조회 상황에서는 팬텀 리드가 발생할 수 있다. 스냅샷이 아닌 실제 테이블 상태를 참조하기 때문에 다른 트랜잭션에서 추가한 새로운 행이 보이게 되는 것이다. 마찬가지로, 트랜잭션 없이 실행되는 SELECT에서도 팬텀 리드가 발생할 수 있다.
 
 
 

정리

MySQL의 REPEATABLE READ 수준에서 MVCC를 통해 한 트랜잭션 내에서 거의 모든 상황에 일관된 읽기를 수행할 수 있고 테이블에 잠금을 설정하지 않기 때문에 트랜잭션에서 일관된 읽기를 수행하는 동안 다른 트랜잭션에서 해당 테이블을 자유롭게 수정할 수 있다.
 
 
 
 
 
 
 

READ COMMITED

커밋된 데이터만 조회할 수 있는 격리수준이다. 아래는 테스트 코드와 이를 풀어낸 그림으로, 트랜잭션 A에서 업데이트 후 커밋하기 전의 조회 결과와 커밋한 후의 조회 결과가 다름을 보여준다.
 

it('READ COMMITTED:: 커밋된 데이터만 조회가 가능하다.', async () => {
    runnerA = dataSource.createQueryRunner();
    runnerB = dataSource.createQueryRunner();

    // 트랜잭션 A 시작
    await runnerA.startTransaction('READ COMMITTED');
    const repoA = runnerA.manager.getRepository(ICashEntity);

    // 트랜잭션 A에서 쓰기 작업
    await repoA.update({ no: 1 }, { icash: 10 });

    // 트랜잭션 B 시작
    await runnerB.startTransaction('READ COMMITTED');
    const repoB = runnerB.manager.getRepository(ICashEntity);

    // 트랜잭션 B에서 SELECT
    const beforeTrxACommited = await repoB.findOne({ where: { no: 1 } });

    // 트랜잭션 A 커밋
    await runnerA.commitTransaction();

    // 트랜잭션 B에서 SELECT
    const afterTrxACommited = await repoB.findOne({ where: { no: 1 } });

    expect(beforeTrxACommited!.icash).toBe(1000);
    expect(afterTrxACommited!.icash).toBe(10);
});

 
 

 
 
이 결과는 트랜잭션 B가 먼저 시작되더라도 동일하다. 다른 트랜잭션에서 커밋이 되었는지 안되었는지, 커밋 결과로 레코드가 업데이트 되었는지가 중요하다. 다른 트랜잭션에서의 커밋 여부에 따라 조회 결과가 계속해서 변할 수 있다.
 
이처럼 동일한 조건으로 데이터를 조회했음에도 다른 트랜잭션의 커밋 여부에 따라 결과가 달라지는 이상 현상을 반복 읽기 불가능
(Non-Repeatable Read)라고 한다. 예상 가능하겠지만, READ COMMITTED 수준에서는, 트랜잭션 내에서 실행되는 조회와 트랜잭션 밖에서 실행되는 조회의 차이가 거의 없다.
 
 
 
 
 

READ UNCOMMITTED

READ UNCOMMITTED는 트랜잭션 처리가 완료되지 않은 데이터까지도 읽을 수 있는 격리 수준이다. READ COMMITTED와 같은 예시에서, READ UNCOMMITTED 수준에서는 반영이 되지 않은 데이터에 접근할 수 있음을 알 수 있다.

it('READ UNCOMMITTED:: 커밋되지 않은 데이터도 조회가 가능하다.', async () => {
    runnerA = dataSource.createQueryRunner();
    runnerB = dataSource.createQueryRunner();

    // 트랜잭션 A 시작
    await runnerA.startTransaction('READ UNCOMMITTED');
    const repoA = runnerA.manager.getRepository(ICashEntity);

    // 트랜잭션 A에서 쓰기 작업
    await repoA.update({ no: 1 }, { icash: 10 });

    // 트랜잭션 B 시작
    await runnerB.startTransaction('READ UNCOMMITTED');
    const repoB = runnerB.manager.getRepository(ICashEntity);

    // 트랜잭션 B에서 SELECT
    const beforeTrxACommited = await repoB.findOne({ where: { no: 1 } });

    // 트랜잭션 A 커밋
    await runnerA.commitTransaction();

    // 트랜잭션 B에서 SELECT
    const afterTrxACommited = await repoB.findOne({ where: { no: 1 } });

    expect(beforeTrxACommited!.icash).toBe(10);
    expect(afterTrxACommited!.icash).toBe(10);
});

 

 
 
 
이렇게, 작업이 완료되지 않았는데도 데이터를 읽을 수 있는 이상 현상을 Dirty Read라고 한다. 커밋이나 롤백되지 않은, 작업이 완료되지 않은 결과를 읽고 다른 작업을 수행한다. 특히 롤백 상황에서, 롤백 전의 데이터로 무언가 작업이 일어날 우려가 있기 때문에
READ UNCOMMITTED는 데이터 정합성에 문제가 많은 격리 수준이다.
 
 
 
 
 
 
 

참조

https://dev.mysql.com/doc/refman/8.4/en/innodb-storage-engine.html
https://dev.mysql.com/doc/refman/8.4/en/innodb-transaction-isolation-levels.html
https://dev.mysql.com/doc/refman/8.4/en/glossary.html
https://dev.mysql.com/doc/refman/8.4/en/innodb-consistent-read.html
https://dev.mysql.com/doc/refman/8.4/en/innodb-next-key-locking.html
https://mangkyu.tistory.com/
https://velog.io/@onejaejae/DB-MVCC
https://medium.com/daangn/mysql-cats-contention-aware-transaction-scheduling-71fe6956e87e
https://techblog.woowahan.com/2606/
https://www.youtube.com/watch?v=704qQs6KoUk
https://www.youtube.com/watch?v=4wGTavSyLxE&t=148s
 
 
 
 
 
 

728x90
300x250
mag1c

mag1c

2년차 주니어 개발자.

[데이터베이스] 인덱스(index) 정리

Tech/Database 2024. 7. 10. 17:41
728x90
728x90

인덱스

 

 
 
목차, 색인, 책갈피와 같은 기능을 하는 인덱스는, 데이터베이스 분야에서는 어떤 데이터를 검색할 때 속도를 높여주는 자료 구조로
메모리 영역에 생성되는 일종의 책갈피이다.
 
 
 
 

인덱스 구조

보통 Hash, B-Tree, B+Tree가 있다.

실제 사용하고있는 MySQL MYISAM의 인덱스 일부

 

Hash

우리가 알고있는 Key, Value형태의 자료 구조다.
해시 함수로 키 값을 해시값으로 변환하고, 이 해시 값을 기반으로 데이터를 빠르게 조회할 수 있다. - O(1)
 

 
 
하지만 이런 해시구조의 특성상 범위 검색에 효율이 떨어진다. 키 값이 조금이라도 변하게 되면 완전히 다른 해시 값을 반환하기 때문이다.
범위 검색에서의 부등호 연산을 포함한 범위 조건(BETWEEN, LIKE 등)에는 적합하지 않다.
 
 
 

B-Tree

B-Tree는 이진 트리를 확장한 트리 구조이다. 자식 노드를 2개보다 더 많이 가질 수 있다.
 

 
 
Root, Branch, Leaf의 구조로 되어있으며 Branch가 곧 리프가 되는 경우도 있다.
B-Tree의 주요 특징 중 하나는 모든 리프 노드가 동일한 깊이에 있다는 점이다.
이는 트리의 균형을 유지하여 연산 시간의 복잡도 logN으로 보장하여 효율적인 검색을 가능하게 한다.
 
B-Tree의 구조가 효율적인 이유는 위에서 언급한 균형 잡힌 구조와 더불어 대수 확장성에 있다.
대수 확장성이란 트리 깊이가 리프 노드 수에 비해 매우 느리게 성장하는 것을 의미한다.
4차 B-Tree구조가 있다고 가정해보자. 각 노드는 최대 4개의 자식노드를 가질 수 있다. 즉 트리의 깊이가 1씩 증가할 때마다 최대 4배의 인덱스가 추가될 수 있다. 따라서 깊이가 d인 4차 B-Tree는 최대 4^d의 리프 노드를 가질 수 있다. 깊이가 10만 되어도 리프 노드수는 100만 개가 넘는다.

트리의 깊이가 낮기 때문에 I/O작업이 적고 연산이 빠르게 일어날 수 있다.

 
 
 

B+Tree

B-Tree를 개선시킨 자료 구조로 각 노드에는 인덱스 키만 저장되고, 실제 데이터리프 노드에 저장된다.
 

 
 
리프 노드들은 서로 연결 리스트 형태로 연결되어 있어 범위 검색에 효율적이다. 하지만 데이터를 조회하기 위해선 반드시 리프 노드까지 탐색(log2N)이 되어야 한다.
 
 
 
 

주의사항

 

DML의 성능 저하

DML(insert, update, delete) 명령에 따라 레코드에 해당하는 인덱스를 생성, 삭제, 변경해야 하기 때문에 성능 저하가 발생한다.
또한 인덱스의 개수가 너무 많으면 레코드 당 인덱스 변경의 횟수가 많아지기 때문에 성능 저하를 발생시킬 수 있다.
인덱스의 개수가 많아지면 옵티마이저가 잘못된 인덱스를 선택하여 조회 성능에 영향을 미치지 못하는 경우도 있다.
 
 

페이지 크기와 레코드 저장

MySQL에서는 디스크와 메모리에 데이터를 읽고 쓰는 최소 작업의 단위를 페이지라 한다.

인덱스를 포함해 PK와 테이블 모두 페이지 단위로 관리된다.

 
우리는 하나의 레코드를 읽기 위해서 여러 레코드가 포함된 페이지를 읽어서 그 중 일치하는 레코드를 읽는 것이다.
(당연히 페이지에는 여러 레코드가 기록되어 있을 수 있다.)
만약 레코드를 찾기 위해 1개의 페이지로 해결이 되지 않는다면 다른 페이지들을 읽어야 하는 추가적인 디스크 I/O가 발생하게 된다. 
 
(참고)
InnoDB의 경우 페이지 크기 16KB로 고정되어 있었지만, 공식 문서에 따르면 MySQL 5.7.6 이후부터는 페이지 크기를 32KB, 64KB로 설정하는 것이 가능하다고 한다. 하지만 한 레코드의 크기는 16KB로 여전히 제한된다고 한다.
 

 
 
인덱스 역시 페이지 단위로 관리가 된다. 인덱스로 사용되는 PK, 컬럼의 크기가 크면 클수록 인덱스 페이지가 줄어들게 된다. 그럼 인덱스를 활용한 조회 시에도 여러 인덱스 페이지를 읽어야 하는 문제가 발생한다. 그렇기에 인덱스 키는 길면 길수록 성능 저하를 발생시킨다.
 


InnoDB의 경우 하나의 레코드 크기가 페이지 크기의 절반 이상일 경우 레코드에서 외부에 저장할 가변 길이 컬럼을 선택해서 외부 페이지(off-page)에 데이터를 저장하고 기존의 페이지에는 off-page를 가리키는 포인터를 저장한다.

 
 
 
 

인덱스 컬럼 기준

 

단일 인덱스

단일 인덱스를 생성할 때는 카디널리티(Cardinality)가 가장 높은 것을 잡아야 한다.

카디널리티란 중복된 수치를 얘기한다. Unique 한 값일수록 카디널리티가 높다.
예를 들어 성별은 카디널리티가 낮고, 주민등록번호는 카디널리티가 높다.

 
카디널리티가 높은 컬럼을 인덱스로 생성해야 하는 이유는 인덱스로 많은 부분을 걸러낼 수 있어야 하기 때문이다. 인덱스를 통해 많이 걸러내면 걸러낼수록 조회 성능이 올라간다.
 
 

복합 인덱스

복합 인덱스를 최적화하기 위해서는 카디널리티를 포함하여 다음 상황들을 고려해야 한다.

  • 공통으로 사용되는 필수 조건
  • =, IN 연산
  • 정렬
  • 범위 비교 연산 (BETWEEN, LIKE, 비교연산자)

여러 자료를 통해 학습을 했을 때, 인덱스는 범위 비교 시에는 다음 인덱스 컬럼은 사용되지 않는다고 했다.
인덱스를 지정할 때 위의 상황들을 오름차순으로 생성하는 게 효율적이라는 자료도 있었다.
 
 
 

복합 인덱스를 통한 현업에서의 튜닝 시도

 

최대 5초가 걸리는 쿼리 개선하기

[데이터베이스] 인덱스(index) 정리인덱스   목차, 색인, 책갈피와 같은 기능을 하는 인덱스는, 데이터베이스 분야에서는 어떤 데이터를 검색할 때 속도를 높여주는 자료 구조로메모리 영역에

mag1c.tistory.com

 

 

 

종류

인덱스는 분류에 따라 여러 종류의 인덱스가 있다.

  • Unique - Non-Unique Index
  • Single - Composite Index
  • Clustered - Non-Clustered Index
  • Function-Based, Bitmap, Reverse key, Hash Index

등등 여러 인덱스들이 있지만 여기서는 클러스터의 분류에 따른 인덱스를 살펴보자
 
 

Clustered Index

클러스터드 인덱스는 데이터가 테이블에 물리적으로 저장되는 순서를 정의한다. 이 뜻은 인덱스가 되는 컬럼을 기준으로 데이터들을 정렬시킴을 의미한다.

primary key의 제약 조건은 클러스터드 인덱스를 자동 생성하기 때문에 일반적인 상황에서는 PK가 곧 클러스터드 인덱스가 된다.
 
테이블당 하나의 클러스터드 인덱스만 존재할 수 있고 인덱스 페이지의 리프 노드에 실제 데이터의 페이지가 들어있다.
 

출처: https://gwang920.github.io/database/clusterednonclustered/

 
 
 
실제 데이터가 인덱스 순서에 따라 정렬되어 검색 시 매우 빠른 성능을 보일 수 있다. 하지만 위에서 말한 것처럼 정렬된 상태여야 한다. no, id값의 일반적인 auto_increment값을 가진 PK라면 문제가 되지 않겠으나, UUID 같은 정렬되지 않은 키를 사용한다면 정렬을 위해 추가적인 리소스를 발생시킬 수 있다. 성능이 저하될 수 있다는 의미이다.

 

PK의 보안 때문에 UUID를 고려한다면 UUIDv7을 사용해 보는 것도 괜찮을 것 같다.
UUIDv7은 UNIX_TIMESTAMP를 ms 단위로 인코딩하여 효율적인 색인화가 가능하다고 한다.

 
 
 
 

Non-Clustered Index

논-클러스터드 인덱스는 테이블에 저장된 물리적 순서에 따라 데이터를 정렬하지는 않는다.
 
클러스터드 인덱스와 달리 리프 노드에는 데이터 페이지에 대한 포인터가 있어 포인터를 통해 데이터 페이지를 조회할 수 있는 형태이고 데이터 페이지는 렬되어있지 않다.
 

출처: https://gwang920.github.io/database/clusterednonclustered/

 
 
클러스터드 인덱스와 달리 데이터를 찾는 데 여러 개의 인덱스를 생성할 수 있다.
 
데이터를 찾을 때 추가적인 스텝 (리프 레벨에서 데이터 페이지에 접근)이 필요하기 때문에, 클러스터드 인덱스보다는 속도가 느리고 데이터 입력 시 별도의 공간에 인덱스를 생성해야 하기 때문에 추가 작업이 요구된다. 별도의 공간도 따로 할당되어야 한다. (약 10%)
 
 
 
 

마무리

블로그를 돌아보니 DB 포스팅이 SQLD를 취득하기 위한 학습 이후에 멈춰있었는데, 사실상 현재 해나가고 있는 모든 것들을 포스팅하는 내 성격상 현재 가장 많이 다루고 있는 것이 데이터베이스인데, 네트워크와 PS에 빠져서 소홀했다는 생각이 들었다. 
 
리팩토링 과정에서 단순히 실행 계획을 바탕으로 튜닝을 이것저것 많이 처리하고, 일의 양이 많아 바로 다음 작업으로 넘어가다 보니 정확히 쿼리 튜닝을 위해 관련된 지식들을 학습할 필요를 느껴 다시 하나하나 시작해보려 한다. DB의 여러 부분들의 공부를 깊게 해 보면서 실제로 적용시켜 보며 정리를 위한 포스팅을 계속 이어나갈 수 있도록 해야겠다.
 
 
 
 

참조

 

Real MySQL 시즌 1 - Part 1 강의 | 이성욱 - 인프런

이성욱 | MySQL의 핵심적인 기능들을 살펴보고, 실무에 효과적으로 활용하는 방법을 배울 수 있습니다. 또한, 오랫동안 관성적으로 사용하며 무심코 지나쳤던 중요한 부분들을 새롭게 이해하고,

www.inflearn.com

 

CS 지식의 정석 | 디자인패턴 네트워크 운영체제 데이터베이스 자료구조 강의 | 큰돌 - 인프런

큰돌 | 국내 1위 '면접을 위한 CS 전공지식노트' 저자의 디자인패턴, 네트워크, 운영체제, 데이터베이스 등 CS 지식 강의! CS 면접에 필요한 모든 개념과 최신 기출을 다룬다!, [사진] [사진] [실제 카

www.inflearn.com

 

[Database] 인덱스(index)란?

1. 인덱스(Index)란? [ 인덱스(index)란? ] 인덱스란 추가적인 쓰기 작업과 저장 공간을 활용하여 데이터베이스 테이블의 검색 속도를 향상시키기 위한 자료구조이다. 만약 우리가 책에서 원하는 내

mangkyu.tistory.com

 

MySQL Ascending index vs Descending index - tech.kakao.com

용어 정리 이 설명에서는 인덱스의 정렬 순서와 데이터 읽기 순서 등 방향에 대한 ...

tech.kakao.com

 

MySQL :: MySQL 5.7 Release Notes :: Changes in MySQL 5.7.6 (2015-03-09, Milestone 16)

These functions are deprecated in favor of the ST_ names: Area(), AsBinary(), AsText(), AsWKB(), AsWKT(), Buffer(), Centroid(), ConvexHull(), Crosses(), Dimension(), Distance(), EndPoint(), Envelope(), ExteriorRing(), GeomCollFromText(), GeomCollFromWKB(),

dev.mysql.com

 

[mysql] 인덱스 정리 및 팁

MySQL 인덱스에 관해 정리를 하였습니다. MySQL을 잘 알아서 정리를 한것이 아니라, 잘 알고 싶어서 정리한 것이라 오류가 있을수도 있습니다. 1. 인덱스란? 인덱스 == 정렬 인덱스는 결국 지정한 컬

jojoldu.tistory.com

 

2. 페이징 성능 개선하기 - 커버링 인덱스 사용하기

2. 커버링 인덱스 사용하기 앞서 1번글 처럼 No Offset 방식으로 개선할 수 있다면 정말 좋겠지만, NoOffset 페이징을 사용할 수 없는 상황이라면 커버링 인덱스로 성능을 개선할 수 있습니다. 커버링

jojoldu.tistory.com

 

[MySQL] B-Tree로 인덱스(Index)에 대해 쉽고 완벽하게 이해하기

인덱스를 저장하는 방식(또는 알고리즘)에 따라 B-Tree 인덱스, Hash 인덱스, Fractal 인덱스 등으로 나눌 수 있습니다. 일반적으로 B-Tree 구조가 사용되기 때문에 B-Tree 인덱스를 통해 인덱스의 동작

mangkyu.tistory.com

 

 

Goodbye to sequential integers,  hello UUIDv7!

Exploring the tradeoffs of different database indexes; from sequential integers, randomly generated UUIDs, to time-based identifiers and the latest & greatest UUIDv7

buildkite.com

 
 

728x90
300x250
mag1c

mag1c

2년차 주니어 개발자.

[MySQL] 프로시저 (Stored Procedure)

Tech/Database 2023. 3. 16. 06:54
728x90
728x90

학원 내 모의 프로젝트에서

1. 회원 탈퇴의 경우 일정 기간이 지나면 자동으로 테이블에서 삭제 처리
2. 통계 테이블에 해당 날짜의 자정이되면 자동으로 insert처리

 

위와 같은 DB 자동화를 구축해 보고 싶었고 강사님께 여쭈어봤더니 CI/CD ?? Jenkins 같은 것들을 학습하면 구현하는 데 도움이 될 거라고 하셨는데 너무 생소한 개념이기도 하고 학습하는데 시간이 다 가서 프로젝트 수행을 못할 것 같아 방법을 찾아보던 중 프로시저를 활용해 MySQL 내에 이벤트를 구현해 간단한 자동화??아닌 자동화를 구현한 적이 있다.

 

그 때 당시 이해했던 프로시저는 Java의 메서드와 같은 역할을 하는 것인줄 알았다.

함수를 생성하여 이벤트 스케쥴러를 통해 프로시저를 호출했기 때문이다.

 

 

지금와서 지난 쿼리를 보니 잘못 사용했다는 생각이 든다.

DELIMITER $$
CREATE PROCEDURE paidDelAuto()
BEGIN
	DELETE FROM PAID WHERE PAID_DATE < (select date_format(date_sub(now(), interval 7 day), '%Y-%m-%d'));
END $$
DELIMITER ;

찾아보니 프로시저는 여러 쿼리문을 하나로 합쳐서 사용하기 위해 주로 사용된다고 한다.

굳이 프로시저가 아니라 이벤트 생성시 단일쿼리문을 넣어서 사용했으면 그냥 돌아갔을 것 같다 ㅋㅋ;;

 

정확히 포스팅해서 다음부터는 올바르게 사용할 수 있도록 하자.

 

 

 

프로시저


MySQL에서는 스토어드 프로시저 (Stored Procedure)라 한다.

장점

1. 하나의 프로시저로 여러 SQL문의 실행이 가능하다. (모듈화)
2. 호스팅언어와 SQL문이 분리되어 보수가 용이하다.
3. 네트워크 소요 시간을 줄일 수 있다. (구문 분석 및 코드 변환을 미리 진행하여 처리 시간이 단축됨)
단점

1. 재사용성이 낮다. (DB 버전 별 구문 규칙과 호환성이 다름)
2. 처리 성능이 좋지않다. (MySQL 스토어드의 프로그램 처리 성능이 낮다)
함수(Function)와의 차이점

함수 : 클라이언트에서 처리하며, 리턴 값이 단 하나이고 꼭 값을 가져와야 한다.
프로시저 : 서버로 보내 처리하며, 리턴값을 여러개 반환 가능하다.

 

 

사용하기


생성

DELIMITER $$
CREATE PROCEDURE paidDelAuto()
BEGIN
	DELETE FROM PAID WHERE PAID_DATE < (select date_format(date_sub(now(), interval 7 day), '%Y-%m-%d'));
END $$
DELIMITER ;
DELIMITER(구문 문자)

프로시저 내부에 세미콜론이 많을 경우, SQL문이 나눠져 서버로 보내지게 되어 프로시저가 어디까지인지 모른다.
따라서 DELIMITER $$를 선언하고, 프로시저 작성이 끝날 경우 END $$로 프로시저의 끝을 알려야 한다.
END $$로 끝나는 부분을 표시하고, 마지막에 DELIMITER를 세미콜론으로 바꿔준다.

 

호출

CALL paidDelAuto();

활용하기

 

답변 테이블에서 원본글인지 답변글인지 판별하고, 답변여부에 따라 삭제여부 UPDATE 혹은 DELETE

DELIMITER $$ 
DROP PROCEDURE IF EXISTS deleteReboard $$ #같은 이름이 있다면 지우기
CREATE PROCEDURE deleteReboard #저장 프로시저 생성 
( 
	#변수 선언 
    m_no INT, 
    m_step INT, 
    m_groupNo INT 
) 
BEGIN #SQL 프로그래밍 부분 시작 
DECLARE cnt INT; #답변 변수 설정 
SET cnt=0; #변수 초기화 
/*답변이 달린 원본 글인 경우에는 삭제하지 말고 delFlag를 Y 로 update하자*/ 
IF m_step=0 THEN /*원본글인 경우*/ 
  /*답변이 달렸는지 확인*/ 
  SELECT COUNT(*) INTO cnt FROM reboard WHERE groupno=m_groupNo; 
  IF cnt >1 THEN /*답변이 달린 경우*/ 
    UPDATE reboard SET delflag='Y' WHERE NO=m_no; 
  ELSE /*답변이 안 달린 경우*/ 
      DELETE FROM reboard WHERE NO=m_no; 
  END IF; 
ELSE /*답변글인 경우*/ 
	DELETE FROM reboard WHERE NO=m_no; 
END IF; 
END$$ 
DELIMITER ;

변수는 프로시저에 선언한 순서대로 입력할 것

CALL deleteReboard(4, 0, 4);

 

 

 

 

추가 정리


목록 확인

SHOW PROCEDURE STATUS;

 

내용 확인

SHOW CREATE PROCEDURE 프로시저명;

 

삭제

DROP PROCEDURE 프로시저명;

 

호출

CALL 프로시저명();

 

 

 

참조

https://wakestand.tistory.com/518
https://spiderwebcoding.tistory.com/7
https://heestory217.tistory.com/18
728x90
300x250
mag1c

mag1c

2년차 주니어 개발자.

[MySQL] 이벤트 생성 - 이벤트 스케쥴러(Event Scheduler)

Tech/Database 2023. 3. 11. 22:13
728x90
728x90

학원 내 모의 프로젝트 중에서 간단하게 이벤트를 생성 해 통계 테이블에 자정마다 날짜를 입력 해 줬던 적이 있다.

사용했던 경험이 있기 때문에 내 것으로 만들어보고자 포스팅을 하게 되었다.

 

 

이벤트 생성


아래의 코드는 프로젝트 내 통계 테이블 중 방문자 테이블의 첫 insert를 위해 사용했다.

(해당 날짜 방문자는 00:00:00으로 초기화 되어있는 해당 일자 테이블에 방문횟수가 +1되는 구조였다)

create event visitDaily
on schedule every 1 hour
starts now()
--comment 주석
do
insert into VISIT(VISIT_DATE, NUMBER)
select date_format(now(), "%Y%m%d"), 0
  from dual
 where not exists(select VISIT_DATE from VISIT where VISIT_DATE = date_format(now(), "%Y%m%d"));
create event 이벤트이름
on schdule every 주기(month,week,day,hour,month,등등)
starts 시간
comment 이벤트주석
do 아래 실행시킬 내용

 

 

여러 구문


MySQL 내 이벤트 스케쥴러 사용 가능 여부 확인

SHOW VARIABLES LIKE 'event%';

 

사용 가능여부를 아래의 구문으로 변경 가능하다.

SET GLOBAL event_scheduler = ON;

 

생성되어 있는 이벤트 스케쥴러 확인

SELECT * FROM information_schema.events;

 

이벤트 삭제

DROP EVENT IF EXIST 이벤트명;

 

 

참조

https://velog.io/@kaitlin_k/MySQL-scheduled-event-%EB%A7%8C%EB%93%A4%EA%B8%B0
728x90
300x250
mag1c

mag1c

2년차 주니어 개발자.

방명록