IchiMozzi 백엔드 아키텍처 - NestJS와 TypeORM으로 구축한 학습 관리 시스템

들어가며

IchiMozzi의 핵심은 사용자의 학습 데이터를 효율적으로 수집, 분석하여 개인화된 학습 경험을 제공하는 것입니다. 이를 위해 복잡한 비즈니스 로직을 안정적으로 처리할 수 있는 백엔드 시스템이 필요했습니다.

이번 글에서는 NestJS와 TypeORM을 기반으로 구축한 IchiMozzi 백엔드 아키텍처의 설계 과정과 핵심 구현 사항들을 실제 코드와 함께 공유합니다.

백엔드 아키텍처 설계 원칙

모듈 기반 설계

사용자 학습 데이터를 체계적으로 관리하는 시스템

NestJS의 모듈 시스템을 활용하여 기능별로 명확히 분리된 아키텍처를 구성했습니다:

// backend/src/app.module.ts - 전체 모듈 구조
@Module({
    imports: [
        TypeOrmModule.forRoot(typeORMConfig),
        AuthModule,          // 사용자 인증 및 권한 관리
        UsersModule,         // 사용자 프로필 및 정보 관리
        AnalysisModule,      // 학습 데이터 분석 시스템
        LearningModule,      // 학습 콘텐츠 관리
        WebhookModule,       // 외부 서비스 연동
        WrongNoteGroupModule,// 오답 노트 그룹 관리
        NotificationsModule, // 푸시 알림 시스템
    ],
    controllers: [AppController],
    providers: [],
})
export class AppModule {}

모듈 분리의 핵심 이점:

  • 단일 책임 원칙: 각 모듈이 하나의 비즈니스 도메인만 담당
  • 테스트 용이성: 모듈별 독립적인 테스트 가능
  • 확장성: 새로운 기능 추가 시 기존 코드에 미치는 영향 최소화

데이터베이스 설계와 TypeORM 모델링

사용자 중심의 데이터 모델

학습 앱의 특성상 사용자의 학습 진도와 성취도를 정확히 추적할 수 있는 데이터 모델이 핵심이었습니다:

// backend/src/entities/user.entity.ts - 사용자 엔티티
@Entity()
export class User {
    @PrimaryGeneratedColumn()
    id!: number;

    @Column({ unique: true })
    email!: string;

    @Column()
    passwordHash!: string;

    // JLPT 레벨 관리
    @Column({ default: 'N5' })
    currentLevel!: string;

    // 영역별 세분화된 점수 시스템 (각 최대 1000점)
    @Column({ default: 0 })
    vocabScore!: number;      // 어휘 점수

    @Column({ default: 0 })
    grammarScore!: number;    // 문법 점수

    @Column({ default: 0 })
    readingScore!: number;    // 독해 점수

    // 총합 점수 (최대 3000점)
    @Column({ default: 0 })
    score!: number;

    @Column({ default: 0 })
    ProblemCount!: number;    // 풀이한 문제 수

    // 서비스 관리 필드
    @Column({ default: '' })
    service!: string;         // 'premium', 'basic', 'guest'

    @Column({ type: 'timestamp', default: () => 'CURRENT_TIMESTAMP' })
    serviceEndAt!: Date;

    // 푸시 알림 관리
    @Column({ nullable: true })
    pushToken!: string;       // Expo 푸시 토큰

    @Column({ default: false })
    marketingAlertEnabled!: boolean;

    // 관계 설정
    @OneToMany(() => WrongNoteGroup, (group) => group.user, { cascade: true })
    wrongNoteGroups!: WrongNoteGroup[];

    @CreateDateColumn()
    createdAt!: Date;

    @UpdateDateColumn()
    updatedAt!: Date;
}

데이터 모델 설계의 핵심 고려사항:

  1. 세분화된 점수 시스템: 어휘, 문법, 독해 영역별 별도 점수 관리로 정확한 취약점 분석
  2. 서비스 등급 관리: 프리미엄, 베이직, 게스트 사용자별 차별화된 서비스 제공
  3. 확장 가능한 구조: 향후 새로운 학습 영역 추가 시 유연한 확장 가능

오답 노트 시스템 설계

사용자 맞춤형 오답 노트 관리 시스템

사용자가 틀린 문제들을 효율적으로 관리할 수 있는 오답 노트 시스템을 구현했습니다:

// backend/src/entities/wrong-note-group.entity.ts
@Entity('wrong_note_groups')
export class WrongNoteGroup {
    @PrimaryGeneratedColumn()
    id!: number;

    @Column()
    name!: string; // 사용자 정의 그룹 이름

    @ManyToOne(() => User, (user) => user.wrongNoteGroups, { onDelete: 'CASCADE' })
    @JoinColumn({ name: 'userId' })
    user!: User;

