N+1 이 왜 나쁜 걸까?
OOM 과 CPU 대기 상태를 일으킬 수 있습니다~!
OOM 이란
시스템이 메모리를 다 사용해 더 이상 추가 메모리를 할당할 수 없는 상태.
- 프로세스가 너무 많은 메모리를 요청하거나, 동시에 많은 프로세스가 실행되어 물리 메모리(RAM), 가상 메모리가 모두 고갈됐을 경우 발생.
- 운영체제는 OOM Killer 를 작동시켜 가장 많은 메모리를 사용하거나, 우선순위가 낮은 프로세스를 종료하여 메모리를 확보하려고 함.
- 시스템 성능 저하, 프로세스 강제 종료, 프로그램 비정상적인 종료.
CPU 대기 상태
프로세스가 실행을 기다리면서 CPU 자원을 할당받지 못해 대기하는 상황.
I/O 작업 등 때문에 프로세스가 일시적으로 실행되지 못하고 대기하는 것을 포함하기도 함.
- CPU 사용이 높은 작업이 많거나, I/O 작업을 기다리는 프로세스가 많을 때 발생 가능.
- I/O 작업을 하는 상태일 때도 CPU 를 사용할 수 없기 때문에, 프로세스는 계속 대기 상태가 된다.
- 시스템 응답 속도가 느려지고, 처리 성능이 저하됨. I/O 작업이 많으면 CPU 는 작업이 끝날 때까지 다른 프로세스를 실행 함. → 전체 시스템 성능에 영향이 있을 수 있음.
N+1 에서 OOM 과 CPU 대기 상태가 일어나는 이유는?
N+1 은 기본적으로 1개의 메인 쿼리 실행 후, 관련 데이터를 가져오기 위해 N개의 개별 쿼리가 실행되는 상황.
웹 애플리케이션에서 쿼리를 요청하는 상황을 가정해보자.
- 웹 애플리케이션은 DB 에 쿼리를 요청하기 위해 I/O 요청을 하고 그를 위해 시스템콜을 한다.
- 보통 DB 는 애플리케이션 서버와 다른 네트워크에 존재하므로 I/O 요청이 필수지요!
- 시스템콜을 하면 일단 현재 웹 애플리케이션이 실행하던 컨텍스트는 커널 스택에 저장하고 프로세스의 현재 상태는 PCB 에 저장한다.
- PCB 는 커널 메모리에 있음.
- 이후 커널 모드로 전환되고, 커널이 요청을 확인하니 → DB 서버에 이 쿼리 요청을 해줘 라고 되어있다.
그러면 커널은 세션 테이블을 확인하여 DB 서버의 정보가 있는지 확인한다. (커넥션 연결 유무 확인) - NIC 에 쿼리 요청을 보내어 네트워크를 타고 DB 서버로 요청이 간다.
- DB 서버의 NIC 가 전기 신호를 받으면 데이터를 DMA 컨트롤러를 이용해 메모리의 네트워크 버퍼로 옮긴다. → 그 다음 인터럽트 발생!
- 인터럽트가 발생하면 DB 서버가 당장 하던 일을 중단하고 역시 컨텍스트를 커널 스택에 집어 넣고, PCB 에 현재 상태를 저장한 뒤 커널 모드로 전환되어 인터럽트를 처리한다.
- 이후 커널이 네트워크 버퍼에 차있는 데이터를 TCP/IP 스택으로 (패킷의 IP 주소, 포트번호, 연결 상태) 확인 후 세션 테이블을 보고 기존 연결인지 새로운 연결인지 확인함.
- 패킷 순서와 무결성을 확인하고 바로 애플리케이션 계층으로 데이터 전달.
만약 3번에서 커넥션 연결이 되지 않은 요청일 경우.
- TCP 연결 설정을 우선 수행하기 위해 쿼리 요청 데이터는 잠시 네트워크 버퍼에 보관한다.
- 커널이 SYN 패킷을 DB 서버로 전송해 연결 요청을 시작한다.
- DB 서버에서는 인터럽트가 오면 ~ 위의 6번~7번으로 확인하고, 애플리케이션 연결 요청 확인을 위한 SYN-ACK 패킷을 보낸다.
- 애플리케이션 서버는 해당 패킷을 잘 받았다는 SYN 패킷을 보낸 즉시 → 커널이 네트워크 버퍼에 대기중인 쿼리 요청 데이터를 NIC 로 전송하고 4번~8번이 실행된다.
그런데 N+1 이면 이 작업들이 연속해서 일어날 것이다.
- 최대 커넥션 10개를 유지할 수 있는 상황이며, N+1 요청이 10개 발생했다.
- 각 요청은 커넥션을 물고 있는 상태.
- 애플리케이션은 추가 쿼리를 요청하려고 했으나 커넥션 풀에 아직 반환된 커넥션이 없는 상태
- 예시로 HikariCP 에서 커넥션 풀에 반환된 커넥션이 없다고 가정하면
- 스프링 앱에서는 커넥션이 반환 될 때까지(DB서버에서 응답이 되돌아올 때까지)요청을 대기 상태로 둠.
- 일정 시간 대기를 하다가 커넥션이 반환되지 않으면 timeout 발생
만약 애플리케이션이 커넥션 풀을 관리해주지 않는다면?
TCP 연결을 맺을 때까진 괜찮지만 해당 요청이 DB 서버까지 도달했을 경우.
- 최대 커넥션 수에 도달했기 때문에 커넥션 연결을 거부함.
- 또는 커넥션 대기열을 설정 가능한 DB라면 일정 시간동안 대기할 수 있도록 허용하기도 함.
- DB의 커넥션 대기열에서 대기하다가 대기 시간이 초과되면 타임아웃 발생.
- 만약 커넥션 대기열이 꽉 차있는데도 요청이 들어오면 해당 요청은 즉시 거부
→ 최종적으로는 애플리케이션에서 커넥션 풀 타임아웃 예외 발생
- 커넥션을 맺고 끊는 비용이란 TCP 3방향 핸드셰이크와 / 4방향 핸드셰이크(연결 끊기) 를 하는 비용을 이야기 함.
4방향 핸드셰이크
- FIN 플래그 설정 패킷 전송 : 클라이언트 → 서버
- 더 이상 전송할 데이터가 없다.
- ACK 플래그 패킷을 전송하여 FIN 패킷을 받았음을 확인 : 서버 → 클라이언트
- 서버도 더 이상 보낼 패킷 없다고 FIN 패킷 전송 : 서버 → 클라이언트
- 클라이언트가 FIN 을 받았다는 ACK 패킷 전송 : 클라이언트 → 서버
그럼 TCP 연결은 얼마나 유지되나.
- 애플리케이션(HTTP 서버, 웹 브라우저) : 일반적으로 몇 분
- 데이터베이스 : 유지 시간을 정할 수 있음. (MySQL 은 기본적으로 8시간) → wait_timeout, interactive_timeout
- 라우터, 방화벽, 프록시 장비 : 장비에 따라 기본적으로 5~30분 사이
- NAT 장비나 방화벽은 TCP 연결 10~30분 동안 유지하기도.
- TCP Keep-Alive 옵션 : 연결이 유휴 상태일 때 주기적으로 신호를 보내서 연결 유지를 확인할 수 있음. (기본 몇 분에서 몇 시간)
- TIME_WAIT : 연결이 종료될 때 클라이언트에서 TIME_WAIT 상태로 잠시 대기. → 몇 초에서 수십 초.
- 이전 연결에 속한 패킷이 네트워크에 남아있을 가능성 대비하여 : 새로운 포트로 새로운 연결이 바로 시작되는 것을 방지.
DB 서버에서는 어떤 일이 일어날까?
- 긴 쿼리 실행으로 인한 자원 점유
- N+1 문제로 인해 한 커넥션에서 많은 쿼리가 순차적으로 실행 됨.
- 쿼리가 매우 많은 데이터를 조회하고 조인, 정렬 등의 작업을 포함하면 → DB 서버의 CPU 와 메모리를 장시간 점유.
- 게다가 다수의 요청이 동시에 들어온다?
- CPU 와 메모리가 지속적으로 높은 사용률 유지 → DB 서버 성능 저하
- 디스크 I/O 부하 증가
- 당연히 디스크에서 읽고 쓰는 작업이 빈번하게 발생할 것이고 I/O burst 로 병목 현상 초래.
- DB 서버 메모리 캐시 및 버퍼 고갈
- DB 서버가 자주 조회되는 데이터를 메모리에 캐싱하여 빠른 응답을 제공하나, N+1 쿼리가 반복되면 캐시나 버퍼에 데이터가 과도하게 적재되어 기존의 캐시가 무효화 됨.
- 캐시와 버퍼가 무효화되면 쿼리마다 새로운 데이터를 디스크에서 읽어와야 함. → 성능 저하.
- 메모리 과부하 및 디스크 I/O 부하.
- 데드락과 락 대기 증가
- 트랜잭션이 포함된 N+1 쿼리가 다량으로 발생하면, 동일한 테이블이나 행에 다수의 락이 발생.
- 이로 인해 다른 쿼리가 대기 상태로 전환 + 데드락 발생. → DB 서버 리소스 사용 증가.
- 메모리 부족으로 OOM
- N+1 문제로 대량의 데이터를 동시에 처리 → 쿼리 실행 시점에 많은 메모리 사용.
- DB 서버가 보유한 메모리 한계 초과 시 OOM 발생해 강제 종료 가능성.
- DB 커넥션 풀과는 별개로 시스템 스레드 오버헤드 증가.
- 각 쿼리가 메모리와 CPU 리소스를 많이 소모하면, DB 서버의 스레드 풀이나 연결 관리 스레드가 계속해서 작업 대기.
- 스레드가 많아지면 스레드의 컨텍스트 스위치 잦아져 CPU 오버헤드 증가 → 성능 저하 초래.
커넥션 풀이 가득 차서 애플리케이션이 요청을 거부하더라도 이미 열린 커넥션들이 DB 서버의 자원을 과도하게 사용하면
: “데드락”, “메모리 부족 문제” 발생.
결과적으로 DB 서버가 과부하로 인해 응답하지 못하거나, 심한 경우 OOM 으로 서버 중단 가능성.
애플리케이션에서의 OOM
애플리케이션이 결과 데이터를 직접 메모리에 적재함.
- DB 에서 반환된 쿼리 결과를 앱 내부의 메모리에 저장할 것임.
- 애플리케이션에서는 “한 개”의 요청을 처리하지만 N+1 로 파생되는 쿼리가 많다면
- 대량의 데이터가 반환되어 애플리케이션의 메모리가 초과될 가능성이 큼.
(스프링의 경우) JVM 메모리 제한
- JVM 의 메모리는 물리 메모리보다 작게 설정되는 경우가 많음.
- DB 서버에서는 (JVM 보다)상대적으로 더 많은 메모리를 사용하도록 구성하는 경우가 많음.
- 그렇다면 애플리케이션이 먼저 메모리 부족에 직면할 수 있음.
캐싱 및 객체 유지
- 애플리케이션에서 ORM(JPA) 를 사용하는 경우, 데이터가 캐시에 유지되거나 객체로 변환되며 메모리 누적 발생 가능성.
- 예1) JPA 로 유저를 조회했는데 User의 Adress 도 함께 조회해야 하여 User, Adress 객체가 각각 만 개씩 메모리에 저장 됨. → 당연히 OOM 가능성 증가.
- 예2) 영속성 컨텍스트에 user, adress 객체가 저장될 것이고 (캐싱을 위해) 한 요청의 영속성 컨텍스트는 트랜잭션이 끝날 때까지 계속 유지 됨.
DB 서버에서는 어떻게 OOM 이 발생할까?
모든 사람들은 애플리케이션의 “첫 페이지”를 방문한다.
- 그 때, 만약 메인 페이지에 개인화된 정보가 있고 그 정보를 조회하기 위한 쿼리가 N+1 문제를 유발시킨다고 가정해보자.
배달의 민족 첫 페이지에 내가 이제까지 시킨 주문의 갯수가 나온다면?
만약 그 갯수가 이제까지 한 모든 주문을 SUM() 하여 구해야 하는 것이라면?
- 유저가 한 주문을 전부 찾아서 더하는 조회 쿼리가 발생할 것이다.
- 게다가 N+1 문제 때문에 주문에 관한 모든 테이블도 전부 조회하는 상황이 발생한다면
- 당연히 OOM 발생 가능성이 크다.
꼭 N+1 문제가 아니어도, 개인화 된 쿼리는 문제가 발생할 수 있다.
배달의 민족 이벤트 푸쉬 알림이 와서 평소의 500배의 트래픽이 한번에 발생한다고 가정해보자.
- 만약 메인페이지에서 “총 주문 갯수” 를 보여주는 란이 있어 집계 쿼리를 날려야 한다면?
- 나의 모든 주문을 찾기 위해 주문 테이블을 뒤져 집계해야 한다면..
- 당연히 메모리에 많은 데이터가 올려질 것이다. 왜냐하면 aggregation 쿼리는 관련된 모든 로우를 가져와서 처리해야 하니까.
- 평소에는 괜찮을(?) 수도 있지만 이벤트 푸쉬 알림 같은 상황에서 한 번에 500배의 트래픽이 발생한다면 당연히 DB 서버에서도 OOM 발생 가능성이 크다!
그러면 해결 방법은?
- 일단 “메인 페이지” 에는 개인화된 쿼리를 사용하는 정보를 보여주는 기능은 최대한 지양하자!
- 만약 굳이 꼭! 기획 상으로 보여줘야 한다면 미리 계산하여 컬럼으로 추가해두는 것이 좋다.
그럼 다른 플랫폼을 한 번 구경해보자!
배달의 민족
- 주소가 “우리집” 으로 되어있다는 정보와 우리집 근처 추천 맛집들 외에 개인 정보는 나와있지 않다.
당근
- 내가 설정해 놓은 주소만 뜬다. 나머진 그 주소와 관련된 당근할 물품들.
토스
- 이름과 내가 어떤 통장을 가지고 있는지만 나와있다.
그렇다 ! 유명(?) 애플리케이션 메인 페이지에는 “최소 정보”만 나와있다.
- 특히 배민이나 토스는 특정 시간에 트래픽이 몰리는 이벤트들이 많다.
- 배민 : 선착순 쿠폰 발행이나 7시부터 치킨 할인 이벤트 등
- 토스 : 빼빼로 데이 이벤트 등 특정 이벤트 등
- 최소한 필요한 개인 정보만 보여주고, aggregation 쿼리가 필요한 집계 데이터들은 보여주지 않는다.
- 멋진 개발자들은 “기능이 되냐 안 되냐” 를 넘어서 서비스/비즈니스 레벨로 봤을 때 보이는 문제들도 고려할 수 있어야 한다.
728x90
'개발 잡담' 카테고리의 다른 글
인생 처음으로 개발자로서 지냈던 지난 1년을 회고하며 (2) | 2024.12.19 |
---|---|
"혼자 공부하는 컴퓨터 구조 + 운영체제"를 마치며 (4) | 2024.10.22 |
NACL 은 왜 Stateless 이고, 보안 그룹은 왜 Stateful 일까? (2) | 2024.10.16 |
스프링 부트가 해주는 자동 구성을 수정할 수 있는 범위는 어디까지일까? (2) | 2024.09.26 |
이제부터 할 일 ! (0) | 2024.09.19 |