2장을 읽고나서#
2장은 주변 사람들과 실시간 위치공유 시스템을 설계하는 것이었습니다. 읽으면서 Websocket과 Redis pub/sub에 대해 좀 더 궁금한 부분들이 있었고 그것들에 대해서 얘기해보려고 합니다.

위 사진처럼 Socket은 다중 인스턴스일 때 사용자마다 다른 인스턴스에 연결되어 있으니
이부분을 고려해야 되었습니다. 책에서는 이부분에 대한 설명은 없다보니 직접 찾아보았습니다.
두가지의 솔루션이 있었습니다. 인스턴스끼리 정보 공유를 위한 직접 Socket 연결을 하는 방식, 그리고 Redis Adapter를 사용하여 브로드캐스트 하는 방식입니다.
직접 Socket 연결#

특정 인스턴스로만 가도록 설정을 할 수 있기에 장점이 있지만 인스턴스 수가 많아지면 Mash 방식으로 연결을 해야 하기에 관리 복잡성이 증가합니다.
Redis Adapter 활용#
이 방식은 Socke.io 공식 홈페이지에서 설명하고 있습니다.

이 방식에서 알아야 할 것은 각 서버에 Socket 연결 정보는 그대로 있다는 것입니다.
따라서 pub/sub 방식을 통한 브로드캐스트로 모든 서버가 일단 메시지를 받고 각 서버 안에서 매칭되는 room이 있는지 확인하는 작업을 거칩니다.
그러면 Redis adapter에서는 무엇을 가지고 정보전달을 하는지 알아보고자 합니다.
Redis adapter의 소스코드로 좀 더 자세히 확인해보겠습니다.
Redis adapter#
/**
* Adapter constructor.
*
* @param nsp - the namespace
* @param pubClient - a Redis client that will be used to publish messages
* @param subClient - a Redis client that will be used to receive messages (put in subscribed state)
* @param opts - additional options
*
* @public
*/
constructor(
nsp: any,
readonly pubClient: any,
readonly subClient: any,
opts: Partial<RedisAdapterOptions> = {}
) {
super(nsp);
this.uid = uid2(6);
this.requestsTimeout = opts.requestsTimeout || 5000;
this.publishOnSpecificResponseChannel = !!opts.publishOnSpecificResponseChannel;
const prefix = opts.key || "socket.io";
this.channel = prefix + "#" + nsp.name + "#";
this.requestChannel = prefix + "-request#" + this.nsp.name + "#";
this.responseChannel = prefix + "-response#" + this.nsp.name + "#";
const specificResponseChannel = this.responseChannel + this.uid + "#";
const isRedisV4 = typeof this.pubClient.pSubscribe === "function";
if (isRedisV4) {
this.subClient.pSubscribe(
this.channel + "*",
(msg, channel) => {
this.onmessage(null, channel, msg);
},
true
);
this.subClient.subscribe(
[this.requestChannel, this.responseChannel, specificResponseChannel],
(msg, channel) => {
this.onrequest(channel, msg);
}
);
}
...
}Redis adapter의 생성자입니다.
this.uid = uid2(6);식별 번호를 위해 uid로 랜덤한 값을 만듭니다.
this.subClient.pSubscribe(
this.channel + "*",
(msg, channel) => {
this.onmessage(null, channel, msg);
},
true
);
this.subClient.subscribe(
[this.requestChannel, this.responseChannel, specificResponseChannel],
(msg, channel) => {
this.onrequest(channel, msg);
}
);위 코드를 통해 채널과 pSubscribe, subscribe를 동시에 하는 것을 알 수 있습니다.
pSubscribe는 패턴을 통한 구독이고 subscribe는 정해진 특정 채널을 구독합니다.
Client 소스코드#
typedef struct client {
/* 1) "구독자 = 연결"을 보여주는 핵심 */
uint64_t id; /* Redis 내부 고유 client id */
uint64_t flags; /* CLIENT_* 플래그 (PUBSUB 모드 등) */
connection *conn; /* 실제 TCP/TLS/Unix 연결 핸들 */
/* 2) 프로토콜/인증 같은 연결 상태 */
int resp; /* RESP2 or RESP3 */
int authenticated; /* 인증 여부(ACL/requirepass 등) */
time_t lastinteraction; /* 마지막 상호작용 시간(타임아웃/idle 등) */
/* 3) Pub/Sub: "이 client가 뭘 구독 중인지" */
dict *pubsub_channels; /* SUBSCRIBE한 채널 집합(=set) */
dict *pubsub_patterns; /* PSUBSCRIBE한 패턴 집합(=set) */
dict *pubsubshard_channels; /* SSUBSCRIBE(sharded pubsub) 집합(=set) */
/* 4) 출력 버퍼 */
list *reply; /* 응답(출력) 큐 */
unsigned long long reply_bytes; /* reply 큐에 쌓인 바이트 */
size_t sentlen; /* 현재 reply에서 이미 보낸 길이 */
/* 작은 응답은 고정 버퍼로도 나감*/
char *buf;
size_t bufpos;
size_t buf_usable_size;
} client;코드에서 먼저 connection쪽을 살펴보겠습니다. Client는 인스턴스당 1개라는 고정관념이 있었습니다. 정확히 어떻게 연결과정에서 어떤 것들이 저장되어있는지 궁금하여 Connection 소스코드도 확인해보겠습니다.
struct connection {
ConnectionType *type;
ConnectionState state;
int fd;
...
};이곳에 fd(file descriptor)가 있었고 연결당 하나씩 생기는 구조입니다.
fd에 대한 개념이 헷갈려서 그림으로 도식화해보았습니다.
static void connSocketAcceptHandler(aeEventLoop *el, int fd, void *privdata, int mask) {
int cport, cfd;
int max = server.max_new_conns_per_cycle;
char cip[NET_IP_STR_LEN];
UNUSED(mask);
UNUSED(privdata);
while(max--) {
cfd = anetTcpAccept(server.neterr, fd, cip, sizeof(cip), &cport);
...
acceptCommonHandler(connCreateAcceptedSocket(el,cfd,NULL), 0, cip);
}
}위 코드에서 매개변수 fd는 6379에 바인딩된 fd이고 while문 안에 있는 cfd가 새로운 TCP 연결에 해당하는
Redis측 새로운 fd 입니다.
여기서 재밌는 부분이 max에 대한 것인데요. 이 변수의 역할은 한번의 while문에서 최대 몇개의 conn까지 연결할지 설정하는 것입니다.
max가 클수록 새 연결은 빨리 받지만, 다른 작업이 밀릴 수 있습니다. 반대로 작아질수록 새 연결 수락이 느려질 수 있겠습니다.
이부분이 하나의 튜닝포인트가 될 수 있다고 느꼈습니다.
또한 레디스의 싱글 스레드의 특성으로 인해 유의해야 할 부분이 아닌가 생각이 들었습니다.
이어서 Client 소스코드를 보겠습니다.
/* 3) Pub/Sub: "이 client가 뭘 구독 중인지" */
dict *pubsub_channels; /* SUBSCRIBE한 채널 집합(=set) */
dict *pubsub_patterns; /* PSUBSCRIBE한 패턴 집합(=set) */
dict *pubsubshard_channels; /* SSUBSCRIBE(sharded pubsub) 집합(=set) */채널과 패턴에 대한 저장소를 클라이언트쪽에서도 관리하고 있다는 것을 알 수 있습니다.
/* 4) 출력 버퍼 */
list *reply; /* 응답(출력) 큐 */
unsigned long long reply_bytes; /* reply 큐에 쌓인 바이트 */
size_t sentlen; /* 현재 reply에서 이미 보낸 길이 */
/* 작은 응답은 고정 버퍼로도 나감*/
char *buf;
size_t bufpos;
size_t buf_usable_size;버퍼와 관련된 부분을 살펴보겠습니다.
Redis는 클라이언트에 내용을 전송하기 전에 buffer에 담고 전송하고 있습니다.
char *buf를 이용하고 큰 내용이거나 쌓이게 된다면 list *reply를 활용합니다.
여기서 문제가 될 수 있는 부분이 버퍼에 계속 쌓이게 되는 상황입니다. 즉, 소켓이 writable 될 때 마다 밀어내야하는데 그렇지 못할 때 입니다.
config.c#
clientBufferLimitsConfig clientBufferLimitsDefaults[CLIENT_TYPE_OBUF_COUNT] = {
{0, 0, 0}, /* normal */
{1024*1024*256, 1024*1024*64, 60}, /* slave : hard 256MB / soft 64MB for 60s */
{1024*1024*32, 1024*1024*8, 60} /* pubsub : hard 32MB / soft 8MB for 60s */
};기본값으로 위와 같이 설정되어 있습니다. slave는 Redis 사이의 연결용이라 데이터 복제를 위한 어느정도 크기가 필요하여 더 크게 잡혀있습니다.
반면 pubsub은 실시간성을 위해 쌓이는 것을 되도록 빨리 판단해야하므로 더 작게 잡혀있는 것이라고 판단됩니다.
위 크기를 조절하는 것도 튜닝 포인트가 될 수 있겠습니다.
느낀점#
소켓간 통신에서 어떻게 인스턴스 연결을 할까에서 Redis adapter, pub/sub 내부 자료구조나 동작방식에 대해 살펴보았습니다.
아직 구현을 직접 해보진 않아서 직접 사용해보면서 생기는 고민점들을 나중에 더 추가해보려고 합니다.
그리고 채널톡의 관련 기술 블로그 자료들이 상당히 유용하였습니다.
한번씩 참고하셔도 좋을 것 같습니다.
+) 2026/3/6 가상면접 스터디를 진행하면서 발표했는데 아직 제대로 이해가 안되는 부분이 있어서 매끄럽지 못했습니다. 그래서 아래 내용들을 다시 한번 정리해보려고 합니다.
spring server, socket server, 그리고 port#
위 글에서 제가 connection단위와 fd ~~ 에 대한식으로 설명을 했지만 제대로 이해를 하지 못했던 것 같습니다.
이부분에 대해서 이해가 부족하여 더 찾아보았는데요.
다음과 같이 정리할 수 있습니다.
8081 포트(리스닝 포트): Netty(Socket.IO 서버)가 bind/listen/accept 해서 실시간 소켓 처리
리스닝 포트와 소스 포트에 대한 구별을 하지 못했기에 이해하는데 어려움이 있었습니다. 소스 포트는 보통 OS에서 무작위(일정 범위 내에서)로 생성한다고 보면 될 것 같습니다.
같은 JVM이지만 내부적으로
- 리스닝 소켓(fd) 2개 (8080, 8081)
- 서로 다른 스레드풀/이벤트 루프 2세트
- Tomcat: worker threads
- Netty: event loop threads
connection, fd, 프로세스에 대한 개념도 헷갈려서 정리를 해보았습니다.
Connection#
먼저 connection은 다음 4-튜플을 논리적 고유 단위로 볼 수 있습니다.
- (srcIP, srcPort, dstIP, dstPort)
FD#
fd는 위 connection(소켓)을 프로세스가 사용하기 위해 OS가 준 번호입니다.
- 프로세스는 PID로 식별되며, 프로세스마다 독립적인 fd 테이블이 존재합니다.
- 그래서 저희 상황에 대입하면 같은 JVM 안에 Tomcat Server, Netty Socket Server가 있기에
모든 fd는 같은 fd 테이블에 존재하게 됩니다.
또한,
- fd 번호 → (커널의 socket 객체/파일 객체)로 매핑
- 해당 socket 안에는 로컬/리모트 주소, TCP 상태, 버퍼 등이 존재합니다.
그래서 정리하자면 Redis 입장에서 Client는 Socket Server단위라고 볼 수 있고 이 Socket Server는 위에서 설명한 것처럼 단일 JVM안에
있을 수도 있지만 별도의 프로세스로도 존재할 수 있습니다. (아래 사진 첨부)
참고 자료
- Redis adapter 공식문서
- Socket.io Redis adapter 소스코드
- Redis pub/sub 소스코드
- Redis connection 소스코드
- Redis socket 소스코드
실습 깃허브 (Redis pub/sub 예제 - Buffer가 동시에 몇개까지 받을 수 있을까?)
