๋น์ฆ๋์ค ๋ก์ง์ด๋?
๋น์ฆ๋์ค ๋ก์ง์ ์ ํ๋ฆฌ์ผ์ด์ ์ ํต์ฌ ๊ท์น๊ณผ ํ๋ก์ธ์ค์ ๋๋ค:๋ฐ์ดํฐ ๊ฒ์ฆ
์
๋ ฅ๊ฐ ์ ํจ์ฑ ๊ฒ์ฌ์ค๋ณต ์ฒดํฌ, ํ์ ๊ฒ์ฆ
๋ฐ์ดํฐ ๋ณํ
๋ฐ์ดํฐ ๊ฐ๊ณต ๋ฐ ๊ณ์ฐ์ํธํ, ํฌ๋งท ๋ณํ
๋๋ฉ์ธ ๊ท์น
๋น์ฆ๋์ค ์ ์ฝ ์กฐ๊ฑด๊ถํ, ์ํ ์ ์ด ๊ท์น
์ํฌํ๋ก์ฐ
๋ณต์กํ ํ๋ก์ธ์ค์ฌ๋ฌ ๋จ๊ณ์ ์์
์กฐ์จ
๋น์ฆ๋์ค ๋ก์ง ์์ฑ ์์น
1. Model์ ์ง์ค
๋น์ฆ๋์ค ๋ก์ง์ Model์๋ง ์์ฑํฉ๋๋ค.๋ณต์ฌ
class UserModelClass extends BaseModelClass {
async save(params: UserSaveParams[]): Promise<number[]> {
// โ
๋น์ฆ๋์ค ๋ก์ง: Model์ ์์ฑ
for (const param of params) {
if (param.age < 18) {
throw new BadRequestException("18์ธ ๋ฏธ๋ง์ ๊ฐ์
ํ ์ ์์ต๋๋ค");
}
const existing = await this.puri()
.where("email", param.email)
.first();
if (existing) {
throw new BadRequestException("์ด๋ฏธ ์ฌ์ฉ์ค์ธ ์ด๋ฉ์ผ์
๋๋ค");
}
}
// ์ ์ฅ...
}
}
2. ํ์ ์์ ์ฑ
Zod ์คํค๋ง๋ก ํ์ ์ ๊ฒ์ฆํฉ๋๋ค.types.ts
๋ณต์ฌ
export const UserSaveParams = z.object({
id: z.number().optional(),
email: z.string().email("์ฌ๋ฐ๋ฅธ ์ด๋ฉ์ผ ํ์์ด ์๋๋๋ค"),
username: z.string()
.min(2, "์ด๋ฆ์ ์ต์ 2์ ์ด์์ด์ด์ผ ํฉ๋๋ค")
.max(50, "์ด๋ฆ์ ์ต๋ 50์๊น์ง ๊ฐ๋ฅํฉ๋๋ค"),
age: z.number()
.int("๋์ด๋ ์ ์์ฌ์ผ ํฉ๋๋ค")
.min(0, "๋์ด๋ 0 ์ด์์ด์ด์ผ ํฉ๋๋ค")
.max(150, "์ ํจํ์ง ์์ ๋์ด์
๋๋ค"),
password: z.string()
.min(8, "๋น๋ฐ๋ฒํธ๋ ์ต์ 8์ ์ด์์ด์ด์ผ ํฉ๋๋ค")
.regex(/[A-Z]/, "๋๋ฌธ์๋ฅผ ํฌํจํด์ผ ํฉ๋๋ค")
.regex(/[0-9]/, "์ซ์๋ฅผ ํฌํจํด์ผ ํฉ๋๋ค"),
});
model.ts
๋ณต์ฌ
async save(params: UserSaveParams[]): Promise<number[]> {
// Zod๊ฐ ์๋์ผ๋ก ๊ฒ์ฆ
// ํ์
๋ ์๋์ผ๋ก ์ถ๋ก ๋จ
}
3. ํธ๋์ญ์ ์ฌ์ฉ
๊ด๋ จ๋ ์ฌ๋ฌ ์์ ์ ํธ๋์ญ์ ์ผ๋ก ๋ฌถ์ต๋๋ค.๋ณต์ฌ
@transactional()
async createUserWithProfile(
userData: UserData,
profileData: ProfileData
): Promise<{ userId: number; profileId: number }> {
// ๋ชจ๋ ์์
์ด ํ๋์ ํธ๋์ญ์
const [userId] = await this.save([userData]);
const [profileId] = await ProfileModel.save([
{ ...profileData, user_id: userId }
]);
// ์ฑ๊ณต ์ ์๋ ์ปค๋ฐ
// ์คํจ ์ ์๋ ๋กค๋ฐฑ
return { userId, profileId };
}
๋ฐ์ดํฐ ๊ฒ์ฆ ํจํด
์ค๋ณต ์ฒดํฌ
๋ณต์ฌ
async save(params: UserSaveParams[]): Promise<number[]> {
// ์ด๋ฉ์ผ ์ค๋ณต ์ฒดํฌ
for (const param of params) {
const existing = await this.puri()
.where("email", param.email)
.whereNot("id", param.id ?? 0) // ์์ ์ ์์ ์ ์ธ
.first();
if (existing) {
throw new BadRequestException(
`์ด๋ฏธ ์ฌ์ฉ์ค์ธ ์ด๋ฉ์ผ์
๋๋ค: ${param.email}`
);
}
}
// ์ ์ฅ...
}
์กฐ๊ฑด๋ถ ๊ฒ์ฆ
๋ณต์ฌ
async save(params: UserSaveParams[]): Promise<number[]> {
for (const param of params) {
// ์ ๊ท ์ฌ์ฉ์๋ง ์ด๋ฉ์ผ ์ธ์ฆ ํ์
if (!param.id && !param.is_verified) {
throw new BadRequestException(
"์ด๋ฉ์ผ ์ธ์ฆ์ด ํ์ํฉ๋๋ค"
);
}
// ๊ด๋ฆฌ์๋ง ๋ค๋ฅธ ์ฌ์ฉ์์ ์ญํ ๋ณ๊ฒฝ ๊ฐ๋ฅ
if (param.role && param.role !== "normal") {
const context = Sonamu.getContext();
if (context.user?.role !== "admin") {
throw new ForbiddenException(
"๊ด๋ฆฌ์๋ง ์ญํ ์ ๋ณ๊ฒฝํ ์ ์์ต๋๋ค"
);
}
}
}
// ์ ์ฅ...
}
๋ฒ์ ๊ฒ์ฆ
๋ณต์ฌ
async save(params: ProductSaveParams[]): Promise<number[]> {
for (const param of params) {
// ๊ฐ๊ฒฉ ๋ฒ์ ๊ฒ์ฆ
if (param.price < 0) {
throw new BadRequestException("๊ฐ๊ฒฉ์ 0 ์ด์์ด์ด์ผ ํฉ๋๋ค");
}
if (param.price > 1000000) {
throw new BadRequestException("๊ฐ๊ฒฉ์ 100๋ง์ ์ดํ์ฌ์ผ ํฉ๋๋ค");
}
// ํ ์ธ์จ ๊ฒ์ฆ
if (param.discount_rate && (param.discount_rate < 0 || param.discount_rate > 100)) {
throw new BadRequestException("ํ ์ธ์จ์ 0~100 ์ฌ์ด์ฌ์ผ ํฉ๋๋ค");
}
}
// ์ ์ฅ...
}
๋ฐ์ดํฐ ๋ณํ ํจํด
์ํธํ
๋ณต์ฌ
async save(params: UserSaveParams[]): Promise<number[]> {
// ๋น๋ฐ๋ฒํธ ํด์ฑ
const hashedParams = params.map(p => ({
...p,
password: bcrypt.hashSync(p.password, 10),
}));
const wdb = this.getPuri("w");
hashedParams.forEach(p => wdb.ubRegister("users", p));
return wdb.transaction(async (trx) => {
return trx.ubUpsert("users");
});
}
์ ๊ทํ
๋ณต์ฌ
async save(params: UserSaveParams[]): Promise<number[]> {
// ๋ฐ์ดํฐ ์ ๊ทํ
const normalizedParams = params.map(p => ({
...p,
email: p.email.toLowerCase().trim(), // ์ด๋ฉ์ผ ์๋ฌธ์ ๋ณํ
username: p.username.trim(), // ๊ณต๋ฐฑ ์ ๊ฑฐ
phone: p.phone?.replace(/[^0-9]/g, ""), // ์ ํ๋ฒํธ ์ซ์๋ง
}));
// ์ ์ฅ...
}
๊ณ์ฐ ํ๋
๋ณต์ฌ
async save(params: ProductSaveParams[]): Promise<number[]> {
// ํ ์ธ๊ฐ ์๋ ๊ณ์ฐ
const calculatedParams = params.map(p => ({
...p,
discounted_price: p.price * (1 - (p.discount_rate ?? 0) / 100),
final_price: Math.floor(p.price * (1 - (p.discount_rate ?? 0) / 100)),
}));
// ์ ์ฅ...
}
์ํ ์ ์ด ํจํด
์ํ ๊ธฐ๊ณ
๋ณต์ฌ
const OrderStatusTransitions = {
pending: ["confirmed", "cancelled"],
confirmed: ["shipping", "cancelled"],
shipping: ["delivered", "cancelled"],
delivered: ["returned"],
cancelled: [],
returned: [],
};
async updateStatus(
orderId: number,
newStatus: OrderStatus
): Promise<void> {
const order = await this.findById("A", orderId);
// ์ํ ์ ์ด ๊ฒ์ฆ
const allowedTransitions = OrderStatusTransitions[order.status];
if (!allowedTransitions.includes(newStatus)) {
throw new BadRequestException(
`${order.status}์์ ${newStatus}๋ก ๋ณ๊ฒฝํ ์ ์์ต๋๋ค`
);
}
// ์ํ ๋ณ๊ฒฝ
await this.getPuri("w")
.table("orders")
.where("id", orderId)
.update({ status: newStatus });
}
๊ถํ ๊ธฐ๋ฐ ์ํ ๋ณ๊ฒฝ
๋ณต์ฌ
async updateStatus(
orderId: number,
newStatus: OrderStatus
): Promise<void> {
const context = Sonamu.getContext();
const order = await this.findById("A", orderId);
// ๊ถํ ๊ฒ์ฆ
if (newStatus === "cancelled") {
// ์ฌ์ฉ์๋ pending๋ง ์ทจ์ ๊ฐ๋ฅ
if (order.status !== "pending" && context.user?.role !== "admin") {
throw new ForbiddenException(
"๊ด๋ฆฌ์๋ง ํ์ ๋ ์ฃผ๋ฌธ์ ์ทจ์ํ ์ ์์ต๋๋ค"
);
}
}
if (newStatus === "delivered") {
// ๋ฐฐ์ก ์๋ฃ๋ ๋ฐฐ์ก ๋ด๋น์๋ง ๊ฐ๋ฅ
if (context.user?.role !== "delivery") {
throw new ForbiddenException(
"๋ฐฐ์ก ๋ด๋น์๋ง ๋ฐฐ์ก ์๋ฃ ์ฒ๋ฆฌํ ์ ์์ต๋๋ค"
);
}
}
// ์ํ ๋ณ๊ฒฝ...
}
๋ณต์กํ ์ฟผ๋ฆฌ ํจํด
์กฐ๊ฑด๋ถ ์ฟผ๋ฆฌ ๋น๋ฉ
๋ณต์ฌ
async findMany<T extends UserSubsetKey>(
subset: T,
params: UserListParams
): Promise<ListResult<UserSubsetMapping[T]>> {
const { qb } = this.getSubsetQueries(subset);
// ์กฐ๊ฑด๋ถ ํํฐ๋ง
if (params.role) {
qb.whereIn("users.role", asArray(params.role));
}
if (params.is_active !== undefined) {
qb.where("users.is_active", params.is_active);
}
if (params.created_after) {
qb.where("users.created_at", ">=", params.created_after);
}
if (params.keyword) {
qb.where((builder) => {
builder
.whereLike("users.email", `%${params.keyword}%`)
.orWhereLike("users.username", `%${params.keyword}%`);
});
}
// ์คํ...
return this.executeSubsetQuery({ subset, qb, params });
}
๋ณต์กํ JOIN
๋ณต์ฌ
async findUsersWithStats(): Promise<UserWithStats[]> {
const rdb = this.getDB("r");
return rdb("users")
.select([
"users.*",
rdb.raw("COUNT(DISTINCT posts.id) as post_count"),
rdb.raw("COUNT(DISTINCT comments.id) as comment_count"),
rdb.raw("AVG(posts.views) as avg_post_views"),
])
.leftJoin("posts", "posts.user_id", "users.id")
.leftJoin("comments", "comments.user_id", "users.id")
.groupBy("users.id");
}
์๋ธ์ฟผ๋ฆฌ ํ์ฉ
๋ณต์ฌ
async findTopUsers(limit: number): Promise<User[]> {
const rdb = this.getDB("r");
const subquery = rdb("orders")
.select("user_id")
.sum("total_price as total_spent")
.groupBy("user_id")
.orderBy("total_spent", "desc")
.limit(limit)
.as("top_users");
return rdb("users")
.select("users.*", "top_users.total_spent")
.joinRaw("JOIN ? ON users.id = top_users.user_id", [subquery]);
}
ํธ๋์ญ์ ํจํด
๊ธฐ๋ณธ ํธ๋์ญ์
๋ณต์ฌ
@transactional()
async transferPoints(
fromUserId: number,
toUserId: number,
points: number
): Promise<void> {
// ํฌ์ธํธ ์ฐจ๊ฐ
const fromUser = await this.findById("A", fromUserId);
if (fromUser.points < points) {
throw new BadRequestException("ํฌ์ธํธ๊ฐ ๋ถ์กฑํฉ๋๋ค");
}
await this.getPuri("w")
.table("users")
.where("id", fromUserId)
.decrement("points", points);
// ํฌ์ธํธ ์ฆ๊ฐ
await this.getPuri("w")
.table("users")
.where("id", toUserId)
.increment("points", points);
// ๋ ๋ค ์ฑ๊ณตํ๊ฑฐ๋ ๋ ๋ค ๋กค๋ฐฑ๋จ
}
์๋ ํธ๋์ญ์
๋ณต์ฌ
async complexOperation(): Promise<void> {
const wdb = this.getPuri("w");
await wdb.transaction(async (trx) => {
// 1๋จ๊ณ
const [userId] = await trx
.table("users")
.insert({ email: "[email protected]" });
// 2๋จ๊ณ
await trx
.table("profiles")
.insert({ user_id: userId, bio: "Hello" });
// 3๋จ๊ณ
await trx
.table("settings")
.insert({ user_id: userId, theme: "dark" });
// ๋ชจ๋ ์ฑ๊ณตํ๋ฉด ์ปค๋ฐ
// ํ๋๋ผ๋ ์คํจํ๋ฉด ์ ์ฒด ๋กค๋ฐฑ
});
}
์ค์ฒฉ ํธ๋์ญ์
๋ณต์ฌ
@transactional()
async outerTransaction(): Promise<void> {
// ์ธ๋ถ ํธ๋์ญ์
await this.save([{ email: "[email protected]" }]);
// ๋ด๋ถ ํธ๋์ญ์
(๊ฐ์ ํธ๋์ญ์
์ฌ์ฌ์ฉ)
await this.innerTransaction();
}
@transactional()
async innerTransaction(): Promise<void> {
// ์ด๋ฏธ ํธ๋์ญ์
์ด ์์ผ๋ฉด ์ฌ์ฌ์ฉ
await this.save([{ email: "[email protected]" }]);
}
์๋ฌ ์ฒ๋ฆฌ ํจํด
๋ช ํํ ์๋ฌ ๋ฉ์์ง
๋ณต์ฌ
async login(params: LoginParams): Promise<{ user: User }> {
const user = await this.puri()
.where("email", params.email)
.first();
if (!user) {
throw new UnauthorizedException(
"์ด๋ฉ์ผ ๋๋ ๋น๋ฐ๋ฒํธ๊ฐ ์ผ์นํ์ง ์์ต๋๋ค"
);
}
const isValid = await bcrypt.compare(params.password, user.password);
if (!isValid) {
throw new UnauthorizedException(
"์ด๋ฉ์ผ ๋๋ ๋น๋ฐ๋ฒํธ๊ฐ ์ผ์นํ์ง ์์ต๋๋ค"
);
}
if (!user.is_verified) {
throw new ForbiddenException(
"์ด๋ฉ์ผ ์ธ์ฆ์ด ์๋ฃ๋์ง ์์์ต๋๋ค. ์ธ์ฆ ๋ฉ์ผ์ ํ์ธํด์ฃผ์ธ์."
);
}
return { user };
}
์ปค์คํ ์์ธ
๋ณต์ฌ
class InsufficientPointsException extends BadRequestException {
constructor(required: number, current: number) {
super(`ํฌ์ธํธ๊ฐ ๋ถ์กฑํฉ๋๋ค. ํ์: ${required}, ํ์ฌ: ${current}`);
}
}
async purchaseItem(userId: number, itemId: number): Promise<void> {
const user = await this.findById("A", userId);
const item = await ItemModel.findById("A", itemId);
if (user.points < item.price) {
throw new InsufficientPointsException(item.price, user.points);
}
// ๊ตฌ๋งค ์ฒ๋ฆฌ...
}
๋น๋๊ธฐ ์ฒ๋ฆฌ ํจํด
๋ณ๋ ฌ ์ฒ๋ฆฌ
๋ณต์ฌ
async getUserDashboard(userId: number): Promise<Dashboard> {
// ๋ณ๋ ฌ๋ก ์คํ
const [user, posts, comments, followers] = await Promise.all([
this.findById("A", userId),
PostModel.findMany("SS", { user_id: userId, num: 10 }),
CommentModel.findMany("SS", { user_id: userId, num: 10 }),
FollowerModel.countFollowers(userId),
]);
return {
user,
posts: posts.rows,
comments: comments.rows,
follower_count: followers,
};
}
์์ฐจ ์ฒ๋ฆฌ
๋ณต์ฌ
async processOrder(orderId: number): Promise<void> {
// ์์ฐจ์ ์ผ๋ก ์คํ
const order = await this.findById("A", orderId);
// ์ฌ๊ณ ํ์ธ
await InventoryModel.checkStock(order.product_id, order.quantity);
// ๊ฒฐ์ ์ฒ๋ฆฌ
await PaymentModel.charge(order.user_id, order.total_price);
// ์ฃผ๋ฌธ ํ์
await this.updateStatus(orderId, "confirmed");
// ๋ฐฐ์ก ์์
await DeliveryModel.startShipping(orderId);
}