    @Column()
    userId!: number;

    // 다대다 관계로 문제들과 연결
    @ManyToMany(() => Problem)
    @JoinTable({
        name: 'wrong_note_group_problems',
        joinColumn: { name: 'groupId', referencedColumnName: 'id' },
        inverseJoinColumn: { name: 'problemId', referencedColumnName: 'id' },
    })
    problems!: Problem[];
}

오답 노트 시스템의 특징:

  • 유연한 그룹화: 사용자가 주제별, 난이도별로 자유롭게 오답 문제 그룹화
  • 다대다 관계: 하나의 문제가 여러 그룹에 속할 수 있는 유연한 구조
  • 계층적 관리: 사용자 → 그룹 → 문제 순의 계층적 데이터 구조

인증 시스템 구현

JWT 기반 보안 인증

// backend/src/auth/auth.module.ts - 인증 모듈 설정
@Module({
    imports: [
        PassportModule,
        JwtModule.register({
            secret: 'SECRET_KEY', // 실제 환경에서는 환경변수로 관리
            signOptions: { expiresIn: '1d' },
        }),
        TypeOrmModule.forFeature([User]),
        UsersModule,
    ],
    controllers: [AuthController],
    providers: [AuthService, JwtStrategy],
    exports: [AuthService],
})
export class AuthModule {}

다양한 인증 방식 지원

사용자 접근성을 높이기 위해 세 가지 인증 방식을 구현했습니다:

// backend/src/auth/auth.service.ts - 인증 서비스
@Injectable()
export class AuthService {
    constructor(
        @InjectRepository(User)
        private readonly userRepo: Repository<User>,
        private readonly jwtService: JwtService,
    ) {}

    // 1. 일반 회원가입
    async register(registerDto: RegisterDto) {
        const { name, email, password, level } = registerDto;
        
        // 중복 이메일 검증
        const found = await this.userRepo.findOne({ where: { email } });
        if (found) {
            throw new UnauthorizedException('이미 가입된 아이디입니다.');
        }

        // 안전한 비밀번호 해싱
        const salt = await bcrypt.genSalt();
        const passwordHash = await bcrypt.hash(password, salt);
        
        // JLPT 레벨에 따른 초기 점수 설정
        let vocabScore = 0, grammarScore = 0, readingScore = 0;
        switch(level) {
            case 'N4': vocabScore = grammarScore = readingScore = 200; break;
            case 'N3': vocabScore = grammarScore = readingScore = 400; break;
            case 'N2': vocabScore = grammarScore = readingScore = 600; break;
            case 'N1': vocabScore = grammarScore = readingScore = 800; break;
            default: break; // N5는 모두 0
        }

        const newUser = this.userRepo.create({
            name, email, passwordHash,
            currentLevel: level,
            score: vocabScore + grammarScore + readingScore,
            vocabScore, grammarScore, readingScore,
            service: 'premium', // 오픈베타 기간 프리미엄 제공
            serviceEndAt: new Date('9999-12-31'),
            ProblemCount: 100,
        });
        
        await this.userRepo.save(newUser);
        return { success: true };
    }

    // 2. 로그인
    async login(loginDto: LoginDto) {
        const { email, password } = loginDto;
        const user = await this.userRepo.findOne({ where: { email } });
        
        if (!user) {
            throw new UnauthorizedException('사용자를 찾을 수 없습니다.');
        }

        const isMatch = await bcrypt.compare(password, user.passwordHash);
        if (!isMatch) {
            throw new UnauthorizedException('비밀번호가 일치하지 않습니다.');
        }

        const payload = { userId: user.id, email: user.email };
        const token = this.jwtService.sign(payload);
        return { token };
    }

    // 3. 게스트 로그인 (가입 없이 체험 가능)
    async guestLogin() {
        const randomSuffix = Date.now() + '_' + Math.floor(Math.random() * 100000);
        const tempEmail = `guest_${randomSuffix}@temp.guest`;
        
        const salt = await bcrypt.genSalt();
        const randomHash = await bcrypt.hash(`guest_password_${randomSuffix}`, salt);

        const guestUser = this.userRepo.create({
            name: 'Guest',
            email: tempEmail,
            passwordHash: randomHash,
            score: 0,
            vocabScore: 0, grammarScore: 0, readingScore: 0,
            currentLevel: 'N5',
            service: 'guest',
            serviceEndAt: new Date('9999-12-31'),
            ProblemCount: 10, // 게스트는 10문제 제한
        });
        
        await this.userRepo.save(guestUser);

        const payload = { userId: guestUser.id };
        const token = this.jwtService.sign(payload);

        return { token, userId: guestUser.id };
    }
}

