TypeScript 핵심 개념 — Spring Boot 개발자를 위한 가이드
들어가며
Spring Boot에서 NestJS로 전환하면서 가장 먼저 마주하게 되는 것은 언어의 차이입니다. Java와 TypeScript는 모두 타입 시스템을 가진 언어지만, 그 철학과 동작 방식은 완전히 다릅니다.
1. 컴파일 타임 vs 런타임: 타입의 생존 기간
Java: 타입이 런타임까지 살아남는다
public class User {
private Long id;
private String name;
}
// 컴파일 후에도 타입 정보가 남아있음 (리플렉션 가능)
Class<?> clazz = User.class;
Field field = clazz.getDeclaredField("id");
System.out.println(field.getType()); // class java.lang.Long
TypeScript: 타입은 컴파일 타임에만 존재
// TypeScript
class User {
id: number;
name: string;
}
// 컴파일 후 JavaScript (타입 완전히 사라짐)
class User {
// id와 name의 타입 정보 없음!
}
// 런타임에 타입 확인 불가능
const user = new User();
console.log(typeof user.id); // "undefined" (초기화 안 됨)
핵심 차이점
구분 Java TypeScript
| 타입의 역할 | 설계 도구 + 런타임 보장 | 설계 도구 (컴파일 타임 only) |
| 런타임 타입 정보 | Reflection으로 접근 가능 | 완전히 소멸됨 |
| 런타임 검증 | Bean Validation, Jackson 등이 자동 처리 | Zod, class-validator 등 별도 라이브러리 필요 |
보완: NestJS에서는 reflect-metadata + 데코레이터(@Field, @Column 등)를 통해 제한적으로 런타임 메타데이터를 유지합니다. 이것이 class-transformer/class-validator가 동작할 수 있는 이유입니다. 순수 TypeScript interface는 런타임에 완전히 사라지지만, class + 데코레이터 조합은 메타데이터를 남깁니다.
2. null vs undefined: Java에는 없는 개념
Java: null만 존재
String name = null; // 명시적으로 null 할당
// undefined라는 개념 자체가 없음
TypeScript: null과 undefined는 다르다
let name1: string | null = null; // 명시적으로 "값이 없음"
let name2: string | undefined = undefined; // 초기화되지 않음
let name3: string; // 선언만 하면 undefined
// 실무 구분
class User {
id: number; // 필수 (undefined 불가)
email?: string; // 선택적 필드 (undefined 허용)
deletedAt: Date | null; // 명시적으로 null 사용 (soft delete)
}
실무 사용 가이드
상황 사용할 것 이유
| 값이 없음을 명시 | null | 의도적인 빈 값 (DB의 NULL과 매핑) |
| 선택적 필드 | ? (undefined) | DTO, API 파라미터 등 |
| 초기화 안 됨 | undefined | 시스템 레벨 (변수 선언만 한 상태) |
보완: 실무에서 DB 컬럼의 NULL은 null로, API 요청에서 생략된 필드는 undefined로 매핑하는 게 일반적입니다. 이 구분은 GraphQL에서 특히 중요합니다 — "필드를 안 보냄(undefined)" vs "필드를 null로 보냄(null)"은 Update Mutation에서 다른 의미를 가질 수 있습니다.
3. 동기 vs 비동기: 사고방식의 전환
Java/Spring: 동기적 사고 (Thread-per-Request)
@Service
public class UserService {
public User getUser(Long id) {
// 1. DB 조회 (블로킹 — 이 스레드가 응답 올 때까지 대기)
User user = userRepository.findById(id).orElseThrow();
// 2. 외부 API 호출 (블로킹)
Profile profile = profileClient.getProfile(user.getProfileId());
// 3. 결합
user.setProfile(profile);
return user;
}
}
// 총 소요 시간: DB 조회 시간 + API 호출 시간 (순차)
TypeScript/NestJS: 이벤트 루프 사고
// ❌ 순차 실행 (느림)
@Injectable()
export class UserService {
async getUser(id: number): Promise<User> {
const user = await this.userRepository.findById(id); // 1초
const profile = await this.profileClient.getProfile(user.profileId); // 1초
user.profile = profile;
return user; // 총 2초
}
}
// ✅ 병렬 실행 (빠름) — 두 작업이 독립적일 때만 가능
@Injectable()
export class UserService {
async getUser(id: number): Promise<User> {
const [user, profiles] = await Promise.all([
this.userRepository.findById(id), // 1초
this.profileClient.getProfiles([id]) // 1초
]);
user.profile = profiles[0];
return user; // 총 1초 (max)
}
}
성능 차이
실행 방식 소요 시간 사용 조건
| 순차 실행 (await 연속) | DB 1초 + API 1초 = 2초 | 두 번째 작업이 첫 번째 결과에 의존할 때 |
| 병렬 실행 (Promise.all) | max(DB 1초, API 1초) = 1초 | 두 작업이 서로 독립적일 때만 |
주의: 병렬 실행이 항상 가능한 것은 아닙니다. User 정보를 조회해야 그 User의 profileId를 알 수 있는 경우처럼, 의존 관계가 있으면 순차 실행이 필수입니다. 무조건 Promise.all을 쓰는 게 아니라, 데이터 의존성을 분석해서 적절히 사용해야 합니다.
4. 타입 안전성: 컴파일 타임의 거짓말
문제 상황: 외부 API 응답
// ⚠️ 위험한 코드
interface User {
id: number;
email: string;
balance: number;
}
async function fetchUser(id: number): Promise<User> {
const response = await fetch(`/api/users/${id}`);
return response.json() as User; // 타입 체크 없이 User로 간주!
}
// 만약 API가 { id: "123", balance: "1000원" }을 반환하면?
const user = await fetchUser(1);
const newBalance = user.balance + 1000; // NaN 발생! (string + number)
해결책: 런타임 검증 (Zod)
import { z } from 'zod';
// 스키마 정의 (컴파일 + 런타임 검증)
const UserSchema = z.object({
id: z.number().int().positive(),
email: z.string().email(),
balance: z.number().nonnegative()
});
// 스키마로부터 타입 추론 (DRY — 타입을 두 번 정의하지 않음)
type User = z.infer<typeof UserSchema>;
// ✅ 안전한 코드
async function fetchUser(id: number): Promise<User> {
const response = await fetch(`/api/users/${id}`);
const data: unknown = await response.json();
// 런타임 검증 — API 응답이 스키마와 다르면 ZodError 발생
return UserSchema.parse(data);
}
Java와의 비교
// Java는 Jackson + Bean Validation으로 역직렬화 + 검증을 동시에 처리
@Data
public class User {
@NotNull
@Positive
private Long id;
@Email
private String email;
@DecimalMin("0.0")
private BigDecimal balance;
}
// Jackson이 역직렬화 시 타입 불일치 감지 + @Valid로 검증
User user = objectMapper.readValue(json, User.class);
보완: 위 Java 코드에서 @NotNull, @Email 등은 Entity가 아닌 **DTO(Request/Response)**에 붙이는 것이 올바른 패턴입니다. Entity에는 도메인 불변식을, DTO에는 입력 검증을 분리하는 것이 클린 아키텍처의 기본입니다.
5. any vs unknown vs object: 타입 안전성 레벨
// ❌ any: 타입 시스템 포기 (사용 금지)
let value: any;
value.nonExistent(); // 컴파일 OK, 런타임 에러
// ✅ unknown: 안전한 any (외부 데이터용)
let value: unknown;
// value.toUpperCase(); // 컴파일 에러! — 타입 좁히기(Narrowing) 필요
if (typeof value === 'string') {
value.toUpperCase(); // OK — 타입이 string으로 좁혀짐
}
// object: primitive(string, number, boolean 등) 제외한 모든 것
let obj: object = { name: 'Alice' }; // OK
obj = [1, 2, 3]; // OK (배열도 object)
obj = 42; // ❌ 컴파일 에러
// {}: null/undefined 제외한 모든 값 (헷갈림 주의!)
let empty: {} = { name: 'Alice' }; // OK
empty = 42; // OK (!) — primitive도 허용
empty = null; // ❌ 컴파일 에러
실무 가이드
상황 사용할 타입 이유
| 외부 API 응답 | unknown | 런타임 검증을 강제하기 위해 |
| 타입을 모를 때 | unknown | any 대신 안전한 대안 |
| Non-nullable 값 | {} | null/undefined를 제외한 모든 값 |
| 금지 | any | 타입 시스템의 모든 보호를 무력화 |
6. Optional Chaining과 Nullish Coalescing
Java 방식
// 전통적 null 체크
String city = null;
if (user != null && user.getAddress() != null) {
city = user.getAddress().getCity();
}
// Java 8+ Optional
String city = Optional.ofNullable(user)
.map(User::getAddress)
.map(Address::getCity)
.orElse("Unknown");
TypeScript 방식
// Optional Chaining (?.)
const city = user?.address?.city;
// user나 address가 null/undefined면 → undefined 반환 (에러 안 남)
// Nullish Coalescing (??)
const displayCity = user?.address?.city ?? 'Unknown';
// null 또는 undefined일 때만 기본값 사용
// ⚠️ || 연산자와의 중요한 차이
const port1 = config.port || 3000; // 0도 falsy라서 3000 반환 (버그!)
const port2 = config.port ?? 3000; // 0은 그대로 0 반환, null/undefined만 3000
핵심: ||는 falsy 값(0, "", false, null, undefined) 전체를 기본값으로 대체하고, ??는 null과 undefined만 대체합니다. 숫자/문자열 기본값에는 항상 ??를 쓰세요.
7. Promise와 async/await: 비동기의 핵심
"await는 느린가?" — 흔한 오해
// await 자체가 느린 게 아니라, "순차 실행 구조"가 느린 것
async function fetchUserData(id: number) {
// ❌ 순차 실행 — 3초 소요
const user = await fetch(`/users/${id}`); // 1초
const posts = await fetch(`/users/${id}/posts`); // 1초
const friends = await fetch(`/users/${id}/friends`); // 1초
}
async function fetchUserData(id: number) {
// ✅ 병렬 실행 — 1초 소요
const [user, posts, friends] = await Promise.all([
fetch(`/users/${id}`),
fetch(`/users/${id}/posts`),
fetch(`/users/${id}/friends`)
]);
}
Promise.all vs Promise.allSettled
// Promise.all: 하나라도 실패하면 전체 실패 (Fast-fail)
try {
const results = await Promise.all([task1, task2, task3]);
} catch (error) {
// 첫 번째로 실패한 task의 에러만 잡힘
// 나머지 task는 계속 실행되지만 결과를 받을 수 없음
}
// Promise.allSettled: 모든 결과를 수집 (실패해도 계속 진행)
const results = await Promise.allSettled([task1, task2, task3]);
// [
// { status: 'fulfilled', value: ... },
// { status: 'rejected', reason: ... },
// { status: 'fulfilled', value: ... }
// ]
// 실무 사용 예시: 부분 실패를 허용하는 배치 작업
results.forEach((result, index) => {
if (result.status === 'rejected') {
logger.error(`Task ${index} failed:`, result.reason);
}
});
사용 기준
상황 사용할 것 이유
| 모든 작업이 성공해야 의미 있을 때 | Promise.all | 하나라도 실패하면 전체 롤백 |
| 부분 실패를 허용할 때 | Promise.allSettled | 성공한 것만 처리, 실패는 로깅 |
| 가장 빠른 하나만 필요할 때 | Promise.race | 타임아웃 구현 등 |
정리: Java vs TypeScript 핵심 비교표
항목 Java TypeScript
| 타입 생존 범위 | 런타임까지 유지 (Reflection 가능) | 컴파일 타임에만 존재 (런타임 소멸) |
| null 처리 | null만 존재 | null + undefined (서로 다른 의미) |
| 비동기 모델 | Thread 기반 (Blocking 기본, WebFlux로 Non-blocking) | Event Loop 기반 (Non-blocking 기본) |
| 타입 안전성 | 컴파일 + 런타임 | 컴파일만 (런타임 검증은 별도 라이브러리) |
| Optional 표현 | Optional<T> | T | undefined 또는 prop?: T |
| 에러 처리 | Checked / Unchecked Exception | Promise rejection / throw |
| 동시성 모델 | 멀티 스레드 | 싱글 스레드 + 비동기 (이벤트 루프) |
| I/O 처리 | Thread 점유 (전통), Non-blocking(WebFlux) | 이벤트 기반 위임 (기본) |
| 타입 강제성 | 명목적 타입 (Nominal) — 이름이 같아야 호환 | 구조적 타입 (Structural) — 형태가 같으면 호환 |
| 런타임 타입 검사 | instanceof, Reflection | typeof, instanceof(class만), Zod/io-ts |
| 인터페이스 역할 | 런타임 계약 (implements 강제) | 컴파일 타임 계약 (런타임에 소멸) |
| 빌드 산출물 | 바이트코드 (.class) | 순수 JavaScript (.js) |
| 대표적 실패 유형 | 컴파일 에러 / 부팅 시 Bean 생성 실패 | 런타임 데이터 타입 불일치 |
다음으로 공부해 볼 것
- NestJS 모듈 시스템: Spring @ComponentScan(자동 스캔) vs NestJS imports(명시적 선언)의 차이
- 의존성 주입(DI): Java Reflection vs TypeScript reflect-metadata + 데코레이터
- 구조적 타입(Structural Typing): Java의 명목적 타입 시스템과 TypeScript의 "덕 타이핑"이 실무에서 어떤 차이를 만드는지
- TypeScript 제네릭: Java 제네릭과의 공통점(컴파일 타임 타입 안전성)과 차이점(타입 소거 방식)
'Language > TypeScript' 카테고리의 다른 글
| [TypeScript] TS Config, ES Lint --> 런타임 장애를 막는 안전한 방법 (0) | 2026.03.25 |
|---|