Spring/Framework 탐방 zone

[Netty] Netty가 뭐에요? - 11: Netty 프레임워크 확장하기 - 도메인 코드와 결합 및 바이브 코딩 도입(feat. Claude Code)

공대키메라 2026. 3. 15. 22:12

지난 시간에는 Netty를 활용해서 성능 테스트를 진행했다.

 

각각 처리량과 속도, 메모리 효율성 비교를 했다.(성능비교 1탄, 성능비교 2탄)

 

 

직접 restful한 요청도 처리해야하고 상세하게 구현한 channel파일들도 범용적으로 상용할 수 있게 해야하는데...

 

이걸 어찌 해야하나? 하는 생각이 문득 들었다.

 

 

고로! 이번 시간에는 어떻게 하면 Netty를 활용하면서, 테스크 코드도 작성하고 확장할 수 있는 구조로 만들 수 있는지 

 

고민하려고 한다. 

 

 

또한, 이 과정에서 키메라는 적극적으로 claude code를 이용할 예정이다.

 

계산은 계산기가 잘하는 것처럼, 코드는 이제 AI가 더 잘 작성한다.

 

과장이 아닌 100배의 효율성이 생겨버렸기에, 이를 안한다는건 힘들다는 생각이다. 빠르게 큰 맥락을 잡으면서 학습하도록 하겠다.

 


 

목표

 

1. 학습용 도메인을 정의한다.

2. Netty에서 어떻게 하면 확장 가능하고 최대한 도메인 중심적인 개발을 할 수 있는지 고민한다.

3. Netty프로젝트를 구축한다.


1. 학습용 도메인 정의

내가 Netty를 쓰건... Spring 을 쓰건... 어디 뭐 그냥 날 Java로 개발을 하건...

 

프레임워크에 따라 핵심 코드가 변경이 되는게 맞는건가? 

 

당연이 그렇지 않다!

 

필자는 그래서 도메인 중심으로 적용할 코드를 미리 작성해서, 이를 Netty에서 붙여서 돌릴것이다.

 

이를 위해서는 어찌되든 코드가 있어야 한다. 그래서, Netty관련 글을 작성하다가 중도에 멈추고 코드를 작성하고 왔다.

 

궁금하면 다음 시리즈를 읽고 오면 이해가 빠르다.

 

[Java] 객체지향 연습 5 - 상황 부여와 코드 작성 전 간단한 지식 학습 (feat. DDD를 곁들인)

[Java] 객체지향 연습 6 - 프로젝트 구성 및 기본 코드 학습 그리고 데코레이터, 책임연쇄 패턴 복기 (feat. 템플릿 메서드 패턴)

[Java] 객체지향 연습 7 - 요금 기능 추가하기 (feat. 개인적인 생각과 향후 방향)


코드는 여기를 보면 된다.(깃 주소 여기 클릭!)

 

이를 작성하는데 처음부터 제대로 분리해가며 학습하니 결코 쉽지 않았고 현 시점에서는 괜찮다라고 생각되지만

 

이것도 추후에 적용을 하면서 문제점이 생길테니 그러한 경우에는 객체지향 연습 시리즈에 다시 글을 작성할 예정이다.

 

하여간 해당 코드를 바탕으로 계쏙 진행을 할 예정이다.

 

 

정리를 하자면 해당 학습용 프로젝트의 주제는 다음과 같다.

 

Github에 올라가 있는 PRD를 가져와 보겠다.

# [PRD] 선착순 할인 쿠폰 발급 시스템

## 1. 프로젝트 개요 (Overview)
* **목적:** 특정 이벤트 기간 동안 한정된 수량의 할인 쿠폰을 사용자에게 발급한다.
* **배경:** 트래픽이 폭증하는 '오픈 런' 상황에서 시스템 다운 없이 안정적으로 트래픽을 처리하고 정확한 수량의 쿠폰을 발급해야 한다.
* **핵심 과제:** 다중 서버 환경에서의 동시성 제어(Race Condition 방어) 및 대기열(Queue) 또는 비동기 처리를 통한 DB 부하 분산.

---

## 2. 보편적인 언어 (Ubiquitous Language)
> 도메인 전문가와 개발자가 동일하게 사용할 핵심 용어

* **Coupon (쿠폰):** 할인 정책과 유효 기간을 가진 마스터 데이터이며, **Aggregate Root** 역할을 수행한다.
* **Inventory (재고 - Value Object):** 전체 수량과 사용 수량을 관리하며, 스스로의 상태를 검증하는 불변 객체.
* **Issue (발급):** 사용자가 쿠폰을 획득하는 행위.
* **IssuedCoupon (발급 이력 - Entity):** 특정 사용자에게 쿠폰이 할당된 이력. 사용 여부(Status)를 관리한다.
* **Member (회원):** 쿠폰 발급을 요청하는 주체.

---

## 3. 핵심 도메인 모델 설계

### 3.1 Coupon (Aggregate Root)
* **책임:** 비즈니스 규칙의 진입점. 유효 기간 및 재고 차감 명령을 내린다.
* **주요 상태:** `id`, `description`, `inventory(VO)`, `expireDate`.

### 3.2 Inventory (Value Object)
* **책임:** 수량 계산의 원자성 및 불변성 보장.
* **특징:** 모든 변경 연산(`use`, `add`, `remove`)은 기존 필드를 수정하지 않고 새로운 `Inventory` 객체를 생성하여 반환한다.
* **주요 필드:**
    * `totalCount`: 기획된 전체 발행 가능 수량
    * `usedCount`: 현재까지 발급 완료된 수량
* **주요 로직:** * `getRemainCount()`: (total - used) 잔여 수량 계산
    * `hasAvailableStock()`: 잔여 수량 존재 여부 확인
    * `isStarted()`: 발급이 한 건이라도 발생했는지 확인 (수정 방어용)

### 3.3 IssuedCoupon (Entity)
* **책임:** 쿠폰의 소유권 증명 및 사용 상태 관리.
* **주요 필드:**
    * `id`: 고유 식별자
    * `couponId`: 연관된 쿠폰 ID
    * `memberId`: 소유한 회원 ID
    * `status`: 사용 상태 (UNUSED, USED, EXPIRED)
    * `issuedAt`: 발급 시각

---

