Kim-Baek 개발자 이야기

NoSQL 특징 - 언제, 왜 사용하는가 본문

컴퓨터 공학/DB

NoSQL 특징 - 언제, 왜 사용하는가

김백개발자 2026. 1. 9. 12:31
반응형

MySQL로는 처리할 수 없었다

Court Alarm이 2년째 운영되면서 새로운 기능을 추가하기로 했다. 실시간 채팅이었다.

사용자들이 코트를 예약하면서 서로 메시지를 주고받고, 매칭을 할 수 있게 하는 것이었다. 간단해 보였다.

-- 채팅 메시지 테이블
CREATE TABLE chat_messages (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    room_id BIGINT NOT NULL,
    user_id BIGINT NOT NULL,
    message TEXT NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    INDEX idx_room_created (room_id, created_at)
);

베타 테스트를 시작했다. 처음엔 괜찮았다. 그런데 사용자가 늘면서 문제가 생기기 시작했다.

동시 접속자: 5,000명
초당 메시지: 10,000건
하루 메시지: 8억 6,400만 건

MySQL:
- 쓰기 TPS: 5,000 (한계)
- 메시지 조회: 2초 (느림)
- 디스크: 하루 50GB 증가
- 인덱스 크기: 테이블의 80%

MySQL이 버티지 못했다. 채팅은 RDBMS가 설계된 용도가 아니었다.

고민 끝에 MongoDB로 전환했다.

// MongoDB 스키마
{
  _id: ObjectId("..."),
  room_id: "court_123",
  user_id: 456,
  message: "테니스 치실 분?",
  created_at: ISODate("2024-01-10T10:30:00Z")
}

결과:

MongoDB:
- 쓰기 TPS: 50,000 (10배)
- 메시지 조회: 50ms (40배 빠름)
- 디스크: 하루 20GB (압축)
- 샤딩으로 무한 확장

이 경험으로 언제 NoSQL을 써야 하는지, 각 NoSQL의 특성이 무엇인지 제대로 이해하게 됐다.

NoSQL이란 무엇인가

정의

NoSQL은 "Not Only SQL" 또는 "Non-Relational" 데이터베이스다.

핵심:

  • 관계형 모델 아님
  • 유연한 스키마
  • 수평 확장 (Scale-out)
  • 분산 시스템 지향
  • 최종 일관성 (Eventual Consistency)

RDBMS vs NoSQL

특성 RDBMS NoSQL

데이터 모델 테이블, 행, 열 다양 (Document, Key-Value 등)
스키마 고정 (Strict) 유연 (Flexible)
확장 수직 (Scale-up) 수평 (Scale-out)
트랜잭션 ACID BASE (대부분)
JOIN 지원 미지원 또는 제한적
일관성 즉시 (Immediate) 최종 (Eventual)
사용 정형 데이터, 복잡한 쿼리 비정형 데이터, 단순 쿼리

BASE 특성

RDBMS의 ACID 대신 BASE:

BA - Basically Available (기본적으로 가용)

  • 일부 노드 장애에도 서비스 계속
  • 완벽한 응답은 보장 안 함

S - Soft state (유연한 상태)

  • 시간에 따라 상태 변할 수 있음
  • 외부 입력 없이도 변화 가능

E - Eventually consistent (최종적 일관성)

  • 즉시 일관성 보장 안 함
  • 시간이 지나면 일관성 달성

예시:

RDBMS (ACID):
User A: balance = 100,000
User B: balance = 100,000

Transfer 10,000 (A → B):
1. A: 90,000
2. B: 110,000
3. 모든 노드에서 즉시 같은 값

NoSQL (BASE):
User A: balance = 100,000
User B: balance = 100,000

Transfer 10,000 (A → B):
1. Node 1: A=90,000, B=110,000 ✓
2. Node 2: A=100,000, B=100,000 (아직 반영 안 됨)
3. Node 3: A=90,000, B=100,000 (일부만 반영)
... 시간이 지나면 ...
4. 모든 노드: A=90,000, B=110,000 ✓

