๐ŸŽ‰ berenickt ๋ธ”๋กœ๊ทธ์— ์˜จ ๊ฑธ ํ™˜์˜ํ•ฉ๋‹ˆ๋‹ค. ๐ŸŽ‰
Back
NestJs
27-Transaction

1. Transaction(๊ฑฐ๋ž˜) ์†Œ๊ฐœ

  • Transaction์€ ์—ฌ๋Ÿฌ ์˜คํผ๋ ˆ์ด์…˜์„ ํ•˜๋‚˜์˜ ๋…ผ๋ฆฌ์ ์ธ ์ž‘์—…์œผ๋กœ ์‹คํ–‰ํ•˜๋Š” ๊ธฐ๋Šฅ์ด๋‹ค.
    • ํŠธ๋žœ์žญ์…˜(Transaction) : ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์˜ ์ƒํƒœ๋ฅผ ๋ณ€ํ™”์‹œํ‚ค๊ธฐ ํ•ด์„œ ์ˆ˜ํ–‰ํ•˜๋Š” ์ž‘์—…์˜ ๋‹จ์œ„
  • e.g. Transaction(๊ฑฐ๋ž˜) ์˜ˆ์ œ : ์€ํ–‰ ๊ฑฐ๋ž˜
    • ๊ณ„์ขŒ A์—์„œ 1000์›์„ ๊ณ„์ขŒ B๋กœ ์†ก๊ธˆํ•œ๋‹ค.
    • ์ด ๊ณผ์ •์„ single transaction์ด๋ผ ํ•œ๋‹ค.
  • Transaction์˜ ์š”์†Œ : Begin, Commit, Rollback
    • Begin : ํŠธ๋žœ์žญ์…˜ ์‹œ์ž‘
    • Commit : ํŠธ๋žœ์žญ์…˜ ์ €์žฅ
    • Rollback : ํŠธ๋žœ์žญ์…˜ ์ทจ์†Œ
  • Transaction์˜ ๋ฌธ์ œ์ 
    • Lost Reads : ๋‘ ํŠธ๋žœ์žญ์…˜์ด ๊ฐ™์€ ๋ฐ์ดํ„ฐ๋ฅผ ์—…๋ฐ์ดํŠธํ•ด์„œ ํ•˜๋‚˜์˜ ์—…๋ฐ์ดํŠธ๊ฐ€ ์†์‹ค๋˜๋Š” ๊ฒฝ์šฐ
    • Dirty Reads : ์•„์ง Commit ๋˜์ง€ ์•Š์€ ๊ฐ’์„ ๋‹ค๋ฅธ ํŠธ๋žœ์žญ์…˜์ด ์ฝ๋Š” ๊ฒฝ์šฐ
    • Non-Repeatable Reads : ํ•œ ํŠธ๋žœ์žญ์…˜์—์„œ ๋ฐ์ดํ„ฐ๋ฅผ ๋‘๋ฒˆ ์ฝ์„๋•Œ ๋‹ค๋ฅธ ๊ฒฐ๊ณผ๊ฐ€ ๋‚˜์˜ค๋Š” ๊ฒฝ์šฐ
    • Phantom Reads : ์ฒซ Read ์ดํ›„์— ๋‹ค๋ฅธ ํŠธ๋žœ์žญ์…˜์—์„œ ๋ฐ์ดํ„ฐ๋ฅผ ์ถ”๊ฐ€ํ•œ ๊ฒฝ์šฐ

1.1 Lost Update

1
-- ์ดˆ๊ธฐ์ƒํƒœ
2
-- Table: account
3
-- | id | balance |
4
-- |----|---------|
5
-- | 1 | 1000 |
6
7
-- ํŠธ๋žœ์žญ์…˜ 1
8
BEGIN TRANSACTION;
9
SELECT balance FROM account WHERE id = 1; -- 1000 ๋ฐ˜ํ™˜
10
UPDATE account SET balance = 1000 - 100 WHERE id = 1; -- 900์œผ๋กœ ์—…๋ฐ์ดํŠธ
11
12
-- ํŠธ๋žœ์žญ์…˜ 2 (๋™์‹œ์— ๋ฐœ์ƒ)
13
BEGIN TRANSACTION;
14
SELECT balance FROM account WHERE id = 1; -- 1000 ๋ฐ˜ํ™˜
15
UPDATE account SET balance = 1000 - 200 WHERE id = 1; -- 800์œผ๋กœ ์—…๋ฐ์ดํŠธ
16
17
-- ํŠธ๋žœ์žญ์…˜ 1 ์ง„ํ–‰
18
COMMIT; -- Balance: 900
19
20
-- ํŠธ๋žœ์žญ์…˜ 2 ์ง„ํ–‰
21
COMMIT; -- Balance: 800
22
23
-- ์ตœ์ข… ๊ฒฐ๊ณผ
24
-- | id | balance |
25
-- |----|---------|
26
-- | 1 | 800 |
  • ๋‘๊ฐœ์˜ ํŠธ๋žœ์žญ์…˜์ด ๊ฐ™์€ ๋ฐ์ดํ„ฐ๋ฅผ ์ฝ๊ณ  ์—…๋ฐ์ดํŠธํ•œ๋‹ค.
  • ๋‚˜์ค‘์— ์ง„ํ–‰๋œ ํŠธ๋žœ์žญ์…˜์ด ๋จผ์ € ์ง„ํ–‰๋œ ํŠธ๋žœ์žญ์…˜์˜ ๊ฒฐ๊ณผ๋ฅผ ๋ฎ์–ด์“ด๋‹ค.
  • ๋จผ์ € ์ง„ํ–‰๋œ ํŠธ๋žœ์žญ์…˜์˜ ์ž‘์—…์€ ์œ ์‹ค๋œ๋‹ค.
  • Optimistic Lock ์ „๋žต์œผ๋กœ ํ•ด๊ฒฐ ๊ฐ€๋Šฅํ•˜๋‹ค

