티스토리 뷰

최근 회사에서 의도치 않게 Node + Express + TypeScript 환경에서 개발을 진행하고 있습니다...ㅠㅠ 매번 하던 것들이 아니라 여러가지 어려움도 많고 이해가 되지 않는 부분이 많지만... 저의 부족함을 매일 느끼며 그와중에 조금이나마 알게된 내용들을 정리해볼까 합니다.

 

TypeORM이란?

TypeORM은 TypeScript와 JavaScript에서 사용할 수 있는 데이터베이스 ORM(Object-Relational Mapping) 라이브러리입니다.

 

ORM은 객체 지향 프로그래밍 언어와 관계형 데이터베이스 간의 데이터를 변환하고 연결하는 기술을 의미합니다. TypeORM은 이러한 변환 및 연결 작업을 간편하게 수행하도록 도와주는 도구 중 하나입니다.

 

TypeORM은 다양한 데이터베이스 시스템을 지원하며, MySQL, PostgreSQL, SQLite, MSSQL 등과 같은 다양한 관계형 데이터베이스와 함께 사용할 수 있습니다. 이는 TypeORM이 강력하게 사용될 수 있도록 다양한 프로젝트에서 적용될 수 있는 유연성을 제공합니다.

 

TypeORM의 특징과 기능:

 

  1. 객체 지향적인 데이터베이스 조작: TypeORM은 데이터베이스 테이블을 JavaScript 또는 TypeScript 클래스로 매핑하여 객체 지향적인 코드를 사용할 수 있도록 합니다.
  2. 쿼리 언어의 대안: SQL 쿼리 대신 JavaScript나 TypeScript로 작성된 메소드를 사용하여 데이터베이스를 조작할 수 있습니다. 이는 개발자에게 더 친숙하고 읽기 쉬운 코드를 작성할 수 있도록 도와줍니다.
  3. 자동 마이그레이션: TypeORM은 데이터베이스 스키마를 자동으로 생성하고 유지하며, 모델의 변경 사항을 데이터베이스에 적용하는 기능을 제공합니다.
  4. 트랜잭션 관리: 트랜잭션을 사용하여 여러 데이터베이스 작업을 원자적으로 실행하고 롤백할 수 있습니다.
  5. 액티브 레코드 패턴 지원: TypeORM은 액티브 레코드(Active Record) 패턴을 지원하여 데이터베이스 레코드를 객체처럼 쉽게 다룰 수 있게 합니다.

사용 방법

1. npm install

$ npm i typeorm mysql2 reflect-metadata --save

 

2. DataSource cofiguration 추가

import { DataSource } from 'typeorm';

export const AppDataSource = new DataSource({
  type: 'mysql',
  host: 'localhost',
  port: 3306,
  username: 'root',
  password: '1234',
  database: 'chat',
  synchronize: true,
  logging: true,
  entities: ['src/entity/*.ts'],
});

 

위와 같이 DataSource 정보를 입력합니다. 보통은 env 파일을 활용하기 때문에 이 부분은 추후에 별도 설정 파일로 분리를 해야겠지만... 우선 이렇게 커넥션 연결 정보를 그대로 입력해주겠습니다.

 

synchronize 설정의 경우 만들어둔 entity가 있다면 해당 entity를 바탕으로 db에 자동으로 테이블 생성을 도와줍니다. JPA에서는 ddl-auto 설정을 create, update 등의 옵션값으로 세세한 설정이 가능하지만 여기서는 true, false 값만 줄 수 있고 기본적으로 update 방식으로 동작하는 것 같습니다.

 

logging은 말그대로 typeORM을 통해 실행되는 쿼리를 logging 할지 여부를 선택하는 것입니다. ORM을 사용하는 경우 의도치 않은 쿼리가 나가는지 확인이 필요하기 때문에 가급적 true 설정하는 것이 좋습니다.

 

entities의 경우 직접 생성한 entity들을 등록할 수 있습니다. 위 경우 경로를 지정하여 해당 경로에 있는 entity들을 등록할 수 있게 했습니다.

 

3. 초기화 진행

AppDataSource.initialize().then(() => {...});

위와 같이 main 파일에서 초기화를 진행한 뒤 사용할 연결이 잘 되는 것을 확인할 수 있습니다.

 

