[NestJS] NestJS를 공부해 보자 ..

2026. 1. 26. 14:34·Framework/NestJS

Spring 공부하다가 NestJS 하게 되어서 공부하는 .. 모듈 시스템 등등 ?

 

Spring Boot → NestJS 전환 가이드: 모듈 시스템, 요청 파이프라인, DDD

들어가며

Spring Boot에서 NestJS로 전환할 때 가장 낯설었던 것이 "모듈 시스템"입니다. Spring의 자동 스캔에 익숙한 개발자에게 NestJS의 명시적 선언은 번거로워 보일 수 있습니다. 하지만 이 차이에는 명확한 이유가 있습니다.


1. Spring vs NestJS: 의존성 등록 방식

항목 Spring NestJS

기본 방식 Component Scan (자동 스캔) 명시적 Module 선언
등록 기준 패키지 경로 스캔 providers 배열에 명시
외부 공개 기본적으로 전부 공개 exports에 명시된 것만
의존성 추적 런타임에 암묵적 imports로 명확
캡슐화 수준 낮음 높음
대규모 프로젝트 빈 충돌 위험 구조적 분리 용이
설계 의도 편의성 중심 경계 명확성 중심

Spring: 자동 스캔

@SpringBootApplication
@ComponentScan(basePackages = "com.myapp")
public class Application {
    // 패키지 내 모든 @Component, @Service 자동 등록
}

@Service
public class UserService { }     // 자동 스캔됨

@Service
public class InternalService { } // 이것도 자동 스캔됨 — 외부에서 접근 가능

Spring의 한계?

  • 모든 빈이 전역에 공개됨 → 솔직히 소규모~중규모 프로젝트에서는 크게 문제를 못 느낌
  • 어떤 서비스가 어디서 사용되는지 추적 어려움 → IntelliJ의 의존성 다이어그램으로 어느 정도 보완 가능
  • 대규모 프로젝트에서 빈 충돌 가능성 → 경험이 적으면 체감하기 어렵지만, 팀이 커지면 실제로 발생

공정한 평가: Spring이 나쁜 게 아니라 설계 철학이 다른 것입니다. Spring은 "편하게 시작하고, 필요할 때 제한을 걸어라"이고, NestJS는 "처음부터 경계를 명확히 하고, 필요할 때 열어라"입니다.

NestJS: 명시적 선언

@Module({
    providers: [UserService, InternalService],
    exports: [UserService]  // UserService만 외부에 공개
})
export class UserModule {}

// 다른 모듈에서
@Module({
    imports: [UserModule],  // UserModule의 exports된 것만 사용 가능
    providers: [PostService]
})
export class PostModule {}

@Injectable()
export class PostService {
    constructor(
        private userService: UserService,          // ✅ OK (exports됨)
        // private internalService: InternalService // ❌ 에러! (exports 안 됨)
    ) {}
}

장점:

  • 캡슐화: 모듈 내부 구현을 숨길 수 있음
  • 명확한 의존성: imports로 의존 관계가 코드에 명시됨
  • 모듈 독립성: 마이크로서비스 전환 시 모듈 단위로 분리 용이

항목 Spring NestJS

내부 전용 서비스 별도 제어 어려움 (package-private 정도) exports 미포함으로 제한
외부 모듈 접근 자동 가능 (같은 컨텍스트면) imports + exports 필요
실수 방지 약함 컴파일 타임에 차단
아키텍처 강제성 약함 (자유도 높음) 강함 (구조를 강제)

2. 요청 처리 파이프라인: Spring vs NestJS

Spring 파이프라인

Client Request
    ↓
Filter (jakarta.servlet.Filter)
    ↓
DispatcherServlet
    ↓
Interceptor (HandlerInterceptor)
    ↓
Controller
    ↓
AOP (@Around)
    ↓
Service
    ↓
Exception Handler (@ControllerAdvice)
    ↓
Response

NestJS 파이프라인

Client Request
    ↓
Middleware (Express/Fastify)
    ↓
Guards (인증/인가)
    ↓
Interceptors (Before)
    ↓
Pipes (데이터 변환/검증)
    ↓
Controller → Service
    ↓
Interceptors (After)
    ↓
Exception Filters
    ↓
Response

1:1 매핑 비교

단계 Spring NestJS