1.2 Dirty Reads

1
-- ์ดˆ๊ธฐ์ƒํƒœ
2
-- Table: account
3
-- | id | balance |
4
-- |----|---------|
5
-- | 1 | 1000 |
6
7
-- ํŠธ๋žœ์žญ์…˜ 1
8
BEGIN TRANSACTION;
9
UPDATE account SET balance = balance - 100 WHERE id = 1; -- 900์œผ๋กœ ์—…๋ฐ์ดํŠธ
10
11
-- ํŠธ๋žœ์žญ์…˜ 2
12
BEGIN TRANSACTION;
13
SELECT balance FROM account WHERE id = 1; -- 900 ๋ฐ˜ํ™˜
14
15
-- ํŠธ๋žœ์žญ์…˜ 1 ๋กค๋ฐฑ
16
ROLLBACK; -- Balance: 1000์œผ๋กœ ๋˜๋Œ๋ฆผ
17
18
-- ํŠธ๋žœ์žญ์…˜ 2 ์ง„ํ–‰
19
-- ํŠธ๋žœ์žญ์…˜ 2์—์„œ ์ฝ์€ balance ๊ฐ’์€ ์ž˜๋ชป๋œ ๊ฐ’์ด๋‹ค.
  • ์•„์ง ์ปค๋ฐ‹๋˜์ง€ ์•Š์€ ๋‹ค๋ฅธ ํŠธ๋žœ์žญ์…˜์˜ ๋ฐ์ดํ„ฐ๋ฅผ ์ฝ์—ˆ์„๋•Œ ์ƒ๊ธฐ๋Š” ๋ฌธ์ œ๋‹ค.
  • ๋ณ€๊ฒฝํ•œ ๋ฐ์ดํ„ฐ๋ฅผ ์ปค๋ฐ‹ํ•˜์ง€ ์•Š๊ณ  ๋กค๋ฐฑํ• ๊ฒฝ์šฐ ๋กค๋ฐฑ ์ „์— ๋ฐ์ดํ„ฐ๋ฅผ ์ฝ์€ ๋‹ค๋ฅธ ํŠธ๋žœ์žญ์…˜์€ ์ž˜๋ชป๋œ ์ •๋ณด๋กœ ๋กœ์ง์„ ์ง„ํ–‰ํ•œ๋‹ค.
  • Read Committed ํŠธ๋žœ์žญ์…˜์œผ๋กœ ํ•ด๊ฒฐ ๊ฐ€๋Šฅํ•˜๋‹ค

1.3 Non-repeatable Reads

1
-- ์ดˆ๊ธฐ์ƒํƒœ
2
-- Table: account
3
-- | id | balance |
4
-- |----|---------|
5
-- | 1 | 1000 |
6
7
-- ํŠธ๋žœ์žญ์…˜ 1
8
BEGIN TRANSACTION;
9
SELECT balance FROM account WHERE id = 1; -- 1000 ๋ฐ˜ํ™˜
10
11
-- ํŠธ๋žœ์žญ์…˜ 2
12
BEGIN TRANSACTION;
13
UPDATE account SET balance = balance - 100 WHERE id = 1; -- 900์œผ๋กœ ์—…๋ฐ์ดํŠธ
14
15
-- ํŠธ๋žœ์žญ์…˜ 1 ์ง„ํ–‰
16
SELECT balance FROM account WHERE id = 1; -- 900 ๋ฐ˜ํ™˜ (non-repeatable read)
17
COMMIT;
  • ํŠธ๋žœ์žญ์…˜์ด ๋ฐ์ดํ„ฐ๋ฅผ ์ฝ์€ ์ƒํƒœ์—์„œ ๋‹ค๋ฅธ ํŠธ๋žœ์žญ์…˜์ด ๋ฐ์ดํ„ฐ๋ฅผ ๋ณ€๊ฒฝํ•  ๊ฒฝ์šฐ,
    • ๊ฐ™์€ ๋ฐ์ดํ„ฐ๋ฅผ ๋‹ค์‹œ ์ฝ์—ˆ์„๋•Œ ๊ธฐ์กด ์ฝ์—ˆ๋˜ ๋ฐ์ดํ„ฐ๊ฐ€ ์žฌ๊ตฌํ˜„๋˜์ง€ ์•Š๋Š” ํ˜„์ƒ์„ ์ด์•ผ๊ธฐํ•œ๋‹ค.
  • Repeatable Read ํŠธ๋žœ์žญ์…˜์œผ๋กœ ํ•ด๊ฒฐ ๊ฐ€๋Šฅํ•˜๋‹ค

