본문 바로가기

데이터 정합성 확보 (1)

@정소민fan2025. 11. 30. 19:40

정규화

이전 포스팅

관계/제약 조건 설계 (무결성의 중요성)

데이터베이스 설계 시 무결성은 가장 핵심적인 가치 중 하나다. 데이터의 정확성과 일관성을 보장하여, 애플리케이션 코드의 버그로부터 데이터를 보호하는 최후의 보루 역할을 한다.

핵심 용어 정리

  • 무결성: 데이터베이스 내 데이터의 정확성, 일관성, 유효성을 유지하는 속성.
  • 참조 무결성: 외래 키(Foreign Key)와 관련. 참조하는 테이블의 값이 반드시 참조되는 테이블에 존재하거나 NULL이어야 한다. ON DELETE CASCADE 같은 설정이 여기에 해당.
  • 엔티티 무결성: 기본 키(Primary Key)와 관련. 모든 테이블은 기본 키를 가지며, 기본 키는 NULL이거나 중복된 값을 가질 수 없음을 보장한다.
  • 도메인 무결성: 속성(컬럼)의 값이 정의된 유효한 값(타입, 범위, 제약조건)이어야 함을 보장한다. CHECK 제약조건, NOT NULL 등이 해당.
  • 멱등성: 같은 작업을 여러 번 수행하더라도 항상 동일한 결과를 만들어내는 성질. 주로 API 설계나 분산 시스템에서 중요하게 다뤄진다.
  • 데드락

설계 원칙

  • DB 무결성은 서버 버그 발생 시 데이터 보호의 마지막 보루 역할을 한다.
  • MVP 단계에서는 개발 속도를 위해 일단 기능을 검증하고, 이후에 점진적으로 복잡한 제약을 추가하는 것도 유효하다. 다만, UNIQUENOT NULL은 초기에 잡아두는 것이 데이터 일관성 유지에 좋다.
  • 최종 목표는 애플리케이션 코드를 믿지 못해도 DB 데이터만큼은 언제나 신뢰할 수 있도록 만드는 것이다.

동시성 설계 원칙

동시성 제어는 여러 트랜잭션이 동시에 데이터를 접근할 때 데이터의 일관성을 유지하기 위한 것이다.

핵심 용어 살펴보기

  • 락 (Lock): 동시에 같은 행(Row) 또는 테이블을 건드리지 못하도록 잠금.
  • 격리 수준 (Isolation Level): 이전 포스팅 참고

데드락 예방과 PK/인덱스 구조

PK/인덱스 구조로 락 경합을 낮추는 것이 중요하다.

  • 순차 증가형 PK (Auto-Increment)의 문제점: 순차 증가형 PK를 사용할 경우, 새로운 레코드 삽입(Insert)은 항상 테이블의 가장 마지막 페이지에 몰리게 된다. 이를 Insert Hotspot이라고 부른다. 여러 트랜잭션이 동시에 삽입을 시도하면 이 마지막 페이지에 대한 락 경합이 심해져 성능 저하로 이어진다.
  • 해결책: UUID v4분산 키를 사용하여 삽입되는 행이 B-Tree의 여러 페이지에 분산되어 삽입되도록 유도해야 락 경합을 낮출 수 있다.
  • 데드락 예방: 데드락은 대부분 락 순서 통일만으로 예방할 수 있다. 트랜잭션마다 테이블 접근 순서를 고정하는 것이 가장 효과적이다. 
  • MySQL의 데드락 처리: MySQL(특히 InnoDB)은 데드락을 감지하면, 관련된 트랜잭션 중 하나를 희생자(Victim)로 선택하여 자동으로 종료시킨다.

인덱스/통계/쿼리 설계

인덱스의 기본

  • 인덱스는 테이블 전체 스캔(Full Table Scan)을 방지하고 검색 속도를 높여준다.
  • 대부분의 데이터베이스는 B-Tree 인덱스를 기본으로 사용한다.

