
본 문서는 공동구매 플랫폼의 백엔드 개발자로 참여하며 학습한 내용을 바탕으로, Node.js(Express) + Sequelize + MySQL을 활용한 백엔드 구조를 체계적으로 정리한 가이드입니다. 단순 구현 방식만을 나열하는 것이 아니라, 실제 개발 과정에서 마주한 문제 상황과 해결 과정, 그리고 그 안에서 얻게 된 인사이트를 함께 기록합니다.
이 문서에서는 Node.js 기반 백엔드를 구성하는 핵심 축인 모델(Model), 레포지토리(Repository), 서비스(Service), 컨트롤러(Controller), 그리고 애플리케이션의 시작점을 담당하는 서버(server.ts) 구조를 중심으로 설명합니다. 각 구성요소가 어떤 역할을 맡고 있으며, 실제 서비스 개발 과정에서 어떻게 유기적으로 연결되는지 실제 사례를 기반으로 서술합니다.
공동구매 서비스는 게시글 등록·조회·참여 로직, 사용자 관리, 이미지 처리, 신고 기능, 정산 프로세스 등 다양한 기능을 요구합니다. 이러한 기능은 모두 데이터 중심적으로 동작하기 때문에, 프로젝트의 기반은 결국 탄탄한 데이터 모델링과 이를 정확하게 반영하는 타입 기반 아키텍처에서 출발합니다.
따라서 문서의 첫 장은 Sequelize 모델 설계와 타입 정의를 이해하는 데 중점을 두며 5개의 포스트가 나갈 예정입니다.
- 1. Model: 데이터 스키마를 코드에서 정의하는 방식
- 2. Repository: 데이터베이스 접근 로직을 캡슐화하는 구조
- 3. Service: 비즈니스 로직을 모듈화하여 재사용성을 높이는 패턴
- 4. Controller: 요청과 응답을 처리하는 애플리케이션의 인터페이스
- 5. Server: 전체 시스템을 실행하고 구성하는 엔트리 포인트
이 문서는 공동구매 서비스 개발을 진행하며 경험한 실제 사례를 중심으로 작성되었으며, 개발 과정에서의 시행착오와 개선 사항을 기반으로 Node.js 백엔드 아키텍처의 실전적 이해와 학습 기록을 제공합니다.
앞으로 유사한 프로젝트를 진행하실 분들께 실용적인 참고 자료가 되기를 바라는 마음으로 작성하였습니다.
1. Model: Post.ts (데이터의 “정의”)
1-1. 타입 정의
export interface PostAttributes {
id: string;
authorId: string;
title: string;
content: string;
price: number;
minParticipants: number;
currentQuantity: number;
status: "open" | "closed" | "cancelled";
deadline: Date;
pickupLocation: string | null;
createdAt?: Date;
updatedAt?: Date;
}
이 ”posts 테이블의 컬럼을 TS로 그대로 옮긴 “정의서”
Sequelize 모델이 실제로 다루는 데이터 shape을 타입으로 고정해서,
- create 할 때 빠뜨리면 컴파일 시점에 잡히고
- 조회했을 때 어떤 필드가 있는지 자동완성으로 보입니다.
쉽게 말하면, Sequelize는 모델 정의를 코드에서 직접 만듭니다. TS 혹은 JS로 그 정의 자체가 하나의 스키마 역할을 하게되는 것입니다.
마이그레이션 파일은 DB에서 실제 테이블을 만들기 위한 SQL일뿐. 런타임에서 모델 정의를 보고 Sequelize가 DB와 통신하는 것입니다.
즉, model 자체가 스키마 역할을 하는 것이기에
DB 스키마에서 TS 타입을 다시 생성할 필요가 없는 것입니다.
1-2. CreationAttributes - 모델을 생성할때 어떤 ATT를 넣어야하는가?
해당 Post Model을 생성할때, 필요한 필드만을 남겨놓은 타입입니다.
DB에 자동 생성되는 값인 (id,timestamps) 이런 것들은 Optional로 처리해둔 타입이겠습니다.
즉, 사용자는 DB가 자동으로 만들어지는 값은 넣을 수 없으니, 사용자가 필수적으로 넣어야할 것과, 넣지 않아야할 attribute를 분리하는 것입니다.
물론 그냥 creationAttribute없이 써도 가능은합니다만, TS가 타입 안전성으로 계속 경고를 내겠습니다.
현재는 title, price, deadline, authorId, Images 라는 속성을 필수적으로 사용자에게 요구하고 있습니다.
그러나 만약 기획이 바뀌어서, 새로운 속성을 클라이언트에게 require해야한다고 가정한다면?
원래는 Post Model을 직접 수정해야하는 소요가 필요했지만, CreationAttribute의 Optional로 명시되어있는 Attribute만 제거해주면 되겠습니다.
export type PostCreationAttributes = Optional<
PostAttributes,
| "id"
| "minParticipants"
| "currentQuantity"
| "status"
| "pickupLocation"
| "createdAt"
| "updatedAt"
>;
Optional<T, K>는 create할 때 K에 해당하는 필드를 보내지 않아도 된다는 의미입니다.
예를 들어 Post 생성 시:
- id는 DB가 UUID로 자동 생성하고,
- minParticipants, currentQuantity, status 등은 default 값이 있으며,
- pickupLocation은 nullable,
- createdAt, updatedAt은 Sequelize가 자동으로 관리합니다.
따라서 생성 요청(create)에서는 Optional로 지정된 필드들은 보내지 않아도 됩니다.
만약 API에서 특정 필드를 필수(required) 또는 옵션(optional) 으로 변경하고 싶다면,
“Model(CreationAttributes)에서 필수/옵션을 수정한 후, Zod 검증 스키마에서도 동일하게 필수/옵션을 맞춰야 합니다.”
Zod는 Controller 입력 검증뿐 아니라 Swagger 문서 구조와 프론트 요청 형식에도 직접 영향을 주기 때문에,
Model과 Zod 스키마의 규칙이 다르면 타입 충돌과 실제 요청 오류가 발생합니다.
기존의 스웨거 문서