1.4 Phantom Reads

1
--- ์ดˆ๊ธฐ์ƒํƒœ
2
--- Table: account
3
--- | id | balance |
4
--- |----|---------|
5
--- | 1 | 1000 |
6
--- | 2 | 1500 |
7
8
--- ํŠธ๋žœ์žญ์…˜ 1
9
BEGIN TRANSACTION;
10
SELECT * FROM account WHERE balance > 1000; -- 1500 ๋ฐ˜ํ™˜
11
12
--- ํŠธ๋žœ์žญ์…˜ 2
13
BEGIN TRANSACTION;
14
INSERT INTO account (id, balance) VALUES (3, 1200);
15
COMMIT;
16
17
-- ํŠธ๋žœ์žญ์…˜ 1
18
SELECT * FROM account WHERE balance > 1000; -- 1500, 1200 ๋ฐ˜ํ™˜ (phantom read)
19
COMMIT;
  • ํŠธ๋žœ์žญ์…˜์ด ์—ฌ๋Ÿฌ Row๋ฅผ ๋ถˆ๋Ÿฌ์˜ค๋Š” ํ•„ํ„ฐ๋ง ์ฟผ๋ฆฌ๋ฅผ ์ง„ํ–‰ ํ›„,
    • ๋‹ค๋ฅธ ํŠธ๋žœ์žญ์…˜์—์„œ ์ฟผ๋ฆฌ์˜ ์กฐ๊ฑด์— ๋งž๋Š” ์ƒˆ๋กœ์šด ๋ฐ์ดํ„ฐ๋ฅผ ์ƒ์„ฑํ–ˆ์„๋•Œ ๊ฐ™์€ ์ฟผ๋ฆฌ๊ฐ€ ๋‹ค๋ฅธ ๊ฒฐ๊ณผ๋ฅผ ๋ฐ˜ํ™˜ํ•˜๋Š”๊ฑธ ์ด์•ผ๊ธฐํ•œ๋‹ค.
  • Serializable ํŠธ๋žœ์žญ์…˜์œผ๋กœ ํ•ด๊ฒฐ ๊ฐ€๋Šฅํ•˜๋‹ค.

2. Transaction Level & Transaction Anomaly

Dirty ReadNon-Repeatable ReadPhantom Read
Read Uncommitted๊ฐ€๋Šฅ๊ฐ€๋Šฅ๊ฐ€๋Šฅ
Read Committed๋ถˆ๊ฐ€๋Šฅ๊ฐ€๋Šฅ๊ฐ€๋Šฅ
Repeatable Read๋ถˆ๊ฐ€๋Šฅ๋ถˆ๊ฐ€๋Šฅ๊ฐ€๋Šฅ
Serializable๋ถˆ๊ฐ€๋Šฅ๋ถˆ๊ฐ€๋Šฅ๋ถˆ๊ฐ€๋Šฅ

2.1 Transaction ๋ฌธ๋ฒ•

1
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
2
BEGIN TRANSACTION;
3
--- SQL ์ž‘์—…
4
COMMIT;

3. Transaction ์‹ค์Šต

posts.controller.ts
1
/*** 3) POST /posts
2
* post๋ฅผ ์ƒ์„ฑํ•œ๋‹ค
3
*
4
* A Model, B Model
5
* Post API -> A๋ชจ๋ธ์„ ์ €์žฅํ•˜๊ณ , B๋ชจ๋ธ์„ ์ €์žฅํ•œ๋‹ค.
6
* await repository.save(a)
7
* await repository.save(b)
8
*
9
* ๋งŒ์•ฝ์— a๋ฅผ ์ €์žฅํ•˜๋‹ค๊ฐ€ ์‹คํŒจํ•˜๋ฉด b๋ฅผ ์ €์žฅํ•˜๋ฉด ์•ˆ๋  ๊ฒฝ์šฐ
10
* ์ด ๊ฒฝ์šฐ๋ฅผ ๋ง‰๊ธฐ ์œ„ํ•ด ๋“ฑ์žฅํ•œ ๊ฒƒ์ด Transaction
11
* all or nothing
12
*
13
* Transaction
14
* start -> ์‹œ์ž‘
15
* commit -> ์ €์žฅ
16
* rollback -> ์›์ƒ๋ณต๊ตฌ
17
*/
18
@Post()
19
@UseGuards(AccessTokenGuard)
20
async postPosts(
21
// ์ƒ๋žต
22
) {
23
await this.postsService.createPostImage(body)
24
return this.postsService.createPost(userId, body)
25
}