인덱스 활용 팁

  • 복합 인덱스의 순서: 복합 인덱스((col1, col2, col3))에서 컬럼의 순서가 매우 중요하며, 인덱스를 사용할 때 왼쪽부터 사용해야 효과를 볼 수 있다.
  • LIKE 검색: LIKE "abc%" 형태는 인덱스를 사용할 수 있지만, LIKE "%abc%"LIKE "%abc"는 인덱스로 동작하지 않고 전체 스캔을 유발한다.
  • 커버링 인덱스:
    • 개념: 인덱스에 포함된 컬럼만으로 SELECT 쿼리를 완전히 처리할 수 있는 인덱스.
    • 효과: 테이블 자체(데이터 파일)를 조회할 필요가 없어서 디스크 I/O가 크게 줄어들어 쿼리 성능이 개선된다.
    • 예시 코드:
      -- (user_id, status) 복합 인덱스가 존재한다고 가정
      SELECT user_id, status
      FROM orders
      WHERE status = 'PENDING';
      -- 이 쿼리는 테이블을 보지 않고 인덱스 파일만으로 처리가 가능하다.
  • 복합 인덱스에서의 범위 조건: 복합 인덱스에서 범위 조건 (<, >, BETWEEN, LIKE 'prefix%') 이후의 컬럼들은 인덱스가 무효화된다. 따라서 자주 필터링하는 조건이 앞에 오도록 설계하는 것이 중요하다.
  • OR 조건 개선: WHERE 절에서 OR는 인덱스 활용도가 낮아 성능이 저하되는 경우가 많다. OR 연산자가 서로 다른 인덱스를 사용하게 강제하거나 아예 UNION을 사용하여 쿼리를 분리하는 것이 옵티마이저에게 더 명확한 실행 계획을 제시할 수 있어 성능상 더 좋다. (UNION은 각각의 쿼리가 독립적으로 인덱스를 활용할 수 있게 한다.)
  • EXPLAIN으로 쿼리 성능 분석이 가능하다.

통계 테이블 작성

  • 매번 통계를 조회할 때마다 복잡한 통계 쿼리를 날리는 것보다, 통계 테이블을 따로 만들어 관리하는 것이 훨씬 좋다.
  • 이 통계 테이블을 스케줄러를 통해 특정 시간마다 정합성을 맞춰주면 DB의 실시간 I/O 부담이 줄어든다.

읽기 스케일아웃 / 복제 지연 대응 전략

Master/Slave (Replica) 구조

  • 대부분의 대형 서비스는 Read/Write DB를 분리한다.
  • 이유: Read 요청이 초당 수천 건 이상 몰리는 경우, 모든 요청이 Master DB에 집중되면 Write 시 발생하는 으로 인해 전체 서비스 성능이 저하된다.
  • 용어: 주로 Master-Slave 구조 또는 Master-Replica 구조라고 부른다. Master는 쓰기(Write)를 담당하고, Slave 또는 Replica는 읽기(Read) 요청을 분산 처리한다.

복제 지연 대응

Master DB에 저장된 데이터가 아주 짧은 시간 동안 Replica DB에 복제되지 않아 최신 데이터가 안 보이는 현상이다.

  • 대응 전략:
    1. 쓰기 후 즉시 읽기 패턴: 무조건 Master DB로 요청을 보낸다. (ex: 게시글 작성 후 바로 상세 보기)
    2. 비동기 이벤트 후 데이터 조회: 복제 지연을 감안하여 대응한다. (ex: 포인트 적립 후 적립 내역 조회)
      • 캐시에 다이렉트로 쓰기: 적립 데이터를 Master DB에 쓴 후, 동시에 캐시에 최신 값을 저장하고 조회 시 캐시를 먼저 확인한다. 커다란 서비스에서는 이 방식을 자주 사용한다.
      • 응답 메시지에 포함: 응답 메시지에 적립 금액 등의 최신 정보를 담아 클라이언트에게 전달하고, 클라이언트가 이를 사용하도록 한다.
      • Sleep + Retry: 데이터가 복제될 때까지 아주 짧게 기다리며(Sleep) 조회를 다시 시도하는 방식이지만, 시스템 부하를 줄이기 위해 사용을 지양하는 것이 좋다.

