프론드엔드가 Express, Prisma, PostgreSQL 써본 후기

2_현주·2026년 4월 14일
post-thumbnail

MOTI 백엔드를 구현하면서 처음으로 Express.js, Prisma, PostgreSQL를 써봤다. 처음 사용한 기술이다보니 한번 개념을 정리해보려고 한다.


Express.js

Express.js는 Node.js 위에서 동작하는 웹 프레임워크다. Node.js만으로도 서버를 만들 수 있지만, HTTP 요청/응답 처리, 라우팅, 미들웨어 구성 등을 직접 다 구현해야 해서 번거롭다. Express는 이런 것들을 간편하게 처리할 수 있도록 도와준다.

미들웨어

요청이 들어오면 응답이 나가기 전까지 거치는 함수들의 체인이라고 보면 된다. 미들웨어 함수는 요청 객체(req), 응답 객체(res), 그리고 다음 미들웨어로 넘기는 next 함수에 접근할 수 있다.

app.use((req, res, next) => {
  console.log('요청 시각:', Date.now());
  next();
});

위에서 아래로 순서대로 실행되고, next()를 호출해야 다음 미들웨어로 넘어간다. next()를 호출하지 않으면 요청이 거기서 멈춰버린다. 인증, 에러 처리, 로깅 같은 공통 로직을 미들웨어로 만들어두면 각 라우트에서 중복 코드 없이 재사용할 수 있다.

app.use(helmet());       // 보안 헤더 설정
app.use(cors());         // CORS 허용
app.use(express.json()); // 요청 바디를 JSON으로 파싱
app.use('/api', routes); // 라우팅

라우팅

URL 경로와 HTTP 메서드를 조합해서 어떤 요청이 어떤 함수로 연결될지 정의하는 것이다. express.Router()를 사용하면 관련된 라우트끼리 모듈로 묶어서 관리할 수 있다.

const requireAuth = (req, res, next) => {
  const token = req.headers.authorization?.replace('Bearer ', '');
  if (!token) return res.status(401).json({ error: '인증 필요' });
  next();
};

이렇게 만든 미들웨어를 라우트에 끼워넣으면 해당 라우트에 접근하려면 반드시 인증을 거쳐야 한다.

const router = express.Router();

router.get('/', requireAuth, asyncHandler(getMaterials));
router.post('/', requireAuth, asyncHandler(createMaterial));
router.put('/:id', requireAuth, asyncHandler(updateMaterial));
router.delete('/:id', requireAuth, asyncHandler(deleteMaterial));

module.exports = router;

라우터를 파일별로 나눠두면 코드 관리가 훨씬 편해진다.


PostgreSQL

PostgreSQL은 관계형 데이터베이스다. 데이터를 테이블 형태로 저장하고, 테이블 간의 관계를 정의할 수 있다.

MOTI에서는 사용자, 재료, 작품 데이터가 서로 연결되어 있어서 관계형 DB가 적합했다. 예를 들어 하나의 작품에 여러 재료가 쓰이고, 하나의 재료가 여러 작품에 쓰일 수 있는 다대다(N:M) 관계를 표현해야 했다.

외래키 (Foreign Key)

다른 테이블의 데이터를 참조할 때 사용하는 키다. 예를 들어 Material 테이블에 userId가 있으면, 이 값이 반드시 User 테이블에 존재하는 id여야 한다는 걸 DB가 강제해준다.

model Material {
  userId String
  user   User   @relation(fields: [userId], references: [id])
  // userId는 반드시 User 테이블의 id를 참조해야 함
}

참조 무결성 (Referential Integrity)

외래키로 연결된 데이터가 항상 일관성 있게 유지되는 것을 말한다. 존재하지 않는 유저의 재료가 저장된다거나, 삭제된 카테고리가 재료에 남아있는 상황을 방지해준다. 이게 보장되지 않으면 DB에 연결이 끊긴 고아 데이터가 생기게 된다.

정규화 (Normalization)

DB 설계에서 정규화는 데이터 중복을 줄이고 무결성을 높이는 과정이다.

처음엔 재료의 카테고리와 특징을 이렇게 배열로 저장했다.

// 배열로 저장
model Material {
  category  String[]  // ["키링", "휴대폰고리"]
  features  String[]  // ["유광", "투명"]
}

이렇게 하면 여러 값이 하나의 필드에 들어가서 제1정규형(1NF)을 위반한다. 원자값(더 이상 쪼갤 수 없는 값)만 저장해야 한다는 원칙에 어긋나는 것이다.
효율적인 쿼리가 어렵고, 카테고리 이름을 바꾸려면 모든 레코드를 일일이 수정해야 한다는 문제도 있었다.

또 재료의 타입과 모양은 이렇게 문자열로만 저장했다.

// 외래키 없이 문자열로
model Material {
  type   String  // "아크릴"
  shape  String  // "원형"
}

별도의 마스터 테이블을 만들어놨지만 실제로 외래키로 연결하지 않아서 존재하지 않는 타입도 저장할 수 있는 상태였다. 이를 의사 정규화라고 하는데, 보기엔 정규화된 것 같지만 실제로는 참조 무결성이 전혀 보장되지 않는 구조다.

