
엥 Atlas에서 클러스터 하나 공짜로 주는데 이걸 왜 로컬에..?
네 간단하게 쓰기엔 무료로 제공해주는 클러스터를 사용하는게 매우 편합니다.
근데 왜 이렇게 글까지 써가면서 만들까요? 그 이유는요~
1. 느려요
2. 테스트 수행시 독립된 환경을 만들기가 어려워요
3. 실제 DB가 얼마나 부하를 받고 있는지 등의 모니터링이 불가능해요
4. AdminCommand 일부가 제한되어 직접 세세한 부분의 커스터마이징이 불가능해요 (이건 유료써도 그래요)
위 이유들중 1,3번은 유료 클러스터를 쓰면 해결 되고, 2,4번은 불가능하진 않지만 번거롭거나 돈이들어가요(매번 초기화, 티켓 발행등..)
또 나중에 TestContainers를 사용해 통합 테스트를 구축할 경우를 대비해 미리 한 번 구축해보시면 좋아요!
우리의 목표는 위 사진과 같이 Primary-Secondary-Secondary 구성의 MongoDB Cluster를 우리의 로컬 PC 한 대에서 구축하는 거에요.
저는 각 mongodb 노드를 편하게 관리하고 싶기 때문에, Docker와 Docker Compose를 이용해 구성해볼게요.
먼저 제 환경과 사용할 Tool, 버전은 아래를 참고해주세요
Os: Mac / Apple Silicon
Tool: Docker, DockerCompose, Mongosh
MongoDB Ver: Community 6.0.20 linux/arm64/v8
docker-compose.yml을 작성합니다.
name: "mongo-cluster"
services:
mongo1:
image: mongo:6.0.20
hostname: mongo1
container_name: mongo1
restart: always
ports:
- "27017:27017"
environment:
MONGO_INITDB_ROOT_USERNAME: test
MONGO_INITDB_ROOT_PASSWORD: "1234"
MONGO_INITDB_DATABASE: test
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- /Users/devgyu/Desktop/mongo/data/node1:/data/db
- /Users/devgyu/Desktop/mongo/keyfile/common:/usr/mongo/key
command:
- "--replSet"
- "testCluster"
- "--bind_ip"
- "localhost,mongo1"
- "--keyFile"
- "/usr/mongo/key/common.key"
- "--port"
- "27017"
networks:
mongoCluster:
aliases:
- mongo1
mongo2:
image: mongo:6.0.20
hostname: mongo2
container_name: mongo2
restart: always
ports:
- "27018:27017"
depends_on:
- mongo1
environment:
MONGO_INITDB_ROOT_USERNAME: test
MONGO_INITDB_ROOT_PASSWORD: "1234"
MONGO_INITDB_DATABASE: test
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- /Users/devgyu/Desktop/mongo/data/node2:/data/db
- /Users/devgyu/Desktop/mongo/keyfile/common:/usr/mongo/key
command:
- "--replSet"
- "testCluster"
- "--bind_ip"
- "localhost,mongo2"
- "--keyFile"
- "/usr/mongo/key/common.key"
- "--port"
- "27017"
networks:
mongoCluster:
aliases:
- mongo2
mongo3:
image: mongo:6.0.20
hostname: mongo3
container_name: mongo3
restart: always
ports:
- "27019:27017"
depends_on:
- mongo1
- mongo2
environment:
MONGO_INITDB_ROOT_USERNAME: test
MONGO_INITDB_ROOT_PASSWORD: "1234"
MONGO_INITDB_DATABASE: test
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- /Users/devgyu/Desktop/mongo/data/node3:/data/db
- /Users/devgyu/Desktop/mongo/keyfile/common:/usr/mongo/key
command:
- "--replSet"
- "testCluster"
- "--bind_ip"
- "localhost,mongo3"
- "--keyFile"
- "/usr/mongo/key/common.key"
- "--port"
- "27017"
networks:
mongoCluster:
aliases:
- mongo3
networks:
mongoCluster:
driver: bridge
하나의 docker-compose.yml 이용해 컨테이너 3대를 띄우려 하다보니 뭔가 장황해졌는데요 하나씩 살펴 보겠습니다.
mongo1: # docker-compose.yml의 서비스 명
image: mongo:6.0.20 # 컨테이너에서 실행할 이미지
hostname: mongo1 # 해당 컨테이너가 속한 네트워크에서 이 컨테이너를 지칭할 호스트 이름
container_name: mongo1 # 컨테이너 명
restart: always # 컨테이너가 종료되면 다시 시작할 건지 여부
ports: # 포트 포워딩
- "27017:27017" # 내 PC의 27017 포트를 컨테이너의 27017 포트와 매칭
environment: # 환경 변수 설정
MONGO_INITDB_ROOT_USERNAME: test # MongoDB가 실행될때 Root 계정 Id
MONGO_INITDB_ROOT_PASSWORD: "1234" # MongoDB가 실행될때 Root 계정 비밀번호
MONGO_INITDB_DATABASE: test # MongoDB가 실행될때 처음 생성할 DB명
volumes: # 내 PC <-> Docker Volume 매칭
- /var/run/docker.sock:/var/run/docker.sock # 로그파일 저장 위치 지정
- /Users/devgyu/Desktop/mongo/data/node1:/data/db # Database 데이터 파일 위치 지정
- /Users/devgyu/Desktop/mongo/keyfile/common:/usr/mongo/key # Cluster 구축시 필요한 암호키 위치 지정
command: # 컨테이너 실행 후 실행할 커맨드를 지정
- "--replSet" # 클러스터 정보 세팅
- "testCluster" # 클러스터 명 정의
- "--bind_ip" # 클러스터 내부에서 해당 호스트의 별칭 정의
- "localhost,mongo1"
- "--keyFile" # 클러스터 연결시 사용할 인증키 위치
- "/usr/mongo/key/common.key"
- "--port" # 몽고DB 실행할 컨테이너 포트
- "27017"
networks: # 해당 컨테이너가 속할 네트워크 지정
mongoCluster:
aliases: # 이 컨테이너의 네트워크 별칭 지정
- mongo1
이제 터미널(파워쉘 등)을 실행하고 작성한 docker-compose.yml이 위치한 곳으로 이동한 뒤 아래 명령어를 입력합니다.
docker-compose up -d --build
여기서 -d 옵션은 백그라운드에서 실행하겠다는 의미이고,
--build는 모든 서비스 실행시 기존에 캐싱된 이미지 대신 항상 새로 빌드한 이미지를 이용해 실행하라는 의미입니다.
커맨드를 입력하고 docker desktop app의 화면을 보면 아래와 같이 클러스터가 형성됩니다
하지만 아직 클러스터끼리 연결을 해 준것은 아니기 때문에 다시 터미널에 아래 명령어를 입력 해 줍니다.
(한번 입력하면 위 docker-compose.yml에 입력한 data 저장 볼륨을 삭제하기 전까지는 알아서 클러스터 연결해 줍니다)
mongosh -u test -p 1234 --eval "rs.initiate({_id: \"testCluster\",members: [{_id: 0, host: \"mongo1\"},{_id: 1, host: \"mongo2\"},{_id: 2, host: \"mongo3\"}]})"
입력하면 { ok: 1 }이라고 아래 사진과 같이 응답이 옵니다.

