TCP Socket 서버 설계 필수 항목 3가지

TCP Socket 서버 설계는 HTTP API 개발과는 전혀 다른 사고방식을 요구합니다. HTTP는 요청-응답 후 연결이 닫히지만, Socket은 연결이 유지되는 동안 서버 자원을 계속 점유합니다. 그래서 설계 단계에서 한 가지를 빠뜨리면 운영에 들어가서 메모리 누수, 좀비 커넥션, 서비스 중단으로 돌아옵니다.

이 글에서는 Socket 서버를 신규 구축하거나 점검할 때 반드시 챙겨야 할 3가지 필수 항목을 정리합니다. 인증 타임아웃, ping/pong 헬스체크, 재연결 로직 — 어느 하나라도 누락되면 정확히 어떤 사고로 이어지는지 실제 운영 경험 기반으로 풀어 보겠습니다.


HTTP와 다른 Socket 서버의 본질

HTTP API를 만들던 감각으로 Socket 서버를 만들면 거의 모든 함정에 빠집니다. 두 모델은 자원 관리 방식이 근본적으로 다릅니다.

비교 항목HTTP APITCP Socket 서버
연결 수명요청-응답 후 종료 (또는 짧은 keep-alive)클라이언트가 끊을 때까지 유지
서버 자원 점유요청 처리 중에만 점유연결되어 있는 내내 점유
상태 관리Stateless (각 요청 독립)Stateful (세션·인증 상태 유지)
장애 모델5xx 응답으로 끝연결이 조용히 죽어도 서버가 모름
핵심 자원스레드·CPU동시 연결 수·메모리·파일 디스크립터

핵심은 “서버가 동시 N개의 연결을 직접 관리해야 한다” 는 점입니다. 각 연결마다 버퍼·핸들러·세션 정보가 메모리에 자리 잡으므로, 연결이 깔끔하게 정리되지 않으면 그 잔여물이 누적되어 OutOfMemoryError로 직행합니다.

이 사고 모델 차이가 곧 이어서 다룰 3가지 필수 항목의 이유가 됩니다.


필수 1: 연결 시 인증과 미인증 연결의 강제 종료

사고 사례 폐쇄망에서 발생한 메모리 아웃

내부 시스템 간 통신용으로 만든 Socket 서버에서 어느 날 갑자기 메모리가 천천히 차오르다 OOM이 발생한 일이 있었습니다. 처음에는 트래픽 패턴이 바뀐 것을 의심했지만, JVM Heap Dump를 분석해 보니 인증되지 않은 채로 남아 있는 연결 객체 수천 개가 발견되었습니다.

원인은 단순했습니다. 초기 설계 시 “폐쇄망이니까 누구나 접속해도 괜찮겠지” 라는 가정 아래 연결 시 인증 단계를 두지 않았습니다. 그러나 운영 환경에서는 다음과 같은 일이 실제로 발생합니다.

  • 다른 시스템이 잘못된 IP로 접속해 와서 그대로 연결만 유지
  • 네트워크 장비의 헬스체크가 TCP 연결만 맺고 빠지지 않음
  • 테스트 스크립트나 보안 스캐너가 연결을 열어 두고 응답을 기다림

이 연결들은 아무 일도 하지 않으면서 서버 자원만 점유합니다. 폐쇄망이라도 마찬가지입니다. 결국 서버 메모리가 한계에 도달했고, 정상 트래픽까지 영향을 받았습니다.

해결법 연결 후 N초 내 인증 강제

해결의 핵심은 두 가지입니다.

첫째, 연결 직후 일정 시간(예: 5초) 안에 인증 메시지가 도착하지 않으면 강제로 연결을 끊습니다.

둘째, 인증 실패 시에도 즉시 연결을 종료하고 로그를 남깁니다.

Netty 기반 예시는 다음과 같습니다.

public class SocketServerInitializer extends ChannelInitializer<SocketChannel> {

    @Override
    protected void initChannel(SocketChannel ch) {
        ChannelPipeline pipeline = ch.pipeline();

        // 1단계: 5초 내 어떠한 데이터도 안 오면 연결 종료
        pipeline.addLast(new ReadTimeoutHandler(5));

        // 2단계: 메시지 디코더
        pipeline.addLast(new LengthFieldBasedFrameDecoder(64 * 1024, 0, 4, 0, 4));

        // 3단계: 인증 핸들러 (인증 성공 시 자기 자신 제거)
        pipeline.addLast(new AuthHandler());

        // 4단계: 실제 비즈니스 핸들러
        pipeline.addLast(new BusinessHandler());
    }
}

public class AuthHandler extends SimpleChannelInboundHandler<ByteBuf> {

