Skip to main content
Summary

저장소 선택은 CRUD 이후의 문제다

대부분의 애플리케이션은 데이터를 저장하고 다시 읽는다. 그래서 처음에는 데이터베이스를 단순히 "값을 넣고 꺼내는 곳"으로 생각하기 쉽다. 하지만 서비스가 커지면 저장소는 단순한 보관함이 아니다. 어떤 데이터를 한곳에 모을지, 어떤 관계를 강제할지, 어떤 조회를 빠르게 만들지, 장애가 났을 때 어디까지 믿을 수 있을지를 결정하는 핵심 설계 요소가 된다.

예를 들어 주문 API를 만든다고 하자. 주문을 저장하는 일 자체는 어렵지 않아 보인다. 하지만 실제로는 더 많은 질문이 따라온다. 존재하지 않는 사용자로 주문이 생성되면 안 된다. 삭제된 상품을 주문 이력에서 어떻게 보여 줄지도 정해야 한다. 사용자의 주문 목록은 빠르게 조회되어야 하지만, 주문 생성 시 재고와 결제 상태도 정확해야 한다. 이런 요구사항은 단순 CRUD만으로 해결되지 않는다.

데이터베이스 설계의 핵심은 세 가지다. 데이터가 틀어지지 않게 하는 것, 필요한 조회를 충분히 빠르게 만드는 것, 앞으로 데이터가 늘어났을 때 감당 가능한 구조를 고르는 것이다.

데이터베이스와 저장소 설계AI 생성 이미지

관계형 모델은 데이터의 약속을 구조로 만든다

관계형 데이터베이스는 데이터를 테이블로 표현한다. 테이블은 엑셀 시트처럼 행과 열로 구성된다. 행은 하나의 데이터 단위이고, 열은 그 데이터가 가진 속성이다. users 테이블의 한 행은 한 명의 사용자이고, email, name, created_at 같은 열은 사용자의 속성이다.

여기서 중요한 점은 테이블이 단순한 표가 아니라는 것이다. 테이블은 "이 데이터는 이런 모양이어야 한다"는 약속을 담는다. 이 약속을 스키마라고 한다. 스키마에는 열 이름, 타입, 비어 있어도 되는지, 중복이 가능한지 같은 규칙이 포함된다. 이 규칙 덕분에 애플리케이션 코드가 실수하더라도 데이터베이스가 잘못된 데이터를 막아 줄 수 있다.

기본키는 각 행을 구분하는 식별자다. 사용자 ID, 주문 ID처럼 한 행을 정확히 가리키는 값이다. 기본키는 중복되면 안 되고, 가능하면 변하지 않아야 한다. 이메일처럼 바뀔 수 있는 값도 고유 식별자로 쓸 수는 있지만, 사용자가 이메일을 변경하는 순간 여러 참조가 흔들릴 수 있다. 그래서 실무에서는 별도의 숫자 ID나 UUID를 기본키로 두는 경우가 많다.

외래키는 다른 테이블의 행을 참조하는 값이다. orders.user_id가 users.id를 참조하면, 주문은 반드시 어떤 사용자와 연결된다. 외래키 제약을 사용하면 존재하지 않는 사용자 ID로 주문이 생성되는 일을 데이터베이스 수준에서 막을 수 있다. 이것이 데이터 무결성이다. 데이터 무결성은 데이터가 현실의 규칙과 어긋나지 않도록 유지하는 성질이다.

물론 모든 무결성을 외래키만으로 표현할 수는 없다. NOT NULL은 필수 값을 강제하고, UNIQUE는 중복을 막고, CHECK는 값의 범위를 제한한다. 이런 제약 조건은 귀찮은 장식이 아니다. 버그가 데이터로 굳어지는 것을 막는 마지막 방어선이다.

정규화는 중복을 줄이고, 반정규화는 읽기를 줄인다