파티셔닝/샤딩 전략

데이터 양이나 트래픽이 단일 DB의 한계를 넘어설 때 사용하는 수평 확장(Scale-out) 기법이다.

파티셔닝 (Partitioning)

  • 개념: 하나의 테이블에 데이터가 수억 건 이상 쌓일 때, 테이블을 물리적으로 여러 파티션으로 분할해 저장하는 기법.
  • 장소: 단일 DB 인스턴스 내에서 테이블만 분할한다.
  • 파티셔닝 방식:
    • Range: 월별, 연도별 등으로 특정 범위를 기준으로 분할.
    • List: 특정 값 목록을 기준으로 분할.
    • Hash: 해시 함수를 적용하여 균등하게 분산. (ex: user_id % 4)
    • Composite: Range와 Hash를 조합하는 등 복합적으로 사용.
  • 장점:
    • 탐색 속도 증가: 인덱스 크기가 작아져 탐색 속도가 빨라진다.
    • 락 경합 감소: INSERT도 파티션 별로 분산되어 락 경합이 줄어든다.
  • 단점: 파티션 키(Partition Key)가 잘못 설계되면 특정 파티션에만 데이터가 몰리는 핫스팟 현상이 발생할 수 있다.

샤딩 (Sharding)

  • 개념: 단일 DB 인스턴스가 감당할 수 없는 데이터양/트래픽이 발생했을 때, DB 자체를 수평 분할하여 여러 독립적인 DB 인스턴스로 만드는 기법.
  • 장소: 물리적으로 분산된 여러 DB 인스턴스에 저장.
  • 시점: 처음부터 적용하기보다는, 서비스가 성장한 후 병목 현상이 발생할 때 적용한다.
  • 장점:
    • 병목 분산: 데이터와 트래픽을 여러 서버로 분산하여 병목 현상을 해소한다.
    • 무중단 증설 가능: 필요에 따라 샤드를 추가하여 용량을 쉽게 확장한다.
  • 단점:
    • 복잡성 증가: 애플리케이션 코드가 샤드 키(Shard Key)를 기준으로 데이터를 라우팅해야 하므로 복잡성이 증가한다.
      • 샤딩 라우팅이란?
        • 단일 DB일 때 개발자는 고민할 필요가 없다. 그냥 ORM이나 SQL을 쓰면 끝이다.
        • 이 때는 쿼리만 날리면 DB가 알아서 데이터를 찾아준다. 하지만 샤딩을 하면 데이터가 물리적으로 다른 서버(DB 1, DB 2, DB 3...)에 흩어져 있다. 이제 "어떤 유저의 데이터가 몇 번째 DB 서버에 있는지"를 DB가 아니라 애플리케이션(백엔드 코드)이 결정하고 찾아가야 한다. 이걸 애플리케이션 레벨 라우팅이라고 한다.
        • 예시: User ID를 기준으로 샤딩한 경우
          # 어느 DB에 있는지 고민할 필요 없음
          user = db.query("SELECT * FROM users WHERE user_id = 100")
          
      • 샤딩 적용 후 : 이제 코드는 쿼리를 날리기 전에 "100번 유저는 어느 DB에 살고 있지?"를 계산해야 한다.
        • 코드 중복: 모든 SELECT, INSERT, UPDATE 코드마다 위와 같은 DB 선택 로직(라우팅)이 들어가거나, 이를 처리해주는 별도의 미들웨어를 직접 구현해야 한다.
        • 트랜잭션 관리의 어려움: 만약 유저 A(DB 1에 있음)가 유저 B(DB 2에 있음)에게 송금을 한다면? 서로 다른 물리적 DB라서 하나의 트랜잭션으로 묶기가 매우 까다롭다. (분산 트랜잭션 필요)
        • 데이터 재배치(Rebalancing): 나중에 샤드 서버를 3개에서 5개로 늘린다면? user_id % 3 로직을 user_id % 5로 바꿔야 하고, 기존 데이터도 전부 이동시켜야 한다. 코드는 코드대로 수정하고 데이터 마이그레이션은 별도로 또 해야 하는 대공사가 펼쳐진다.
          user_id = 100
          
          # 1. 샤드 키(user_id)를 이용해 라우팅 로직 수행 (예: 모듈러 연산)
          shard_index = user_id % 3  # 3개의 샤드 서버가 있다고 가정
          
          # 2. 계산된 인덱스에 맞는 DB 커넥션을 선택
          if shard_index == 0:
              connection = db_shard_A.connect()
          elif shard_index == 1:
              connection = db_shard_B.connect()
          else:
              connection = db_shard_C.connect()
          
          # 3. 그제서야 쿼리 실행
          user = connection.query("SELECT * FROM users WHERE user_id = 100")
    • Cross-Shared-Join 불가능: JOIN할 데이터는 동일한 샤드에 존재해야 한다. 다른 샤드에 있는 테이블 간에는 직접적인 JOIN이 불가능하다.