    @Override
    protected void channelRead0(ChannelHandlerContext ctx, ByteBuf msg) {
        if (!isValidAuth(msg)) {
            log.warn("[AUTH FAIL] remote={}", ctx.channel().remoteAddress());
            ctx.close();
            return;
        }
        // 인증 성공: 타임아웃 핸들러 제거 후 자기 자신도 제거
        ctx.pipeline().remove(ReadTimeoutHandler.class);
        ctx.pipeline().remove(this);
    }
}

ReadTimeoutHandler(5)는 5초간 어떤 데이터도 수신되지 않으면 예외를 발생시켜 연결을 강제로 종료합니다. 인증 성공 시에만 타임아웃을 해제하므로, “연결만 맺고 아무것도 하지 않는” 좀비 클라이언트가 누적될 여지가 사라집니다.

추가로 동시 연결 수 상한도 함께 두면 좋습니다. 인증 절차가 있어도, 인증 성공한 정상 연결이 한 번에 폭증하면 자원이 부족해질 수 있습니다.

// 글로벌 동시 연결 수 제한
private static final AtomicInteger ACTIVE_CONNECTIONS = new AtomicInteger();
private static final int MAX_CONNECTIONS = 10_000;

@Override
public void channelActive(ChannelHandlerContext ctx) {
    if (ACTIVE_CONNECTIONS.incrementAndGet() > MAX_CONNECTIONS) {
        log.warn("[REJECT] 동시 연결 수 초과");
        ctx.close();
    }
}

필수 2: ping/pong 헬스체크로 죽은 연결을 정리한다

왜 TCP만으로는 부족한가

가장 흔한 오해는 “TCP 자체가 연결 상태를 알려 줄 것”이라는 기대입니다. 실제로는 TCP는 상대방이 명시적으로 FIN 패킷을 보내야 연결이 끊긴 것을 알 수 있습니다. 다음 상황에서는 서버가 연결이 죽었는지 알 길이 없습니다.

  • 클라이언트가 갑자기 전원 차단되어 FIN을 못 보냄
  • 중간 NAT 장비가 일정 시간 후 세션을 임의로 끊음
  • 무선 환경에서 신호가 끊겨 패킷이 영영 도착하지 않음

이런 상황에서 서버는 연결이 살아 있다고 믿으며 자원을 계속 점유합니다. 이른바 좀비 커넥션(half-open connection) 이 누적되는 상황입니다. 결과는 1번 항목과 같은 메모리 누수입니다.

해결법 — IdleStateHandler + ping/pong

해결책은 주기적인 ping/pong입니다. 서버가 일정 시간 동안 클라이언트로부터 어떤 데이터도 받지 못하면 ping을 보내고, 일정 시간 안에 pong이 돌아오지 않으면 연결을 끊습니다.

pipeline.addLast(new IdleStateHandler(60, 0, 0));  // 60초간 read 없으면 이벤트
pipeline.addLast(new HeartbeatHandler());

public class HeartbeatHandler extends ChannelDuplexHandler {

    @Override
    public void userEventTriggered(ChannelHandlerContext ctx, Object evt) {
        if (evt instanceof IdleStateEvent e && e.state() == IdleState.READER_IDLE) {
            // 60초간 데이터 없음 → ping 전송
            if (pingsSent.incrementAndGet() > 3) {
                log.warn("[HEARTBEAT TIMEOUT] {}", ctx.channel().remoteAddress());
                ctx.close();  // 3회 ping에도 응답 없으면 종료
            } else {
                ctx.writeAndFlush(PING_MESSAGE);
            }
        }
    }

    @Override
    protected void channelRead0(ChannelHandlerContext ctx, Object msg) {
        if (isPong(msg)) {
            pingsSent.set(0);  // pong 수신 시 카운터 리셋
        } else {
            // 일반 비즈니스 메시지 처리
        }
    }
}

주기 설정의 기준

환경권장 주기
내부망 (안정적)30~60초
인터넷·모바일15~30초
NAT 세션 타임아웃 회피 목적NAT 타임아웃의 절반 이하

너무 짧으면 트래픽이 늘고, 너무 길면 좀비 연결을 빨리 정리하지 못합니다. 운영 환경의 NAT/방화벽 타임아웃을 먼저 확인하고 그보다 짧게 설정하는 것이 안전합니다.


필수 3: 재연결 로직은 선택이 아닌 필수

클라이언트 측 책임이지만 서버 설계에도 영향을 준다