정규화는 불필요한 중복을 줄이고 데이터를 적절한 단위로 나누는 설계 방식이다. 같은 사실을 여러 곳에 저장하지 않고 한곳에만 저장하는 것이 목표다. 예를 들어 사용자의 현재 이름을 주문 테이블마다 반복해서 저장하면, 사용자가 이름을 바꿨을 때 여러 주문 행을 모두 수정해야 할 수 있다. 하나라도 빠지면 데이터가 서로 달라진다. 이런 문제를 갱신 이상이라고 한다.

정규화된 설계에서는 사용자 정보는 users에, 주문 정보는 orders에, 주문 상품 정보는 order_items에 나눈다. 각 테이블은 자기 책임만 가진다. 덕분에 데이터 변경은 단순해지고, 중복으로 인한 불일치 위험도 줄어든다.

하지만 정규화가 항상 조회 성능에 유리한 것은 아니다. 화면 하나를 만들기 위해 여러 테이블을 조인해야 할 수 있다. 조인은 관계형 데이터베이스의 강력한 기능이지만, 데이터가 커지고 요청이 많아지면 비용이 된다. 특히 자주 조회되는 목록 페이지나 통계성 화면에서 매번 많은 테이블을 조인하면 응답 시간이 늘어날 수 있다.

반정규화는 의도적으로 중복을 허용하는 설계다. 예를 들어 주문 이력에 주문 당시의 상품명을 함께 저장하면, 상품명이 나중에 바뀌어도 사용자가 구매했던 시점의 상품명을 그대로 보여 줄 수 있다. 이때의 상품명은 단순한 중복이라기보다 "주문 당시의 기록"이라는 의미도 가진다. 게시글 목록에 댓글 수를 미리 저장해 두면 매번 댓글 테이블을 세지 않아도 된다. 검색 화면을 위해 별도의 조회용 테이블을 만들 수도 있다.

반정규화는 성능이나 기록 보존을 위해 중복을 사는 선택이다. 대신 중복된 값을 누가, 언제, 어떻게 갱신할지 정해야 한다. 댓글이 추가될 때 게시글의 댓글 수를 함께 증가시킬지, 배치 작업으로 나중에 맞출지, 잠시 틀어져도 허용할지 결정해야 한다. 따라서 반정규화는 "정규화를 몰라서 합치는 것"이 아니라, 비용을 알고 선택하는 최적화다.

인덱스는 빠른 조회를 위한 별도 길이다

테이블에 데이터가 백만 건 있는데 특정 이메일을 가진 사용자를 찾는다고 하자. 인덱스가 없다면 데이터베이스는 처음부터 끝까지 행을 확인해야 할 수 있다. 이를 전체 테이블 스캔이라고 한다. 데이터가 적을 때는 문제없지만, 커질수록 응답 시간이 늘어난다.

인덱스는 책의 색인과 비슷하다. 책에서 특정 단어가 나온 페이지를 찾기 위해 처음부터 읽지 않고 색인을 보듯, 데이터베이스는 인덱스를 통해 원하는 값이 있는 위치를 빠르게 찾는다. 관계형 데이터베이스의 일반적인 인덱스는 정렬된 자료구조를 사용해 특정 값 검색, 범위 검색, 정렬, 조인 조건을 빠르게 처리한다.

하지만 인덱스는 공짜가 아니다. 먼저 저장 공간을 차지한다. 인덱스는 원본 테이블과 별도로 관리되는 구조이기 때문이다. 또한 쓰기 성능을 떨어뜨릴 수 있다. 행을 삽입하거나 수정하거나 삭제할 때 테이블만 바꾸는 것이 아니라 관련 인덱스도 함께 갱신해야 한다. 인덱스가 많을수록 쓰기 작업의 부담이 커진다.

