현실 세계와 연결하기: 비동기, 모듈, 외부 라이브러리, API 데이터
지금까지 우리는 타입을 만들고, 조립하고, 다듬는 법을 배웠습니다. 이제 진짜 세상으로 나갈 시간입니다. 진짜 세상에는 느린 네트워크가 있고, 다른 사람이 만든 라이브러리가 있고, 생김새가 들쑥날쑥한 API 응답이 있습니다.
여기서 TypeScript의 진가가 드러납니다. 실무의 TypeScript는 "문법 시험"이 아니라, 외부 세계와 안전하게 연결하는 기술입니다.
이 장에서는 네 가지를 다룹니다. 기다리는 작업을 다루는 비동기, 파일을 나눠 협업하기 위한 모듈, 외부 라이브러리와 타입 연결하기, 그리고 믿을 수 없는 API 데이터를 안전하게 다루는 방법입니다.
비동기란 무엇일까요?
프로그램은 늘 즉시 끝나는 일만 하지 않습니다. 서버에서 데이터를 받아오거나, 파일을 읽거나, 사용자의 클릭을 기다리는 일은 시간이 걸립니다. 이런 작업을 기다리는 동안 프로그램 전체가 멈춰 버리면 답답하겠죠. 그래서 자바스크립트와 타입스크립트는 비동기 처리를 사용합니다.
비동기의 핵심은 Promise입니다. Promise<T>는 "나중에 T 타입의 값을 줄게"라는 약속이라고 생각하면 됩니다. 그리고 async/await는 그 약속을 더 읽기 쉬운 문장으로 다루게 해주는 문법입니다.1
아주 작은 예제로 감을 잡아봅시다.
function delay(ms: number): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, ms)
})
}
async function makeToast(): Promise<string> {
await delay(1000)
return "따끈한 토스트 완성!"
}여기서 중요한 점이 하나 있습니다. async가 붙은 함수는 항상 Promise를 반환합니다. 겉으로는 문자열을 return하는 것처럼 보여도, 실제로는 Promise<string>이 됩니다.1
즉, 아래 두 생각은 거의 같은 방향입니다.
"지금 문자열을 돌려준다"
"잠시 뒤 문자열이 준비되면 돌려준다"
비동기 세계에서는 두 번째가 더 흔합니다.
Promise를 읽는 법
Promise는 성공할 수도 있고, 실패할 수도 있습니다. 그래서 보통 .then()으로 성공을, .catch()로 실패를 처리합니다.
function fetchMessage(): Promise<string> {
return new Promise((resolve, reject) => {
const success = true
setTimeout(() => {
if (success) {
resolve("서버에서 인사를 보냈습니다")
} else {
reject(new Error("메시지를 가져오지 못했습니다"))
}
}, 500)
})
}
fetchMessage()
.then((message) => {
console.log(message.toUpperCase())
})
.catch((error) => {
console.error(error.message)
})이 방식도 좋지만, 흐름이 길어지면 층층이 쌓인 양파처럼 눈물이 날 수 있습니다. 그래서 등장하는 것이 async/await입니다.
async/await: 비동기를 순서대로 읽는 느낌으로
await는 "이 약속이 끝날 때까지 여기서 결과를 기다릴게"라는 뜻입니다. 덕분에 비동기 코드가 동기 코드처럼 왼쪽부터 차분히 읽힙니다.1
interface User {
id: number
name: string
email: string
}
async function fetchUser(id: number): Promise<User> {
const response = await fetch(`https://jsonplaceholder.typicode.com/users/${id}`)
if (!response.ok) {
throw new Error(`요청 실패: ${response.status}`)
}
const data = (await response.json()) as User
return data
}
async function printUser(): Promise<void> {
try {
const user = await fetchUser(1)
console.log(`${user.name} <${user.email}>`)
} catch (error) {
if (error instanceof Error) {
console.error("사용자 조회 실패:", error.message)
}
}
}읽기가 편해졌죠. 다만 여기서 한 가지 조심해야 합니다. as User는 "정말 User일 거야"라고 컴파일러를 설득하는 말이지, 실제 데이터를 검사하는 마법 주문은 아닙니다. 이 문제는 뒤에서 제대로 다루겠습니다.
에러 처리는 try/catch가 기본입니다
비동기 작업도 실패합니다. 서버가 잠깐 누워 있을 수도 있고, 인터넷이 끊길 수도 있고, 응답 형식이 바뀔 수도 있습니다. 그래서 await를 쓸 때는 try/catch를 자연스럽게 떠올리는 습관이 좋습니다.1
async function loadProfile(): Promise<void> {
try {
const user = await fetchUser(1)
console.log("프로필 로드 성공:", user.name)
} catch (error) {
if (error instanceof Error) {
console.error("프로필 로드 실패:", error.message)
return
}
console.error("알 수 없는 오류가 발생했습니다")
}
}초보자가 자주 하는 실수는 "에러는 나중에 생각하지 뭐"입니다. 하지만 비동기 코드에서 에러 처리를 미루면, 나중에 콘솔에 뜬 메시지가 마치 수수께끼처럼 보이기 시작합니다. 수수께끼는 소설에서나 재밌지, 배포 직전에는 별로 반갑지 않습니다.
순서대로 기다릴까, 한꺼번에 보낼까?
비동기 코드에서 성능 차이를 만드는 큰 포인트는 순차 실행과 병렬 실행의 구분입니다.
예를 들어 상품 3개를 가져온다고 해봅시다.
interface Product {
id: number
name: string
price: number
}
async function fetchProduct(id: number): Promise<Product> {
const response = await fetch(`https://example.com/products/${id}`)
if (!response.ok) {
throw new Error("상품 조회 실패")
}
return (await response.json()) as Product
}이렇게 작성하면 순차 실행입니다.
async function loadProductsSlow(): Promise<Product[]> {
const p1 = await fetchProduct(1)
const p2 = await fetchProduct(2)
const p3 = await fetchProduct(3)
return [p1, p2, p3]
}하나가 끝나야 다음 것이 시작되니, 세 번 줄을 섭니다. 상품들이 서로 독립적이라면 동시에 출발시키는 편이 낫습니다.
async function loadProductsFast(): Promise<Product[]> {
const products = await Promise.all([
fetchProduct(1),
fetchProduct(2),
fetchProduct(3),
])
return products
}Promise.all은 여러 비동기 작업을 한꺼번에 실행하고, 전부 끝나면 결과를 배열로 돌려줍니다. 대신 하나라도 실패하면 전체가 실패합니다. 반대로 성공과 실패를 모두 보고 싶다면 Promise.allSettled를 고려할 수 있습니다.2
정리하면 이렇습니다.
앞선 결과가 다음 작업에 필요하면 순차 실행
서로 독립적인 작업이면
Promise.all일부 실패해도 전체 결과를 보고 싶으면
Promise.allSettled
실무에서는 이 구분 하나만 잘해도 코드가 꽤 똑똑해집니다.
취소도 실전입니다: AbortController
사용자가 페이지를 떠났는데도 예전 요청이 계속 달리고 있으면 어떨까요? 검색창에 글자를 빠르게 입력하는데 이전 요청이 늦게 도착하면 어떨까요? 이럴 때는 요청 취소가 중요합니다.
fetch와 함께 자주 쓰는 도구가 AbortController입니다.6
async function fetchWithCancel(url: string): Promise<unknown> {
const controller = new AbortController()
const timeoutId = setTimeout(() => {
controller.abort()
}, 3000)
try {
const response = await fetch(url, {
signal: controller.signal,
})
if (!response.ok) {
throw new Error(`HTTP 오류: ${response.status}`)
}
return await response.json()
} finally {
clearTimeout(timeoutId)
}
}이 패턴은 "3초 안에 안 오면 취소" 같은 타임아웃 처리에도 유용합니다. 사용자는 기다림이 길어지는 것을 싫어하고, 서버도 불필요한 요청을 덜 좋아합니다. 양쪽 모두에게 예의 바른 코드죠.
모듈: 파일을 나누고 연결하는 방법
코드가 한 파일에 다 들어 있으면 처음엔 편합니다. 하지만 파일이 커질수록 찾기 어렵고, 재사용도 힘들고, 충돌도 생깁니다. 그래서 우리는 코드를 파일 단위로 나눕니다. 이때 사용하는 것이 모듈입니다.
타입스크립트에서 파일에 최상위 import나 export가 있으면 그 파일은 모듈로 취급됩니다. 모듈 안의 변수와 함수는 기본적으로 그 파일 안에서만 보이고, 밖으로 내보내려면 export, 가져오려면 import를 사용합니다.3
예를 들어 math.ts 파일이 있다고 합시다.
// math.ts
export const pi = 3.14
export function add(a: number, b: number): number {
return a + b
}다른 파일에서는 이렇게 가져옵니다.
// app.ts
import { pi, add } from "./math"
console.log(pi)
console.log(add(2, 3))모듈의 좋은 점은 단순합니다. 필요한 것만 보여주고, 나머지는 숨길 수 있다는 점입니다. 협업할 때 특히 강력합니다.
export와 import의 자주 쓰는 형태
모듈 문법은 생각보다 단순합니다. 가장 자주 만나는 형태만 익혀도 실전에서 충분히 버틸 수 있습니다.
먼저 이름 있는 내보내기(named export) 입니다.
// user.ts
export interface User {
id: number
name: string
}
export function formatUser(user: User): string {
return `${user.id}번 - ${user.name}`
}import { formatUser, type User } from "./user"
const user: User = { id: 1, name: "김타입" }
console.log(formatUser(user))여기서 type User처럼 가져오는 문법에 주목해 봅시다. import type 또는 type 한정자는 타입만 가져온다는 뜻입니다. 값으로 쓰지 않을 타입이라면 이렇게 표시해 두는 편이 더 분명하고, 빌드 도구가 불필요한 코드를 정리하는 데도 도움이 됩니다.3
이번에는 기본 내보내기(default export) 입니다.
// logger.ts
export default function log(message: string): void {
console.log(`[LOG] ${message}`)
}import log from "./logger"
log("모듈 연결 완료")기본 내보내기는 파일당 하나만 둘 수 있습니다. 그래서 "이 파일의 대표 기능이 딱 하나다" 싶을 때 잘 어울립니다.
모듈을 잘 나누는 감각
모듈은 무조건 잘게 쪼갠다고 좋은 게 아닙니다. 너무 잘게 나누면 파일 찾다가 오후가 사라집니다. 반대로 너무 크게 뭉치면 거대한 잡동사니 상자가 됩니다.
초보자에게 추천하는 기준은 이렇습니다.
한 파일은 한 가지 주제에 집중하기
타입 정의와 기능을 자연스럽게 함께 두기
여러 파일의 공개 입구가 필요하면
index.ts로 다시 내보내기
예를 들어 이렇게 구성할 수 있습니다.
src/
api/
users.ts
posts.ts
index.ts
models/
user.ts
post.ts
utils/
date.ts그리고 api/index.ts에서 다시 내보내면, 사용하는 쪽은 더 깔끔해집니다.
// api/index.ts
export * from "./users"
export * from "./posts"import { fetchUsers, fetchPosts } from "./api"이런 파일을 흔히 배럴 파일(barrel file) 이라고 부릅니다. 여러 문을 하나의 현관으로 모아 주는 느낌입니다.
외부 라이브러리와 만날 때: 타입 정보부터 확인하세요
실무에서는 모든 기능을 직접 만들지 않습니다. 날짜 처리, HTTP 요청, 유틸리티 함수, UI 컴포넌트 같은 것들은 외부 라이브러리를 자주 씁니다.
문제는 "이 라이브러리, 타입스크립트가 이해하나요?"입니다.
요즘 많은 라이브러리는 타입 정보를 자체적으로 포함합니다. 이 경우 그냥 설치해서 쓰면 됩니다. 하지만 타입 정보가 없는 자바스크립트 라이브러리라면 @types/라이브러리이름 패키지를 함께 설치해야 할 수 있습니다. 이 타입 패키지들은 보통 DefinitelyTyped 생태계에서 제공됩니다.4
예를 들어 lodash를 쓴다면 이렇게 할 수 있습니다.
npm install lodash
npm install -D @types/lodash그리고 코드에서는 이렇게 사용합니다.
import _ from "lodash"
const numbers = [10, 20, 30]
const total = _.sum(numbers)
console.log(total)타입 정보가 있으면 자동 완성도 좋아지고, 잘못된 인수도 빨리 잡을 수 있습니다. 라이브러리와 손을 잡을 때 장갑을 끼는 셈입니다.
타입이 없는 라이브러리는 어떻게 할까요?
간단한 라이브러리라면 직접 선언 파일을 만들 수도 있습니다. 선언 파일은 보통 .d.ts 확장자를 사용합니다.
// types/confetti-party.d.ts
declare module "confetti-party" {
export function fire(count: number): void
}이제 타입스크립트는 confetti-party라는 모듈이 있고, 그 안에 fire 함수가 있다는 사실을 알게 됩니다.
import { fire } from "confetti-party"
fire(30)물론 실제 라이브러리 API와 선언이 다르면 위험합니다. 그래서 직접 타입을 만들 때는 라이브러리 문서를 꼭 확인해야 합니다.4
API 데이터는 믿지 말고 확인하세요
이 장에서 가장 중요한 문장입니다.
외부에서 들어오는 데이터는 타입 선언만으로 안전해지지 않습니다.
왜냐하면 타입은 컴파일 시점의 약속이고, API 응답은 런타임의 현실이기 때문입니다. 서버가 실수로 name 대신 fullName을 보낼 수도 있고, 숫자여야 할 id가 문자열로 올 수도 있습니다.
그래서 response.json() 결과를 곧바로 as User로 단정하는 습관은 편하지만 위험합니다.5
interface User {
id: number
name: string
email: string
}
async function fetchUserUnsafe(): Promise<User> {
const response = await fetch("https://example.com/user")
const data = (await response.json()) as User
return data
}이 코드는 컴파일은 잘 되지만, 실제 데이터가 다르면 실행 중에 터질 수 있습니다.
더 안전한 방법은 unknown으로 받고, 검사한 뒤 좁히는 것입니다.
type User = {
id: number
name: string
email: string
}
function isUser(value: unknown): value is User {
if (typeof value !== "object" || value === null) {
return false
}
const obj = value as Record<string, unknown>
return (
typeof obj.id === "number" &&
typeof obj.name === "string" &&
typeof obj.email === "string"
)
}
async function fetchUserSafe(id: number): Promise<User> {
const response = await fetch(`https://example.com/users/${id}`)
if (!response.ok) {
throw new Error(`요청 실패: ${response.status}`)
}
const data: unknown = await response.json()
if (!isUser(data)) {
throw new Error("API 응답 형식이 예상과 다릅니다")
}
return data
}이제야 비로소 TypeScript가 진짜 빛납니다. 외부 데이터는 처음엔 낯선 손님으로 받고, 검사한 뒤 우리 집 규칙에 맞는 손님인지 확인하는 겁니다.
배열 데이터도 같은 원리입니다
실제로는 단일 객체보다 목록 데이터를 더 많이 받습니다. 예를 들어 게시글 목록이라면 이렇게 검사할 수 있습니다.
type Post = {
id: number
title: string
}
function isPost(value: unknown): value is Post {
if (typeof value !== "object" || value === null) {
return false
}
const obj = value as Record<string, unknown>
return typeof obj.id === "number" && typeof obj.title === "string"
}
function isPostArray(value: unknown): value is Post[] {
return Array.isArray(value) && value.every(isPost)
}
async function fetchPosts(): Promise<Post[]> {
const response = await fetch("https://example.com/posts")
const data: unknown = await response.json()
if (!isPostArray(data)) {
throw new Error("게시글 목록 형식이 올바르지 않습니다")
}
return data
}귀찮아 보여도, 이 검사가 나중에 "왜 여기서 title.toUpperCase()가 터지지?" 같은 혼란을 크게 줄여 줍니다.
실전 예제: API, 타입, 모듈을 함께 써 보기
이제 작은 구조로 묶어 봅시다.
먼저 타입 파일입니다.
// models/todo.ts
export interface Todo {
userId: number
id: number
title: string
completed: boolean
}다음은 타입 검사 함수입니다.
// api/guards.ts
import type { Todo } from "../models/todo"
export function isTodo(value: unknown): value is Todo {
if (typeof value !== "object" || value === null) {
return false
}
const obj = value as Record<string, unknown>
return (
typeof obj.userId === "number" &&
typeof obj.id === "number" &&
typeof obj.title === "string" &&
typeof obj.completed === "boolean"
)
}이제 실제 API 함수입니다.
// api/todos.ts
import type { Todo } from "../models/todo"
import { isTodo } from "./guards"
export async function fetchTodo(id: number): Promise<Todo> {
const response = await fetch(`https://jsonplaceholder.typicode.com/todos/${id}`)
if (!response.ok) {
throw new Error(`할 일 조회 실패: ${response.status}`)
}
const data: unknown = await response.json()
if (!isTodo(data)) {
throw new Error("할 일 데이터 형식이 잘못되었습니다")
}
return data
}마지막으로 사용하는 쪽입니다.
// main.ts
import { fetchTodo } from "./api/todos"
async function main(): Promise<void> {
try {
const todo = await fetchTodo(1)
console.log(todo.title)
console.log(todo.completed ? "완료" : "미완료")
} catch (error) {
if (error instanceof Error) {
console.error(error.message)
}
}
}
main()이 예제에는 이 장의 핵심이 다 들어 있습니다. 모듈로 나누고, import/export로 연결하고, 비동기로 데이터를 받고, 타입 가드로 외부 데이터를 검증합니다. 이게 바로 "현실 세계와 연결하는 TypeScript"입니다.
자주 만나는 실수들
초보자들이 이 장에서 특히 자주 만나는 실수를 한 번 정리해 봅시다.
async함수가 일반 값을 반환한다고 착각하기await를 반복문 안에 무심코 넣어 순차 실행을 만들어 버리기response.ok확인 없이 바로json()으로 가기as 타입만 믿고 실제 데이터 검증을 생략하기타입이 없는 외부 라이브러리를 그냥
any로 밀어붙이기
이 다섯 가지만 피해도 실전 사고율이 꽤 떨어집니다.
기억해 둘 핵심 문장
이 장을 한 문장씩만 챙겨 간다면 다음이면 충분합니다.
async함수는 항상Promise를 반환한다.독립적인 비동기 작업은
Promise.all을 고려한다.파일을 나눌 때는
export하고, 사용할 때는import한다.외부 라이브러리는 타입 정보가 있는지 먼저 확인한다.
API 응답은 선언보다 검증이 먼저다.
TypeScript는 외부 세계를 없애 주지 않습니다. 대신 외부 세계가 몰고 오는 불확실함을 보이는 문제로 바꿔 줍니다. 그리고 보이는 문제는 고칠 수 있습니다.
다음 장에서는 이런 코드를 더 튼튼하게 만드는 습관을 배웁니다. strict 모드, tsconfig, 디버깅, 테스트, 마이그레이션이 등장합니다. 이제 TypeScript는 단지 "타입 문법"이 아니라, 프로젝트 전체를 지탱하는 작업 방식으로 커지기 시작합니다.
참고
1A guide to async/await in TypeScript
4Using Third-Party Libraries in TypeScript
5Making REST API calls with TypeScript
6TypeScript Async/Await: Complete Guide to Modern Asynchronous Code
이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.