비즈니스 로직이란?
비즈니스 로직은 애플리케이션의 핵심 규칙과 프로세스입니다:데이터 검증
입력값 유효성 검사중복 체크, 형식 검증
데이터 변환
데이터 가공 및 계산암호화, 포맷 변환
도메인 규칙
비즈니스 제약 조건권한, 상태 전이 규칙
워크플로우
복잡한 프로세스여러 단계의 작업 조율
비즈니스 로직 작성 원칙
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);
}