HTTP 진입 Filter (jakarta.servlet.Filter) Middleware
인증/인가 Spring Security Filter Guards
요청 가로채기 Interceptor (HandlerInterceptor) Interceptor
데이터 검증 @Valid / Validator Pipes
비즈니스 진입 Controller → Service Controller → Service
공통 로직 (횡단 관심사) AOP (@Around) Interceptor
예외 처리 @ControllerAdvice Exception Filter

핵심 차이: Spring은 AOP(프록시 기반)로 횡단 관심사를 처리하고, NestJS는 Interceptor(명시적 파이프라인)로 처리합니다. Spring의 AOP는 강력하지만 "어디서 어떤 Aspect가 끼어드는지" 파악이 어렵고, NestJS의 Interceptor는 파이프라인 순서가 명시적이라 추적이 쉽습니다.


3. 실전 예제: JWT 인증 구현

Spring Security

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/public/**").permitAll()
                .requestMatchers("/admin/**").hasRole("ADMIN")
                .anyRequest().authenticated()
            )
            .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }
}

hasRole() vs hasAuthority() 참고:

  • hasRole("ADMIN") → 내부적으로 "ROLE_ADMIN"으로 변환되어 비교
  • hasAuthority("ADMIN") → "ADMIN" 그대로 비교
  • Enum으로 Role을 관리할 때 ROLE_ 프리픽스 때문에 매칭이 안 되는 이슈가 발생할 수 있음
  • 팁: hasAuthority()를 쓰거나, Enum에 "ROLE_" 프리픽스를 포함시키면 해결

참고: 위 코드는 Spring Security 6.x (Spring Boot 3.x) 스타일입니다. WebSecurityConfigurerAdapter는 deprecated되었으므로, SecurityFilterChain Bean 방식을 사용합니다.

NestJS Guards

// 1. JWT 인증 Guard
@Injectable()
export class JwtAuthGuard implements CanActivate {
    constructor(private jwtService: JwtService) {}

    canActivate(context: ExecutionContext): boolean {
        const request = context.switchToHttp().getRequest();
        const token = request.headers.authorization?.replace('Bearer ', '');

        if (!token) {
            throw new UnauthorizedException('토큰이 없습니다');
        }

        try {
            const payload = this.jwtService.verify(token);
            request.user = payload;
            return true;
        } catch (error) {
            throw new UnauthorizedException('유효하지 않은 토큰입니다');
        }
    }
}

// 2. 역할 Guard
@Injectable()
export class RolesGuard implements CanActivate {
    constructor(private reflector: Reflector) {}

    canActivate(context: ExecutionContext): boolean {
        const roles = this.reflector.get<string[]>('roles', context.getHandler());
        if (!roles) return true;

        const request = context.switchToHttp().getRequest();
        return roles.includes(request.user.role);
    }
}

// 3. Controller에서 사용
@Controller('users')
@UseGuards(JwtAuthGuard, RolesGuard)
export class UserController {
    @Get('public')
    @Public()  // 커스텀 데코레이터 — JwtAuthGuard를 건너뛰게 설정
    getPublicData() {
        return 'Public data';
    }

    @Get('admin')
    @Roles('admin')
    getAdminData() {
        return 'Admin data';
    }
}

인증/인가 매핑 비교

개념 Spring Security NestJS

인증 진입 Filter Guard
인가 판단 Expression (hasRole) / Voter Guard + Reflector
메타데이터 Annotation (@PreAuthorize 등) Decorator + Reflector
전역 설정 SecurityFilterChain Bean Global Guard
엔드포인트 제어 requestMatchers @UseGuards

항목 Spring NestJS

토큰 추출 Filter 내부 Guard 내부
토큰 검증 AuthenticationProvider JwtService
사용자 주입 SecurityContextHolder request.user
실패 처리 ExceptionTranslationFilter UnauthorizedException
적용 범위 설정 파일 (전역 URL 패턴) Controller / Method 단위

4. DDD 구현: Spring vs NestJS

Spring DDD 구조

/src/main/java/com/example/lecture
    /domain
        Lecture.java             # 엔티티 + 비즈니스 로직
        LectureRepository.java   # 리포지토리 인터페이스
    /application
        LectureService.java      # 애플리케이션 서비스 (오케스트레이션)
    /presentation
        LectureController.java   # 표현 계층
// 엔티티에 비즈니스 로직을 넣는 Rich Domain Model
@Entity
public class Lecture {
    @Id
    private Long id;

    private int capacity;
    private int enrolledCount;

    // 도메인 로직 — 엔티티가 자신의 불변식을 스스로 지킴
    public LectureEnrollment enroll(Student student) {
        increaseEnrolledCount();
        return new LectureEnrollment(this, student);
    }

    private void increaseEnrolledCount() {
        if (enrolledCount >= capacity) {
            throw new BusinessException("수강 정원 초과");
        }
        this.enrolledCount++;
    }
}

// 서비스는 오케스트레이션만 — 도메인 로직을 직접 구현하지 않음
@Service
@Transactional
public class LectureService {
    public Long enroll(Long lectureId, Long studentId) {
        Lecture lecture = lectureRepository.findById(lectureId).orElseThrow();
        Student student = studentRepository.findById(studentId).orElseThrow();

        // 도메인 로직 실행 — 서비스는 "조합"만
        LectureEnrollment enrollment = lecture.enroll(student);

        enrollmentRepository.save(enrollment);
        return enrollment.getId();
    }
}

이 예시는 제 개인 프로젝트(SSA)를 간소화한 것입니다. 실제로는 더 복잡하지만, 핵심 구조는 동일합니다.

NestJS DDD 구조

/src/lecture
    /entities
        lecture.entity.ts        # 엔티티 + 비즈니스 로직
    lecture.service.ts           # 애플리케이션 서비스
    lecture.controller.ts        # 표현 계층
    lecture.module.ts            # 모듈 선언
// 엔티티에 비즈니스 로직 — Spring DDD와 동일한 패턴
@Entity()
export class Lecture {
    @PrimaryGeneratedColumn()
    id: number;

    @Column()
    capacity: number;

    @Column({ default: 0 })
    enrolledCount: number;

    // 도메인 로직
    enroll(student: Student): LectureEnrollment {
        this.increaseEnrolledCount();
        return new LectureEnrollment(this, student);
    }

    private increaseEnrolledCount(): void {
        if (this.enrolledCount >= this.capacity) {
            throw new BadRequestException('수강 정원 초과');
        }
        this.enrolledCount++;
    }
}

// 서비스는 오케스트레이션만
@Injectable()
export class LectureService {
    async enroll(lectureId: number, studentId: number): Promise<number> {
        const lecture = await this.lectureRepository.findById(lectureId);
        const student = await this.studentRepository.findById(studentId);

        // 도메인 로직 실행
        const enrollment = lecture.enroll(student);

        await this.enrollmentRepository.save(enrollment);
        return enrollment.id;
    }
}

TypeScript에서 DDD가 잘 먹힐까? — 개인적 고찰

TypeScript는 함수형 프로그래밍 스타일이 강한 언어라서, "엔티티에 로직을 넣는" OOP 중심 DDD가 자연스럽게 먹힐까 하는 의문이 생겼습니다.

결론적으로 NestJS에서도 DDD 구현은 가능합니다. 다만 몇 가지 차이점이 있습니다:

  • Prisma를 쓰면 DDD가 어려워짐: Prisma의 모델은 plain object를 반환하므로 메서드를 가진 Rich Domain Model을 만들기 어려움. TypeORM은 Active Record / Data Mapper 패턴을 모두 지원해서 DDD 친화적
  • TypeScript 커뮤니티 문화: 함수형 스타일(순수 함수 + 불변 데이터)을 선호하는 경향이 있어서, 엔티티에 상태 변경 로직을 넣는 전통적 DDD보다는 도메인 서비스에 로직을 두는 Transaction Script + 도메인 이벤트 조합이 더 흔함
  • 그래도 핵심은 같음: "도메인 로직이 어디에 있느냐?"가 DDD의 본질이고, 언어/프레임워크는 수단일 뿐

DDD 레이어 매핑

레이어 Spring NestJS

Domain Entity + Repository Interface Entity (+ Repository Interface)
Application Service (@Transactional) Service (@Injectable())
Presentation Controller Controller / Resolver
Infra JPA 구현체 ORM Adapter (TypeORM/Prisma)

항목 Spring NestJS

도메인 로직 위치 Entity Entity (TypeORM) / Service (Prisma)
서비스 역할 오케스트레이션 오케스트레이션
트랜잭션 @Transactional (AOP 기반) 라이브러리별 또는 명시적
예외 Custom Exception HttpException 또는 Custom
테스트 용이성 높음 높음

AOP vs Interceptor

개념 Spring NestJS

Before @Before Interceptor (before handler)
After @After / @AfterReturning Interceptor (after handler)
Around @Around Interceptor (next.handle() 전후)
횡단 관심사 AOP (프록시 기반, 암묵적) Interceptor / Middleware (명시적)
트랜잭션 AOP 기반 (@Transactional) 라이브러리 또는 명시적 관리

정리

항목 Spring NestJS

모듈 등록 방식 클래스패스 기반 자동 스캔 @Module에서 명시적 선언
캡슐화 수준 약함 (빈은 기본적으로 전역) 강함 (exports로 공개 범위 제한)
인증/인가 Servlet Filter + Spring Security Guard (요청 전 단계)
횡단 관심사 (AOP) @Around, 프록시 기반 AOP Interceptor (명시적 파이프라인)
DDD 적용 가능 여부 가능 (Entity 중심) 가능 (Entity 중심, ORM에 따라 난이도 차이)

다음으로 공부할 것

  • GraphQL N+1 문제와 DataLoader: Resolver가 필드별로 호출되면서 생기는 N+1과 DataLoader의 배칭 원리
  • Prisma vs JPA: 영속성 컨텍스트의 유무: JPA는 1차 캐시 + 변경 감지(dirty checking)가 있지만, Prisma는 없음. 이 차이가 트랜잭션 설계에 미치는 영향

 

 

'Framework > NestJS' 카테고리의 다른 글

[NestJS] NestJS + Prisma ? MikroORM ?  (0) 2026.03.16
[NestJS] Prisma vs TypeORM  (0) 2026.02.02
'Framework/NestJS' 카테고리의 다른 글
  • [NestJS] NestJS + Prisma ? MikroORM ?
  • [NestJS] Prisma vs TypeORM
하가네
하가네
  • 하가네
    하 렌
    하가네
  • 전체
    오늘
    어제
    • 분류 전체보기 (127)
      • Computer Science (23)
        • 운영체제 (7)
        • 데이터통신 (6)
        • 자료구조 (4)
        • 논리회로 (0)
        • 확률 및 통계 (0)
        • 데이터베이스 (2)
        • AI소프트웨어 (3)
        • 컴퓨터네트워크 (1)
      • Design (4)
        • OOP - 객체 지향 프로그래밍 (2)
        • DDD - 도메인 주도 개발 (데이터베이스 주도 .. (0)
        • EDA - 이벤트 기반 아키텍처 (1)
        • MSA - 마이크로서비스 아키텍처 (0)
        • ADD - AI 주도 개발 (1)
      • Language (2)
        • Java (0)
        • TypeScript (2)
      • Framework (12)
        • Spring (9)
        • NestJS (3)
      • Engine (3)
        • Elasticsearch (1)
        • GraphQL + Apollo Federation (2)
      • Plugin - Extension (1)
        • VS Code (1)
        • IntelliJ (0)
      • Tips (2)
        • 터미널 명령어 (1)
        • 우분투 명령어 에러 (1)
      • SSA (26)
        • Front (1)
        • Back (23)
        • DB (1)
        • 기획 (1)
      • CNU SW 아카데미 (42)
        • 1주차 (5)
        • 2주차 (5)
        • 3주차 (2)
        • 4주차 (1)
        • 5주차 (3)
        • 6주차 (2)
        • 7주차 (0)
        • 8주차 (1)
        • 9주차 (14)
        • 10주차 (0)
        • 11주차 (1)
        • 12주차 (0)
        • 13주차 (2)
        • 14주차 (2)
        • 15주차(최종 프로젝트) (3)
        • 최종 프로젝트 이후 (1)
      • 모각코 (6)
        • 2023 동계 (6)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    생산성
    프론트엔드/백엔드
    Husky
    ci/cd
    lint-staged
    개발자경험(DX)
    릴리스엔지니어링
    ESLint
    Typescript
    아키텍처
    DX(DeveloperExperience)
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.
하가네
[NestJS] NestJS를 공부해 보자 ..
상단으로

티스토리툴바