데이터를 엔티티에 저장할 때 예상되는 문제

동시성 이슈 (Concurrency Issues)

두명 이상의 사용자가 동시에 같은 데이터의 삽입 요청을 했을 때 모든 요청에 담긴 데이터가 중복이 없다고 판단되어 동시에 데이터가 삽입되는 현상.

 
이런 상황을 '레이스 컨디션'이라고 합니다. 레이스 컨디션은 두 개 이상의 프로세스나 스레드가 동시에 같은 데이터에 접근하려고 할 때 발생할 수 있습니다.
 

해결방법

Lock

락은 데이터를 보호하고, 다른 트랜잭션이 동시에 같은 데이터를 수정/삽입 하지 못하게 합니다.

하지만 락을 사용하면 다른 트랜잭션이 락이 해제될 때까지 기다려야 하므로 성능 문제가 발생 할 수 있습니다.

  •  [프로그래밍/SQL] - [ SQL ] Lock  
    • 소극적 락(Pessimistic Locking) : 데이터를 처음 읽을 때 락을 걸고 트랜잭션이 끝날 때까지 락을 유지하는 방법입니다.
    • 적극적 락(Optimistic Locking) : 데이터에 버전 번호나 타임스탬프를 추가하여 데이터를 불러온 후 수정된 경우에만 업데이트가 발생하도록 하는 방법입니다.

MVCC (Multi-version Concurrency Control)

MVCC는 여러 트랜잭션이 동시에 같은 데이터에 접근할 수 있게 합니다. 이는 락을 사용하는 것보다 성능이 좋지만, 데이터 중복 문제를 완전히 해결하지는 못합니다.

 
예를 들어, 한 트랜잭션이 데이터를 확인한 후 다른 트랜잭션이 같은 데이터를 추가하고, 첫 번째 트랜잭션이 데이터를 추가하려 할 때 중복이 발생할 수 있습니다.

MVCC는 동시에 여러 트랜잭션이 데이터베이스에 접근할 때 일관성을 유지하면서 동시성을 높이는 기술입니다. 각 트랜잭션은 데이터의 특정 버전을 보게 되며, 실제 데이터를 직접 변경하는 대신 변경 시점의 스냅샷을 참조합니다. 이렇게 하면 여러 트랜잭션이 동시에 같은 데이터를 읽을 수 있습니다.

 

Unique 데코레이션

@Unique() 데코레이션은 데이터베이스 테이블의 특정 컬럼이나 컬럼 조합에 유니크 제약조건을 적용하여, 그 필드에 중복된 값이 저장되지 않도록 합니다.

주의점

  • 데이터베이스가 유니크 제약조건을 위반할 경우, 일반적으로 예외가 발생합니다. 이 예외를 적절히 처리해야 데이터베이스 에러로 인한 프로그램 중단을 방지할 수 있습니다.
  • 모든 데이터베이스와 ORM이 @Unique 주석을 지원하지는 않을 수 있으므로, 사용하기 전에 해당 ORM 또는 프레임워크 문서를 확인해야 합니다.

락의 종류

데이터베이스가 데이터 자원을 어떻게 락을 거는지에 대한 구체적인 메커니즘

 

  • 공유 락(Shared Locks): 여러 트랜잭션이 동시에 데이터를 읽을 수 있게 해주는 락입니다. 한 트랜잭션이 데이터에 공유 락을 걸고 있을 때, 다른 트랜잭션도 그 데이터를 읽을 수 있지만, 쓸 수는 없습니다.

 

  • 배타 락(Exclusive Locks): 한 트랜잭션이 데이터에 배타 락을 걸면, 그 트랜잭션만이 데이터를 읽고 쓸 수 있으며, 다른 어떤 트랜잭션도 해당 데이터에 접근할 수 없습니다.

락 사용전략 

