003 - TODO 저장하기
Backstage 백엔드를 재시작하면 TODO 목록이 사라진다는 것을 알아차렸을 거예요. yarn start를 다시 실행하지 않고 백엔드를 재시작하는 일반적인 방법은 yarn start를 실행 중인 터미널에서 ENTER를 누르는 거예요. 그러면 Backstage 백엔드가 완전히 재시작되고, 메모리의 모든 데이터를 지우고 처음부터 다시 시작하게 돼요 — 데이터베이스만 빼고요. 이 글에서는 플러그인 상태를 데이터베이스에 영구 저장하는 방법을 설명드릴게요.
출처: 문서
본문
플러그인 상태를 무기한 저장하기
Backstage 백엔드를 재시작하면 TODO 목록이 사라진다는 것을 알아차렸을 거예요. yarn start를 다시 실행하지 않고 백엔드를 재시작하는 일반적인 방법은 yarn start를 실행 중인 터미널에서 ENTER를 누르는 거예요. 그러면 Backstage 백엔드가 완전히 재시작되고, 메모리의 모든 데이터를 지우고 처음부터 다시 시작하게 돼요 — 데이터베이스만 빼고요.
SQLite 간단 소개
SQLite는 로컬 개발의 기본 데이터베이스예요. 메모리에서 실행되며(디스크의 파일에서도 실행될 수 있어요) 빠른 반복 주기를 지원해서 문제가 생기면 쉽게 삭제할 수 있어요.
우리 데이터는 저장 시 어떤 모습일까요?
데이터베이스에 쓰려면 테이블이 필요하고, 그러려면 무엇을 저장할지 간단히 이야기해야 해요. title, id, createdBy, createdAt 키가 있는 우리의 TODO 객체는 데이터베이스 스키마와 1:1로 매핑하기 좋아요.
플러그인에 databaseService 추가하기
배선(plumbing)하기
시작하려면 예상하는 일반적인 databaseService 사용법을 배선해 볼게요.
먼저 databaseService에 대한 새 서비스 의존성을 추가해요:
export const todoListServiceRef = createServiceRef<Expand<TodoListService>>({ id: 'todo.list', defaultFactory: async service => createServiceFactory({ service, deps: { logger: coreServices.logger, catalog: catalogServiceRef,+ database: coreServices.database, }, async factory(deps) { return TodoListService.create(deps); }, }),});
그런 다음 서비스에 그것을 추가해야 해요:
+import type { Knex } from 'knex';import { coreServices, createServiceFactory, createServiceRef, LoggerService,+ DatabaseService,} from '@backstage/backend-plugin-api';export class TodoListService {+ readonly #database: Knex;- readonly #storedTodos = new Array<TodoItem>();- static create(options: {+ static async create(options: { logger: LoggerService; catalog: typeof catalogServiceRef.T;+ database: DatabaseService; }) { const knex = await options.database.getClient();- return new TodoListService(options.logger, options.catalog);+ return new TodoListService(options.logger, options.catalog, knex); } private constructor( logger: LoggerService, catalog: typeof catalogServiceRef.T,+ database: Knex, ) { this.#logger = logger; this.#catalog = catalog;+ this.#database = database; }
그리고 이제 우리 데이터베이스와 통신할 격리된 knex 클라이언트가 생겼어요!
테이블 만들기
불행히도 데이터베이스에 테이블이 없으면 knex 클라이언트가 많은 일을 하지 못해요. 마이그레이션을 만들어야 해요. Knex는 마이그레이션을 knex.migrate.latest() 호출의 일부로 실행되는 JavaScript/TypeScript 파일로 저장해요. 기본적으로 migrations/ 디렉터리에 저장돼요.
시작해 볼게요. 먼저 knex를 의존성으로 설치해서 CLI와 import되는 Knex 타입을 모두 사용할 수 있게 해요:
yarn workspace @internal/plugin-todo-backend add knex
이제 이 명령을 실행하면 migrations/ 디렉터리에 파일을 스캐폴드해 줄 거예요.
yarn workspace @internal/plugin-todo-backend knex migrate:make init --migrations-directory ./migrations
이렇게 하면 다음과 같은 메시지가 출력될 거예요:
Created Migration: ~/Projects/backstage/backstage/plugins/todo-backend/migrations/20260323130057_init.js
그 파일을 열어 볼게요:
/** * @param { import("knex").Knex } knex * @returns { Promise<void> } */exports.up = async function up(knex) { // await knex.schema...};/** * @param { import("knex").Knex } knex * @returns { Promise<void> } */exports.down = async function down(knex) { // await knex.schema...};
up과 down 두 함수를 볼 수 있어요. up은 마이그레이션을 적용하기 위해 호출되고 down은 이전 마이그레이션을 되돌리는 데 사용돼요. 이들은 되돌릴 수 있어야 해요. up을 호출한 다음 down을 호출하면, 해당 명령이 실행되지 않았을 때와 일반적으로 같은 상태여야 해요.
테이블을 만들어 볼게요:
exports.up = async function up(knex) {+ await knex.schema.createTable('todo', table => {+ table.uuid('id').primary();+ table.string('created_by', 255).notNullable();+ table.string('title').notNullable();+ table.datetime('created_at').defaultTo(knex.fn.now()).notNullable();+ table.index(['created_by'], 'todo_user_idx'); });};
camelCase 대신 snake_case를 사용한다는 것을 알아차릴 거예요 — SQL이 관례적으로 쓰이는 방식이에요.
down 마이그레이션을 추가하는 것을 잊지 않도록 합시다!
/** * @param {import('knex').Knex} knex */exports.down = async function down(knex) {+ await knex.schema.dropTable('todo');};
이제 실제로 knex 클라이언트에게 이 마이그레이션들을 자동으로 적용하도록 지시해야 해요. 플러그인의 init 함수에 database 서비스를 추가할게요:
import { coreServices, createBackendPlugin,+ resolvePackagePath,} from '@backstage/backend-plugin-api';// ... deps: { httpAuth: coreServices.httpAuth, httpRouter: coreServices.httpRouter,+ logger: coreServices.logger,+ database: coreServices.database, todoList: todoListServiceRef, },- async init({ httpAuth, httpRouter, todoList }) {+ async init({ httpAuth, logger, httpRouter, database, todoList }) {+ const knex = await database.getClient();++ if (!database.migrations?.skip) {+ logger.info('Running database migrations...');++ const migrationsDir = resolvePackagePath(+ '@internal/plugin-todo-backend',+ 'migrations',+ );++ await knex.migrate.latest({+ directory: migrationsDir,+ });+ } httpRouter.use( await createRouter({ httpAuth, todoList, }), );
우리가 작성한 것을 살펴보면 —
-
database.migrations?.skip- 구성을 통해 마이그레이션을 건너뛸 수 있게 하는 관례. -
const migrationsDir = resolvePackagePath- 환경과 무관하게 올바른 migrations 디렉터리가 전달되도록 보장. -
await knex.migrate.latest(- 실제로 마이그레이션을 실행하고, 위에서 작성한up메서드를 호출.
한 가지 더 해야 해요:
"files": [- "dist"+ "dist",+ "migrations" ],
이렇게 하면 플러그인의 마이그레이션이 모든 사용자에게 동작하도록 보장해요.
더 자세한 내용을 원하는 분들을 위해 전체 Knex 마이그레이션 문서가 아주 유익해요!
타입 정의하기
이제 테이블이 있으니, 런타임 비호환성을 방지하기 위해 테이블용 타입을 추가해야 해요. 지금은 이것들이 손으로 작성돼요.
src/services/TodoListService.ts
+export interface TodoDatabaseRow {+ title: string;+ id: string;+ created_by: string;+ created_at: string;+}export interface TodoItem { title: string; id: string; createdBy: string; createdAt: string;}
위의 데이터베이스 스키마와 일치해야 하므로 snake case로 바뀐 것에 주목하세요. 이제 쓰기에는 TodoItem을 TodoDatabaseRow로, 읽기에는 TodoDatabaseRow를 TodoItem으로 변환해야 해요.
src/services/TodoListService.ts
private constructor( logger: LoggerService, catalog: typeof catalogServiceRef.T,+ database: Knex, ) { this.#logger = logger; this.#catalog = catalog;+ this.#database = database; }+ private toDatabaseRow(todo: TodoItem): TodoDatabaseRow {+ return {+ id: todo.id,+ title: todo.title,+ created_by: todo.createdBy,+ created_at: todo.createdAt,+ };+ }+ private fromDatabaseRow(row: TodoDatabaseRow): TodoItem {+ return {+ id: row.id,+ title: row.title,+ createdBy: row.created_by,+ createdAt: row.created_at,+ };+ }
그리고 그게 전부예요! 이제 실제로 데이터베이스에서 읽고 쓸 준비가 되었어요.
테이블에 쓰기
테이블을 만드는 것은 꽤 큰 작업이었지만, 다행히 테이블에 쓰는 것은 훨씬 쉬울 거예요!
src/services/TodoListService.ts
async createTodo( // ... const id = crypto.randomUUID(); const createdBy = options.credentials.principal.userEntityRef; const newTodo = { title, id, createdBy, createdAt: new Date().toISOString(), };- this.#storedTodos.push(newTodo);+ await this.#database+ .insert(this.toDatabaseRow(newTodo))+ .into('todo'); return newTodo; }
우리는 기본적으로 서비스 호출을 this.#storedTodos 대신 this.#database를 사용하도록 업데이트했어요.
테이블에서 읽기
이제 데이터베이스에 데이터가 있으니, 실제로 다시 가져오려면 어떻게 해야 할까요?
src/services/TodoListService.ts
async listTodos(): Promise<{ items: TodoItem[] }> {- return { items: Array.from(this.#storedTodos) };+ const rows = await this.#database('todo').select();+ return { items: rows.map(row => this.fromDatabaseRow(row)) }; } async getTodo(request: { id: string }): Promise<TodoItem> {- const todo = this.#storedTodos.find(item => item.id === request.id);+ const item = await this.#database('todo').where({ id: request.id }).first();- if (!todo) {+ if (!item) { throw new NotFoundError(`No todo found with id '${request.id}'`); }- return todo;+ return this.fromDatabaseRow(item); }
그리고 끝났어요!
변경 사항 테스트하기
이 흐름을 검증하려면 이 가이드의 마지막 섹션에서 실행했던 것과 같은 명령을 사용해 보세요.
모든 것이 올바르게 동작한다면 지난번과 같은 응답을 볼 수 있을 거예요.