CQRS/Outbox 설계

CQRS (Command Query Responsibility Segregation)

  • 개념: 명령과 조회의 책임을 분리하는 패턴.
  • 효과: Write 모델과 Read 모델을 완전히 분리하여, 쓰기 모델은 정합성에, 읽기 모델은 성능 및 유연성에 집중할 수 있게 한다.

Outbox 패턴

  • 배경: DB 상태는 변경되었지만, 다른 서비스나 외부 시스템(ex: 메세지 브로커)이 이 변경을 알지 못하여 데이터 불일치 상태가 발생하는 문제를 해결하기 위해 고안된 패턴.
  • 기본 흐름 (쓰기/이벤트 발행을 원자적으로 처리):
    1. DB 쓰기 및 아웃박스 인서트: 비즈니스 로직에 따른 DB 쓰기와 동시에 Outbox 이벤트 테이블에 발행할 이벤트 정보를 단일 트랜잭션으로 인서트한다.
    2. 이벤트 폴링/전송: 별도의 Relay 프로세스가 Outbox 테이블을 주기적으로 폴링하며 미전송 이벤트를 확인한다.
    3. 메세지 브로커 전송: 이벤트를 메세지 브로커(ex: Kafka)에 전송한다.
    4. 마킹/삭제: 전송에 성공하면 해당 이벤트 레코드를 Outbox 테이블에서 삭제하거나 전송 완료로 마킹한다.

데이터 거버넌스 / 수명 주기 & GDPR

무중단 스키마 변경

  • 문제: 운영 중인 대용량 테이블에 ALTER TABLE을 사용하면, DB가 전체 테이블을 다시 쓰는 작업을 실행하여 서비스 전체가 멈추는(Lock) 현상이 발생할 수 있다.
  • 대책: 운영 환경에서는 절대 ALTER TABLE을 직접 사용하지 말고, OSC(Online Schema Change) 방식을 사용해야 한다.
  • OSC 방식:
    1. 변경 사항이 적용된 신규 테이블 생성.
    2. Trigger를 걸어 기존 테이블 변경 내용을 신규 테이블에 실시간 복제.
    3. 백그라운드에서 기존 테이블의 데이터를 신규 테이블에 복사.
    4. 복사가 완료되면 테이블 이름을 RENAME하여 맞바꾼다.

데이터 거버넌스 (Data Governance)

  • 개념: 조직 내의 모든 데이터가 정확하고, 안전하며, 일관성 있게 사용되도록 관리하는 체계 및 정책.
  • 주요 요소:
    • 스키마 네이밍 규칙 통일.
    • 민감 정보는 반드시 암호화.
    • DB 사용자 계정은 목적별로 분리하여 권한을 최소화한다.
    • 개인정보 보유 기간 및 삭제 방식(GDPR/국내 법규 준수)에 대한 명확한 기준을 준수한다.
정소민fan
@정소민fan :: 코딩은 관성이야

코딩은 관성적으로 해야합니다 즐거운 코딩 되세요

목차