처음엔 시스템에서 기본으로 제공하는 값들(재질 종류, 모양 종류 등)을 마스터 테이블로 관리하려 했다. 근데 마스터 테이블은 외래키로 연결되지 않아서 폭포수(Cascade) 설계가 불가능하다. 상위 데이터를 삭제해도 하위 데이터가 자동으로 따라 삭제되지 않아 고아 데이터가 남게 된다. 시스템 고정값이든 사용자 정의값이든 상관없이 정규화된 참조 테이블 + 외래키 + Cascade로 관계를 명확히 정의하는 게 맞는 방식이었다.

정션 테이블 (Junction Table)

다대다 관계를 표현할 때 사용한다. 두 테이블을 직접 연결하는 대신, 중간 테이블을 두는 방식이다.

// ✅ 정규화된 설계 - 정션 테이블 사용
model MaterialCategoryAssignment {
  id         String           @id @default(cuid())
  materialId String
  categoryId String
  material   Material         @relation(fields: [materialId], references: [id], onDelete: Cascade)
  category   MaterialCategory @relation(fields: [categoryId], references: [id], onDelete: Cascade)
  createdAt  DateTime         @default(now())

  @@unique([materialId, categoryId])  // 중복 방지
}

onDelete: Cascade를 설정하면 카테고리를 삭제할 때 연결된 정션 테이블 레코드가 자동으로 삭제된다. Prisma 공식 문서에서도 이렇게 설명하고 있다.

"onDelete: Cascade means that deleting a User record will also delete all related Post records."
— Prisma 공식 문서

이전 방식에서는 배열에 값이 남아 고아 데이터가 생겼는데, 이제는 DB가 알아서 정리해준다.

쿼리도 훨씬 명확해졌다.

// ❌ 변경 전 - 배열 필터링
const materials = await prisma.material.findMany({
  where: { category: { has: "비즈" } },
});

// ✅ 변경 후 - 정션 테이블 조인
const materials = await prisma.material.findMany({
  where: {
    categories: {
      some: { category: { name: "비즈" } },
    },
  },
});

처음엔 왜 이렇게 복잡하게 설계해야 하나 싶었는데, 직접 문제를 겪고 나서 정규화가 왜 필요한지 체감이 됐다.


Prisma

Prisma는 TypeScript 환경에서 PostgreSQL을 편하게 사용할 수 있게 해주는 ORM(Object Relational Mapping)이다. SQL을 직접 작성하는 대신 TypeScript 코드로 DB를 다룰 수 있다.
백엔드를 처음 구현하면서 SQL을 따로 배워야 하나 고민했는데, Prisma 덕분에 TypeScript 문법 그대로 DB를 다룰 수 있어서 진입 장벽이 낮았다.

스키마 정의

Prisma는 schema.prisma 파일에서 DB 구조를 정의한다.

model Material {
  id       String  @id @default(cuid())
  userId   String
  user     User    @relation(fields: [userId], references: [id], onDelete: Cascade)
  name     String
  stock    Int     @default(0)
}

스키마를 수정하고 아래 명령어를 실행하면 DB에 자동으로 반영된다. SQL 마이그레이션 파일도 자동으로 생성되어 변경 이력을 관리할 수 있다.

npx prisma migrate dev

타입 안전한 쿼리

Prisma의 가장 큰 장점은 쿼리 결과에 타입이 자동으로 붙는다는 점이다.

// 반환 타입이 자동으로 Material[]로 추론됨
const materials = await prisma.material.findMany({
  where: { userId: req.user.userId },
  orderBy: { createdAt: 'desc' },
});

SQL을 직접 쓰면 결과 타입을 수동으로 지정해야 해서 실수가 생기기 쉬운데, Prisma는 스키마 기반으로 타입을 자동 생성해줘서 IDE 자동완성도 잘 되고 엉뚱한 필드명을 쓰면 바로 오류로 잡아줬다. 프론트엔드에서 TypeScript를 쓰던 감각 그대로 백엔드 코드를 작성할 수 있었던 게 생각보다 큰 도움이 됐다.

관계 데이터 가져오기

// 작품과 사용한 재료 목록을 한 번에 가져오기
const product = await prisma.product.findUnique({
  where: { id: productId },
  include: {
    productMaterials: {
      include: { material: true },
    },
  },
});

include로 연결된 테이블 데이터를 한 번에 가져올 수 있다. SQL의 JOIN을 직접 작성했다면 꽤 복잡했을 쿼리인데, include 한 줄로 해결되니까 관계형 DB가 처음인 입장에서도 부담이 덜했다.


마치며

처음 백엔드를 구현하면서 처음 이 세 기술을 사용해 보았는데 괜,,,찮았다. 나름? 기존에 개발하고 있는 프로젝트에서 가끔 백엔드 코드도 보곤 해서 그런지 컨트롤러, 라우트 서비스 파일 등등 그 흐름을 이해하는 데에는 문제없었다! 단지 이해도가 아직 낮아서 좋은 퀄리티의 코드 구현이 잘 안 된 것 같아서 아쉽다. 특히 DB 설계에서 배열로 저장했다가 뒤엎고, 마스터 테이블 만들었다가 또 뒤엎고~ 두 번 갈아엎고 나서야 정규화가 왜 필요한지 머리가 아닌 손으로 이해했다.

profile
프론드엔드 개발자 이현주 입니다.

0개의 댓글