4. ImageModel ๋งŒ๋“ค๊ธฐ

์ง€๊ธˆ์€ 1๊ฐœ์˜ ํฌ์ŠคํŠธ์— 1๊ฐœ์˜ ์ด๋ฏธ์ง€์ด์ง€๋งŒ, ์—ฌ๋Ÿฌ ๊ฐœ์˜ ์ด๋ฏธ์ง€๋ฅผ ์˜ฌ๋ฆด ์ˆ˜ ์žˆ๊ฒŒ ๋ณ€๊ฒฝํ•  ๊ฒƒ์ด๋‹ค.

  • ์ด๋–„ ํฌ์ŠคํŠธ ์ƒ์„ฑ, ์ด๋ฏธ์ง€ ์—…๋กœ๋“œ, ์˜ฎ๊ธฐ๊ธฐ๊นŒ์ง€ Transaction์œผ๋กœ ๋ฌถ์–ด๋‘๋ฉด,
  • ์ด๋ฏธ์ง€์™€ ํฌ์ŠคํŠธ ๋‘˜ ๋‹ค ์ž˜ ์ƒ์„ฑ๋˜์•ผ์ง€๋งŒ ๋กœ์ง์ด ์™„์„ฑ๋œ๋‹ค.

posts.entity.ts์—์„œ ์ด๋ฏธ์ง€ ํ•„๋“œ๋ฅผ ๊ทธ๋ƒฅ ์‚ญ์ œํ•œ๋‹ค.

  • ํ•˜๋‚˜์˜ ํฌ์ŠคํŠธ๋Š” ์—ฌ๋Ÿฌ ๊ฐœ์˜ ์ด๋ฏธ์ง€๋ฅผ ๊ฐ€์งˆ ์ˆ˜ ์žˆ์œผ๋‹ˆ,
  • PostsModel์„ ImageModel๊ณผ 1:M ๊ด€๊ณ„๋กœ ๋ฌถ์–ด์ฃผ๋ฉด ๋œ๋‹ค.

common/entities/image.entity.tsํŒŒ์ผ์„ ๋งŒ๋“ ๋‹ค.

common/entities/image.entity.ts
1
import { IsEnum, IsInt, IsOptional, IsString } from 'class-validator'
2
import { Transform } from 'class-transformer'
3
4
import { Column, Entity, ManyToOne } from 'typeorm'
5
import { join } from 'path'
6
import { BaseModel } from './base.entity'
7
import { PostsModel } from '../../posts/entities/posts.entity'
8
import { POST_PUBLIC_IMAGE_PATH } from '../const/path.const'
9
10
export enum ImageModelType {
11
POST_IMAGE,
12
}
13
14
@Entity()
15
export class ImageModel extends BaseModel {
16
@Column({
17
default: 0,
18
})
19
@IsInt()
20
@IsOptional()
21
order: number
22
23
/***
24
* UserModel -> ์‚ฌ์šฉ์ž ํ”„๋กœํ•„ ์ด๋ฏธ์ง€
25
* PostsModel -> ํฌ์ŠคํŠธ ์ด๋ฏธ์ง€
26
*/
27
@Column({
28
enum: ImageModelType,
29
})
30
@IsEnum(ImageModelType)
31
@IsString()
32
type: ImageModelType
33
34
@Column()
35
@IsString()
36
@Transform(({ value, obj }) => {
37
// obj๋Š” ์ด๋ฏธ์ง€ ๋ชจ๋ธ์ด ์ƒ์„ฑ๋์„ ๋–„์˜, ํ˜„์žฌ ๊ฐ์ฒด๋ฅผ ์˜๋ฏธ
38
if (obj.type === ImageModelType.POST_IMAGE) {
39
return `/${join(POST_PUBLIC_IMAGE_PATH, value)}`
40
} else {
41
return value
42
}
43
})
44
path: string
45
46
@ManyToOne(type => PostsModel, post => post.images)
47
post?: PostsModel
48
}

posts ์—”ํ‹ฐํ‹ฐ์—์„œ images ํ”„๋กœํผํ‹ฐ๋ฅผ ์ถ”๊ฐ€ํ•œ๋‹ค.

posts.entity.ts
1
// posts.entity.ts ์ƒ๋žต
2
3
@Entity()
4
export class PostsModel extends BaseModel {
5
// ์ƒ๋žต
6
7
@OneToMany(() => ImageModel, image => image.post)
8
images: ImageModel[]
9
}

