-
Notifications
You must be signed in to change notification settings - Fork 0
프로필 이미지 업로드
NCP의 Object Storage 를 활용한 프로필 이미지를 업로드하면서 경험한 과정을 정리했습니다. (2024-11-14)
- 언어: TypeScript
- 프레임워크: NestJS
- ORM: TypeORM
- 클라우드 서비스: Ncloud (AWS SDK v3 사용)
- 파일 업로드 라이브러리: Multer
-
필요한 패키지 설치:
npm install @nestjs/typeorm typeorm npm install @aws-sdk/client-s3 @aws-sdk/lib-storage multer npm install @nestjs/platform-express
-
환경 변수 설정: Ncloud Object Storage와 연동하기 위한 설정을
.env
파일에 추가했습니다.NCLOUD_ACCESS_KEY=your_access_key NCLOUD_SECRET_KEY=your_secret_key NCLOUD_REGION=kr-standard NCLOUD_ENDPOINT=https://kr.object.ncloudstorage.com NCLOUD_BUCKET_NAME={우리 버킷 이름 }
ncloud 루트(root) 계정의 마이페이지에서 id와 시크릿 키를 확인할 수 있습니다.
-
프로필 이미지 필드 확인: 사용자 엔티티에
profileImage
필드를 확인(string)하여 프로필 이미지 URL을 저장할 수 있도록 했습니다.import { LiveEntity } from '../../lives/entity/live.entity'; import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, DeleteDateColumn, OneToOne, JoinColumn, } from 'typeorm'; @Entity('users') export class UserEntity { @PrimaryGeneratedColumn({ name: 'users_id' }) id: number; @Column({ name: 'oauth_uid', type: 'varchar', length: 50 }) oauthUid: string; @Column({ name: 'oauth_platform', type: 'enum', enum: ['naver', 'github', 'google'], nullable: false, }) oauthPlatform: 'naver' | 'github' | 'google'; @Column({ name: 'users_nickname', type: 'varchar', length: 50 }) nickname: string; @Column({ name: 'users_profile_image', type: 'text', nullable: true }) profileImage: string | null; @CreateDateColumn({ name: 'created_at' }) createdAt: Date; @UpdateDateColumn({ name: 'updated_at', nullable: true }) updatedAt: Date | null; @DeleteDateColumn({ name: 'deleted_at', nullable: true }) deletedAt: Date | null; @OneToOne(() => LiveEntity) @JoinColumn({ name: 'lives_id' }) live: LiveEntity; }
-
S3 클라이언트 설정: AWS SDK v3를 사용하여 Ncloud Object Storage와 연동할 S3 클라이언트를 설정했습니다.
-
관련 트러블 슈팅 : [aws-sdk v2→v3](https://www.notion.so/aws-sdk-v2-v3-b438cfc080ae4d69b6a3e6b5bd781213?pvs=21)
// src/users/users.service.ts import { Injectable, NotFoundException, InternalServerErrorException, BadRequestException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository, DataSource } from 'typeorm'; import { UserEntity } from './entity/user.entity'; import { LiveEntity } from '../lives/entity/live.entity'; import { CreateUserDto } from './dto/create.user.dto'; import { UpdateUserDto } from './dto/update.user.dto'; import { randomUUID } from 'crypto'; import { Upload } from '@aws-sdk/lib-storage'; import { PutObjectCommandInput, S3 } from '@aws-sdk/client-s3'; import { ConfigService } from '@nestjs/config'; @Injectable() export class UsersService { private s3: S3; constructor( @InjectRepository(UserEntity) private usersRepository: Repository<UserEntity>, @InjectRepository(LiveEntity) private livesRepository: Repository<LiveEntity>, private dataSource: DataSource, private configService: ConfigService, ) { this.s3 = new S3({ credentials: { accessKeyId: this.configService.get<string>('NCLOUD_ACCESS_KEY'), secretAccessKey: this.configService.get<string>('NCLOUD_SECRET_KEY'), }, region: this.configService.get<string>('NCLOUD_REGION'), endpoint: this.configService.get<string>('NCLOUD_ENDPOINT'), tls: true, forcePathStyle: true, }); } // 사용자 조회 메서드 async findById(id: number): Promise<UserEntity | null> { return this.usersRepository.findOne({ where: { id }, relations: ['live'], }); } // 사용자 생성 메서드 async createUser(createUserDto: CreateUserDto): Promise<UserEntity> { return this.dataSource.transaction(async manager => { const live = manager.create(LiveEntity, { categoriesId: null, channelId: randomUUID(), name: null, description: null, streamingKey: randomUUID(), onAir: false, startedAt: null, }); const savedLive = await manager.save(LiveEntity, live); const newUser = manager.create(UserEntity, { ...createUserDto, live: savedLive, }); return await manager.save(UserEntity, newUser); }); } // 사용자 업데이트 메서드 async updateUser( userId: number, updateUserDto: UpdateUserDto, file?: Express.Multer.File, ): Promise<UserEntity> { const user = await this.usersRepository.findOne({ where: { id: userId } }); if (!user) { throw new NotFoundException('사용자를 찾을 수 없습니다.'); } if (updateUserDto.nickname !== undefined) { user.nickname = updateUserDto.nickname; } if (file) { // 파일을 Ncloud Object Storage에 업로드 const profileImageUrl = await this.uploadFileToNcloud(file); user.profileImage = profileImageUrl; } else if (updateUserDto.profileImage) { // 프로필 이미지 URL이 제공된 경우 user.profileImage = updateUserDto.profileImage; } return await this.usersRepository.save(user); } // Ncloud에 파일 업로드 메서드 private async uploadFileToNcloud(file: Express.Multer.File): Promise<string> { const bucketName = this.configService.get<string>('NCLOUD_BUCKET_NAME'); // 고유한 파일명 생성 const fileName = `profile-images/${Date.now()}-${file.originalname}`; const params: PutObjectCommandInput = { Bucket: bucketName, Key: fileName, Body: file.buffer, ContentType: file.mimetype, ACL: 'public-read', // 공개 액세스 설정 }; try { const upload = new Upload({ client: this.s3, params, }); const data = await upload.done(); return data.Location; // 업로드된 파일의 URL 반환 } catch (error) { console.error('Ncloud Upload Error:', error); throw new InternalServerErrorException('파일 업로드에 실패했습니다.'); } } }
-
프로필 조회 및 업데이트 엔드포인트 추가: 사용자 프로필을 조회하고 업데이트할 수 있는 API 엔드포인트를 구현했습니다.
// src/users/users.controller.ts import { Controller, Get, Put, UseGuards, UseInterceptors, Req, Param, Body, UploadedFile, ForbiddenException, NotFoundException, ParseIntPipe, BadRequestException, } from '@nestjs/common'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { Request } from 'express'; import { UserEntity } from './entity/user.entity'; import { UsersService } from './users.service'; import { UpdateUserDto } from './dto/update.user.dto'; import { FileInterceptor } from '@nestjs/platform-express'; import * as multer from 'multer'; @Controller('users') export class UsersController { constructor(private readonly usersService: UsersService) {} // 사용자 프로필 조회 @Get('/:userId') async getProfile(@Param('userId', ParseIntPipe) userId: number) { const user = await this.usersService.findById(userId); if (!user) { throw new NotFoundException('사용자를 찾을 수 없습니다.'); } return { users_id: user.id, nickname: user.nickname, profile_image: user.profileImage, created_at: user.createdAt, }; } // 사용자 프로필 업데이트 @Put('/:userId') @UseGuards(JwtAuthGuard) @UseInterceptors( FileInterceptor('profile_image', { storage: multer.memoryStorage(), limits: { fileSize: 5 * 1024 * 1024 }, // 최대 5MB fileFilter: (req, file, cb) => { if (file.mimetype.match(/\/(jpg|jpeg|png)$/)) { cb(null, true); } else { cb(new BadRequestException('지원되지 않는 이미지 형식입니다.'), false); } }, }), ) async updateProfile( @Param('userId', ParseIntPipe) userId: number, @Req() req: Request & { user: UserEntity }, @Body() updateUserDto: UpdateUserDto, @UploadedFile() file?: Express.Multer.File, ) { if (req.user.id !== userId) { throw new ForbiddenException('본인만 프로필을 수정할 수 있습니다.'); } const updatedUser = await this.usersService.updateUser(userId, updateUserDto, file); return { users_id: updatedUser.id, nickname: updatedUser.nickname, profile_image: updatedUser.profileImage, created_at: updatedUser.createdAt, }; } }
-
Multer 설정: 파일 업로드 시 메모리 저장소를 사용하고, 파일 크기 제한과 파일 형식 필터링을 설정했습니다.
@UseInterceptors( FileInterceptor('profile_image', { storage: multer.memoryStorage(), limits: { fileSize: 5 * 1024 * 1024 }, // 최대 5MB fileFilter: (req, file, cb) => { if (file.mimetype.match(/\/(jpg|jpeg|png)$/)) { cb(null, true); } else { cb(new BadRequestException('지원되지 않는 이미지 형식입니다.'), false); } }, }), )
-
UpdateUserDto: 사용자 프로필 업데이트 시 필요한 데이터를 정의했습니다.
// src/users/dto/update.user.dto.ts import { IsOptional, IsString, IsUrl } from 'class-validator'; export class UpdateUserDto { @IsOptional() @IsString() nickname?: string; @IsOptional() @IsUrl() profileImage?: string | null; }
-
JwtAuthGuard 사용: 프로필 업데이트 API는 인증된 사용자만 접근할 수 있도록
JwtAuthGuard
를 적용했습니다. -
본인 확인 로직: 요청한 사용자가 자신의 프로필만 수정할 수 있도록 사용자 ID를 비교하는 로직을 추가했습니다.
import { Injectable } from '@nestjs/common'; import { PassportStrategy } from '@nestjs/passport'; import { Strategy, ExtractJwt } from 'passport-jwt'; import { ConfigService } from '@nestjs/config'; import { UsersService } from '../../users/users.service'; import { UserEntity } from '../../users/entity/user.entity'; @Injectable() export class JwtStrategy extends PassportStrategy(Strategy) { constructor( private configService: ConfigService, private usersService: UsersService, ) { super({ jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), ignoreExpiration: false, secretOrKey: configService.get<string>('JWT_SECRET'), }); } async validate(payload: any): Promise<UserEntity | null> { const { id, provider } = payload.sub; const user = await this.usersService.findById(id); if (user && user.oauthPlatform === provider) { return user; // req.user에 사용자 정보 첨부 } retu
-
프론트엔드와 연동을 사용한 테스트:
-
프로필 조회:
GET /users/:userId
엔드포인트를 통해 사용자 프로필이 정상적으로 조회되는지 확인했습니다. -
프로필 업데이트:
PUT /users/:userId
엔드포인트에 인증 토큰과 함께 이미지 파일을 포함한 요청을 보내어 프로필이 정상적으로 업데이트되는지 테스트했습니다.
-
프로필 조회:
- Ncloud Object Storage 확인: 업로드된 이미지가 Ncloud Object Storage에 정상적으로 저장되고, 공개 URL이 반환되는지 확인했습니다.
-
파일 업로드 실패 시 처리: Ncloud에 파일 업로드 중 오류가 발생하면
InternalServerErrorException
을 던져 클라이언트에게 오류를 전달하도록 했습니다.catch (error) { console.error('Ncloud Upload Error:', error); throw new InternalServerErrorException('파일 업로드에 실패했습니다.'); }
-
사용자 존재 여부 확인: 프로필 조회 및 업데이트 시 사용자가 존재하지 않으면
NotFoundException
을 던지도록 했습니다.if (!user) { throw new NotFoundException('사용자를 찾을 수 없습니다.');
-
지원되지 않는 파일 형식 처리: 허용되지 않은 이미지 형식 업로드 시
BadRequestException
을 던지도록 설정했습니다.if (file.mimetype.match(/\/(jpg|jpeg|png)$/)) { cb(null, true); } else { cb(new BadRequestException('지원되지 않는 이미지 형식입니다.'), false); }