🔧 lib/books.ts 한 줄씩 만들기 — CLI 로 확인하며 배우기

이 글의 목적
book-api-tutorial 본 튜토리얼(TUTORIAL.md)의 STEP 2 에서 lib/books.ts 를 한 번에 보여줬어요. 짧은 파일이지만 처음 보면 익숙하지 않은 문법이 한꺼번에 나옵니다.
Omit<Book, "id">같은 타입- 배열의
find,findIndex globalThis캐스팅!(non-null assertion)
이 보조 튜토리얼에서는 같은 파일을 6 단계 로 잘라서, 각 단계마다 터미널 에서 동작을 직접 확인하며 만듭니다. 매 단계 끝에는 항상 같은 명령이 등장합니다:
npx tsx scripts/test-books.ts
이 한 줄로 우리가 만든 코드를 즉시 실행해 결과를 봅니다. 페이지를 새로고침하거나 Next.js 서버를 띄울 필요가 없어요.
📁 작업 위치:
book-api-tutorial/프로젝트 안에서 그대로 작업합니다. 별도 폴더를 만들지 않아요.
마지막엔lib/books.ts가 본 튜토리얼과 똑같은 형태가 됩니다.
사전 준비
book-api-tutorial 폴더가 이미 있다는 전제로 시작합니다.
0-1. TypeScript 실행 도구 설치
.ts 파일을 컴파일 없이 바로 실행하는 도구 tsx 를 추가합니다.
cd book-api-tutorial npm install -D tsx
-D 는 "개발용 의존성" 이라는 표시예요 (실제 빌드에는 안 들어감).
0-2. scripts 폴더 만들기
이번 보조 튜토리얼에서만 쓸 테스트 스크립트를 둘 폴더입니다.
mkdir scripts
폴더 구조는 이렇게 됩니다.
book-api-tutorial/ ├─ app/ ← 기존 ├─ lib/ │ └─ books.ts ← 단계별로 다시 만들 파일 (지금은 완성본 상태) ├─ scripts/ ← 새로 추가 │ └─ test-books.ts ← CLI 에서 실행할 테스트 ├─ package.json └─ tsconfig.json ← @/* 별칭이 이미 설정돼 있어요
tsx 가 잘 동작하는지 한 번 확인해 봅니다. 임시 파일을 하나 만들고:
echo 'console.log("hello", { from: "tsx" });' > scripts/smoke.ts npx tsx scripts/smoke.ts # 출력: hello { from: 'tsx' }
확인이 됐으면 임시 파일은 지웁니다.
rm scripts/smoke.ts
0-3. 기존 lib/books.ts 백업 (선택)
이미 본 튜토리얼을 따라 만든 lib/books.ts 가 있다면, 단계별로 덮어쓰게 되니 미리 어딘가에 복사해 두면 마음이 편합니다.
cp lib/books.ts /tmp/books-original-backup.ts
이제 빈 마음으로 다시 시작합니다.
STEP 1. 타입과 초기 데이터만
가장 단순한 형태로 시작합니다. 함수도 없고, 데이터만 있는 상태.
📄 lib/books.ts 전체를 다음으로 교체:
export type Book = { id: number; title: string; author: string; publishedYear: number; }; export const books: Book[] = [ { id: 1, title: "어린 왕자", author: "생텍쥐페리", publishedYear: 1943 }, { id: 2, title: "데미안", author: "헤르만 헤세", publishedYear: 1919 }, { id: 3, title: "1984", author: "조지 오웰", publishedYear: 1949 }, ];
📄 scripts/test-books.ts 새로 만들기:
import { books } from "@/lib/books"; console.log("=== 초기 데이터 ==="); console.log(books); console.log("개수:", books.length);
풀어 보기
🔹 export type Book
"Book 이라는 타입을 만들었고, 다른 파일에서도 쓸 수 있어" 라는 뜻. 객체 한 권의 모양을 미리 적어 두면 오타가 났을 때 TypeScript 가 빨간 줄로 알려줍니다.
🔹 export const books: Book[] = [...]
export: 다른 파일에서 import 해서 쓸 수 있다.const: 이 변수의 이름 은 다른 배열로 못 바꾼다. 단, 배열 안의 내용은 바꿀 수 있다 (books.push(...)같은 건 가능).: Book[]: "Book 의 배열" 이라는 타입.
🔹 import { books } from "@/lib/books"
@/ 는 프로젝트 루트를 가리키는 별칭입니다. book-api-tutorial/tsconfig.json 에 "@/*": ["./*"] 로 설정돼 있어 tsx 도 이를 인식해요. 그래서 어디서 import 하든 @/lib/books 한 가지 표현으로 통일됩니다.
✅ 확인하기
npx tsx scripts/test-books.ts
출력:
=== 초기 데이터 === [ { id: 1, title: '어린 왕자', author: '생텍쥐페리', publishedYear: 1943 }, { id: 2, title: '데미안', author: '헤르만 헤세', publishedYear: 1919 }, { id: 3, title: '1984', author: '조지 오웰', publishedYear: 1949 } ] 개수: 3
3권의 책이 보입니다. 다음 단계에서 함수를 추가합니다.
실수 1:
export를 빼먹고const books = [...]만 쓰면, 다른 파일에서 못 가져옵니다.실수 2: 객체에 필드를 빠뜨리면 (예:
publishedYear없이) 빨간 줄이 그어집니다.
STEP 2. getAllBooks() 함수
데이터를 직접 export 하지 않고, 함수로 한 번 감싸서 돌려주는 방식으로 바꿉니다. 나중에 데이터 저장 방식이 바뀌어도(예: globalThis → DB) 함수만 같은 모양이면 바깥 코드는 안 바뀌어요.
📄 lib/books.ts 를 통째로 교체:
export type Book = { id: number; title: string; author: string; publishedYear: number; }; const books: Book[] = [ { id: 1, title: "어린 왕자", author: "생텍쥐페리", publishedYear: 1943 }, { id: 2, title: "데미안", author: "헤르만 헤세", publishedYear: 1919 }, { id: 3, title: "1984", author: "조지 오웰", publishedYear: 1949 }, ]; export function getAllBooks(): Book[] { return books; }
바뀐 곳:
export const books의export를 떼고, 대신getAllBooks()함수를 export 했습니다.
📄 scripts/test-books.ts:
import { getAllBooks } from "@/lib/books"; console.log("=== 모든 책 ==="); console.log(getAllBooks()); console.log("개수:", getAllBooks().length);
풀어 보기
🔹 export function getAllBooks(): Book[] { ... }
- 함수 이름:
getAllBooks - 반환 타입:
Book[]— Book 배열 - 몸체:
return books— 모듈 안의 books 를 그대로 돌려준다.
타입을 명시하면 IDE 가 자동완성을 잘 해 줍니다. 안 적어도 동작은 하는데 명시하는 게 안전합니다.
✅ 확인하기
npx tsx scripts/test-books.ts
=== 모든 책 === [ { id: 1, title: '어린 왕자', author: '생텍쥐페리', publishedYear: 1943 }, ... ] 개수: 3
Step 1 과 같은 결과인데, 이제는 함수를 통해서 가져왔습니다.
STEP 3. getBookById() 함수
ID 한 개를 받아 그 책 한 권만 돌려줍니다. 자바스크립트 배열의 find 메서드를 처음 쓰는 자리예요.
📄 lib/books.ts 에 함수 추가 (앞 코드는 그대로 두고, 마지막에):
export function getBookById(id: number): Book | undefined { return books.find((b) => b.id === id); }
📄 scripts/test-books.ts:
import { getAllBooks, getBookById } from "@/lib/books"; console.log("=== 전체 ==="); console.log(getAllBooks().length, "권"); console.log("\n=== ID 로 찾기 ==="); console.log("ID 2:", getBookById(2)); console.log("ID 999 (없는 ID):", getBookById(999));
풀어 보기
🔹 books.find((b) => b.id === id)
배열을 처음부터 훑으면서 조건을 만족하는 첫 번째 항목 을 돌려줍니다. 조건은 화살표 함수 (b) => b.id === id 인데, "각 책 b 에 대해, 그 책의 id 가 우리가 받은 id 와 같으면 통과" 라는 뜻이에요.
🔹 Book | undefined
반환 타입에 | undefined 가 붙어 있어요. find 는 못 찾으면 undefined 를 돌려주거든요. 이걸 명시해야 호출하는 쪽에서 "있을 수도 있고 없을 수도 있다" 는 걸 알아챕니다.
✅ 확인하기
npx tsx scripts/test-books.ts
=== 전체 === 3 권 === ID 로 찾기 === ID 2: { id: 2, title: '데미안', author: '헤르만 헤세', publishedYear: 1919 } ID 999 (없는 ID): undefined
- 있을 때: 책 객체
- 없을 때:
undefined
두 경우를 모두 눈으로 봤으니, 나중에 페이지에서 "책을 찾을 수 없습니다" 분기를 만들 때 자신 있게 처리할 수 있어요.
STEP 4. createBook() 함수
이번엔 데이터가 바뀝니다. 새 책을 등록하면 id 가 자동으로 매겨져야 해요.
📄 lib/books.ts. 두 가지가 바뀝니다:
let nextId = 4;를 books 배열 아래에 추가createBook함수를 추가
export type Book = { id: number; title: string; author: string; publishedYear: number; }; const books: Book[] = [ { id: 1, title: "어린 왕자", author: "생텍쥐페리", publishedYear: 1943 }, { id: 2, title: "데미안", author: "헤르만 헤세", publishedYear: 1919 }, { id: 3, title: "1984", author: "조지 오웰", publishedYear: 1949 }, ]; let nextId = 4; // ⬅️ 추가 export function getAllBooks(): Book[] { return books; } export function getBookById(id: number): Book | undefined { return books.find((b) => b.id === id); } // ⬇️ 추가 export function createBook(data: Omit<Book, "id">): Book { const newBook: Book = { id: nextId, ...data }; nextId++; books.push(newBook); return newBook; }
📄 scripts/test-books.ts:
import { createBook, getAllBooks } from "@/lib/books"; console.log("=== 등록 전 ==="); console.log(getAllBooks().length, "권"); const created = createBook({ title: "노르웨이의 숲", author: "무라카미 하루키", publishedYear: 1987, }); console.log("\n=== 새로 등록한 책 ==="); console.log(created); console.log("\n=== 등록 후 ==="); console.log(getAllBooks().length, "권");
풀어 보기
🔹 Omit<Book, "id">
"Book 타입에서 id 필드만 빼낸 새 타입" 입니다. 호출하는 쪽은 id 를 모르고, 우리가 자동으로 매겨 줄 거니까요.
이렇게 호출 가능:
createBook({ title: "...", author: "...", publishedYear: 1987 }); // OK
이렇게는 에러:
createBook({ id: 99, title: "...", ... }); // ❌ id 를 넣으면 안 됨
🔹 const newBook: Book = { id: nextId, ...data };
id: nextId— 우리가 가진 다음 id...data— 매개변수로 받은 객체의 필드들을 펼쳐서 합쳐 넣기
결과: { id: 4, title: "...", author: "...", publishedYear: 1987 }
🔹 nextId++
다음 등록을 위해 1을 더해 둡니다. let 이라서 값을 바꿀 수 있어요.
🔹 books.push(newBook)
books 가 const 인데 어떻게 추가가 될까요? const 는 변수 이름을 다른 배열로 못 바꾼다는 뜻일 뿐, 배열 내용 은 바꿀 수 있어요.
✅ 확인하기
npx tsx scripts/test-books.ts
=== 등록 전 === 3 권 === 새로 등록한 책 === { id: 4, title: '노르웨이의 숲', author: '무라카미 하루키', publishedYear: 1987 } === 등록 후 === 4 권
- id 가 자동으로 4 가 됐고
- 전체 개수가 4권으로 늘었습니다.
매번
npx tsx를 실행할 때 결과가 항상 똑같이 4권으로 나오는 게 이상해 보일 수 있어요. 이유: 각 실행은 새로운 Node 프로세스 라 매번 처음 상태(3권)에서 시작하기 때문입니다. 이게 우리한테는 좋은 거예요 — 매번 같은 시나리오로 테스트할 수 있으니까.
STEP 5. updateBook() 함수
이미 있는 책의 내용을 통째로 바꿉니다.
📄 lib/books.ts 마지막에 함수 추가:
export function updateBook( id: number, data: Omit<Book, "id">, ): Book | null { const idx = books.findIndex((b) => b.id === id); if (idx === -1) return null; books[idx] = { id, ...data }; return books[idx]; }
📄 scripts/test-books.ts:
import { getBookById, updateBook } from "@/lib/books"; console.log("=== 수정 전 ID 2 ==="); console.log(getBookById(2)); console.log("\n=== updateBook(2, ...) ==="); const updated = updateBook(2, { title: "데미안 (개정판)", author: "헤르만 헤세", publishedYear: 1919, }); console.log("반환값:", updated); console.log("\n=== 수정 후 다시 조회 ==="); console.log(getBookById(2)); console.log("\n=== 없는 책 수정 시도 ==="); console.log(updateBook(999, { title: "x", author: "y", publishedYear: 0 }));
풀어 보기
🔹 findIndex vs find
find는 항목 자체 를 돌려줍니다.findIndex는 그 항목의 위치(인덱스) 를 돌려줍니다. 없으면-1.
수정하려면 "몇 번째 자리인지" 를 알아야 하므로 findIndex 가 어울려요.
🔹 if (idx === -1) return null;
없는 책을 수정하라는 요청이 들어오면 null 을 돌려 "그런 책 없어요" 신호를 보냅니다. getBookById 는 undefined 를 썼는데, updateBook 은 null 입니다. 약간 일관성이 부족해 보이지만 실무에서 흔히 보는 차이예요 (조회 → undefined / 변경 → null 같은 관례).
🔹 books[idx] = { id, ...data };
- 옛 객체를 새 객체로 통째로 교체합니다.
{ id, ...data }는{ id: id, ...data }의 축약형입니다.
✅ 확인하기
npx tsx scripts/test-books.ts
=== 수정 전 ID 2 === { id: 2, title: '데미안', ... } === updateBook(2, ...) === 반환값: { id: 2, title: '데미안 (개정판)', ... } === 수정 후 다시 조회 === { id: 2, title: '데미안 (개정판)', ... } === 없는 책 수정 시도 === null
세 가지 모두 확인:
- 수정이 실제로 반영됨 (
getBookById로 재조회했더니 새 제목) - 수정한 결과가 반환값으로도 나옴
- 없는 책 수정은
null
STEP 6. globalThis 로 업그레이드
여기서 끝내도 코드는 동작합니다. CLI 환경 에서는 충분해요.
문제는 Next.js dev 모드 입니다. Next.js dev 서버는 파일을 저장할 때마다 변경된 모듈을 다시 불러와요(hot-reload). 이때 lib/books.ts 안의 books 와 nextId 변수가 새 값으로 초기화 됩니다. 사용자가 폼에서 책을 등록했는데 코드를 한 번만 저장해도 그 책이 사라지는 거예요.
해결법: 데이터를 모듈 안의 변수 가 아니라 프로세스 전역(globalThis) 에 두기. globalThis 는 hot-reload 가 일어나도 살아남습니다.
📄 lib/books.ts 를 통째로 다음으로 바꿉니다:
export type Book = { id: number; title: string; author: string; publishedYear: number; }; // Next.js dev 모드의 hot-reload 가 일어나도 데이터가 살아남도록 // 모듈 안의 변수가 아니라 "프로세스 전역" 인 globalThis 에 저장합니다. type GlobalStore = { __books?: Book[]; __nextId?: number; }; const store = globalThis as unknown as GlobalStore; if (!store.__books) { store.__books = [ { id: 1, title: "어린 왕자", author: "생텍쥐페리", publishedYear: 1943 }, { id: 2, title: "데미안", author: "헤르만 헤세", publishedYear: 1919 }, { id: 3, title: "1984", author: "조지 오웰", publishedYear: 1949 }, ]; store.__nextId = 4; } export function getAllBooks(): Book[] { return store.__books!; } export function getBookById(id: number): Book | undefined { return store.__books!.find((b) => b.id === id); } export function createBook(data: Omit<Book, "id">): Book { const newBook: Book = { id: store.__nextId!, ...data }; store.__nextId!++; store.__books!.push(newBook); return newBook; } export function updateBook( id: number, data: Omit<Book, "id">, ): Book | null { const idx = store.__books!.findIndex((b) => b.id === id); if (idx === -1) return null; store.__books![idx] = { id, ...data }; return store.__books![idx]; }
📄 scripts/test-books.ts — 함수 사용은 그대로지만 통합 회귀 테스트 한 번 더:
import { createBook, getAllBooks, getBookById, updateBook, } from "@/lib/books"; console.log("=== 1. 초기 데이터 ==="); console.log(getAllBooks().length, "권"); console.log("\n=== 2. ID 로 조회 ==="); console.log("ID 2:", getBookById(2)?.title); console.log("ID 999:", getBookById(999)); console.log("\n=== 3. 새 책 등록 ==="); const created = createBook({ title: "노르웨이의 숲", author: "무라카미 하루키", publishedYear: 1987, }); console.log("새 ID:", created.id); console.log("등록 후 전체:", getAllBooks().length, "권"); console.log("\n=== 4. 수정 ==="); const updated = updateBook(2, { title: "데미안 (개정판)", author: "헤르만 헤세", publishedYear: 1919, }); console.log("수정 결과:", updated?.title); console.log("재조회:", getBookById(2)?.title); console.log("\n=== 5. 없는 책 수정 ==="); console.log(updateBook(999, { title: "x", author: "y", publishedYear: 0 })); console.log("\n=== 6. 최종 ==="); console.log(getAllBooks());
풀어 보기
🔹 type GlobalStore = { __books?: Book[]; __nextId?: number; }
globalThis 에 우리가 쓸 두 필드의 모양을 적어 둔 타입. ? 는 "있을 수도 있고 없을 수도 있다" 는 표시.
🔹 const store = globalThis as unknown as GlobalStore;
TypeScript 에게 "globalThis 를 GlobalStore 타입처럼 다룰 거야" 라고 알려 줍니다.
as unknown as 라는 두 단계 캐스팅 이 들어가는 이유: globalThis 의 기본 타입과 GlobalStore 는 완전히 달라서 한 번에 변환하면 TypeScript 가 "너무 위험한 변환인데?" 라며 거절해요. unknown 으로 한 번 거치면 통과합니다.
🔹 if (!store.__books) { ... store.__books = [...]; }
"globalThis 에 __books 가 아직 없으면 처음 데이터를 채워라". 이미 있으면(=hot-reload 후) 그대로 둡니다. 이게 살아남는 핵심.
🔹 store.__books!.find(...)
끝의 ! 는 "이 값은 절대 undefined 가 아니야" 라고 TypeScript 한테 약속하는 표시. 위의 if (!store.__books) 로 채웠으니 이제부터는 분명히 있다 — 그걸 컴파일러는 모르니까 우리가 알려 주는 거예요.
✅ 확인하기
npx tsx scripts/test-books.ts
=== 1. 초기 데이터 === 3 권 === 2. ID 로 조회 === ID 2: 데미안 ID 999: undefined === 3. 새 책 등록 === 새 ID: 4 등록 후 전체: 4 권 === 4. 수정 === 수정 결과: 데미안 (개정판) 재조회: 데미안 (개정판) === 5. 없는 책 수정 === null === 6. 최종 === [ { id: 1, title: '어린 왕자', ... }, { id: 2, title: '데미안 (개정판)', ... }, { id: 3, title: '1984', ... }, { id: 4, title: '노르웨이의 숲', ... } ]
Step 5 와 같은 결과 가 나오면 성공입니다. 데이터 저장소만 바뀌었지 동작은 그대로니까요.
CLI 환경에서는 globalThis 의 이점이 안 보입니다(매 실행이 새 프로세스). 하지만 Next.js dev 서버를 띄우고 app/books/new 에서 책을 등록한 뒤 lib/books.ts 의 주석 한 줄을 바꿔 저장해 보세요. 새로 등록한 책이 사라지지 않습니다.
본 튜토리얼로 돌아가기
이제 lib/books.ts 가 본 튜토리얼(TUTORIAL.md)의 STEP 2 결과와 동일합니다. Next.js 페이지/라우트를 통해서도 같은 함수들을 부르게 됩니다.
dev 서버에서도 확인
npm run dev
http://localhost:3300/api/books 를 열면 같은 책 3권이 JSON 으로 나옵니다. CLI 에서 확인한 함수가 그대로 API 라우트에서 호출되고 있어요.
scripts 폴더 처리
scripts/test-books.ts 는 학습용 산출물이라 그대로 둬도 본 프로젝트에 영향을 주지 않습니다. 빌드(npm run build) 에서도 무시되고, dev 서버에서도 안 불립니다. 깔끔하게 정리하고 싶으면 scripts/ 폴더 전체를 지우세요.
rm -rf scripts
tsx 도 더 이상 안 쓰겠다면 의존성에서 제거:
npm uninstall tsx
우리가 배운 것
| 단계 | 새로 배운 것 |
|---|---|
| Step 1 | export, type, Book[] 같은 기초 타입 |
| Step 2 | 함수로 감싸 export 하는 패턴 |
| Step 3 | Array#find, `T |
| Step 4 | Omit<T, K>, 스프레드 연산자, const 배열의 push |
| Step 5 | Array#findIndex, null 로 "없음" 표현 |
| Step 6 | globalThis 캐스팅, ! non-null assertion |
다음에 해 볼 만한 것
deleteBook(id)추가 —splice또는filter로 한 줄 정도.- 검색 함수
searchBooks(keyword)—title.includes(keyword)활용. - 정렬
sortBooksByYear()—[...books].sort(...)로 원본을 안 건드리고 정렬.
CLI 에서 매번 결과를 확인하는 이 패턴은 어떤 라이브러리 함수를 만들든 똑같이 쓸 수 있어요. 작게 만들고, 실행해서 보고, 그다음 한 줄을 더한다 — 이게 지치지 않고 코드를 늘리는 가장 안전한 방법입니다.

댓글
댓글을 작성하려면 이 필요합니다.