오늘 개모임 발표 서두에 최근에 본 영상 중 AI에 관한 개인적인 생각을 정리한 글이 있어 이번에 이 이야기를 한번 해보면서 근황도 좀 풀고 그럴까 한다.
2.0 (링크임!!!)
위 이야기는 직접적인 개발 이야기는 아니지만 간접적으로 개발 패러다임의 변화에 대한 내 생각을 많이 포함하고 있다.
B2B는 여전히 아직까지도 틀 만들기다.
저번에 이어서 백오피스 관련된 여러 가지 기능들을 많이 업데이트했다.
특히 개발한 기능 중에 좀 뽑으라면 클라우드 스케줄러 관리 기능, 롤 분기 완성, 친구 초대 기능 리뉴얼 등이 기억에 남는 것 같다.
Google Cloud Scheduler를 활용한 작업 스케줄링 관리 시스템을 구축했다. 이전에는 cron job을 수동으로 관리했는데, 이제는 어드민에서 모든 스케줄을 한눈에 보고 관리할 수 있게 되었다.
TypeScript// Cloud Scheduler Service 핵심 구현 @Injectable() export class CloudSchedulerService { private client: CloudSchedulerClient private projectId: string private locationId: string async listJobs() { const parent = `projects/${this.projectId}/locations/${this.locationId}` const [jobs] = await this.client.listJobs({ parent }) return jobs.map((job) => this.formatJobResponse(job)) } async createJob(name: string, schedule: string, httpTarget: any) { const job = { name: `${this.getParent()}/jobs/${name}`, schedule, timeZone: "Asia/Seoul", httpTarget, } const [createdJob] = await this.client.createJob({ parent: this.getParent(), job, }) return this.formatJobResponse(createdJob) } }


이런식으로 설정하면


실제 GCP의 NestJS를 통해 Cloud Schduler에도 설정되게 만들었다.
지난번에 잠깐 언급했었던, Role-Based Access Control(RBAC) 시스템을 드디어 완성했다. 가장 큰 변화는 단순한 role 구분에서 벗어나 세밀한 권한 관리가 가능해졌다는 점이다.
핵심 철학: "정의는 코드로, 구성은 데이터로"
이게 무슨 말이냐면:
이렇게 하면 권한 자체는 명확하게 관리되면서도, 운영 중에 유연하게 역할을 조정할 수 있다!
1. Permissions (권한)
TypeScript// 시스템에서 가능한 모든 액션을 정의 @Schema({ collection: 'permissions' }) export class Permissions { @Prop({ required: true, unique: true }) name: string; // 'user:create', 'product:read' 등 @Prop({ required: true }) displayName: string; // '사용자 생성', '상품 조회' 등 @Prop({ required: true }) domain: string; // 'USER', 'PRODUCT', 'ORDER' 등 @Prop({ required: true }) action: string; // 'CREATE', 'READ', 'UPDATE', 'DELETE' @Prop({ type: Types.ObjectId, ref: 'Menu' }) menuId: Types.ObjectId | null; // 이 권한과 연결된 메뉴 }
2. Role (역할)
TypeScript// 권한들의 묶음, 사용자에게 할당되는 단위 @Schema({ collection: 'roles' }) export class Role { @Prop({ required: true, unique: true }) name: string; // '콘텐츠 관리자', '파트너 관리자' 등 @Prop({ default: true }) isEditable: boolean; // SuperAdmin은 false로 보호 @Prop({ type: [{ type: Types.ObjectId, ref: 'Permissions' }] }) permissions: PermissionsDocument[]; // 이 역할이 가진 권한들 }
3. RbacUser (어드민 사용자)
TypeScript// 백오피스 접근 권한을 가진 사용자 @Schema({ collection: 'rbac_users' }) export class RbacUser { @Prop({ required: true }) email: string; @Prop({ required: true }) name: string; @Prop({ type: [{ type: Types.ObjectId, ref: 'Role' }] }) roles: RoleDocument[]; // 사용자가 가진 역할들 @Prop({ type: [{ type: Types.ObjectId, ref: 'Permissions' }] }) permissions: PermissionsDocument[]; // 추가 개별 권한 @Prop({ enum: ['active', 'invited', 'disabled'] }) status: string; // 계정 상태 }
4. Menu (동적 메뉴)
TypeScript// 권한에 따라 동적으로 보여지는 메뉴 @Schema({ collection: 'menus' }) export class Menu { @Prop({ required: true }) title: string; // '상품 관리', '주문 관리' 등 @Prop({ required: true }) path: string; // '/product-management', '/order-management' @Prop({ type: Types.ObjectId, ref: 'Permissions' }) permissionId: Types.ObjectId; // 이 메뉴를 보기 위한 권한 @Prop({ default: 0 }) order: number; // 메뉴 표시 순서 }
1. 권한 정의 (코드에서 관리)
TypeScript// unified-permission.constants.ts export const UNIFIED_PERMISSIONS = [ { name: 'product:read', displayName: '상품 조회', domain: 'PRODUCT', action: 'READ', hasMenu: true, menuInfo: { title: '상품 관리', path: '/product-management', icon: 'box' } }, { name: 'product:create', displayName: '상품 생성', domain: 'PRODUCT', action: 'CREATE', hasMenu: false // 메뉴 없이 버튼 권한만 } // ... 더 많은 권한들 ];
2. 역할 생성 및 권한 할당 (관리자 UI에서)
TypeScript// Permission Service async createRole(createDto: CreateRoleDto) { // 1. 권한 유효성 검증 (실제로 존재하는 권한인지) const validPermissions = await this.permissionsModel.find({ _id: { $in: createDto.permissions } }); // 2. 새 역할 생성 const role = new this.roleModel({ name: '콘텐츠 매니저', description: '상품과 배너를 관리하는 역할', permissions: validPermissions.map(p => p._id), isEditable: true }); await role.save(); // 3. 권한 캐시 갱신 (성능 최적화) await this.updatePermissionCache(); }
3. 사용자 로그인 시 권한 체크
TypeScript// Auth Service async login(email: string, password: string) { const user = await this.rbacUserModel .findOne({ email }) .populate('roles') .populate('permissions'); // 역할의 권한 + 개별 권한 합치기 const allPermissions = this.mergePermissions( user.roles.flatMap(role => role.permissions), user.permissions ); // JWT에 권한 정보 포함 return this.jwtService.sign({ userId: user._id, email: user.email, permissions: allPermissions.map(p => p.name) }); }
4. 메뉴 동적 생성
TypeScript// Menu Service async getUserMenus(userId: string) { const user = await this.getUserWithPermissions(userId); const userPermissionNames = user.permissions.map(p => p.name); // 사용자 권한에 따른 메뉴만 필터링 const menus = await this.menuModel .find({ isActive: true }) .populate('permissionId') .sort({ order: 1 }); return menus.filter(menu => userPermissionNames.includes(menu.permissionId.name) ); }
5. API 권한 체크 (Guards)
TypeScript// 특정 권한이 필요한 API @UseGuards(PermissionsGuard) @RequirePermissions('product:create') @Post('/products') async createProduct(@Body() dto: CreateProductDto) { // 권한이 있는 사용자만 실행 가능 return this.productService.create(dto); }
이렇게 구성하니까 새로운 기능 추가할 때는:





거의 코드 수정 없이 역할과 권한을 자유롭게 조합할 수 있어서 정말 편하다.
기존의 단순한 추천인 코드 시스템을 전면 개편했다. 이제는 추천 보상 추적, 마일리지 자동 지급, 네이버 포인트 연동까지 지원한다.
TypeScript// Referral Service 핵심 로직 @Injectable() export class ReferralService { private readonly REFERRAL_REWARD_AMOUNT = 2000 private readonly MAX_REFERRALS_PER_USER = 5 private readonly MAX_TOTAL_REWARD = 10000 async getUserReferralCode(userId: number) { // 기존 코드 확인 let referralUser = await this.referralUserModel.findOne({ userId }).exec() if (!referralUser) { // 새 추천인 코드 생성 const code = await this.generateSimpleReferralCode(userId) referralUser = new this.referralUserModel({ userId, referralCode: code, totalReferrals: 0, totalMileageEarned: 0, confirmedReferrals: 0, }) await referralUser.save() } return { code: referralUser.referralCode } } async processReferralReward(referrerId: number, referredUserId: number) { // 1. 추천 횟수 확인 const referralUser = await this.referralUserModel.findOne({ userId: referrerId, }) if (referralUser.totalReferrals >= this.MAX_REFERRALS_PER_USER) { throw new BadRequestException("최대 추천 횟수 초과") } // 2. 추천 이벤트 생성 const event = new this.referralEventModel({ referrerId, referredUserId, type: ReferralEventType.FRIEND_REFERRAL, status: ReferralEventStatus.PENDING, rewardAmount: this.REFERRAL_REWARD_AMOUNT, }) await event.save() // 3. 첫 구매 후 자동 보상 지급 스케줄링 await this.scheduleRewardCheck(event._id) } }