NoSQL 종류

1. Key-Value Store

가장 단순한 구조:

Key          Value
───────────  ─────────────────
user:1001    {"name": "규철", "age": 30}
session:abc  "user_id=1001;login_time=..."
cache:page1  "<html>...</html>"

대표 제품:

  • Redis: 인메모리, 초고속, 다양한 자료구조
  • Memcached: 인메모리 캐시
  • DynamoDB: AWS 관리형, 무한 확장

특징:

  • 가장 빠름 (O(1) 조회)
  • 단순 CRUD
  • 복잡한 쿼리 불가

사용 사례:

// Redis로 세션 관리
@Service
class SessionService(
    private val redisTemplate: RedisTemplate<String, String>
) {
    
    fun createSession(userId: Long): String {
        val sessionId = UUID.randomUUID().toString()
        val key = "session:$sessionId"
        
        // 세션 데이터 저장 (24시간 TTL)
        redisTemplate.opsForValue().set(
            key,
            userId.toString(),
            24,
            TimeUnit.HOURS
        )
        
        return sessionId
    }
    
    fun getSession(sessionId: String): Long? {
        val key = "session:$sessionId"
        val userId = redisTemplate.opsForValue().get(key)
        return userId?.toLongOrNull()
    }
    
    fun deleteSession(sessionId: String) {
        val key = "session:$sessionId"
        redisTemplate.delete(key)
    }
}

Redis 고급 자료구조:

// Sorted Set으로 랭킹
redisTemplate.opsForZSet().add("leaderboard", "user:1001", 1500.0)
redisTemplate.opsForZSet().add("leaderboard", "user:1002", 1800.0)

// 상위 10명 조회
val top10 = redisTemplate.opsForZSet()
    .reverseRangeWithScores("leaderboard", 0, 9)

// List로 최근 활동
redisTemplate.opsForList().leftPush("recent:user:1001", "action:view_court")
redisTemplate.opsForList().leftPush("recent:user:1001", "action:reservation")

// 최근 10개 조회
val recent = redisTemplate.opsForList().range("recent:user:1001", 0, 9)

Court Alarm 사용:

  • 세션 관리 (로그인 상태)
  • 캐시 (코트 목록, 사용자 정보)
  • Rate Limiting (API 호출 제한)
  • 실시간 랭킹

2. Document Store

문서 단위로 저장:

// MongoDB Document
{
  _id: ObjectId("65a1b2c3d4e5f6789"),
  user_id: 1001,
  name: "규철",
  email: "test@test.com",
  profile: {
    age: 30,
    city: "Seoul",
    interests: ["tennis", "coding"]
  },
  reservations: [
    {
      court_id: 123,
      date: ISODate("2024-01-15"),
      status: "confirmed"
    },
    {
      court_id: 456,
      date: ISODate("2024-01-20"),
      status: "pending"
    }
  ],
  created_at: ISODate("2024-01-01T00:00:00Z")
}

대표 제품:

  • MongoDB: 가장 인기, JSON 기반
  • CouchDB: HTTP REST API
  • Couchbase: 메모리 + 디스크

특징:

  • JSON/BSON 형식
  • 중첩 구조 지원
  • 유연한 스키마
  • 강력한 쿼리 (집계, 텍스트 검색)

MongoDB 쿼리:

// 조회
db.users.find({ 
  "profile.city": "Seoul",
  "profile.age": { $gte: 25, $lte: 35 }
})

// 집계 (Aggregation)
db.reservations.aggregate([
  { $match: { status: "confirmed" } },
  { $group: {
      _id: "$court_id",
      count: { $sum: 1 },
      total_revenue: { $sum: "$price" }
  }},
  { $sort: { total_revenue: -1 } },
  { $limit: 10 }
])

// 업데이트
db.users.updateOne(
  { user_id: 1001 },
  { 
    $set: { "profile.age": 31 },
    $push: { 
      reservations: {
        court_id: 789,
        date: ISODate("2024-01-25"),
        status: "confirmed"
      }
    }
  }
)

Spring Data MongoDB:

// Document 모델
@Document(collection = "chat_messages")
data class ChatMessage(
    @Id
    val id: String? = null,
    
    @Field("room_id")
    @Indexed
    val roomId: String,
    
    @Field("user_id")
    @Indexed
    val userId: Long,
    
    val message: String,
    
    val attachments: List<Attachment> = emptyList(),
    
    @Field("created_at")
    @Indexed
    val createdAt: Instant = Instant.now(),
    
    val metadata: Map<String, Any> = emptyMap()
)

data class Attachment(
    val type: String,  // "image", "file"
    val url: String,
    val size: Long
)

// Repository
interface ChatMessageRepository : MongoRepository<ChatMessage, String> {
    
    // 방의 최근 메시지 조회
    fun findByRoomIdOrderByCreatedAtDesc(
        roomId: String,
        pageable: Pageable
    ): List<ChatMessage>
    
    // 특정 시간 이후 메시지
    fun findByRoomIdAndCreatedAtAfter(
        roomId: String,
        after: Instant
    ): List<ChatMessage>
}

// 사용
@Service
class ChatService(
    private val messageRepository: ChatMessageRepository
) {
    
    fun sendMessage(
        roomId: String,
        userId: Long,
        message: String,
        attachments: List<Attachment> = emptyList()
    ): ChatMessage {
        val chatMessage = ChatMessage(
            roomId = roomId,
            userId = userId,
            message = message,
            attachments = attachments
        )
        
        return messageRepository.save(chatMessage)
    }
    
    fun getRecentMessages(roomId: String, limit: Int = 50): List<ChatMessage> {
        val pageable = PageRequest.of(0, limit)
        return messageRepository.findByRoomIdOrderByCreatedAtDesc(roomId, pageable)
    }
}

Court Alarm 사용:

  • 채팅 메시지
  • 사용자 활동 로그
  • 알림 이력
  • 리뷰 및 평점

3. Column Family Store

컬럼 단위로 저장:

Row Key: user:1001
┌─────────────┬──────────────────────────────────────────┐
│ Column      │ Value                                    │
├─────────────┼──────────────────────────────────────────┤
│ name        │ "규철"                                   │
│ email       │ "test@test.com"                          │
│ age         │ 30                                       │
│ city        │ "Seoul"                                  │
│ last_login  │ "2024-01-10T10:30:00Z"                   │
└─────────────┴──────────────────────────────────────────┘

Row Key: user:1002
┌─────────────┬──────────────────────────────────────────┐
│ name        │ "민수"                                   │
│ phone       │ "010-1234-5678"                          │  ← 다른 컬럼!
│ interests   │ ["tennis", "soccer"]                     │
└─────────────┴──────────────────────────────────────────┘

대표 제품:

  • Cassandra: 분산, 고가용성
  • HBase: Hadoop 기반, 대용량
  • ScyllaDB: C++ 구현, Cassandra 호환

특징:

  • 컬럼 단위 압축 (같은 타입)
  • 분산 저장 (샤딩)
  • 쓰기 최적화
  • 시계열 데이터에 강함

Cassandra 예시:

-- 테이블 생성
CREATE TABLE sensor_data (
    sensor_id TEXT,
    timestamp TIMESTAMP,
    temperature DOUBLE,
    humidity DOUBLE,
    PRIMARY KEY (sensor_id, timestamp)
) WITH CLUSTERING ORDER BY (timestamp DESC);

-- 데이터 삽입
INSERT INTO sensor_data (sensor_id, timestamp, temperature, humidity)
VALUES ('sensor_001', '2024-01-10 10:30:00', 25.5, 60.0);

-- 조회 (특정 센서의 최근 데이터)
SELECT * FROM sensor_data
WHERE sensor_id = 'sensor_001'
AND timestamp > '2024-01-10 00:00:00'
ORDER BY timestamp DESC
LIMIT 100;

사용 사례:

  • 시계열 데이터 (센서, 로그)
  • 쓰기 많은 시스템
  • 대용량 분석

4. Graph Database

노드와 관계로 저장:

