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 |