Laravel + DocumentDB 연결, 직접 막혀본 주의점 6가지
Laravel을 AWS DocumentDB에 붙이며 반나절씩 날린 지점들을 정리했다. retryWrites=false, TLS 인증서, VPC 밖 접속 불가부터 정렬·트랜잭션·인덱스 같은 MongoDB 호환의 빈틈까지 config/database.php 설정과 함께.
Laravel 앱을 AWS DocumentDB에 붙이는 작업은 “MongoDB 드라이버 그대로 쓰면 되겠지”라고 가볍게 시작했다가 며칠을 잡아먹었다. 로컬 MongoDB에서 멀쩡히 돌던 코드가 DocumentDB로 향하는 순간 연결조차 안 됐고, 어렵게 붙이고 나서도 정렬이 뒤죽박죽이 되는 식으로 한 번씩 더 발목을 잡혔다.
핵심은 하나다. DocumentDB는 MongoDB가 아니라 MongoDB API를 흉내 내는 별도 엔진이다. 이 전제를 깔고 가면 막혔던 지점들이 전부 설명된다. 아래는 실제로 시간을 날렸던 순서대로 정리한 것이다. 패키지는 mongodb/laravel-mongodb 기준이고(예전 jenssegers/laravel-mongodb가 이쪽으로 이관됐다), PHP ext-mongodb 확장이 깔려 있어야 한다.
🚫 1. retryWrites=false를 안 넣으면 쓰기가 전부 막힌다
가장 먼저, 그리고 가장 황당하게 막힌 부분이다. 연결은 되는데 insert나 update만 하면 이런 에러가 떴다.
{"ok":0,"errmsg":"Unrecognized field: 'txnNumber'","code":9,"name":"MongoError"}
원인은 드라이버 기본값이다. MongoDB 4.2 호환 드라이버부터 retryWrites가 기본 켜짐인데, DocumentDB는 retryable writes를 지원하지 않는다. 드라이버가 쓰기 요청에 txnNumber를 붙여 보내면 DocumentDB가 “그런 필드 모른다”며 거절하는 것이다. 읽기는 멀쩡한데 쓰기만 죽으니 처음엔 권한 문제인 줄 알고 한참 헤맸다.
해결은 연결 문자열에 retryWrites=false를 박는 것뿐이다.
# .env
DB_URI="mongodb://app:secret@docdb-prod.cluster-xxxx.ap-northeast-2.docdb.amazonaws.com:27017/?tls=true&replicaSet=rs0&readPreference=secondaryPreferred&retryWrites=false"
DocumentDB로 붙는 연결이라면 예외 없이 넣어야 한다. 코드에서 트랜잭션을 직접 안 쓰더라도 마찬가지다.
🔐 2. TLS는 옵션이 아니라 필수, 그리고 CA 번들이 필요하다
DocumentDB는 기본적으로 TLS를 강제한다(tls.enabled 파라미터 그룹을 끄지 않는 한). 그냥 tls=true만 켜면 이번엔 인증서 검증에서 막힌다. 서버 인증서를 검증할 CA 번들을 직접 받아서 드라이버에 물려줘야 한다.
# Amazon RDS/DocumentDB 공통 글로벌 CA 번들
curl -o storage/certs/global-bundle.pem \
https://truststore.pki.rds.amazonaws.com/global/global-bundle.pem
config/database.php에서는 인증서 경로를 DSN에 욱여넣기보다 options 배열에 두는 쪽이 읽기 편하다. 공식 문서도 이 방식을 권장한다.
// config/database.php
'connections' => [
'docdb' => [
'driver' => 'mongodb',
'dsn' => env('DB_URI'),
'database' => env('DB_DATABASE', 'app'),
'options' => [
'tls' => true,
'tlsCAFile' => storage_path('certs/global-bundle.pem'),
],
],
],
여기서 한 번 더 데인 게 배포였다. 로컬에 받아둔 global-bundle.pem은 .gitignore에 잡혀 있거나 배포 산출물에서 빠지기 쉽다. 컨테이너 이미지나 배포 스크립트에서 이 파일이 실제로 같은 경로에 존재하는지 확인해야 한다. 급할 때 tlsAllowInvalidCertificates나 tlsInsecure로 검증을 꺼버리고 싶은 유혹이 있는데, 이건 테스트 환경에서만 쓰고 프로덕션엔 절대 넘기지 말 것.
🌐 3. VPC 밖에서는 아예 붙지 않는다
이건 코드 문제가 아니라서 더 오래 헤맸다. DocumentDB는 퍼블릭 엔드포인트가 없다. 같은 VPC 안(혹은 피어링된 네트워크)에서만 접속이 된다. 로컬 노트북에서 php artisan tinker로 찔러보면 그냥 타임아웃이 나는데, 설정이 틀린 줄 알고 DSN만 계속 들여다보게 된다.
로컬에서 확인하려면 같은 VPC의 EC2 같은 곳을 경유하는 SSH 터널을 뚫어야 한다.
# 로컬 27017 → 베스천 경유 → DocumentDB 클러스터 엔드포인트
ssh -i key.pem -L 27017:docdb-prod.cluster-xxxx.ap-northeast-2.docdb.amazonaws.com:27017 \
ec2-user@bastion-host -N
다만 터널로 붙을 땐 호스트명이 localhost가 되면서 인증서의 호스트명과 안 맞아 검증에 걸린다. 이때만 tlsAllowInvalidHostnames를 켜서 로컬 검증용으로 쓰고, 실제 앱 서버는 VPC 안에서 클러스터 엔드포인트로 정상 검증되게 둔다.
↔️ 4. 정렬을 명시 안 하면 순서를 보장하지 않는다
연결을 끝내고 기능이 도는 줄 알았는데, 목록 화면 정렬이 가끔 뒤섞였다. MongoDB에서는 자연 순서가 어느 정도 일관되게 나오던 쿼리가 DocumentDB에서는 명시적 sort()가 없으면 순서를 보장하지 않는다. AWS 문서가 대놓고 “implicit result sort ordering을 보장하지 않는다”고 적어둔 부분이다.
그래서 Eloquent에서도 정렬이 중요한 쿼리엔 orderBy를 반드시 붙였다.
// 순서가 의미 있으면 항상 명시
$posts = Post::on('docdb')
->where('status', 'published')
->orderBy('published_at', 'desc')
->get();
집계(aggregation)에선 함정이 더 깊다. $sort는 파이프라인의 마지막 단계일 때만 순서가 유지되고, $group과 같이 쓰면 $first·$last 누적기에만 적용된다. 중간 $sort로 정렬해 놓고 그 순서를 믿으면 안 된다.
🧩 5. MongoDB 기능이 다 되는 게 아니다
호환 엔진이라는 말 그대로, 일상적으로 쓰는 연산자에도 구멍이 있다. 마이그레이션이나 쿼리를 짜다 “이게 왜 안 되지” 싶을 때 대부분 이 목록 안에 있었다.
$lookup은 equality 조인과 비상관 서브쿼리까지는 되지만 상관 서브쿼리(correlated subquery)는 안 된다.$graphLookup은 아예 미지원.$all안에서$elemMatch를 못 쓴다.$and로 풀어 써야 한다.- 인덱스 빌드는 컬렉션당 한 번에 하나만 가능하다. 마이그레이션에서 여러 인덱스를 동시에 만들려 하면 뒤엣것이 실패한다. 인덱스 생성은 순차로 돌리는 게 안전하다.
- sparse 인덱스를 쿼리에서 타게 하려면 해당 필드에
$exists를 같이 걸어야 한다. 안 그러면 인덱스를 안 쓴다. admin·local데이터베이스와system.*컬렉션이 없다. 이걸 건드리는 헬스체크나 운영 도구가 조용히 깨질 수 있다.
지원 여부가 버전(DocumentDB는 3.6/4.0/5.0/8.0 API를 에뮬레이트한다)마다 다르니, 본격적으로 옮기기 전에 AWS의 호환성 평가 도구로 쿼리·소스를 한 번 훑어보면 이런 지뢰를 미리 걸러낼 수 있다.
🔁 6. 트랜잭션은 된다, 단 조건이 붙는다
DocumentDB 4.0부터 다중 문서·다중 컬렉션 트랜잭션이 ACID로 지원된다. 그래서 laravel-mongodb의 트랜잭션 헬퍼도 쓸 수 있다.
DB::connection('docdb')->transaction(function () {
Order::create([...]);
Stock::where('sku', $sku)->decrement('qty');
});
다만 제약이 있다. 세션 하나당 트랜잭션은 한 번에 하나고, causal consistency는 지원하지 않으며, 앞서 본 retryable writes도 당연히 안 된다. 그리고 updateMany·deleteMany 같은 벌크 연산은 개별 문서 단위로는 원자적이어도 연산 전체가 하나의 원자 단위는 아니다. 여러 문서를 “전부 또는 전무”로 묶어야 한다면 명시적 트랜잭션으로 감싸야 한다.
✅ 연결이 제대로 됐는지 확인하기
설정을 다 맞췄으면 tinker로 가장 단순한 왕복을 찍어보는 게 빠르다. 읽기뿐 아니라 쓰기까지 해봐야 retryWrites 함정에 안 걸린다.
php artisan tinker
>>> DB::connection('docdb')->getMongoClient()->listDatabases();
// 데이터베이스 목록이 나오면 TLS·네트워크는 통과
>>> DB::connection('docdb')->collection('healthcheck')->insert(['ping' => now()]);
// true가 나오면 retryWrites=false까지 정상
listDatabases()에서 타임아웃이면 3번(VPC/터널)이나 2번(TLS·CA)을, insert에서 txnNumber 에러가 뜨면 1번(retryWrites)을 다시 보면 된다. 막히는 지점이 위 여섯 중 어디인지만 가려내면 나머지는 금방이다.
관련 글
Go(Golang) 프로젝트 폴더 구조 컨벤션 — cmd · internal · pkg 제대로 쓰는 법 (실전 예시 포함)
Go 프로젝트의 표준 폴더 구조 컨벤션을 cmd, internal, pkg를 중심으로 정리했습니다. golang-standards/project-layout이 권장하는 디렉터리의 의미, internal 내부를 domain · platform · transport로 분리하는 4계층 아키텍처, 패키지·파일 네이밍 규칙, 그리고 실제 OAuth2 서버 프로젝트의 트리 예시까지 한 번에 살펴봅니다.