(User:규철)─[FRIEND]→(User:민수)
     │
     └─[RESERVED]→(Court:강남 코트A)
              │
              └─[LOCATED_IN]→(City:서울)

(User:민수)─[RESERVED]→(Court:강남 코트A)

대표 제품:

  • Neo4j: 가장 인기, Cypher 쿼리
  • Amazon Neptune: AWS 관리형
  • JanusGraph: 오픈소스, 분산

특징:

  • 관계 탐색에 최적화
  • 복잡한 연결 쿼리 빠름
  • 소셜 네트워크에 적합

Neo4j Cypher 쿼리:

// 노드 생성
CREATE (u:User {id: 1001, name: "규철"})
CREATE (c:Court {id: 123, name: "강남 코트A"})
CREATE (u)-[:RESERVED]->(c)

// 친구의 친구 찾기
MATCH (me:User {id: 1001})-[:FRIEND]->(friend)-[:FRIEND]->(fof)
WHERE NOT (me)-[:FRIEND]->(fof) AND me <> fof
RETURN DISTINCT fof

// 추천 시스템
MATCH (me:User {id: 1001})-[:RESERVED]->(c:Court)<-[:RESERVED]-(other)
WHERE me <> other
MATCH (other)-[:RESERVED]->(recommend:Court)
WHERE NOT (me)-[:RESERVED]->(recommend)
RETURN recommend, COUNT(*) as score
ORDER BY score DESC
LIMIT 5

Spring Data Neo4j:

// 노드
@Node
data class User(
    @Id
    @GeneratedValue
    val id: Long? = null,
    
    val userId: Long,
    val name: String,
    
    @Relationship(type = "FRIEND", direction = Relationship.Direction.OUTGOING)
    val friends: MutableSet<User> = mutableSetOf(),
    
    @Relationship(type = "RESERVED", direction = Relationship.Direction.OUTGOING)
    val reservations: MutableSet<Court> = mutableSetOf()
)

@Node
data class Court(
    @Id
    @GeneratedValue
    val id: Long? = null,
    
    val courtId: Long,
    val name: String
)

// Repository
interface UserRepository : Neo4jRepository<User, Long> {
    
    @Query("""
        MATCH (me:User {userId: ${'$'}myId})-[:FRIEND*2..2]->(fof)
        WHERE NOT (me)-[:FRIEND]->(fof) AND me.userId <> fof.userId
        RETURN DISTINCT fof
    """)
    fun findFriendsOfFriends(myId: Long): List<User>
    
    @Query("""
        MATCH (me:User {userId: ${'$'}myId})-[:RESERVED]->(c:Court)<-[:RESERVED]-(other)
        WHERE me.userId <> other.userId
        MATCH (other)-[:RESERVED]->(recommend:Court)
        WHERE NOT (me)-[:RESERVED]->(recommend)
        RETURN recommend, COUNT(*) as score
        ORDER BY score DESC
        LIMIT ${'$'}limit
    """)
    fun recommendCourts(myId: Long, limit: Int): List<Court>
}

사용 사례:

  • 소셜 네트워크
  • 추천 시스템
  • 사기 탐지
  • 지식 그래프

CAP 정리

CAP Theorem

분산 시스템은 3가지 중 2가지만 만족 가능:

C - Consistency (일관성)

  • 모든 노드가 같은 데이터
  • 읽기는 항상 최신 쓰기 반영

A - Availability (가용성)

  • 모든 요청이 응답 받음
  • 일부 노드 장애에도 서비스

P - Partition Tolerance (분할 내성)

  • 네트워크 분할에도 동작
  • 노드 간 통신 장애 허용
        C (일관성)
         ╱  ╲
        ╱    ╲
       ╱  CA  ╲
      ╱ (불가능)╲
     ╱          ╲
    ╱            ╲
   CP ────────── AP
(일관성+분할)  (가용성+분할)

CA: Consistency + Availability

분할 내성 포기 (단일 노드 또는 완벽한 네트워크):

