[TypeScript] 타입스크립트를 공부해 보자 ..

2026. 1. 26. 14:11·Language/TypeScript

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
'Language/TypeScript' 카테고리의 다른 글
  • [TypeScript] TS Config, ES Lint --> 런타임 장애를 막는 안전한 방법
하가네
하가네
  • 하가네
    하 렌
    하가네
  • 전체
    오늘
    어제
    • 분류 전체보기 (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)
  • 블로그 메뉴

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

  • 공지사항

  • 인기 글

  • 태그

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

  • 최근 글

  • hELLO· Designed By정상우.
하가네
[TypeScript] 타입스크립트를 공부해 보자 ..
상단으로

티스토리툴바