간단한 예제 구성

이제 간단한 시나리오를 구성하고 엔티티 연결 관계를 확인한 뒤 데이터를 저장하고 조회해보도록 하겠습니다.

 

시나리오

1. 사용자 등록을 할 수 있습니다. (POST /members)

: 사용자 정보를 등록하는데 이때 사용자의 역할(role) 정보, 주소 정보를 같이 저장할 수 있도록 하겠습니다.

2. 사용자의 정보를 수정할 때 사용자의 역할(role) 및 주소도 수정할 수 있습니다. (PATCH /members/{memberId})

: 사용자의 정보를 수정하고 사용자의 기존 역할을 모두 삭제한 뒤 다시 모든 신규 권한을 추가, 기존 주소를 모두 삭제한 뒤 새로운 주소를 추가합니다.

 

우선 엔티티들을 설계해보도록 하겠습니다.

 

role.entity.ts

import {Column, CreateDateColumn, Entity, PrimaryGeneratedColumn, Timestamp} from "typeorm";

@Entity('role')
export class Role {
    @PrimaryGeneratedColumn('increment')
    id: number;

    @Column('varchar', { length: 20 })
    name: 'ADMIN' | 'MEMBER';

    @CreateDateColumn()
    createdAt: Timestamp;
}

member.entity.ts

import {Column, CreateDateColumn, Entity, JoinTable, ManyToMany, PrimaryGeneratedColumn, Timestamp} from "typeorm";
import { Role } from "./role.entity";

@Entity('member')
export class Member {
    @PrimaryGeneratedColumn('increment')
    id: number;

    @Column('varchar', { length: 20 })
    name: string;
    
    @Column('varchar', { length: 4, nullable: true })
    mbti: string;

    @ManyToMany(() => Role, {cascade: ['insert']})
    @JoinTable({
        name: 'member_role',
        joinColumn: {
            name: 'member_id',
            referencedColumnName: 'id'
        },
        inverseJoinColumn: {
            name: 'role_id',
            referencedColumnName: 'id'
        }
    })
    roles: Role[];

    @CreateDateColumn()
    createdAt: Timestamp;

    constructor(name: string) {
        this.name = name
    }
}

 

JPA를 사용할 때 @ManyToMany를 사용한 경우는 거의 없었습니다. @ManyToMany를 사용하게 되면 자동으로 중간 테이블을 생성해주지만 각각의 키 하나씩만 추가되고 다른 컬럼을 추가할 수 없기 때문입니다. 실무에서는 보통 더 많은 컬럼들을 중간 테이블에 넣기 때문에 중간 테이블을 직접 생성하고 @OneToMany, @ManyToOne을 사용해 중간 테이블과 연결하여 사용하는 편입니다.

 

다음으로 각 repository에서 쿼리들을 작성하고 이를 사용하는 비즈니스 로직이 담긴 service를 개발해보겠습니다.

 

먼저 사용자의 name과 roles를 입력 받아 해당 정보를 저장하는 로직을 살펴보겠습니다. 먼저 전달 받은 정보를 가지고 member 객체를 생성합니다. 이후 생성된 Member Entity를 저장하면 중간 테이블인 member_role 테이블에도 데이터가 같이 저장됩니다.

// member.repository.ts
export class MemberRepository {
    memberRepository = AppDataSource.getRepository(Member);

    constructor() {}
    
    ...

    save(member: Member) {
        return this.memberRepository.save(member);
    }

    ...
}

// member.service.ts
export class MemberService {
    private memberRepository = new MemberRepository();
    private roleRepository = new RoleRepository();

    constructor() {}

    ...

    async save(member: MemberCreateRepuest) {
        let findRole = await this.roleRepository.findByName(member.role); // 기존 role entity를 조회해서
        if (!findRole) findRole = new Role(member.role); // 없는 경우 새로운 role 객체를 생성한 뒤
        
        const newMember = new Member(member.name);
        newMember.roles = [findRole];
    
        return this.memberRepository.save(newMember); // member 만 장하면 같이 저장
    }
    
    ...
}

 