데이터베이스의 자원에 접근할 때 발생할 수 있는 충돌을 관리하는 방식에 대한 전략

 

  • 소극적 락(Pessimistic Locking): 이 전략에서는 충돌이 발생할 것을 예상하고, 데이터를 사용하기 전에 락을 걸어 다른 트랜잭션의 접근을 차단합니다. 즉, 데이터를 처음 읽을 때 락을 걸고 트랜잭션이 끝날 때까지 락을 유지하는 방법입니다. 소극적 락은 보통 배타 락을 사용하여 데이터의 수정이 예상되는 경우 사전에 다른 트랜잭션의 접근을 차단합니다.

 

  • 적극적 락(Optimistic Locking): 충돌이 드물게 발생할 것으로 예상할 때 사용하는 전략입니다. 데이터를 실제로 수정할 때까지 락을 걸지 않고, 대신 데이터의 버전을 체크하여 업데이트 시점에 충돌을 감지합니다. 즉, 데이터에 버전 번호나 타임스탬프를 추가하여 데이터를 불러온 후 수정된 경우에만 업데이트가 발생하도록 하는 방법입니다. 적극적 락은 락을 사용하지 않는 상태에서 데이터의 일관성을 확인하는 방법으로, 공유 락과 배타 락의 전통적인 사용법과는 조금 다릅니다.
 

 

예시

@Entity()
export class Exercise extends Timestamps {
  @PrimaryGeneratedColumn()
  id: number;

  @Column({ type: 'enum', enum: BodyPart })
  bodyPart: BodyPart;

  @Column()
  exerciseName: string;

  @OneToMany(() => WorkoutLog, (workoutLog) => workoutLog.exercise)
  workoutLogs: WorkoutLog[];

  @OneToMany(() => RoutineToExercise, (routineToExercise) => routineToExercise.exercise)
  routineToExercises: RoutineToExercise[];

  constructor();
  constructor(params: { bodyPart: BodyPart; exerciseName: string });
  constructor(params?: { bodyPart: BodyPart; exerciseName: string }) {
    super();
    if (params) {
      this.exerciseName = params.exerciseName;
      this.bodyPart = params.bodyPart;
    }
  }
}

  constructor :  클래스에서 객체의 설계도 또는 청사진으로, 특정 타입의 객체를 생성하고 초기화하는데 사용됩니다.

  • constructor() 초기화할 속성이 없는 경우에 사용
  • constructor(params? : {})  매개변수가 선택적이라는 의미 (optional)
  • constructor(params : {}) 매개변수가 선택적이라는 의미 (optional)

위 코드에서 매개변수의 초기화가 꼭 필요하기 때문에 

constructor() 와 constructor(params? : {}) 는 필요없는 코드

 

  .....
  // 이 와 같이 수정
  constructor(params: { bodyPart: BodyPart; exerciseName: string }){
    super();
    if (params) {
      this.exerciseName = params.exerciseName;
      this.bodyPart = params.bodyPart;
    }
  }

constructor(params:{}) 에서 params를 선언했는데  왜 if(params) 가 필요할까??

( TypeORM 공식문서에서 보면)

When using an entity constructor its arguments must be optional. Since ORM creates instances of entity classes when loading from the database, therefore it is not aware of your constructor arguments.

 

ORM 은 앱을 실행시킬때 엔티티의 인스턴스를 생성한다. 그러면 초기화 값을 받지 못한 params 는 undefined 가 되어 버리기 때문에 앱 실행 중에 오류가 발생한다.

 

 

 

참고자료

TypeORM 공식문서

https://seungtaek-overflow.tistory.com/15

routine.entity.ts

import { Column, Entity, ManyToOne, OneToMany, PrimaryGeneratedColumn } from 'typeorm';
import { Timestamps } from '../../TimeStamp.entity';
import { User } from '../../user/domain/User.entity';
import { RoutineToExercise } from '../../routineToExercise/domain/RoutineToExercise.entity';

@Entity()
export class Routine extends Timestamps {
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  name: string;

