Spring 백엔드 개발자의 Node.js·TypeScript 전환 가이드
Spring의 익숙한 개념을 NestJS, Node.js, TypeScript, Redis, Kafka에 대응해 보면서 기본 문법부터 동시성, 트랜잭션, 메시징, 운영까지 한 번에 익히는 전환 가이드.
Spring 백엔드 개발자의 Node.js·TypeScript 전환 가이드
한 줄 요약
Spring의 구조적 감각은 NestJS에서도 그대로 활용할 수 있습니다. 다만 전환의 성패는 프레임워크 문법보다 이벤트 루프, 런타임 검증, 명시적 트랜잭션 컨텍스트, 멱등한 메시지 처리를 제대로 이해하는 데 달려 있습니다.
먼저 읽을 결론
스프링 백엔드 개발자가 Node.js·TypeScript로 전환할 때 가장 자연스러운 진입점은 NestJS입니다.
Spring Boot
= Java/JVM
+ Spring IoC Container
+ Spring MVC/WebFlux
+ JPA/Hibernate
+ Spring Security
+ Spring Data Redis/Kafka
NestJS
= TypeScript → JavaScript
+ Node.js/V8/libuv
+ Nest IoC Container
+ Express 또는 Fastify
+ TypeORM/Prisma 등
+ Guard/Pipe/Interceptor
+ Redis/Kafka 클라이언트
NestJS는 컨트롤러, 프로바이더, 모듈, DI, 데코레이터를 제공하므로 Spring Boot와 구조적으로 매우 비슷합니다. 하지만 NestJS를 사용한다고 Node.js가 JVM처럼 동작하는 것은 아닙니다. 특히 이벤트 루프, 런타임 타입 검증, 트랜잭션 전파, 동시성 모델은 별도로 이해해야 합니다. (NestJS 문서)
왜 저장했나
Spring 백엔드 개발자가 Node.js·TypeScript 실무로 넘어갈 때는 익숙한 개념과 낯선 런타임 특성을 함께 봐야 합니다. Controller·Service·DI 같은 표면은 NestJS와 쉽게 대응되지만, 실제 장애는 이벤트 루프를 막는 CPU 작업, 검증되지 않은 외부 입력, 트랜잭션 밖의 Repository 호출, 중복 Kafka 메시지처럼 두 생태계의 차이에서 생깁니다.
이 문서는 JavaScript와 TypeScript의 기초부터 데이터베이스, Redis, Kafka, 테스트, 관측성, graceful shutdown까지 한 흐름으로 묶습니다. 마지막에는 주문·결제·재고 시스템을 전환 프로젝트로 제시해 개념을 실습으로 연결합니다.
정리한 질문
Spring 백엔드 개발자가 Node.js·TypeScript 기반 백엔드로 전환하려면 어떤 기본·심화·실무 개념을 익혀야 하는가? Spring과 대응되는 개념을 출발점으로 삼되, Node.js 런타임의 차이와 Redis·Kafka를 포함한 운영 설계까지 어떻게 이해하고 연습하면 좋은가?
1. Spring과 Node.js·NestJS의 개념 대응표
아래 대응은 완전히 동일하다는 의미가 아니라, 스프링 개발자가 새로운 개념을 이해하기 위한 가장 가까운 대응 관계입니다.
1.1 웹 애플리케이션과 DI
| Spring | Node.js·NestJS | 대응 정도 | 핵심 차이 |
|---|---|---|---|
| JVM | Node.js 런타임, V8, libuv | 부분 | JVM 스레드 모델이 아니라 이벤트 루프가 중심 |
| Spring Boot | NestJS | 높음 | Nest는 Express/Fastify 위에서 동작 |
SpringApplication.run() | NestFactory.create() | 높음 | 애플리케이션 부트스트랩 |
ApplicationContext | Nest IoC Container | 높음 | 의존성 생성·주입·생명주기 관리 |
| Spring Bean | Nest Provider | 높음 | 대부분 기본 singleton |
@Component, @Service | @Injectable() | 높음 | 컨테이너 관리 대상 |
@Configuration | @Module() | 높음 | 기능 단위 구성 및 의존성 공개 |
@Bean | providers: [{ provide, useFactory }] | 높음 | 팩토리 기반 프로바이더 등록 |
@Import | imports | 높음 | 다른 모듈 가져오기 |
| Bean 공개 | exports | 높음 | 다른 모듈에서 사용할 프로바이더 공개 |
@RestController | @Controller() | 높음 | HTTP 요청 처리 |
@GetMapping | @Get() | 높음 | HTTP 라우팅 |
@RequestBody | @Body() | 높음 | 요청 본문 바인딩 |
@PathVariable | @Param() | 높음 | 경로 변수 |
@RequestParam | @Query() | 높음 | 쿼리 파라미터 |
@Valid | ValidationPipe | 높음 | 런타임 입력 검증 |
@ControllerAdvice | ExceptionFilter | 높음 | 예외를 공통 응답으로 변환 |
| Servlet Filter | Middleware | 높음 | 원시 request/response 수준 전처리 |
| Spring Security Filter Chain | Guard + 인증 전략 | 부분 | Guard가 실행할 핸들러 메타데이터를 알 수 있음 |
HandlerInterceptor | Interceptor | 부분 | 실행 전후 처리와 응답 변환 가능 |
| Spring AOP | Interceptor + Decorator | 부분 | 범용 프록시 AOP와 완전히 같지는 않음 |
| Argument Resolver | Custom Parameter Decorator + Pipe | 부분 | 인자 추출 및 변환 |
ThreadLocal, MDC | AsyncLocalStorage | 부분 | 스레드가 아니라 비동기 실행 컨텍스트를 추적 |
| singleton/request/prototype scope | default/request/transient scope | 높음 | Nest 기본값은 singleton |
Nest 요청은 대체로 이 순서로 처리됩니다.
Middleware
↓
Guard
↓
Interceptor - before
↓
Pipe
↓
Controller
↓
Service
↓
Interceptor - after
↓
Response
예외 발생 → ExceptionFilter
인증·인가는 Guard, DTO 검증은 Pipe, 로깅·응답 래핑·실행시간 측정은 Interceptor, 예외 응답 통일은 ExceptionFilter에 배치하는 것이 자연스럽습니다. (NestJS 문서)
1.2 데이터베이스와 미들웨어
| Spring | Node.js·NestJS | 핵심 차이 |
|---|---|---|
| JPA Entity | TypeORM Entity | TypeORM이 JPA와 가장 비슷함 |
| Spring Data Repository | TypeORM Repository | 기본 CRUD와 QueryBuilder 제공 |
| JPA/Hibernate | TypeORM | Entity/Relation/Repository 중심 |
| JPA 대신 명시적 SQL 접근 | Prisma 또는 SQL Query Builder | 객체 영속성 컨텍스트보다 명시적 질의 중심 |
EntityManager | TypeORM EntityManager | 트랜잭션에서 전달받은 인스턴스를 사용 |
@Transactional | ORM transaction callback | Spring처럼 공통 프록시 추상화가 표준은 아님 |
| Flyway/Liquibase | TypeORM/Prisma migration | 운영에서는 migration 사용 |
@Cacheable | CacheInterceptor 또는 Cache Service | 캐시 일관성 정책은 명시적으로 작성하는 편이 안전 |
RedisTemplate | Redis Client Provider | Node Redis 클라이언트를 DI로 주입 |
| Spring Session Redis | Redis Session Store | 동일한 용도 |
| Redisson Lock | Redis Lock Library | TTL, 소유권 토큰, 안전한 해제가 중요 |
KafkaTemplate | ClientKafka 또는 KafkaJS Producer | 이벤트 발행 |
@KafkaListener | @EventPattern() 또는 KafkaJS Consumer | 이벤트 소비 |
| Spring Kafka AckMode | Auto commit 또는 Manual offset commit | 처리 성공 시점과 offset commit 순서를 설계해야 함 |
| Maven/Gradle | package.json + lockfile | 패키지 버전과 실행 스크립트 관리 |
2. 기본 개념
2.1 NestJS보다 먼저 JavaScript를 이해해야 한다
Java 개발자가 흔히 하는 실수는 TypeScript를 “문법이 조금 다른 Java”라고 생각하는 것입니다. TypeScript의 기반은 Java가 아니라 JavaScript입니다.
반드시 익혀야 할 JavaScript 개념을 추리면 이렇습니다.
값과 객체
const a = { count: 1 };
const b = a;
b.count = 2;
console.log(a.count); // 2
Java와 마찬가지로 객체는 참조로 전달되지만, JavaScript에서는 객체 펼치기 문법을 자주 사용합니다.
const copied = { ...a };
다만 이것은 얕은 복사입니다. 내부에 중첩 객체가 있으면 해당 객체의 참조는 공유됩니다.
const, let
- 기본적으로
const - 재할당이 필요한 경우만
let var는 신규 코드에서 피하는 것이 일반적
const는 객체 자체를 불변으로 만드는 것이 아니라 변수 재할당만 금지합니다.
const user = { name: 'kim' };
user.name = 'lee'; // 가능
동등성
1 === '1'; // false
1 == '1'; // true
실무에서는 타입 변환이 발생하는 ==보다 ===를 사용합니다.
null과 undefined
Java의 null 하나와 달리 JavaScript에는 둘 다 존재합니다.
undefined: 값이 제공되지 않았거나 프로퍼티가 없음null: 의도적으로 값이 없음을 표현
API와 도메인 모델에서 둘을 혼용하면 버그가 많아집니다. 팀 차원에서 정책을 정하는 것이 좋습니다.
예를 들어:
- 요청에서 생략 가능:
undefined - 데이터베이스에서 명시적으로 비어 있음:
null
Closure, this, Prototype
다음 개념도 필수입니다.
- Closure와 lexical scope
- Arrow function과 일반 function의
this차이 - Prototype 기반 상속
- 구조 분해 할당
- Optional chaining
?. - Nullish coalescing
?? - Array의
map,filter,reduce - ESM의
import/export
Node.js는 ESM과 CommonJS를 모두 지원하므로 프로젝트 초기에 한 방식을 정하고 package.json, tsconfig.json, 테스트 도구의 모듈 설정을 일관되게 맞춰야 합니다. (Node.js)
2.2 TypeScript는 Java와 다른 타입 시스템이다
구조적 타입 시스템
Java는 주로 명목적 타입 시스템입니다.
class UserId {
String value;
}
UserId라는 명시적인 타입 선언이 중요합니다.
TypeScript는 기본적으로 구조적 타입 시스템입니다.
interface User {
id: string;
name: string;
}
const value = {
id: '1',
name: 'Kim',
extra: true,
};
const user: User = value; // 필요한 구조를 만족하므로 가능
어떤 클래스나 인터페이스를 명시적으로 구현했느냐보다, 필요한 프로퍼티 구조를 만족하는지가 중요합니다. (TypeScript)
TypeScript 타입은 런타임에 사라진다
이것이 가장 중요한 차이입니다.
interface CreateUserRequest {
email: string;
age: number;
}
위 인터페이스는 컴파일된 JavaScript에 남지 않습니다. 외부에서 다음 JSON이 들어와도 TypeScript 자체는 막을 수 없습니다.
{
"email": 123,
"age": "not-number"
}
네트워크, Kafka, Redis, 파일, 환경변수에서 들어오는 데이터는 반드시 런타임 검증이 필요합니다.
NestJS에서 DTO를 interface가 아니라 class로 만드는 이유도 이 때문입니다.
export class CreateUserDto {
@IsEmail()
email!: string;
@IsInt()
@Min(0)
age!: number;
}
app.useGlobalPipes(
new ValidationPipe({
transform: true,
whitelist: true,
forbidNonWhitelisted: true,
}),
);
transform: 가능한 경우 입력을 DTO 타입으로 변환whitelist: DTO에 정의되지 않은 필드 제거forbidNonWhitelisted: 정의되지 않은 필드가 있으면 거부
NestJS도 TypeScript interface는 transpile 과정에서 제거되므로 런타임 DTO 기능에는 class 사용을 권장합니다. (NestJS 문서)
strict는 사실상 필수
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"useUnknownInCatchVariables": true
}
}
특히 다음을 지켜야 합니다.
// 피해야 함
function parse(value: any) {
return value.user.name;
}
// 경계에서 권장
function parse(value: unknown) {
if (!isValidUser(value)) {
throw new Error('Invalid user');
}
return value;
}
any는 해당 지점 이후 타입 검사를 사실상 비활성화합니다. unknown은 사용하기 전에 검증하도록 강제합니다. TypeScript의 strict와 strictNullChecks는 null과 undefined 처리를 명시적으로 만들기 위한 핵심 옵션입니다. (TypeScript)
Java의 숫자 타입과 다르다
JavaScript의 일반 숫자는 모두 number입니다.
const count: number = 10;
const price: number = 10.5;
int, long, double 구분이 없습니다. 특히 다음 항목을 주의해야 합니다.
- Java
long범위의 ID - 금액
- 정밀한 소수
- JSON 직렬화
큰 정수 ID는 API에서 문자열로 다루는 경우가 많습니다.
type UserId = string;
금액은 정수 단위로 저장하거나 Decimal 타입을 사용하는 것이 안전합니다.
type PriceInWon = number;
type PriceInCents = number;
2.3 NestJS의 기본 구조
스프링의 package-by-feature 구조와 거의 동일하게 가져갈 수 있습니다.
src/
modules/
orders/
api/
order.controller.ts
dto/
application/
order.service.ts
domain/
order.ts
order.repository.ts
infrastructure/
typeorm-order.repository.ts
order-cache.repository.ts
order-event.publisher.ts
orders.module.ts
common/
config/
main.ts
핵심 흐름은 이렇게 이어집니다.
Controller
↓
Application Service
↓
Domain
↓
Repository / Redis / Kafka Adapter
예를 들어:
@Controller('orders')
export class OrderController {
constructor(private readonly orderService: OrderService) {}
@Post()
create(@Body() dto: CreateOrderDto): Promise<OrderResponse> {
return this.orderService.create(dto);
}
}
@Injectable()
export class OrderService {
constructor(
@Inject(ORDER_REPOSITORY)
private readonly orderRepository: OrderRepository,
) {}
async create(command: CreateOrderCommand): Promise<OrderResponse> {
// 유스케이스 처리
}
}
export const ORDER_REPOSITORY = Symbol('ORDER_REPOSITORY');
@Module({
controllers: [OrderController],
providers: [
OrderService,
{
provide: ORDER_REPOSITORY,
useClass: TypeOrmOrderRepository,
},
],
})
export class OrdersModule {}
Java interface를 바로 DI할 수 없는 이유
interface OrderRepository {
save(order: Order): Promise<void>;
}
TypeScript interface는 런타임에 사라지므로 Nest 컨테이너가 이를 DI 토큰으로 사용할 수 없습니다. 이때 다음 중 하나를 사용합니다.
- class
- abstract class
Symbol- 문자열 토큰
일반적으로 Symbol이나 abstract class가 안전합니다.
3. 반드시 바꿔야 하는 Node.js의 동시성 모델
3.1 async/await는 @Async도 아니고 새 스레드도 아니다
Java의 일반적인 Spring MVC 애플리케이션에서는 요청을 처리하는 스레드를 떠올립니다.
Request A → Thread 1
Request B → Thread 2
Request C → Thread 3
Node.js에서는 기본적으로 하나의 JavaScript 메인 스레드가 이벤트 루프를 실행합니다.
Request A → DB I/O 시작 → 대기
↓
Request B 처리
↓
A의 DB 결과 도착 → A 처리 재개
async function findOrder(id: string) {
const order = await repository.findById(id);
return order;
}
await는 새로운 스레드를 만드는 것이 아닙니다. Promise가 완료될 때까지 현재 함수의 실행을 양보하고 이벤트 루프가 다른 작업을 처리하게 합니다. Node.js는 기본적으로 하나의 JavaScript 스레드에서 이벤트 루프를 사용하면서 커널 등에 I/O 작업을 위임해 non-blocking I/O를 구현합니다. (Node.js)
3.2 CPU 작업은 프로세스 전체를 막을 수 있다
@Get('calculate')
calculate() {
let result = 0;
for (let i = 0; i < 5_000_000_000; i++) {
result += i;
}
return result;
}
이 작업이 3초 동안 실행되면 해당 Node 프로세스의 다른 요청들도 상당 부분 대기하게 됩니다.
대표적인 위험 작업을 꼽으면 이렇습니다.
- 이미지·영상 변환
- 대규모 JSON 파싱
- 복잡한 암호화
- 압축
- 대규모 반복 계산
- 매우 큰 데이터 정렬
작업 성격에 따라 대응 방법을 나눕니다.
I/O 중심 작업
→ Promise와 비동기 API
CPU 중심 작업
→ Worker Threads
→ 별도 Worker Service
→ Job Queue
Node 공식 문서도 Worker Thread는 CPU 집약적인 JavaScript 작업에 유용하지만, I/O 집약적 작업에는 Node의 비동기 I/O가 더 적합하다고 설명합니다. (Node.js)
3.3 Promise.all()이 항상 좋은 것은 아니다
await Promise.all(
userIds.map((id) => externalApi.getUser(id)),
);
사용자가 10명일 때는 괜찮을 수 있지만 10만 명이면 다음 문제가 발생할 수 있습니다.
- 외부 API connection 폭증
- DB connection pool 고갈
- 메모리 사용량 증가
- Redis/Kafka 부하 급증
- 외부 서비스 rate limit 초과
실무에서는 동시 실행 수를 제한해야 합니다.
for (const batch of chunk(userIds, 20)) {
await Promise.all(batch.map((id) => externalApi.getUser(id)));
}
핵심은 다음입니다.
Node.js에서 비동기는 무한 동시성이 아니라, 제한된 리소스를 효율적으로 사용하는 방식이다.
4. Provider Scope와 요청 컨텍스트
Nest Provider의 기본 scope는 singleton입니다.
@Injectable()
export class OrderService {}
애플리케이션 전체에서 하나의 인스턴스가 공유됩니다.
요청별 데이터를 다음처럼 singleton 필드에 저장하면 안 됩니다.
@Injectable()
export class BadService {
private currentUserId?: string;
setUserId(userId: string) {
this.currentUserId = userId;
}
}
Node가 단일 JavaScript 스레드를 사용하더라도 여러 요청의 비동기 실행이 서로 교차될 수 있기 때문입니다.
요청 ID, 사용자 ID, trace ID 같은 데이터는 다음 방법을 사용합니다.
1. 메서드 인자로 명시적으로 전달
2. Request 객체에서 추출
3. AsyncLocalStorage 사용
AsyncLocalStorage는 Promise와 callback 체인에 걸쳐 데이터를 전파하며, 다른 언어의 ThreadLocal과 유사한 용도로 사용할 수 있습니다. (Node.js)
interface RequestContext {
requestId: string;
userId?: string;
}
const context = new AsyncLocalStorage<RequestContext>();
context.run({ requestId }, async () => {
await next();
});
Nest의 request scope도 존재합니다.
@Injectable({ scope: Scope.REQUEST })
export class RequestContextService {}
하지만 request-scoped provider는 요청마다 인스턴스를 생성하고 의존성 체인을 request scope로 전파할 수 있으므로 필요한 곳에만 써야 합니다. Nest 공식 문서도 대부분의 경우 singleton 사용을 권장하고 request scope에는 성능 비용이 있다고 설명합니다. (NestJS 문서)
5. 데이터베이스와 트랜잭션
5.1 ORM 선택
TypeORM
스프링·JPA 개발자에게 가장 익숙합니다.
@Entity()
export class OrderEntity {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column()
status!: string;
@VersionColumn()
version!: number;
}
- Entity
- Repository
- Relation
- Cascade
- QueryBuilder
- Transaction
- Migration
- Optimistic version column
등의 개념이 있어 JPA와 대응하기 쉽습니다. TypeORM Entity는 class를 데이터베이스 테이블에 매핑합니다. (TypeORM)
하지만 Hibernate의 다음 기능을 그대로 기대해서는 안 됩니다.
- 영속성 컨텍스트 기반 자동 변경 감지
- 트랜잭션 종료 시 자동 flush
- OSIV
- JPA 수준의 일관된 lazy-loading 동작
- Spring의 공통 트랜잭션 추상화
TypeORM에서는 명시적으로 save, update, insert 등을 호출한다고 생각하는 것이 안전합니다.
Prisma
Prisma는 JPA보다 명시적인 Query Client에 가깝습니다.
const order = await prisma.order.findUnique({
where: { id },
include: {
items: true,
},
});
- Schema 기반
- 타입이 생성된 Query Client
- Entity 객체의 변경 감지보다 명시적인 질의
- 관계 로딩이 명시적
- 트랜잭션 Client를 콜백으로 전달
스프링식 Entity 중심 모델을 그대로 옮기려면 TypeORM이 쉽고, 데이터 접근을 명시적으로 관리하려면 Prisma가 이해하기 쉬울 수 있습니다. TypeORM은 전통적인 클래스-테이블 매핑 ORM인 반면 Prisma는 다른 방식의 타입 안전 Query Client를 제공합니다. (Prisma)
5.2 @Transactional 사고방식을 그대로 가져오면 안 된다
Spring의 일반적인 proxy 모드에서는 다음처럼 선언합니다.
@Transactional
public Order createOrder(...) {
...
}
Spring은 프록시를 통해 트랜잭션 시작과 종료를 처리합니다. 이 때문에 자기 자신 내부 호출이 프록시를 통과하지 않는 문제도 존재합니다. (Spring 문서)
TypeORM에서는 보통 다음처럼 트랜잭션 객체를 명시적으로 전달받습니다.
await dataSource.transaction(async (tx) => {
const order = tx.create(OrderEntity, {
status: 'CREATED',
});
await tx.save(order);
await tx.insert(OutboxEventEntity, {
eventId: randomUUID(),
aggregateId: order.id,
eventType: 'order.created.v1',
payload: JSON.stringify({
orderId: order.id,
}),
});
});
중요한 규칙은 다음입니다.
await dataSource.transaction(async (tx) => {
// 올바름
await tx.save(order);
// 위험
await this.injectedOrderRepository.save(order);
});
트랜잭션 콜백 안에서는 전달받은 tx 또는 해당 tx에서 얻은 repository를 사용해야 합니다. 전역 EntityManager나 평소 주입받은 Repository를 사용하면 같은 트랜잭션에 참여하지 않을 수 있습니다. TypeORM 공식 문서도 트랜잭션 내부에서는 제공된 transactional EntityManager를 사용해야 한다고 명시합니다. (TypeORM)
5.3 실무 트랜잭션 원칙
- 트랜잭션 경계는 Controller가 아니라 Application Service에 둡니다.
- 트랜잭션 안에서 외부 HTTP 호출을 오래 기다리지 않습니다.
- 트랜잭션 안에서 Kafka에 바로 발행해 원자성을 기대하지 않습니다.
- isolation level, deadlock, lock timeout을 이해해야 합니다.
- unique constraint 위반을 도메인 오류로 변환합니다.
- 트랜잭션 재시도는 해당 유스케이스가 멱등할 때만 합니다.
- 애플리케이션 replica 수와 DB connection pool 크기를 함께 계산합니다.
예를 들어 replica가 10개이고 프로세스당 pool이 20이면 최대 200개의 DB connection을 사용할 수 있습니다.
Prisma를 사용한다면 PrismaClient를 요청마다 만들지 않고 애플리케이션 singleton으로 재사용해야 connection pool 고갈을 피할 수 있습니다. (Prisma)
5.4 운영에서는 자동 스키마 동기화를 사용하지 않는다
TypeORM의 synchronize: true 같은 설정은 개발 초기에는 편리하지만 운영 데이터가 있는 환경에서는 migration을 사용하는 것이 안전합니다. TypeORM 공식 문서도 운영에서는 자동 synchronize보다 migration 사용을 설명합니다. (TypeORM)
6. Redis를 Spring 개념과 연결하기
6.1 개념 대응
Spring @Cacheable
→ Nest CacheInterceptor
→ 또는 명시적인 Cache Service
RedisTemplate / StringRedisTemplate
→ Redis Client Provider
Spring Session Redis
→ Redis Session Store
Redisson Lock
→ Redis Lock Library / SET NX PX 기반 Lock
Redis Pub/Sub
→ Redis Pub/Sub
Spring @Async + Queue
→ Redis 기반 작업 Queue
비즈니스 캐시에서는 어노테이션 기반 캐시보다 별도의 Cache Repository를 두면 일관성 정책이 더 잘 드러납니다.
export interface OrderCacheRepository {
get(orderId: string): Promise<OrderView | null>;
set(order: OrderView, ttlSeconds: number): Promise<void>;
delete(orderId: string): Promise<void>;
}
6.2 Cache-Aside 패턴
스프링의 @Cacheable 내부 동작을 명시적으로 구현한다고 생각하면 쉽습니다.
async findOrder(orderId: string): Promise<OrderView> {
const key = `order:v1:${orderId}`;
const cached = await this.redis.get(key);
if (cached) {
return JSON.parse(cached) as OrderView;
}
const order = await this.orderRepository.findById(orderId);
if (!order) {
throw new OrderNotFoundError(orderId);
}
const view = OrderView.from(order);
await this.redis.set(key, JSON.stringify(view), {
EX: 60,
});
return view;
}
쓰기 시에는 일반적으로 다음 순서로 처리합니다.
1. DB transaction commit
2. Redis cache key 삭제
3. 다음 조회에서 DB를 읽어 cache 재생성
await this.updateOrderInTransaction(command);
await this.orderCache.delete(command.orderId);
Redis 공식 문서도 cache-aside에서 캐시를 항상 최신이라고 가정하지 않고, 쓰기 시 cache key를 무효화한 후 다음 조회에서 원본 DB로부터 다시 채우는 방식을 설명합니다. (Redis)
실무에서 추가해야 하는 것
- TTL
- TTL jitter
- negative cache
- cache stampede 방지
- 키 버전
- 직렬화 버전
- 최대 객체 크기
- Redis 장애 시 DB fallback 여부
- cache hit ratio 측정
TTL jitter 예:
const ttl = 60 + Math.floor(Math.random() * 10);
모든 인기 키가 같은 시점에 만료되어 DB로 요청이 몰리는 것을 줄이기 위한 방법입니다.
Cache Key 버전
order:v1:{orderId}
order:v2:{orderId}
응답 스키마가 바뀌었을 때 기존 캐시 데이터를 안전하게 분리할 수 있습니다.
6.3 Redis를 source of truth로 오해하지 않는다
일반적인 조회 캐시에서는 다음이 기준입니다.
PostgreSQL = 정합성 기준
Redis = 성능 최적화 계층
Redis에서 값이 없어도 서비스가 동작하도록 설계할 것인지, Redis 장애 시 요청을 실패시킬 것인지를 기능별로 명확히 정해야 합니다.
예를 들어:
- 상품 상세 캐시: Redis 장애 시 DB fallback 가능
- 로그인 세션: Redis 장애 시 인증 실패 가능
- rate limiter: 장애 시 fail-open 또는 fail-closed 정책 필요
- 분산 락: 장애 시 작업 실행 여부 정책 필요
6.4 Redis Pub/Sub과 Kafka는 용도가 다르다
Redis Pub/Sub은 현재 접속해 있는 구독자에게 메시지를 전달합니다. 메시지를 저장하지 않으며 구독자가 오프라인이면 놓칠 수 있는 at-most-once 성격을 가집니다. (Redis)
| 구분 | Redis Pub/Sub | Kafka |
|---|---|---|
| 저장 | 기본적으로 저장하지 않음 | 일정 기간 내구성 있게 저장 |
| 구독자가 오프라인일 때 | 메시지를 놓침 | 이후 offset부터 소비 가능 |
| 재처리 | 어려움 | 가능 |
| 주 용도 | 실시간 알림, 캐시 무효화 신호 | 비즈니스 이벤트, 데이터 파이프라인 |
| 대규모 replay | 부적합 | 적합 |
| 소비 상태 | 별도 관리 없음 | offset 관리 |
Redis Pub/Sub이 적합한 예:
- WebSocket 서버 간 실시간 broadcast
- presence 정보
- 캐시 무효화 신호
- 일시적 알림
Kafka가 적합한 예:
- 주문 생성 이벤트
- 결제 완료 이벤트
- 회원 변경 이력
- 분석 데이터 수집
- 여러 서비스가 같은 이벤트를 독립적으로 소비
- 과거 이벤트 replay
Redis Streams는 Pub/Sub보다 내구성 있는 스트림과 consumer group이 필요하지만 Kafka까지 도입할 규모는 아닐 때 고려할 수 있습니다.
6.5 Redis 분산 락
이런 구현은 피해야 합니다.
SETNX lock:key 1
작업 수행
DEL lock:key
문제는 락 만료 후 다른 프로세스가 획득한 락을 이전 프로세스가 삭제할 수 있다는 점입니다.
기본적으로 다음 요소가 필요합니다.
1. SET key unique-token NX PX ttl
2. 작업 수행
3. 저장된 token이 내 token과 같을 때만 삭제
SET order:123:lock random-token NX PX 30000
Redis 공식 문서도 락 소유자마다 고유한 값을 저장하고, 해제할 때 그 값이 동일할 때만 삭제하도록 설명합니다. 단순한 replica failover 기반 락에는 비동기 복제에 따른 상호 배제 위반 가능성도 있습니다. (Redis)
하지만 중요한 비즈니스 정합성은 가능하면 다음으로 보장하는 것이 더 좋습니다.
- DB unique constraint
- optimistic locking
- conditional update
- idempotency key
- fencing token
분산 락은 마지막 수단에 가깝습니다.
7. Kafka를 Spring Kafka 개념과 연결하기
7.1 기본 대응
KafkaTemplate.send()
→ ClientKafka.emit()
→ KafkaJS producer.send()
@KafkaListener
→ @EventPattern()
→ KafkaJS consumer.run()
ConsumerRecord
→ Kafka message + KafkaContext
groupId
→ 동일
partition
→ 동일
offset
→ 동일
AckMode
→ autoCommit / manual commit 전략
NestJS 방식:
@Controller()
export class OrderEventConsumer {
@EventPattern('order.created.v1')
async handleOrderCreated(
@Payload() event: OrderCreatedV1,
@Ctx() context: KafkaContext,
): Promise<void> {
// 처리
}
}
단, OrderCreatedV1 타입 선언만으로 Kafka payload가 검증되는 것은 아닙니다. Kafka 메시지도 HTTP DTO와 마찬가지로 런타임 schema 검증이 필요합니다.
7.2 Topic, Partition, Key
Kafka topic은 여러 partition으로 나뉩니다.
order-events
├─ partition 0
├─ partition 1
├─ partition 2
└─ partition 3
이벤트 key를 orderId로 설정하면 동일 주문의 이벤트가 같은 partition에 배치되도록 할 수 있습니다.
await producer.send({
topic: 'order-events',
messages: [
{
key: orderId,
value: JSON.stringify(event),
},
],
});
Kafka에서 순서는 topic 전체가 아니라 partition 내부에서 보장됩니다. 같은 event key의 이벤트는 같은 partition으로 전달되며, 소비자는 해당 partition의 이벤트를 기록된 순서대로 읽습니다. (Apache Kafka)
이 순서를 지키고 싶다면 key가 중요합니다.
order.created
order.payment-approved
order.cancelled
모두 orderId를 key로 설정해야 같은 주문에 대한 순서를 유지하기 쉽습니다.
7.3 Consumer Group과 병렬성
전통적인 Kafka consumer group에서는 하나의 partition을 한 시점에 group 내 한 consumer가 담당합니다.
Partition 0 → Consumer A
Partition 1 → Consumer B
Partition 2 → Consumer C
partition이 3개인데 consumer replica를 10개 띄우면 일반적인 consumer group에서는 나머지 consumer가 유휴 상태가 될 수 있습니다.
처리 병렬성을 정할 때는 다음 관계를 고려해야 합니다.
최대 활성 consumer 수
≈ partition 수
Kafka consumer group은 partition assignment를 통해 ordering과 확장성을 함께 제공합니다. (Apache Kafka)
7.4 Offset commit과 전달 보장
가장 중요한 실패 시나리오는 다음입니다.
1. Kafka 메시지 읽음
2. DB 처리 성공
3. 프로세스 종료
4. offset commit 실패
5. 같은 메시지 재수신
이것이 at-least-once 처리입니다. 메시지가 유실되지는 않지만 중복 처리될 수 있습니다.
반대로:
1. 메시지 읽음
2. offset 먼저 commit
3. DB 처리 전에 프로세스 종료
이 경우 메시지를 잃을 수 있어 at-most-once가 됩니다.
Kafka 공식 문서도 처리 후 offset을 저장하면 장애 시 메시지가 재처리될 수 있어 at-least-once가 되고, 처리 전에 offset을 저장하면 메시지가 처리되지 않을 가능성이 있다고 설명합니다. 일반적인 Kafka 처리의 기본 사고방식은 at-least-once이며 소비자가 중복에 안전해야 합니다. (Apache Kafka)
일반적으로 다음 순서를 사용합니다.
1. 메시지 수신
2. 비즈니스 로직 및 DB transaction 성공
3. offset commit
Nest Kafka transport에서도 auto commit을 끄고 consumer를 통해 offset을 수동 commit할 수 있습니다. (NestJS 문서)
7.5 Consumer는 반드시 멱등해야 한다
이벤트에 고유한 eventId를 넣습니다.
interface OrderCreatedV1 {
eventId: string;
eventType: 'order.created.v1';
occurredAt: string;
orderId: string;
version: 1;
}
Consumer에서는 처리한 event를 inbox 테이블에 기록합니다.
await dataSource.transaction(async (tx) => {
const inserted = await this.inboxRepository.tryInsert(tx, {
eventId: event.eventId,
});
if (!inserted) {
return; // 이미 처리한 이벤트
}
await this.stockRepository.reserve(tx, event.orderId);
});
inbox.event_id에 unique constraint를 둡니다.
CREATE UNIQUE INDEX ux_inbox_event_id
ON event_inbox(event_id);
그러면 같은 이벤트가 다시 전달되어도 비즈니스 효과는 한 번만 적용됩니다.
Kafka 전달은 중복될 수 있음
+
Consumer 처리는 멱등
=
비즈니스 결과는 사실상 한 번
7.6 DB와 Kafka의 Dual Write 문제
다음 코드는 위험합니다.
await orderRepository.save(order);
await kafkaProducer.send(orderCreatedEvent);
두 작업 사이에 프로세스가 종료되면:
DB에는 주문 존재
Kafka에는 주문 생성 이벤트 없음
순서를 반대로 하면:
Kafka에는 주문 생성 이벤트 있음
DB에는 주문 없음
이를 해결하는 대표적인 방법이 Transactional Outbox입니다.
await dataSource.transaction(async (tx) => {
await tx.save(order);
await tx.insert(OutboxEventEntity, {
eventId,
aggregateId: order.id,
eventType: 'order.created.v1',
payload: JSON.stringify(event),
published: false,
});
});
별도 publisher가 outbox를 읽어 Kafka로 전송합니다.
Order DB transaction
├─ orders INSERT
└─ outbox_events INSERT
↓
Outbox Publisher
↓
Kafka
↓
Consumer
↓
Inbox + Business DB transaction
Outbox publisher가 이벤트를 두 번 발행할 수도 있으므로 consumer 측 Inbox 멱등 처리까지 함께 적용해야 합니다.
Kafka의 exactly-once 기능은 Kafka topic을 읽고 다른 Kafka topic으로 쓰는 흐름에서 가장 자연스럽습니다. 외부 DB와의 exactly-once 결과를 만들려면 외부 시스템의 협조나 별도 패턴이 필요하므로, 일반적인 서비스에서는 outbox와 idempotent consumer가 더 현실적입니다. (Apache Kafka)
7.7 Retry와 Dead Letter Topic
처리 실패 시 같은 메시지를 즉시 무한 재시도하면 해당 partition이 막힐 수 있습니다.
권장 흐름은 이렇습니다.
원본 Topic
↓ 실패
Retry Topic - 10초
↓ 실패
Retry Topic - 1분
↓ 실패
Retry Topic - 10분
↓ 실패
DLT
Retry 이벤트에는 다음 정보를 포함합니다.
interface EventHeaders {
retryCount: number;
originalTopic: string;
originalPartition: number;
originalOffset: string;
errorCode: string;
correlationId: string;
}
DLT 운영에서 필요한 기능은 다음입니다.
- 실패 이유 조회
- 원본 payload 조회
- 수정 후 재처리
- 특정 이벤트 skip
- 동일 원인 대량 실패 감지
- 알람
- replay 권한 통제
7.8 Node Kafka Consumer의 특별한 주의점
Kafka handler에서 CPU 작업을 오래 수행하면 Node 이벤트 루프뿐 아니라 consumer heartbeat 처리도 지연될 수 있습니다.
@EventPattern('large-data.created')
async handle(event: LargeDataEvent, context: KafkaContext) {
const heartbeat = context.getHeartbeat();
for (const batch of createBatches(event.items)) {
await this.processBatch(batch);
await heartbeat();
}
}
Nest Kafka 문서도 처리 시간이 긴 handler에서는 heartbeat callback 사용을 고려하도록 설명합니다. (NestJS 문서)
CPU 집약적인 작업은 다음처럼 분리하는 것이 좋습니다.
Kafka Consumer
→ 메시지 검증
→ 작업 Queue 등록
→ Worker Thread 또는 Worker Service 처리
8. Redis와 Kafka를 함께 사용하는 주문 서비스 예시
아래 구조 하나로 핵심 개념을 함께 학습할 수 있습니다.
┌───────────────┐
Client ── HTTP ─────────▶ │ Order API │
└───────┬───────┘
│
Middleware → Guard → Interceptor → Pipe
│
Controller
│
Application Service
│
PostgreSQL Transaction
├─ orders
├─ order_items
└─ outbox_events
│
Outbox Publisher / CDC
│
▼
Kafka Topic
│
┌─────────────┴─────────────┐
▼ ▼
Payment Consumer Stock Consumer
│ │
Inbox + Payment DB Inbox + Stock DB
조회 흐름:
Client
↓
Order API
↓
Redis Cache
├─ Hit → 바로 반환
└─ Miss → PostgreSQL → Redis 저장 → 반환
주문 생성은 아홉 단계로 이어집니다.
ValidationPipe가 HTTP 요청 DTO를 검증합니다.- Guard가 인증과 권한을 확인합니다.
- Application Service가 주문 유스케이스를 실행합니다.
- 하나의 DB transaction으로 주문과 outbox event를 저장합니다.
- commit 후 관련 Redis cache를 삭제합니다.
- Outbox publisher가 Kafka로
order.created.v1을 발행합니다. - 결제·재고 consumer는 inbox로 중복을 제거합니다.
- 실패 이벤트는 retry topic을 거쳐 DLT로 이동합니다.
- 모든 HTTP 요청과 Kafka 이벤트에 correlation ID를 전달합니다.
9. 심화 개념
9.1 Streams와 Backpressure
대용량 파일을 다음처럼 한 번에 메모리에 올리면 위험합니다.
const data = await readFile('/large/file');
대신 stream을 사용합니다.
await pipeline(
createReadStream(sourcePath),
transformStream,
createWriteStream(targetPath),
);
Backpressure는 소비자가 처리할 수 있는 속도보다 생산자가 더 빠르게 데이터를 밀어 넣지 않도록 조절하는 개념입니다.
Spring WebFlux를 경험했다면 개념은 익숙할 수 있지만 다음은 구분해야 합니다.
Mono<T> → 주로 Promise<T>
Flux<T> → Node Stream / AsyncIterable / RxJS Observable
Reactor Context → AsyncLocalStorage와 일부 유사
Node의 stream.pipeline()은 backpressure와 관련 오류 처리를 추상화합니다. (Node.js)
9.2 Timeout과 Cancellation
await 자체에는 자동 timeout이 없습니다.
const result = await externalApi.call();
외부 서비스가 응답하지 않으면 요청이 오래 대기할 수 있습니다.
모든 외부 I/O에는 다음 항목을 고려해야 합니다.
- connection timeout
- request timeout
- overall deadline
AbortSignal- retry 횟수
- exponential backoff
- circuit breaker
- fallback
- bulkhead
특히 retry는 요청이 멱등할 때만 안전합니다.
GET 상품 조회
→ 일반적으로 retry 가능
POST 결제 승인
→ idempotency key 없이 retry하면 중복 결제 위험
9.3 Error 처리
Java의 checked exception에 대응하는 개념은 없습니다.
async function createOrder(): Promise<Order> {
throw new OrderConflictError();
}
호출자는 await와 try/catch로 처리합니다.
try {
return await orderService.create(command);
} catch (error: unknown) {
// error narrowing
}
HTTP 경로의 예외는 ExceptionFilter가 처리할 수 있지만 다음 작업의 오류는 별도로 처리해야 합니다.
- fire-and-forget Promise
- cron job
- queue worker
- Kafka consumer
- EventEmitter listener
- background polling loop
다음 코드는 피해야 합니다.
this.sendNotification(); // Promise를 기다리지도, 오류를 처리하지도 않음
최소한 다음처럼 처리합니다.
void this.sendNotification().catch((error) => {
this.logger.error({ error }, 'Failed to send notification');
});
그러나 중요한 작업이라면 fire-and-forget 대신 Queue에 넣는 것이 더 안전합니다.
10. 실무 아키텍처 원칙
10.1 기능 단위 모듈화
다음과 같은 기술 계층 중심 구조보다:
controllers/
services/
repositories/
entities/
다음과 같은 기능 중심 구조가 확장에 유리합니다.
orders/
payments/
members/
products/
각 모듈 내부에 API, application, domain, infrastructure를 배치합니다.
orders/
api/
application/
domain/
infrastructure/
orders.module.ts
Spring의 package-by-feature, modular monolith, hexagonal architecture와 동일한 방향입니다.
10.2 글로벌 모듈을 남용하지 않는다
모든 것을 global provider로 만들면 의존성이 숨겨집니다.
나쁜 구조:
어디서든 DB, Redis, Kafka, Config에 자유롭게 접근
권장 구조:
OrdersModule이 필요한 Port를 선언
Infrastructure Module이 Adapter를 제공
Nest Module의 exports는 해당 모듈의 공개 API와 비슷하게 사용하면 좋습니다. Nest Module은 기본적으로 provider를 캡슐화하며 명시적으로 export한 provider만 다른 모듈에서 사용할 수 있습니다. (NestJS 문서)
10.3 HTTP Adapter에 과도하게 결합하지 않는다
Nest는 기본적으로 Express를 사용할 수 있고 Fastify Adapter로 교체할 수도 있습니다.
const app = await NestFactory.create<NestFastifyApplication>(
AppModule,
new FastifyAdapter(),
);
하지만 Express 전용 middleware를 사용하면 Fastify 전환이 어려워질 수 있습니다. Nest 공식 문서도 Fastify Adapter 사용 시 Express 기반 패키지가 동작하지 않을 수 있으므로 Fastify 대응 패키지를 사용해야 한다고 설명합니다. (NestJS 문서)
처음에는 기본 Adapter를 사용하고 다음을 우선하는 것이 좋습니다.
- Nest 표준 decorator 사용
@Res()직접 조작 최소화- framework-specific request 타입 의존 최소화
- 성능 측정 후 Adapter 변경
11. 테스트 전략
단위 테스트
Application Service와 도메인 로직을 테스트합니다.
const module = await Test.createTestingModule({
providers: [
OrderService,
{
provide: ORDER_REPOSITORY,
useValue: mockOrderRepository,
},
],
}).compile();
const service = module.get(OrderService);
Nest의 TestingModule은 실제 Nest DI 컨테이너와 비슷한 환경을 만들고 provider를 override할 수 있습니다. (NestJS 문서)
통합 테스트
가능하면 다음은 실제 인프라를 사용합니다.
- PostgreSQL
- Redis
- Kafka
- migration
- unique constraint
- transaction rollback
- serialization
Mock만으로는 다음 문제를 찾기 어렵습니다.
- transaction 미참여
- 잘못된 SQL
- DB isolation 문제
- Redis TTL 문제
- Kafka serialization 문제
- offset 처리 문제
E2E 테스트
HTTP 요청
→ Guard
→ Pipe
→ Controller
→ Service
→ DB
→ 응답
전체 파이프라인을 검증합니다.
반드시 테스트할 장애 시나리오
- DB commit 직후 프로세스 종료
- Kafka 처리 후 offset commit 직전 종료
- 같은 Kafka 이벤트 두 번 수신
- Redis 장애
- Kafka broker 일시 장애
- 외부 API timeout
- DLT 이동
- cache stale
- optimistic lock conflict
- graceful shutdown 중 요청 유입
12. 관측성과 운영
12.1 로그
콘솔 문자열보다는 구조화 로그를 사용합니다.
logger.info(
{
requestId,
orderId,
userId,
durationMs,
},
'Order created',
);
요청 ID와 trace ID는 AsyncLocalStorage로 전파할 수 있습니다.
로그에 다음을 넣습니다.
- timestamp
- level
- service name
- environment
- request ID
- trace ID
- user ID
- event ID
- Kafka topic/partition/offset
- error class
- stack trace
비밀번호, access token, 카드 번호와 같은 정보는 기록하지 않습니다.
12.2 주요 지표
HTTP
- 요청 수
- 오류율
- p50, p95, p99 latency
- timeout
- 동시 요청 수
Node.js
- event loop delay/utilization
- heap used
- RSS
- GC 시간
- 프로세스 restart 횟수
DB
- active connection
- waiting connection
- query latency
- slow query
- deadlock
- transaction rollback
Redis
- cache hit ratio
- command latency
- memory usage
- eviction
- connection 오류
Kafka
- consumer lag
- rebalance 횟수
- 처리 latency
- retry 횟수
- DLT 이벤트 수
- producer 오류
12.3 Graceful Shutdown
컨테이너나 Kubernetes가 SIGTERM을 보냈을 때 다음 순서로 종료해야 합니다.
1. 신규 요청 수신 중단
2. 처리 중인 HTTP 요청 drain
3. Kafka consumer 중단
4. 처리 중인 메시지 완료
5. producer flush
6. DB connection 종료
7. Redis connection 종료
8. 프로세스 종료
Nest에서는 shutdown hook을 활성화합니다.
const app = await NestFactory.create(AppModule);
app.enableShutdownHooks();
await app.listen(3000);
Nest는 shutdown hook을 활성화하면 SIGTERM 같은 종료 신호에 대해 onModuleDestroy, beforeApplicationShutdown, onApplicationShutdown 등의 lifecycle hook을 실행할 수 있습니다. (NestJS 문서)
13. 스프링 개발자가 자주 하는 실수
| 잘못된 생각 | 올바른 이해 |
|---|---|
async 함수는 별도 스레드에서 실행된다 | async는 Promise를 반환하며 일반 JS 코드는 이벤트 루프에서 실행된다 |
| TypeScript 타입이 JSON을 검증한다 | 타입은 컴파일 시점에만 존재하므로 런타임 검증이 필요하다 |
| interface를 생성자에 쓰면 DI된다 | interface는 런타임에 없으므로 token이 필요하다 |
| 요청마다 Service 인스턴스가 생긴다 | Nest Provider는 기본적으로 singleton이다 |
| singleton 필드에 현재 사용자 정보를 저장해도 된다 | 요청 실행이 교차될 수 있으므로 금지해야 한다 |
await를 붙이면 CPU 작업도 non-blocking이다 | 동기 CPU 작업은 이벤트 루프를 막는다 |
Promise.all은 많이 쓸수록 빠르다 | connection pool과 외부 시스템을 고갈시킬 수 있다 |
| JPA처럼 Entity만 바꾸면 자동 저장된다 | ORM별로 명시적 save나 update가 필요하다 |
@Transactional과 동일한 표준이 있다 | 일반적으로 transaction client를 명시적으로 전달한다 |
| Redis cache는 항상 DB와 동일하다 | cache는 stale할 수 있으며 일관성 정책이 필요하다 |
| Redis Pub/Sub과 Kafka는 비슷하다 | Pub/Sub은 일시적 broadcast, Kafka는 내구성 있는 event log에 가깝다 |
| Kafka exactly-once를 켜면 중복 처리가 사라진다 | 외부 DB 처리까지 포함하면 idempotency와 outbox/inbox가 필요하다 |
| in-memory session으로 충분하다 | replica가 여러 개면 요청이 다른 프로세스로 이동한다 |
| Kafka consumer에서 긴 CPU 작업을 해도 된다 | 이벤트 루프와 heartbeat가 지연될 수 있다 |
14. 추천 학습 순서
1단계: JavaScript와 TypeScript
다음을 코드로 직접 확인합니다.
- Closure
this- Prototype
- Promise
async/await- Event Loop
- ESM
- Structural Typing
- Union과 Narrowing
unknownnull과undefined- Type Erasure
2단계: Spring CRUD를 NestJS로 포팅
기존 Spring 프로젝트 하나를 골라 다음을 옮깁니다.
@RestController → @Controller
@Service → @Injectable
@Configuration → @Module
@Valid → ValidationPipe
@ControllerAdvice → ExceptionFilter
Spring Security → Guard
3단계: 데이터베이스
- migration
- transaction callback
- repository
- relation
- pagination
- N+1
- optimistic lock
- unique constraint
- connection pool
4단계: Redis
- cache-aside
- TTL
- cache invalidation
- negative cache
- idempotency key
- rate limit
- 분산 락
- 장애 fallback
5단계: Kafka
- topic
- partition
- key
- consumer group
- offset
- manual commit
- retry
- DLT
- schema version
- idempotent consumer
6단계: Outbox와 Inbox
- 주문과 outbox를 같은 transaction으로 저장
- publisher가 Kafka 발행
- consumer가 inbox로 중복 제거
- 장애 시나리오 테스트
7단계: 운영
- 구조화 로그
- AsyncLocalStorage
- tracing
- metrics
- health/readiness
- graceful shutdown
- 부하 테스트
- event loop 지연 측정
- DB pool 계산
15. 전환용 프로젝트로 가장 좋은 예시
주문·결제·재고 시스템 하나를 구현하는 것이 좋습니다.
필수 기능은 다음 정도가 적절합니다.
POST /orders
- Idempotency-Key 지원
- 주문 DB transaction
- Outbox 저장
GET /products/:id
- Redis cache-aside
- TTL 및 invalidation
order.created.v1
- Kafka 발행
- orderId를 message key로 사용
Payment Consumer
- Inbox 중복 제거
- 결제 처리
- Retry/DLT
Stock Consumer
- 재고 예약
- Optimistic lock
- 중복 이벤트 방지
운영
- requestId/traceId
- Kafka lag
- Redis hit ratio
- DB pool
- graceful shutdown
이 프로젝트를 완성하면 단순히 NestJS 문법을 아는 수준을 넘어, Node.js·TypeScript 백엔드 실무에서 중요한 문제를 대부분 경험하게 됩니다.
가장 우선적으로 체득해야 할 네 가지는 이벤트 루프를 막지 않는 것, 외부 입력을 런타임에 검증하는 것, 트랜잭션 컨텍스트를 명시적으로 관리하는 것, 모든 메시지 소비를 멱등하게 만드는 것입니다. NestJS의 Controller·Service·DI 문법은 스프링 경험으로 빠르게 익힐 수 있지만, 실제 전환의 성패는 이 네 가지 차이를 제대로 이해하는 데 달려 있습니다.
주의점과 불확실성
- 이 문서의 Spring 대응표는 학습을 돕기 위한 비유입니다. 같은 칸에 놓인 기능이라도 실행 모델, 생명주기, 오류 처리와 확장 지점까지 같다는 뜻은 아닙니다.
- Node.js, NestJS, TypeORM, Prisma와 Kafka client의 API·기본값은 버전에 따라 달라질 수 있습니다. 실제 프로젝트에서는 설치한 버전의 공식 문서와 lockfile을 기준으로 판단해야 합니다.
- batch 크기, connection pool, TTL, timeout, retry 간격은 예시입니다. 트래픽, 지연시간, 데이터베이스 한도와 장애 예산을 측정한 뒤 조정해야 합니다.
- Redis 분산 락은 topology와 장애 모델에 따라 안전성이 달라집니다. 중요한 정합성은 DB constraint, optimistic locking, idempotency key, fencing token을 먼저 검토합니다.
- Kafka의 exactly-once는 적용 범위가 중요합니다. 외부 데이터베이스까지 포함한 비즈니스 결과는 outbox, inbox와 멱등성 설계로 따로 보완해야 합니다.
검증이 필요한 주장
- 본문이 참조한 Node.js v26.5.0 및 Kafka 4.3 문서의 버전과 세부 동작은 구현 시점에 다시 확인해야 한다.
- Nest Kafka transport의 manual commit과 heartbeat 사용법은 NestJS 및 내부 Kafka client 버전에 따라 설정 방식이 달라질 수 있다.
- TypeORM과 Prisma의 transaction, relation loading, migration, connection pool 동작은 선택한 major version과 adapter 조합에서 재검증해야 한다.
- Redis 장애 시 fail-open·fail-closed, DB fallback, lock 사용 여부는 서비스의 정합성·보안 요구사항을 기준으로 별도 결정해야 한다.
- Express와 Fastify의 성능 차이는 workload와 plugin 구성에 크게 좌우되므로 benchmark 없이 일반화하면 안 된다.
Source Fidelity Notes
- Preserved key numbers: 5,000,000,000회 CPU 반복 예시, batch 20개, replica 10개 × pool 20개 = 최대 200 connection, cache TTL 60초와 최대 9초 jitter, lock TTL 30,000ms, retry 10초·1분·10분, p50·p95·p99, graceful shutdown 8단계, 학습 로드맵 7단계.
- Preserved frameworks / models: Spring Boot와 NestJS 대응표, JavaScript event loop, TypeScript structural typing과 type erasure, TypeORM·Prisma, cache-aside, Redis Pub/Sub·Streams·분산 락, Kafka partition·consumer group·offset, at-least-once, idempotent consumer, transactional outbox·inbox, retry topic·DLT, backpressure, hexagonal architecture.
- Preserved templates / checklists: Nest request lifecycle, package-by-feature 디렉터리, transaction 예제, Redis key·TTL 정책, Kafka event schema, outbox 처리 흐름, 주문 서비스 아키텍처, 장애 테스트 목록, 관측 지표, graceful shutdown 순서, 전환 프로젝트 요구사항.
- Omitted or compressed: 본문 기술 내용과 코드 예시는 생략하지 않았다. 공개 문서에는 frontmatter와 탐색용 요약을 추가했고, 링크의 불필요한 UTM query parameter와 잘못된 링크 표시 이름 하나만 정리했다. 공개 한국어 문장은 기술 의미·수치·고유명사를 유지한 채 문장 흐름만 다듬었다.
- Omission risk: 기술 본문은 모두 남아 있어 해석 손실 위험은 낮다. 원문 구조와 표현을 그대로 확인할 수 있도록 private source layer에도 별도 보존했다.