본 내용은 내일배움캠프에서 활동한 내용을 기록한 글입니다.
ADMIM
으로 로그인한 사용자가 공연을 등록하는 API
createShowDto
를 통해서 사용자의 입력을 Controller로 가져옴
매개변수로 풀어서 Service로 넘기기에는 입력하는 데이터가 많아서 createShowDto
객체 형태로 한 번에 넘김
Service에서 데이터를 추가할 테이블의 종류가 많기 때문에 트랜젝션으로 묶어서 테이블에 데이터 추가
이미지를 여러개 받기 때문에 Multer와 AWS S3를 활용
일단 이미지는 Multer를 통해서 이미지URL를 받아오는 것까지 진행함
// show.module.ts
@Module({
imports: [
// JwtModule이라는 동적 모듈을 설정하고 다른 auth 모듈에서 사용하기 위한 코드
JwtModule.registerAsync({
useFactory: (config: ConfigService) => ({
secret: config.get<string>('JWT_SECRET_KEY'),
}),
inject: [ConfigService],
}),
TypeOrmModule.forFeature([Show, ShowImage, ShowTime, ShowPlace, ShowPrice]),
AwsModule,
],
providers: [ShowService],
controllers: [ShowController],
exports: [ShowService],
})
export class ShowModule {}
사용자의 입력은 createShowDto를 통해서 받음
추후에 좌석 지정에 대한 코드도 구현할 계획이기에 등급별 좌석에 대한 데이터도 사용자에게 받아옴
// create-show.dto.ts
export class CreateShowDto {
// 공연 제목
@IsString()
@IsNotEmpty({ message: '공연 제목을 입력해 주세요.' })
title: string;
// 공연 내용
@IsString()
@IsNotEmpty({ message: '공연 내용을 입력해 주세요.' })
content: string;
// 공연 카테고리
@IsEnum(Category)
@IsNotEmpty({ message: '공연 카테고리를 입력해 주세요.' })
category: Category;
// 공연 상영 시간
@Type(() => Number)
@IsNumber()
@IsNotEmpty({ message: '공연 상영 시간을 입력해 주세요.' })
runningTime: number;
// 공연 시간 배열
// 가져오는 시간 배열이 문자열 형태의 배열이기 때문에
// 데이터를 가공할 필요가 있음
@Transform(({ value }) => {
const dateTimeArray = value.slice(1, -1).split(',');
const dates = dateTimeArray.map((str: string) => new Date(str.trim().slice(1, -1)));
if (Array.isArray(dates)) {
return dates.map((item) => new Date(item));
}
return dates;
})
@IsArray()
@IsDate({ each: true })
@IsNotEmpty({ message: '공연 시간을 입력해 주세요.' })
times: Date[];
// 장소명
@IsString()
@IsNotEmpty({ message: '장소명을 입력해 주세요.' })
placeName: string;
// A좌석 수
@Type(() => Number)
@IsNumber()
@IsNotEmpty({ message: '장소의 A좌석 수를 입력해 주세요.' })
seatA: number;
// S좌석 수
@IsOptional()
@Type(() => Number)
@IsNumber()
seatS: number;
// R좌석 수
@IsOptional()
@Type(() => Number)
@IsNumber()
seatR: number;
// Vip좌석 수
@IsOptional()
@Type(() => Number)
@IsNumber()
seatVip: number;
// A좌석 가격
@Type(() => Number)
@IsNumber()
@IsNotEmpty({ message: '장소의 A좌석 가격을 입력해 주세요.' })
priceA: number;
// S좌석 가격
@IsOptional()
@Type(() => Number)
@IsNumber()
priceS: number;
// R좌석 가격
@IsOptional()
@Type(() => Number)
@IsNumber()
priceR: number;
// Vip좌석 가격
@IsOptional()
@Type(() => Number)
@IsNumber()
priceVip: number;
}
// show.entity.ts
@Entity({
name: 'show',
})
export class Show {
@PrimaryGeneratedColumn()
id: number;
@Column({ type: 'varchar', nullable: false })
title: string;
@Column({ type: 'text', nullable: false })
content: string;
@Column({ type: 'enum', enum: Category, nullable: false })
category: Category;
@Column({ type: 'int', nullable: false })
runningTime: number;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
@OneToMany(() => ShowImage, (showImage) => showImage.show)
showImages: ShowImage[];
@OneToMany(() => ShowTime, (showTime) => showTime.show)
showTimes: ShowTime[];
@OneToOne(() => ShowPrice, (showPrice) => showPrice.show)
showPrice: ShowPrice;
@OneToOne(() => ShowPlace, (showPlace) => showPlace.show)
showPlace: ShowPlace;
}
공연 등록 시 등록할 이미지들의 URL를 저장하는 엔티티
공연 엔티티와 일대다 관계를 가짐
// showImage.entity.ts
@Entity({
name: 'show_image',
})
export class ShowImage {
@PrimaryGeneratedColumn()
id: number;
// 공연 외래키 설정
@Column({ type: 'int', name: 'show_id' })
showId: number;
@Column({ type: 'varchar', nullable: false })
imageUrl: string;
@CreateDateColumn()
createdAt: Date;
// 공연 엔티티와 관계 설정
@ManyToOne(() => Show, (show) => show.showImages, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'show_id' })
show: Show;
}
공연을 하는 시간대들을 저장하는 엔티티
공연 엔티티와 일대다 관계를 가짐
// showTime.entity.ts
@Entity({
name: 'show_time',
})
export class ShowTime {
@PrimaryGeneratedColumn()
id: number;
// 공연 외래키 설정
@Column({ type: 'int', name: 'show_id' })
showId: number;
@Column({ type: 'datetime', nullable: false })
showTime: Date;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
// 공연 엔티티와 관계 설정
@ManyToOne(() => Show, (show) => show.showTimes, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'show_id' })
show: Show;
}
공연을 하는 장소에 대한 정보를 저장하는 엔티티
총 좌석이 몇개고 각 등급별 좌석이 몇개 있는지 저장
공연 엔티티와 일대일 관계를 가짐
// showPlace.entity.ts
@Entity({
name: 'show_place',
})
export class ShowPlace {
@PrimaryGeneratedColumn()
id: number;
// 공연 외래키 설정
@Column({ type: 'int', name: 'show_id' })
showId: number;
@Column({ type: 'varchar', nullable: false })
placeName: string;
@Column({ type: 'int', nullable: false })
totalSeat: number;
@Column({ type: 'int', nullable: false })
seatA: number;
@Column({ type: 'int', nullable: true, default: 0 })
seatS: number;
@Column({ type: 'int', nullable: true, default: 0 })
seatR: number;
@Column({ type: 'int', nullable: true, default: 0 })
seatVip: number;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
// 공연 엔티티와 관계 설정
@OneToOne(() => Show, (show) => show.showPlace, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'show_id' })
show: Show;
}
공연 예매를 위한 가격 정보를 저장하는 엔티티
각 등급별 좌석의 가격 정보를 저장
공연 엔티티와 일대일 관계를 가짐
// showPrice.entity.ts
@Entity({
name: 'show_price',
})
export class ShowPrice {
@PrimaryGeneratedColumn()
id: number;
// 공연 외래키 설정
@Column({ type: 'int', name: 'show_id' })
showId: number;
@Column({ type: 'int', nullable: false })
priceA: number;
@Column({ type: 'int', nullable: true, default: 0 })
priceS: number;
@Column({ type: 'int', nullable: true, default: 0 })
priceR: number;
@Column({ type: 'int', nullable: true, default: 0 })
priceVip: number;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
// 공연 엔티티와 관계 설정
@OneToOne(() => Show, (show) => show.showPrice, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'show_id' })
show: Show;
}
해당 메서드가 어떤 역할이 가능한지 설정하기 위한 커스텀 데코레이터
@Roles(Role.ADMIN)
처럼 사용함으로써 사용 가능한 역할을 지정
// auth/utils/roles.decorator.ts
import { SetMetadata } from '@nestjs/common';
import { Role } from 'src/user/types/userRole.type';
export const Roles = (...roles: Role[]) => SetMetadata('roles', roles);
클래스 전역으로 설정 가능하기에 클래스의 메서드에도 적용 가능
해당 메서드가 실행되기 위해서 JWT 토큰이 유효한지 검사
그 후 메서드의 역할 데코레이터에 설정된 역할 메타데이터를 기반으로 사용 가능 여부를 파악
// auth/utils/roles.guard.ts
// 가드(Guard)는 roles.decorator를 기반으로 인가를 할지 결정함
// 즉, roles.decorator에서 알려주는 역할이 통과될 수 있는지 검사함
import { Role } from 'src/user/types/userRole.type';
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
// AuthGuard('jwt') jwt 인증이 된 상태에서 역할을 확인하기 위해서 extends를 함
export class RolesGuard extends AuthGuard('jwt') implements CanActivate {
// eslint-disable-next-line prettier/prettier
constructor(private reflector: Reflector) {
super();
}
// 가능할 경우에 동작하는 것
async canActivate(context: ExecutionContext) {
const authenticated = await super.canActivate(context);
if (!authenticated) {
return false;
}
// @Roles(Role.Admin) -> 'roles'에 [Role.Admin] 배열이 담겨 있음
// 즉, requiredRoles에 [Role.Admin] 배열이 들어감
// reflector를 통해서 메타데이터를 탐색 후 'roles' 키의 값을 가져옴
const requiredRoles = this.reflector.getAllAndOverride<Role[]>('roles', [
context.getHandler(),
context.getClass(),
]);
if (!requiredRoles) {
return true;
}
const { user } = context.switchToHttp().getRequest();
// 사용자의 role이 메타데이터 'roles' 배열인 requiredRoles에 포함되는지 확인
// 포함되어 있으면 true 반환
return requiredRoles.some((role) => user.role === role);
}
}
공연 등록 API를 사용하기 위해 공연 Controller에 해당 메서드를 등록
해당 기능은 ADMIN
만 사용 가능하기에 JWT 토큰과 역할 데코레이터에 의해 설정된 역할에 해당하는지 검증
파일이 입력되면 @UseInterceptors(FilesInterceptor('files', 10))
데코레이터를 통해서 files
라는 키의 파일 데이터를 가져옴
@UploadedFiles()
데코레이터는 인터셉터를 통해 가져온 파일 데이터를 files
변수에 할당함
// show.controller.ts
@UseGuards(RolesGuard)
@Controller('show')
export class ShowController {
// eslint-disable-next-line prettier/prettier
constructor(private readonly showService: ShowService) {}
// 공연 등록 (ADMIN만 사용 가능)
@Roles(Role.ADMIN)
@UseInterceptors(FilesInterceptor('files', 10))
@Post()
async createShow(@Body() createShowDto: CreateShowDto, @UploadedFiles() files) {
console.log(files);
return await this.showService.createShow(createShowDto, files);
}
}
사용자 입력을 통해 받아온 createShowDto 객체 형태로 가져와서 객체 구조 분해 할당으로 해당 객체를 풀어서 사용
공연의 기본 정보, 장소, 가격, 시간을 하나의 트랜젝션에서 처리해야 하기 때문에 queryRunner
를 통해서 해당 기능들을 트랜젝션 처리함
이 때 queryRunner
를 사용할 때 save + create 조합을 정확히 사용해야 트랜젝션이 동작함
아직 데이터베이스에 이미지 URL를 저장하는 코드는 구현하지 못했지만 files
를 통해서 이미지가 AWS S3에 등록되는 것을 확인함
// show.service.ts
...
// 공연 등록
async createShow(createShowDto: CreateShowDto, files: Express.Multer.File[]) {
const {
title,
content,
category,
runningTime,
times,
placeName,
seatA,
seatS,
seatR,
seatVip,
priceA,
priceS,
priceR,
priceVip,
} = createShowDto;
const uploadImage = await this.imageUpload(files);
console.log(uploadImage);
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
// show 테이블에 데이터 저장
const show = await queryRunner.manager.save(
this.showRepository.create({
title,
content,
category,
runningTime,
}),
);
// show_place 테이블에 데이터 저장
const showPlace = await queryRunner.manager.save(
this.showPlaceRepository.create({
showId: show.id,
placeName,
totalSeat: seatA + (seatS ?? 0) + (seatR ?? 0) + (seatVip ?? 0),
seatA,
seatS,
seatR,
seatVip,
}),
);
// show_price 테이블에 데이터 저장
const showPrice = await queryRunner.manager.save(
this.showPriceRepository.create({
showId: show.id,
priceA,
priceS,
priceR,
priceVip,
}),
);
// show_time 테이블에 데이터 저장
const showTimes = await queryRunner.manager.save(
await Promise.all(
times.map(async (time) => {
return this.showTimeRepository.create({
showId: show.id,
showTime: time,
show,
});
}),
),
);
// 출력 형식 지정
const createdShow = {
id: show.id,
title: show.title,
content: show.content,
runningTime: show.runningTime,
placeName: showPlace.placeName,
totalSeat: showPlace.totalSeat,
priceA: showPrice.priceA,
priceS: showPrice.priceS,
priceR: showPrice.priceR,
priceVip: showPrice.priceVip,
showTimes: showTimes.map((time) => time.showTime),
createdAt: show.createdAt,
updatedAt: show.updatedAt,
};
await queryRunner.commitTransaction();
return createdShow;
} catch (err) {
await queryRunner.rollbackTransaction();
throw new InternalServerErrorException('공연 등록에 실패했습니다.');
} finally {
await queryRunner.release();
}
}
aws 라는 새로운 모듈의 Service에서 작업을 진행함
생성자에서 AWS S3에 필요한 설정을 작성
사용자에게 받은 파일 데이터에서 S3에 업로드될 파일명, 파일 원본, 확장자를 매개변수로 받아옴
AWS S3의 버킷 정보를 기입하고 해당 데이터를 S3 클라이언트에게 전달 후 만들어지는 URL를 반환함
// aws/aws.service.ts
@Injectable()
export class AwsService {
s3Client: S3Client;
constructor(private configService: ConfigService) {
// AWS S3 클라이언트 초기화. 환경 설정 정보를 사용하여 AWS 리전, Access Key, Secret Key를 설정.
this.s3Client = new S3Client({
region: this.configService.get('AWS_S3_REGION'), // AWS Region
credentials: {
accessKeyId: this.configService.get('AWS_S3_ACCESS_KEY'), // Access Key
secretAccessKey: this.configService.get('AWS_S3_SECRET_KEY'), // Secret Key
},
});
}
// 이미지를 S3에 업로드
async imageUploadToS3(
fileName: string, // 업로드될 파일의 이름
file: Express.Multer.File, // 업로드할 파일
ext: string, // 파일 확장자
) {
try {
// AWS S3에 이미지 업로드 명령을 생성합니다. 파일 이름, 파일 버퍼, 파일 접근 권한, 파일 타입 등을 설정합니다.
const command = new PutObjectCommand({
Bucket: this.configService.get('AWS_BUCKET'), // S3 버킷 이름
Key: fileName, // 업로드될 파일의 이름
Body: file.buffer, // 업로드할 파일
ACL: 'public-read', // 파일 접근 권한
ContentType: `image/${ext}`, // 파일 타입
});
// 생성된 명령을 S3 클라이언트에 전달하여 이미지 업로드를 수행합니다.
await this.s3Client.send(command);
// 업로드된 이미지의 URL을 반환합니다.
return `https://s3.${process.env.AWS_S3_REGION}.amazonaws.com/${process.env.AWS_BUCKET}/${fileName}`;
} catch (err) {
console.log(err);
throw new InternalServerErrorException('관리자에게 문의해 주세요.');
}
}
}
위에서 본 AWS S3에 이미지를 업로드하는 작업을 하기 전에 S3로 보낼 이미지 파일의 이름을 가공하기 위한 작업을 진행함
지금은 이미지 데이터를 순차적으로 하나씩 업로드하고 있지만 속도 개선을 위해서 await this.awsService.imageUploadToS3
를 비동기적으로 동작시킬 필요가 있음
// show.service.ts
// 받아온 파일 데이터 가공해서 aws 서비스에 전달
async imageUpload(files: Express.Multer.File[]) {
const allowedExtensions = ['.png', '.jpg', '.jpeg', '.bmp', '.gif'];
// 오늘 날짜 구하기
const today = new Date();
const currentYear = today.getFullYear();
const currentMonth = today.getMonth() + 1;
const currentDate = today.getDate();
const date = `${currentYear}-${currentMonth}-${currentDate}`;
const imageUrls: string[] = [];
for (const file of files) {
// 임의번호 생성
let randomNumber: string = '';
for (let i = 0; i < 8; i++) {
randomNumber += String(Math.floor(Math.random() * 10));
}
// 확장자 검사
const extension = path.extname(file.originalname).toLowerCase();
if (!allowedExtensions.includes(extension)) {
throw new Error('확장자 에러');
}
const imageName = `test/${date}_${randomNumber}`;
const ext = file.originalname.split('.').pop();
const imageUrl = await this.awsService.imageUploadToS3(`${imageName}.${ext}`, file, ext);
imageUrls.push(imageUrl);
}
return { imageUrls };
}
공연 등록에서 이미지 URL를 데이터베이스 등록하는 로직이 아직 덜 구현되어서 마저 구현할 예정
그리고 공연 목록을 조회하는 API를 구현할 예정
단순한 목록 조회는 간단하기에 금방 구현될 예정
공연 검색은 공연명으로 검색을 하는데 단순히 완벽히 같은 공연명으로 검색할지 아니면 공연명 일부라도 일치하면 검색되도록 할지는 구현하면서 생각해볼 예정
공연 등록하는 로직이 생각보다 길어져서 하루 종일 구현함
특히 트랜젝션과 Multer & S3를 구현하는데 거의 모든 시간을 사용함
사실 지금 Service에 있는 공연 등록 메서드가 너무 길어져서 각 테이블별로 모듈을 따로 만들어서 구현을 해야 할지 고민임
가독성을 위해서는 따로 분리해서 작업하는게 맞는 것 같음
하지만 과제 시간을 맞추기 위해서는 일단 구현에 집중하는게 맞을 것 같음
// 생성된 명령을 S3 클라이언트에 전달하여 이미지 업로드를 수행합니다.
await this.s3Client.send(command);
위 코드 기준으로 다음의 콘솔들이 찍히지 않고 에러가 발생함
인터넷에 검색해보니 .env의 AWS S3 키 값이 잘못 설정되어 있다고 적혀 있음
즉, AWS S3와 연결하기 위한 S3Client 설정의 문제 같아 보임
constructor(private configService: ConfigService) {
// AWS S3 클라이언트 초기화. 환경 설정 정보를 사용하여 AWS 리전, Access Key, Secret Key를 설정.
this.s3Client = new S3Client({
region: this.configService.get('AWS_S3_REGION'), // AWS Region
credentials: {
accessKeyId: this.configService.get('AWS_S3_ACCESS_KEY'), // Access Key
secretAccessKey: this.configService.get('AWS_S3_SECRET_ACCESS_KEY'), // Secret Key
},
});
}
에러에서 말하는 credential object가 위의 코드에서 정의한 credentials 객체에 대한 에러를 말하는 것 같음
약 2시간 정도의 대치 후 잘못된 곳을 발견함
.env에서 가져오기 위한 키의 이름이 잘못 설정되어 있었음
즉, 단순한 오타였음....ㅠㅠ
아래와 같이 .env의 키 이름에 맞도록 작성하니 정상적으로 동작함
constructor(private configService: ConfigService) {
// AWS S3 클라이언트 초기화. 환경 설정 정보를 사용하여 AWS 리전, Access Key, Secret Key를 설정.
this.s3Client = new S3Client({
region: this.configService.get('AWS_S3_REGION'), // AWS Region
credentials: {
accessKeyId: this.configService.get('AWS_S3_ACCESS_KEY'), // Access Key
secretAccessKey: this.configService.get('AWS_S3_SECRET_KEY'), // Secret Key
},
});
}