## 4. 기능 요구사항 (Functional Requirements)
* **FR-1. 쿠폰 조회:** 사용자는 발급 가능한 쿠폰 정보와 현재 남은 수량을 확인할 수 있다.
* **FR-2. 쿠폰 발급 요청:** 사용자는 특정 쿠폰에 대해 발급을 요청할 수 있다.
* **FR-3. 재고 감소:** 쿠폰이 성공적으로 발급되면 잔여 수량은 1 감소한다.
* **FR-4. 중복 발급 방지:** 1명의 사용자는 동일한 쿠폰을 1매만 발급받을 수 있다.
* **FR-5. 발급 종료:** 재고가 0이 되거나 이벤트 기간이 종료되면 발급이 불가능하다.

---

## 5. 비기능 요구사항 (Non-Functional Requirements)

### NFR-1. 정확성 (데이터 정합성 보장)
* **초과 발급 방지:** 1,000개 발행 시, 10,000명이 동시 요청해도 정확히 1,000개만 발급되어야 한다.
* **멱등성 보장:** 동일 사용자의 '따닥' 요청(중복 클릭) 시 1번만 발급되어야 한다.

### NFR-2. 성능 (Throughput & Latency)
* **목표 처리량:** V1(RDB Lock) 1,000 TPS -> V2(Redis) 5,000 TPS 이상.
* **응답 속도:** 평균 200ms 이내 응답.

### NFR-3. 가용성 (High Availability)
* **장애 격리:** 대량 트래픽으로 인한 DB 커넥션 풀 고갈 및 전체 시스템 장애 방지.

---

## 6. 단계별 개발 및 학습 마일스톤

* **Phase 1: 도메인 모델링 및 TDD (현재 진행 중)**
    * 프레임워크 의존성 없는 순수 Java 객체(Entity, VO) 설계.
    * `Inventory`의 불변 연산 로직 Unit Test 검증.
* **Phase 2: RDBMS 비관적/낙관적 락 적용**
    * DB 락을 이용한 동시성 제어 및 JMeter 성능 측정.
* **Phase 3: Redis 기반 분산 락 및 대기열 적용**
    * Redis 도입을 통한 DB 부하 분산 및 대용량 트래픽 최적화.
 
## 7. 인프라 및 예외/장애 대응 설계 (Architecture & Resilience)

### 7.1 비동기 이벤트 기반 아키텍처
* **Decoupling:** RDBMS의 I/O 병목을 방지하기 위해 '재고 차감'과 '이력 저장'을 분리한다.
* **흐름:** Redis를 통해 원자적으로 재고 차감 및 중복 발급 검증을 수행한 후, 성공한 요청에 한해 `CouponIssuedEvent`를 Message Queue(예: AWS SQS)로 발행하여 워커(Worker) 노드가 비동기로 RDB에 Insert 하도록 설계한다.

### 7.2 어뷰징 방지 및 인프라 제어
* **Rate Limiting:** 오픈 런 상황에서의 매크로 및 악의적 공격을 방어하기 위해 API Gateway 계층에서 초당 요청 수(Rate Limit)를 제어한다.

### 7.3 정합성 보정 (Reconciliation)
* **보상 트랜잭션:** Redis 재고는 차감되었으나 RDB 저장이 실패한(Event Lost) 경우를 대비하여, 실패한 메시지는 DLQ(Dead Letter Queue)로 전송하여 재처리 로직을 구성한다.
* 주기적인 배치 워커를 통해 Redis의 발급 카운트와 RDB의 실제 `IssuedCoupon` 로우 수를 대조하여 정합성을 모니터링한다.

### 7.4 관측 가능성 (Observability)
* APM 및 모니터링 툴(CloudWatch, Grafana 등)을 연동하여 JMeter 부하 테스트 시 Active Thread, TPS, Latency, DB 커넥션 풀 상태를 시각화하고 병목 구간을 역추적한다.

 

필자가 신경써야 하는 부분은 프레임워크를 적용하는 부분이니 5번부터 신경을 쓰면 될 것이다. 

 

2. Netty를 어떻게 확장해야할까?

시작하기 앞서 다시 Netty의 구조를 복기하자. (궁금하면 여기 클릭)

 

 

Netty는 그렇다 쳐도.. Spring boot를 사용하게 되면 내장톰캣이 있어서 서블릿 컨테이너인 톰캣이 요청과 반환을 담당해준다.

(Netty와 Spring 비교 참고)

 

그런데 우리는 이것이 없는 관계로 직접 작성을 해줘야 한다.

 

우선 강력한 기능인 의존성 주입과 서블릿 컨테이너인 톰캣을 통한 손쉬운 작업이 당장 떠오른다.

 

요청과 응답을 XML, JSON으로 주고 받을 것인가에 대한 문제도 있고 Restful하게 코드를 작성할 경우, 작성한 요청이 우리가 실행시키고자 하는 work group의 channel로 잘 흘러들어가 결과를 반환하는지도 문제이다.

 

결국에 직접 기능을 하나하나 쌓아가면서 이를 작업해야하는데, 기능별로 package를 관리하면 문제가 없을 듯 보인다.

 

객체지향 연습 코드를 가져와서 그곳에서 기능을 확장할 것이며 다만 서버를 직접 구축하다보니 응답에 대한 여러 정보들을 담고 볼 수 있도록 해야 할 것이다. 

3.  Netty프로젝트 구축

진행하기에 앞서 크~게 다시 짚고 넘어가자.

 

 

Tomcat 같은 Servlet container의 경우에는 요청 하나에 tread하나가 생성되는데,

Netty에서는 적은 thread로 NIO로 최대 작업을 할 수 있게 만들어서 빠르고 효율적이다.

또 Netty 에서는 이 제한된 thread로도 Java 의 NIO의 Selector와 Netty의 Eventloop구조를 통해서 빠르게 작업이 가능하다.

 

 

그런데 필자는 들어오는 요청을 원하는 형식으로 가공해주고, 반환시에는 올바르게 사용자에게 전달을 해야한다. 

 

프로젝트를 생성하겠다. 여기 작성하는 코드들은 다음 git 주소에서 확인이 가능하다