예: RDBMS (단일 서버)
- 모든 데이터 일관
- 항상 응답
- 하지만 네트워크 분할 시 불가능

문제:

  • 단일 장애점
  • 확장 어려움

실제로는 존재 불가 (네트워크는 항상 분할 가능)

CP: Consistency + Partition Tolerance

가용성 포기 (일관성 우선):

예: MongoDB, HBase, Redis (단일 마스터)

상황: 네트워크 분할 발생
Node 1 (Master): 쓰기 가능
Node 2 (Replica): 읽기 차단! (오래된 데이터 반환 방지)

결과:
- 일관성 보장 ✓
- 일부 노드 응답 못 함 ✗

특징:

  • 최신 데이터 보장
  • 분할 시 일부 노드 차단
  • 금융, 재고 관리에 적합

AP: Availability + Partition Tolerance

일관성 포기 (가용성 우선):

예: Cassandra, DynamoDB, Riak

상황: 네트워크 분할 발생
Node 1: balance = 100,000 (최신)
Node 2: balance = 90,000 (오래됨)

결과:
- 모든 노드 응답 ✓
- 일시적으로 다른 데이터 ✗
- 나중에 일관성 달성 (Eventual Consistency)

특징:

  • 항상 응답
  • 최종 일관성
  • 소셜 미디어, 분석에 적합

실제 선택

DB 유형 특징

MySQL (단일) CA 일관성 + 가용성, 분산 불가
MongoDB (단일 마스터) CP 일관성 우선, 분할 시 차단
Cassandra AP 가용성 우선, 최종 일관성
PostgreSQL (단일) CA RDBMS, 분산 제한적
Riak AP 고가용성, 최종 일관성

Court Alarm 선택:

1. 사용자 인증, 결제: RDBMS (CP)
   - 일관성 필수
   - 단일 서버 또는 Master-Replica
   
2. 채팅 메시지: MongoDB (CP)
   - 일관성 중요 (메시지 순서)
   - 샤딩으로 확장
   
3. 세션, 캐시: Redis (CP)
   - 빠른 응답
   - 일관성 필요
   
4. 사용자 활동 로그: Cassandra (AP)
   - 쓰기 많음
   - 일관성 덜 중요
   - 최종 일관성 허용

실전: MySQL → MongoDB 전환

전환 이유

MySQL의 한계:

-- 채팅 메시지 테이블
CREATE TABLE chat_messages (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    room_id VARCHAR(50) NOT NULL,
    user_id BIGINT NOT NULL,
    message TEXT NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    INDEX idx_room_created (room_id, created_at)
);

-- 문제:
-- 1. 하루 1억 건 삽입 (쓰기 병목)
-- 2. 인덱스 크기 = 테이블의 80% (디스크 낭비)
-- 3. AUTO_INCREMENT 병목 (단일 지점)
-- 4. 샤딩 어려움 (AUTO_INCREMENT, JOIN)

성능 측정:

부하 테스트 (동시 사용자 5,000명):
MySQL:
- 쓰기 TPS: 5,000
- 조회 지연: 2,000ms
- CPU: 90% (I/O 대기)
- 디스크: 하루 50GB 증가

MongoDB로 전환

스키마 설계:

// MongoDB Document
{
  _id: ObjectId("..."),  // 분산 ID 자동 생성
  room_id: "court_123",
  user: {  // 임베딩 (JOIN 불필요)
    id: 1001,
    name: "규철",
    avatar_url: "https://..."
  },
  message: "테니스 치실 분?",
  attachments: [
    {
      type: "image",
      url: "https://...",
      size: 102400
    }
  ],
  created_at: ISODate("2024-01-10T10:30:00Z"),
  read_by: [1002, 1003]  // 읽음 표시
}

// 인덱스
db.chat_messages.createIndex({ room_id: 1, created_at: -1 })
db.chat_messages.createIndex({ "user.id": 1 })

Kotlin 코드:

@Document(collection = "chat_messages")
data class ChatMessage(
    @Id
    val id: String? = null,
    
    @Field("room_id")
    @Indexed
    val roomId: String,
    
    val user: UserInfo,
    val message: String,
    val attachments: List<Attachment> = emptyList(),
    
    @Field("created_at")
    @Indexed
    val createdAt: Instant = Instant.now(),
    
    @Field("read_by")
    val readBy: MutableSet<Long> = mutableSetOf()
)

data class UserInfo(
    val id: Long,
    val name: String,
    @Field("avatar_url")
    val avatarUrl: String?
)

// Service
@Service
class ChatService(
    private val messageRepository: ChatMessageRepository,
    private val mongoTemplate: MongoTemplate
) {
    
    fun sendMessage(
        roomId: String,
        userId: Long,
        userName: String,
        message: String
    ): ChatMessage {
        val chatMessage = ChatMessage(
            roomId = roomId,
            user = UserInfo(
                id = userId,
                name = userName,
                avatarUrl = null
            ),
            message = message
        )
        
        return messageRepository.save(chatMessage)
    }
    
    fun getMessages(
        roomId: String,
        before: Instant? = null,
        limit: Int = 50
    ): List<ChatMessage> {
        val query = Query()
            .addCriteria(Criteria.where("room_id").`is`(roomId))
            .with(Sort.by(Sort.Direction.DESC, "created_at"))
            .limit(limit)
        
        if (before != null) {
            query.addCriteria(Criteria.where("created_at").lt(before))
        }
        
        return mongoTemplate.find(query, ChatMessage::class.java)
    }
    
    fun markAsRead(messageId: String, userId: Long) {
        val query = Query.query(Criteria.where("_id").`is`(messageId))
        val update = Update().addToSet("read_by", userId)
        
        mongoTemplate.updateFirst(query, update, ChatMessage::class.java)
    }
}

샤딩 설정

MongoDB Sharding:

// room_id 기준 샤딩
sh.enableSharding("court_alarm")
sh.shardCollection(
  "court_alarm.chat_messages",
  { room_id: "hashed" }  // 해시 샤딩
)

// 결과:
// Shard 1: room_id 해시값 0~5000
// Shard 2: room_id 해시값 5001~10000
// Shard 3: room_id 해시값 10001~15000

자동 분산:

채팅방 "court_123" 메시지 → Shard 1
채팅방 "court_456" 메시지 → Shard 2
채팅방 "court_789" 메시지 → Shard 3

각 샤드가 쓰기 부담 분산

성능 비교

부하 테스트 (동시 사용자 5,000명):

MongoDB:
- 쓰기 TPS: 50,000 (10배)
- 조회 지연: 50ms (40배 빠름)
- CPU: 40% (효율적)
- 디스크: 하루 20GB (압축)

추가 이점:
- 샤딩으로 무한 확장
- 스키마 변경 자유로움
- ObjectId로 분산 ID

마이그레이션 과정

@Service
class MigrationService(
    private val jdbcTemplate: JdbcTemplate,
    private val mongoTemplate: MongoTemplate
) {
    
    @Async
    fun migrateMessages() {
        val batchSize = 10000
        var offset = 0L
        
        while (true) {
            // MySQL에서 배치 조회
            val messages = jdbcTemplate.query(
                """
                SELECT id, room_id, user_id, message, created_at
                FROM chat_messages
                WHERE id > ?
                ORDER BY id
                LIMIT ?
                """,
                { rs, _ ->
                    ChatMessage(
                        roomId = rs.getString("room_id"),
                        user = UserInfo(
                            id = rs.getLong("user_id"),
                            name = getUserName(rs.getLong("user_id")),
                            avatarUrl = null
                        ),
                        message = rs.getString("message"),
                        createdAt = rs.getTimestamp("created_at").toInstant()
                    )
                },
                offset,
                batchSize
            )
            
            if (messages.isEmpty()) break
            
            // MongoDB에 배치 삽입
            mongoTemplate.insertAll(messages)
            
            offset = messages.last().id?.toLongOrNull() ?: break
            
            logger.info("마이그레이션 진행: $offset")
        }
        
        logger.info("마이그레이션 완료")
    }
}

