클라우드의 황금기, 우리 같은 개인 개발자나 소규모 스타트업에게 AWS 프리티어(Free Tier)는 가뭄의 단비와도 같습니다. 주머니 사정 걱정 없이 번뜩이는 아이디어를 현실로 만들고, 사이드 프로젝트를 세상에 내놓을 수 있는 든든한 발판이 되어주니까요. 하지만 '공짜'라는 달콤한 유혹 뒤에는, 방심하는 순간 우리의 소중한 서버를 집어삼키는 무서운 기술적 함정이 도사리고 있습니다. 이 글은 어느 날 갑자기 응답이 눈에 띄게 느려지거나 아예 숨을 거둬버린 당신의 개발 서버를 위한 응급 처치 매뉴얼이자 근본적인 치료 가이드입니다.
많은 개발자들이 원인 모를 서버 다운에 직면하면 습관적으로 EC2 인스턴스를 재부팅하며 임시방편으로 버팁니다. 그러다 결국 '역시 프리티어는 성능이 구려서 안돼'라며 스펙 업그레이드를 고민하곤 하죠. 하지만 범인은 어쩌면 당신의 코드 안에, 아주 사소하다고 여겼던 한 줄에 숨어있을지 모릅니다. 바로 '데이터베이스 커넥션'이라는 이름의 조용한 암살자, 그 관리에 실패했기 때문입니다.
이 글을 통해 우리는 AWS 프리티어라는 제한된 운동장에서 왜 유독 서버 성능 문제가 빈번하게 발생하는지, 그 문제의 진앙인 데이터베이스 커넥션이 정확히 무엇이며 어떻게 시스템 전체를 질식시키는지 뼛속까지 파헤쳐 볼 것입니다. 그리고 가장 효과적이고 표준적인 해결책인 '커넥션 풀(Connection Pool)'의 개념과 실제 언어별 적용 방법, 나아가 당신의 애플리케이션을 한 단계 더 성숙시켜 줄 근본적인 아키텍처 개선 방안까지, 마치 선배 개발자가 옆에 앉아 알려주듯 친절하고 상세하게 안내할 것입니다. 단순히 EC2 인스턴스 타입을 올리거나 RDS로 이전하는 미봉책을 넘어, 어떤 환경에서도 안정적으로 동작하는 확장 가능한 애플리케이션을 구축하는 개발자로서 반드시 갖춰야 할 핵심 내공을 이 글에서 모두 얻어 가시길 바랍니다.
- CPU 크레딧과 메모리 관점에서 AWS 프리티어의 함정을 명확히 이해하게 됩니다.
- '서버 재부팅 신공'의 근본 원인이 데이터베이스 커넥션 누수임을 깨닫게 됩니다.
- 커넥션 풀(Connection Pool)의 필요성을 절감하고, 주요 파라미터의 의미를 알고 튜닝할 수 있게 됩니다.
- Java, Node.js, Python 환경에서 안전하게 데이터베이스 커넥션을 관리하는 코드를 작성할 수 있게 됩니다.
- CloudWatch와 DB 명령어를 통해 내 서버의 건강 상태를 직접 진단하는 방법을 배웁니다.
1. AWS 프리티어, 달콤하지만 위태로운 양날의 검
문제의 원인을 파헤치기 전에, 우리가 전투를 벌이고 있는 전장, 즉 AWS 프리티어 환경의 특성을 정확히 이해해야 합니다. 왜 대형 인스턴스에서는 어지간해서 드러나지 않을 문제가 유독 이 작은 거인에게는 치명적인지 알게 되면, 해결의 실마리를 더 쉽게 찾을 수 있습니다.
프리티어의 매력과 그 이면의 스펙
AWS는 신규 사용자를 유치하고 클라우드 생태계를 확장하기 위해 12개월(일부 서비스는 상시) 동안 특정 서비스를 무료로 제공하는 프리티어 프로그램을 운영합니다. 개발자에게 가장 매력적인 혜택은 단연 컴퓨팅 자원인 EC2입니다.
- Amazon EC2: 월 750시간의 t2.micro 또는 t3.micro(리전에 따라 t4g.micro) 인스턴스 사용 시간. 이는 한 달 내내 인스턴스 하나를 끄지 않고 운영할 수 있는 충분한 시간입니다.
- Amazon S3: 5GB의 표준 스토리지. 이미지나 정적 파일을 저장하기에 부족함이 없습니다.
- Amazon RDS: 월 750시간의 db.t2.micro(또는 유사 스펙) 인스턴스. 데이터베이스를 EC2에서 분리하여 운영할 수 있는 기회를 제공합니다.
- AWS Lambda: 월 100만 건의 무료 요청. 서버리스 아키텍처를 실험해 보기에 충분합니다.
이 글의 주인공인 EC2 t2.micro 인스턴스는 1개의 가상 CPU(vCPU)와 1GiB의 메모리를 제공합니다. 개인 블로그, 포트폴리오 사이트, 간단한 REST API 서버, 소규모 커뮤니티 등을 처음 시작하기에는 충분해 보이는 스펙입니다. 하지만 이 '무료' 서버의 심장부에는 '버스터블 성능 인스턴스(Burstable Performance Instance)'라는, 반드시 이해하고 넘어가야 할 독특한 작동 방식이 숨어 있습니다.
| 인스턴스 타입 | vCPU | 메모리 (GiB) | 기준 성능 (Baseline) | 시간당 적립 크레딧 | 최대 적립 크레딧 |
|---|---|---|---|---|---|
| t2.micro | 1 | 1 | 10% | 6 | 144 |
| t3.micro | 2 | 1 | 10% | 12 | 288 |
CPU 크레딧: 성능의 롤러코스터를 타다
t2, t3와 같은 버스터블 인스턴스는 이름 그대로 성능이 '폭발(Burst)'할 수 있습니다. 평소에는 낮은 기준(Baseline) 성능으로 동작하며 CPU를 아끼다가, 순간적으로 높은 트래픽이나 무거운 작업(빌드, 데이터 처리 등)이 필요할 때 최대 100% 성능으로 '버스트'하는 능력을 가집니다. 이 버스트 능력은 마치 게임 머니처럼 사용되는 'CPU 크레딧'이라는 개념으로 철저하게 관리됩니다.
- 크레딧 적립: 인스턴스의 CPU 사용량이 기준 성능(예: t2.micro는 10%)보다 낮을 때, 사용하지 않은 만큼의 CPU 시간이 크레딧으로 차곡차곡 적립됩니다. t2.micro는 시간당 6개의 크레딧을 벌어들입니다.
- 크레딧 소모: CPU 사용량이 기준 성능을 초과할 때, 예를 들어 50%를 사용한다면 기준(10%)을 초과한 40%만큼의 성능을 사용하기 위해 적립해 둔 크레딧을 소모합니다.
- 크레딧 고갈 (Throttling): 만약 장시간 동안 기준 성능을 초과하는 작업을 계속하여 적립된 크레딧을 모두 소진하면 어떻게 될까요? 인스턴스의 CPU 성능은 가차 없이 기준선(t2.micro의 경우 10%)까지 뚝 떨어집니다. 이것을 'Throttling(조절)'이라고 부릅니다.
바로 이 크레딧 고갈 지점이 많은 개발자들이 처음으로 '서버가 갑자기 돌처럼 굳었어요'라고 느끼게 되는 순간입니다. 애플리케이션 빌드, 대규모 데이터 마이그레이션, 혹은 갑작스러운 트래픽 증가로 CPU 크레딧이 바닥나면, 서버는 단 10%의 성능으로 모든 요청을 처리해야 하므로 극심한 성능 저하를 겪게 됩니다. AWS 관리 콘솔의 EC2 모니터링 탭에서 'CPU Credit Balance' 지표가 0에 수렴하는 그래프를 본다면 범인은 바로 이 녀석입니다.
이것은 프리티어의 명백한 제약 사항이지만, 우리가 앞으로 다룰 '진짜' 문제는 이 CPU 크레딧 고갈을 더욱 가속화하고, 심지어 크레딧이 충분한 상황에서도 서버를 멈추게 만드는 또 다른 원인에 있습니다.
1GiB 메모리의 함정과 OOM Killer의 습격
CPU 크레딧보다 더 치명적인 약점은 바로 1GiB라는 지극히 제한된 메모리입니다. 현대의 운영체제(Linux)와 웹 애플리케이션 스택(JVM, Node.js 런타임 등)이 기본적으로 차지하는 메모리 양을 고려하면, 실제 우리 애플리케이션이 자유롭게 사용할 수 있는 공간은 생각보다 훨씬 협소합니다.
이런 환경에서 약간의 메모리 누수(Memory Leak)라도 발생하면 시스템은 최후의 수단으로 '스왑(Swap)' 메모리를 사용하기 시작합니다. 스왑은 메모리의 일부 내용을 하드 디스크(EBS)에 임시로 저장하는 것인데, 메모리에 비해 수천 배는 느린 디스크 I/O를 유발하므로 시스템 전체의 반응 속도를 급격히 저하시킵니다. 사용자는 '웹 페이지 로딩이 끝나질 않아요'라고 느끼게 됩니다.
최악의 상황은 가용 메모리와 스왑 공간마저 모두 소진되었을 때 찾아옵니다. 이때 리눅스 커널은 시스템 전체의 다운을 막기 위해 OOM(Out of Memory) Killer라는 해결사를 등판시킵니다. OOM Killer는 가장 많은 메모리를 사용하고 있는 프로세스를 무자비하게 '죽여서(kill)' 메모리를 확보합니다. 대부분의 경우, 그 희생양은 바로 우리의 소중한 애플리케이션 프로세스나 데이터베이스 프로세스가 됩니다. 사용자는 '502 Bad Gateway' 오류를 마주하게 되고, 개발자는 영문도 모른 채 죽어있는 프로세스를 발견하고는 서버를 재부팅하게 됩니다. OOM Killer의 활동은 dmesg 명령어나 /var/log/messages 파일에서 그 흔적을 찾을 수 있습니다.
2. 조용한 암살자, 관리되지 않는 데이터베이스 커넥션
애플리케이션이 갑자기 느려지는 현상이 발생하면, 우리의 의심은 보통 최근에 배포한 코드의 버그나 특정 API의 비효율적인 쿼리로 향합니다. 물론 타당한 의심입니다. 하지만 서버를 재부팅하면 마법처럼 정상으로 돌아왔다가, 시간이 지나면서 다시 서서히 악화되는 패턴이 반복된다면, 이는 특정 로직의 문제가 아닌 시스템 자원이 서서히 고갈되는 '자원 누수'를 강력하게 시사하는 신호입니다. 그리고 그 누수의 가장 흔하고 치명적인 원천이 바로 데이터베이스 커넥션 관리의 실패입니다.
데이터베이스 커넥션의 생명주기와 숨겨진 비용
우리가 작성한 코드가 데이터베이스의 데이터를 읽거나 쓰기 위해서는 눈에 보이지 않는 여러 단계를 거쳐야 합니다. 이 과정을 '커넥션의 생명주기'라고 부를 수 있습니다.
- 연결 생성 (Establish Connection): 애플리케이션이 데이터베이스 서버에 연결을 요청합니다. 이 과정은 단순히 문을 여는 것 이상입니다.
- TCP/IP 3-way Handshake: 클라이언트와 서버가 네트워크 통신을 위한 경로를 설정합니다. ("똑똑, 누구세요?", "나야", "들어와")
- SSL/TLS Handshake (선택 사항): 암호화된 통신을 위해 암호화 방식과 키를 교환합니다.
- 데이터베이스 인증: 서버가 클라이언트가 보낸 사용자 이름과 비밀번호가 유효한지 확인합니다.
- 세션 설정: 데이터베이스 서버는 이 클라이언트를 위한 전용 작업 공간(메모리)과 프로세스 또는 스레드를 할당합니다.
- 쿼리 실행 (Execute Query): 생성된 연결 통로를 통해 SQL 쿼리를 데이터베이스에 전송하고, 그 결과를 수신합니다.
- 연결 종료 (Close Connection): 작업이 끝나면 TCP/IP 연결을 닫고(4-way Handshake), 데이터베이스 서버에 할당되었던 자원을 모두 해제하도록 알립니다.
여기서 핵심은 1번 '연결 생성' 과정이 생각보다 훨씬 비싼 작업이라는 점입니다. 애플리케이션 서버와 데이터베이스 서버 양쪽 모두에서 CPU와 메모리 자원을 소모하며, 네트워크를 여러 번 왕복하는 데 따른 지연 시간도 발생합니다. 따라서 사용자의 모든 요청에 대해 매번 이 과정을 반복하는 것은 지극히 비효율적입니다. 마치 편의점에 갈 때마다 새로 자동차를 조립해서 타고 가는 것과 같습니다.
연결을 닫지 않았을 때 벌어지는 연쇄 참사
진짜 재앙은 '연결을 사용한 뒤 닫는 것을 잊었을 때' 시작됩니다. 개발자가 코드에서 연결을 닫는 로직(connection.close())을 누락했다고 가정해 봅시다. 어떤 끔찍한 일들이 연쇄적으로 벌어질까요?
| 피해자 | 발생 현상 | 상세 설명 및 결과 |
|---|---|---|
| 애플리케이션 서버 | 메모리 누수 | 닫히지 않은 커넥션 객체는 가비지 컬렉터(GC)에 의해 수거되지 않고 힙 메모리에 계속 쌓입니다. 요청이 들어올 때마다 이런 '유령 커넥션'이 하나씩 늘어나면 가용 메모리는 순식간에 고갈됩니다. 1GiB 메모리의 프리티어 서버에게 이는 OOM Killer를 부르는 직접적인 원인이 됩니다. |
| 파일 디스크립터 고갈 | 모든 네트워크 연결(소켓)은 운영체제 수준에서 '파일 디스크립터'라는 자원으로 관리됩니다. 프로세스당 열 수 있는 개수(ulimit -n으로 확인 가능)는 제한되어 있습니다. 닫히지 않은 커넥션이 이 제한에 도달하면, 애플리케이션은 더 이상 DB 연결은 물론 어떤 종류의 새로운 네트워크 연결도 맺지 못하고 모든 요청이 실패하기 시작합니다. |
|
| 데이터베이스 서버 | 유휴(Idle) 커넥션의 자원 점유 | 애플리케이션이 연결을 닫지 않으면, DB 입장에서는 클라이언트가 여전히 활성 상태인 것으로 간주하고 관련 자원(프로세스, 메모리)을 계속 점유합니다. 이런 유휴 커넥션이 수백 개 쌓이면 DB 서버의 메모리와 CPU에 엄청난 부담을 주어 정상적인 쿼리 처리 성능까지 저하시킵니다. |
max_connections 초과 |
데이터베이스에는 동시에 맺을 수 있는 최대 연결 개수(max_connections) 설정이 있습니다. 이는 서버 자원의 과다 사용을 막는 최후의 안전장치입니다. 관리되지 않는 연결들이 계속 쌓여 이 한계에 도달하면, 데이터베이스는 더 이상 새로운 연결 요청을 받아주지 않습니다. 이 시점부터 애플리케이션의 모든 DB 관련 기능은 완벽하게 마비되고, 사용자는 무한 로딩 화면이나 '서비스 접속 오류'만을 보게 됩니다. |
이것이 바로 '서버를 재부팅하면 괜찮아지는' 미스터리의 실체입니다. 재부팅을 하면 애플리케이션 프로세스가 종료되면서 운영체제가 해당 프로세스가 열었던 모든 네트워크 연결을 강제로 끊어버리고, 메모리도 초기화되기 때문에 일시적으로 문제가 해결된 것처럼 보이는 것입니다. 하지만 근본 원인인 코드 속 '연결 누수' 버그는 그대로 남아있기에, 시간이 지나면 어김없이 동일한 비극이 반복될 수밖에 없습니다.
프리티어 EC2 인스턴스에 애플리케이션과 데이터베이스(예: MySQL, PostgreSQL)를 함께 설치해 운영하는 경우, 문제는 더욱 심각해집니다. 가뜩이나 부족한 1GiB의 메모리와 1개의 vCPU를 두 개의 무거운 프로세스가 나눠 써야 합니다. 애플리케이션에서 발생한 커넥션 누수는 애플리케이션 자신의 메모리를 잠식하는 동시에, 데이터베이스 프로세스의 자원까지 좀먹으며 시스템을 공멸의 길로 이끄는 것입니다.
이 지점에서 많은 개발자들이 RDS로 데이터베이스를 이전하는 선택을 합니다. RDS를 사용하면 데이터베이스 프로세스가 EC2 인스턴스로부터 분리되므로, EC2 인스턴스의 메모리와 CPU 부담이 줄어드는 것은 명백한 사실입니다. 하지만 이는 증상을 완화시켰을 뿐, 병의 근원을 치료한 것이 아닙니다. 애플리케이션의 연결 누수 버그는 그대로 남아있으며, 이제는 RDS 인스턴스의 max_connections를 향해 차곡차곡 시한폭탄을 쌓아 올리고 있는 셈입니다.
3. 커넥션 풀(Connection Pool): 현명한 자원 관리의 시작
매번 연결을 생성하고 닫는 것은 너무 비싸고, 열어놓고 닫지 않는 것은 재앙을 부릅니다. 이 딜레마를 해결하기 위해 수십 년간 검증된 표준적인 해법이 바로 '커넥션 풀(Connection Pool)'입니다. 이름 그대로, 데이터베이스 커넥션을 미리 정해진 개수만큼 만들어 '풀(Pool)이라는 수영장'에 담아두고, 필요할 때마다 하나씩 빌려 쓰고 다 쓰면 반납하는 방식입니다. 이는 자원의 재활용을 극대화하는 매우 효율적인 전략입니다.
커넥션 풀의 작동 원리 (대여와 반납의 미학)
커넥션 풀 라이브러리를 도입하면 우리의 애플리케이션이 데이터베이스와 소통하는 방식이 근본적으로 바뀝니다.
- 초기화 (Initialization): 애플리케이션이 시작될 때, 커넥션 풀은 설정값(예: minimum-idle)에 따라 일정 개수의 데이터베이스 커넥션을 미리 생성하여 풀에 준비해 둡니다. 이 과정에서 비싼 '연결 생성' 비용을 미리 치릅니다.
- 대여 (Borrow/Get): 애플리케이션 로직이 데이터베이스 접근을 필요로 할 때, 커넥션 풀에 연결을 '요청'합니다. 이때 새로운 물리적 연결을 만드는 것이 아니라, 풀에 이미 준비되어 대기 중인 기존 연결 중 하나를 즉시 빌려옵니다. 이 과정은 매우 빠릅니다.
- 사용 (Use): 빌려온 커넥션을 사용하여 필요한 SQL 쿼리를 실행합니다. 이 커넥션은 이 요청 스레드에 의해 독점적으로 사용됩니다.
- 반납 (Return/Release): 작업이 끝나면 물리적 연결을 닫는(
close()) 것이 아니라, 커넥션을 풀에 '반납'(release()또는 문맥에 따른 자동 반납)합니다. 반납된 커넥션은 깨끗하게 정리된 후 다른 요청이 사용할 수 있도록 다시 대기 상태가 됩니다.
만약 모든 커넥션이 사용 중(대여 중)일 때 새로운 요청이 들어오면 어떻게 될까요? 커넥션 풀은 설정(connection-timeout)에 따라 잠시 대기합니다. 기다리는 동안 다른 요청이 커넥션을 반납하면 그것을 받아 처리합니다. 만약 최대 풀 크기(maximum-pool-size)에 도달하지 않았다면 새로운 커넥션을 추가로 생성하여 대응할 수도 있습니다. 반대로, 오랫동안 사용되지 않아 풀에 쌓여있는 유휴(idle) 커넥션은 설정(idle-timeout)에 따라 풀에서 제거하여 불필요한 데이터베이스 자원 점유를 막아줍니다.
커넥션 풀 도입의 3가지 핵심 이점
- 압도적인 성능 향상: 가장 비싼 작업인 '연결 생성' 과정을 대부분 생략하고 기존 연결을 재사용하므로, 데이터베이스 작업의 응답 시간이 극적으로 단축됩니다. 이는 애플리케이션의 전반적인 처리량(Throughput)과 성능 향상으로 직결됩니다.
- 자원 통제 및 시스템 안정성: 커넥션 풀은 애플리케이션이 생성할 수 있는 총 DB 커넥션의 수를
maximum-pool-size로 제한하는 강력한 통제 장치 역할을 합니다. 갑작스러운 트래픽 폭증에도 데이터베이스 커넥션이 무한정 늘어나 DB의max_connections를 초과하여 시스템 전체가 다운되는 끔찍한 사태를 예방할 수 있습니다. 즉, 애플리케이션의 부하가 데이터베이스 시스템을 마비시키는 것을 막아주는 든든한 방파제와 같습니다. - 연결 관리 자동화와 견고함: 좋은 커넥션 풀 라이브러리는 연결의 유효성을 주기적으로 검사(Connection Health Check)하는 기능을 제공합니다. 네트워크 문제나 데이터베이스 재시작 등으로 인해 끊어진 '죽은' 커넥션을 자동으로 풀에서 제거하고 건강한 새 커넥션으로 교체해 줍니다. 이는 개발자가 일일이 예외 처리를 하지 않아도 애플리케이션의 안정성을 크게 높여줍니다.
4. 실전! 언어/프레임워크별 커넥션 풀 설정 가이드
현대의 대부분의 웹 프레임워크나 데이터베이스 라이브러리는 커넥션 풀링 기능을 내장하고 있거나, 매우 손쉽게 통합할 수 있는 표준 라이브러리를 제공합니다. 중요한 것은 '그냥 쓰는 것'이 아니라, 내 애플리케이션과 데이터베이스 환경에 맞게 주요 설정 값을 이해하고 적절하게 튜닝하는 것입니다.
Java: Spring Boot & HikariCP (사실상의 표준)
Java 진영의 절대 강자인 Spring Boot는 spring-boot-starter-jdbc 또는 spring-boot-starter-data-jpa 의존성을 추가하면, 현재 가장 빠르고 효율적인 것으로 평가받는 HikariCP를 기본 커넥션 풀로 자동 설정해 줍니다. 개발자는 그저 application.yml (또는 `application.properties`) 파일에서 필요한 설정값만 조정해주면 됩니다.
spring:
datasource:
url: jdbc:mysql://:3306/?useSSL=false&serverTimezone=UTC&characterEncoding=UTF-8
username:
password:
driver-class-name: com.mysql.cj.jdbc.Driver
hikari:
# 풀이 가질 수 있는 최대 커넥션 수. 이 숫자가 DB의 max_connections보다 항상 작아야 합니다.
# 프리티어 환경(t2.micro EC2 + db.t2.micro RDS)에서는 5~10 사이의 작은 값으로 시작하는 것이 매우 중요하고 안전합니다.
maximum-pool-size: 10
# 풀이 유지하는 최소한의 유휴(idle) 커넥션 수. 트래픽이 적을 때도 이만큼은 유지하여 갑작스런 요청에 빠르게 대응합니다.
minimum-idle: 5
# 커넥션이 풀에서 유휴 상태로 머물 수 있는 최대 시간(ms). 이 시간을 초과하면 풀에서 제거될 수 있습니다. (기본값 10분)
# 너무 길면 DB 자원을 낭비하고, 너무 짧으면 커넥션 생성/제거가 빈번해집니다.
idle-timeout: 600000
# 풀에서 커넥션을 얻기 위해 대기하는 최대 시간(ms). 이 시간을 초과하면 "Connection is not available, request timed out" 예외가 발생합니다. (기본값 30초)
connection-timeout: 30000
# 커넥션의 최대 생존 시간(ms). 이 시간이 지나면 커넥션은 사용 중이더라도 풀에서 제거되고 새로운 커넥션으로 교체됩니다.
# 네트워크 방화벽이나 DB의 타임아웃(wait_timeout)보다 짧게 설정하여 '죽은' 커넥션을 예방하는 매우 중요한 설정입니다. (기본값 30분)
max-lifetime: 1800000
# 커넥션의 유효성을 검사할 때 사용할 간단한 쿼리입니다.
connection-test-query: SELECT 1
Spring의 `JdbcTemplate`이나 JPA(Hibernate)와 같은 고수준 추상화를 사용하면 개발자가 직접 커넥션을 얻고(`getConnection()`) 반납하는(`close()`) 코드를 작성할 필요가 전혀 없습니다. 프레임워크가 트랜잭션 범위나 메서드 실행 범위에 맞춰 자동으로 커넥션의 대여와 반납을 완벽하게 처리해주기 때문에, 개발자는 비즈니스 로직에만 온전히 집중할 수 있어 실수를 원천적으로 방지합니다.
Node.js: `pg` (PostgreSQL) 또는 `mysql2` (MySQL)
Node.js 환경에서는 데이터베이스 드라이버 자체가 풀링 기능을 제공하는 경우가 많습니다. 가장 널리 쓰이는 PostgreSQL 드라이버인 `node-postgres` (보통 `pg`로 알려짐)의 예시입니다.
const { Pool } = require('pg');
// 풀은 애플리케이션 전체에서 단 한 번만 생성하여 모듈로 export하거나 전역적으로 관리합니다.
// 절대로 요청 핸들러 안에서 매번 new Pool()을 호출하면 안 됩니다!
const pool = new Pool({
user: 'your_user',
host: 'your_db_endpoint',
database: 'your_database',
password: 'your_password',
port: 5432,
// 풀이 가질 수 있는 최대 클라이언트(커넥션) 수. 프리티어 환경에서는 10 이하를 권장합니다.
max: 10,
// 유휴 클라이언트가 풀에 남아있는 최대 시간(ms). 이 시간이 지나면 해당 클라이언트는 연결을 끊고 풀에서 제거됩니다.
idleTimeoutMillis: 30000,
// 클라이언트를 얻기 위해 대기하는 최대 시간(ms). 이 시간이 지나면 에러를 발생시킵니다.
connectionTimeoutMillis: 2000,
});
// 올바른 사용 예시 (async/await 와 try...finally)
async function getUser(id) {
let client; // client 변수를 try 블록 바깥에 선언합니다.
try {
// pool.connect()는 풀에서 커넥션을 빌려오는 비동기 작업입니다.
client = await pool.connect();
const res = await client.query('SELECT * FROM users WHERE id = $1', [id]);
return res.rows[0];
} catch (err) {
console.error('Error executing query', err.stack);
throw err; // 에러를 상위로 전파하여 처리하도록 합니다.
} finally {
// finally 블록은 try 블록에서 에러 발생 여부와 관계없이 *반드시* 실행됩니다.
if (client) {
client.release(); // 물리적 연결을 닫는 것이 아니라, 풀에 '반납'합니다.
}
}
}
Node.js와 같은 비동기 환경에서 가장 중요한 부분은 `try...catch...finally` 구문입니다. 특히 `finally` 블록에서 `client.release()`를 호출하는 것은 절대 잊어서는 안 되는 철칙입니다. 쿼리 실행 중 에러가 발생하더라도 커넥션을 반드시 풀에 반납해야만 데이터베이스 커넥션 누수를 막고 다음 요청이 해당 커넥션을 재사용할 수 있습니다.
Python: SQLAlchemy (ORM과 Core 모두)
Python의 대표적인 ORM이자 데이터베이스 툴킷인 SQLAlchemy는 `Engine` 객체를 통해 매우 정교하고 투명하게 커넥션 풀링을 관리합니다.
import os
from sqlalchemy import create_engine
# Engine 객체는 애플리케이션의 시작점에서 전역적으로 한 번만 만들어 재사용합니다.
# create_engine 함수는 내부적으로 커넥션 풀을 생성하고 관리합니다.
DATABASE_URL = os.getenv("DATABASE_URL") # "postgresql+psycopg2://user:password@host:port/dbname"
engine = create_engine(
DATABASE_URL,
pool_size=5, # 풀에 기본적으로 유지할 커넥션의 수.
max_overflow=10, # 풀 크기(pool_size)를 초과하여 임시로 생성할 수 있는 추가 커넥션 수.
# 즉, 총 커넥션 한도는 (pool_size + max_overflow) 입니다.
pool_timeout=30, # 풀에서 커넥션을 얻기 위해 대기할 최대 시간(초). 이 시간을 초과하면 예외 발생.
pool_recycle=1800 # 커넥션을 풀에 반납한 후 다시 사용되기까지의 최대 시간(초).
# 이 시간이 지난 커넥션은 재연결을 시도합니다. MySQL의 wait_timeout보다 짧게 설정하여
# "MySQL server has gone away" 오류를 방지하는 데 매우 효과적입니다.
)
# 올바른 사용 예시 (컨텍스트 매니저 'with' 구문 사용)
def get_user_by_id(user_id: int):
# 'with engine.connect() as conn:' 구문은 블록이 끝날 때
# 성공/실패 여부와 관계없이 자동으로 conn.close() (실제로는 풀에 반납)를 호출해 줍니다.
with engine.connect() as conn:
result = conn.execute(f"SELECT * FROM users WHERE id = {user_id}")
return result.fetchone()
# SQLAlchemy ORM의 Session을 사용하는 경우에도 내부적으로 같은 원리로 동작합니다.
# Session 컨텍스트가 끝나면(예: with 문 종료) 사용했던 커넥션은 자동으로 Engine의 풀에 반납됩니다.
from sqlalchemy.orm import sessionmaker
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
def get_user_with_orm(db_session, user_id: int):
# db_session은 외부에서 주입받아 사용
user = db_session.query(User).filter(User.id == user_id).first()
return user
Python SQLAlchemy의 `with engine.connect() as conn:` 구문은 Node.js의 `try...finally`와 동일한 역할을 수행하여 개발자의 실수를 원천적으로 방지하고 코드의 안정성을 비약적으로 높여줍니다. 항상 컨텍스트 매니저를 사용하는 습관을 들이는 것이 좋습니다.
5. 내 서버는 건강한가? 모니터링 및 진단 기법
커넥션 풀을 성공적으로 적용했더라도, 우리의 임무가 끝난 것은 아닙니다. 설정이 과연 적절한지, 혹은 우리가 놓친 다른 곳에서 커넥션 누수가 발생하고 있지는 않은지 주기적으로 확인하고 진단하는 과정은 필수입니다. 문제 해결의 첫걸음은 언제나 정확한 측정과 진단입니다.
데이터베이스에 직접 접속하여 확인하기
가장 직접적이고 확실한 방법입니다. 데이터베이스 관리 툴(DBeaver, DataGrip 등)이나 터미널을 통해 데이터베이스에 직접 접속하여 현재 연결 상태를 확인하는 것입니다.
- MySQL / MariaDB:
이 명령어를 실행했을 때 나오는 결과 테이블이 핵심 단서입니다. 여기서-- 현재 연결된 모든 세션 목록을 상세하게 보여줍니다. SHOW FULL PROCESSLIST;Command컬럼이Sleep이고Time컬럼의 숫자가 수백, 수천 초 이상으로 매우 높은 연결들이 많이 보인다면, 이는 애플리케이션이 사용이 끝난 커넥션을 제대로 닫거나 풀에 반납하지 않고 방치하고 있다는 매우 강력한 증거입니다. 이런 유휴(idle) 커넥션들이 바로 DB 서버의 자원을 좀먹는 주범입니다. - PostgreSQL:
PostgreSQL에서는-- 현재 실행 중인 모든 활동(쿼리, 유휴 상태 등)을 보여줍니다. SELECT pid, datname, usename, application_name, client_addr, state, now() - query_start AS query_duration, now() - state_change AS state_duration FROM pg_stat_activity WHERE datname = 'your_database' ORDER BY state_duration DESC;state컬럼을 주목해야 합니다.idle상태(아무 쿼리도 실행하지 않음)나, 더 위험한idle in transaction상태(트랜잭션을 시작하고 끝내지 않음)로state_duration이 매우 긴 커넥션이 다수 발견된다면 데이터베이스 커넥션 누수를 심각하게 의심해야 합니다.
AWS CloudWatch를 이용한 거시적 모니터링
데이터베이스를 Amazon RDS로 운영하고 있다면, AWS가 제공하는 강력한 모니터링 도구인 CloudWatch 지표를 적극적으로 활용해야 합니다. 이것은 마치 우리 서버의 건강검진 기록과도 같습니다.
DatabaseConnections: 현재 데이터베이스에 연결된 클라이언트의 총 수입니다. 이 지표가 커넥션 누수 진단의 핵심입니다. 시간이 지남에 따라 트래픽이 없는데도 불구하고 이 지표가 계단식으로 계속해서 우상향하는 그래프를 그린다면, 이는 100% 커넥션 누수가 발생하고 있다는 뜻입니다. 정상적인 애플리케이션이라면 트래픽에 따라 오르내리되 일정한 범위 내에서 안정적인 패턴을 보여야 합니다.CPUUtilization: 데이터베이스 인스턴스의 CPU 크레딧과는 별개인, RDS 인스턴스 자체의 CPU 사용률입니다. 관리되지 않는 유휴 커넥션이 많아지면 DB가 이들을 관리하는 데 CPU 자원을 소모하므로 이 수치 또한 서서히 증가할 수 있습니다.FreeableMemory: 가용 메모리입니다. 역시 유휴 커넥션 증가는 DB 서버의 메모리 사용량을 높여 이 지표를 지속적으로 감소시킵니다.
DatabaseConnections 지표에 대한 '경보(Alarm)'를 설정하세요. 예를 들어, 이 값이 여러분이 설정한 커넥션 풀의 maximum-pool-size를 초과하거나, 비정상적으로 높은 값(예: 50)을 10분 이상 유지할 경우 이메일이나 Slack으로 알림을 받도록 설정하면 문제를 조기에 발견하고 대응할 수 있습니다.
애플리케이션 및 OS 레벨에서 확인하기
- 애플리케이션 로그: 대부분의 커넥션 풀 라이브러리는 풀에서 커넥션을 얻기 위해 대기하다가 타임아웃이 발생했을 때 경고나 에러 로그를 남깁니다. 'Connection is not available, request timed out', 'Timeout waiting for idle object' 같은 메시지가 로그에 자주 보인다면, 이는 현재 풀 크기가 서비스 부하에 비해 부족하거나, 어딘가에서 커넥션이 누수되어 풀에 반납되지 않고 있음을 의미합니다.
- OS 명령어 (SSH 접속): EC2 인스턴스에 직접 SSH로 접속하여 실제 네트워크 연결 상태를 확인할 수 있습니다.
이 명령어로 확인한 연결 수가 여러분이 애플리케이션에 설정한# 현재 ESTABLISHED 상태인 TCP 연결 중, 목적지 포트가 3306(MySQL)인 연결의 개수를 셉니다. # ss 명령어는 netstat보다 빠르고 더 상세한 정보를 제공합니다. ss -tpn 'dport = :3306' | grep 'ESTAB' | wc -lmaximum-pool-size보다 비정상적으로 많고 계속해서 증가한다면, 애플리케이션 레벨에서 심각한 커넥션 누수가 발생하고 있음을 의미합니다.
6. 근본적인 아키텍처 개선을 향하여
커넥션 풀링은 애플리케이션 레벨에서 할 수 있는 가장 효과적인 해결책이지만, 더 많은 트래픽과 더 복잡한 환경, 특히 서버리스(Serverless) 아키텍처에 대비하기 위해서는 한 단계 더 나아간 구조적 개선을 고려해 볼 수 있습니다.
AWS RDS Proxy: 똑똑한 중간 관리자의 등장
AWS Lambda와 같은 서버리스 환경은 데이터베이스 커넥션 관리의 난이도를 극단적으로 높입니다. 각 Lambda 함수 실행은 독립적인 경량 컨테이너에서 짧은 시간 동안 이루어지기 때문에, 기존의 애플리케이션 레벨 커넥션 풀이 효과적으로 동작하기 매우 어렵습니다. 수백, 수천 개의 Lambda 함수가 동시에 실행되면 순식간에 DB의 max_connections를 고갈시키는 '커넥션 폭풍(Connection Storm)'이 발생하여 전체 시스템을 마비시킬 수 있습니다.
이런 골치 아픈 문제를 해결하기 위해 AWS는 RDS Proxy라는 완전 관리형 서비스를 제공합니다. RDS Proxy는 우리의 애플리케이션과 RDS 데이터베이스 사이에 위치하는 일종의 '지능형 트래픽 경찰' 또는 '커넥션 중간 관리자'입니다.
- 애플리케이션은 더 이상 RDS 데이터베이스의 엔드포인트에 직접 연결하지 않고, 대신 RDS Proxy의 엔드포인트에 연결합니다.
- RDS Proxy는 내부적으로 데이터베이스에 대한 커넥션 풀을 매우 효율적으로 관리하고 유지합니다.
- 수천 개의 애플리케이션 연결이 들어오더라도, RDS Proxy는 이를 소수의 실제 DB 커넥션으로 다중화(Multiplexing)하여 재사용하고 전달합니다. 애플리케이션의 연결 요청과 실제 DB 커넥션을 분리하여 관리하는 것입니다.
- 확장성 및 복원력 향상: 데이터베이스가 감당해야 하는 동시 연결 부하를 극적으로 줄여주어 서버 다운 가능성을 낮춥니다. 또한, DB 장애 조치(failover)가 발생했을 때 애플리케이션의 재연결을 수 초 내에 매끄럽게 처리하여 서비스 중단 시간을 최소화합니다.
- 서버리스 환경에 최적화: Lambda와 같이 수명이 짧고 상태를 유지하지 않는 실행 환경에서 발생하는 커넥션 관리 문제를 근본적으로 해결해 줍니다.
- 보안 강화: AWS Secrets Manager와 완벽하게 통합하여 데이터베이스 자격 증명을 코드에서 완전히 제거하고, IAM 인증을 통해 더욱 안전하게 데이터베이스 연결을 관리할 수 있습니다.
물론 RDS Proxy는 추가 비용이 발생하는 유료 서비스이지만, 서비스의 규모가 커지고 안정성과 확장성이 중요해지는 시점에서는 충분히 투자할 가치가 있는 강력한 솔루션입니다.
맺음말: 성장의 밑거름이 되는 값진 경험
처음에는 그저 '역시 AWS 프리티어라서 느린가 보다'라고 막연하게 생각했던 작은 성능 저하 문제에서 시작하여, 우리는 데이터베이스 커넥션 관리의 중요성, 커넥션 풀의 작동 원리와 HikariCP, SQLAlchemy 같은 실제 라이브러리 적용법, 그리고 시스템의 건강 상태를 진단하는 모니터링 기법까지 깊이 있게 탐험했습니다. EC2 서버가 응답을 멈추는 현상은 단순한 해프닝이 아니라, 개발자로서 시스템의 내부 동작 원리를 이해하고 한 단계 더 성장할 수 있는 값진 기회입니다.
안정적인 서비스를 만드는 데 있어 자원 관리는 화려한 신기능 구현만큼이나, 어쩌면 그보다 더 중요합니다. 특히 모든 것이 제한적인 프리티어 환경에서 겪는 문제 해결 경험은, 훗날 대규모 서비스를 운영할 때 마주할 수많은 잠재적 문제를 미리 예방하는 훌륭한 예방 주사가 될 것입니다. 이 글을 통해 얻은 지식을 바탕으로, 지금 바로 당신의 application.yml을 열어 maximum-pool-size를 확인해보고, 코드 속 DB 연결 로직에 finally나 with 구문이 잘 적용되어 있는지 다시 한번 점검해 보십시오. 그 작은 점검 하나가 미래의 심각한 서버 다운을 막고, 당신의 소중한 밤잠을 지켜줄 것입니다.
Post a Comment