백엔드 성능 최적화를 위한 데이터베이스 통신 이해

백엔드 성능 최적화를 위한 데이터베이스 통신 이해
엔키 화이트햇에서 백엔드 개발자 인턴십을 수행하면서 회사의 기술 스택 중 하나였던 MikroORM을 처음 접하게 되었다.
Mikro ORM은 데이터베이스와의 통신을 최소화하기 위해, 마치 printf()의 I/O 버퍼처럼 SQL 쿼리를 내부 버퍼에 쌓아두었다가 특정 시점에 한 번에 flush하여 전송하는 방식을 사용한다.
Prisma ORM이나 Mongoose ORM을 사용할 때는 이러한 접근을 체감하기 어려웠는데, Mikro ORM은 왜 이러한 방식을 채택했을까? 백엔드 애플리케이션과 데이터베이스 사이에서는 어떤 과정들이 일어나고, 무엇이 성능의 병목을 만들어낼까?
이 질문을 출발점으로, 이번 글에서는 데이터베이스 통신의 핵심 개념인 동기식 vs 비동기식과 블로킹 vs 논블로킹을 중심으로, 백엔드 성능 최적화를 위한 통신 원리와 ORM 최적화 기법까지 정리하고자 이 글을 작성하게 되었다.
목차
- 애플리케이션과 데이터베이스는 어떻게 연결될까
- 동기식 vs 비동기식 통신
- 블로킹 vs 논블로킹 IO
- 각 방식의 조합과 특징
- 백엔드 애플리케이션에서의 IO 방식 선택
- ORM의 통신 최적화 기법
- Mikro ORM의 flush 전략
- 마치며
애플리케이션과 데이터베이스는 어떻게 연결될까
백엔드 애플리케이션이 데이터베이스와 통신하려면 먼저 네트워크 연결이 필요하다.
이 연결은 TCP/IP 프로토콜을 기반으로, 소켓(IP:Port)을 통해 이루어진다.
이후, 인증 정보(계정, 비밀번호, SSL 인증서 등)를 교환하여 애플리케이션이 데이터베이스에 접근할 수 있는 권한을 얻는다.
이 과정에서는 네트워크 지연, I/O 작업, 프로토콜 처리 등 다양한 오버헤드가 발생한다. 데이터베이스는 애플리케이션 입장에서 외부 시스템이므로, 통신 자체가 비교적 비용이 큰 작업이다.
따라서 애플리케이션은 데이터베이스와의 통신 횟수를 최소화하고, 응답을 기다리는 동안 CPU 자원이 낭비되지 않도록 주로 비동기식 논블로킹 통신 방식을 선호한다.
동기식 vs 비동기식 통신
애플리케이션이 데이터베이스로 요청을 보냈을 때, 응답을 기다리는 동안 애플리케이션이 어떤 상태를 유지하는지에 따라 동기식 통신과 비동기식 통신으로 나눌 수 있다.
- 동기식 통신(Synchronous Communication): 요청을 보낸 후 응답이 올 때까지 기다리는 방식으로 구현이 간단하고 직관적이라는 장점이 있다. 하지만 응답 대기 시간이 길어질 경우 전체 시스템의 성능이 저하될 수 있다는 단점이 있다.
- 비동기식 통신(Asynchronous Communication): 요청을 보낸 후 즉시 다음 작업을 수행할 수 있는 방식이다. 이 방식에서는 요청을 보낸 후 응답이 도착할 때까지 기다리지 않고 다른 작업을 수행할 수 있으므로 시스템 자원을 효율적으로 활용할 수 있지만, 구현이 복잡하고 디버깅이 어려울 수 있다는 단점이 있다.
특히 통신과 I/O는 CPU 작업에 비해 매우 느리기 때문에, 비동기식 통신 방식을 사용하면 CPU가 응답을 기다리는 동안 다른 작업을 수행할 수 있어 전체 시스템의 처리량이 향상된다.
블로킹 vs 논블로킹 IO
동기/비동기 방식이 응답 결과를 언제 확인할지를 다루는 개념이라면, 블로킹/논블로킹 방식은 응답을 기다리는 동안 호출한 주체(프로세스 또는 쓰레드)가 멈추는지 여부를 설명하는 개념이다.
- 블로킹 I/O(Blocking I/O) 요청을 보낸 후 응답이 올 때까지 해당 쓰레드가 대기 상태에 머무르는 방식으로, 구현이 쉽고 직관적이라는 장점이 있다. 그러나 응답 대기 시간이 길어질 경우 전체 시스템의 처리량이 저하될 수 있다는 단점이 있다.
- 논블로킹 I/O(Non-Blocking I/O) 요청을 보낸 즉시 다음 작업을 수행할 수 있다. 응답이 도착할 때까지 기다리지 않고 다른 작업을 수행할 수 있으므로 시스템 자원을 효율적으로 활용할 수 있지만, 구현이 복잡하고 디버깅이 어려울 수 있다.
이 지점에서 많은 사람들이(특히 내가) 동기식/비동기식과 블로킹/논블로킹을 많이 혼동하는데, 이 둘은 독립적인 개념임을 명확히 할 필요가 있다. 쉽게 말하면, 동기/비동기는 결과를 확인할 때까지 기다릴지 다른 일을 하다가 나중에 확인할지를 결정하는 개념이고, 블로킹/논블로킹은 결과를 기다리는 동안 호출한 쓰레드가 멈출지 움직일지를 결정하는 개념이다.
CPU와 애플리케이션 관점에서 보면, 블로킹 상태에서는 다른 쓰레드가 CPU를 사용할 수 있어 자원 활용은 효율적이지만, 호출한 애플리케이션은 응답을 기다리는 동안 아무 작업도 수행하지 못해 전체 처리량이 떨어질 수 있다. 반면 논블로킹 상태에서는 호출한 애플리케이션이 응답을 기다리는 동안 다른 작업을 수행할 수 있어 처리량이 증가하지만, CPU 입장에서는 응답 여부를 계속 확인해야 하므로 낭비가 발생할 수 있다.
각 방식의 조합과 특징
동기식/비동기식과 블로킹/논블로킹의 조합으로 총 네 가지 통신 방식이 존재한다.
| 블로킹 I/O | 논블로킹 I/O | |
|---|---|---|
| 동기식 통신 | 동기식 블로킹 I/O | 동기식 논블로킹 I/O |
| 비동기식 통신 | 비동기식 블로킹 I/O | 비동기식 논블로킹 I/O |
각 통신 방식의 특징은 다음과 같다.
- 동기식 블로킹 I/O: 가장 단순한 통신 방식으로, 요청을 보낸 후 응답이 올 때까지 기다린다. 이 방식은 구현이 쉽지만, 응답 대기 시간이 길어질 경우 전체 시스템의 성능이 저하될 수 있다.
- 동기식 논블로킹 I/O: 요청을 보낸 후 응답이 준비되지 않으면 즉시 반환하지만, 여전히 응답을 직접 확인해야 하는 구조(Polling 등)이다.
- 비동기식 블로킹 I/O: 요청을 보낸 후 응답이 올 때까지 기다리지 않고 다른 작업을 수행할 수 있다. 그러나 요청이 완료될 때까지 해당 프로세스는 대기 상태에 머무른다. 실질적인 의미는 거의 없다.
- 비동기식 논블로킹 I/O: 요청을 보낸 후 즉시 다음 작업을 수행할 수 있다. 응답이 도착하면 이벤트나 콜백을 통해 처리하는 방식으로, 이 방식은 시스템의 자원을 효율적으로 활용할 수 있으며, 현대의 많은 백엔드 시스템에서 선호되는 방식이다.
이를 조금 더 쉽게 이해하기 위해 음식점에서 주문을 하는 상황을 예로 들어보자.
- 동기식 블로킹 I/O: 음식을 주문한 후, 카운터에서 음식을 받을 때까지 기다린다. 다른 일을 할 수 없다.
- 동기식 논블로킹 I/O: 음식을 주문한 후, 카운터에서 기다리면서 음식이 준비되었는지 물어본다. 다른 일을 할 수 없다.
- 비동기식 블로킹 I/O: 음식을 주문한 후 벨을 받고 카운터에서 기다린다. 벨을 받았지만 아무 일도 할 수 없고 그저 기다린다. 의미가 없다.
- 비동기식 논블로킹 I/O: 음식을 주문한 후 다른 일을 하다가 벨이 울리면 음식을 받으러 간다.
백엔드 애플리케이션에서의 IO 방식 선택
동기식 vs 비동기식
백엔드 애플리케이션에서는 작업 간 의존성에 따라 동기/비동기 방식을 결정하는 것이 중요하다.
예를 들어, 사용자가 친구 관계에 있는 다른 사람을 차단하는 상황을 생각해보자. 비즈니스 로직 상 친구를 차단하면 더 이상 친구 관계가 유지될 수 없으므로, 친구 관계를 삭제하고 차단 목록에 추가하는 두 가지 작업이 필요하다. 이를 typeScript 기반의 pseudo 코드로 표현하면 다음과 같다.
const blockUser = async (userId: string, targetId: string): Promise<void> => {
database.erase("friends", { userId, targetId }); // 친구 관계 삭제
database.insert("blocked_users", { userId, targetId }); // 차단 목록에 추가
};
이 경우 두 가지 작업이 서로 독립적이며 순서에 상관없이 실행될 수 있고 서로의 결과에 의존하지 않는다. 따라서 이 함수는 비동기 방식을 사용하는 것이 가장 적합하다. (성공 여부, 트랜잭션, 반환 값 등의 다른 요소는 고려하지 않기로 한다.)
이번엔 프로필 사진을 등록(업데이트)하는 상황을 생각해보자. 사용자가 새로운 프로필 사진을 업로드하면, 먼저 사진을 저장소에 업로드한 후, 해당 URL을 데이터베이스에 저장해야 한다. 이를 typescript 기반의 pseudo 코드로 표현하면 다음과 같다.
const updateProfilePicture = async (
userId: string,
file: File
): Promise<void> => {
const imageUrl = await storage.upload(file); // 사진 저장소에 업로드
database.update("users", { id: userId }, { profilePicture: imageUrl }); // 데이터베이스에 URL 저장
};
이 함수는 두 개의 작업을 순차적으로 수행한다: 먼저 사진을 저장소에 업로드하고, 그 다음에 데이터베이스에 URL을 저장하는 것이다. 이 경우 사용자의 프로필 사진이 정상적으로 업데이트되기 위해서는 첫 번째 작업이 성공적으로 완료되어야 한다. 따라서 이 함수는 동기 방식을 사용하는 것이 적합하다.
블로킹 vs 논블로킹 IO
블로킹/논블로킹 방식은 애플리케이션의 구조와 I/O 처리 모델에 따라 달라진다.
42Seoul 과정 진행중에 Webserv라는 Nginx와 유사한 웹 서버를 구현하는 프로젝트를 수행했었는데, 이 때 요청 처리를 어떤 식으로 할 지 결정하는 것이 중요했다. 멀티 프로세스, 멀티 쓰레드, I/O 멀티플렉싱(select, epoll, kqueue, ...) 등 다양한 방법이 있었는데, 최종적으로는 kqueue 기반의 논블로킹 I/O 방식을 선택했다.
백엔드 애플리케이션이 멀티 프로세스 혹은 멀티 쓰레드 방식으로 동작한다면, 각 프로세스/쓰레드가 블로킹 I/O 방식을 사용해도 큰 문제가 없을 수 있다. 오히려 논블로킹으로 구현하여 폴링 비용을 감수하는 것보다, 각 프로세스/쓰레드가 블로킹 상태에 머무르는 것이 더 효율적일 수 있다.
현재 사용 중인 Nest.js는 Node.js 기반으로 동작하며, 싱글 쓰레드 이벤트 루프 모델을 사용한다. 따라서 Nest.js 애플리케이션에서는 논블로킹 I/O를 활용해 데이터베이스와 상호작용하는 것이 가장 적합하다.
ORM의 통신 최적화 기법
ORM(Object-Relational Mapping)은 객체 지향 프로그래밍 언어와 관계형 데이터베이스 간의 데이터를 변환하는 기술이다. ORM은 데이터베이스와의 통신을 최소화하기 위해 다양한 최적화 기법을 사용한다. 대표적인 기법으로는 다음과 같은 것들이 있다.
- 지연 로딩(Lazy Loading): 필요한 시점에만 데이터를 로드하는 기법으로, 불필요한 데이터 로드를 방지하여 통신 횟수를 줄인다.
- 일괄 처리(Batch Processing): 여러 개의 데이터 조작 작업을 하나의 트랜잭션으로 묶어 처리하는 기법으로, 데이터베이스와의 통신 횟수를 줄인다.
- 캐싱(Caching): 자주 사용되는 데이터를 메모리에 저장하여 데이터베이스와의 통신을 줄이는 기법이다.
- 변경 감지(Change Tracking): 객체의 상태 변화를 추적하여 실제로 변경된 데이터만 데이터베이스에 반영하는 기법으로, 불필요한 업데이트를 방지한다.
- 쿼리 최적화(Query Optimization): 복잡한 쿼리를 최적화하여 데이터베이스의 부하를 줄이고 응답 속도를 향상시키는 기법이다.
ORM은 이러한 최적화 기법들을 활용하여 데이터베이스와의 통신을 최소화하고, 애플리케이션의 성능을 향상시킨다.
Mikro ORM의 flush 전략
Mikro ORM은 데f터베이스와의 통신을 최소화하기 위해, SQL 쿼리를 내부 버퍼에 쌓아두었다가 특정 시점에 한 번에 flush하여 전송하는 방식을 사용한다. 이러한 방식은 printf()의 I/O 버퍼와 유사하다.
Mikro ORM의 엔티티 매니저(Entity Manager)는 내부적으로 변경된 엔티티들의 상태를 추적하고, 변경 사항을 SQL 쿼리로 변환하여 내부 버퍼에 저장한다.
예를 들어, user.name = "newName"; 와 같이 엔티티 매니저가 관리하고 있는 엔티티의 속성이 변경되면, 해당 변경 사항에 대한 SQL 쿼리가 내부 버퍼에 쌓이게 된다.
또한, persist(), remove() 등의 메서드를 호출하여 엔티티를 추가하거나 삭제할 때도 마찬가지로 SQL 쿼리가 내부 버퍼에 쌓이게 되고, 최종적으로 flush 시점에 한 번에 데이터베이스에 전송된다.
Mikro ORM은 다음과 같은 시점에 flush를 수행한다.
- 트랜잭션 커밋 시점: 트랜잭션이 커밋될 때, 내부 버퍼에 쌓인 모든 쿼리를 한 번에 데이터베이스에 전송한다.
- 명시적 flush 호출 시점: 개발자가 명시적으로 flush 메서드를 호출할 때, 내부 버퍼에 쌓인 쿼리를 데이터베이스에 전송한다.
- 쿼리 실행 시점: 특정 쿼리를 실행할 때, 내부 버퍼에 쌓인 쿼리를 데이터베이스에 전송한다.
기본적으로 트랜잭션의 커밋 시점에 flush가 자동으로 호출되지만, 필요에 따라 명시적으로 flush를 호출하여 쿼리를 즉시 전송할 수도 있다. 특히, auto increment ID와 같이 스키마에서 자동 생성되는 필드 값을 즉시 사용해야 하는 경우, 명시적으로 flush를 호출해 해당 레코드를 데이터베이스에 생성하고 값을 받아와야 한다.
마치며
그동안 진행한 프로젝트는 상대적으로 적은 트래픽 환경에서 PrismaORM, MongooseORM 등과 같은 ORM을 함수형 프로그래밍처럼 사용해왔었다. 하지만 MikroORM이나 Hibernate와 같은 ORM을 접하면서, 백엔드 애플리케이션과 데이터베이스 간 통신 인터페이스를 보다 세밀하게 이해하는 것이 중요하다는 것을 깨닫게 되었다. 특히 대규모 트래픽을 처리해야 하는 시스템에서는 데이터베이스와의 통신이 성능 병목이 될 수 있기 때문에, 이러한 통신 방식을 이해하고 적절히 최적화하는 것이 애플리케이션 성능 향상에 핵심적인 인사이트가 됐다.