위와 같이 @ManyToMany로 연결되는 중간 테이블의 경우 기준 테이블에 정보를 저장할 때 해당 엔티티를 생성하여 넣어주는 경우 자동으로 해당 데이터도 같이 저장(insert)이 됩니다.

 

또한 자동 생성된 중간 테이블이 아니라고 하더라고 설정 중 cascade 설정 값을 통해 기준 테이블의 정보만 저장하면 자동으로 부가적인 정보를 같이 저장할 수 있게 됩니다. 다른 예시로 회원 저장 시 회원의 주소 정보를 저장할 수 있다고 가정해보겠습니다.

// address.entity.ts
@Entity('address')
export class Address {
    @PrimaryGeneratedColumn('increment')
    id: number;

    @Column('varchar', { length: 6 })
    zipcode: string;

    @Column('varchar', { length: 20 })
    address: string;

    @ManyToOne(() => Member, (member) => member.addresses, { eager: false })
    @JoinColumn({ name: 'member_id', referencedColumnName: 'id' })
    member: Member

    @CreateDateColumn()
    createdAt: Timestamp;

    constructor(zipcode: string, address: string) {
        this.zipcode = zipcode;
        this.address = address;
    }
}

// member.entity.ts
@Entity('member')
export class Member {
    ...

    @OneToMany(() => Address, (address) => address.member, {cascade: ['insert']})
    addresses: Address[]

    ...
}

// member.service.ts
export class MemberService {
    ...

    async save(member: MemberCreateRepuest) {
        let findRole = await this.roleRepository.findByName(member.role);
        if (!findRole) findRole = new Role(member.role);
        
        const newMember = new Member(member.name);
        newMember.roles = [findRole];
        newMember.addresses = member.addresses.map(
          address => new Address(address.zipcode, address.address),
        );
    
        return this.memberRepository.save(newMember);
    }

    ...
}

 

위와 같이 작성하는 경우 MemberRepository에서만 save를 해도 자동으로 address 테이블에 정보가 저장됩니다.

 

JPA에서는 양방향 연관 관계 매핑이 되어 있는 경우 1. 연관 관계의 주인이 아닌 객체를 중심으로 한번에 save 하거나 2. 영속성 컨텍스트 내에서 순수 객체끼리 참조가 가능하도록 하기 위해 양쪽 모두에 참조 값을 추가해주는 작업이 필요합니다. (JPA 양방향 연관 관계 저장, 연관 관계 편의 메서드 등으로 검색해보세요!)

 

하지만 TypeORM의 경우 양방향 연관 관계가 맺어져 있고 cascade 설정이 되어 있다면 연관 관계의 주인이 아닌 객체에 데이터를 저장하고 별도 객체 참조를 넣지 않은 상태로 저장할 때 양쪽 테이블에 모두 데이터가 잘 저장됩니다. (이 부분이 혹시 잘못 알고 있는거라면 말씀주시면 감사하겠습니다.)

 

다음으로 회원의 정보를 수정할 때 회원 정보와 함께 member_role 테이블의 정보 및 address 테이블의 정보도 같이 수정(삭제 후 저장)하는 예제를 살펴보겠습니다.

export class MemberService {
  private memberRepository = new MemberRepository();
  private roleRepository = new RoleRepository();
  private addressRepository = new AddressRepository();

  ...

  async updateMemberInfo(request: MemberUpdateRequest) {
    const member = await this.memberRepository.findById(request.memberId);
    if (!member) throw new Error('member does not exist.');

    if (request.mbti) member.mbti = request.mbti;

    if (request.roles) {
      const roles = await Promise.all(
        request.roles?.map(
          async role => await this.roleRepository.findByName(role),
        ),
      );

      if (roles) {
        member.roles = roles.filter(role => role !== null) as Role[];
      }
    }

    if (request.address) {
      const address = request.address?.map(
        adr => new Address(adr.zipcode, adr.address),
      );
      if (address) member.addresses = address;
    }
    
    await this.addressRepository.deleteByMemberId(member.id); // 기존 멤버 주소 전체 삭제
    return this.memberRepository.save(member); // 멤버 (멤버, 롤, 주소 업데이트 및 삭제, 등록)
  }
}

 