이 항목은 보통 클라이언트가 구현하지만, 서버가 클라이언트 역할로 동작하는 경우(예: 다른 시스템과 양방향 연결을 유지해야 하는 경우)에도 동일하게 적용됩니다. Socket 연결은 다음 상황에서 언제든 끊깁니다.

  • 서버 재배포·재시작
  • 네트워크 중간 장비 장애
  • 무선 환경 신호 단절
  • ping/pong 타임아웃으로 인한 강제 종료

재연결 로직이 없으면 한 번의 단절이 그대로 서비스 중단이 됩니다. 그래서 재연결은 선택이 아니라 기본 사양입니다.

Exponential Backoff로 재시도하기

가장 흔한 실수는 “끊기면 즉시 다시 연결”하는 패턴입니다. 서버가 잠시 죽었다 살아나는 시점에 모든 클라이언트가 동시에 재연결을 시도하면 서버가 다시 죽습니다(이른바 Thundering Herd 문제).

해결은 Exponential Backoff + Jitter입니다.

public class SocketClient {

    private static final int MAX_RETRIES = 10;
    private static final long INITIAL_DELAY_MS = 1000;
    private static final long MAX_DELAY_MS = 60_000;

    public void connectWithRetry() {
        int attempt = 0;
        while (attempt < MAX_RETRIES) {
            try {
                connect();
                return;  // 성공 시 종료
            } catch (Exception e) {
                attempt++;
                long delay = Math.min(
                    INITIAL_DELAY_MS * (1L << attempt),  // 1, 2, 4, 8, 16, ... 초
                    MAX_DELAY_MS
                );
                long jitter = ThreadLocalRandom.current().nextLong(delay / 2);
                long sleepMs = delay + jitter;

                log.warn("[RECONNECT] attempt={}, delay={}ms", attempt, sleepMs);
                Thread.sleep(sleepMs);
            }
        }
        throw new IllegalStateException("재연결 최대 횟수 초과");
    }
}

핵심은 세 가지입니다. 재시도 간격을 지수적으로 늘리고, Jitter(랜덤 지연)를 추가해 동시 재연결을 분산하며, 최대 횟수와 최대 지연을 둬 무한 루프를 방지합니다.

재연결 후 세션 복구를 잊지 말기

재연결만으로는 부족합니다. 끊기기 직전에 처리 중이던 메시지가 있다면 중복 처리되거나 유실될 수 있습니다. 다음 두 가지를 함께 고려해야 합니다.

고려 사항대응
미전송 메시지 큐잉재연결 성공 시 큐의 메시지부터 전송
멱등성 보장메시지에 고유 ID(messageId) 부여, 서버가 중복 수신 시 무시
인증 재수행재연결 후 인증 단계부터 다시
구독 상태 복구채널·토픽 구독 정보 재등록

추가로 챙겨야 할 4가지 보강 항목

3가지 필수 외에 함께 챙기면 운영 안정성이 한층 올라가는 항목입니다.

항목설명
메시지 프레이밍TCP는 스트림이라 메시지 경계가 없음. Length-prefixed 또는 Delimiter 기반 프로토콜 필수 (LengthFieldBasedFrameDecoder)
백프레셔 처리수신 측이 느리면 송신 버퍼가 가득 차서 OOM. Channel.isWritable() 체크 후 전송
그레이스풀 셧다운재배포 시 진행 중인 메시지 처리를 완료한 뒤 종료. EventLoopGroup.shutdownGracefully() 활용
메트릭 노출동시 연결 수·전송량·에러율·평균 응답 시간을 Prometheus 등으로 노출, 알람 연동

실무 운영 체크리스트

신규 Socket 서버 오픈 또는 레거시 점검 시 다음을 확인하시기 바랍니다.

첫째, 연결 후 N초 내 인증을 강제하고, 미인증 연결은 강제 종료하는지 확인합니다. 폐쇄망도 예외 없이 적용합니다.

둘째, 동시 연결 수 상한이 설정되어 있고, 초과 시 새 연결을 거절하는지 확인합니다.

셋째, ping/pong 헬스체크 주기와 임계치가 NAT·방화벽 세션 타임아웃보다 짧게 설정되어 있는지 확인합니다.

넷째, 클라이언트 측 재연결 로직이 Exponential Backoff + Jitter로 구현되어 있는지, 최대 재시도 횟수가 정해져 있는지 확인합니다.

다섯째, 메시지 프레이밍(Length-prefixed 또는 Delimiter)이 일관되게 적용되어 있는지 확인합니다.

여섯째, 동시 연결 수·메모리·파일 디스크립터 사용량이 모니터링 대시보드에 노출되어 있는지 확인합니다.

일곱째, 그레이스풀 셧다운이 구현되어 있어 재배포 시 진행 중인 처리가 손실되지 않는지 확인합니다.