전환 전략:

1. Dual Write (2주)
   - MySQL + MongoDB 동시 쓰기
   - MongoDB 읽기 테스트
   
2. Read Migration (1주)
   - 신규 사용자 → MongoDB 읽기
   - 기존 사용자 → MySQL 읽기
   
3. Full Migration (1주)
   - 모든 읽기 → MongoDB
   - MySQL 쓰기 중단
   
4. MySQL 제거 (1개월 후)
   - 백업 후 테이블 삭제

언제 NoSQL을 쓸까

NoSQL이 적합한 경우

✅ 1. 유연한 스키마 필요

// MongoDB: 사용자마다 다른 필드
{
  _id: ObjectId("..."),
  user_id: 1001,
  name: "규철",
  email: "test@test.com",
  // 일부 사용자만 추가 필드
  social_links: {
    twitter: "@kyuchul",
    instagram: "kyuchul_insta"
  }
}

{
  _id: ObjectId("..."),
  user_id: 1002,
  name: "민수",
  phone: "010-1234-5678"
  // 다른 필드 조합
}

✅ 2. 대용량 쓰기

로그 수집 시스템:
- 초당 100,000건 삽입
- NoSQL (Cassandra): 분산 쓰기
- RDBMS: 단일 마스터 병목

✅ 3. 수평 확장 필요

샤딩 (Sharding):
Shard 1 (user_id 1~100만)
Shard 2 (user_id 100만~200만)
Shard 3 (user_id 200만~300만)
→ 무한 확장 가능

✅ 4. 최종 일관성 허용

소셜 미디어 좋아요:
- 즉시 정확한 개수 불필요
- 몇 초 후 일치하면 OK
- AP 시스템 (Cassandra)

✅ 5. 비정형 데이터

// 로그, 센서 데이터
{
  timestamp: ISODate("..."),
  sensor_id: "temp_001",
  data: {
    temperature: 25.5,
    humidity: 60.0,
    // 센서마다 다른 필드
    pressure: 1013.25
  }
}

RDBMS가 적합한 경우

✅ 1. 복잡한 쿼리와 JOIN

-- 다중 테이블 JOIN + 집계
SELECT 
    u.name,
    COUNT(r.id) as reservation_count,
    SUM(r.price) as total_spent,
    AVG(rv.rating) as avg_rating
FROM users u
LEFT JOIN reservations r ON u.id = r.user_id
LEFT JOIN reviews rv ON r.id = rv.reservation_id
WHERE r.created_at >= '2024-01-01'
GROUP BY u.id
HAVING total_spent > 100000
ORDER BY total_spent DESC;

-- NoSQL에서는 매우 어렵거나 불가능

✅ 2. ACID 트랜잭션 필수

-- 계좌 이체: 원자성 필수
START TRANSACTION;
UPDATE accounts SET balance = balance - 10000 WHERE id = 1;
UPDATE accounts SET balance = balance + 10000 WHERE id = 2;
COMMIT;

-- NoSQL: 분산 트랜잭션 지원 제한적

✅ 3. 정형 데이터

-- 고정된 스키마
CREATE TABLE users (
    id INT PRIMARY KEY,
    email VARCHAR(100) UNIQUE NOT NULL,
    age INT CHECK (age >= 0)
);

-- 제약조건으로 데이터 품질 보장

✅ 4. 복잡한 비즈니스 로직

-- 트리거, 저장 프로시저
CREATE TRIGGER check_inventory
BEFORE INSERT ON orders
FOR EACH ROW
BEGIN
    IF (SELECT stock FROM products WHERE id = NEW.product_id) < NEW.quantity THEN
        SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '재고 부족';
    END IF;
END;

Court Alarm 사용 사례

MySQL (RDBMS):
- 사용자 계정 (인증, 권한)
- 코트 정보 (마스터 데이터)
- 예약 (트랜잭션 필수)
- 결제 (ACID 필수)

MongoDB (Document):
- 채팅 메시지 (유연한 스키마)
- 사용자 활동 로그
- 리뷰 (중첩 구조)

