NestJS-Transaction

jaegeunsong97·2024년 2월 12일
0

NestJS

목록 보기
27/37

🖊️Transaction 이론

Transaction

A model, B model
Post API -> A 모델을 저장하고, B 모델을 저장한다.

await repository.save(a);
await repository.save(b); // 이경우는 순서대로 진행이 된다.

만약에 a를 저장하다가 실패하면 b를 저장하면 안될경우 -> all or nothing

transaction
start -> 시작 (실행하는 모든 기능은 transaction에 묶인다)
commit -> 저장 (한번에 저장이 된다.)
rollback -> 원상복구 (start를 하고 commit 전까지 문제가 발생하면 원상복구를 한다.)

🖊️ImageModel 만들기

image는 1개보다는 여러개를 올릴 수 있도록 바꿔보겠습니다. 여러개를 올리므로써 transaction관련 코드를 작성할 수 있습니다. transaction으로 post를 생성하는 것과 이미지를 생성하고 옮기는 코드까지 전부 묶어서 1개라도 에러가 발생하면 rollback하도록 하겠습니다.

posts.entity.ts에서 image필드를 전부 제거합니다. 데이터베이스 컬럼에도 사라졌습니다.

1개의 Post에는 여러개의 Image가 붙을 수 있습니다. 따라서 ImagesModel을 만들고 PostsModel에서 @OneToMany로 묶어줍니다.

여기서는 image가 공유된다는 가정하에 common 폴더에서 작업을 하겠습니다.

여기서 @Transform에서 value는 path에 입력된 값 자체를 의미하고, obj는 ImageModel이 instance화가 되었을 때를 의미합니다.

  • image.entity.ts
export enum ImageModelType {
     POST_IMAGE,
}

@Entity()
export class ImageModel extends BaseModel {

    @Column({
      	default: 0, // order가 FE로부터오면 그대로 반영, FE로부터 아무런 값이 오지않으면 0하고 생성된 순서대로 만들 것
    })
    @IsInt()
    @IsOptional()
    order: number;

    // UsersModel -> 사용자 프로필 이미지
    // PostsModel -> 포스트 이미지
    @Column({
      	enum: ImageModelType
    })
    @IsString()
    @IsEnum(ImageModelType)
    type: ImageModelType;

    @Column()
    @IsString()
    @Transform(({value, obj}) => { // obj: 현재 객체, ImageModel이 instance로 되었을 때
        if (obj.type === ImageModelType.POST_IMAGE) {
            return join(
                POST_IMAGE_PATH, // POST_IMAGE_PATH 경로에 path를 추가
                value,
            )
        } else {
          	return value;
        }
    })
    path: string;
}

이제 연관관계를 추가하겠습니다. post는 null이 가능하도록 해야합니다. 왜냐하면 post랑만 연동되는 것이 아닌 user랑만 연동이 될 수 있기 때문입니다.

  • image.entity.ts
export enum ImageModelType {
     POST_IMAGE,
}

@Entity()
export class ImageModel extends BaseModel {

    @Column({
      	default: 0,
    })
    @IsInt()
    @IsOptional()
    order: number;

    @Column({
      	enum: ImageModelType
    })
    @IsString()
    @IsEnum(ImageModelType)
    type: ImageModelType;

    @Column()
    @IsString()
    @Transform(({value, obj}) => {
        if (obj.type === ImageModelType.POST_IMAGE) {
            return join(
                POST_IMAGE_PATH,
                value,
            )
        } else {
          	return value;
        }
    })
    path: string;

    @ManyToOne((type) => PostsModel, (post) => post.images)
    post?: PostsModel; // 추가
}
  • posts.entity.ts
@Entity()
export class PostsModel extends BaseModel {

    @ManyToOne(() => UsersModel, (user) => user.posts, {
      	nullable: false,
    })
    author: UsersModel;

    @Column()
    @IsString({
      	message: stringValidationMessage
    }) 
    title: string;

    @Column()
    @IsString({
      	message: stringValidationMessage
    }) 
    content: string;

    @Column()
    likeCount: number;

    @Column()
    commentCount: number;

    @OneToMany((type) => ImageModel, (image) => image.post)
    images: ImageModel[]; // 추가
}

마지막으로 app.module.tsImageModel을 추가합니다.

  • app.module.ts
TypeOrmModule.forRoot({
    type: 'postgres',
    host: process.env[ENV_DB_HOST_KEY],
    port: parseInt(process.env[ENV_DB_PORT_KEY]),
    username: process.env[ENV_DB_USERNAME_KEY],
    password: process.env[ENV_DB_PASSWORD_KEY],
    database: process.env[ENV_DB_DATABASE_KEY],
    entities: [
        PostsModel,
        UsersModel,
        ImageModel, // 추가
    ],
    synchronize: true,
}),

만약 여기까지 진행을 했는데도 다음과 같은 에러가 나온 경우 dist폴더를 삭제하고 재실행하면 됩니다.