app ๋ชจ๋“ˆ์—์„œ ImageModel์„ ๋“ฑ๋กํ•œ๋‹ค.

app.module.ts
1
// app.module.ts ์ƒ๋žต
2
@Module({
3
imports: [
4
// ์ƒ๋žต
5
TypeOrmModule.forRoot({
6
// ์ƒ๋žต
7
// entitiesํด๋”์— ์ž‘์„ฑํ•œ PostsModel ๊ฐ€์ ธ์˜ค๊ธฐ
8
entities: [PostsModel, UsersModel, ImageModel],
9
synchronize: true,
10
}),
11
UsersModule,
12
AuthModule,
13
CommonModule,
14
],
15
// ์ƒ๋žต
16
})
  • ๊ฐ„ํ˜น ์ด๋ ‡๊ฒŒ ํ–ˆ์Œ์—๋„ ์•ˆ๋˜๋Š” ๊ฒฝ์šฐ๊ฐ€ ์žˆ๋Š”๋ฐ,
  • ๊ทธ๋–„๋Š” ์„œ๋ฒ„๋ฅผ ๊บผ์ฃผ๊ณ , dist(build๋˜๋Š” ์‹ค์ œ ๋ฐฐํฌํด๋”)๋ฅผ ์‚ญ์ œํ–ˆ๋‹ค๊ฐ€ ๋‹ค์‹œ ์‹œ์ž‘ํ•˜๋ฉด ๋œ๋‹ค.

5. ImageModel ์ƒ์„ฑ ๋กœ์ง

posts/const/default-post-find-options.const.ts ํŒŒ์ผ์„ ๋งŒ๋“ ๋‹ค.

posts/const/default-post-find-options.const.ts
1
import { FindManyOptions } from 'typeorm'
2
import { PostsModel } from '../entities/posts.entity'
3
4
export const DEFAULT_POST_FIND_OPTIONS: FindManyOptions<PostsModel> = {
5
relations: {
6
author: true,
7
images: true,
8
},
9
}

image/dto/create-image.dto.ts ํŒŒ์ผ์„ ๋งŒ๋“ ๋‹ค.

image/dto/create-image.dto.ts
1
import { PickType } from '@nestjs/mapped-types'
2
import { ImageModel } from 'src/common/entities/image.entity'
3
4
export class CreatePostImageDto extends PickType(ImageModel, [
5
'path', //
6
'post',
7
'order',
8
'type',
9
]) {}

posts/dto/create-post.dto.ts ํŒŒ์ผ์„ ์ˆ˜์ •ํ•œ๋‹ค.

1
import { IsOptional, IsString } from 'class-validator'
2
3
import { PickType } from '@nestjs/mapped-types'
4
import { PostsModel } from '../entities/posts.entity'
5
6
export class CreatePostDto extends PickType(PostsModel, ['title', 'content']) {
7
@IsString({
8
each: true, // ๋ฆฌ์ŠคํŠธ ๊ฐœ๋ณ„ ์š”์†Œ๋งˆ๋‹ค string์œผ๋กœ ํ• ์ง€
9
})
10
@IsOptional()
11
images: string[] = []
12
}

posts ์ปจํŠธ๋กค๋Ÿฌ์— postPosts()๋ฅผ ๋‹ค์Œ๊ณผ ๊ฐ™์ด ์ˆ˜์ •ํ•œ๋‹ค.

posts.controller.ts
1
// posts.controller.ts ์ƒ๋žต
2
@Post()
3
@UseGuards(AccessTokenGuard)
4
async postPosts(
5
@User('id') userId: number,
6
@Body() body: CreatePostDto,
7
// @Body('title') title: string,
8
// @Body('content') content: string,
9
// ๊ธฐ๋ณธ๊ฐ’์„ true๋กœ ์„ค์ •ํ•˜๋Š” ํŒŒ์ดํ”„
10
// @Body('isPublic', new DefaultValuePipe(true)) isPublic: boolean,
11
) {
12
const post = await this.postsService.createPost(userId, body)
13
for (let i = 0; i < body.images.length; i++) {
14
await this.postsService.createPostImage({
15
post,
16
order: i,
17
path: body.images[i],
18
type: ImageModelType.POST_IMAGE,
19
})
20
}
21
return this.postsService.getPostById(post.id)
22
}

posts ์„œ๋น„์Šค๋ฅผ ๋‹ค์Œ๊ณผ ๊ฐ™์ด ์ˆ˜์ •ํ•œ๋‹ค.