❗️위와 같이 작성을 하는 경우 기존 멤버의 주소가 삭제하고 멤버를 저장하는 과정에서 에러가 발생한다면 기존 멤버의 주소는 삭제되고 멤버 정보의 수정은 실패하여 하나의 트랜잭션이 보장되지 않아서 문제가 발생합니다.

 

트랜잭션을 처리하는 다양한 방법이 있겠지만...! 저는 일단 임시로 AppDataSource에서 runner를 가져와서 커넥션 연결 후 하나로 묶어야 하는 쿼리 작업 전 트랜잭션을 시작하고 성공인 경우만 커밋, 아닌 경우는 롤백할 수 있도록 하였습니다.

export class MemberService {
  private memberRepository = new MemberRepository();
  private roleRepository = new RoleRepository();
  private addressRepository = new AddressRepository();
  
  ...

  async updateMemberInfo(request: MemberUpdateRequest) {
    const member = await this.memberRepository.findById(request.memberId);
    if (!member) throw new Error('member does not exist.');

    if (request.mbti) member.mbti = request.mbti;

    if (request.roles) {
      const roles = await Promise.all(
        request.roles?.map(
          async role => await this.roleRepository.findByName(role),
        ),
      );

      if (roles) {
        member.roles = roles.filter(role => role !== null) as Role[];
      }
    }

    if (request.address) {
      const address = request.address?.map(
        adr => new Address(adr.zipcode, adr.address),
      );
      if (address) member.addresses = address;
    }

    let response: Member;

    const runner = AppDataSource.createQueryRunner();
    try {
      await runner.connect();
      await runner.startTransaction();

      const addressRepo = runner.manager.getRepository(Address);
      const memberRepo = runner.manager.getRepository(Member);

      await addressRepo
        .createQueryBuilder()
        .delete()
        .from(Address)
        .where('member_id = :memberId', { memberId: member.id })
        .execute();

      response = await memberRepo.save(member);

      // throw new Error('강제 에러');

      await runner.commitTransaction();
    } catch (error) {
        console.log(error);
        await runner.rollbackTransaction();
        return Promise.reject(error);
    } finally {
      runner.release();
    }

    return response;
  }
}

 

이렇게 하면 중간에 에러가 발생하는 경우 모두 rollback, 모두 성공하는 경우 commit 하게 되어 하나의 트랜잭션으로 처리가 가능합니다. 주석 처리 되어 있는 '강제 에러' 부분의 주석을 풀고 테스트하는 경우 주소 삭제, 멤버 저장 쿼리가 실행되었다가 다시 rollback 하는 것을 확인할 수 있습니다.

 

위 코드를 보면 다른 곳에서도 트랜잭션이 있을 때 저렇게 다 try-catch 하고 써야하나라는 의문이 있는데요 이를 별도로 모듈화하여 작성하는 것이 좋을 것 같아 추후에 샘플 코드는 수정을 할 계획입니다...!! 우선 지금 글에서는 트랜잭션 처리의 필요와 방법에 대해 살펴보고자 하여 일회성 로직을 구현한 점 양해부탁드립니다. 😅


지금까지 TypeORM 사용 방법과 트랜잭션 처리에 대해서 공부해보았습니다. 아직 제가 전체적인 구조를 잡고 이렇게 구성을 해야지 생각하기 보다 리딩하시는 분의 코드를 이해하고 따라가기 바쁜 상황이지만... 매일 조금씩 배워나간다는 마음으로 꾸준히 하면 어느새 다양한 코드를 이해하는 능력이 더 길러지겠죠...?ㅎㅎ

 

이번에 정리한 TypeORM은 기본 + 프로젝트에서 사용하면서 생겼던 이슈를 다루었습니다. 공부하면서 Spring의 JPA에 대해서도 조금 더 개념적인 이해를 할 수 있어 좋은 시간이었던 것 같습니다. 😊

 

TypeORM의 기본 사용법과 다양한 쿼리에 대해서는 공식 설명을 더 참고하시기 바랍니다. 감사합니다. 🙏

 

해당 글에 사용된 샘플 코드는 여기에서 확인하실 수 있습니다.

 

참고

 

댓글
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2024/05   »
1 2 3 4
5 6 7 8 9 10 11
12 13 14 15 16 17 18
19 20 21 22 23 24 25
26 27 28 29 30 31
글 보관함