🖊️ImageModel 생성하는 로직 작성

  • posts/dto/create-post.dto.ts
export class CreatePostDto extends PickType(PostsModel, ['title', 'content']) {

    @IsString({
      	each: true, // 리스트 안에있는 것들을 검증 해야한다.
    })
    @IsOptional()
    images: string[] = []; // 아무것도 없는 경우 empty array
}

여러개의 이미지들을 받을 수 있도록 바꿔줍니다. 또한 리스트에 들어있는 각각의 이미지를 검증해야하기 때문에 each: true를 붙여줍니다.

컨트롤러로 이동 후 몇가지를 변경하겠습니다. 트랜잭션을 진행할 때, post 관련 작업을 먼저 하고, 그 다음에 post가 잘 생성이 되면 image작업을 해야됩니다. 왜냐하면 이미지 작업을 할 때, 이미지를 옮기는 것이 있기 때문입니다. 즉, 이미지를 옮기는 과정을 가장 마지막에 할 것입니다.

왜냐하면 트랜젝션에는 롤백 기능이 있기 때문입니다. 따라서 데이터베이스와 관련이 있는 작업을 먼저 진행하고, 나중에 관련이 없는 파일을 옮기는 것과 같은 것을 마지막에 작업하도록 하겠습니다.

  • posts.controller.ts
@Post()
@UseGuards(AccessTokenGuard)
async postPosts(
    @User('id') userId: number,
    @Body() body: CreatePostDto,
    @UploadedFile() file?: Express.Multer.File,
) {
    const post = await this.postsService.createPost( // 위치 변경
      	userId, body,
    );

    await this.postsService.createPostImage(body); // temp -> posts
}
.
.
변경
.
.
@Post()
@UseGuards(AccessTokenGuard)
async postPosts(
    @User('id') userId: number,
    @Body() body: CreatePostDto,
    @UploadedFile() file?: Express.Multer.File,
) {
    const post = await this.postsService.createPost(
      	userId, body,
    );

  	// 추가
    for (let i = 0; i < body.images.length; i++) {
      	await this.postsService.createPostImage(body);
    }
}
  • posts.service.ts
async generatePosts(userId: number) {
    for(let i = 0; i < 100; i++) {
        await this.createPost(userId, {
            title: `임의로 생성된 ${i}`,
            content: `임의로 생성된 포수트 내용 ${i}`,
            images: [], // 추가
        });
    }
}

그리고 image생성과 관련해서 dto를 추가로 만들도록 하겠습니다. 왜냐하면 지금 createPostImage()는 image가 1개만 되기 때문입니다.

  • posts/image/dto/create-image.dto.ts
export class CreatePostImageDto extends PickType(ImageModel, [
     'path',
     'post',
     'order',
     'type',
]) {}

post의 경우는 컨트롤러에서 직접 주입 받도록 하겠습니다.

@Entity()
export class ImageModel extends BaseModel {

     @Column({
          default: 0,
     })
     @IsInt()
     @IsOptional()
     order: number;

     @Column({
          enum: ImageModelType
     })
     @IsString()
     @IsEnum(ImageModelType)
     type: ImageModelType;

     @Column()
     @IsString()
     @Transform(({value, obj}) => {
          if (obj.type === ImageModelType.POST_IMAGE) {
               return join(
                    POST_IMAGE_PATH,
                    value,
               )
          } else {
               return value;
          }
     })
     path: string;

     @ManyToOne((type) => PostsModel, (post) => post.images)
     post?: PostsModel; // 이거는 직접 주입받자!!
}
@Post()
@UseGuards(AccessTokenGuard)
async postPosts(
    @User('id') userId: number,
    @Body() body: CreatePostDto,
    @UploadedFile() file?: Express.Multer.File,
) {
    const post = await this.postsService.createPost(
      	userId, body,
    ); // 여기서 post를 직접 주입받는다!

    for (let i = 0; i < body.images.length; i++) {
      	await this.postsService.createPostImage(body);
    }
}

다시 서비스로 이동해서 코드를 작성하겠습니다. 또한 이미지 모델을 생성해야하기 때문에 주입을 받도록 하겠습니다. 그리고 posts.module.ts에 imports를 하겠습니다.

  • posts.service.ts
constructor(
    @InjectRepository(PostsModel)
    private readonly postsRepository: Repository<PostsModel>,
    @InjectRepository(ImageModel)
  	private readonly imageRepository: Repository<ImageModel>,
    private readonly commonService: CommonService,
    private readonly configService: ConfigService,
) {}
.
.
async createPostImage(dto: CreatePostImageDto) { // 변경
    const tempFilePath = join(
        TEMP_FOLDER_PATH,
        dto.path, // 변경 : 왜냐하면 CreatePostImageDto는 각각의 하나가 image 생성
    );

    try {
      	await promises.access(tempFilePath);
    } catch (error) {
      	throw new BadRequestException('존재하지 않는 파일입니다. ');
    }

    const fileName = basename(tempFilePath);
    const newPath = join(
        POST_IMAGE_PATH,
        fileName,
    );

    await promises.rename(tempFilePath, newPath);
    return true;
}
  • posts.module.ts
