| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | ||||
| 4 | 5 | 6 | 7 | 8 | 9 | 10 |
| 11 | 12 | 13 | 14 | 15 | 16 | 17 |
| 18 | 19 | 20 | 21 | 22 | 23 | 24 |
| 25 | 26 | 27 | 28 | 29 | 30 | 31 |
- 이펙티브자바
- Sort
- k8s
- kubernetes
- 이펙티브 자바
- Effective Java
- ElasticSearch
- 자바
- 스프링
- springboot
- 알고리즘정렬
- 예제로 배우는 스프링 입문
- java
- 카카오
- 알고리즘
- 데이터베이스
- 오블완
- 티스토리챌린지
- JavaScript
- Kotlin
- 클린아키텍처
- Spring
- Effective Java 3
- effectivejava
- 엘라스틱서치
- 자바스크립트
- 스프링 핵심원리
- 스프링부트
- 김영한
- 스프링핵심원리
- Today
- Total
Kim-Baek 개발자 이야기
NoSQL 특징 - 언제, 왜 사용하는가 본문
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: 대용량 데이터
- 폴리글랏 퍼시스턴스 (혼합 사용)
'컴퓨터 공학 > DB' 카테고리의 다른 글
| DB 트랜잭션 격리 수준 - 동시성과 일관성의 트레이드오프 (0) | 2026.01.10 |
|---|---|
| DB 백업 및 복구 전략 - 데이터 손실 제로를 향한 여정 (1) | 2026.01.09 |
| 데이터베이스 - RDBMS 특징 (1) | 2026.01.09 |
| MongoDB에서 Time Series 기능을 사용하지 않고 일반 컬렉션을 활용하는 것과의 차이점 (0) | 2025.01.21 |
| MongoDB Time Series Collection (몽고DB 시계열 컬렉션)란? (1) | 2025.01.21 |