이 이야기에 앞서, admin api에 관련되어서 조금 설명이 필요할 것 같다. 개모임 분들은 아시다시피 우리 회사 데이터베이스가 일전에 너어어어무 잘 짜여져 있었어서... 현재는
PLAIN┌─────────────┐ ┌───────────────┐ ┌──────────────────┐ │ │ │ │ │ │ │ Admin React │────▶│ Admin NestJS │────▶│ Service NestJS │ │ │ │ (Gateway) │ │ │ └─────────────┘ └───────────────┘ └──────────────────┘ │ │ │ │ │ │ └────── JWT Auth ────┴───────────────────────┘ │ └─── RBAC Permission ───┘
이와 같은 모습을 하고 있다... 나도 실제 운영되는 Service단에서 Admin 관련된 api가 있으면 너무 안 좋다는 사실을 잘 알고 있지만, 너어어무 더러운 테이블을 Admin NestJS 서버에 이 postgres 엔티티를 복사해서 다 가지고 오는 것은 용납이 안되었다.
그러나 어쩔 수 없었다...
따라서 지금 아키텍처는 위에서처럼 어드민용 계정으로 접속하면 Admin NestJS에서 어드민용 인증을 만들고, 이걸 Admin Frontend에서 운영 서비스의 데이터를 수정해야 될 시 아까 만든 Admin NestJS의 인증이 Service NestJS에서 특수한 Guards를 통해 인증되서 어드민만 쓸 수 있는 api들이 보호되고 있는 형식이다.
TypeScript// Service NestJS의 Admin Guard 구현 @Injectable() export class AdminBackendGuard implements CanActivate { constructor( private readonly httpService: HttpService, private readonly configService: ConfigService ) {} async canActivate(context: ExecutionContext): Promise<boolean> { const request = context.switchToHttp().getRequest() const adminToken = request.headers["x-admin-token"] if (!adminToken) { throw new UnauthorizedException("Admin token required") } // Admin NestJS로 토큰 검증 요청 const verifyUrl = `${this.configService.get("ADMIN_API_URL")}/auth/verify` try { const response = await this.httpService .post(verifyUrl, { token: adminToken, }) .toPromise() // 검증된 관리자 정보를 request에 주입 request.adminUser = response.data.user request.permissions = response.data.permissions return true } catch (error) { throw new UnauthorizedException("Invalid admin token") } } }
따라서 현재 우리 개발팀의 솔루션은 아마 다음과 같이 진행될 것 같다.
현재 상태:
예전 글에 기술했다시피, MongoDB 2의 경우 postgres의 엔티티를 수정하지 않고, 뭔가 새로운 피처를 만들어야 할 때 새롭게 정제해서 SQL 정보와 같이 때려넣는 서비스 새로운 피처용 MongoDB이다.
마이그레이션 계획:
Phase 1: dual writing
Phase 2: 데이터 동기화
Phase 3: 읽기 전환
그럼 프론트 혹은 유저 입장에선 쥐도 새도 모르게 백엔드의 데이터베이스 쪽이 전부 마이그레이션될 것이라고 예측하고 있고 이렇게 개발이 진행될 것 같다.
이 내용 끝으로, 뭐 마침 SKT T 멤버쉽에 관련되어서 서비스 혜택을 만들어서 협업이 일어날 것 같아 운영 서비스를 개발하기는 잘했다는 생각이 든다….
일단 첫 번째 외주는 꽤 성공적으로 개발해서 마무리했다. 그러나 아직 이쪽에 데이터가 안 넘어온 상태이기 때문에 조금 더 보고 데이터가 올 때쯤에 한 번 더 대규모 업데이트를 진행하면서 마무리 지을 것 같다.
성과는 요 정도가 있다.
두 번째 외주는 베어링 사이트 쪽인데, 기획은 전부 끝냈고, 세부적으로 어떻게 개발로 풀어갈지 그리고 실제 개발이 들어가면 될 것 같다.
특이사항:
그리고 드디어 졸업한다.

소프트웨어학과로 전과한 게 엊그제 같은데... 사실상 이 학과로 학교 생활을 한 시간이 1년 반밖에 되지 않아서 약간 전문대 졸업하는 느낌이기도 하다 ㅎㅎ...
아 끝으로 그리고 벌써 이번 주 금요일 날 몽골로 여행을 간다. 인간은 행복했던 기억을 양분 삼아 살아간다고 하는데, 또 어떠한 재밌는 이야기를 조금이라도 더 가지고 올 수 있을지 기대된다.
P.S. 다음 개모임 때는 몽골 여행기와 함께 더 재밌는 개발 이야기를 들고 오겠습니다! 🏁