(https://github.com/thelovemsg/netty_basecamp)

 

자바, Gradle, JDK version은 21로 맞췄다. (그림상에서는 틀릴 수 있으니 양해...)

 

 

 

NettyBootcampServer.java

package org.example.netty_basecamp.netty;

import io.netty.bootstrap.ServerBootstrap;
import io.netty.channel.*;
import io.netty.channel.nio.NioIoHandler;
import io.netty.channel.socket.nio.NioServerSocketChannel;
import io.netty.handler.ssl.SslContext;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.example.netty_basecamp.netty.channel.CustomChannelInitializer;
import org.example.netty_basecamp.netty.rest.route.RouteRegistry;
import org.example.netty_basecamp.netty.util.ServerUtil;

public class NettyBootcampServer {
    final Logger logger = LogManager.getLogger();

    private final int port;
    private final SslContext sslCtx = ServerUtil.buildZeroTrustSslContext();
    private final RouteRegistry routeRegistry;

    public NettyBootcampServer(int port, RouteRegistry routeRegistry) throws Exception {
        this.port = port;
        this.routeRegistry = routeRegistry;
    }

    public void start() throws InterruptedException {
        logger.info("Netty Server starting on port: {}", port);
        // 1. EventLoopGroup 생성 (Boss와 Worker)
        IoHandlerFactory ioHandlerFactory = NioIoHandler.newFactory();

        EventLoopGroup bossGroup = new MultiThreadIoEventLoopGroup(1, ioHandlerFactory);
        EventLoopGroup workerGroup = new MultiThreadIoEventLoopGroup(4, ioHandlerFactory); // 기본값: CPU 코어 수 * 2
        try {
            // 2. ServerBootstrap 설정
            ServerBootstrap b = new ServerBootstrap();
            b.group(bossGroup, workerGroup)
                    .channel(NioServerSocketChannel.class) // NIO 기반의 채널 사용
                    .option(ChannelOption.SO_BACKLOG, 128) // TCP 연결 대기열 설정
                    .childOption(ChannelOption.SO_KEEPALIVE, true) // Worker 채널의 KeepAlive 설정
                    .childOption(ChannelOption.TCP_NODELAY, true)
                    .childHandler(new CustomChannelInitializer(sslCtx, routeRegistry))
            ;

            // 4. 서버 바인딩 및 동기화
            logger.info("Netty Server started on port: {}", port);
            ChannelFuture f = b.bind(port).sync();
            f.channel().closeFuture().sync();
        } finally {
            // 6. 우아한 종료 (Graceful Shutdown)
            workerGroup.shutdownGracefully();
            bossGroup.shutdownGracefully();
        }
    }
}

 

이렇게 설정하고 main 메소드에서 서버를 실행해보았다.

 

NettyBasecampApplication.java

public class NettyBaseCampApplication {
    public static void main(String[] args) throws InterruptedException {
        new NettyBootcampServer(8080).start();
    }
}

 

그러자 다음과 같은 에러가 낫다.

Exception in thread "main" java.lang.IllegalStateException: childHandler not set at io.netty.bootstrap.ServerBootstrap.validate(ServerBootstrap.java:182) at io.netty.bootstrap.ServerBootstrap.validate(ServerBootstrap.java:46) at io.netty.bootstrap.AbstractBootstrap.bind(AbstractBootstrap.java:287) at io.netty.bootstrap.AbstractBootstrap.bind(AbstractBootstrap.java:266) at org.example.netty_basecamp.netty.NettyBootcampServer.start(NettyBootcampServer.java:34) at org.example.netty_basecamp.NettyBaseCampApplication.main(NettyBaseCampApplication.java:7)

 

childHandler not set 이라는다...

 

그냥 ServerBootstrap의 메소드를 찾아본다.

 

 

여기 childHandler에 뭔가 누락이되서 그런듯하다.

 

사실 이전에 우리는 Netty구조가 어떻게 되는지 봐서 구조를 알고있다.

NIO Selector 구조

 

Channel 은 각자의 ChannelPipieline 가지고 있음

 

ChannelPipeline 내부에는 Handler 정의가 필요

 

다시 이걸 생각해보면, 당연히 에러가 나는것이다. 내부로 들어오는 요청을 어떻게 처리할지, 외부로 나가는 응답을 어떻게 처리할지 선언을 해주지 않은 것이다.

 

ServerBootstrap코드 내부를 볼까? 어떻게 하길래 이걸 등록 안하면 에러가 나는거지? 

 

하고 보니 그냥... ChannelPipeline을 우리가 직접 작업해서 처리해줄 것을 ServerBoostrap에서 쉽게 해주는 것이다.

 

일종의 Factory로써 작동을 하고 있다고 봤다.

 

ServerBootstrap.java 내부 코드 - init 분석하기 

/*
 * Copyright 2012 The Netty Project
 *
 * The Netty Project licenses this file to you under the Apache License,
 * version 2.0 (the "License"); you may not use this file except in compliance
 * with the License. You may obtain a copy of the License at:
 *
 *   https://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
 * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
 * License for the specific language governing permissions and limitations
 * under the License.
 */
public class ServerBootstrap extends AbstractBootstrap<ServerBootstrap, ServerChannel> {

...

@Override
void init(Channel channel) throws Throwable {
    setChannelOptions(channel, newOptionsArray(), logger);
    setAttributes(channel, newAttributesArray());

    ChannelPipeline p = channel.pipeline();

    final EventLoopGroup currentChildGroup = childGroup;
    final ChannelHandler currentChildHandler = childHandler;
    final Entry<ChannelOption<?>, Object>[] currentChildOptions = newOptionsArray(childOptions);
    final Entry<AttributeKey<?>, Object>[] currentChildAttrs = newAttributesArray(childAttrs);
    final Collection<ChannelInitializerExtension> extensions = getInitializerExtensions();

    p.addLast(new ChannelInitializer<Channel>() {
        @Override
        public void initChannel(final Channel ch) {
            final ChannelPipeline pipeline = ch.pipeline();
            ChannelHandler handler = config.handler();
            if (handler != null) {
                pipeline.addLast(handler);
            }

            ch.eventLoop().execute(new Runnable() {
                @Override
                public void run() {
                    pipeline.addLast(new ServerBootstrapAcceptor(
                            ch, currentChildGroup, currentChildHandler, currentChildOptions, currentChildAttrs,
                            extensions));
                }
            });
        }
    });
    if (!extensions.isEmpty() && channel instanceof ServerChannel) {
        ServerChannel serverChannel = (ServerChannel) channel;
        for (ChannelInitializerExtension extension : extensions) {
            try {
                extension.postInitializeServerListenerChannel(serverChannel);
            } catch (Exception e) {
                logger.warn("Exception thrown from postInitializeServerListenerChannel", e);
            }
        }
    }
    ...
}

 

해당 코드에서 init 메소드 위에 @Override가 있다.

AbstractBootstrap을 보면 이것을 등록해줄것이다.

 

이것을 타고 가보니 bind 시에 initRegister를 실행하는데, 해당 메소드에서 ServerBootstrap에서 정의한 init 메소드를 사용하고 있다.

 

bind 메소드 -> initRegistory메소드 -> ServerBootstrap에서 정의한 init

 

일정 부분만 구현하면 작동하게 하는 Template Method 와 비스무리하다. 굳이 interface가 아닌 추상메소드로 필요한것을 이미 다 제공하는것이다.

 

3. 프로젝트 개발 가속 - Claude와의 끊임없는 소통

이제는 필자도 피곤하다. 일일히 타이핑하기 싫어서 클로드 코드를 사용해서 빠르게 개발해보았다.

 

작업하면서 boiler plate 혹은 반복되는 코드와 문제 그리고 논의사항은 md 파일을 정의해서 claude code가 불필요한 토큰을 낭비하지 않도록 작업했다. 

 

 

프로젝트를 다음과 같이 나누었다.

 

 

프레임 워크 설정용은 netty 패키지에 놓았고, 객체지향 학습용으로 작성한 코드는 domain에 두고 작성했다.

 

package org.example.netty_basecamp.netty;

import io.netty.bootstrap.ServerBootstrap;
import io.netty.channel.*;
import io.netty.channel.nio.NioIoHandler;
import io.netty.channel.socket.nio.NioServerSocketChannel;
import io.netty.handler.ssl.SslContext;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.example.netty_basecamp.netty.channel.CustomChannelInitializer;
import org.example.netty_basecamp.netty.rest.route.RouteRegistry;
import org.example.netty_basecamp.netty.util.ServerUtil;

public class NettyBootcampServer {
    final Logger logger = LogManager.getLogger();

    private final int port;
    private final SslContext sslCtx = ServerUtil.buildZeroTrustSslContext();
    private final RouteRegistry routeRegistry;

    public NettyBootcampServer(int port, RouteRegistry routeRegistry) throws Exception {
        this.port = port;
        this.routeRegistry = routeRegistry;
    }

    public void start() throws InterruptedException {
        logger.info("Netty Server starting on port: {}", port);
        // 1. EventLoopGroup 생성 (Boss와 Worker)
        IoHandlerFactory ioHandlerFactory = NioIoHandler.newFactory();

        EventLoopGroup bossGroup = new MultiThreadIoEventLoopGroup(1, ioHandlerFactory);
        EventLoopGroup workerGroup = new MultiThreadIoEventLoopGroup(4, ioHandlerFactory); // 기본값: CPU 코어 수 * 2
        try {
            // 2. ServerBootstrap 설정
            ServerBootstrap b = new ServerBootstrap();
            b.group(bossGroup, workerGroup)
                    .channel(NioServerSocketChannel.class) // NIO 기반의 채널 사용
                    .option(ChannelOption.SO_BACKLOG, 128) // TCP 연결 대기열 설정
                    .childOption(ChannelOption.SO_KEEPALIVE, true) // Worker 채널의 KeepAlive 설정
                    .childOption(ChannelOption.TCP_NODELAY, true)
                    .childHandler(new CustomChannelInitializer(sslCtx, routeRegistry))
            ;

            // 4. 서버 바인딩 및 동기화
            logger.info("Netty Server started on port: {}", port);
            ChannelFuture f = b.bind(port).sync();
            f.channel().closeFuture().sync();
        } finally {
            // 6. 우아한 종료 (Graceful Shutdown)
            workerGroup.shutdownGracefully();
            bossGroup.shutdownGracefully();
        }
    }
}

 

NettyBootcampServer 에는 기본적으로 서버를 실행하고 이를 처리하고 그 외의 pipeline에 들어갈 설정을 channel 패키지에 정리했다.

 

spring과 달리 Netty에서는 restful한 요청을 직접 개발자가 작성해줘야 하기에 이를 CustomChannelInitializer에서 HttpRoutingHandler를 선언해 등록했다.

 

CustomChannelInitializer.java

package org.example.netty_basecamp.netty.channel;

import io.netty.channel.Channel;
import io.netty.channel.ChannelInitializer;
import io.netty.channel.ChannelPipeline;
import io.netty.handler.codec.http.HttpObjectAggregator;
import io.netty.handler.codec.http.HttpServerCodec;
import io.netty.handler.ssl.SslContext;
import org.example.netty_basecamp.netty.rest.route.HttpRoutingHandler;
import org.example.netty_basecamp.netty.rest.route.RouteRegistry;

public class CustomChannelInitializer extends ChannelInitializer<Channel>{

    private final SslContext sslCtx;
    private final RouteRegistry routeRegistry;

    public CustomChannelInitializer(SslContext sslCtx, RouteRegistry routeRegistry) {
        this.sslCtx = sslCtx;
        this.routeRegistry = routeRegistry;
    }


    @Override
    protected void initChannel(Channel ch) throws Exception {
        ChannelPipeline p = ch.pipeline();

        // 1. HTTP 요청/응답 인코딩·디코딩
        p.addLast(new HttpServerCodec());
        // 2. HTTP 메시지 조각을 하나로 합침
        p.addLast(new HttpObjectAggregator(65536));

//        p.addLast(new AuthChannelInboundHandler());
        // 3. 우리가 만들 라우팅 핸들러
        p.addLast(new HttpRoutingHandler(routeRegistry));
        // 4. ???
    }
}

 

그리거 Spring 에서 @annotation기반으로 restful 하게 개발을 편하게 해주는건 없기에 직접 대강 만들었다.

 

각각의 도메인에 대해 직접 RouteConfig를 하는 방식으로 진행했고, controller를 직접 netty쪽에 패키지를 넣어서 Controller를 작성했다.

 

MemberRouteConfig.java

package org.example.netty_basecamp.netty.rest.config;

import io.netty.handler.codec.http.HttpMethod;
import org.example.netty_basecamp.domains.common.service.impl.CurrentTimeGenerator;
import org.example.netty_basecamp.domains.member.application.MemberApplicationService;
import org.example.netty_basecamp.domains.member.infrastructure.InMemoryMemberRepository;
import org.example.netty_basecamp.netty.rest.controller.MemberController;
import org.example.netty_basecamp.netty.rest.route.RouteEntry;

import java.util.List;

public class MemberRouteConfig {

    public static List<RouteEntry> routes() {
        // DI 조립
        CurrentTimeGenerator timeGenerator = new CurrentTimeGenerator();
        InMemoryMemberRepository memberRepository = new InMemoryMemberRepository();
        MemberApplicationService memberService = new MemberApplicationService(memberRepository, timeGenerator);
        MemberController controller = new MemberController(memberService);

        // 라우트 등록
        return List.of(
            new RouteEntry(HttpMethod.POST, "/api/members", controller::create),
            new RouteEntry(HttpMethod.GET, "/api/members", controller::findAll),
            new RouteEntry(HttpMethod.GET, "/api/members/{id}", controller::findById),
            new RouteEntry(HttpMethod.PUT, "/api/members/{id}", controller::update),
            new RouteEntry(HttpMethod.DELETE, "/api/members/{id}", controller::delete)
        );
    }
}

 

물론 필자도 Annotation기반으로 개발하면 좋겟고, DispatcherServlet에서 해주듯이 Bean으로 등록해서 등록하면 좋겠지만,

 

Netty는 Annotation 혹은 리플렉션을 쓰면 성능 저하가 있을 수 있다고 한다. 그리고 

 

제미나이에게 질문을 해 보았다. 궁금하면 열어서 보길 바랍니다!

더보기
Q1. 어차피 DB I/O에서 병목이 생기고 스레드가 대기해야 하는데, 그럼에도 불구하고 Netty를 사용하는 것이 시스템적으로 이점이 있나요?

A. 네, 압도적인 이점이 있습니다. 애플리케이션의 성능 한계는 단순히 'DB 처리 속도'에만 있는 것이 아니라, '동시 연결(Connection)을 유지하는 비용'과 '스레드 자원의 효율성'에 있기 때문입니다.

전통적인 Tomcat(Spring Boot MVC)과 Netty의 구조적 차이를 보면 그 이유가 명확해집니다.
1. 연결 유지 리소스와 C10K 문제 극복
1.1) Tomcat (Thread-per-Request): 클라이언트가 연결될 때마다 1개의 스레드를 할당합니다. 1만 명이 접속하면 1만 개의 스레드가 필요하며, 막대한 메모리 소모와 함께 CPU의 잦은 컨텍스트 스위칭(Context Switching) 오버헤드가 발생하여 서버가 다운될 위험이 큽니다.

1.2) Netty (Event-Loop 모델): 소수의 EventLoop 스레드가 다중화(Multiplexing) 기술을 이용해 수만 개의 연결을 동시에 관리합니다. 동시 접속자가 늘어나도 스레드가 급증하지 않아 CPU 자원을 온전히 비즈니스 로직에 집중시킬 수 있습니다.

