"로컬에선 잘 됐는데 서버에선 접속이 안 돼요"
Claude Code로 만든 앱을 처음으로 진짜 서버에 올려본다. AWS EC2 인스턴스를 하나 띄우고, 앱이랑 MongoDB를 같이 설치했다. 로컬에선 멀쩡히 돌던 게, 서버에 올리니 브라우저에서 접속이 안 된다. 검색해보니 보안 그룹인지 뭔지를 건드려야 한단다.
콘솔에 들어가 보니 인바운드 규칙이라는 표가 있고, 소스라는 칸에 IP를 넣게 되어 있다. 뭘 넣어야 할지 모르겠으니 일단 검색 결과에서 본 대로 0.0.0.0/0을 넣는다. 되네. 접속된다. DB도 붙는다. 다음 작업으로 넘어간다.
그리고 다음 날, DB를 열어보니 데이터가 전부 비어 있다. 대신 README_TO_RECOVER라는 이름의 컬렉션 하나가 새로 생겨 있고, 안에는 "당신의 데이터는 백업됐다. 복구하려면 비트코인 0.05개를 보내라"는 문구가 들어 있다. 백업은 없다.
이건 운이 나빠서 생긴 사고가 아니다. 0.0.0.0/0을 넣는 순간부터 예약된 결과에 가깝다. 왜 이렇게 되는지 이해하려면 먼저 이름이 똑같이 생긴 두 개의 0.0.0.0을 구분해야 한다. 바이브 코더가 서버 배포에서 가장 많이 헷갈리고, 가장 크게 데는 지점이 정확히 여기다.
똑같이 생긴 두 개의 0.0.0.0
서버를 다루다 보면 0.0.0.0이 두 군데에서 나온다. 생긴 건 같은데 사는 층이 다르다. 이걸 섞으면 대화가 꼬인다.
첫 번째는 바인딩할 때 나오는 0.0.0.0이다. 앱을 실행할 때 uvicorn main:app --host 0.0.0.0 같은 옵션을 봤을 것이다. 여기서 0.0.0.0은 "이 컴퓨터가 가진 모든 네트워크 통로로 들어오는 접속을 다 받겠다"는 뜻이다. 반대말은 127.0.0.1, 흔히 localhost라고 부르는 주소다. 여기에 바인딩하면 "이 컴퓨터 자기 자신에서 온 접속만 받겠다"가 된다. 같은 서버 안에서 도는 프로그램끼리는 통하지만, 바깥에서는 아무리 두드려도 문이 열리지 않는다.
두 번째는 보안 그룹의 소스에 나오는 0.0.0.0/0이다. 뒤에 /0이 붙는다. 이건 "출처가 어떤 IP든 상관없이 다 허용한다"는 뜻이다. 인터넷에 존재하는 모든 IPv4 주소, 즉 전 세계 누구나를 가리킨다.
정리하면 하나는 "서버 안쪽에서 누가 이 문 앞까지 올 수 있느냐(바인딩)"이고, 다른 하나는 "서버 바깥에서 누구를 들여보내느냐(보안 그룹)"이다. 앞의 사고는 이 둘이 동시에 활짝 열려서 벌어진 일이다. DB가 0.0.0.0에 바인딩돼 모든 통로로 접속을 받고 있었고, 보안 그룹은 0.0.0.0/0으로 전 세계에서 그 통로로 들어오게 열려 있었다.

