데이터베이스 문제는 코드 안에서 드러난다
개발자는 SQL 콘솔보다 애플리케이션 코드 안에서 데이터베이스를 더 자주 만난다. 같은 SELECT라도 관리자 콘솔에서 한 번 실행할 때와, API 서버 20대가 초당 수백 번 실행할 때의 의미는 다르다. 트랜잭션을 어디서 시작하고 끝낼지, 커넥션을 얼마나 오래 잡고 있을지, ORM이 실제로 어떤 SQL을 만드는지에 따라 성능과 정합성이 달라진다.
이 장의 핵심은 "데이터베이스를 잘 안다"는 말을 SQL 문법 지식에만 가두지 않는 것이다. 좋은 애플리케이션 코드는 데이터베이스와 맺는 관계를 명확히 한다. 필요한 동안만 연결을 빌리고, 같은 업무 단위는 같은 트랜잭션 안에서 처리하며, 조회 방식이 실제 데이터 양에서 버틸 수 있는지 확인한다.
AI 생성 이미지
커넥션 풀은 왜 필요한가
애플리케이션이 데이터베이스에 접근하려면 네트워크 연결, 인증, 세션 초기화 같은 과정이 필요하다. 요청마다 새 연결을 만들고 끊으면 이 비용이 커진다. 그래서 대부분의 서버 애플리케이션은 커넥션 풀을 사용한다. 커넥션 풀은 미리 만들어 둔 데이터베이스 연결을 여러 요청이 빌려 쓰고 반납하게 하는 구조다.
흐름은 단순하다.
요청이 들어온다.
애플리케이션이 풀에서 커넥션을 빌린다.
SQL을 실행하고 트랜잭션을 처리한다.
커넥션을 풀에 반납한다.
중요한 점은 커넥션을 "닫는다"는 말이 실제 네트워크 연결을 끊는다는 뜻이 아닐 수 있다는 것이다. 풀을 사용하는 환경에서 close()는 보통 커넥션을 풀로 돌려보내는 동작이다.
커넥션 풀 크기는 무조건 클수록 좋은 값이 아니다. 커넥션이 많아지면 동시에 처리할 수 있는 쿼리 수는 늘 수 있지만, 데이터베이스 입장에서는 더 많은 세션, 메모리, 잠금, 컨텍스트 전환을 감당해야 한다. 애플리케이션 인스턴스가 10대이고 각 인스턴스의 풀 크기가 30이면 데이터베이스에는 최대 300개의 연결이 몰릴 수 있다. 데이터베이스의 최대 연결 수만 보고 정하면 안 되고, 실제 CPU, 쿼리 시간, 트랜잭션 길이, 배포 인스턴스 수를 함께 봐야 한다.
실무에서는 작은 값에서 시작해 관찰하는 편이 안전하다. API 응답 시간이 늘어나는데 데이터베이스 CPU는 낮고, 애플리케이션 로그에 "connection acquisition timeout" 같은 메시지가 보이면 풀 대기가 병목일 수 있다. 반대로 풀을 늘렸더니 데이터베이스 CPU와 잠금 대기가 급증하면 데이터베이스가 동시에 들어온 일을 처리하지 못하는 것이다.
커넥션 누수는 더 위험하다. 커넥션을 빌린 뒤 예외 상황에서 반납하지 않으면 풀의 사용 가능한 연결이 점점 줄어든다. 증상은 보통 다음과 같다.
처음에는 정상이다가 시간이 지날수록 요청이 느려진다.
특정 시점 이후 모든 DB 요청이 타임아웃된다.
데이터베이스에는
idle in transaction상태의 연결이 오래 남아 있다.애플리케이션 재시작 후 잠시 정상화된다.
누수를 막으려면 커넥션과 트랜잭션 범위를 짧고 명확하게 유지해야 한다. 파일 업로드, 외부 API 호출, 이메일 발송처럼 오래 걸리거나 실패 가능성이 큰 작업을 커넥션을 잡은 상태로 수행하면 안 된다. 데이터베이스 작업이 필요할 때 빌리고, 끝나면 즉시 반납하는 것이 기본이다.
ORM은 SQL을 없애지 않는다
ORM은 객체와 테이블 사이의 변환을 도와준다. 개발자는 user.name처럼 객체를 다루고, ORM은 내부에서 SELECT, INSERT, UPDATE를 만든다. 이 추상화는 생산성을 높이지만, SQL이 사라진 것은 아니다. SQL이 보이지 않을 뿐이다.
대표적인 함정은 지연 로딩이다. 지연 로딩은 연관 데이터를 실제로 사용할 때 조회하는 방식이다. 예를 들어 주문 목록을 조회한 뒤 각 주문의 사용자 이름을 화면에 표시한다고 하자.
주문 20건 조회
→ 첫 번째 주문의 사용자 접근: 사용자 조회 SQL 1번
→ 두 번째 주문의 사용자 접근: 사용자 조회 SQL 1번
→ ...
→ 스무 번째 주문의 사용자 접근: 사용자 조회 SQL 1번겉으로는 주문 목록 조회 한 번처럼 보이지만 실제로는 주문 조회 1번과 사용자 조회 20번이 실행된다. 이것이 N+1 쿼리 문제다. 데이터가 적을 때는 티가 나지 않지만, 운영 환경에서 목록 크기가 커지면 응답 시간이 급격히 늘어난다.
해결 방법은 상황에 따라 다르다. 항상 즉시 로딩으로 바꾸는 것이 답은 아니다. 즉시 로딩은 처음부터 연관 데이터를 함께 가져오는 방식이다. 필요한 데이터가 명확한 상세 조회에서는 적합할 수 있다. 그러나 여러 연관을 무조건 즉시 로딩하면 조인이 과도하게 커지고, 같은 데이터가 중복되어 전송되거나, 예상보다 많은 객체가 메모리에 올라올 수 있다.
실무적인 판단 기준은 "이 API가 응답을 만들 때 실제로 필요한 데이터가 무엇인가"이다.
목록에서 작성자 이름만 필요하다면 조인이나 전용 조회 DTO로 필요한 컬럼만 가져온다.
상세 화면에서 주문과 주문 항목이 항상 함께 필요하다면 fetch join이나 preload를 고려한다.
여러 부모에 대한 자식 데이터를 모아 가져와야 한다면 batch loading 전략을 쓴다.
복잡한 통계 화면이라면 ORM 객체 그래프보다 SQL이나 읽기 전용 모델이 더 단순할 수 있다.
ORM의 변경 감지도 이해해야 한다. 많은 ORM은 트랜잭션 안에서 조회한 객체의 처음 상태를 기억해 두었다가, 커밋 시점에 바뀐 필드를 찾아 UPDATE를 실행한다. 이 방식은 편리하지만 트랜잭션 범위가 길면 의도치 않은 변경이 함께 저장될 수 있다. 또한 객체를 수정했는데 트랜잭션 밖이라 저장되지 않는 경우도 생긴다.
따라서 ORM을 사용할 때는 세 가지를 확인해야 한다. 첫째, 이 코드가 실제로 몇 번의 SQL을 실행하는가. 둘째, 트랜잭션 안에서 어떤 객체가 관리 상태인가. 셋째, 커밋 시점에 어떤 변경이 데이터베이스에 반영되는가. 로그와 쿼리 모니터링 없이 ORM을 믿기만 하면 성능 문제를 늦게 발견한다.
Prepared Statement와 파라미터 바인딩
사용자 입력을 SQL 문자열에 직접 붙이는 코드는 위험하다.
SELECT * FROM users WHERE email = '입력값';만약 입력값에 SQL 문법이 섞이면 데이터베이스는 그것을 값이 아니라 명령의 일부로 해석할 수 있다. 이것이 SQL 인젝션의 기본 원리다. 방어의 핵심은 SQL 구조와 값의 분리다.
Prepared Statement는 SQL의 틀을 먼저 준비하고, 값은 나중에 파라미터로 바인딩한다.
SELECT * FROM users WHERE email = ?;여기서 ?에 들어가는 값은 SQL 문법이 아니라 데이터 값으로 처리된다. 사용자가 특수문자나 따옴표를 입력해도 쿼리 구조를 바꾸지 못한다. 대부분의 ORM과 데이터베이스 드라이버는 파라미터 바인딩 기능을 제공하므로, 동적 SQL을 만들 때도 문자열 결합 대신 바인딩을 사용해야 한다.
Prepared Statement는 성능에도 도움이 될 수 있다. 데이터베이스는 SQL을 실행하기 전에 문법을 분석하고 실행 계획을 세운다. 같은 형태의 SQL을 반복 실행하면 준비된 문장을 재사용해 파싱 비용을 줄일 수 있다. 다만 실행 계획 캐시 방식은 DBMS와 드라이버 설정에 따라 다르므로, "Prepared Statement를 쓰면 항상 빨라진다"라고 단정해서는 안 된다. 보안 측면에서는 기본 선택이고, 성능 측면에서는 반복 실행이 많은 쿼리에서 효과를 기대할 수 있다고 보는 편이 정확하다.
주의할 점도 있다. 파라미터 바인딩은 값에는 사용할 수 있지만 테이블명, 컬럼명, 정렬 방향 같은 SQL 식별자에는 그대로 적용하기 어렵다. 예를 들어 사용자가 선택한 정렬 컬럼을 처리해야 한다면 입력값을 직접 붙이지 말고 허용 목록을 둬야 한다.
허용: created_at, price, name
거부: 그 외 모든 입력보안은 "나쁜 문자열을 잘 걸러내는 것"보다 "애초에 명령으로 해석될 자리를 주지 않는 것"에 가깝다.
페이지네이션: OFFSET과 커서
목록 API에서 흔히 쓰는 방식은 OFFSET 기반 페이지네이션이다.
SELECT *
FROM posts
ORDER BY created_at DESC
LIMIT 20 OFFSET 10000;이 방식은 이해하기 쉽고 "3페이지로 이동" 같은 UI를 만들기 좋다. 문제는 뒤쪽 페이지로 갈수록 비용이 커진다는 점이다. 데이터베이스는 OFFSET 10000이라고 해서 10001번째 행부터 바로 읽는 것이 아니다. 정렬 조건에 맞게 앞의 많은 행을 확인하고 건너뛴 뒤 필요한 행을 반환해야 한다. 데이터가 많아질수록 느려진다.
또 다른 문제는 목록이 바뀌는 상황이다. 사용자가 1페이지를 본 뒤 새 게시글이 추가되면, 2페이지 조회 시 앞에서 본 글이 다시 나오거나 일부 글이 건너뛰어질 수 있다. OFFSET은 "현재 시점의 순서에서 몇 개를 건너뛴다"는 방식이기 때문에 변경이 잦은 목록에서 흔들릴 수 있다.
커서 기반 페이지네이션은 마지막으로 본 위치를 기준으로 다음 데이터를 가져온다.
SELECT *
FROM posts
WHERE (created_at, id) < (?, ?)
ORDER BY created_at DESC, id DESC
LIMIT 20;여기서는 마지막으로 본 게시글의 created_at과 id가 다음 조회의 기준이 된다. 복합 인덱스가 created_at, id 순서로 잡혀 있다면 데이터베이스는 특정 위치 이후의 범위를 효율적으로 탐색할 수 있다. 또한 새 글이 앞쪽에 추가되더라도 사용자가 이어 보는 흐름은 비교적 안정적이다.
커서 방식에도 단점은 있다. 임의의 페이지 번호로 바로 이동하기 어렵고, 정렬 기준이 명확하고 고유해야 한다. created_at만 쓰면 같은 시각에 생성된 행 사이의 순서가 흔들릴 수 있으므로 보통 id 같은 고유 값을 함께 사용한다.
선택 기준은 다음과 같다.
관리자 화면처럼 전체 개수와 페이지 번호 이동이 중요하면 OFFSET이 단순하다.
피드, 알림, 댓글, 주문 내역처럼 "더 보기" 흐름이면 커서 방식이 유리하다.
데이터가 적고 증가 속도가 낮으면 OFFSET도 충분하다.
큰 테이블의 깊은 페이지를 자주 조회하면 커서 방식을 먼저 검토한다.
페이지네이션은 UI 문제가 아니라 쿼리 성능 문제다. 정렬 기준과 인덱스가 함께 설계되어야 한다.
읽기 모델과 쓰기 모델을 나눌 때
처음에는 하나의 테이블 구조로 생성, 수정, 조회를 모두 처리해도 충분하다. 사용자 정보 수정 API가 users 테이블을 갱신하고, 사용자 상세 API가 같은 테이블을 조회하는 구조는 단순하고 안정적이다. 모든 서비스가 처음부터 읽기 모델과 쓰기 모델을 나눌 필요는 없다.
하지만 서비스가 커지면 쓰기에 좋은 구조와 읽기에 좋은 구조가 달라진다. 쓰기 모델은 정합성을 지키기 위해 정규화된 구조를 선호한다. 주문, 결제, 배송, 쿠폰 사용 이력은 각각 의미와 변경 주기가 다르므로 분리하는 편이 안전하다. 반면 읽기 화면은 "주문 번호, 구매자 이름, 결제 금액, 배송 상태, 최근 변경 일시"를 한 번에 보여주고 싶어 한다. 매번 여러 테이블을 조인하면 쿼리가 복잡해지고 부하가 커질 수 있다.
이럴 때 읽기 모델을 별도로 둘 수 있다. 예를 들어 order_summary 같은 테이블을 만들어 화면에 필요한 값을 미리 모아 둔다. 쓰기 모델에서 주문 상태가 바뀌면 이벤트나 배치 작업으로 읽기 모델을 갱신한다. 검색이 중요하면 Elasticsearch 같은 검색 저장소를, 빠른 조회가 중요하면 Redis나 별도 조회 테이블을 사용할 수도 있다.
다만 읽기 모델 분리는 공짜가 아니다. 데이터가 두 곳에 존재하므로 지연과 불일치가 생길 수 있다. 사용자가 결제를 완료했는데 주문 목록에는 몇 초 늦게 반영될 수 있다. 이 지연을 허용할 수 있는 화면인지 판단해야 한다. 결제 승인 여부처럼 즉시 정합성이 중요한 정보는 원본 쓰기 모델을 기준으로 확인해야 한다.
단순 CRUD로 충분한 상황은 명확하다.
데이터 양이 작고 조인 비용이 크지 않다.
조회 화면이 원본 테이블 구조와 크게 다르지 않다.
쓰기 직후 읽기에서 즉시 같은 값을 보여줘야 한다.
별도 동기화 로직을 운영할 팀과 필요성이 없다.
반대로 읽기 모델 분리를 검토할 상황은 다음과 같다.
한 화면을 만들기 위해 많은 테이블을 매번 조인한다.
목록 조회가 서비스의 주요 트래픽이다.
검색, 정렬, 필터 조건이 원본 스키마와 맞지 않는다.
약간의 반영 지연을 사용자 경험상 허용할 수 있다.
핵심은 구조를 멋지게 나누는 것이 아니라, 정합성과 성능의 책임을 어디에 둘지 결정하는 것이다.
애플리케이션 코드의 체크리스트
데이터베이스를 다루는 코드는 다음 질문에 답할 수 있어야 한다.
이 요청은 커넥션을 얼마나 오래 점유하는가?
트랜잭션 안에서 외부 API 호출이나 긴 계산을 하고 있지 않은가?
ORM이 실행하는 실제 SQL 수와 조인 구조를 알고 있는가?
사용자 입력은 모두 파라미터로 바인딩되는가?
목록 조회는 데이터가 10배, 100배 늘어도 같은 방식으로 버틸 수 있는가?
읽기 성능을 위해 중복 저장한 데이터의 원본과 갱신 책임은 명확한가?
데이터베이스 지식은 콘솔에서 멋진 SQL을 작성할 때보다, 평범한 API 코드에서 더 자주 시험받는다. 커넥션 하나를 오래 잡는 코드, 무심코 만든 N+1 조회, 깊은 OFFSET 페이지, 문자열로 이어 붙인 SQL이 운영 장애와 보안 사고로 이어진다. 반대로 애플리케이션 코드가 데이터베이스의 동작 방식을 존중하면 성능과 안정성은 훨씬 예측 가능해진다.
이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.