코드는 돌아가기만 하면 되는 거 아니야?? 라는 생각을 처음엔 했었다. 하지만 서비스가 커지고 사용자 수가 늘어나거나, 잘못된 로직으로 인해 장애, 운영 비용 증가, 사용자 이탈로 이어지게 된다.

 

그래서 개발자는 기능과 성능을 동시에 잡는 코드를 짤 줄 알아야 한다.

 

그럼 성능이 안나오는 코드는 어떤 특징을 가지고 있는지 알아보자

 

 

1. 데이터 구조를 무시한 코드

일을 하다보면 무의식적으로 무조건 List에 담고 for문을 돌리는 경우가 있다. 그러다 해시맵을 써야 할 곳에 배열을 쓰게 되는 경우가 발생한다.

 

이렇게 되면 검색, 삽입, 삭제할 때 시간 복잡도가 폭발하게 된다.

// 10,000건 데이터 중에서 특정 ID 찾기
for (User user : userList) {
    if (user.getId() == targetId) {
        return user;
    }
}

 

처음부터 Map<Integer, User> 로 관리했다면 O(1) 시간에 찾을 수 있다.

 

 

2. 쓸데없이 많은 IO (DB/네트워크 호출)

for문 안에서 매번 DB나 API 호출하는 경우나 같은 데이터를 반복해서 조회하는 경우 네트워크 지연, DB부하 폭발 → 시스템 전체가 느려짐으로 연결될 수 있다.

 

for (Order order : orders) {
    Customer customer = customerRepository.findById(order.getCustomerId());
    // ...
}

 

위 코드에서 orders가 1만개라면 DB를 1만 번 호출하게 된다. 

 

이럴경우 한 번에 Customer 데이터를 IN 조건으로 미리 가져오거나 조인(Join)으로 조회해 DB 접근을 최소화 한다.

 

 

 

3. 캐시를 쓰지 않는다.

자주 조회하는 데이터를 매번 새로 계산하는 경우나 매번 외부 API를 호출하는 경우 CPU, 메모리 낭비 + 응답 지연이 발생할 수 있다.

 

예를 들어

public Product getBestSeller() {
    // 매번 DB를 때려서 상위 10개를 조회
    return productRepository.findTop10BySalesOrderByDesc();
}

 

하루에도 수만 번 호출될 수 있다.

 

인기상품 같은 건 5분 간격으로 캐시에 저장하거나 Redis, Caffeine 같은 캐시를 활용한다.

 

 

 

4. 데이터 양에 대한 고려가 없을 때

어차피 데이터가 몇 건 없을거야 가정하고 코딩을했을 때 만약 한 번에 100만건 select 후 메모리에 올리게 되면 어떻게 될까?

데이터가 늘어나면 OOM(OutOfMemoryError) 혹은 서버가 터질수도 있다.

 

List<User> allUsers = userRepository.findAll(); // 수십만 건

 

초반엔 문제 없을 수 있지만, 추후 데이터가 쌓이게 되면 대규모 트래픽 시 서버가 뻗을 수 있다.

페이징 처리(LIMIT, OFFSET) 혹은 스트리밍 처리(cursor 기반 조회) 으로 개선이 필요하다.

 

 

 

5. 비효율적인 반복 로직

이중 for문, 삼중 for문을 남발하는 경우 or 같은 계산을 여러 번 반복하는 경우 시간복잡도가 기하급수적으로 증가할 수 있다.

for (Item itemA : itemList) {
    for (Item itemB : itemList) {
        if (itemA.equals(itemB)) continue;
        // 비교 처리
    }
}

 

O(n^2) 알고리즘이다. 사전에 정렬하거나 해시셋(HashSet)을 활용, 필요없는 반복을 최소화해서 개선을 할 수 있다.

 

 

 

정리하자면 

항목 핵심 요약
데이터 구조 상황에 맞는 자료구조를 써야 한다
IO 최소화 DB, API 호출을 줄여야 한다
캐시 적극 사용 비싼 연산은 저장하고 재사용한다
데이터 양 고려 언제든 데이터가 늘어날 수 있다
반복 최소화 이중/삼중 for문은 반드시 경계한다

쿼리 계획이란?

