레디스가 싱글 스레드임에도 불구하고 빠른이유

반응형

일반적으로는 싱글 스레드보다 멀티 스레드가 더 빠르다고 생각합니다. 실제로도 혼자 일하는 것보다 여러 사람이 함께 일하는 편이 더 많은 일을 처리할 수 있습니다. 하지만 우리가 간과하기 쉬운 사실이 하나 있습니다. 바로 락(Lock) 입니다. 멀티 스레드는 여러 작업을 동시에 처리할 수 있지만, 공유 자원에 접근하는 순간 락이 발생합니다. 락 경쟁이 심해질수록 스레드는 대기하거나 문맥 교환을 반복하게 되고, 기대했던 성능 향상을 얻지 못하는 경우도 적지 않습니다. 그렇다면 락을 사용하는 멀티 스레드보다, 락 자체가 필요 없는 싱글 스레드가 더 효율적일 수도 있지 않을까요? 오늘날 Redis, Nginx, Node.js처럼 싱글 스레드 기반의 이벤트 루프를 활용하는 기술들은 이러한 질문에 대한 하나의 답을 보여 주고 있습니다. 하지만 이러한 발상이 처음부터 존재했던 것은 아닙니다. 그 시작은 1999년 Dan Kegel이 제시한 C10K 문제에 있습니다. 당시 서버는 1만 개의 동시 연결조차 효율적으로 처리하지 못했고, 기존의 멀티 스레드 멀티 프로세스 모델은 한계를 드러내고 있었습니다.

이번 글에서는 C10K 문제가 무엇이었는지, 왜 기존 방식으로는 해결하기 어려웠는지, 그리고 이 문제가 이후의 서버 아키텍처와 이벤트 기반 프로그래밍에 어떤 영향을 주었는지 살펴보겠습니다.

C10K 문제란 무엇일까?

생각보다 문제는 단순합니다.

"서버가 동시에 10,000개의 클라이언트 연결을 처리하려면 어떻게 해야 할까?"

이 질문이 바로 C10K(C10,000) 문제입니다.

1999년 Dan Kegel은 당시 서버 아키텍처가 10,000개의 동시 연결을 효율적으로 처리하지 못한다는 점을 지적했습니다. 당시에는 연결마다 하나의 프로세스나 스레드를 생성하는 방식이 일반적이었습니다.

예를 들어 동시에 10,000명의 사용자가 서버에 접속한다면, 서버는 최대 10,000개의 스레드를 생성하게 됩니다. 처음에는 각 스레드가 독립적으로 요청을 처리하므로 효율적으로 보일 수 있습니다.

하지만 스레드가 많아질수록 문제가 발생합니다. 각 스레드는 메모리를 차지할 뿐만 아니라, 운영체제는 여러 스레드를 번갈아 실행하기 위해 끊임없이 문맥 교환(Context Switching) 을 수행해야 합니다. 결국 CPU는 실제 요청을 처리하는 시간보다 스레드를 전환하는 데 더 많은 시간을 사용하게 되고, 서버의 성능은 급격히 저하됩니다.

그렇다면 정말 연결마다 스레드를 하나씩 생성해야만 할까요? 하나의 스레드가 여러 연결을 효율적으로 관리할 수는 없을까요?
C10K 문제는 바로 이러한 질문에서 시작되었습니다.

I/O 멀티 플렉싱(I/O Multiplexing)

C10K 문제를 해결하기 위한 방법은 여러 가지가 있지만, 그중 가장 대표적인 기술이 I/O 멀티플렉싱(I/O Multiplexing) 입니다.

멀티플렉싱(Multiplexing)을 직역하면 '여러 개를 하나로 처리한다'는 의미를 가지고 있습니다.

기존의 서버는 연결(Connection) 하나당 하나의 스레드를 생성하여 요청을 처리했습니다. 따라서 동시에 연결되는 클라이언트가 많아질수록 스레드 수도 함께 증가했고, 메모리 사용량과 문맥 교환(Context Switching) 비용도 커질 수밖에 없었습니다.

반면, I/O 멀티플렉싱은 하나의 스레드가 여러 연결을 동시에 감시하다가 이벤트가 발생한 연결만 선택적으로 처리하는 방식입니다. 즉, 모든 연결에 스레드를 할당하는 것이 아니라, 하나의 스레드가 여러 연결을 효율적으로 관리할 수 있도록 만든 기술입니다.

덕분에 적은 수의 스레드만으로도 수많은 동시 연결을 처리할 수 있게 되었으며, C10K 문제를 해결하는 핵심 기술로 자리 잡게 되었습니다.

I/O 멀티플렉싱을 기반으로 select, poll, epoll, kqueue와 같은 다양한 기술이 등장했습니다.

이러한 기술의 발전으로 하나의 스레드가 여러 연결을 지속적으로 감시하고, 이벤트가 발생한 연결만 처리하는 이벤트 루프(Event Loop) 방식이 널리 사용되기 시작했습니다.

이벤트 루프

이벤트 루프(Event Loop)라는 이름을 보면 이벤트를 반복적으로 처리하는 구조라는 의미를 떠올릴 수 있습니다.

실제로 이벤트 루프는 하나의 스레드에서 계속 실행되며, 이벤트가 발생한 연결을 확인하고 해당 작업을 처리하는 실행 방식입니다. 즉, 모든 연결에 스레드를 생성하는 것이 아니라 하나의 스레드가 지속적으로 이벤트를 감시하고, 이벤트가 발생한 연결만 선택적으로 처리합니다.

이러한 방식 덕분에 적은 수의 스레드만으로도 많은 동시 연결을 효율적으로 처리할 수 있습니다.

그렇다면 이벤트 루프는 실제로 어떻게 동작할까요? 간단한 자바 코드를 통해 살펴보겠습니다.

