Skip to content

프로필 이미지 업로드

pc5401 edited this page Dec 3, 2024 · 1 revision

개요

NCP의 Object Storage 를 활용한 프로필 이미지를 업로드하면서 경험한 과정을 정리했습니다. (2024-11-14)

기술 스택

  • 언어: TypeScript
  • 프레임워크: NestJS
  • ORM: TypeORM
  • 클라우드 서비스: Ncloud (AWS SDK v3 사용)
  • 파일 업로드 라이브러리: Multer

1. 프로젝트 설정 및 초기 구성

  • 필요한 패키지 설치:

    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와 시크릿 키를 확인할 수 있습니다.


2. 사용자 엔티티(UserEntity) 확인

  • 프로필 이미지 필드 확인: 사용자 엔티티에 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;
    }

3. 사용자 서비스(UsersService) 구현

  • 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('파일 업로드에 실패했습니다.');
        }
      }
    }

4. 사용자 컨트롤러(UsersController) 구현

  • 프로필 조회 및 업데이트 엔드포인트 추가: 사용자 프로필을 조회하고 업데이트할 수 있는 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);
          }
        },
      }),
    )

5. DTO(Data Transfer Object) 정의

  • 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;
    }

6. 인증 및 권한 부여 설정

  • 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

7. 파일 업로드 및 Ncloud 연동 테스트

  • 프론트엔드와 연동을 사용한 테스트:
    • 프로필 조회: GET /users/:userId 엔드포인트를 통해 사용자 프로필이 정상적으로 조회되는지 확인했습니다.
    • 프로필 업데이트: PUT /users/:userId 엔드포인트에 인증 토큰과 함께 이미지 파일을 포함한 요청을 보내어 프로필이 정상적으로 업데이트되는지 테스트했습니다.
  • Ncloud Object Storage 확인: 업로드된 이미지가 Ncloud Object Storage에 정상적으로 저장되고, 공개 URL이 반환되는지 확인했습니다.

8. 예외 처리 및 에러 핸들링

  • 파일 업로드 실패 시 처리: 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);
    }

👋 소개
📖 회의록
🗓️ 개발일지
🗃 설계 문서
🕵️‍♂️ 회고록
💪 멘토링 일지
🎳 트러블 슈팅
💽 발표자료
Clone this wiki locally