보안 그룹은 서버 앞에 세운 출입 명단이다
보안 그룹(Security Group)은 AWS가 EC2 서버 앞에 세워둔 방화벽이다. 방화벽이라는 말이 거창하게 들리지만, 하는 일은 출입 명단을 든 경비원과 같다. 서버로 들어오려는 접속이 있으면 경비원이 명단을 확인한다. 명단에 없으면 그냥 돌려보낸다.
여기서 중요한 기본값이 하나 있다. 보안 그룹을 새로 만들면 인바운드(들어오는 접속) 규칙이 비어 있고, 비어 있으면 전부 거부다. 아무것도 안 열려 있는 게 기본이라는 뜻이다. 그래서 처음엔 브라우저에서 접속이 안 된다. 이건 고장이 아니라 안전한 기본 상태다. 명단에 이름을 하나씩 올려주는 게 우리가 할 일이다.
명단에 이름을 올리는 규칙은 보통 두 가지를 정한다. 어떤 포트로 들어오는 걸 허용할지, 그리고 어떤 **소스(출처 IP)**에서 오는 걸 허용할지다. 포트는 서비스의 종류라고 보면 된다. 웹은 80번과 443번, SSH 원격 접속은 22번, PostgreSQL은 5432번, MongoDB는 27017번, Redis는 6379번을 쓴다. 소스는 아까 그 0.0.0.0/0이 들어가는 칸이다.
그러니까 "22번 포트를 소스 0.0.0.0/0으로 허용"이라는 규칙은 경비원한테 "SSH로 들어오겠다는 사람은 누구든 다 들여보내라"고 지시한 것과 같다. "5432번 포트를 0.0.0.0/0으로 허용"은 "우리 DB에 접속하겠다는 사람은 전 세계 누구든 다 들여보내라"가 된다. 명단이 아니라 그냥 문을 떼어낸 셈이다.
문을 여는 순간 봇이 먼저 들어온다
여기서 바이브 코더가 흔히 하는 착각이 있다. "내 서버 주소를 아무도 모르는데 누가 들어오겠어"라는 생각이다. 안 알려줘도 들어온다. 인터넷에는 새로 열린 포트를 24시간 훑고 다니는 자동 스캐너가 수도 없이 돌아다닌다.
Shodan이라는 검색엔진이 대표적이다. Shodan은 인터넷 전체를 계속 스캔해서 "어떤 IP의 몇 번 포트가 열려 있고, 거기 뭐가 돌고 있는지"를 색인해둔다. 공격자는 Shodan에서 검색 몇 번으로 "인증 안 걸린 MongoDB가 떠 있는 서버 목록"을 통째로 뽑아낸다. 내 서버 주소를 아무한테도 안 알려줘도, 포트를 여는 순간 이 목록에 자동으로 올라간다.
인증 없는 DB가 왜 문제인지는 이 대목에서 분명해진다. MongoDB나 Redis 같은 데이터베이스는 초기 설정에서 비밀번호를 요구하지 않는 경우가 많다. 원래는 서버 안에서만, 같은 앱에서만 접속한다는 걸 전제로 만들어졌기 때문이다. 그런데 이걸 0.0.0.0/0으로 열어버리면, 비밀번호도 없는 데이터베이스가 전 세계에 그대로 노출된다. 스캐너 입장에서는 문도 없고 자물쇠도 없는 창고를 발견한 셈이다.
그다음은 자동이다. 봇이 접속해서 데이터를 전부 지우거나 다른 곳으로 복사한 뒤 삭제하고, 그 자리에 랜섬 노트를 남긴다. 앞에서 본 README_TO_RECOVER가 이것이다. 2020년의 이른바 Meow 공격은 랜섬 요구조차 없이 노출된 데이터베이스 수천 개를 그냥 밀어버렸다. 인증 없이 열린 Elasticsearch, MongoDB, Redis가 무차별로 당했다.
피해가 데이터 삭제로 끝나지 않는 경우도 있다. 특히 Redis처럼 설정에 따라 서버에 파일을 쓰거나 명령을 실행하게 만들 수 있는 경우, 공격자는 데이터를 지우는 데 그치지 않고 그 서버에 코인 채굴 프로그램을 심어둔다. 그러면 내 EC2 인스턴스가 남의 채굴기로 밤새 풀가동되고, 그 요금은 다음 달 내 클라우드 청구서에 그대로 찍힌다. 데이터도 잃고 요금 폭탄까지 맞는 셈이다.
무서운 건 속도다. 여러 보안 연구에서 미끼용 데이터베이스를 비밀번호 없이 인터넷에 노출하는 실험을 했는데, 첫 스캔이 들어오기까지 짧게는 수십 초, 데이터가 실제로 지워지고 랜섬 노트가 꽂히기까지 길어야 몇 시간이었다. "일단 테스트만 하고 이따 닫아야지" 할 때의 그 "이따"까지 기다려주지 않는다. 배포하는 나보다 공격 봇이 먼저 내 DB에 접속해본다고 봐야 한다.