구현

public class EventLoop {

    public static void main(String[] args) throws Exception {

        List<Connection> connections = List.of(
                new Connection("Client-1"),
                new Connection("Client-2"),
                new Connection("Client-3")
        );

        while (server.isRunning()) {

            // 이벤트가 발생한 연결만 가져온다.
            List<Connection> readyConnections = findReadyConnections(connections);

            // 이벤트 처리
            for (Connection connection : readyConnections) {
                connection.handle();
            }
        }
    }

    private static List<Connection> findReadyConnections(List<Connection> connections) {

        return connections.stream()
                .filter(Connection::hasEvent)
                .toList();
    }
}

class Connection {

    private final String name;

    Connection(String name) {
        this.name = name;
    }

    boolean hasEvent() {
        // 실제로는 epoll, kqueue 등이 이벤트를 알려준다.
        return Math.random() > 0.7;
    }

    void handle() {
        System.out.println(name + " 요청 처리");
    }
}

이벤트 루프는 매우 단순한 구조입니다.

먼저 이벤트가 발생한 연결을 확인한 뒤(findReadyConnections()), 이벤트가 존재하는 연결만 순회하며 처리합니다(handle()). 이후 다시 이벤트를 확인하고 처리하는 과정을 계속 반복합니다. 실제 운영체제에서는 findReadyConnections() 역할을 epoll, kqueue와 같은 I/O 멀티플렉싱 기술이 수행합니다. 이벤트 루프는 전달받은 이벤트만 처리하면서 계속 반복 실행됩니다. Redis의 이벤트 루프도 개념적으로는 이 구조와 거의 같습니다. 다만 실제 구현에서는 epoll_wait() 등이 이벤트가 발생할 때까지 블로킹하며 대기하고, 이벤트가 발생하면 해당 이벤트 목록만 전달받아 처리하는 방식으로 동작합니다.

여기서는 epoll을 "이벤트가 발생했음을 알려주는 알람 기능" 정도로 이해해도 충분합니다. 내부 동작은 훨씬 복잡하지만, 이벤트 루프가 필요한 연결만 처리할 수 있도록 도와주는 역할을 한다고 생각하면 됩니다.

이벤트 기반 서버를 구현하는 대표적인 I/O 처리 패턴

이벤트 기반 서버를 구현하는 대표적인 I/O 처리 패턴은 ReactorProactor, 크게 두 가지입니다. 앞에서 살펴본 코드는 이벤트 루프의 동작 원리를 이해하기 위해 단순화한 예제입니다. 실제 시스템에서는 이벤트를 감지하고 처리하는 방식에 따라 Reactor와 Proactor 패턴으로 구현됩니다. 두 패턴 모두 이벤트 기반으로 동작한다는 공통점이 있지만, 누가 I/O를 수행하고 이벤트를 처리하는지에 따라 동작 방식에 차이가 있습니다.

이 글에서는 Reactor 패턴을 중심으로 설명하겠습니다. 그 이유는 Reactor 패턴이 Redis를 비롯해 Nginx, Netty와 같은 네트워크 서버에서 널리 사용되는 이벤트 처리 방식이기 때문입니다. 반면 Proactor 패턴도 이벤트 기반으로 동작하지만, 주로 운영체제가 비동기 I/O를 직접 지원하는 환경에서 사용됩니다. 따라서 Redis의 동작 방식을 이해하기 위해서는 Reactor 패턴을 먼저 이해하는 것이 가장 자연스럽습니다.

Reactor

Reactor Pattern은 이벤트가 발생할 때까지 기다렸다가(Event Demultiplexing), 이벤트가 발생하면 해당 이벤트를 적절한 처리 로직(Handler)으로 전달하는 이벤트 기반 설계 패턴입니다. 쉽게 말하면 "이벤트를 감지하고, 적절한 담당자에게 일을 분배하는 패턴"이라고 생각하면 됩니다.

while (true) {

    List<Event> events = findReadyEvents();

    for (Event event : events) {
        reactor.dispatch(event);
    }
}

public void dispatch(Event event) {

    switch (event.getType()) {
        case GET -> get(event);
        case SET -> set(event);
        case DEL -> del(event);
    }
}

이벤트 루프가 계속 실행되면서 이벤트를 기다리고 있다가, 이벤트가 발생하면 어떤 작업을 수행해야 하는지 확인한 뒤 해당 처리 로직을 호출합니다.

Redis는 클라이언트의 요청을 적절한 명령 처리 로직으로 전달하기 위해 Reactor 패턴을 사용합니다.

결론

이번 글에서는 Redis가 싱글 스레드임에도 높은 성능을 낼 수 있는 이유를 C10K 문제부터 I/O 멀티플렉싱, 이벤트 루프, 그리고 Reactor 패턴까지 살펴보며 알아보았습니다. Redis가 단순히 "싱글 스레드라서 빠르다"가 아니라, C10K 문제를 해결하기 위해 등장한 기술과 설계 방식을 적극적으로 활용한 결과라는 점을 이해할 수 있었습니다. 다만 이번 글에서는 주제에서 벗어나지 않기 위해 Reactor 패턴의 내부 동작이나 구현 방식까지는 자세히 다루지 않았습니다. Redis의 동작 원리를 이해하는 데 필요한 수준에서만 살펴보았습니다.

다음 글에서는 Reactor 패턴을 조금 더 깊이 있게 살펴보고, 또 다른 이벤트 처리 방식인 Proactor 패턴과의 차이점도 함께 알아보겠습니다. 두 패턴은 모두 이벤트 기반으로 동작하지만 I/O를 처리하는 방식에 차이가 있으며, 어떤 환경에서 각각이 적합한지도 함께 살펴볼 예정입니다.

반응형

댓글

Designed by JB FACTORY