
2. Repo: PostRepo.ts (DB 접근 “전담”)
레포지토리 계층은 한마디로 “Sequelize 쿼리 모음집”입니다.
다시 말하면, 서비스 컨트롤러는 더 이상 PostModel.findAll()같은 야생의 쿼리를 사용하지 않고, PostRepo.create()와 같은 메서드만 호출하면 DB작업이 끝나도록 처리합니다.
이렇게 분리해두면, 나중에 DB를 변경해야할 소요가 있거나, 쿼리 최적화를 위해 인덱스를 조정하거나, 캐싱 레이어를 추가하고 싶을때 서비스/컨트롤러 코드는 최대한 손대지 않을 수 있습니다.
2-1. create(data, imageUrls) : 게
목표: post 하나 만들고, 이미지 URLs도 별도 테이블에 저장.
const post = await PostModel.create(data);
- posts 테이블에 1행 insert합니다.
- 여기서 post.id가 생성되겠습니다. sequilze 기본 uuid 자동완성.
Sequelize가 제공하는 Model 기반의 함수들 create, findAll, bulkCreate 애소 bulkCreate를 활용했습니다.
for (const data of rows) {
await PostImageModel.create(data)
}
이렇게 반복적인 Insert보다 성능이 더 좋습니다. 이렇게하면 매번 훅 호출마다 검증이 지속적으로 일어나기때문에 좋지 않습니다.
if (imageUrls.length > 0) {
await PostImageModel.bulkCreate(
imageUrls.map((url, index) => ({
postId: post.id,
imageUrl: url,
sortOrder: index,
})),
);
}
- images가 있으면 post_images에 여러 행 insert
- sortOrder = index → 프론트에서 준 순서 그대로 보장하려고
- bulkCreate는 여러 행 한 번에 넣는 최적화
return await PostModel.findByPk(post.id, {
include: [
{
model: PostImageModel,
as: "images",
attributes: ["id", "imageUrl", "sortOrder"],
},
],
});
- 만든 post를 이미지 포함해서 다시 조회해 반환
post 객체를 만들었는데 왜 한번더 findByPK(post.id, { include : ..} )을 하는가?
쉽게 말하면, post 인스턴스에는 연결된 이미지 정보가 부재합니다.
posts 테이블에 create함수로 INSERT는 하였지만, post.images와 같은 association 필드는 따로 로드하지 않았습니다.
즉, post 인스턴스에는 현재 이미지 정보가 없는 것입니다.
그러나 우리는 클라이언트에게 아래와 같은 JSON 구조로 주고싶습니다. 그렇기에 다시 DB에서 게시글 + 이미지를 한번에 조회 해오는게 제일 깔끔한 것입니다.
{
"id": "...",
"title": "...",
"content": "...",
"images": [
{ "id": 1, "imageUrl": "...", "sortOrder": 0 },
{ "id": 2, "imageUrl": "...", "sortOrder": 1 }
]
}
{
// 여기까지가 Post의 “원래 컬럼들”
id: "....",
authorId: "....",
title: "....",
content: "....",
price: 10000,
status: "open",
deadline: "...",
pickupLocation: "...",
createdAt: "...",
updatedAt: "...",
// 이 아래가 include로 “추가로 붙은” 부분
images: [
{ id: 1, imageUrl: "https://...", sortOrder: 0 },
{ id: 2, imageUrl: "https://...", sortOrder: 1 },
],
}
따라서 클라이언트가 필요한 DTO만 위처럼 만들어서 클라이언트에 제공하는 것입니다.
2-2. findById(id)
post table의 PK(id)로 단일 레코드를 조회하는 함수입니다.
async findById(id: string) {
const post = await PostModel.findByPk(id, {
include: [
{
model: PostImageModel,
as: "images",
attributes: ["id", "imageUrl", "sortOrder"],
order: [["sortOrder", "ASC"]],
},
],
});
return post ? post.get() : null;
},
- PK(=id)로 단건 조회
여기서 return되는 post는, PostModel 인스턴스겠습니다.
post.get()을 굳이 호출하는 이유는 인스턴스를 순수 JS 객체로 변환하는 함수이기 때문입니다. res.json(post)로 응답을 줄때 훨씬 편하겠습니다.
- post.get()
- Sequelize 인스턴스를 “순수 JS 객체”로 변환
- res.json 보낼 때 훨씬 깔끔함
2-3. list(limit, offset)
post 게시글을 한번에 다 가져오는게 아니라 필요한만큼 페이지네이션을 하여 가져오는 함수입니다.
우리가 서비스 계층에서 흔히 보는 아래와 같은 기능을 조회하기 위해 설계되었습니다.
- “게시글 목록”
- “1페이지에 20개씩”
- “최신 글이 위에”
- “카테고리별로 보기 (food만 보기, etc)”
async list(limit = 20, offset = 0, category?: string | null)
- HEAD
- limit: “한 번에 몇 개까지 가져올지”
- → 기본은 20개
- offset: “앞에서 몇 개를 건너뛸지”
- → 기본은 0개 (즉, 처음부터)
- category: “이 카테고리 글만 보고 싶으면 넣는 옵션”
즉:
- 1페이지: list(20, 0)
- 2페이지: list(20, 20)
- 3페이지: list(20, 40)
- 카테고리 food만: list(20, 0, "food")
이런 식으로 쓰입니다.
- WHERE 조건 만들기
const where = category ? { category } : undefined;
말로 하면:
“category가 들어오면 WHERE category = ? 조건을 쓰고,
안 들어오면 조건 없이 전체에서 가져온다.”
딱 이거 하나입니다.
- DB에 전달하는 부분
const posts = await PostModel.findAll({
where,
include: [
{
model: PostImageModel,
as: "images",
attributes: ["id", "imageUrl", "sortOrder"],
},
],
order: [["createdAt", "DESC"]],
limit,
offset,
});
모델의 findAll() 메서드를 활용해서, 위에서 만들어둔 where(카테고리 별 조건분기)
findAll의 녀석들은 모두 options 객체 들어가는 키들입니다.
option 객체 키 내부의 여러 키 값은 Sequelize의 SQL 쿼리 옵션으로서. 저희는 그저 편리하게 위처러 findALL의 options 객체에 넣으면 알아서 SQL을 조립해주는 장점이 있겠습니다.
Sequelize 쿼리 옵션 (내부에서 쓰는 설정값)
- findAll({ ... }) 안에 들어가는 키들
- → where, order, limit, offset, include, attributes …
- 이건 Sequelize 라이브러리가 미리 약속해 둔 옵션 이름이라,
- where → WHERE ...
- order → ORDER BY ...
- limit → LIMIT ...
- offset → OFFSET ...
- 이런 식으로 SQL을 조립합니다.
- 우리가 저 이름으로 넣어주면 Sequelize가 그걸 읽고:
- 마지막으로 id로 가져오면, 그 id를 p.get()으로 매핑합니다.
- posts.map((p) => p.get())
- findAll 결과 역시 Sequelize 인스턴스 배열이기 때문에,
- .get()을 한 번씩 호출해 순수 객체 배열로 변환해서 반환합니다.
- // before (Sequelize 인스턴스 불순물 포함) p.id p.title p.content p.images p.save() p.update() p.toJSON() p._previousDataValues p._options ... // after (p.get()) { id: "...", title: "...", content: "...", images: [...], createdAt: "...", updatedAt: "..." }
- 쉽게말하면, 순수 데이터가 아닌 Sequelize의 불순물들이 묻어있는 것입니다.
2-4. findByAuthorId(authorId)
특정 사람이 쓴 글만 목록 형태로 가져옵니다.
async findByAuthorId(authorId: string, limit = 20, offset = 0) {
const posts = await PostModel.findAll({
where: { authorId },
include: [
{
model: PostImageModel,
as: "images",
attributes: ["id", "imageUrl", "sortOrder"],
order: [["sortOrder", "ASC"]],
},
],
order: [["createdAt", "DESC"]],
limit,
offset,
});
return posts.map((p) => p.get());
},
위의 list()와 매우 비슷한 구조입니다.
postModel의 (Sequelize) 내장함수인 findAll을 활용하였고, 옵션 또한 거의 유사합니다만, where 조건을 {authorId}로 잡았습니다.
그 해당 조건은 findByAuthorId의 첫번째 파라미터의 문자열로 확인되겠습니다.
즉 그 조건을 활용해서 특정 작성자 글만 필터링해서 목록으로 가져옵니다.
2-5. update(id, patch)
핵심: “부분 업데이트 + 이미지 갈아끼우기” 의 역할을 하는 함수입니다.
핵심은 두 가지입니다.
- 게시글 기본 정보(제목, 내용, 가격, 상태 등)를 부분 업데이트
- images가 오면, 이미지 목록을 **“초기화하고 새로 채우는 방식”**으로 처리
1단계: 수정할 게시글 찾기
const post = await PostModel.findByPk(id);
if (!post) throw 404;
- 먼저 id로 해당 게시글을 찾습니다.
- 없다면 404 에러를 던집니다.
- → “없는 게시글을 수정하려고 했다”는 상황입니다.
2단계: 기본 필드 부분 업데이트
await post.update(patch);
- patch 객체에 들어 있는 필드만 골라서 업데이트합니다.
- 예: { title: "새 제목" }만 들어있으면 제목만 수정합니다.
- { price: 12000, deadline: ... }이면 가격 + 마감일만 수정합니다.
- 즉, **“전체 덮어쓰기”가 아니라 “들어온 것만 바꾸기”**입니다.
patch: Partial<PostCreationAttributes>
현재 patch의 타입은 post을 새로 create할때 쓰는 기본속성인
// Post를 만들 때 쓰는 기본 속성들
export interface PostCreationAttributes {
id?: string; // create에서는 optional일 수도 있고
authorId: string;
title: string;
content: string;
price: number;
minParticipants: number;
currentQuantity?: number;
status?: "open" | "closed" | "in_progress" | "completed" | "cancelled";
deadline: Date;
pickupLocation?: string | null;
category?: string;
}
이중 patch 타입은 위 post 생성타입을 Partial로 두었기에 post 생성 타입의 필드들을 모두 optional 화 시킨것입니다.
3단계: 이미지 배열 처리 (옵션)
const images = (patch as { images?: string[] }).images;
if (images !== undefined) {
await PostImageModel.destroy({ where: { postId: id } });
if (images.length > 0) {
await PostImageModel.bulkCreate(
images.map((url, index) => ({
postId: id,
imageUrl: url,
sortOrder: index,
}))
);
}
}
여기가 이미지 전담 로직입니다.
- images가 아예 안 왔다 (undefined)기존 이미지는 그대로 둡니다.
- → “이번 수정에서는 이미지 건드리지 않습니다.”
- images가 빈 배열 []로 왔다→ destroy로 기존 이미지 전부 삭제하고, 다시 넣지 않습니다.
- → “이미지를 전부 지우고 싶다”는 뜻으로 해석합니다.
- images가 ["url1", "url2", ...] 같은 배열로 왔다
- 기존 이미지 전부 삭제
- 새 배열 기준으로 bulkCreate로 다시 insert
- index를 sortOrder로 사용해서 순서까지 보존합니다.
- → “이미지 목록을 이 배열로 새로 세팅해 주세요”라는 의미입니다.
즉, 이미지 쪽은:
“기존 거 다 지우고, 새 배열로 갈아끼우는 구조”입니다.
구현이 단순하고, 프론트에서도 이해하기 쉬운 방식입니다.
※ 나중에 “일부만 바꾸기/순서만 변경” 같은 고급 기능이 필요하면
이 delete+insert 전략을 더 정교하게 바꾸면 됩니다.
MVP 단계에서는 이 방식이 안정적이고 충분하다고 판단했습니다.
2-6. delete(id)
**“게시글 한 건을 삭제하고, 실제로 삭제됐는지 확인하는 함수”**입니다.
const deleted = await PostModel.destroy({ where: { id } });
if (deleted === 0) throw 404;
- PostModel.destroy({ where: { id } })
- 조건에 맞는 row를 삭제합니다.
- 반환값 deleted는 삭제된 row 개수입니다.
- deleted === 0
- 해당 id의 게시글이 없었다는 뜻입니다.
- 그래서 404를 던져서 상위 레이어(서비스/컨트롤러)에서
- “이미 삭제되었거나 존재하지 않는 게시글입니다” 같은 응답을 주도록 할 수 있습니다.
참고로 DB 레벨에서 FK에 ON DELETE CASCADE를 걸어두었습니다.
- posts에서 특정 게시글이 삭제될 때
- post_images 테이블에서 연결된 이미지들도 자동으로 함께 삭제됩니다.
그래서 레포지토리에서는,
- 굳이 PostImageModel.destroy({ where: { postId: id } })를 따로 또 호출하지 않아도 되고,
- PostModel.destroy(...) 한 번으로 게시글 + 이미지 정리까지 끝낼 수 있습니다.
'Project Records BE > Project : DAMARA' 카테고리의 다른 글
| Node.js 백엔드 아키텍쳐 가이드. # 1. Model (0) | 2025.11.28 |
|---|