  @ManyToOne(() => User, (user) => user.routines)
  public user: User;

  @OneToMany(() => RoutineToExercise, (routineToExercise) => routineToExercise.routine)
  public routineToExercises: RoutineToExercise[];

}

 

exercise.entity.ts

import { Column, Entity, OneToMany, PrimaryGeneratedColumn } from 'typeorm';
import { Timestamps } from '../../TimeStamp.entity';
import { BodyPart } from './bodyPart.enum';
import { WorkoutLogToExercise } from '../../workoutLogToExercise/domain/WorkoutLogToExercise.entity';
import { RoutineToExercise } from '../../routineToExercise/domain/RoutineToExercise.entity';

@Entity()
export class Exercise extends Timestamps {
  @PrimaryGeneratedColumn()
  id: number;

  @Column({ type: 'enum', enum: BodyPart })
  bodyPart: BodyPart;

  @Column()
  exerciseName: string;

  @OneToMany(() => WorkoutLogToExercise, (workoutLogToExercise) => workoutLogToExercise.exercise)
  workoutLogToExercises: WorkoutLogToExercise[];

  @OneToMany(() => RoutineToExercise, (routineToExercise) => routineToExercise.exercise)
  routineToExercises: RoutineToExercise[];

}

 

routineToExercises.entity.ts

import { Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm';
import { Exercise } from '../../excercise/domain/Exercise.entity';
import { Routine } from '../../routine/domain/Routine.entity';

@Entity()
export class RoutineToExercise {
  @PrimaryGeneratedColumn()
  id: number;

  @ManyToOne(() => Routine, (routine) => routine.routineToExercises)
  public routine: Routine;

  @ManyToOne(() => Exercise, (exercise) => exercise.routineToExercises)
  public exercise: Exercise;
}

 

 

routine 에서  유저 A 가 게시한 routine 목록을 검색하고 이와 연관된 excersie 값도 같이 검색한다. 이때 모든 값을 다 보여주지 않고 선택된 값만 반환 받는다.

const routines = await this.routineRepository
      .createQueryBuilder('routine')
      .leftJoinAndSelect('routine.routineToExercises', 'routineToExercises')
      .leftJoinAndSelect('routineToExercises.exercise', 'exercise')
      .innerJoin('routine.user', 'user')
      .select([
        'routine.id',
        'routine.name',
        'routineToExercises.id',
        'exercise.id',
        'exercise.exerciseName',
        'exercise.bodyPart',
      ])
      .where('routine.name = :name AND user.id = :userId', { name: 'test', userId: user.id })
      .getMany();

용어 설명

 

  • createQueryBuilder() : SQL 쿼리를 보다 세밀하게 제어할 수 있게 해주는 메소드입니다
    •  createQueryBuilder('routine')
      • routineRepository 를 이용하여 생성된 쿼리에서 Routine 엔티티를 참조할 때 사용할 별칭을 routine 으로 합니다.
  • .leftJoinAndSelect() : 지정된 엔티티와 연결된 다른 엔티티를 "왼쪽 조인"으로 불러오며, 결과에 포함시키기를 원하는 필드를 선택적으로 로드합니다.
    • .leftJoinAndSelect('routine.routineToExercises', 'routineToExercises')
      • Routine 과 RoutineToExercises 사이에 설정된 관계를 기반으로 왼쪽 조인을 수행하고, routineToExercises라는 별칭을 사용하여 조인된 결과를 선택합니다.
      • 이 조인은 Routine 과 RoutineToExercises 사이의 관계를 탐색하고 관련 데이터를 반환합니다.
      • 일치하는 항목이 없어도 Routine 데이터는 반환합니다.
  • .innerJoin() : 조건을 만족하는 데이터만 결과에 포함시키는 "내부 조인"을 수행합니다. 연결된 테이블의 매칭되는 행이 없다면 결과에서 해당 데이터를 제외합니다.
    • .innerJoin('routine.user', 'user')
      • Routine 테이블의 user 필드와 User 테이블에서 일치하는 항목만 결과에 포함시킵니다.
  • .where() :쿼리에서 데이터를 필터링할 조건을 설정하는 부분입니다.
    • .where('routine.name = :name AND user.id = :userId', { name: 'test', userId: 3 })
      • 조건을 설정 : routine.name = :name AND user.id = :userId
        • routine.name = :name 은 Routine 테이블의 name 필드가 쿼리 파라미터 :name으로 지정된 값과 일치해야 한다는 것을 의미합니다.
        • :name, :userId와 같은 표현은 쿼리에 사용될 변수의 이름으로 자유롭게 지정할 수 있습니다.
      • 파라미터 바인딩: { name: 'test', userId: user.id }
        • name 필드가 'test' 이고 userId 필드가 user.id 에 일치하는 데이터만 반환합니다.

 

NestJS 에서 TypeORM 을 사용

 

게시물의 titlecontent 에 'keyword' 가 들어 있는 게시물 검색

    - Exception handeler 적용안하고 구현

 

board.controller.ts

// board.controller.ts
@Get('/keyword')
  @HttpCode(200)
  public async findKeyword(
    @Query('word') word: string,
  ): Promise<SearchKeywordBoardResponseDto[]> {
    return await this.boardService.findKeyword(word);
  }

 

!!! 어떻게 path 에 쿼리를 주고 받아 올것인가??? 이 부분에서 아주 많이 시간이 들었다.

1. query string 을 사용하는 방법을 잊어먹어서 이 부분에서 아주 많이 시간이 들었다.

    https://joey0203.tistory.com/192 <- 한번더 확인하자.

@Get('/keyword')

위와 같이 선언을 하여  주소가 http://localhost:3000/boards/keyword 이렇게 확장됨

 

@Queary('word') word: string,

 여기서 쿼리 값을 받아오기 위해 설정한 키가 'word' 가 되겠다.

 http://localhost:3000/boards/keyword?word=posting 를 해주면 posting 이라는 단어를 이용하여 검색하게된다.

 

 

board.service.ts

// board.service.ts
async findKeyword(keyword: string) {
    const searchResult = await this.boardRepository
      .createQueryBuilder('board')
      .where('board.title LIKE :key', { key: `%${keyword}%` })
      .orWhere('board.content like :key', { key: `%${keyword}%` })
      .execute();
    const result = [];
    searchResult.forEach((board) => {
      result.push(
        new SearchKeywordBoardResponseDto(
          board.board_id,
          board.board_title,
          board.board_content,
        ),
      );
    });
    return result;
  }

 

this.boadRepository  
.createQueryBuilder('board')

     - this.boadRepository : DB 에 있는 하나의 테이블을 호출 

     - createQueryBuilder('board') :  TypeORM 에서  제공하는 편리한 함수로써 SQL 쿼리문을 생성할 수 있도록 도와 준다. 이때 모                                                   든 열의 이름이 'board_id' 와 같은 형태로 자동으로 변환된다.

where( 'board.title LIKE :key', {key: `%${keyword}%`})

     - Like 함수를 이용 → LIKE, like LIke 다 된다. 대소문자 구별 필요없음

     - :key  내가 찾고싶은 값을 넣는 곳 

               → : key 사이에 빈공간 있으면 요청을 할때 에러발생

               → 'board.title LIKE :key' 을  `board.title LIKE  %${keyword}%` 로 써도 될거 같지만 안된다

orWhere('.......')

     - or 함수를 이용한 방식

getMany()

     - 여러개의 결과를 원할 때 사용

     - 하나의 결과를 원할 때는 getOne() 

 

SearchKeywordBoardResponseDto

     -  결과에 있는 id, title, content, created_at, updated_at, deleted_at 모든 값을 반환하지 않고 필요한 값만 보내기 위해 적용

참고자료

https://typeorm.io/select-query-builder#adding-where-expression

+ Recent posts