posts.service.ts
1
// ์ƒ๋žต
2
@Injectable()
3
export class PostsService {
4
constructor(
5
@InjectRepository(PostsModel)
6
private readonly postsRepository: Repository<PostsModel>,
7
@InjectRepository(ImageModel)
8
private readonly imageRepository: Repository<ImageModel>,
9
private readonly commonService: CommonService,
10
private readonly configService: ConfigService,
11
) {}
12
13
async getAllPosts() {
14
return this.postsRepository.find({ ...DEFAULT_POST_FIND_OPTIONS })
15
}
16
17
async generatePosts(userId: number) {
18
for (let i = 0; i < 100; i++) {
19
await this.createPost(userId, {
20
title: `์ž„์˜๋กœ ์ƒ์„ฑ๋œ ํฌ์ŠคํŠธ ์ œ๋ชฉ ${i}`,
21
content: `์ž„์˜๋กœ ์ƒ์„ฑ๋œ ํฌ์ŠคํŠธ ๋‚ด์šฉ ${i}`,
22
images: [],
23
})
24
}
25
}
26
27
async paginatePosts(dto: PaginatePostDto) {
28
return this.commonService.paginate(
29
dto, //
30
this.postsRepository,
31
{ ...DEFAULT_POST_FIND_OPTIONS },
32
'posts',
33
)
34
}
35
36
async getPostById(id: number) {
37
const post = await this.postsRepository.findOne({
38
...DEFAULT_POST_FIND_OPTIONS,
39
// PostsModel์˜ id๊ฐ€ ์ž…๋ ฅ๋ฐ›์€ id์™€ ๊ฐ™์€์ง€ ํ•„ํ„ฐ๋ง
40
where: {
41
id,
42
},
43
})
44
// ์ƒ๋žต
45
}
46
47
async createPost(authorId: number, postDto: CreatePostDto) {
48
const post = this.postsRepository.create({
49
author: {
50
id: authorId,
51
},
52
...postDto,
53
images: [],
54
likeCount: 0,
55
commentCount: 0,
56
})
57
const newPost = await this.postsRepository.save(post)
58
return newPost
59
}
60
61
async createPostImage(dto: CreatePostImageDto) {
62
// ์ƒ๋žต
63
64
// save
65
const result = await this.imageRepository.save({
66
...dto,
67
})
68
69
// ํŒŒ์ผ ์˜ฎ๊ธฐ๊ธฐ
70
await promises.rename(tempFilePath, publicFilePath)
71
72
return result
73
}
74
// ์ƒ๋žต
75
}

posts ๋ชจ๋“ˆ์— ์ถ”๊ฐ€ํ•œ ImageModel์„ ์ ์šฉ์‹œํ‚ฌ ์ˆ˜ ์žˆ๋„๋ก importํ•œ๋‹ค.

posts.module.ts
1
// posts.module.ts ์ƒ๋žต
2
@Module({
3
imports: [
4
/*** ๋ชจ๋ธ์— ํ•ด๋‹นํ•˜๋Š” repostory๋ฅผ ์ฃผ์ž… ==> forFeature
5
* repository : ํ•ด๋‹น ๋ชจ๋ธ์„ ๋‹ค๋ฃฐ ์ˆ˜ ์žˆ๊ฒŒ ํ•ด์ฃผ๋Š” ํด๋ž˜์Šค
6
*/
7
TypeOrmModule.forFeature([
8
PostsModel, //
9
ImageModel,
10
]),
11
AuthModule,
12
UsersModule,
13
CommonModule,
14
],
15
// ์ƒ๋žต
16
})
17
export class PostsModule {}

6. Transaction ์‹œ์ž‘

์ด๋ฏธ์ง€ ์—…๋กœ๋“œ๊ฐ€ ์ œ๋Œ€๋กœ ์•ˆ๋ฌ๊ฑฐ๋‚˜, ์—๋Ÿฌ๊ฐ€ ๋ฐœ์ƒํ•˜๋ฉด, ํฌ์ŠคํŠธ๊ฐ€ ์ƒ์„ฑ๋˜๋ฉด ์•ˆ๋œ๋‹ค.

  • ์ด๋ฏธ์ง€ ์—…๋กœ๋“œ ์—๋Ÿฌ ๋“ฑ์˜ ์—๋Ÿฌ ์ƒํ™ฉ์ด ๋ฐœ์ƒํ•˜๋ฉด ํฌ์ŠคํŠธ ์ƒ์„ฑ๋„ ์•ˆ๋˜๊ฒŒ ๋งŒ๋“ค์–ด์•ผ ํ•œ๋‹ค.
  • ์ด ๋กœ์ง์„ Transaction์œผ๋กœ ๋ฌถ์–ด์ค˜์„œ 1๊ฐœ ์—๋Ÿฌ๊ฐ€ ๋ฐœ์ƒํ•˜๋ฉด, ๋ชจ๋‘ ์•ˆ๋˜๊ฒŒ ํ•ด์•ผ ํ•œ๋‹ค.

posts ์ปจํŠธ๋กค๋Ÿฌ์˜ postPosts()๋ฅผ ์ˆ˜์ •ํ•œ๋‹ค.