기획 변경 후 스웨거 문서

1-3. Sequelize Model 클래스
먼저 Sequelize가 뭔지부터 설명하겠습니다.
Node.js로 백엔드 프로젝트를 구성할때, MySQL이나 PostgreSQL같은 DB를 쉽게 쓰게 해주는 ORM 라이브러리입니다.
더 쉽게말하면 SQL 쿼리 직접 작성하기 귀찮으니, JS or TS 객체로 DB 테이블을 다루게 해주는 도구 입니다.
SELECT * FROM posts WHERE id = '123';
위의 id가 123인 데이터를 posts 테이블에서 가져오는 쿼리를
Sequelize에서는
PostModel.findbyPK(123);
이런식으로 객체처럼 사용 가능하게 하여
실제 데이터베이스 테이블을 클래스처럼 다루게 하는게 도와줍니다.
스프링에서의 Entity 선언 처럼, DB 테이블 하나가 Model 클래스 하나에 대응됩니다.
export class PostModel
extends Model<PostAttributes, PostCreationAttributes>
implements PostAttributes
{
public id!: string;
public authorId!: string;
public title!: string;
public content!: string;
public price!: number;
public minParticipants!: number;
public currentQuantity!: number;
public status!: "open" | "closed" | "cancelled";
public deadline!: Date;
public pickupLocation!: string | null;
public readonly createdAt!: Date;
public readonly updatedAt!: Date;
}
현재 이 클래스는 Sequelize의 Model을 상속받는것은 자명합니다. 그러나 뒤의 상속은 왜하는 것일까 의문일수도 있겠습니다.
Model을 상속받지만, 다루는 타입은 PostAttributes이며, create() 할때 넣는 데이터타입은 PostCreationAttributes이다는 것을 TS에게 알려주는 역할입니다.
좀 더 자세히보면,
extends Model<T, F>
- T (PostAttributes) = “DB에 실제로 존재하는 전체 필드의 타입”
- F (PostCreationAttributes) = “create() 할 때 넣을 수 있는(혹은 생략 가능한) 필드들 타입”
Sequelize의 제네릭 구조로는 대략 이런 느낌입니다.
class Model<TModelAttributes = any, TCreationAttributes = TModelAttributes> {
// ...
}
첫번째 제네릭으로 TModelAttributes - 모델의 실질적 필드
두번째 제네릭으로 TCreationAttributes - 모델의 생성 필드
따라서 저희 코드는
extends Model<PostAttributes, PostCreationAttributes>
이런식으로 제네릭을 주었던 것입니다.
기본 Att 필드와 생성자 필드를 나눈 것은 즉 sequlize Model Generic을 맞춰주긴 위함이라고 할 수도 있겠습니다.
그럼 실제로 상속이 무슨 의미가 있을까요?
당연히 sequelize 의 ORM 기능을 상속하겠습니다.
- findAll, findByPk, create, update, destroy, save 같은 메서드
- 즉, DB랑 실제로 통신하는 로직은 전부 Model에 들어있고,
- PostModel은 “이 테이블은 이런 필드를 가진다”라고 선언만 하는 구조
또한, 제네릭 타입 정보 결합 또한 담당합니다.
우리가 지정한 PostAttributes, PostCreationAttributes가 반영되도록 합니다.
- 그 메서드들의 인자/리턴 타입에
- 우리가 지정한 PostAttributes, PostCreationAttributes가 반영되도록 함
자세히 설명하면,
extends Model<PostAttributes, PostCreationAttributes>를 사용한다는 것은, 이 모델이 Sequelize의 모든 기능을 상속받으면서 동시에 “이 모델은 어떤 형태의 데이터를 입력받고, 어떤 형태의 데이터를 반환하는지”를 TypeScript에게 정확하게 알려주는 과정이라고 이해하시면 됩니다.
이렇게 타입 정보를 주입해두면, Sequelize가 제공하는 각종 메서드의 동작 방식이 모두 그 타입 정의를 기반으로 바뀌게 됩니다.
예를 들어 create()를 호출할 때는 PostCreationAttributes를 기준으로 어떤 필드를 넣을 수 있는지, 어떤 필드는 넣으면 안 되는지를 TypeScript가 정확하게 검사합니다.
생성 시 자동으로 채워지는 id, createdAt, updatedAt 같은 값은 넣으면 오히려 에러가 발생하고, 반대로 title, content, price처럼 꼭 필요한 값이 빠져 있으면 그 역시 타입 오류로 잡아줍니다. 덕분에 개발자가 모든 스키마 규칙을 외우고 있을 필요 없이, TypeScript가 잘못된 호출을 사전에 막아주는 구조가 됩니다.
데이터를 조회할 때도 같은 개념이 적용됩니다. sequelize의 ORM 메서드인findByPk()나 findOne()으로 결과를 불러오면, 반환되는 객체는 PostAttributes를 구현한 PostModel 인스턴스로 간주되기 때문에 post.id는 문자열, post.price는 숫자, post.createdAt은 Date 타입이라는 점을 TypeScript가 정확하게 알고 있습니다. 존재하지 않는 필드에 접근하거나 잘못된 타입으로 값을 대입하려 하면 즉시 오류를 발생시켜 줍니다. TS의 철학과도 맞닿아 있겠습니다.
업데이트 시에도 동일합니다. update() 메서드에 전달하는 값은 Partial<PostAttributes> 형태로 처리되기 때문에, 스키마에 없는 필드를 전달하면 타입 오류가 생기고, 인스턴스를 직접 수정할 때도 필드 타입이 자동으로 검증됩니다. 예를 들어 post.price = "aaa"처럼 잘못된 타입을 대입하면 바로 에러가 발생하고, 정의되지 않은 필드에 접근하려 하면 그것 역시 허용되지 않습니다.
정리하면, 단순히 ORM 기능을 상속하는 것이 아니라, “**이 모델은 DB에서 어떤 구조를 가진 데이터로 동작하며, 생성·조회·수정 과정에서 어떤 타입 규칙을 따라야 한다”**는 정보를 TypeScript 수준에서 강하게 적용하는 역할을 합니다. 이러한 구조 덕분에 개발 과정에서 타입 실수가 크게 줄어들고, 런타임에서 발생할 수 있는 오류들을 코드 작성 단계에서 미리 잡아낼 수 있어 훨씬 안정적인 개발이 가능해집니다.
1-4. init (실제 DB 컬럼 매핑)
init() 메서드는 Sequelize 모델의 핵심입니다.
여기서 실제 MySQL/PostGreSQL 테이블의 모든 컬럼이 정의됩니다.
PostModel.init(
{
id: {
type: DataTypes.UUID,
primaryKey: true,
defaultValue: DataTypes.UUIDV4,
},
authorId: {
type: DataTypes.UUID,
allowNull: false,
field: "author_id",
references: {
model: UserModel,
key: "id",
},
},
title: {
type: DataTypes.STRING(200),
allowNull: false,
},
content: {
type: DataTypes.TEXT,
allowNull: false,
},
price: {
type: DataTypes.DECIMAL(10, 2),
allowNull: false,
},
minParticipants: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 1,
field: "min_participants",
},
currentQuantity: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 0,
field: "current_quantity",
},
status: {
type: DataTypes.ENUM("open", "closed", "cancelled"),
allowNull: false,
defaultValue: "open",
},
deadline: {
type: DataTypes.DATE,
allowNull: false,
},
pickupLocation: {
type: DataTypes.STRING(200),
allowNull: true,
field: "pickup_location",
},
},
{
sequelize,
tableName: "posts",
timestamps: true, // createdAt, updatedAt 자동 관리
underscored: true, // created_at / updated_at 자동 snake_case
}
);
크게 첫번째 인자, 두번째 인자로 나눌 수 있겠습니다.
첫번째 인자는 컬럼 정의 객체입니다.
각 Attribute가 어떤 특성을 지녔는지를 기입하겠습니다. 여기서 카멜케이스로 작성하는 이유는 JS/TS 코드의 컨벤션을 따르면서 DB의 스네이크 케이스와 깔끔하게 분리하기 위함입니다.
두번째 인자는 모델 전역 옵션입니다.
매핑할 실제 테이블을 담고, timestamps와 같은 기능을 추가하여, cratedAt, updateAt을 자동관리하며
underscored 설정을 켜 컬럼이름을 기존 컨벤션인 스네이크 케이스로 합니다.
1-5. 관계 설정(Associations)
기본적으로 관계기반 모델이므로 다른 테이블과의 관계 설정은 필수적입니다.
UserModel.hasMany(PostModel, { foreignKey: "authorId", as: "posts" });
PostModel.belongsTo(UserModel, { foreignKey: "authorId", as: "author" });
참조를 제공하는 쪽(1:N 관계에서 “1”에 해당) → hasMany / hasOne
- 예: UserModel.hasMany(PostModel, { foreignKey: "authorId" })
- → “User 한 명이 여러 Post를 가진다”는 의미겠습니다.
참조를 받는 쪽(외래키를 실제로 들고 있는 “N” 쪽) → belongsTo
- 예: PostModel.belongsTo(UserModel, { foreignKey: "authorId" })
- → “Post는 authorId 외래키를 통해 User에 속한다”는 뜻.
그래서 hasMany가 선언된 모델이 참조를 제공하고(User), belongsTo가 선언된 모델이 외래키를 갖고 참조를 받는(Post) 구조라고 이해하면 됩니다.
참조받는 참조하는 두 관계를 동시에 정의해야한다는 것입니다.
'Project Records BE > Project : DAMARA' 카테고리의 다른 글
| Node.js 백엔드 아키텍쳐 가이드. # 2. Repository (0) | 2025.11.30 |
|---|