쿼리 계획(Execution Plan)은 오라클 데이터베이스가 SQL 문장을 실행할 때 "어떤 방법으로 데이터를 읽고 처리할지" 미리 계산해서 보여주는 설명서이다.

 

즉,

  • 테이블을 풀스캔할지?
  • 인덱스를 탈지?
  • 조인을 어떤 순서로 할지? 

등을 알려주는 실행 청사진이다.

 

이걸 보면 쿼리 성능 문제를 미리 예측하거나 최적화할 수 있다.

 

 

SQL Developer에서 Explain Plan 확인하는 기본 방법

  1. SQL Developer 실행
  2. 오라클 DB에 로그인
  3. 쿼리 작성  ex) SELECT * FROM employees WHERE department_id = 10;
  4. 쿼리 블록을 드래그하거나 선택한 후, 상단 메뉴에서 Explain Plan(F10) 버튼 클릭 또는 오른쪽 클릭 → Explain Plan 선택

 

Explain Plan 결과 화면 읽는 방법

컬럼 설명
Operation 어떤 작업을 했는지(Full Table Scan, Index Scan 등)
Object Name 대상 테이블이나 인덱스 이름
Rows (Cardinality) 예측 결과로 읽게 될 데이터 건수
Cost 이 작업에 필요한 예상 리소스 소비량(CPU, IO 등)
Bytes 읽어야 할 데이터 크기

 

Cardinality 가 높다 = 많은 데이터가 나온다. / Cardinality가 낮다 = 적은 데이터가 나온다.

 

자주 나오는 Operation 종류 및 의미

Operation 의미
TABLE ACCESS FULL 테이블 전체를 읽는다( 성능 위험 신호 But 더 좋을때도 있음!)
INDEX RANGE SCAN 인덱스를 범위 검색(좋음)
INDEX UNIQUE SCAN 인덱스를 정확히 하나만 검색(매우 좋음)
NESTED LOOPS 두 테이블을 반복적으로 조인(작은 데이터에 적합)
HASH JOIN 대량 데이터 조인 (메모리 사용 많음)
SORT AGGREGATE 집계 연산(SUM, COUNT 등)

 

 

 

Explain Plan 볼 때 주의할 점!

1. Cost가 낮다고 무조건 좋은 게 아니다.

Cost는 참고용이고 "IO 비용"을 줄이는게 최종 목표이다.

 

2. 풀스캔(TABLE ACCESS FULL)이 무조건 나쁜 건 아니다.

아주 작은 테이블이라면 풀스캔이 더 빠를 수도 있다. 하지만 큰 테이블이라면 반드시 인덱스 활용을 고려해봐야한다.

 

3. 실행계획이 변할 수 있다.

바인드 변수, 통계 정보, 힌트 등에 따라 변동이 가능하다. ALWAYS 실제 데이터와 상황을 고려해야 한다.

 

4. AUTOTRACE나 DBMS_XPLAN.DISPLAY를 써보자

Explain Plan은 예측, Autotrace는 진짜 실행 기반이므로 함께 보는 게 좋다.

대부분의 웹 애플리케이션은 DB Connection Pool을 사용한다. DB 커넥션 풀 덕분에 동시 사용자 요청을 빠르게 처리할 수 있다.

 

하지만 어느 순간, 예상치 못한 상황이 발생한다. 

 

그 순간중 하나가 바로 커넥션 풀 고갈(Connection Exhaustion) 이다.

 

  • DB 연결 대기 시간이 급격히 증가한다
  • 요청이 지연된다
  • 최악의 경우 애플리케이션 전체가 다운된다.

 

그렇다면 DB Connection Pool에 대해서 알아보자.

 

DB Connection Pool 이란?

DB 연결(Connection)을 매번 새로 열고 닫으면 비용이 크기 때문에, 일정 수의 커넥션을 미리 생성해 풀(pool)에 담아두고 재사용하는 구조이다.

 

특징 설명
커넥션 생성 비용 절감 연결이 미리 준비되어 빠른 응답 가능
커넥션 수 제한 가능 DB에 과한 부하를 주지 않음
대기시간 단축 필요시 즉시 커넥션 제공 가능

 

인기 있는 커넥션 풀 라이브러리는 다음과 같다.

  • HikariCP (스프링 부트 기본)
  • Tomcat JDBC Pool
  • DBCP2 (Apache)