posts.controller.ts
1
// posts.controller.ts ์ƒ๋žต
2
@Post()
3
@UseGuards(AccessTokenGuard)
4
async postPosts(
5
@User('id') userId: number,
6
@Body() body: CreatePostDto,
7
// @Body('title') title: string,
8
// @Body('content') content: string,
9
// ๊ธฐ๋ณธ๊ฐ’์„ true๋กœ ์„ค์ •ํ•˜๋Š” ํŒŒ์ดํ”„
10
// @Body('isPublic', new DefaultValuePipe(true)) isPublic: boolean,
11
) {
12
// (1) ํŠธ๋žœ์žญ์…˜๊ณผ ๊ด€๋ จ๋œ ๋ชจ๋“  ์ฟผ๋ฆฌ๋ฅผ ๋‹ด๋‹นํ•  ์ฟผ๋ฆฌ ๋Ÿฌ๋„ˆ(qr)๋ฅผ ์ƒ์„ฑํ•œ๋‹ค.
13
const qr = this.dataSource.createQueryRunner()
14
15
// (2) ์ฟผ๋ฆฌ ๋Ÿฌ๋„ˆ์— ์—ฐ๊ฒฐํ•œ๋‹ค.
16
await qr.connect()
17
/*** ์ฟผ๋ฆฌ ๋Ÿฌ๋„ˆ์—์„œ ํŠธ๋žœ์žญ์…˜์„ ์‹œ์ž‘ํ•œ๋‹ค.
18
* ์ด ์‹œ์ ๋ถ€ํ„ฐ ๊ฐ™์€ ์ฟผ๋ฆฌ ๋Ÿฌ๋„ˆ๋ฅผ ์‚ฌ์šฉํ•˜๋ฉด
19
* ํŠธ๋žœ์žญ์…˜ ์•ˆ์—์„œ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์•ก์…˜์„ ์‹คํ–‰ํ•œ๋‹ค.
20
*/
21
await qr.startTransaction()
22
23
// ๋กœ์ง์‹คํ–‰
24
try {
25
const post = await this.postsService.createPost(userId, body)
26
for (let i = 0; i < body.images.length; i++) {
27
await this.postsService.createPostImage({
28
post,
29
order: i,
30
path: body.images[i],
31
type: ImageModelType.POST_IMAGE,
32
})
33
}
34
await qr.commitTransaction()
35
await qr.release()
36
return this.postsService.getPostById(post.id)
37
} catch (e) {
38
// ์–ด๋–ค ์—๋Ÿฌ๋“  ์—๋Ÿฌ๊ฐ€ ๋˜์ ธ์ง€๋ฉด, ํŠธ๋žœ์žญ์…˜์„ ์ข…๋ฃŒํ•˜๊ณ  ์›๋ž˜ ์ƒํƒœ๋กœ ๋˜๋Œ๋ฆฐ๋‹ค.
39
await qr.rollbackTransaction()
40
await qr.release()
41
}
42
}

posts ์„œ๋น„์Šค๋ฅผ ์ˆ˜์ •ํ•œ๋‹ค.

posts.service.ts
1
// posts.service.ts ์ƒ๋žต
2
getRepository(qr?: QueryRunner) {
3
return qr
4
? qr.manager.getRepository<PostsModel>(PostsModel) //
5
: this.postsRepository
6
}
7
8
async createPost(authorId: number, postDto: CreatePostDto, qr?: QueryRunner) {
9
const repository = this.getRepository(qr)
10
11
const post = repository.create({
12
author: {
13
id: authorId,
14
},
15
...postDto,
16
images: [],
17
likeCount: 0,
18
commentCount: 0,
19
})
20
const newPost = await repository.save(post)
21
return newPost
22
}

7. Transaction ์ ์šฉ ๋ฐ ํ…Œ์ŠคํŠธ

posts/image/images.service.ts ํŒŒ์ผ์„ ๋งŒ๋“ ๋‹ค.

  • posts ์„œ๋น„์Šค์˜ createPostImage()๋ฅผ ์ž˜๋ผ๋‚ด์„œ images ์„œ๋น„์Šค์— ๋ณต๋ถ™ํ•œ๋‹ค.