여덟째, JVM Heap Dump를 떠 봤을 때, 연결 객체가 비정상적으로 누적되어 있지 않은지 주기적으로 확인합니다.


자주 묻는 질문 (FAQ)

Q1. WebSocket과 일반 TCP Socket은 어떻게 다른가요?

WebSocket은 HTTP 위에서 핸드셰이크 후 양방향 통신을 하는 프로토콜이고, 일반 TCP Socket은 HTTP 없이 바로 TCP 위에서 통신합니다. WebSocket은 ping/pong이 프로토콜 자체에 정의되어 있어 헬스체크가 더 표준화되어 있고, 브라우저에서 바로 접속할 수 있다는 장점이 있습니다. 내부 시스템 간 고성능 통신은 일반 TCP Socket이, 웹 클라이언트 연동은 WebSocket이 적합합니다.

Q2. Spring 환경에서 Socket 서버는 무엇으로 구축하나요?

신규 구축이라면 Netty가 사실상 표준입니다. Spring Integration의 TCP 모듈도 있지만 추상화가 무거워 고성능에는 불리합니다. Spring Boot + Netty 조합으로 비동기 이벤트 루프 위에 비즈니스 핸들러를 얹는 구조가 가장 안정적입니다. 간단한 PoC라면 java.net.ServerSocket로도 가능하지만, 동시 연결이 늘면 한계가 빠르게 옵니다.

Q3. ping/pong 주기는 얼마가 적정한가요?

운영 환경의 NAT·방화벽 세션 타임아웃의 절반 이하가 안전한 기준입니다. 대부분의 기업망은 5분(300초) 또는 10분 타임아웃을 사용하므로, 60~120초 주기가 무난합니다. 모바일·인터넷 환경은 더 짧게(15~30초) 가져가는 것이 좋습니다.

Q4. 재연결 시 메시지 중복이 걱정됩니다. 어떻게 방지하나요?

메시지마다 고유 ID(messageId)를 부여하고, 서버가 최근 N분 동안 수신한 ID를 기억해 중복 메시지를 거르는 것이 일반적입니다. Redis의 SETNX로 ID 중복 체크를 구현하면 분산 환경에서도 안전합니다. 클라이언트는 ACK를 받지 못한 메시지만 재전송하는 정책을 함께 적용합니다.

Q5. 동시 1만 연결 이상을 처리하려면 어떻게 해야 하나요?

이벤트 루프 기반 비동기 I/O(Netty의 NIO/Epoll)가 필수입니다. 스레드 1개당 연결 1개를 잡는 전통 BIO 모델로는 1만 연결 처리가 불가능합니다. 또한 OS의 ulimit -n(파일 디스크립터 한계)TCP 커널 파라미터(net.core.somaxconn, net.ipv4.tcp_max_syn_backlog 등)를 미리 조정해야 합니다.


마무리

TCP Socket 서버 설계의 핵심은 화려한 기능이 아니라 연결의 생명주기를 누가 어떻게 관리하느냐입니다. HTTP는 요청-응답으로 깔끔하게 끝나지만, Socket은 한 번 연결되면 끝까지 자원을 점유하므로 누락된 한 가지 설계가 곧 메모리 누수와 OOM으로 이어집니다.

핵심을 다시 정리하면 다음과 같습니다. 연결 시 인증을 강제하고 미인증 연결은 N초 안에 끊고, ping/pong 헬스체크로 죽은 연결을 정리하며, 클라이언트 측 재연결은 Exponential Backoff + Jitter로 구현합니다. 여기에 메시지 프레이밍·백프레셔·그레이스풀 셧다운·메트릭 노출까지 함께 챙기면, 폐쇄망이든 인터넷이든 안정적으로 운영되는 Socket 서버가 완성됩니다.


핵심 요약

  • Socket은 HTTP와 다르다: 연결이 유지되는 내내 서버 자원을 점유하므로 생명주기 관리가 핵심입니다.
  • 필수 1 — 인증 + 타임아웃: 연결 후 N초 내 인증 강제, 미인증 연결은 즉시 종료해 좀비 커넥션 누적을 막습니다.
  • 필수 2 — ping/pong 헬스체크: TCP만으로는 죽은 연결을 알 수 없으므로 주기적 ping/pong으로 좀비 연결을 정리합니다.
  • 필수 3 — 재연결 로직: Exponential Backoff + Jitter로 Thundering Herd를 막고, 멱등성 ID로 중복 메시지를 방지합니다.
  • 추가 보강: 메시지 프레이밍, 백프레셔, 그레이스풀 셧다운, 동시 연결 수·메모리 메트릭 노출을 함께 적용합니다.

댓글 남기기