정규화
관계/제약 조건 설계 (무결성의 중요성)
데이터베이스 설계 시 무결성은 가장 핵심적인 가치 중 하나다. 데이터의 정확성과 일관성을 보장하여, 애플리케이션 코드의 버그로부터 데이터를 보호하는 최후의 보루 역할을 한다.
핵심 용어 정리
- 무결성: 데이터베이스 내 데이터의 정확성, 일관성, 유효성을 유지하는 속성.
- 참조 무결성: 외래 키(Foreign Key)와 관련. 참조하는 테이블의 값이 반드시 참조되는 테이블에 존재하거나 NULL이어야 한다. ON DELETE CASCADE 같은 설정이 여기에 해당.
- 엔티티 무결성: 기본 키(Primary Key)와 관련. 모든 테이블은 기본 키를 가지며, 기본 키는 NULL이거나 중복된 값을 가질 수 없음을 보장한다.
- 도메인 무결성: 속성(컬럼)의 값이 정의된 유효한 값(타입, 범위, 제약조건)이어야 함을 보장한다. CHECK 제약조건, NOT NULL 등이 해당.
- 멱등성: 같은 작업을 여러 번 수행하더라도 항상 동일한 결과를 만들어내는 성질. 주로 API 설계나 분산 시스템에서 중요하게 다뤄진다.
- 데드락
설계 원칙
- DB 무결성은 서버 버그 발생 시 데이터 보호의 마지막 보루 역할을 한다.
- MVP 단계에서는 개발 속도를 위해 일단 기능을 검증하고, 이후에 점진적으로 복잡한 제약을 추가하는 것도 유효하다. 다만, UNIQUE와 NOT 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에 복제되지 않아 최신 데이터가 안 보이는 현상이다.
- 대응 전략:
- 쓰기 후 즉시 읽기 패턴: 무조건 Master DB로 요청을 보낸다. (ex: 게시글 작성 후 바로 상세 보기)
- 비동기 이벤트 후 데이터 조회: 복제 지연을 감안하여 대응한다. (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이 불가능하다.
- 복잡성 증가: 애플리케이션 코드가 샤드 키(Shard Key)를 기준으로 데이터를 라우팅해야 하므로 복잡성이 증가한다.
CQRS/Outbox 설계
CQRS (Command Query Responsibility Segregation)
- 개념: 명령과 조회의 책임을 분리하는 패턴.
- 효과: Write 모델과 Read 모델을 완전히 분리하여, 쓰기 모델은 정합성에, 읽기 모델은 성능 및 유연성에 집중할 수 있게 한다.
Outbox 패턴
- 배경: DB 상태는 변경되었지만, 다른 서비스나 외부 시스템(ex: 메세지 브로커)이 이 변경을 알지 못하여 데이터 불일치 상태가 발생하는 문제를 해결하기 위해 고안된 패턴.
- 기본 흐름 (쓰기/이벤트 발행을 원자적으로 처리):
- DB 쓰기 및 아웃박스 인서트: 비즈니스 로직에 따른 DB 쓰기와 동시에 Outbox 이벤트 테이블에 발행할 이벤트 정보를 단일 트랜잭션으로 인서트한다.
- 이벤트 폴링/전송: 별도의 Relay 프로세스가 Outbox 테이블을 주기적으로 폴링하며 미전송 이벤트를 확인한다.
- 메세지 브로커 전송: 이벤트를 메세지 브로커(ex: Kafka)에 전송한다.
- 마킹/삭제: 전송에 성공하면 해당 이벤트 레코드를 Outbox 테이블에서 삭제하거나 전송 완료로 마킹한다.
데이터 거버넌스 / 수명 주기 & GDPR
무중단 스키마 변경
- 문제: 운영 중인 대용량 테이블에 ALTER TABLE을 사용하면, DB가 전체 테이블을 다시 쓰는 작업을 실행하여 서비스 전체가 멈추는(Lock) 현상이 발생할 수 있다.
- 대책: 운영 환경에서는 절대 ALTER TABLE을 직접 사용하지 말고, OSC(Online Schema Change) 방식을 사용해야 한다.
- OSC 방식:
- 변경 사항이 적용된 신규 테이블 생성.
- Trigger를 걸어 기존 테이블 변경 내용을 신규 테이블에 실시간 복제.
- 백그라운드에서 기존 테이블의 데이터를 신규 테이블에 복사.
- 복사가 완료되면 테이블 이름을 RENAME하여 맞바꾼다.
데이터 거버넌스 (Data Governance)
- 개념: 조직 내의 모든 데이터가 정확하고, 안전하며, 일관성 있게 사용되도록 관리하는 체계 및 정책.
- 주요 요소:
- 스키마 네이밍 규칙 통일.
- 민감 정보는 반드시 암호화.
- DB 사용자 계정은 목적별로 분리하여 권한을 최소화한다.
- 개인정보 보유 기간 및 삭제 방식(GDPR/국내 법규 준수)에 대한 명확한 기준을 준수한다.
'항해 Lite' 카테고리의 다른 글
| 데이터 정합성 확보(3) - 트러블슈팅 (0) | 2025.12.09 |
|---|---|
| 데이터 정합성 확보(2) - 아웃박스 패턴 구현 (0) | 2025.12.08 |
| 서버 구조 설계(3) - 콘서트 예약 서비스 만들기 리뷰 (1) (0) | 2025.11.20 |
| 서버 구조 설계(2) - 인프라 설계 요소 (0) | 2025.11.19 |
| 서버 구조 설계(1) - 애플리케이션 구조 설계 (0) | 2025.11.19 |