2. 서버의 가용성(Availability) 방어
2.1) DB 커넥션 풀이 고갈되어 대기가 발생하는 상황을 가정해 보겠습니다. Tomcat 환경에서는 요청을 받은 스레드 자체가 블로킹되어 스레드 풀이 꽉 차고, 결국 새로운 사용자의 접속조차 거부하게 됩니다.

2.2) 반면 Netty는 무거운 DB 작업을 별도의 Worker 스레드 풀(예: DefaultEventExecutorGroup)로 오프로딩(Offloading)하고, EventLoop는 즉시 다른 클라이언트의 새로운 요청을 받으러 돌아갑니다. 즉, DB 응답이 지연되더라도 서버 자체는 수만 명의 접속을 유지하며 "현재 처리 지연 중입니다"라는 안내를 내려줄 수 있는 높은 가용성을 확보합니다.

2.3) I/O 다양성 대응
현대 백엔드 시스템은 RDBMS만 바라보지 않습니다. Redis 접근, S3 업로드, 외부 API 호출, Kafka 이벤트 발행 등 전체 I/O의 상당 부분을 차지하는 네트워크 작업들을 Netty에서는 100% 논블로킹으로 처리할 수 있어 전체 시스템의 처리량(Throughput)이 비약적으로 상승합니다.