그래서 모든 열에 인덱스를 거는 것은 좋은 전략이 아니다. 자주 조회 조건에 쓰이는 열, 조인에 쓰이는 열, 정렬에 자주 쓰이는 열에 우선 적용해야 한다. 반대로 값의 종류가 적은 열은 인덱스 효과가 작을 수 있다. 예를 들어 gender, is_deleted처럼 선택도가 낮은 열은 인덱스를 사용해도 많은 행을 읽어야 하므로 큰 이득이 없을 수 있다. 선택도는 인덱스 값이 얼마나 데이터를 잘 좁혀 주는지를 뜻한다.

복합 인덱스도 주의해야 한다. (user_id, created_at) 인덱스는 사용자별 최신 주문을 찾는 데 유용할 수 있다. 하지만 열의 순서에 따라 사용할 수 있는 쿼리가 달라진다. 예를 들어 user_id로 먼저 좁힌 뒤 created_at으로 정렬하거나 범위를 찾는 쿼리에는 잘 맞지만, created_at만 조건으로 쓰는 쿼리에는 기대만큼 도움이 되지 않을 수 있다. 인덱스는 단순히 "있다/없다"가 아니라 쿼리 패턴과 함께 설계해야 한다.

실무에서 인덱스 문제는 성능 장애로 자주 나타난다. 개발 환경에서는 데이터가 적어 빠르던 쿼리가 운영 환경에서 갑자기 느려진다. 실행 계획을 확인해 보면 인덱스를 쓰지 못하고 전체 테이블을 스캔하는 경우가 있다. 이때 필요한 것은 막연한 인덱스 추가가 아니라, 어떤 조건으로 얼마나 많은 행을 읽는지 보는 습관이다.

NoSQL은 관계형 데이터베이스의 반대말이 아니다

NoSQL은 하나의 제품이나 모델을 뜻하지 않는다. 관계형 모델과 다른 방식으로 데이터를 저장하고 조회하는 여러 저장소를 넓게 부르는 말이다. NoSQL을 선택하는 이유도 다양하다. 유연한 스키마가 필요할 수도 있고, 매우 큰 쓰기 처리량이 필요할 수도 있고, 관계 탐색이 핵심일 수도 있다.

문서형 데이터베이스는 JSON과 비슷한 문서 단위로 데이터를 저장한다. 게시글과 작성자 일부 정보, 태그, 설정값처럼 함께 읽히는 데이터를 한 문서에 담기 좋다. 스키마 변화가 잦은 서비스 초기에 유연하게 대응하기도 쉽다. 대신 문서 사이의 복잡한 관계를 자주 조인해야 한다면 관계형 데이터베이스보다 불편할 수 있다. 같은 정보가 여러 문서에 중복되면 갱신 일관성도 직접 관리해야 한다.

키-값 저장소는 가장 단순한 형태다. 키를 주면 값을 돌려준다. 세션, 캐시, 인증 토큰, 임시 상태 저장처럼 빠른 단건 조회에 적합하다. 구조가 단순한 만큼 복잡한 검색이나 조인에는 맞지 않는다. "사용자 ID로 장바구니를 가져온다"처럼 접근 패턴이 명확할 때 강하다.

NoSQL에서 말하는 컬럼형 저장소는 보통 와이드 컬럼 또는 컬럼 패밀리 저장소를 가리킨다. 행마다 서로 다른 컬럼을 넓게 가질 수 있고, 대량 쓰기와 분산 저장에 강한 경우가 많다. 로그, 이벤트, 시계열 데이터처럼 데이터가 계속 쌓이고 특정 키나 시간 범위로 조회하는 패턴에 어울린다. 다만 관계형 데이터베이스처럼 자유롭게 조건을 바꿔 가며 조회하는 용도에는 맞지 않을 수 있다. 이런 저장소는 보통 "어떻게 조회할 것인가"를 먼저 정하고 테이블을 설계한다.