Redis (Key-Value):
- 세션 (빠른 조회)
- 캐시 (코트 목록)
- Rate Limiting

Elasticsearch:
- 전문 검색 (코트 이름, 위치)
- 로그 분석

면접에서 이렇게 답하자

1단계: NoSQL 정의 (30초)

"NoSQL은 관계형 모델이 아닌 데이터베이스로, 유연한 스키마와 수평 확장을 지원합니다. ACID 대신 BASE 특성을 가지며, 최종 일관성을 허용해 고가용성을 달성합니다. Key-Value, Document, Column Family, Graph 4가지 유형이 있습니다."

2단계: CAP 정리 (1분)

"CAP 정리는 분산 시스템이 일관성, 가용성, 분할 내성 중 2가지만 만족할 수 있다는 이론입니다. MongoDB는 CP로 일관성을 우선하고, Cassandra는 AP로 가용성을 우선합니다. 실제로는 네트워크 분할이 불가피하므로 CP와 AP 중 선택해야 합니다."

3단계: 실무 경험 (2분 30초)

"Court Alarm에서 채팅 기능을 MySQL로 구현했을 때 쓰기 TPS 5,000이 한계였고 조회가 2초 걸렸습니다. 하루 1억 건의 메시지로 인덱스가 테이블의 80%를 차지했고, AUTO_INCREMENT가 병목이었습니다."

"MongoDB로 전환하면서 Document 모델로 사용자 정보를 임베딩해 JOIN을 제거했고, room_id 기준 해시 샤딩으로 3개 샤드에 분산했습니다. 결과적으로 쓰기 TPS가 50,000으로 10배 증가했고, 조회 지연은 50ms로 40배 개선됐습니다. ObjectId로 분산 ID를 생성해 AUTO_INCREMENT 병목도 해결했습니다."

4단계: 선택 기준 (1분)

"RDBMS는 복잡한 JOIN과 ACID 트랜잭션이 필요한 경우 사용합니다. Court Alarm에서는 사용자 인증, 예약, 결제에 MySQL을 사용합니다. NoSQL은 대용량 쓰기와 유연한 스키마가 필요할 때 사용하며, 채팅 메시지와 활동 로그는 MongoDB, 세션과 캐시는 Redis를 사용합니다."

핵심 팁

구체적인 수치:

  • ❌ "NoSQL이 빨라요"
  • ✅ "TPS 5,000 → 50,000 (10배), 조회 2초 → 50ms (40배), 샤딩 3개로 분산"

CAP 이해:

  • ❌ "MongoDB는 빨라요"
  • ✅ "MongoDB는 CP로 일관성 우선, 마스터-레플리카 구조, 분할 시 레플리카 차단"

적절한 선택:

  • 트랜잭션 → RDBMS
  • 대용량 쓰기 → NoSQL
  • 복잡한 JOIN → RDBMS
  • 유연한 스키마 → NoSQL

정리

NoSQL은 특정 상황에서 RDBMS보다 훨씬 효율적이다.

NoSQL 특징:

  • 유연한 스키마
  • 수평 확장 (샤딩)
  • BASE (최종 일관성)
  • 4가지 유형

NoSQL 유형:

  • Key-Value: Redis (세션, 캐시)
  • Document: MongoDB (JSON 데이터)
  • Column Family: Cassandra (시계열)
  • Graph: Neo4j (관계 탐색)

CAP 정리:

  • CP: 일관성 우선 (MongoDB)
  • AP: 가용성 우선 (Cassandra)
  • CA: 분산 불가 (RDBMS 단일)

선택 기준:

상황 선택

복잡한 JOIN RDBMS
ACID 트랜잭션 RDBMS
대용량 쓰기 NoSQL
유연한 스키마 NoSQL
수평 확장 NoSQL
최종 일관성 허용 NoSQL

실무 조합:

  • RDBMS: 핵심 비즈니스 로직
  • NoSQL: 대용량 데이터
  • 폴리글랏 퍼시스턴스 (혼합 사용)

 

반응형
Comments