그렇다면 커넥션 풀 고갈이란 무엇일까?

 

 

커넥션 풀 고갈(Connection Pool Exhaustion) 이란?

커넥션 풀 안의 커넥션이 모두 사용 중이어서, 새로운 요청에 빌려줄 커넥션이 없는 상태를 말한다.

 

발생 과정을 간략하게 살펴보면

  1. 사용자는 요청을 보낸다.
  2. 서버는 DB 작업을 하려고 커넥션을 풀에서 꺼낸다.
  3. 풀에 남은 커넥션이 없다.
  4. 요청은 대기(wait) 하거나, 시간 초과(timeout) 되며 실패한다.

커넥션 풀이 고갈되면

현상 설명
서버 응답 지연 커넥션을 얻을 때까지 기다리면서 전체 응답이 느려진다
타임아웃 발생 일정 시간 안에 커넥션을 못 얻으면 에러가 발생한다
DB 부하 급증 또는 다운 일부 트랜잭션이 커넥션을 오래 점유하면 DB에 과부화
서버 과부화/장애 요청이 몰리면서 스레드/큐가 포화, 시스템 전반 문제 발생

 

이런 현상들이 발생할 수 있다.

 

스프링부트 + HikariCP 에서 고갈상황을 억지로 만들어 보자.

spring.datasource.hikari.maximum-pool-size: 10
spring.datasource.hikari.connection-timeout: 30000

 

위 설정을 추가해

  • 최대 10개의 커넥션만 풀에 존재시키고
  • 커넥션을 못 빌리면 최대 30초 동안 대기 후 타임아웃이 발생

만약 동시에 요청이 50개 들어오고, 각각 DB 작업을 오래 잡고 있으면 어떻게 될까?

 

결과 : 

  • 처음 10개는 커넥션 얻고 정상 처리
  • 나머지 40개는 커넥션 풀에서 대기
  • 일부 요청은 30초 대기하다 타임아웃 발생
  • 서버 전체가 느려지고 장애 징후가 보인다.

 

이런 커넥션 풀 고갈의 주요 원인은 다음과 같은 것들이 있다.

 

1. 커넥션 미반납(Conneciton Lock)

val conn = dataSource.connection
// ... 쿼리 수행
// conn.close() 빠뜨림!!

 

close()를 호출하지 않으면, 커넥션 풀로 반환되지 않는다. 시간이 지나면 커넥션이 모두 소비되고 고갈된다.

항상 try-with-resources 또는 finally 블록에서 명시적으로 close 해야 한다.

 

 

2. 긴 트랜잭션(Long Transaction)

트랜잭션 안에서 오래 걸리는 로직(슬로우 쿼리, 외부 API 호출 등)이 있으면, 커넥션을 장시간 점유하게 된다. 이렇게 되면

커넥션을 회수하지 못하고 대기열만 쌓임 → 고갈 상태가 된다.

 

 

3. 예상 외 트래픽 폭주

대규모 이벤트, 외부 봇 공격, 잘못된 반복 호출 등으로 서버가 과도하게 많은 요청을 동시에 처리하게 되면, 커넥션 풀 한계를 초과할 수 있다.

 

 

4. 커넥션 풀 설정 오류

  • maximumPoolSize를 너무 작게 설정
  • connectionTimeout을 너무 짧거나 길게 잡은경우

위와 같은 설정 문제도 고갈을 부추길 수 있다.

 

 

 

고갈 대응 방법

방법 설명
커넥션 풀 크기 조정 CPU 코어 수 x 2 또는 예상 TPS에 맞춰 조정
커넥션 타임아웃 설정 connectionTimeout을 적절하게 조절(ex: 5초)
커넥션 반납 철저 관리 close 누락 방지(try-with-resources 적극 사용)
트랜잭션 최소화 DB 점유 시간을 줄인다(슬로우 쿼리 개선)
커넥션 풀 모니터링 HikariCP Metrics, JMX를 통해 실시간 감지
슬로우 쿼리 탐지 및 개선 DB 쿼리 튜닝, 인덱스 최적화
백프레셔(Backpressure) 적용 서버에 요청이 몰릴 때, 내부적으로 대기시키거나 제한
이중화 및 읽기 전용 Replica DB 활용 부하 분산

+ Recent posts