@Module({
    imports: [
        TypeOrmModule.forFeature([
            PostsModel,
            ImageModel, // 추가
        ]),
        AuthModule,
        UsersModule,
        CommonModule,
    ],
    controllers: [PostsController],
    providers: [PostsService],
    exports: [PostsService]
})
export class PostsModule {}

그리고 createPostImage()안에서 image 모델은 언제 저장하는 것이 가장 좋은지를 생각해야합니다. 무엇인가를 만들때 파일을 temp에서 이동 시키기전에 저장하는 것이 좋다고 생각합니다.

왜냐하면 만약 파일을 이동시키고 에러가 발생하면 다시 원상복구를 해야해서 번거롭기 때문입니다.

  • posts.service.ts
async createPostImage(dto: CreatePostImageDto) {
    const tempFilePath = join(
        TEMP_FOLDER_PATH,
        dto.path,
    );

    try {
      	await promises.access(tempFilePath);
    } catch (error) {
      	throw new BadRequestException('존재하지 않는 파일입니다. ');
    }

    const fileName = basename(tempFilePath);
    const newPath = join(
        POST_IMAGE_PATH,
        fileName,
    );

    // save
    const result = await this.imageRepository.save({
      	...dto, // create하고 save도 가능하고 바로 넣어도 상관없음
    });

    await promises.rename(tempFilePath, newPath);
    return result; // 변경
}

그리고 createPost() 또한 바꿔주도록 하겠습니다. 왜냐하면 image가 CreatePostImage와 ImageModel이 호환 되지않기 때문에 에러가 발생합니다. posts의 엔티티로 가게 되면 image는 리스트 타입으로 되어있습니다. 따라서 images: [] 이렇게 만들어 줍니다.

그리고 createPost를 할 때는 Post 자체만 생성을 하고, 이후에 createPostImage()를 하면서 이미지를 생성합니다. 이때 save 부분에서 image를 저장하는 동시에 자동으로 post와 연결이 됩니다. 왜냐하면 PickType으로 상속을 받기 때문입니다.

async createPost(authorId: number, postDto: CreatePostDto ) {
    const post = this.postsRepository.create({
        author: {
          	id: authorId,
        },
        ...postDto,
        images: [], // createPostImage()를 실행하면 post를 생성하고 image가 생성되면서 자동으로 연결이 된다.
        likeCount: 0,
        commentCount: 0,
    });

    const newPost = await this.postsRepository.save(post);
    return newPost;
}

async createPostImage(dto: CreatePostImageDto) {
    const tempFilePath = join(
        TEMP_FOLDER_PATH,
        dto.path,
    );

    try {
        await promises.access(tempFilePath);
    } catch (error) {
      	throw new BadRequestException('존재하지 않는 파일입니다. ');
    }

    const fileName = basename(tempFilePath);
    const newPath = join(
        POST_IMAGE_PATH,
        fileName,
    );

    // save
    const result = await this.imageRepository.save({
      	...dto,
    });

    await promises.rename(tempFilePath, newPath);
    return result;
}

컨트롤러를 변경해줍니다.

  • posts.controller.ts
@Post()
@UseGuards(AccessTokenGuard)
async postPosts(
    @User('id') userId: number,
    @Body() body: CreatePostDto,
    @UploadedFile() file?: Express.Multer.File,
) {
  	// 현재 image없이 post만 생성된 상태
    const post = await this.postsService.createPost(
      	userId, body,
    );

  	// 루핑하면서 image 생성 -> 동시에 post와 연동된다.
    for (let i = 0; i < body.images.length; i++) {
        await this.postsService.createPostImage({
            post,
            order: i,
            path: body.images[i],
            type: ImageModelType.POST_IMAGE,
        });
    }
  	
  	// 생성된 post id 반환
  	return this.postsService.getPostById(post.id);
}

포스트맨으로 테스트를 해보겠습니다. 먼저 로그인을 하고 1개의 image만 해보도록 하겠습니다.

{
    "fileName": "6ea0ea3f-960b-4f36-a50e-06d93cddf5bd.png"
}

{
    "id": 112,
    "updatedAt": "2024-02-13T05:52:20.390Z",
    "createdAt": "2024-02-13T05:52:20.390Z",
    "title": "제목",
    "content": "내용",
    "likeCount": 0,
    "commentCount": 0,
    "author": {
        "id": 1,
        "updatedAt": "2024-01-26T05:58:10.800Z",
        "createdAt": "2024-01-26T05:58:10.800Z",
        "nickname": "codefactory",
        "email": "codefactory@codefactory.ai",
        "role": "USER"
    }
}