그리고 아래 커맨드로 mongodb에 다시 접속해보면
mongosh -u test -p 1234
testCluster [direct: primary] test>
이렇게 클러스터 노드에 직접적으로 접속한 것을 볼 수 있습니다.
다만 이는 클러스터에 접속한 것은 아니기 때문에, 항상 접속하는 노드가 Primary Node라는 보장이 없고,
접속한 노드가 Secondary Node라면 Command Operation시 아래와 같이 예외가 발생합니다.

이를 해결하기 전에 먼저 해줘야 하는 것이 있는데요, 우리가 클러스터에 접속하려면 위에서 클러스터를 만들때 지정한 노드의 호스트 이름을 커넥션 주소에 써줘야 접속을 할 수 있겠죠?.
그런데, 아쉽게도 우리의 PC는 위에 지정한 호스트 이름의 ip주소를 모르기 때문에 접속을 할 수 없어요.
그러기 때문에 우리의 PC에 호스트 이름의 ip주소가 우리 PC자체의 주소(127.0.0.1) 이라는 것을 알려주도록 할게요.
아래 명령어를 입력한 뒤 맨 아래에 호스트 정보를 입력해 주세요
sudo vim /private/etc/hosts
127.0.0.1 mongo1
127.0.0.1 mongo2
127.0.0.1 mongo3

