오늘 개모임 발표 서두에 최근에 본 영상 중 AI에 관한 개인적인 생각을 정리한 글이 있어 이번에 이 이야기를 한번 해보면서 근황도 좀 풀고 그럴까 한다.
2.0 (링크임!!!)
위 이야기는 직접적인 개발 이야기는 아니지만 간접적으로 개발 패러다임의 변화에 대한 내 생각을 많이 포함하고 있다.
B2B는 여전히 아직까지도 틀 만들기다.
저번에 이어서 백오피스 관련된 여러 가지 기능들을 많이 업데이트했다.
특히 개발한 기능 중에 좀 뽑으라면 클라우드 스케줄러 관리 기능, 롤 분기 완성, 친구 초대 기능 리뉴얼 등이 기억에 남는 것 같다.
Google Cloud Scheduler를 활용한 작업 스케줄링 관리 시스템을 구축했다. 이전에는 cron job을 수동으로 관리했는데, 이제는 어드민에서 모든 스케줄을 한눈에 보고 관리할 수 있게 되었다.
TypeScript


이런식으로 설정하면


실제 GCP의 NestJS를 통해 Cloud Schduler에도 설정되게 만들었다.
지난번에 잠깐 언급했었던, Role-Based Access Control(RBAC) 시스템을 드디어 완성했다. 가장 큰 변화는 단순한 role 구분에서 벗어나 세밀한 권한 관리가 가능해졌다는 점이다.
핵심 철학: "정의는 코드로, 구성은 데이터로"
이게 무슨 말이냐면:
이렇게 하면 권한 자체는 명확하게 관리되면서도, 운영 중에 유연하게 역할을 조정할 수 있다!
1. Permissions (권한)
TypeScript
2. Role (역할)
TypeScript
3. RbacUser (어드민 사용자)
TypeScript
4. Menu (동적 메뉴)
TypeScript
1. 권한 정의 (코드에서 관리)
TypeScript
2. 역할 생성 및 권한 할당 (관리자 UI에서)
TypeScript
3. 사용자 로그인 시 권한 체크
TypeScript
4. 메뉴 동적 생성
TypeScript
5. API 권한 체크 (Guards)
TypeScript
이렇게 구성하니까 새로운 기능 추가할 때는:





거의 코드 수정 없이 역할과 권한을 자유롭게 조합할 수 있어서 정말 편하다.
기존의 단순한 추천인 코드 시스템을 전면 개편했다. 이제는 추천 보상 추적, 마일리지 자동 지급, 네이버 포인트 연동까지 지원한다.
TypeScript



이 이야기에 앞서, admin api에 관련되어서 조금 설명이 필요할 것 같다. 개모임 분들은 아시다시피 우리 회사 데이터베이스가 일전에 너어어어무 잘 짜여져 있었어서... 현재는
PLAIN
이와 같은 모습을 하고 있다... 나도 실제 운영되는 Service단에서 Admin 관련된 api가 있으면 너무 안 좋다는 사실을 잘 알고 있지만, 너어어무 더러운 테이블을 Admin NestJS 서버에 이 postgres 엔티티를 복사해서 다 가지고 오는 것은 용납이 안되었다.
그러나 어쩔 수 없었다...
따라서 지금 아키텍처는 위에서처럼 어드민용 계정으로 접속하면 Admin NestJS에서 어드민용 인증을 만들고, 이걸 Admin Frontend에서 운영 서비스의 데이터를 수정해야 될 시 아까 만든 Admin NestJS의 인증이 Service NestJS에서 특수한 Guards를 통해 인증되서 어드민만 쓸 수 있는 api들이 보호되고 있는 형식이다.
TypeScript
따라서 현재 우리 개발팀의 솔루션은 아마 다음과 같이 진행될 것 같다.
현재 상태:
예전 글에 기술했다시피, MongoDB 2의 경우 postgres의 엔티티를 수정하지 않고, 뭔가 새로운 피처를 만들어야 할 때 새롭게 정제해서 SQL 정보와 같이 때려넣는 서비스 새로운 피처용 MongoDB이다.
마이그레이션 계획:
Phase 1: dual writing
Phase 2: 데이터 동기화
Phase 3: 읽기 전환
그럼 프론트 혹은 유저 입장에선 쥐도 새도 모르게 백엔드의 데이터베이스 쪽이 전부 마이그레이션될 것이라고 예측하고 있고 이렇게 개발이 진행될 것 같다.
이 내용 끝으로, 뭐 마침 SKT T 멤버쉽에 관련되어서 서비스 혜택을 만들어서 협업이 일어날 것 같아 운영 서비스를 개발하기는 잘했다는 생각이 든다….
일단 첫 번째 외주는 꽤 성공적으로 개발해서 마무리했다. 그러나 아직 이쪽에 데이터가 안 넘어온 상태이기 때문에 조금 더 보고 데이터가 올 때쯤에 한 번 더 대규모 업데이트를 진행하면서 마무리 지을 것 같다.
성과는 요 정도가 있다.
두 번째 외주는 베어링 사이트 쪽인데, 기획은 전부 끝냈고, 세부적으로 어떻게 개발로 풀어갈지 그리고 실제 개발이 들어가면 될 것 같다.
특이사항:
그리고 드디어 졸업한다.

소프트웨어학과로 전과한 게 엊그제 같은데... 사실상 이 학과로 학교 생활을 한 시간이 1년 반밖에 되지 않아서 약간 전문대 졸업하는 느낌이기도 하다 ㅎㅎ...
아 끝으로 그리고 벌써 이번 주 금요일 날 몽골로 여행을 간다. 인간은 행복했던 기억을 양분 삼아 살아간다고 하는데, 또 어떠한 재밌는 이야기를 조금이라도 더 가지고 올 수 있을지 기대된다.
P.S. 다음 개모임 때는 몽골 여행기와 함께 더 재밌는 개발 이야기를 들고 오겠습니다! 🏁
// 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)
}
}
// 시스템에서 가능한 모든 액션을 정의
@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; // 이 권한과 연결된 메뉴
}
// 권한들의 묶음, 사용자에게 할당되는 단위
@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[]; // 이 역할이 가진 권한들
}
// 백오피스 접근 권한을 가진 사용자
@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; // 계정 상태
}
// 권한에 따라 동적으로 보여지는 메뉴
@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; // 메뉴 표시 순서
}
// 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 // 메뉴 없이 버튼 권한만
}
// ... 더 많은 권한들
];
// 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();
}
// 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)
});
}
// 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)
);
}
// 특정 권한이 필요한 API
@UseGuards(PermissionsGuard)
@RequirePermissions('product:create')
@Post('/products')
async createProduct(@Body() dto: CreateProductDto) {
// 권한이 있는 사용자만 실행 가능
return this.productService.create(dto);
}
// 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 React │────▶│ Admin NestJS │────▶│ Service NestJS │
│ │ │ (Gateway) │ │ │
└─────────────┘ └───────────────┘ └──────────────────┘
│ │ │
│ │ │
└────── JWT Auth ────┴───────────────────────┘
│
└─── RBAC Permission ───┘
// 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")
}
}
}