그래프 데이터베이스는 데이터 사이의 관계 자체가 핵심일 때 유용하다. 사용자 간 친구 관계, 추천 경로, 조직도, 권한 상속, 사기 거래 탐지처럼 "A와 B가 몇 단계로 연결되어 있는가"를 자주 묻는 문제에 강하다. 관계형 데이터베이스에서도 조인으로 표현할 수 있지만, 관계 깊이가 가변적이고 탐색이 잦다면 그래프 모델이 더 자연스러울 수 있다.

중요한 것은 NoSQL이 관계형 데이터베이스보다 항상 빠르거나 확장성이 좋다는 식의 단순 비교를 피하는 것이다. 어떤 저장소든 잘 맞는 접근 패턴이 있고, 맞지 않는 질의가 있다. 저장소 선택은 제품명이 아니라 데이터 모델과 조회 방식의 문제다.

저장소 설계는 접근 패턴에서 시작한다

좋은 데이터베이스 설계는 "어떤 기술이 인기 있는가"보다 "우리 서비스가 데이터를 어떻게 사용하는가"에서 출발한다. 다음 질문을 먼저 던져야 한다.

  • 어떤 데이터가 함께 생성되고 함께 읽히는가?

  • 가장 자주 실행되는 조회는 무엇인가?

  • 읽기가 많은가, 쓰기가 많은가?

  • 데이터가 틀어지면 즉시 문제가 되는가, 잠시 후 맞아도 되는가?

  • 관계가 중요한가, 단건 조회가 중요한가?

  • 데이터 크기는 얼마나 빠르게 증가하는가?

  • 운영팀이 백업, 복구, 모니터링, 장애 대응을 감당할 수 있는가?

요구사항이 명확하지 않은 일반적인 서비스에서는 관계형 데이터베이스가 좋은 출발점인 경우가 많다. 스키마, 제약 조건, 조인, 트랜잭션이 균형 있게 제공되기 때문이다. 반면 접근 패턴이 분명하고 특정 성능 요구가 강하면 문서형, 키-값, 컬럼형, 그래프 저장소를 함께 고려할 수 있다.

다만 저장소를 여러 개 쓰는 것은 복잡도를 늘린다. 같은 데이터를 두 저장소에 나누어 저장하면 동기화, 장애 복구, 데이터 불일치 문제가 생긴다. 검색 성능을 위해 별도 검색 엔진을 붙이거나, 캐시를 추가하거나, 분석용 저장소를 분리할 때도 마찬가지다. 성능 이득만큼 운영 비용도 생긴다.

데이터베이스 설계의 목표는 완벽한 모델을 처음부터 만드는 것이 아니다. 현재 요구사항을 안전하게 만족하면서, 데이터가 커졌을 때 어느 부분이 비용이 될지 예측 가능한 구조를 만드는 것이다. 기본키와 외래키는 데이터의 뼈대를 세운다. 정규화와 반정규화는 변경 비용과 조회 비용 사이의 균형을 잡는다. 인덱스는 읽기 성능을 높이지만 쓰기와 저장 공간을 소비한다. NoSQL 저장소는 특정 문제에 더 잘 맞는 다른 모델을 제공한다.

결국 개발자가 판단해야 할 질문은 하나다. "이 데이터는 어떤 형태로 저장될 때 가장 안전하고, 가장 자주 필요한 방식으로 빠르게 읽힐 수 있는가?" 이 질문에 답할 수 있을 때, 데이터베이스는 단순한 CRUD 대상이 아니라 시스템 설계의 중심 도구가 된다.

책 목록

개발자가 알아야 할 컴퓨터 과학 지식 정리

전체 목차 보기

이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.

틸노트는 사용자가 AI를 활용해 노트를 쉽게 작성하거나 편집할 수 있는 서비스입니다.

AI 스퀘어 단톡방에 함께해요

AI 활용과 틸노트 사용 팁을 나누는 오픈채팅방입니다. 입장 비밀번호는 tilnote입니다.

참여하기