이렇게 적은 다음 저장해주세요.
다음으로는 드디어 우리가 만든 MongoDB 클러스터에 접속 해 보겠습니다.
mongosh "mongodb://mongo1:27017,mongo2:27018,mongo3:27019/test?authSource=admin&readPreference=primary" -u test -p 1234
입력하면 아래와 같이 클러스터의 Primary Node에 접속하게 됩니다
testCluster [primary] test>
이상태에서 rs.status() 명령어를 입력하면 아래와 같이 클러스터의 정보가 나오게 됩니다
testCluster [primary] test> rs.status()
{
set: 'testCluster',
date: ISODate('2025-01-18T13:20:27.126Z'),
myState: 1,
term: Long('1'),
syncSourceHost: '',
syncSourceId: -1,
heartbeatIntervalMillis: Long('2000'),
majorityVoteCount: 2,
writeMajorityCount: 2,
votingMembersCount: 3,
writableVotingMembersCount: 3,
optimes: {
lastCommittedOpTime: { ts: Timestamp({ t: 1737206426, i: 1 }), t: Long('1') },
lastCommittedWallTime: ISODate('2025-01-18T13:20:26.592Z'),
readConcernMajorityOpTime: { ts: Timestamp({ t: 1737206426, i: 1 }), t: Long('1') },
appliedOpTime: { ts: Timestamp({ t: 1737206426, i: 1 }), t: Long('1') },
durableOpTime: { ts: Timestamp({ t: 1737206426, i: 1 }), t: Long('1') },
lastAppliedWallTime: ISODate('2025-01-18T13:20:26.592Z'),
lastDurableWallTime: ISODate('2025-01-18T13:20:26.592Z')
},
lastStableRecoveryTimestamp: Timestamp({ t: 1737206393, i: 2 }),
electionCandidateMetrics: {
lastElectionReason: 'electionTimeout',
lastElectionDate: ISODate('2025-01-18T13:10:06.274Z'),
electionTerm: Long('1'),
lastCommittedOpTimeAtElection: { ts: Timestamp({ t: 1737205794, i: 1 }), t: Long('-1') },
lastSeenOpTimeAtElection: { ts: Timestamp({ t: 1737205794, i: 1 }), t: Long('-1') },
numVotesNeeded: 2,
priorityAtElection: 1,
electionTimeoutMillis: Long('10000'),
numCatchUpOps: Long('0'),
newTermStartDate: ISODate('2025-01-18T13:10:06.301Z'),
wMajorityWriteAvailabilityDate: ISODate('2025-01-18T13:10:07.104Z')
},
members: [
{
_id: 0,
name: 'mongo1:27017',
health: 1,
state: 1,
stateStr: 'PRIMARY',
uptime: 635,
optime: { ts: Timestamp({ t: 1737206426, i: 1 }), t: Long('1') },
optimeDate: ISODate('2025-01-18T13:20:26.000Z'),
lastAppliedWallTime: ISODate('2025-01-18T13:20:26.592Z'),
lastDurableWallTime: ISODate('2025-01-18T13:20:26.592Z'),
syncSourceHost: '',
syncSourceId: -1,
infoMessage: '',
electionTime: Timestamp({ t: 1737205806, i: 1 }),
electionDate: ISODate('2025-01-18T13:10:06.000Z'),
configVersion: 1,
configTerm: 1,
self: true,
lastHeartbeatMessage: ''
},
{
_id: 1,
name: 'mongo2:27017',
health: 1,
state: 2,
stateStr: 'SECONDARY',
uptime: 632,
optime: { ts: Timestamp({ t: 1737206416, i: 1 }), t: Long('1') },
optimeDurable: { ts: Timestamp({ t: 1737206416, i: 1 }), t: Long('1') },
optimeDate: ISODate('2025-01-18T13:20:16.000Z'),
optimeDurableDate: ISODate('2025-01-18T13:20:16.000Z'),
lastAppliedWallTime: ISODate('2025-01-18T13:20:26.592Z'),
lastDurableWallTime: ISODate('2025-01-18T13:20:26.592Z'),
lastHeartbeat: ISODate('2025-01-18T13:20:25.765Z'),
lastHeartbeatRecv: ISODate('2025-01-18T13:20:26.853Z'),
pingMs: Long('0'),
lastHeartbeatMessage: '',
syncSourceHost: 'mongo1:27017',
syncSourceId: 0,
infoMessage: '',
configVersion: 1,
configTerm: 1
},
{
_id: 2,
name: 'mongo3:27017',
health: 1,
state: 2,
stateStr: 'SECONDARY',
uptime: 632,
optime: { ts: Timestamp({ t: 1737206416, i: 1 }), t: Long('1') },
optimeDurable: { ts: Timestamp({ t: 1737206416, i: 1 }), t: Long('1') },
optimeDate: ISODate('2025-01-18T13:20:16.000Z'),
optimeDurableDate: ISODate('2025-01-18T13:20:16.000Z'),
lastAppliedWallTime: ISODate('2025-01-18T13:20:26.592Z'),
lastDurableWallTime: ISODate('2025-01-18T13:20:26.592Z'),
lastHeartbeat: ISODate('2025-01-18T13:20:25.765Z'),
lastHeartbeatRecv: ISODate('2025-01-18T13:20:26.852Z'),
pingMs: Long('0'),
lastHeartbeatMessage: '',
syncSourceHost: 'mongo1:27017',
syncSourceId: 0,
infoMessage: '',
configVersion: 1,
configTerm: 1
}
],
ok: 1,
'$clusterTime': {
clusterTime: Timestamp({ t: 1737206426, i: 1 }),
signature: {
hash: Binary.createFromBase64('IwSBWQznS9evx2x57GBBcbGMYFY=', 0),
keyId: Long('7461242123191320581')
}
},
operationTime: Timestamp({ t: 1737206426, i: 1 })
}
여기서 이제 기초 CRUD Operation을 해보겠습니다
Create
db.test.insertOne({test:'1234'}) =>
{
acknowledged: true,
insertedId: ObjectId('678babc029eb09ba4b68d719')
}
Read
db.test.find() =>
[ { _id: ObjectId('678babc029eb09ba4b68d719'), test: '1234' } ]
Update
testCluster [primary] test> db.test.updateOne({test:'1234'},{$set:{test:4567}}) =>
{
acknowledged: true,
insertedId: null,
matchedCount: 1,
modifiedCount: 1,
upsertedCount: 0
}
Delete
db.test.deleteOne({test:4567}) =>
{ acknowledged: true, deletedCount: 1 }
Transaction Insert
testCluster [primary] test> let session = db.getMongo().startSession()
testCluster [primary] test> session.startTransaction()
testCluster [primary] test> db.test.insertOne({test:1234})
{
acknowledged: true,
insertedId: ObjectId('678bb05429eb09ba4b68d71a')
}
testCluster [primary] test> db.test.insertOne({test:5678})
{
acknowledged: true,
insertedId: ObjectId('678bb05f29eb09ba4b68d71b')
}
testCluster [primary] test> session.commitTransaction()
testCluster [primary] test> session.endSession()
이제 클러스터 구축은 완료됐으니 로컬에 구축한 클러스터와 실제 무료 클러스터의 성능차이를 확인해봐야겠죠?
저는 Spring Data MongoDB의 드라이버를 사용할 예정입니다.
insert 테스트 시나리오는 아래와 같습니다