인증 시스템의 핵심 특징:

  1. bcrypt 해싱: 솔트를 사용한 안전한 비밀번호 저장
  2. 레벨별 초기화: 사용자의 JLPT 목표 레벨에 따른 적절한 초기 점수 설정
  3. 게스트 시스템: 가입 부담을 줄여 사용자 진입 장벽 최소화

데이터베이스 연결 및 설정

AWS RDS PostgreSQL 연동

// backend/src/config/ormconfig.ts - 데이터베이스 설정
export const typeORMConfig: TypeOrmModuleOptions = {
    type: 'postgres',
    host: 'ichimozzi.cxkekoe889ud.ap-northeast-2.rds.amazonaws.com',
    port: 5432,
    username: 'postgres',
    password: 'wlstjs890!', // 실제 환경에서는 환경변수로 관리
    database: 'IchiMozzi',
    
    // AWS RDS SSL 연결 설정
    ssl: {
        rejectUnauthorized: false, // 개발환경용, 운영환경에서는 true 권장
    },
    
    entities: [__dirname + '/../**/*.entity{.ts,.js}'],
    synchronize: true, // 개발용, 운영환경에서는 migration 사용 권장
};

클라우드 데이터베이스 선택 이유:

  • 확장성: 트래픽 증가에 따른 자동 스케일링
  • 백업: 자동 백업과 포인트 인 타임 복구
  • 보안: VPC 내 격리된 환경과 암호화 지원

서버 설정 및 미들웨어

CORS 및 보안 설정

// backend/src/main.ts - 서버 설정
async function bootstrap() {
    const app = await NestFactory.create(AppModule);

    // CORS 설정: React Native 앱과의 원활한 통신
    app.enableCors({
        origin: '*', // 개발환경용, 운영환경에서는 특정 도메인 지정
    });

    // GitHub Webhook 등 외부 서비스 연동을 위한 rawBody 확보
    app.use(bodyParser.json({
        verify: (req: any, res, buf) => {
            req.rawBody = buf; // 시그니처 검증에 필요
        },
    }));

    const port = 3000;
    await app.listen(port);
    console.log(`IchiMozzi Server running on http://localhost:${port}`);
}
bootstrap();

성능 최적화와 확장성 고려사항

쿼리 최적화

TypeORM의 관계 설정을 통해 N+1 쿼리 문제를 방지하고 효율적인 데이터 로딩을 구현:

// 사용자와 오답 노트를 한 번에 로딩
const userWithWrongNotes = await this.userRepo.findOne({
    where: { id: userId },
    relations: ['wrongNoteGroups', 'wrongNoteGroups.problems']
});

확장 가능한 모듈 구조

새로운 기능 추가 시 기존 코드에 영향을 주지 않는 모듈 구조:

backend/src/
├── auth/              # 인증 관련 (독립적)
├── users/             # 사용자 관리 (독립적)
├── learning/          # 학습 콘텐츠 (독립적)
├── analysis/          # 데이터 분석 (다른 모듈의 데이터 활용)
├── notifications/     # 알림 시스템 (독립적)
└── webhook/           # 외부 연동 (독립적)

배운 점과 개선 사항

성공 요인

  1. 타입 안전성: TypeScript + TypeORM 조합으로 컴파일 타임 오류 검출
  2. 모듈화: 기능별 모듈 분리로 코드 관리 용이성 확보
  3. 확장성: 새로운 기능 추가 시 기존 코드 영향 최소화

개선할 점

  1. 환경 변수 관리: 하드코딩된 설정값들을 환경 변수로 분리 필요
  2. 에러 핸들링: 더 세분화된 예외 처리와 로깅 시스템 구축
  3. 테스트 코드: 단위 테스트와 통합 테스트 커버리지 확대

다음 단계

다음 글에서는 이렇게 구축한 백엔드 API를 React Native 앱에서 어떻게 활용했는지, 모바일 앱의 UI/UX 구현 과정을 다룰 예정입니다.

특히 TypeScript의 타입 안전성을 프론트엔드와 백엔드 간에 어떻게 일관성 있게 유지했는지, 실시간 학습 데이터 수집과 분석을 위한 API 설계 과정을 상세히 공유하겠습니다.


NestJS와 TypeORM을 활용한 백엔드 구축 경험을 통해 확장 가능하고 유지보수하기 쉬운 서버 아키텍처의 중요성을 깊이 이해할 수 있었습니다.




Enjoy Reading This Article?

Here are some more articles you might like to read next:

  • RoboDine 프로젝트 완료 - 2개월간의 로봇 레스토랑 개발 여정
  • RoboDine 데이터베이스 설계 - SQLModel과 관계형 데이터 모델링
  • RoboDine 키오스크 개발 - React 기반 고객 주문 인터페이스
  • FastAPI로 구축한 RoboDine 백엔드 시스템
  • RoboDine 운영자 대시보드 개발 - React 기반 실시간 관제 시스템