이번에는 1개의 post에 여러개의 이미지를 생성해보겠습니다.

{
    "id": 113,
    "updatedAt": "2024-02-13T05:54:05.084Z",
    "createdAt": "2024-02-13T05:54:05.084Z",
    "title": "제목",
    "content": "내용",
    "likeCount": 0,
    "commentCount": 0,
    "author": {
        "id": 1,
        "updatedAt": "2024-01-26T05:58:10.800Z",
        "createdAt": "2024-01-26T05:58:10.800Z",
        "nickname": "codefactory",
        "email": "codefactory@codefactory.ai",
        "role": "USER"
    }
}

get 요청으로 확인해보도록 하겠습니다.

{
    "data": [
        {
            "id": 113,
            "updatedAt": "2024-02-13T05:54:05.084Z",
            "createdAt": "2024-02-13T05:54:05.084Z",
            "title": "제목",
            "content": "내용",
            "likeCount": 0,
            "commentCount": 0,
            "author": {
                "id": 1,
                "updatedAt": "2024-01-26T05:58:10.800Z",
                "createdAt": "2024-01-26T05:58:10.800Z",
                "nickname": "codefactory",
                "email": "codefactory@codefactory.ai",
                "role": "USER"
            }
        },
        {
            "id": 112,
            "updatedAt": "2024-02-13T05:52:20.390Z",
            "createdAt": "2024-02-13T05:52:20.390Z",
            "title": "제목",
            "content": "내용",
            "likeCount": 0,
            "commentCount": 0,
            "author": {
                "id": 1,
                "updatedAt": "2024-01-26T05:58:10.800Z",
                "createdAt": "2024-01-26T05:58:10.800Z",
                "nickname": "codefactory",
                "email": "codefactory@codefactory.ai",
                "role": "USER"
            }
        },

image가 보이지 않습니다. 이 문제는 이전에도 여러번 겪은 문제입니다. 왜냐하면 이제는 post 엔티티는 author과 images 모두 relation이기 때문입니다. 이후에 다시 포스트맨으로 요청을 해보겠습니다.

  • posts.service.ts
async getPostById(id: number) {
    const post = await this.postsRepository.findOne({
        where: {
          	id,
        },
        relations: [
            'author',
            'images' // 추가
        ],
    });
    if (!post) throw new NotFoundException();
    return post;
}
.
.
async paginatePosts(dto: PaginatePostDto) {
    return this.commonService.paginate(
        dto,
        this.postsRepository,
        {
          	relations: ['author', 'images'] // 추가
        },
        'posts'
    );
}
{
    "data": [
        {
            "id": 113,
            "updatedAt": "2024-02-13T05:54:05.084Z",
            "createdAt": "2024-02-13T05:54:05.084Z",
            "title": "제목",
            "content": "내용",
            "likeCount": 0,
            "commentCount": 0,
            "author": {
                "id": 1,
                "updatedAt": "2024-01-26T05:58:10.800Z",
                "createdAt": "2024-01-26T05:58:10.800Z",
                "nickname": "codefactory",
                "email": "codefactory@codefactory.ai",
                "role": "USER"
            },
            "images": [
                {
                    "id": 2,
                    "updatedAt": "2024-02-13T05:54:05.155Z",
                    "createdAt": "2024-02-13T05:54:05.155Z",
                    "order": 0,
                    "type": 0,
                    "path": "C:\\workspace\\nestjsworkspace\\df_sns_2\\public\\posts\\0a314f62-3bfe-4e2b-8e49-925fbceacf70.png"
                },
                {
                    "id": 3,
                    "updatedAt": "2024-02-13T05:54:05.226Z",
                    "createdAt": "2024-02-13T05:54:05.226Z",
                    "order": 1,
                    "type": 0,
                    "path": "C:\\workspace\\nestjsworkspace\\df_sns_2\\public\\posts\\3d651d58-2939-409f-afc0-6a109816f906.png"
                }
            ]
        },
        {
            "id": 112,
            "updatedAt": "2024-02-13T05:52:20.390Z",
            "createdAt": "2024-02-13T05:52:20.390Z",
            "title": "제목",
            "content": "내용",
            "likeCount": 0,
            "commentCount": 0,
            "author": {
                "id": 1,
                "updatedAt": "2024-01-26T05:58:10.800Z",
                "createdAt": "2024-01-26T05:58:10.800Z",
                "nickname": "codefactory",
                "email": "codefactory@codefactory.ai",
                "role": "USER"
            },
            "images": [
                {
                    "id": 1,
                    "updatedAt": "2024-02-13T05:52:20.487Z",
                    "createdAt": "2024-02-13T05:52:20.487Z",
                    "order": 0,
                    "type": 0,
                    "path": "C:\\workspace\\nestjsworkspace\\df_sns_2\\public\\posts\\6ea0ea3f-960b-4f36-a50e-06d93cddf5bd.png"
                }
            ]
        },

public부터 경로가 오도록 만들어 보겠습니다.

  • image.entity.ts
@Entity()
export class ImageModel extends BaseModel {

    @Column({
      	default: 0,
    })
    @IsInt()
    @IsOptional()
    order: number;

    @Column({
      	enum: ImageModelType
    })
    @IsString()
    @IsEnum(ImageModelType)
    type: ImageModelType;

    @Column()
    @IsString()
    @Transform(({value, obj}) => {
        if (obj.type === ImageModelType.POST_IMAGE) {
            return `/${join(
                POST_PUBLIC_IMAGE_PATH, // POST_IMAGE_PATH 경로에 path를 추가
                value,
            )}`
        } else {
          	return value;
        }
    })
    path: string;

    @ManyToOne((type) => PostsModel, (post) => post.images)
    post?: PostsModel;
}
{
    "data": [
        {
            "id": 113,
            "updatedAt": "2024-02-13T05:54:05.084Z",
            "createdAt": "2024-02-13T05:54:05.084Z",
            "title": "제목",
            "content": "내용",
            "likeCount": 0,
            "commentCount": 0,
            "author": {
                "id": 1,
                "updatedAt": "2024-01-26T05:58:10.800Z",
                "createdAt": "2024-01-26T05:58:10.800Z",
                "nickname": "codefactory",
                "email": "codefactory@codefactory.ai",
                "role": "USER"
            },
            "images": [
                {
                    "id": 2,
                    "updatedAt": "2024-02-13T05:54:05.155Z",
                    "createdAt": "2024-02-13T05:54:05.155Z",
                    "order": 0,
                    "type": 0,
                    "path": "/public\\posts\\0a314f62-3bfe-4e2b-8e49-925fbceacf70.png" // 변경
                },
                {
                    "id": 3,
                    "updatedAt": "2024-02-13T05:54:05.226Z",
                    "createdAt": "2024-02-13T05:54:05.226Z",
                    "order": 1,
                    "type": 0,
                    "path": "/public\\posts\\3d651d58-2939-409f-afc0-6a109816f906.png" // 변경
                }
            ]
        },
        {
            "id": 112,
            "updatedAt": "2024-02-13T05:52:20.390Z",
            "createdAt": "2024-02-13T05:52:20.390Z",
            "title": "제목",
            "content": "내용",
            "likeCount": 0,
            "commentCount": 0,
            "author": {
                "id": 1,
                "updatedAt": "2024-01-26T05:58:10.800Z",
                "createdAt": "2024-01-26T05:58:10.800Z",
                "nickname": "codefactory",
                "email": "codefactory@codefactory.ai",
                "role": "USER"
            },
            "images": [
                {
                    "id": 1,
                    "updatedAt": "2024-02-13T05:52:20.487Z",
                    "createdAt": "2024-02-13T05:52:20.487Z",
                    "order": 0,
                    "type": 0,
                    "path": "/public\\posts\\6ea0ea3f-960b-4f36-a50e-06d93cddf5bd.png" // 변경
                }
            ]
        },
/public\\posts\\6ea0ea3f-960b-4f36-a50e-06d93cddf5bd.png
http://localhost:3000/public//posts//6ea0ea3f-960b-4f36-a50e-06d93cddf5bd.png

잘 나오는 것을 알 수 있습니다.

지금부터는 서비스 코드에 고정으로 들어가게 되는 값들이 있습니다. 이것을 정리하도록 하겠습니다.

  • posts/const/default-post-find-options.const.ts
import { FindManyOptions } from "typeorm";
import { PostsModel } from "../entities/posts.entity";

export const DEFAULT_POST_AND_OPTIONS: FindManyOptions<PostsModel> = {
    // relations: [
    //      'author', 
    //      'images'
    // ] 이것도 가능하다
    relations: {
        author: true,
        images: true,
    }
}
  • posts.service.ts
async getAllPosts() {
    return await this.postsRepository.find({
      	...DEFAULT_POST_AND_OPTIONS,
    });
}

async paginatePosts(dto: PaginatePostDto) {
    return this.commonService.paginate(
        dto,
        this.postsRepository,
        {
          	...DEFAULT_POST_AND_OPTIONS
        },
        'posts'
    );
}

async getPostById(id: number) {
    const post = await this.postsRepository.findOne({
      	...DEFAULT_POST_AND_OPTIONS,
        where: {
          	id,
        },
    });
    if (!post) throw new NotFoundException();
    return post;
}

이렇게 함으로써 매번 변경할 필요 없이 DEFAULT_POST_AND_OPTIONS에서 변경을 하면 됩니다.


🖊️Transaction 시작

이제부터는 트랜젝션을 사용하지 않으면 발생하는 전형적인 문제에 대해서 알아보겠습니다. post를 생성하는 코드와 이미지를 만드는 코드를 보면 각각 전부 따로 놀고 있습니다. 강제로 에러를 줘보도록 하겠습니다.

  • posts.controller.ts
@Post()
@UseGuards(AccessTokenGuard)
async postPosts(
    @User('id') userId: number,
    @Body() body: CreatePostDto,
    @UploadedFile() file?: Express.Multer.File,
) {
    const post = await this.postsService.createPost(
      	userId, body,
    );
  
  	throw new InternalServerErrorException('@@@@@@@@') // 강제 에러 발생

    for (let i = 0; i < body.images.length; i++) {
        await this.postsService.createPostImage({
            post,
            order: i,
            path: body.images[i],
            type: ImageModelType.POST_IMAGE,
        });
    }

    return this.postsService.getPostById(post.id);
}

그리고 포스트맨으로 보내보도록 하겠습니다. 로그인을 진행하고 /common/image로 temp폴더에 임시 저장까지 한 뒤, /post를 해보겠습니다.

{
    "message": "@@@@@@@@",
    "error": "Internal Server Error",
    "statusCode": 500
}

하지만 post 전체 조호를 클릭하면 다음과 같이 나옵니다. 이 데이터는 존재하면 안되는 데이터 입니다. post까지는 생성을 했는데 이미지를 생성하지 못한것 즉, 제대로 작업되지 않은 것입니다. 따라서 이전 상태로 롤백이 되어야합니다. 버그입니다.

이제 이 기능들을 1개로 묶어서 트랜젝션으로 관리하도록 하겠습니다. 강제로 발생시킨 에러를 다시 지워줍니다.

{
    "data": [
        {
            "id": 114,
            "updatedAt": "2024-02-13T13:21:40.868Z",
            "createdAt": "2024-02-13T13:21:40.868Z",
            "title": "제목",
            "content": "내용",
            "likeCount": 0,
            "commentCount": 0,
            "author": {
                "id": 1,
                "updatedAt": "2024-01-26T05:58:10.800Z",
                "createdAt": "2024-01-26T05:58:10.800Z",
                "nickname": "codefactory",
                "email": "codefactory@codefactory.ai",
                "role": "USER"
            },
            "images": []
        },

컨트롤러에서 DataSource를 가져옵니다. DataSource는 nest.js 패키지안에 들어있습니다. DataSource를 만들고 release까지 진행이 되어야합니다.

  • posts.controller.ts
constructor(
    private readonly postsService: PostsService,
    private readonly dataSource: DataSource, // 추가
) {}

@Post()
@UseGuards(AccessTokenGuard)
async postPosts(
    @User('id') userId: number,
    @Body() body: CreatePostDto,
    @UploadedFile() file?: Express.Multer.File,
) {
    // 트랜젝션과 관련된 모든 쿼리를 담당할 쿼리 러너를 생성한다.
    const queryRunner = this.dataSource.createQueryRunner();

    // 쿼리 러너에 연결한다.
    await queryRunner.connect();

    // 쿼리 러너에서 트랜젝션을 시작한다.
    // 이 시점부터 같은 쿼리 러너를 사용하면
    // 트랜젝션 안에서 데이터베이스 액션을 실행 할 수 있다.
    await queryRunner.startTransaction();

    // 로직 실행 -> tryCatch 내부에 사용하기
    try {
      const post = await this.postsService.createPost( 
        userId, body,
      );

      for (let i = 0; i < body.images.length; i++) {
        await this.postsService.createPostImage({
          post,
          order: i,
          path: body.images[i],
          type: ImageModelType.POST_IMAGE,
        });
      }

      // 정상적으로 진행된 경우, commit -> release
      await queryRunner.commitTransaction();
      await queryRunner.release();

      return this.postsService.getPostById(post.id);
    } catch (error) {
      // 어떤 에러든 에러가 던져지면 트랜젝션을 종료하고 워래 상태로 되돌린다.
      await queryRunner.rollbackTransaction();
      await queryRunner.release();
    }
}

하지만 아직 쿼리 러너의 모든 기능이 트랜젝션이 묶이지 않습니다. 같은 쿼리러너를 사용해야만 트랜젝션에 묶이는 것입니다.

따라서 같은 쿼리러너를 사용한 경우에는 같은 저장소에서 가져오도록 만들고, 아닌 경우는 주입을 받았던 레포지토리를 사용하도록 만들겠습니다.

  • psots.service.ts
getRepository(queryRunner?: QueryRunner) {
    // queryRunner가 있는 경우에는 queryRunner 저장소만 사용
	// 아니면 주입받은 저장소 사용
	return queryRunner ? queryRunner.manager.getRepository<PostsModel>(PostsModel) : this.postsRepository;
}
getRepository(queryRunner?: QueryRunner) {
    // queryRunner가 있는 경우에는 queryRunner 저장소만 사용
    // 아니면 주입받은 저장소 사용
    return queryRunner ? queryRunner.manager.getRepository<PostsModel>(PostsModel) : this.postsRepository;
}

async createPost(authorId: number, postDto: CreatePostDto, queryRunner?: QueryRunner) {
    const repository = this.getRepository(queryRunner); // 추가

    const post = repository.create({ // 변경
        author: {
          id: authorId,
        },
        ...postDto,
        images: [],
        likeCount: 0,
        commentCount: 0,
    });

    const newPost = await repository.save(post); // 변경
    return newPost;
}

🖊️Transaction 적용 및 테스트

이제 createPost에서는 queryRunner를 파라미터로 값을 받습니다. 따라서 컨트롤러에 queryRunner를 추가하도록 하겠습니다.

  • posts.controller.ts
@Post()
@UseGuards(AccessTokenGuard)
async postPosts(
    @User('id') userId: number,
    @Body() body: CreatePostDto,
    @UploadedFile() file?: Express.Multer.File,
) {
    const queryRunner = this.dataSource.createQueryRunner();
    await queryRunner.connect();
    await queryRunner.startTransaction();

    try {
      const post = await this.postsService.createPost( 
        userId, body, queryRunner, // 추가
      );

      for (let i = 0; i < body.images.length; i++) {
        await this.postsService.createPostImage({
          post,
          order: i,
          path: body.images[i],
          type: ImageModelType.POST_IMAGE,
        });
      }

      await queryRunner.commitTransaction();
      await queryRunner.release();

      return this.postsService.getPostById(post.id);
    } catch (error) {
      await queryRunner.rollbackTransaction();
      await queryRunner.release();
    }
}

그리고 queryRunner를 필요로 하는 createPostImage 서비스 메소드도 바꾸도록 하겠습니다. createPostImage서비스 코드를 보면 imageRepository를 사용하는 공간이 있습니다. 근데 여기서 우리가 queryRunner를 만들고 하면 복잡해집니다. post관련된 기능과 postImage관련된 기능이 섞이기 때문에 image 폴더에 post의 image기능을 담당하는 코드를 작성하겠습니다.

먼저 posts.service.ts에 있는 createPostImage() 내부의 로직을 그대로 옮겨 붙이고 posts.service.ts에 있는 createPostImage()는 제거하겠습니다.

그리고 추가로 queryRunner를 파라미터로 받겠습니다.

  • image/images.service.ts
@Injectable()
export class PostsImagesService{

    constructor(
        @InjectRepository(ImageModel)
        private readonly imageRepository: Repository<ImageModel>,
    ){}
  
  	getRepository(queryRunner?: QueryRunner) {
          return queryRunner ? queryRunner.manager.getRepository<ImageModel>(ImageModel) : this.imageRepository;
     }

    async createPostImage(dto: CreatePostImageDto, queryRunner?: QueryRunner) {
      	const repository = this.getRepository(queryRunner); // 추가
        const tempFilePath = join(
            TEMP_FOLDER_PATH,
            dto.path,
        );

        try {
          	await promises.access(tempFilePath);
        } catch (error) {
          	throw new BadRequestException('존재하지 않는 파일입니다. ');
        }

        const fileName = basename(tempFilePath);
        const newPath = join(
            POST_IMAGE_PATH,
            fileName,
        );

        const result = await repository.save({ // 변경
          	...dto,
        });

        await promises.rename(tempFilePath, newPath);
        return result;
    }
}

그리고 posts의 컨트롤러로 이동을 해서 postsImagesService를 주입하고 module에서도 provider로 제공을 하도록 하겠습니다.

  • posts.module.ts
@Module({
    imports: [
        TypeOrmModule.forFeature([
            PostsModel,
            ImageModel,
        ]),
        AuthModule,
        UsersModule,
        CommonModule,
    ],
    controllers: [PostsController],
    providers: [PostsService, PostsImagesService,],
    exports: [PostsService]
})
export class PostsModule {}
  • posts.controller.ts
@Controller('posts')
export class PostsController {
    constructor(
        private readonly postsService: PostsService,
        private readonly postsImagesService: PostsImagesService, // 추가
        private readonly dataSource: DataSource,
    ) {}
.
.
@Post()
@UseGuards(AccessTokenGuard)
async postPosts(
    @User('id') userId: number,
    @Body() body: CreatePostDto,
    @UploadedFile() file?: Express.Multer.File,
) {
      const queryRunner = this.dataSource.createQueryRunner();
      await queryRunner.connect();
      await queryRunner.startTransaction();

      try {
          const post = await this.postsService.createPost( 
          	  userId, body, queryRunner,
          );

          for (let i = 0; i < body.images.length; i++) {
              await this.postsImagesService.createPostImage({ // 변경
                  post,
                  order: i,
                  path: body.images[i],
                  type: ImageModelType.POST_IMAGE,
              }, queryRunner); // 추가
          }

          await queryRunner.commitTransaction();
          await queryRunner.release();

          return this.postsService.getPostById(post.id);
      } catch (error) {
          await queryRunner.rollbackTransaction();
          await queryRunner.release();
      }
}

포스트맨으로 테스트를 하겠습니다. 로그인을 하고 임시 폴더에 이미지를 저장까지 해놓고 진행하겠습니다.

{
    "id": 115,
    "updatedAt": "2024-02-13T14:04:28.383Z",
    "createdAt": "2024-02-13T14:04:28.383Z",
    "title": "제목",
    "content": "내용",
    "likeCount": 0,
    "commentCount": 0,
    "author": {
        "id": 1,
        "updatedAt": "2024-01-26T05:58:10.800Z",
        "createdAt": "2024-01-26T05:58:10.800Z",
        "nickname": "codefactory",
        "email": "codefactory@codefactory.ai",
        "role": "USER"
    },
    "images": [
        {
            "id": 4,
            "updatedAt": "2024-02-13T14:04:28.383Z",
            "createdAt": "2024-02-13T14:04:28.383Z",
            "order": 0,
            "type": 0,
            "path": "/public\\posts\\e3989050-da50-49e3-9dc6-e99afe9612cc.png"
        }
    ]
}

115번입니다. get 요청을 해도 115번으로 나오고 있습니다.

{
    "data": [
        {
            "id": 115,
            "updatedAt": "2024-02-13T14:04:28.383Z",
            "createdAt": "2024-02-13T14:04:28.383Z",
            "title": "제목",
            "content": "내용",
            "likeCount": 0,
            "commentCount": 0,
            "author": {
                "id": 1,
                "updatedAt": "2024-01-26T05:58:10.800Z",
                "createdAt": "2024-01-26T05:58:10.800Z",
                "nickname": "codefactory",
                "email": "codefactory@codefactory.ai",
                "role": "USER"
            },
            "images": [
                {
                    "id": 4,
                    "updatedAt": "2024-02-13T14:04:28.383Z",
                    "createdAt": "2024-02-13T14:04:28.383Z",
                    "order": 0,
                    "type": 0,
                    "path": "/public\\posts\\e3989050-da50-49e3-9dc6-e99afe9612cc.png"
                }
            ]
        },

이번에는 중간에 에러를 만들겠습니다.

@Post()
@UseGuards(AccessTokenGuard)
async postPosts(
    @User('id') userId: number,
    @Body() body: CreatePostDto,
    @UploadedFile() file?: Express.Multer.File,
) {
    const queryRunner = this.dataSource.createQueryRunner();
    await queryRunner.connect();
    await queryRunner.startTransaction();

    try {
        const post = await this.postsService.createPost( 
          	userId, body, queryRunner,
        );

        throw new InternalServerErrorException('!!!!!!!!!!!!!!!!!!!!!!!');

        for (let i = 0; i < body.images.length; i++) {
            await this.postsImagesService.createPostImage({
                post,
                order: i,
                path: body.images[i],
                type: ImageModelType.POST_IMAGE,
            }, queryRunner);
        }

        await queryRunner.commitTransaction();
        await queryRunner.release();

        return this.postsService.getPostById(post.id);
    } catch (error) {
        await queryRunner.rollbackTransaction();
        await queryRunner.release();
      	throw new InternalServerErrorException('에러 발생');
    }
}

똑같이 로그인을 하고 이미지를 임시 폴더에 저장하고 진행하겠습니다.

{
    "message": "에러 발생",
    "error": "Internal Server Error",
    "statusCode": 500
}

전체 조회를 해도 그대로 115번이 존재하는걸 알 수 있습니다. 즉, 안에서 에러가 발생하면서 트랜젝션이 롤백이 된 것입니다.

이제 강제로 만든 에러는 제거합니다.

{
    "data": [
        {
            "id": 115,
            "updatedAt": "2024-02-13T14:04:28.383Z",
            "createdAt": "2024-02-13T14:04:28.383Z",
            "title": "제목",
            "content": "내용",
            "likeCount": 0,
            "commentCount": 0,
            "author": {
                "id": 1,
                "updatedAt": "2024-01-26T05:58:10.800Z",
                "createdAt": "2024-01-26T05:58:10.800Z",
                "nickname": "codefactory",
                "email": "codefactory@codefactory.ai",
                "role": "USER"
            },
            "images": [
                {
                    "id": 4,
                    "updatedAt": "2024-02-13T14:04:28.383Z",
                    "createdAt": "2024-02-13T14:04:28.383Z",
                    "order": 0,
                    "type": 0,
                    "path": "/public\\posts\\e3989050-da50-49e3-9dc6-e99afe9612cc.png"
                }
            ]
        },

따라서 서버에서 a, b, c 에러가 날 수 있는 상황이 있으면 무조건 트랜젝션으로 묶어줘야 합니다.

profile
블로그 이전 : https://medium.com/@jaegeunsong97

0개의 댓글