단일 MongoDB 노드로 서비스를 돌리다 보면 결국 한 번은 겪는다. 프로세스가 죽거나 디스크가 꽉 차면 그 순간 데이터베이스 전체가 멈춘다. 예전에 mongodump와 oplog로 백업 복구를 정리한 글을 쓴 적이 있는데, 그건 사고가 난 뒤의 이야기고 이번엔 사고가 나도 서비스가 안 멈추게 하는 쪽이다. 레플리카셋을 구성하면 primary가 쓰러져도 남은 노드가 알아서 새 primary로 올라간다.
결론부터 말하자면, MongoDB에서 자동 장애조치를 받으려면 최소 3개의 투표 멤버로 레플리카셋을 구성하고 rs.initiate()로 초기화하면 된다. primary가 10초간 응답이 없으면 남은 secondary들이 선거를 열어 하나를 새 primary로 승격시킨다. 데이터 정합성은 쓰기 쪽 write concern과 읽기 쪽 read preference 두 축으로 맞춘다.
1. 레플리카셋과 자동 승격의 원리
레플리카셋(replica set)은 같은 데이터를 복제해 들고 있는 mongod 노드들의 묶음이다. 쓰기를 받는 노드가 하나 있고(primary), 나머지는 그 변경 로그(oplog)를 비동기로 따라 읽으며 복제본을 유지한다(secondary).
primary가 일정 시간(electionTimeoutMillis, 기본 10000ms) 동안 하트비트에 응답하지 않으면 secondary들이 선거(election)를 연다. 투표에서 과반을 먼저 얻은 멤버가 새 primary가 된다. 이 과정이 자동 승격(failover)이고, 기본 설정에서 새 primary가 뽑히는 데 걸리는 중앙값은 12초를 넘지 않는 수준이다.
여기서 핵심은 과반(majority)이다. 3노드 중 2개가 살아 있어야 선거가 성립한다. 그래서 멤버 수를 홀수로 맞추는 게 중요하다. 2노드만 두면 하나가 죽었을 때 남은 하나가 과반을 못 만들어 스스로 secondary로 주저앉는다. 쓰기를 못 받는다는 뜻이다.
2. 전제 조건
- MongoDB 서버(mongod) 3대. 실습은 한 호스트에서 포트만 다르게 띄워도 되지만, 운영은 반드시 물리적으로 분리된 3대에 올린다.
- 각 노드 사이 27017 포트(또는 지정 포트) 통신 가능.
- mongosh(MongoDB Shell) 설치.
- 멤버 지정에는 IP보다 DNS 호스트명을 권장한다. IP가 바뀌면 구성까지 고쳐야 하기 때문이다.
버전 확인부터 한다.
$ mongod --version
db version v7.0.12
Build Info: {
"version": "7.0.12",
"gitVersion": "...",
"modules": [],
"allocator": "tcmalloc",
"environment": {
"distmod": "rhel80",
"target_arch": "x86_64"
}
}
버전 번호는 설치 상태에 따라 다를 수 있다. 아래 설명은 MongoDB 5.0 이상을 전제로 한다.
3. mongod 설정과 기동
세 노드 모두 같은 레플리카셋 이름을 줘야 한다. /etc/mongod.conf에 복제 설정을 넣는다.
replication: replSetName: "rs0" net: port: 27017 bindIp: localhost,mongo1.example.net
replSetName은 세 노드에서 글자 하나까지 동일해야 한다. bindIp에는 다른 노드가 접속할 주소를 넣는다. localhost만 열어두면 서로 못 본다. 설정을 바꿨으면 재시작한다.
$ sudo systemctl restart mongod
$ systemctl status mongod
● mongod.service - MongoDB Database Server
Loaded: loaded (/usr/lib/systemd/system/mongod.service; enabled)
Active: active (running) since ...
명령줄로 직접 띄운다면 이렇게 한다.
$ mongod --replSet "rs0" --bind_ip localhost,mongo1.example.net --port 27017 --dbpath /var/lib/mongo
4. rs.initiate로 초기화
초기화 명령은 세 노드 중 딱 한 곳에서만 실행한다. 아무 노드에나 mongosh로 붙어서 멤버 목록을 넘긴다.
$ mongosh --host mongo1.example.net:27017
rs.initiate({
_id: "rs0",
members: [
{ _id: 0, host: "mongo1.example.net:27017" },
{ _id: 1, host: "mongo2.example.net:27017" },
{ _id: 2, host: "mongo3.example.net:27017" }
]
})
{ ok: 1 }
성공하면 잠시 뒤 프롬프트가 rs0 [direct: primary]> 처럼 바뀐다. 한 번 막히기 쉬운 지점이 하나 있다. members의 host에 localhost와 실제 호스트명을 섞어 쓰면 can't use localhost and non-localhost hostnames 류 에러가 난다. 셋 다 같은 방식(전부 호스트명 또는 전부 IP)으로 통일해야 한다.
상태를 본다.
rs0 [direct: primary]> rs.status()
{
set: 'rs0',
members: [
{ _id: 0, name: 'mongo1.example.net:27017', stateStr: 'PRIMARY', health: 1 },
{ _id: 1, name: 'mongo2.example.net:27017', stateStr: 'SECONDARY', health: 1 },
{ _id: 2, name: 'mongo3.example.net:27017', stateStr: 'SECONDARY', health: 1 }
],
ok: 1
}
PRIMARY 1개, SECONDARY 2개가 나오면 정상이다. 구성값은 rs.conf()로 확인한다. 초기화 직후 모든 멤버의 priority는 1, votes는 1로 동일하다.
5. 우선순위로 승격 순서 정하기
priority는 선거에서 누가 primary가 될지를 조정하는 값이다. 기본값은 1이고, 값이 높을수록 선거를 먼저 열고 이길 확률이 높다. priority: 0인 멤버는 primary가 되지 못한다.
특정 노드를 평소에 primary로 쓰고 싶다면 우선순위를 올린다. 예를 들어 사양이 좋은 mongo1을 우선 primary로 두려면 이렇게 한다.
cfg = rs.conf() cfg.members[0].priority = 2 cfg.members[1].priority = 1 cfg.members[2].priority = 1 rs.reconfig(cfg)
이렇게 두면 mongo1이 장애로 빠졌다 복구되었을 때, 레플리카셋이 계속 선거를 열어 결국 우선순위가 가장 높은 mongo1을 다시 primary로 되돌린다. 낮은 우선순위 멤버가 잠깐 primary가 될 수는 있지만 최종적으로는 최고 우선순위 멤버로 수렴한다.
주의할 점. 승격 대상을 조정할 때는 votes가 아니라 priority를 건드린다. 공식 문서도 투표 수는 멤버가 7개를 넘는 예외적 경우에만 손대라고 못 박는다.
6. arbiter는 언제 쓰나
arbiter(중재자)는 데이터를 들고 있지 않고 투표만 하는 멤버다. 데이터 노드가 2대뿐이라 홀수를 못 맞출 때, 세 번째 데이터 노드를 둘 여력은 없을 때 투표 수를 홀수로 만드는 용도로 쓴다. 이른바 P-S-A(primary, secondary, arbiter) 구성이다.
arbiter는 데이터 디렉터리만 만들어 가볍게 띄운다.
$ mkdir -p /var/lib/mongo/arb $ mongod --port 27017 --dbpath /var/lib/mongo/arb --replSet rs0 --bind_ip localhost,arb.example.net
그다음 primary에 붙어 추가한다.
rs0 [direct: primary]> rs.addArb("arb.example.net:27017")
{ ok: 1 }
다만 자동 장애조치만 놓고 보면 arbiter는 차선이다. 데이터 노드 3대(P-S-S)를 둘 수 있으면 그쪽을 권한다. 이유는 다음 절의 write concern과 얽힌다. P-S-A에서는 데이터 노드가 2대뿐이라, secondary 하나가 내려가면 w: "majority" 쓰기가 과반을 채우지 못해 지연되거나 막힐 수 있다. arbiter는 같은 호스트에 primary나 secondary와 함께 올리지 말고, 여러 대 두지도 않는다.
7. write concern으로 쓰기 정합성 맞추기
write concern은 쓰기가 몇 개의 노드에 반영되어야 성공으로 응답할지를 정하는 값이다. 자동 승격과 직접 엮인다. primary에만 쓰고 응답한 데이터는 그 primary가 승격 직전에 죽으면 롤백되어 사라질 수 있기 때문이다.
{ w: 1 }- primary 하나만 확인하면 성공. 빠르지만 장애 시 롤백 위험.{ w: "majority" }- 과반의 데이터 노드가 반영해야 성공. 승격 후에도 데이터가 남는다.{ w: 0 }- 응답을 안 기다린다. 사실상 로그성 데이터에만.
MongoDB 5.0부터 기본 write concern은 { w: "majority" }다(arbiter가 있고 데이터 노드가 과반에 못 미치는 구성이면 { w: 1 }로 내려간다). 3노드 P-S-S에서 majority는 2다. 즉 primary와 secondary 1대가 반영하면 성공으로 돌아온다.
쓰기에서 직접 지정하려면 이렇게 한다.
db.orders.insertOne(
{ sku: "A-100", qty: 3 },
{ writeConcern: { w: "majority", wtimeout: 5000 } }
)
wtimeout은 과반 확인을 기다리는 상한(ms)이다. 넘기면 에러로 돌아오지만, 이미 primary에 쓰인 내용이 취소되지는 않는다는 점을 기억해 둔다. 돈이 오가는 데이터라면 w: "majority"를 기본으로 둔다.
8. read preference로 읽기 분배하기
read preference는 읽기를 어느 멤버로 보낼지 정하는 값이다. secondary는 비동기 복제라 약간 뒤처진 데이터를 줄 수 있으므로, 정합성과 부하 분산 사이에서 고른다.
primary(기본) - 모두 primary에서 읽는다. 항상 최신. 트랜잭션은 이 모드만 된다.primaryPreferred- 평소 primary, primary가 없으면 secondary.secondary- secondary에서만. 오래된 데이터일 수 있다.secondaryPreferred- 평소 secondary, 없으면 primary. 읽기 부하 분산에 쓴다.nearest- 역할 무관, 지연이 가장 낮은 멤버.
연결 문자열로 지정하는 게 편하다.
mongodb://mongo1.example.net:27017,mongo2.example.net:27017,mongo3.example.net:27017/mydb?replicaSet=rs0&readPreference=secondaryPreferred&maxStalenessSeconds=120
maxStalenessSeconds로 "이만큼 이상 뒤처진 secondary에는 읽기를 보내지 말라"는 상한을 건다.
함정 하나. w: "majority"로 쓴 직후 secondary에서 바로 읽으면 아직 반영 전이라 예전 값이 나올 수 있다. 방금 쓴 걸 반드시 읽어야 한다면 인과적 일관성 세션(causally consistent session)을 쓰고 readConcern: "majority"와 writeConcern: "majority"를 함께 맞춘다.
9. 장애조치 확인
실제로 primary를 죽여보는 게 가장 확실하다. primary에서 강제로 물러나게 한다.
rs0 [direct: primary]> rs.stepDown()
또는 primary 프로세스를 정지시킨다.
$ sudo systemctl stop mongod
남은 노드 중 하나에 붙어 상태를 다시 본다. 10초 남짓 지나면 secondary 하나가 PRIMARY로 올라가 있다.
rs0 [direct: secondary]> rs.status().members.map(m => ({ name: m.name, state: m.stateStr }))
[
{ name: 'mongo1.example.net:27017', state: '(not reachable/healthy)' },
{ name: 'mongo2.example.net:27017', state: 'PRIMARY' },
{ name: 'mongo3.example.net:27017', state: 'SECONDARY' }
]
mongo1을 다시 띄우면 SECONDARY로 복귀한다. 앞서 우선순위를 2로 올려뒀다면 복구 후 다시 primary로 되돌아온다. 여기까지 나오면 자동 승격이 제대로 도는 것이다.
10. 정리
자동 장애조치의 최소 구성은 데이터 노드 3대(P-S-S)다. rs.initiate()로 초기화하고, 평소 primary를 고정하고 싶으면 priority를 올린다. 데이터 노드를 3대 둘 수 없을 때만 arbiter로 홀수를 맞추되, 그 구성에서는 w: "majority"가 취약해진다는 점을 안고 간다. 쓰기는 w: "majority"로 승격 후 데이터 유실을 막고, 읽기는 정합성이 필요하면 primary, 부하를 나누려면 secondaryPreferred에 maxStalenessSeconds를 걸어 쓴다.