posts/image/images.service.ts
1
import { BadRequestException, Injectable } from '@nestjs/common'
2
import { InjectRepository } from '@nestjs/typeorm'
3
import { QueryRunner, Repository } from 'typeorm'
4
import { basename, join } from 'path'
5
6
import { promises } from 'fs'
7
import { CreatePostImageDto } from './dto/create-image.dto'
8
import { ImageModel } from 'src/common/entities/image.entity'
9
import { POST_IMAGE_PATH, TEMP_FOLDER_PATH } from 'src/common/const/path.const'
10
11
@Injectable()
12
export class PostsImagesService {
13
constructor(
14
@InjectRepository(ImageModel)
15
private readonly imageRepository: Repository<ImageModel>,
16
) {}
17
18
getRepository(qr?: QueryRunner) {
19
return qr
20
? qr.manager.getRepository<ImageModel>(ImageModel) //
21
: this.imageRepository
22
}
23
24
async createPostImage(dto: CreatePostImageDto, qr?: QueryRunner) {
25
const repository = qr.manager.getRepository<ImageModel>(ImageModel)
26
27
// dto์˜ ์ด๋ฏธ์ง€ ์ด๋ฆ„์„ ๊ธฐ๋ฐ˜์œผ๋กœ ํŒŒ์ผ ๊ฒฝ๋กœ๋ฅผ ์ƒ์„ฑํ•œ๋‹ค
28
const tempFilePath = join(TEMP_FOLDER_PATH, dto.path)
29
30
try {
31
/*** promises์˜ fs ๋ชจ๋“ˆ์„ import
32
* ํŒŒ์ผ์ด ์กด์žฌํ•˜๋Š”์ง€ ํ™•์ธ
33
* ๋งŒ์•ฝ์— ์กด์žฌํ•˜์ง€ ์•Š๋Š”๋‹ค๋ฉด ์—๋Ÿฌ๋ฅผ ๋˜์ง
34
*/
35
await promises.access(tempFilePath)
36
} catch (e) {
37
throw new BadRequestException('์กด์žฌํ•˜์ง€ ์•Š๋Š” ์ž„์‹œ ํŒŒ์ผ์ž…๋‹ˆ๋‹ค!')
38
}
39
40
/*** ํŒŒ์ผ์˜ ์ด๋ฆ„๋งŒ ๊ฐ€์ ธ์˜ค๊ธฐ
41
* /USers/aaa/bbb/ccc/asdf.jpg -> asdf.jpg
42
*/
43
const fileName = basename(tempFilePath)
44
45
/*** ์ƒˆ๋กœ ์ด๋™ํ•  ํฌ์ŠคํŠธ ํด๋”์˜ ๊ฒฝ๋กœ + ์ด๋ฏธ์ง€์˜ ์ด๋ฆ„
46
* {ํ”„๋กœ์ ํŠธ๊ฒฝ๋กœ}/public/posts/asdf.jpg
47
*/
48
const publicFilePath = join(POST_IMAGE_PATH, fileName)
49
50
// save
51
const result = await repository.save({
52
...dto,
53
})
54
55
// ํŒŒ์ผ ์˜ฎ๊ธฐ๊ธฐ
56
await promises.rename(tempFilePath, publicFilePath)
57
58
return result
59
}
60
}

์ด์ œ๋Š” posts ์ปจํŠธ๋กค๋Ÿฌ๋Š” images ์„œ๋น„์Šค์—์„œ ๊ธฐ๋Šฅ์„ ๊ฐ€์ ธ์˜จ๋‹ค.

posts.controller.ts
1
// ์ƒ๋žต
2
3
@Controller('posts')
4
export class PostsController {
5
constructor(
6
private readonly postsService: PostsService,
7
private readonly postsImagesService: PostsImagesService, // ์ถ”๊ฐ€
8
private readonly dataSource: DataSource,
9
) {}
10
// ์ƒ๋žต
11
@Post()
12
@UseGuards(AccessTokenGuard)
13
async postPosts(
14
// ์ƒ๋žต
15
try {
16
const post = await this.postsService.createPost(userId, body, qr)
17
// throw new InternalServerErrorException('์ผ๋ถ€๋Ÿฌ ์—๋Ÿฌ ๋ฐœ์ƒ ํ…Œ์ŠคํŠธ')
18
for (let i = 0; i < body.images.length; i++) {
19
await this.postsImagesService.createPostImage(
20
{
21
post,
22
order: i,
23
path: body.images[i],
24
type: ImageModelType.POST_IMAGE,
25
},
26
qr,
27
)
28
}
29
await qr.commitTransaction()
30
await qr.release()
31
return this.postsService.getPostById(post.id)
32
} catch (e) {
33
// ์–ด๋–ค ์—๋Ÿฌ๋“  ์—๋Ÿฌ๊ฐ€ ๋˜์ ธ์ง€๋ฉด, ํŠธ๋žœ์žญ์…˜์„ ์ข…๋ฃŒํ•˜๊ณ  ์›๋ž˜ ์ƒํƒœ๋กœ ๋˜๋Œ๋ฆฐ๋‹ค.
34
await qr.rollbackTransaction()
35
await qr.release()
36
throw new InternalServerErrorException('์—๋Ÿฌ๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.')
37
}
38
}
39
}

posts ๋ชจ๋“ˆ์— ์ปจํŠธ๋กค๋Ÿฌ, ์„œ๋น„์Šค์—์„œ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ๋„๋ก PostsImagesService์„ ์ถ”๊ฐ€ํ•œ๋‹ค.

posts.module.ts
1
// ์ƒ๋žต
2
@Module({
3
// ์ƒ๋žต
4
providers: [PostsService, PostsImagesService],
5
})
6
export class PostsModule {}