데이터 롤백을 위해 트랜잭션을 사용합니다.
무료 클러스터에 데이터 10000건 insert한 결과입니다.

처리시간이 1분이 넘어가면서 transaction이 강제로 timeout되어 abort되면서 예외가 발생했네요.
다음은 로컬클러스터에 데이터 10000건 insert한 결과입니다.

대략 5초 조금 넘게 걸렸네요
Command 작업을 비교해봤으니 Query 작업을 비교해봐야겠죠? 테스트 시나리오는 아래와 같습니다.

무료 클러스터에 데이터 10000건 batch insert 후 10000번 조회한 결과입니다.

역시 abort 예외가 발생했네요.
로컬 클러스터에 데이터 10000건 batch insert 후 10000번 조회한 결과입니다.

insert 7초, 인덱스가 걸려있지 않아서 그런지 조회 31초가 걸렸습니다.
DB 초기화를 진행하려면 컨테이너를 모두 중지시키고 docker-compose.yml에 작성했던 volumes 파트의 DB data 폴더를 통째로 지워주시면 간단하게 초기화가 가능합니다.(물론 클러스터링도 다시 해줘야해요)
컨테이너 중지는 docker-compose.yml이 있는 폴더에서 아래 커맨드를 입력하면 됩니다
docker-compose down --remove-orphans
사실 무료 클러스터의 경우 아무래도 클라우드까지의 네트워크 통신 시간이 훨씬 길테니 조회 횟수가 많으면 많을 수록 로컬에 비해 더 많이 느려질 수 밖에 없습니다.
그리고 한 프로세스에 DB를 10000번씩 태우는 경우도 사실 없을거구요.
다만, 개발할때 및 실제 의존주입까지 다 된 상황에서 테스트를 하는 경우 이 차이의 누적이 꽤나 영향이 있을 것이고, 후에 소개할 TestContainers를 활용한 독립된 DB를 활용한 의존 주입 테스트를 진행하는데 밑바탕이 될테니 한 번 구현 해보는 것도 꽤 흥미로운 도전이라 생각되네요.
긴 글 읽어주셔서 감사합니다.