Q2. Netty 환경에서 Spring처럼 어노테이션(@GetMapping 등) 기반으로 라우터(Router)를 개발하는 것은 안 좋은가요?

A. 프레임워크(Spring WebFlux 등)의 지원 없이 순수 Netty 위에서 직접 어노테이션 기반 라우팅을 구현하는 것은 Netty의 설계 철학과 충돌하며, '추상화의 함정'에 빠질 위험이 큽니다.

주요 이유는 다음과 같습니다.

1. 추상화의 함정과 EventLoop 블로킹 위험 (가장 치명적)
1.1) 어노테이션의 목적은 복잡한 내부 컨텍스트를 개발자로부터 숨기는 데 있습니다. 파라미터와 리턴 타입에만 집중하게 되면, 개발자는 무의식적으로 기존 동기식(Tomcat) 환경처럼 코드를 작성하게 됩니다.

1.2) Netty에서는 개발자가 ChannelHandlerContext의 존재를 명확히 인지하고 데이터가 파이프라인을 타고 흐른다는 것을 시각적으로 확인해야 합니다. 컨텍스트가 숨겨지면 무거운 동기 호출이나 블로킹 로직이 EventLoop에서 직접 실행되는 대참사(Thread Starvation)를 유발하기 쉽습니다.

2. 리플렉션(Reflection) 오버헤드
2.1) 라우팅을 위해 클래스패스를 스캔하고 동적으로 메서드를 호출(Method.invoke())하는 과정은 극한의 성능과 Low-level 제어를 지향하는 Netty의 방향성과 맞지 않습니다. (물론 메모리 캐싱으로 완화할 수 있으나, 불필요한 오버헤드입니다.)

2.2) 바퀴의 재발명 (Over-engineering)
파라미터 바인딩, 직렬화, 글로벌 예외 처리 등을 순수 Netty 위에서 직접 구현하는 것은 프레임워크를 밑바닥부터 다시 만드는 것과 같습니다. 이는 비즈니스 도메인(DDD) 설계에 쏟아야 할 리소스를 낭비하게 만듭니다.

대안: 함수형 라우팅 (Functional Routing)
순수 Netty 환경에서는 어노테이션 대신, 람다와 일급 객체를 활용한 명시적인 함수형 라우팅 구조를 채택하는 것이 훨씬 직관적이고 안전합니다.

// 명시적이고 안전한 함수형 라우팅 예시
Router router = new Router() .GET("/coupons", request -> couponController.getCoupons(request)) .POST(
"/coupons", request -> couponController.issueCoupon(request));

이러한 방식은 리플렉션 비용이 없고, 요청의 흐름이 투명하게 보이며, 앞서 언급한 DB 블로킹 작업의 스레드 오프로딩 제어를 훨씬 수월하게 만들어 줍니다.

 

Explicit is better than implicit(암시적인 것보다 명시적인 것이 낫다)

 