한 가지 더. 보안 그룹은 서버 바깥에 있는 AWS 쪽 방화벽이고, 서버 안에도 별도의 방화벽이 하나 더 있을 수 있다. 리눅스의 ufw나 firewalld 같은 것이다. 보안 그룹에서 포트를 열었는데도 접속이 안 되면 이 안쪽 방화벽이 막고 있는 경우가 많다. 반대로 안쪽 방화벽을 열었다고 안심하면 안 된다. 보안 그룹이 0.0.0.0/0으로 열려 있으면 결국 바깥은 뚫려 있는 것이다. 두 방화벽은 각자 따로 동작하니, 접속 문제를 디버깅할 때는 "보안 그룹, OS 방화벽, 그리고 앱의 바인딩" 세 군데를 순서대로 확인하는 습관을 들이면 헤매는 시간이 준다.
바인딩 쪽도 같이 조여야 한다
보안 그룹만 막으면 끝일까. 앞에서 나눈 두 개의 0.0.0.0 중 바인딩 쪽도 봐야 한다.
핵심 원칙은 간단하다. 바깥에서 접속할 이유가 없는 서비스는 127.0.0.1에만 바인딩한다. DB가 대표적이다. 같은 서버에 있는 앱만 DB에 붙으면 되는 구조라면, DB를 127.0.0.1에 바인딩해두는 것만으로 외부 접속은 원천 차단된다. 이렇게 해두면 설령 보안 그룹에서 실수로 27017번 포트를 열어놓더라도, DB 자체가 바깥에서 온 접속을 받지 않으므로 한 겹의 방어가 더 생긴다.
바인딩과 보안 그룹은 서로 독립된 두 개의 잠금장치다. 하나만 잠가도 막히지만, 둘 다 잠가야 한 쪽이 뚫려도 버틴다. 실무에서 DB가 인터넷에 노출되는 사고는 대부분 이 두 잠금장치가 우연히 동시에 풀렸을 때 일어난다. 앱을 편하게 붙이려고 DB를 0.0.0.0에 바인딩해두고, 접속이 안 돼서 보안 그룹까지 0.0.0.0/0으로 열어버리는 조합이다.
그래서 어떻게 열어야 하나
정리하면 규칙은 포트의 성격에 따라 갈린다.
웹 포트(80, 443)는 어차피 아무나 접속하라고 만든 서비스다. 여기는 0.0.0.0/0으로 여는 게 정상이다. 대신 그 앞단은 앱 코드에서 인증, 레이트 리밋 같은 걸로 지켜야 한다.
SSH(22번)는 나만 들어가면 되는 관리용 문이다. 여기를 0.0.0.0/0으로 열면 전 세계 봇이 비밀번호를 무차별로 대입해온다. 소스를 내 현재 IP 하나로 좁혀두는 게 기본이다. IP가 자주 바뀐다면 접속할 때마다 규칙을 갱신하거나, 별도의 접속 경로를 두는 방법이 있다.
DB 포트(5432, 27017, 6379 등)는 원칙적으로 인터넷에 직접 열지 않는다. 앱과 DB가 같은 서버에 있으면 DB를 127.0.0.1에 바인딩하고 보안 그룹에는 DB 포트를 아예 넣지 않는다. 서버가 여러 대로 나뉘어 있으면, 소스에 IP 대신 "우리 앱 서버의 보안 그룹"을 지정할 수 있다. 그러면 그 앱 서버에서 온 접속만 DB에 닿고, 나머지 세상은 DB 포트가 열려 있는지조차 알 수 없다. 개발 중에 내 노트북에서 직접 DB를 봐야 한다면, 그때만 소스에 내 IP를 한시적으로 넣었다가 빼는 게 안전하다.
혹시 이미 0.0.0.0/0으로 DB를 열어둔 채 며칠 지났다면, 지금 당장 그 규칙부터 지워야 한다. 그다음 DB에 비밀번호를 걸고, 이미 새어나갔을 수 있으니 그 비밀번호는 새로 만든다. 데이터가 이미 지워졌다면 백업에서 복구하는 수밖에 없다. 그래서 백업은 사고가 나기 전에 켜둬야 하는 물건이다.
정리
배포에서 접속이 안 될 때 0.0.0.0/0을 넣으면 문제가 풀리는 것처럼 보인다. 실제로는 문제를 푼 게 아니라 문을 떼어낸 것이다. 보안 그룹의 소스 칸에 들어가는 0.0.0.0/0은 "전 세계 누구나"라는 뜻이고, 웹 포트가 아닌 이상 여기에 이걸 넣을 일은 거의 없다. SSH는 내 IP로 좁히고, DB는 인터넷에 아예 열지 않는다. 여기에 더해 바깥에서 부를 일 없는 서비스는 127.0.0.1에만 바인딩해두면, 두 개의 0.0.0.0 중 어느 쪽이 실수로 풀려도 한 겹이 남는다. 데이터베이스를 인터넷에 노출하는 순간부터 시계는 이미 돌아가고 있다는 것만 기억하면 된다.