데이터는 서비스의 기억이다
대부분의 웹 서비스는 데이터를 중심으로 움직인다. 회원은 가입 정보를 남기고, 상품은 가격과 재고를 가진다. 주문은 결제 상태와 배송 상태를 가진다. 화면과 API는 바뀔 수 있지만, 데이터는 서비스의 기억으로 오래 남는다.
그래서 데이터베이스를 단순히 값을 저장하는 공간으로만 보면 위험하다. 잘못 설계된 데이터 구조는 기능 추가를 어렵게 만들고, 잘못 작성된 조회는 서버 전체를 느리게 만든다. 결제, 예약, 재고처럼 여러 사용자가 동시에 접근하는 기능에서는 데이터 정합성 문제가 곧 장애로 이어질 수 있다.
데이터베이스를 배운다는 것은 SQL 문법을 외우는 일이 아니다. 현실의 개념을 어떻게 테이블로 옮길지, 필요한 데이터를 어떻게 정확히 조회할지, 동시에 변경되는 데이터를 어떻게 안전하게 다룰지 판단하는 법을 익히는 일이다.
AI 생성 이미지
현실의 개념을 테이블로 옮기기
관계형 데이터베이스는 데이터를 테이블, 행, 열로 표현한다. 테이블은 같은 종류의 데이터를 담는 구조다. 행은 하나의 실제 데이터이며, 열은 데이터가 가진 속성이다.
예를 들어 쇼핑몰의 회원을 저장한다면 users 테이블을 만들 수 있다.
users
- id
- email
- name
- created_at여기서 users는 회원이라는 개념을 담는 테이블이다. 각 회원 한 명은 행이 되고, email, name, created_at은 열이 된다.
기본키는 각 행을 구분하는 고유한 값이다. 보통 id 같은 값을 기본키로 둔다. 이메일도 고유할 수 있지만, 이메일은 사용자가 변경할 수 있다. 반면 내부 식별자인 id는 바뀌지 않는 값으로 두는 편이 안정적이다.
외래키는 다른 테이블의 기본키를 참조하는 값이다. 주문 테이블을 생각해 보자.
orders
- id
- user_id
- status
- total_price
- created_atorders.user_id는 users.id를 가리킨다. 이 관계를 통해 어떤 사용자가 어떤 주문을 했는지 알 수 있다. 외래키는 단순한 연결 값이 아니라 데이터의 의미를 보존하는 장치다. 데이터베이스에 외래키 제약을 설정하면 존재하지 않는 사용자에게 주문이 연결되는 일을 막을 수 있다.
데이터 모델링의 핵심은 질문을 잘게 나누는 것이다.
이 데이터는 무엇을 나타내는가?
같은 값이 여러 곳에 반복 저장되고 있지는 않은가?
이 값은 시간이 지나도 변하지 않는 식별자인가?
하나의 회원이 여러 주문을 가질 수 있는가?
하나의 주문에 여러 상품이 들어갈 수 있는가?
마지막 질문은 주문과 상품의 관계를 보여준다. 한 주문에는 여러 상품이 들어갈 수 있고, 한 상품도 여러 주문에 포함될 수 있다. 이런 다대다 관계는 보통 중간 테이블로 표현한다.
order_items
- id
- order_id
- product_id
- quantity
- price_at_orderorder_items는 주문과 상품을 연결하면서, 주문 안에 들어간 개별 상품 항목을 표현한다. 여기서 price_at_order가 중요하다. 상품 가격은 나중에 바뀔 수 있다. 하지만 과거 주문의 결제 금액은 바뀌면 안 된다. 따라서 주문 당시의 가격을 주문 항목에 따로 저장해야 한다. 좋은 모델링은 현재 데이터뿐 아니라 시간이 지나도 보존되어야 할 의미를 함께 고려한다.
SQL은 요구사항을 데이터 언어로 바꾸는 도구다
SQL은 데이터베이스에 원하는 데이터를 요청하는 언어다. 중요한 것은 문법 자체보다 요구사항을 SQL로 바꾸는 사고다.
가장 기본은 SELECT다. 예를 들어 최근 가입한 회원 10명을 보고 싶다면 다음처럼 쓸 수 있다.
SELECT id, email, name, created_at
FROM users
ORDER BY created_at DESC
LIMIT 10;조건이 필요하면 WHERE를 사용한다.
SELECT id, email, status
FROM users
WHERE status = 'ACTIVE';실무의 조회는 한 테이블에서 끝나지 않는 경우가 많다. 주문 목록에 회원 이메일을 함께 보여주려면 orders와 users를 연결해야 한다. 이때 JOIN을 사용한다.
SELECT o.id, o.status, o.total_price, u.email
FROM orders o
JOIN users u ON o.user_id = u.id
WHERE o.status = 'PAID'
ORDER BY o.created_at DESC;JOIN은 관계형 모델의 핵심이다. 테이블을 나누어 중복을 줄이고, 필요한 순간 관계를 따라 데이터를 합쳐 읽는다. 다만 JOIN이 많아질수록 데이터 양과 조건에 따라 비용이 커질 수 있으므로, 어떤 테이블을 어떤 조건으로 연결하는지 이해해야 한다.
집계가 필요하면 GROUP BY를 사용한다. 예를 들어 사용자별 주문 금액 합계를 구할 수 있다.
SELECT user_id, SUM(total_price) AS total_spent
FROM orders
WHERE status = 'PAID'
GROUP BY user_id;GROUP BY는 같은 기준으로 행을 묶은 뒤 합계, 개수, 평균 같은 값을 계산한다. 매출 통계, 사용자 활동 수, 상품별 판매량 같은 기능에서 자주 쓰인다.
서브쿼리는 쿼리 안에 들어가는 또 다른 쿼리다. 예를 들어 결제 완료된 주문 중에서 평균 결제 금액보다 큰 주문을 찾을 수 있다.
SELECT id, user_id, total_price
FROM orders
WHERE status = 'PAID'
AND total_price > (
SELECT AVG(total_price)
FROM orders
WHERE status = 'PAID'
);서브쿼리는 읽기 쉬운 경우도 있지만, 복잡해지면 성능과 가독성 모두 나빠질 수 있다. 이때는 JOIN, 임시 테이블, 뷰, 애플리케이션 로직 분리 등을 함께 검토해야 한다.
좋은 SQL은 원하는 결과를 정확히 반환할 뿐 아니라, 데이터 양이 늘어도 감당 가능한 방식으로 실행되어야 한다. 작은 개발 데이터에서는 빠른 쿼리도 실제 운영 데이터 수백만 건에서는 장애 원인이 될 수 있다. 그래서 SQL을 작성할 때는 "지금 결과가 맞는가"와 함께 "데이터가 늘어도 이 방식이 괜찮은가"를 같이 봐야 한다.
인덱스는 빠른 조회를 위한 목차다
인덱스는 데이터베이스가 특정 값을 빠르게 찾기 위해 사용하는 별도 구조다. 책에서 원하는 주제를 찾을 때 모든 페이지를 처음부터 읽지 않고 목차나 색인을 보는 것과 비슷하다.
예를 들어 로그인할 때 이메일로 사용자를 찾는다면 다음 쿼리가 자주 실행된다.
SELECT id, email, password_hash
FROM users
WHERE email = '[email protected]';email에 인덱스가 없으면 데이터베이스는 많은 행을 하나씩 확인해야 할 수 있다. 이를 전체 스캔이라고 한다. 반대로 email에 인덱스가 있으면 해당 값을 더 빠르게 찾을 수 있다.
인덱스는 다음과 같은 열에 특히 유용하다.
WHERE조건에 자주 사용되는 열JOIN에 사용되는 외래키 열ORDER BY나 범위 검색에 자주 사용되는 열고유해야 하는 값, 예를 들어 이메일이나 주문 번호
하지만 인덱스는 공짜가 아니다. 인덱스를 만들면 조회는 빨라질 수 있지만, 쓰기 작업은 느려질 수 있다. 새로운 행을 추가하거나 값을 수정할 때 테이블뿐 아니라 인덱스도 함께 갱신해야 하기 때문이다. 또한 인덱스는 별도 저장 공간을 사용한다.
따라서 인덱스는 많이 만들수록 좋은 것이 아니다. 자주 쓰는 조회 패턴을 기준으로 만들어야 한다. 예를 들어 주문 목록에서 특정 사용자의 최신 주문을 자주 조회한다면 user_id와 created_at을 함께 고려한 복합 인덱스가 도움이 될 수 있다.
SELECT id, status, total_price, created_at
FROM orders
WHERE user_id = 10
ORDER BY created_at DESC
LIMIT 20;이런 쿼리가 많다면 user_id만 보는 것이 아니라 정렬 조건까지 함께 봐야 한다. 예를 들어 (user_id, created_at) 형태의 복합 인덱스는 특정 사용자의 주문을 찾고 최신순으로 가져오는 데 도움을 줄 수 있다. 다만 복합 인덱스는 열의 순서와 조건의 형태에 따라 효과가 달라지므로, 테이블 구조만 보고 정하기보다 실제 쿼리와 사용 빈도를 보고 정해야 한다.
성능 문제가 생겼을 때는 감으로 인덱스를 추가하지 말아야 한다. 실행 계획을 확인해 데이터베이스가 어떤 방식으로 테이블을 읽고 있는지 봐야 한다. 느린 쿼리의 원인이 인덱스 부족인지, 잘못된 JOIN인지, 너무 많은 데이터를 한 번에 읽는 API 설계인지 구분해야 한다.
트랜잭션은 여러 변경을 하나의 약속으로 묶는다
트랜잭션은 여러 데이터 변경을 하나의 작업 단위로 묶는 기능이다. 전부 성공하거나, 하나라도 실패하면 전부 취소되어야 하는 작업에 필요하다.
주문 결제를 예로 들어 보자.
주문 상태를 결제 완료로 바꾼다.
결제 기록을 저장한다.
상품 재고를 줄인다.
쿠폰을 사용 처리한다.
이 중 일부만 성공하면 데이터가 망가진다. 결제는 되었는데 주문 상태가 그대로이거나, 재고는 줄었는데 결제 기록이 없으면 고객 문의와 정산 문제가 생긴다. 트랜잭션은 이런 작업을 하나로 묶어 데이터 일관성을 지킨다. 모든 작업이 성공하면 커밋하고, 중간에 문제가 생기면 롤백해 이전 상태로 되돌리는 식이다.
트랜잭션의 기본 성질은 ACID로 설명한다.
원자성: 작업은 전부 성공하거나 전부 실패해야 한다.
일관성: 작업 전후 데이터는 정해진 규칙을 만족해야 한다.
격리성: 동시에 실행되는 트랜잭션이 서로를 이상하게 방해하지 않아야 한다.
지속성: 성공한 결과는 장애가 나도 보존되어야 한다.
실무에서 특히 어려운 부분은 격리성이다. 여러 사용자가 동시에 같은 데이터를 바꾸면 문제가 생긴다. 예를 들어 남은 좌석이 1개인 공연을 두 사용자가 동시에 예약할 수 있다. 두 요청이 모두 재고 1개를 읽고 각각 예약을 성공시키면 실제보다 많은 예약이 생긴다.
이를 막기 위해 데이터베이스는 락을 사용한다. 락은 특정 데이터를 읽거나 쓰는 동안 다른 작업이 함부로 변경하지 못하게 막는 장치다. 락은 정합성을 지키지만, 너무 오래 잡으면 다른 요청이 대기하면서 서비스가 느려진다. 그래서 트랜잭션 안에서는 꼭 필요한 작업만 빠르게 처리하고, 외부 API 호출처럼 오래 걸릴 수 있는 작업을 무심코 끼워 넣지 않도록 주의해야 한다.
격리 수준은 트랜잭션끼리 서로 얼마나 강하게 분리할지 정하는 설정이다. 낮은 격리 수준은 성능에 유리할 수 있지만, 동시에 실행될 때 이상한 읽기가 발생할 가능성이 있다. 높은 격리 수준은 안전하지만 락 대기와 충돌이 늘 수 있다.
결제, 재고, 예약처럼 틀리면 안 되는 기능에서는 단순히 빠른 것보다 정확한 것이 우선이다. 반면 조회 통계나 추천 목록처럼 약간 늦게 반영되어도 되는 데이터는 다른 전략을 쓸 수 있다. 데이터베이스 설계는 항상 정확성, 성능, 사용자 경험 사이의 균형을 판단해야 한다.
RDBMS와 NoSQL은 상황에 따른 선택지다
관계형 데이터베이스, 즉 RDBMS는 테이블과 관계를 기반으로 데이터를 저장한다. MySQL, PostgreSQL, Oracle, SQL Server 같은 시스템이 대표적이다. RDBMS는 정해진 스키마, SQL, 트랜잭션, JOIN이 강점이다. 회원, 주문, 결제, 정산처럼 관계와 정합성이 중요한 데이터에 잘 맞는다.
NoSQL은 하나의 제품군이 아니라 여러 유형의 데이터 저장 방식을 묶어 부르는 말이다. 문서형 데이터베이스, 키-값 저장소, 와이드 컬럼 저장소, 그래프 데이터베이스 등이 있다. 예를 들어 문서형 데이터베이스는 JSON과 비슷한 구조로 데이터를 저장해 필드 변화가 잦은 데이터에 유연하게 대응할 수 있다. 키-값 저장소는 캐시나 세션처럼 키로 값을 빠르게 찾는 데 적합하다.
중요한 것은 RDBMS와 NoSQL을 정답 경쟁으로 보지 않는 것이다. 선택 기준은 데이터의 성격이다.
데이터 간 관계가 복잡하고 정확성이 중요한가?
스키마가 안정적인가, 자주 바뀌는가?
JOIN과 트랜잭션이 중요한가?
읽기와 쓰기 중 어느 쪽이 더 많은가?
수평 확장이 중요한가?
잠깐의 불일치를 허용할 수 있는가?
예를 들어 주문과 결제 원장은 RDBMS가 자연스럽다. 반대로 사용자별 임시 설정, 캐시, 이벤트 로그, 대규모 클릭 스트림은 NoSQL이나 별도 저장소가 더 적합할 수 있다.
현대 서비스에서는 하나의 데이터베이스만 쓰지 않는 경우도 많다. 핵심 거래 데이터는 RDBMS에 저장하고, 빠른 조회를 위해 캐시를 두며, 검색 기능은 검색 엔진에 맡기고, 분석용 이벤트는 데이터 웨어하우스에 쌓을 수 있다. 이때 중요한 것은 데이터의 원본이 어디인지, 불일치가 생겼을 때 어떤 값을 신뢰할지 정하는 것이다.
좋은 데이터 설계는 변경을 견딘다
데이터베이스 설계에서 완벽한 정답은 드물다. 처음에는 단순한 테이블로 시작해도 기능이 늘어나면 관계가 복잡해진다. 그래서 좋은 설계는 지금 필요한 기능만이 아니라 앞으로 생길 변경을 견딜 수 있어야 한다.
그렇다고 처음부터 모든 가능성을 대비해 복잡하게 만들 필요는 없다. 중요한 것은 데이터의 의미를 명확히 하고, 식별자를 안정적으로 두며, 중복 저장이 필요한 경우 그 이유를 분명히 하는 것이다. 또한 자주 실행되는 조회를 알고, 인덱스와 트랜잭션을 실제 사용 패턴에 맞게 설계해야 한다.
데이터베이스 문제는 늦게 발견될수록 고치기 어렵다. 코드 구조는 리팩터링으로 비교적 빠르게 바꿀 수 있지만, 운영 중인 데이터 구조 변경은 마이그레이션, 배포 순서, 백필, 장애 대응까지 고려해야 한다. 특히 이미 쌓인 데이터는 단순히 컬럼 하나를 바꾸는 일도 서비스 중단, 데이터 변환, 이전 버전 코드와의 호환성을 함께 생각해야 할 수 있다.
결국 데이터베이스의 핵심 질문은 단순하다. 이 데이터는 무엇을 의미하는가. 누가 언제 바꾸는가. 어떤 조건으로 자주 읽는가. 동시에 바뀌어도 안전한가. 이 질문에 답할 수 있을 때, 서비스는 더 안정적이고 예측 가능한 방향으로 성장한다.
이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.