명시적인 제어 흐름을 지키고자 Netty에서는 ChannelPipeline을 도입했고, 이렇게 되면 명백하게 모든 코드의 흐름이 개발자가 통제가 가능하다.

 

해당 md파일은 오늘 기준으로 작업을 하면서 정리한 내용이다.

# Architectural Decision Records

프로젝트의 주요 의사결정 사항을 기록한 문서. Second Brain으로 활용하여 과거 결정의 맥락과 근거를 빠르게 참조할 수 있도록 함.

업데이트 히스토리
- 2026-03-15 생성

---

## 목차
1. [ADR-001: RequestContext 도입 — BiFunction 핸들러 제거](#adr-001)
2. [ADR-002: RouteMatch 도입 — RouteRegistry 매칭 결과 정리](#adr-002)
3. [ADR-003: AuthFilter 최소 구조 도입](#adr-003)
4. [ADR-004: EventLoop 블로킹 이슈 — Phase 2 진입 시 해결 필요](#adr-004)
5. [ADR-005: Controller 레이어 분리 — Inbound Adapter 도입](#adr-005)
6. [ADR-006: Repository 인터페이스를 domain 패키지로 이동 — 의존성 역전 적용](#adr-006)
7. [ADR-007: 리플렉션 없이 명시적 타입 변환 유지](#adr-007)
8. [ADR-008: 인증은 파이프라인 ChannelHandler, 인가는 Controller — AUTH_KEY 단일 전달](#adr-008)

---

## ADR-001: RequestContext 도입 — BiFunction 핸들러 제거 {#adr-001}
**날짜**: 2026-03-15

### 문제
- 핸들러 시그니처가 `BiFunction<Map<String, String>, String, Object>` — 파라미터가 뭘 의미하는지 시그니처만 봐서는 알 수 없음
- header, queryParam 등 새 요청 정보가 필요해지면 시그니처 자체를 변경해야 함
- 각 RouteConfig마다 `ObjectMapper`를 직접 들고 있어서 JSON 파싱이 분산됨

### 결정
- `RequestContext` 불변 객체를 만들어 요청의 모든 정보(method, path, pathVariables, queryParams, headers, body)를 하나로 묶음
- 핸들러 시그니처를 `Function<RequestContext, Object>`로 변경
- `readBody(Class<T>)`, `pathVariableAsLong(String)` 등 편의 메서드를 RequestContext에 집중

### Before
```java
new RouteEntry(HttpMethod.GET, "/api/members/{id}",
    (params, body) -> memberService.findById(Long.parseLong(params.get("id"))))
```

### After
```java
new RouteEntry(HttpMethod.GET, "/api/members/{id}",
    ctx -> memberService.findById(ctx.pathVariableAsLong("id")))
```

### 효과
- 가독성 향상 — 의도가 명확한 메서드 호출
- 확장성 — 새 요청 정보 추가 시 핸들러 시그니처 변경 불필요
- JSON 파싱 일원화 — `ctx.readBody()`로 통일

---

## ADR-002: RouteMatch 도입 — RouteRegistry 매칭 결과 정리 {#adr-002}
**날짜**: 2026-03-15

### 문제
- `RouteRegistry.find(method, path, pathParams)` — 호출 측에서 `new HashMap<>()`을 만들어서 넘겨야 했음
- 매칭 결과(RouteEntry)와 추출된 pathVariables가 분리되어 있어 조립 로직이 산재

### 결정
- `RouteMatch` 객체 도입 — `RouteEntry` + 추출된 `pathVariables`를 하나로 묶음
- `find()` 반환 타입을 `RouteEntry` → `RouteMatch`로 변경
- 외부에서 HashMap을 넘기는 오버로드 제거

### Before
```java
Map<String, String> pathParams = new HashMap<>();
RouteEntry entry = registry.find(method, path, pathParams);
// pathParams와 entry가 따로 논다
```

### After
```java
RouteMatch match = registry.find(method, path);
// match.getEntry() + match.getPathVariables() 하나로 묶임
```

---

## ADR-003: 자체 필터 체인 제거 — Netty 파이프라인으로 통일 {#adr-003}
**날짜**: 2026-03-15
**수정**: 2026-03-15

### 초기 결정
- `RouteFilter` 인터페이스 + `AuthFilter` 구현체로 자체 필터 체인을 만들었음

### 재검토
- Netty 파이프라인 자체가 필터 체인 역할을 함 (`ChannelHandler` 추가/제거)
- Netty를 배우는 프로젝트에서 Netty 파이프라인을 안 쓰고 자체 필터 체인을 만드는 건 방향이 맞지 않음

### 변경된 결정
- `RouteFilter`, `AuthFilter`, `UnauthorizedException` 제거
- 인증 같은 cross-cutting concern이 필요하면 **ChannelHandler를 파이프라인에 추가**하는 방식으로 처리
- `HttpRoutingHandler`는 라우팅과 핸들러 실행에만 집중

### Netty 파이프라인 방식 예시
```
HttpServerCodec → Aggregator → [AuthHandler] → HttpRoutingHandler
                                    ↑ ChannelHandler를 넣고 빼기만 하면 됨
```

### 판단 근거
- Netty의 파이프라인이 곧 필터 체인 — 같은 기능을 이중으로 만들 필요 없음
- 파이프라인 핸들러는 넣고 빼기가 자유롭고, Netty가 스레드 안전성을 보장함

---

## ADR-004: EventLoop 블로킹 이슈 — Phase 2 진입 시 해결 필요 {#adr-004}
**날짜**: 2026-03-15
**상태**: 대기 (Phase 2 진입 시 착수)

### 문제
현재 `HttpRoutingHandler.channelRead0()`에서 도메인 로직이 **Netty Worker EventLoop 스레드에서 직접 실행**된다.

```
Worker EventLoop 스레드 (4개)
  └── channelRead0()
        ├── RequestContext 생성
        ├── Filter 실행
        ├── match.getEntry().handle(ctx)
        │     └── ApplicationService → Repository.save()  ← 블로킹 지점
        └── sendJson()
```

- **현재(Phase 1)**: `InMemoryMemberRepository`(ConcurrentHashMap)라서 논블로킹, 문제 없음
- **Phase 2(RDBMS)부터**: JDBC 호출이 50~200ms 블로킹 → Worker 스레드 4개가 DB I/O에 점유됨 → 전체 서버 처리량 급감
- Netty의 **"EventLoop를 절대 블로킹하지 마라"** 원칙 위반

### 왜 위험한가
| 상황 | Worker 스레드 상태 | 결과 |
|------|-------------------|------|
| InMemory (현재) | ~0ms, 즉시 반환 | 정상 |
| JDBC 단건 조회 | ~10ms 블로킹 | 체감 없음 |
| JDBC 트랜잭션 (락 포함) | 50~200ms 블로킹 | Worker 4개 고갈 → 신규 요청 큐잉 |
| 선착순 쿠폰 오픈런 | 비관적 락 대기 | Worker 전부 멈춤 → 서버 무응답 |

### 해결 방향 (Phase 2 착수 시)

**방법 1: `DefaultEventExecutorGroup` 사용**
```java
// CustomChannelInitializer
EventExecutorGroup businessGroup = new DefaultEventExecutorGroup(16);
p.addLast(businessGroup, new HttpRoutingHandler(registry));
```
- Netty가 제공하는 가장 간단한 방법
- `HttpRoutingHandler`의 `channelRead0()`가 별도 스레드풀에서 실행됨
- 코드 변경 최소 (1줄 추가)

**방법 2: 핸들러 내부에서 직접 오프로드**
```java
// HttpRoutingHandler 내부
private final ExecutorService blockingPool = Executors.newFixedThreadPool(16);

protected void channelRead0(ChannelHandlerContext ctx, FullHttpRequest request) {
    // RequestContext 생성은 EventLoop에서 (가벼운 작업)
    RequestContext requestContext = buildContext(request);

    blockingPool.submit(() -> {
        Object result = match.getEntry().handle(requestContext);
        ctx.writeAndFlush(buildResponse(result));  // EventLoop로 돌아감
    });
}
```
- 더 세밀한 제어 가능 (어떤 라우트만 오프로드 등)
- 코드 변경 多

### 판단
- Phase 1에서는 **현 상태 유지** (InMemory라 문제 없음)
- Phase 2 RDBMS 도입 시 **방법 1(`DefaultEventExecutorGroup`)로 시작** → 부족하면 방법 2로 전환
- `CustomChannelInboundHandler`, `CustomChannelOutboundHandler`는 현재 빈 껍데기이며 파이프라인 위치상 호출도 안 됨 → Phase 2 시점에 역할 재정의 또는 삭제 결정

### 관련 파일
- `netty/channel/CustomChannelInitializer.java` — 파이프라인 구성
- `netty/rest/route/HttpRoutingHandler.java` — 블로킹 발생 지점
- `netty/channel/CustomChannelInboundHandler.java` — 빈 껍데기 (미사용)
- `netty/channel/CustomChannelOutboundHandler.java` — 빈 껍데기 (미사용)

---

## ADR-005: Controller 레이어 분리 — Inbound Adapter 도입 {#adr-005}
**날짜**: 2026-03-15

### 문제
- `MemberRouteConfig`에서 DI 조립 + 라우트 등록 + 요청 파싱/변환을 람다 안에서 모두 처리
- 헥사고날 아키텍처 관점에서 **Inbound Adapter(Controller)** 레이어가 부재
- 핸들러 로직이 커지면(검증, 응답 변환, 에러 매핑) 람다 안에서 감당 불가

### 결정
- `controller/` 패키지에 `MemberController` 도입 — **HTTP ↔ ApplicationService 변환** 책임만 담당
- `MemberRouteConfig`는 **DI 조립 + RouteEntry 등록**만 담당
- 메서드 레퍼런스(`controller::create`)로 라우트 등록

### Before
```java
// MemberRouteConfig — DI + 라우트 + 요청 파싱이 한 곳에
new RouteEntry(HttpMethod.PUT, "/api/members/{id}",
    ctx -> memberService.update(
            ctx.pathVariableAsLong("id"),
            ctx.readBody(MembersUpdate.class)))
```

### After
```java
// MemberController — Inbound Adapter
public Object update(RequestContext ctx) {
    return memberService.update(
            ctx.pathVariableAsLong("id"),
            ctx.readBody(MembersUpdate.class));
}

// MemberRouteConfig — 조립 + 등록만
new RouteEntry(HttpMethod.PUT, "/api/members/{id}", controller::update)
```

### 헥사고날 매핑
```
[Adapter (In)]        [Port (In)]              [Domain]
MemberController → MemberApplicationService → Members (AR)
      ↑                                           ↑
 RequestContext 변환                        도메인 불변식 보호
```

### 효과
- 책임 분리 — Controller(변환), RouteConfig(조립), ApplicationService(유스케이스)
- Controller 단위 테스트 용이 — RequestContext mock만 넣으면 됨
- 라우트 등록이 메서드 레퍼런스로 간결해짐

### 관련 파일
- `netty/rest/controller/MemberController.java` — 신규 (Inbound Adapter)
- `netty/rest/config/MemberRouteConfig.java` — DI 조립 + 라우트 등록만 남김

---

## ADR-006: Repository 인터페이스를 domain 패키지로 이동 — 의존성 역전 적용 {#adr-006}
**날짜**: 2026-03-15

### 문제
- Repository **인터페이스(Port)**가 `infrastructure/` 패키지에 위치
- 도메인이 인프라 방향을 바라보는 구조 — 헥사고날의 의존성 방향 원칙에 어긋남
- `InMemoryMemberRepository` 구현체가 `netty/repository/`에 있어서 도메인과 무관한 위치에 산재

### 결정
- Repository **인터페이스**를 `domain/` 패키지로 이동 — 도메인이 자신이 필요한 계약을 직접 정의
- Repository **구현체**를 각 도메인의 `infrastructure/` 패키지로 이동

### Before
```
domains/member/
├── domain/              ← AR, VO
├── application/         ← ApplicationService
└── infrastructure/
    └── MemberRepository.java    ← interface가 여기에

netty/repository/
    └── InMemoryMemberRepository.java   ← 구현체가 엉뚱한 곳에
```

### After
```
domains/member/
├── domain/
│   ├── Members.java              ← AR
│   └── MemberRepository.java    ← interface (Port) — 도메인이 계약 정의
├── application/
│   └── MemberApplicationService.java
└── infrastructure/
    └── InMemoryMemberRepository.java   ← 구현체 (Adapter) — 인프라가 계약 구현
```

### 적용 범위
| 도메인 | 인터페이스 이동 | 구현체 이동 |
|--------|----------------|------------|
| member | `infrastructure/` → `domain/` | `netty/repository/` → `infrastructure/` |
| coupon | `infrastructure/` → `domain/` | (구현체 아직 없음) |
| fare | `infrastructure/` → `domain/` (FareRepository, FarePolicyRepository) | (구현체 아직 없음) |

### 효과
- 의존성 방향이 **인프라 → 도메인** 한 방향으로 통일
- 도메인 패키지만 보면 어떤 계약이 필요한지 한눈에 파악 가능
- coupon, fare에 구현체 추가 시 각 도메인의 `infrastructure/`에 넣으면 됨

---

## ADR-007: 리플렉션 없이 명시적 타입 변환 유지 {#adr-007}
**날짜**: 2026-03-15

### 논의 배경
- Controller에서 `ctx.readBody(MembersCreate.class)` 호출이 반복되고, 반환 타입이 `Object`인 것이 아쉬움
- Spring처럼 `@RequestBody` 어노테이션으로 자동 변환할 수 있지 않을까?

### 검토한 대안
| 방법 | 장점 | 단점 |
|------|------|------|
| 현재 방식 (`ctx.readBody()`) | 단순, 명시적, 리플렉션 없음 | Controller에서 한 줄 반복 |
| RouteEntry에 타입 지정 | 파싱 자동화 가능 | 핸들러 시그니처 복잡해짐 |
| 어노테이션 + 리플렉션 | Spring처럼 깔끔 | Spring 재발명, 프로젝트 목적에 어긋남 |

### 결정
- **리플렉션 없이 현재 방식 유지**
- `ctx.readBody(Class)` 한 줄이 리플렉션 없는 최선의 형태
- 반환 타입 `Object`도 현재 수준에서는 허용

### 판단 근거
- 프로젝트 목적이 "Spring 없이 순수 Netty + DDD" 검증
- 리플렉션 도입 시 어노테이션 → 스캐너 → 파라미터 리졸버 → 타입 변환기로 이어져 Spring MVC를 재발명하게 됨
- 학습 프로젝트에서 중요한 건 구조의 본질을 이해하는 것이지, 편의 기능을 만드는 것이 아님

---

## ADR-008: 인증은 파이프라인 ChannelHandler, 인가는 Controller — AUTH_KEY 단일 전달 {#adr-008}
**날짜**: 2026-03-15

### 문제
- 인증(Authentication)과 인가(Authorization)의 책임이 분리되어 있지 않았음
- ADR-003에서 자체 필터 체인을 제거하고 Netty 파이프라인을 쓰기로 했으므로, 인증도 ChannelHandler로 처리해야 함
- 초기 구현에서 `userId`, `role`을 각각 별도 `AttributeKey`로 전달 → 실제 토큰 기반 인증과 거리가 멀고, 필드가 늘어날 때마다 attr이 증가하는 구조

### 결정
- **인증**: `AuthChannelInboundHandler`에서 처리 — Authorization 헤더의 토큰을 디코딩하여 `AuthInfo` VO를 생성, `Channel.attr(AUTH_KEY)` **단일 키**로 전달
- **인가**: `Controller`에서 처리 — `ctx.getAuthInfo().getRole()` 등으로 접근 제어
- **ApplicationService**: 인증/인가와 무관하게 순수 비즈니스 로직만 담당

### 핵심: AUTH_KEY 단일 전달
```java
// AuthChannelInboundHandler
public static final AttributeKey<AuthInfo> AUTH_KEY = AttributeKey.valueOf("AUTH_KEY");

AuthInfo authInfo = decodeToken(token);  // 토큰 디코딩 → AuthInfo VO
ctx.channel().attr(AUTH_KEY).set(authInfo);
ctx.fireChannelRead(request.retain());

// HttpRoutingHandler — 꺼내서 RequestContext에 세팅만
.authInfo(ctx.channel().attr(AuthChannelInboundHandler.AUTH_KEY).get())

// MemberController — 인가 체크
if (!"ADMIN".equals(ctx.getAuthInfo().getRole())) { ... }
```

### 파이프라인 흐름
```
HttpServerCodec → Aggregator → AuthChannelInboundHandler → HttpRoutingHandler → Controller
                                      │                          │                  │
                                  토큰 디코딩              AUTH_KEY에서           인가 체크
                                  AuthInfo 생성            AuthInfo 꺼내서        (role 기반)
                                  AUTH_KEY에 저장          RequestContext에 세팅
```

### 판단 근거
- **단일 책임**: AuthHandler는 인증만, Controller는 인가만, ApplicationService는 비즈니스만
- **AUTH_KEY 단일 전달**: 인증 결과가 아무리 복잡해져도(claims, permissions 등) AuthInfo VO만 확장하면 됨, attr 키는 하나
- **Netty 파이프라인 활용**: ADR-003의 결정과 일관 — cross-cutting concern은 ChannelHandler로 처리
- **HttpRoutingHandler는 전달만**: 인증 로직을 모름, attr에서 꺼내서 RequestContext에 넣을 뿐

### 관련 파일
- `netty/channel/AuthInfo.java` — 인증 결과 VO (신규)
- `netty/channel/AuthChannelInboundHandler.java` — 인증 ChannelHandler (신규)
- `netty/rest/route/RequestContext.java` — `authInfo` 필드 추가
- `netty/rest/route/HttpRoutingHandler.java` — AUTH_KEY에서 꺼내 RequestContext에 세팅
- `netty/rest/controller/MemberController.java` — 인가 체크 TODO 예시

 

사실 리플렉션과 어노테이션이 왜 Netty에게는 별로 좋지 않은지를 Gemini에게 deep research 기능을 이용해 분석했다.

 

초보 개발자의 수준에 맞춰서 왜 이것이 Netty에서는 안좋은지를 잘 설명해주니 잘 읽어보길 추천한다.

 

https://docs.google.com/document/d/174oaq8qL1BQhYwVk-gKNpUmJR9IX67wKTOifBXwjNQc/edit?usp=sharing


 

 

사실 필자는 이 글을 쓰면서 많은 고민을 했는데, 대부분의 코드를 ai를 활용해서 작성하던것을 본격적으로 좀 더 적극적으로 했다.

 

그리고 미친듯한 성능으로 내가 원하는 문제들과 논의들을 몇 번의 대화를 통해 해결했다.

 

물론 현재 복잡한 시스템이 아닌 토이 프로젝트라서 이렇게 쉽게 쉽게 할 수 있지만 확장성과 구조 혹은 아키텍처에 대해 훨씬 많은 시간을 소비하고 협업(AI와의 협업)을 위해 파일을 작성했고, 이를 적용중이다. 

 

다음 글에는 아무래도 확장을 하면서 마주친 문제들을 어떻게 해결했는지를 적어보려고 한다.