[Nest, Typeorm] ManyToMany 관계 개선 #1 (feat. @ManyToMany를 사용할 경우)

DatQueue·2023년 3월 12일
0
post-thumbnail

💥 시작하기에 앞서

역할(Role)과 권한(Permission)의 관계를 '다대다(ManyToMany)' 로 join함으로써 일어났던 문제와 그에대한 개선을 중심으로 작성해보고자 한다.

릴레이션 관계가 없는 경우, 다대일의 경우, 일대다의 경우와는 다르게 "다대다(N:M)"의 경우에서 생성한 Role객체의 릴레이션 프로퍼티를 수정하는 과정에서 문제가 발생하였다.

아래에서 코드를 토대로 발생한 에러와 해결과정을 기술하고자 한다. 그전에 앞서 이번 포스팅을 통해 어떤것을 해결하고 알아갈 수 있는지 간단하게 알아보자.

  1. ManyToMany관계의 컬럼을 수정하는 과정에서 발생한 에러를 알아본다.
  2. 에러를 해결하는 방법을 크게 두 가지의 코드방법으로 알아본다. ( 각 방식의 특징과 개선을 통한 전개 )
  3. ManyToMany를 사용하였을때의 문제점을 파악하고, ManyToOneOneToMany로써 분리하여 구현할 수 있는지에 대해 알아본다.
  4. 개선한 방법의 이점에 대해 알아본다.
  5. 조금 더 효율적인 코드를 통한 개선 방법을 알아본다.

이 중 우리는 이번 포스팅에선 1,2에 해당하는 내용을 다루고자 한다.

내용이 길어지는 것을 고려해 3, 4, 5에 해당하는 내용은 추후 포스팅에서 이어서 다루도록 한다.


💥 [N:M] 연관관계용 프로퍼티 수정 시 발생한 에러와 해결 과정

> Role & Permission 엔터티 확인

// role.entity.ts

import { Permission } from "../../permission/model/permission.entity";
import { User } from "src/user/model/user.entity";
import { Column, Entity, JoinTable, ManyToMany, OneToMany, PrimaryGeneratedColumn } from "typeorm";

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

  @Column()
  name: string;

  @OneToMany(() => User, user => user.role)
  users: User[];

  @ManyToMany(() => Permission, { cascade: true })
  @JoinTable({
    name: 'role_permissions',
    joinColumn: { name: 'role_id', referencedColumnName: 'id' },
    inverseJoinColumn: { name: 'permission_id', referencedColumnName: 'id' }
  })
  permissions: Permission[];
}

// permisison.entity.ts

import { Role } from "src/role/model/role.entity";
import { Column, Entity, ManyToMany, PrimaryGeneratedColumn } from "typeorm";

@Entity('permissions')
export class Permission {
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  name: string;
	
  // 추가해주지 않아도 동작의 문제는 없다.  
  @ManyToMany(() => Role, { cascade: true })
  roles: Role[]
}

보다 시피 Role 엔터티에 permissions FK 컬럼을 통해 "다대다"매핑의 중간 테이블role_permissions 테이블을 만들어준 것을 확인할 수 있다. (@JoinTable() 사용)

Permission 엔터티에 굳이 위와 같이 ManyToMany 관계 설정을 해주지 않더라도 동작에 차이는 없다. 하지만 일반적으로는 설정해주는 것이 좋고, 만약 한 곳에만 관계설정을 지정해준다고 하면, 통상적으로 더 중요하다고 생각되는(부모 격인) 엔터티에서 다대다 관계를 설정해주는 것이 일반적이다. (상황에 따라 유연하게 하는것이 좋다.)


> permissions 속성 수정(update)과정과 에러 발생

먼저 기존에 수행하였던, 서비스 로직과 컨트롤러단의 로직을 확인해보자.

// role.service.ts
// ---- 생략

async update(id: number, data: object): Promise<any> {
  const role = await this.roleRepository.findOne({
    where: {
      id,
    },
  });

  if (!role) {
    throw new NotFoundException('해당 id의 권한 정보는 존재하지 않습니다.');
  }

  return await this.roleRepository.update(id, data);
}
// role.controller.ts

@Put(':id')
async update(
  @Param('id') id: number,
  @Body('name') name: string,
  @Body('permissions') ids: number[]
) {
  await this.roleService.update(id, {
    name,
    permissions: ids.map(id => ({id}))
  });
  return await this.roleService.findOne(id);
}

(사실 컨트롤러 단에서 위와 같이 중요 로직을 작성하는것은 그리 좋지 못하다.)

update()를 구현한 후 다시 find 메서드를 사용하여 값을 불러와야한다. 즉, roleService에서 정의해 준 findOne() 함수를 사용할 것이고 구현체는 아래와 같다.

// role.service.ts

  async findOne(condition: number): Promise<Role> {
    return await this.roleRepository.findOne({
      where: {
        id: condition,
      },
      // relations 관계 설정을 통한 연관관계 테이블 매핑
      relations: ["permissions"]
    })
  }

크게 중요한 부분은 없다고 판단하였다. 단순히 프로퍼티의 밸류 업데이트하는 로직이기 때문이다.

여기서 언급하진 않았지만 role 객체를 생성(create)하는 부분은 아래와 같다.

// role.service.ts
  async create(data: object): Promise<Role> {
    return await this.roleRepository.save(data);
  }
  
// role.controller.ts
  @Post()
  async create(
    @Body('name') name: string,
    @Body('permissions') ids: number[]
  ){
    return await this.roleService.create({
      name,
      permissions: ids.map(id => ({id}))
    });
  }

create 함수의 인자로써 object인 data를 받게되고, 클라이언트의 요청 응답으로 { name, permissions: ids.map(id => ({id}) }를 넘겨주었다.

이렇게 할 경우, 바디에서 작성된 ids 배열 요소 (즉, id값에 따른 Permission 데이터)에 따른 배열 객체인 permissions가 만들어질 것이다.

자, 그럼 다시 update 과정으로 돌아와 보자.

return await this.roleService.create({
  name,
  permissions: ids.map(id => ({id}))
});

다음과 같이 객체를 생성하였으니 수정할 때도 동일한 객체로써 수정해주는 것이 일반적이다. 즉, 이에 따라 우리는 update 함수에도

await this.roleService.update(id, {
   name,
   permissions: ids.map(id => ({id}))
});

다음과 같이 처리해주었던 것이다.

이대로 작성하고 포스트맨에서 업데이트 요청을 하면 어떻게 될까?

500 에러가 발생하였다.... 위와 같은 업데이트 방식은 잘못되었다는 것이다...


에러 문구를 확인해보면 아래와 같다.

Error: Cannot query across many-to-many for property permissions

" 다대다 관계의 permissions 프로퍼티에서 쿼리를 수행할 수 없다 "


JPA나 다른 orm 환경에 대해선 모르겠지만, Typeorm에선 "다대다(N:M)" 관계를 가지는 엔터티에서 해당 프로퍼티에 대해 쿼리를 수행하는 것이 불가능하다고 제시하는 것이다.

앞서, Role 엔터티를 구성하게 되는 코드를 통해서도 확인할 수 있듯이 Typeorm에선 "다대다" 관계를 구성하는데 있어서 @JoinTable() 을 통해 "연결 테이블"을 만들고 해당 관계를 정의한다.

즉, 우리가 여태껏 학습했던 "일대다 (OneToMany)", "다대일 (ManyToOne)"등에선 릴레이션 관계를 나타내는 프로퍼티가 "컬럼" 이었지만 "다대다 (ManyToMany)" 관계에선 "테이블(엔터티)"이 되는 것이다.


role 객체를 생성할때와(레포지터리의 save를 통한 create) 수정할때 같은 data 객체를 통해 처리해주었음에도 문제가 발생한 원인도 위와 직접 연관된다.

생성할 때엔 앞서 코드에서 create()함수의 인자로 보았듯이, permissions 배열을 받아 해당 배열에 포함된 id 값을 지닌 Permission 엔터티를 새롭게 생성하여 Role 엔티티와 함께 저장(save)한다.

하지만, 수정 시에는 이미 생성된 Permissions 엔터티와 Role 엔터티간의 관계, 즉, @JoinTable()로써 생성한 연결 테이블을 건드려야하므로 얘기가 달라지는 것이다.

결론은 "ManyToMany"관계에서 연결 엔터티를 위한 프로퍼티는 "직접 수정"이 불가하다.


> 해결 과정 1) 엔터티 객체의 복제를 통한 값 덮어씌우기

해당 방법을 설명하기 앞서 먼저 알아야할 부분이 있다.
Typeorm의 레포지터리 내부의 update() 함수를 통해서 직접적 수정, 혹은 프로퍼티 자체에 바로 접근하여 값 수정의 방법이 수행될 수 없으므로, 우린 다른 접근으로 프로퍼티 수정을 구현해야할 것이다.

결국, 그 방법은 "직접 수정"이 아닌 "삭제 후 재생성"의 느낌으로 수행해야 할 것이다. (결국 save가 중점이 될 것이다.)

코드로써 알아보는 것이 좋을거 같다. 그럼 제목에서와 같이 첫 번째 해결과정을 살펴보자.


✔ 컨트롤러 update() 함수 수정

서비스단의 update() 함수와 특정 id의 객체를 조회하는 findOne() 함수는 이전과 동일하다.

// 기존 

  @Put(':id')
  async update(
    @Param('id') id: number,
    @Body('name') name: string,
    @Body('permissions') ids: number[]
  ) {
    await this.roleService.update(id, {
      name,
      permissions: ids.map(id => ({id}))
    });
    return await this.roleService.findOne(id);
  }


// 수정

  @Put(':id')
  async update(
    @Param('id') id: number,
    @Body('name') name: string,
    @Body('permissions') ids: number[]
  ) {
    await this.roleService.update(id, {
      name,
    });
    const role =  await this.roleService.findOne(id);
    return await this.roleService.create({
      ...role,
      permissions: ids.map(id => ({id}))
    })
  }

수정된 부분을 확인해보면 기존 잘못되었던 업데이트 구현방식과 다른 것을 확인할 수 있을 것이다. permissions 프로퍼티를 수정하는 부분을 update() 함수의 인자인 data 객체에서 제외하고 오로지 name 값만 수정하도록 해주었다.


해당 과정을 순서를 통해 확인해보자.

  1. roleServiceupdate() 함수를 통하여 Role 엔터티 배열의 특정 id에 해당하는 객체의 name을 수정한다. (permissions를 이때 수정하지 않는다.)

  2. name 이 수정된 Role 엔터티 객체를 findOne(id)를 통해 불러온다. --> 업데이트된 역할 정보를 반환한다.

  3. 그런 다음 ...role을 사용하여 해당 역할(Role 객체)의 기존 정보를 복제하고, permissions 속성을 업데이트된 값으로 덮어쓴다. 이렇게 함으로써 새로운 Role 객체를 만들어 반환하게 된다.
    --> roleServicecreate() 함수는 Typeorm 레포지터리 내부의 save() 메서드를 리턴하기 때문


✔ 원리 알아보기

마지막 과정인 3번 과정에 대해 조금 더 자세히 볼 필요가 있다.

핵심은 "Spread Operator"를 사용하여 객체 복제를 하였다는 것이다.

예를 들어, 다음과 같은 Role 객체가 있다고 가정해보자.

const role = {
  id: 1,
  name: "admin",
  permissions: [{ id: 1, name: "create" }, { id: 2, name: "read" }]
};

해당 객체를 복제하여 아래와 같은 새로운 Role 객체를 만든다고 해보자.

const newRole = { ...role, name: "Another admin" };

그리고 콘솔에 newRole를 찍어 객체 내부를 보면 어떨까?

console.log(newRole);
// Output: { id: 1, name: "Another admin", permissions: [{ id: 1, name: "create" }, { id: 2, name: "read" }] }

위와 같이 idpermissions 속성 값은 그대로 유지되고 , 수정한 name 의 값만이 덮어씌어진다.

이 원리를 우리의 코드에 구현한 것이다.


이러한 "객체 복제"를 통하여 마치 기존 데이터를 수정하는 것과 유사한 효과를 얻을 수 있게 되었다.


✔ 포스트맨을 통한 요청 응답 확인

기존에 id=4에 해당하는 Role 객체의 permissions 엔터티는 id가 2,4인 값을 가지는 객체의 배열이었지만 전문(body)에서 [1,3] 으로 수정함에따라 응답 데이터 배열 값또한 변경된 것을 확인할 수 있다.


> 객체 복제의 방법은 괜찮은 방법일까?

데이터베이스 쿼리를 조금 덜 날리고 "객체 복제"를 통해 업데이트를 수행하므로 오히려 좋지 않을까? 라고 생각 할 수도 있다. 어쨋든 디비에 조금이라도 덜 접근하면서 작업하기 때문이다.

하지만, 데이터베이스의 "전체적 측면"으로 봤을때 해당 "객체 복제"의 방법은 여러 문제를 일으킬 수 있다는 것을 알게 되었다.

우리의 구현과 같이 간단하게 Role , Permission 테이블에 대해서만 진행하는 것은 크게 문제가 되지 않겠지만, 데이터 베이스의 크기가 매우 커지거나, 처리해야할 작업의 양이 많은 경우 성능에 영향을 주게 된다.


✔ 메모리 성능에 따른 문제

예를 들어, 단순 몇백 혹은 몇천건의 레코드가 아닌 수백만 건의 레코드가 존재하는 대규모 데이터베이스에서 만약 다음과 같이 객체 복제를 진행할 경우 상당한 메모리와 시간이 필요할 수 있다.

...을 이용한 Spread Operator의 방법은 자바스크립트의 "얕은 복사(Shallow Copy)"로써 (얕은 복사, 깊은 복사에 관한 자세한 설명은 따로 하지 않겠습니다.) 설명이 되곤 한다. 하지만 엄밀히 말해, Spread Operatpr를 통한 전개구문 또한 Object.assign()과 같이 내부의 객체(속성)는 얕은 복사가 진행되지만 복사한 객체 자체"깊은 복사"가 진행된다. 결국 메모리 주소 하나가 추가되는 것이고 이는 메모리 힙에 또다른 주소로 쌓일 것이다.

정확히 말하면 우리의 코드와 같이 "1 depth"인 경우에만 오브젝트 자체가 "깊은 복사"로써 진행된다.

즉, 다시 말해 대규모 레코드를 처리하게 되면 이처럼 메모리 사용량 측면에서 좋지 않는 것이다.


✔ 동기화 측면에서의 문제

이 부분은 정확히 머릿속에 정립이 되지 않았으므로 생각 위주로 작성해보겠다.

일단 핵심은 복제된 객체와 원본 객체가 동기화되지 않을 경우, 예상치 못한 동작이 발생할 수 있다는 것이다.

"동기화(Synchronized)"에 대해 깊게 설명하기엔 주제에 벗어나므로 간단히 말하자면 "여러 작업이 동시에 실행되는 환경에서 작업들의 순서나 실행 시점을 조절하여 데이터 일관성을 유지하고 충돌을 방지하는 것" 을 말한다. 조금 더 단순히 말하면 "데이터 일치"라고 말할 수도 있다.

예를 들어, 복제된 객체를 수정하는 작업을 하였지만 원본 객체가 그대로인 경우, 복제된 객체의 변경 사항이 원본 객체에 반영되지 않아 "데이터 불일치" 가 발생할 수 있다. 시스템의 안정성을 저해하게 되는 것이다.

현재 우리의 상황에서 복제된 객체와 원본 객체는 서로 다른 메모리 공간에 위치해 있다. 서로 다른 메모리에 위치하고 있기 때문에 원본 객체의 상태를 변경하더라도 복제 객체는 그 변경사항을 알지 못하고 그대로 유지될 수 있다. 혹은 1depth 깊은 복사인 우리의 코드 경우처럼 복제된 객체를 다시 수정할 때, 서로 다른 메모리로 인해 원본 객체에 영향을 미치지 않을 수 있다. (즉, 동기화가 이루어지지 않게 된다.)

따라서 동기화가 이루어지지 않으면 예상치 못한 결과를 야기할 수 있는 것이다.

이러한 문제를 해결하기 위해, 수정된 변경사항을 적용한 후, 모든 복제된 객체를 순회하며 변경된 원본 객체와 동일한 상태를 유지하도록 취해주는 것이다. 이 방법은 동기화의 문제는 해결할 수 있겠지만, 말만 들어도 특정한 연산이 기존에 비해 더 많이 수행될것으로 예상된다. 결국 코드 성능은 떨어질 수 있다.

( 물론, 자바스크립트 및 노드js의 환경은 "싱글 스레드(Single Thread)"기반이므로 "멀티 스레드(Multi Thread)" 환경에서보다 해당 문제는 덜 탈 수 있다. )


✔ "뎁스(depth)"가 깊어질 경우

위에서도 언급하였지만,Spread-Operator를 통한 객체 복제를 구현한 경우는 일반적으로 "1 depth"에 한해서 효과적으로 동작한다.

다차원의 경우, 물론 Spread-Operator를 이용하여 작성은 해줄 수 있지만 이는 좋은 방법이 아니다. 즉, 뎁스가 깊어질수록, 객체 복제 시 더 많은 작업이 필요하게 되고 성능에 영향을 미친다.

이를 데이터베이스적 측면으로 옮겨 적용하면 중첩 관계(nested relationship)가 발생하는 것이라고 생각할 수도 있다. 물론, 동일한 개념은 아니지만 이처럼 뎁스가 깊어지게 되면 관계가 복잡해짐에 따른 여러 문제가 야기될 수 있다.

사실 이 문제는 우리 코드의 경우에 데이터베이스의 중첩 관계 측면에서 생각하는것이 더 맞을 것이다.


✔ "트랜잭션(Transaction)" 이용의 경우

트랜잭션이란 무엇인가에 대해 설명하면서 진행하면 좋겠지만 해당 포스팅의 주제에서 벗어나므로 언급하지 않겠다.

아무튼, 현재 코드와 같이 트랜잭션을 사용하고 있지 않는 경우 문제가 생긴다.

바로, 데이터베이스의 일관성 측면에서의 문제이다. 만약, 트랜잭션을 사용하고 있다면 수정 작업시 에러가 날 경우, 데이터베이스 롤백을 통해 변경사항을 모두 취소하고 이전 상태로 되돌리게된다. 하지만, 트랜잭션을 사용하고 있지 않다면 에러가 터져도 변경된 엔터티 상태가 롤백되지 않고 그대로 데이터베이스에 반영이 될 수도 있다.

조금 더 말하자면, 복제된 Role 객체는 원본 객체의 상태를 복사해왔기 때문에, 트랜잭션이 설정되지 않은 상황이라한다면 업데이트된 내용이 바로 데이터베이스에 반영된다. 만약, 업데이트 중 에러가 발생한다면, 원본 객체의 상태는 그대로 유지되고, 복제된 객체에 업데이트가 반영되지 않게 된다. 이 경우, 데이터베이스에는 업데이트가 적용되지 않은 상태가 유지될 수 있게 된다.

즉, 이러한 "데이터 불일치" 문제가 일어날 수 있고 이는 "데이터 무결성" 측면에서 위배가 된다.

사실, 이건 객체 복사의 방법 뿐만아니라 "트랜잭션"을 설정하지 않았기에 발생하는 문제 에 더 가깝다. 그렇지만 한번 짚고 넘어가는 것도 좋다고 판단하여 추가하였다.


> 해결 과정 2) findByIds(물론 현재는 deprecated)를 통한 개선

이러나 저러나 Spread Operator를 통한 객체 복제의 방법으로써 엔터티를 업데이트 하는 것은 좋지 않다고 판단하였다.

기존의 방법과는 다르게, permissionRepository에 직접 접근해 permissions 엔터티를 수정하는 방법을 수행하게 되었다.

또한, 매우 간단한 비즈니스 로직이지만 컨트롤러 액션내부에는 요청과 응답에 해당하는 코드만 작성하고, 나머지 구현부는 모두 서비스단의 update() 메서드 내부에서 처리하도록 하였다.


✔ 서비스 단위 update() 함수

// role.service.ts

@Injectable()
export class RoleService {
  constructor(
    private readonly roleRepository: RoleRepository,
    private readonly permissionRepository: PermissionRepository,
  ){}

  async update(id: number, data: Partial<Role>): Promise<Role> {
      const role = await this.roleRepository.findOne({
        where: {
          id,
        },
        relations: ['permissions'],
      })

      if (!role) {
        throw new NotFoundException('해당 id의 권한 정보는 존재하지 않습니다.');
      }

      // Update role name
      if (data.name) {
        role.name = data.name;
        await this.roleRepository.save(role);
      }

      // Update role permissions
      if (data.permissions) {
        // Remove old permissions
        role.permissions = [];
        await this.roleRepository.save(role);

        // Add new permissions
        const permissions = await this.permissionRepository.find({
          where: {
            id: In(data.permissions)
          }
        });
        role.permissions = permissions;
        await this.roleRepository.save(role);
      }

      return role;
   }
}

매개변수로 받을 datanamepermissions를 가지는 RoleDto로써 표현하는 것이 일반적이고 효율적이지만 업데이트와 같은 기능에 한에선 Partial<> 타입을 사용해줘도 무방하다 본다.


그럼 단계별로 알아보자.

  1. 위에서도 언급했다시피 우린 roleRepository 뿐만아니라 permissionRepository에 접근함으로써 해당 엔터티를 수정할 것이다. 그러므로 constructor 내부에서 해당 레포지터리를 주입한다.

  2. 먼저 첫 번째 매개변수로 받은 id 를 통해 해당 idrole이 있는지 확인하고, 없을 경우 예외를 던진다.

  3. 이전(객체 복사의 방법)과 같이 직접 수정이 가능한 name과 같은 경우 수정 후 레포지터리의 save 메서드를 통해 변경 사항을 저장한다.

  4. 권한(permissions)을 수정하는 경우, 역할의 기존 권한을 제거하고 (role.permissions = []) roleRepositorysave 메서드를 통해 해당 변경사항을 먼저 저장한다.
    그런 다음, permissionRepository를 통해 새 권한을 가져와 역할의 permissions 속성에 할당하고 roleRepositorysave 메서드를 통해 변경 사항을 저장한다.

    이때 새로운 권한을 가져오는 부분을 살펴보자.

    
      // Add new permissions
     const permissions = await this.permissionRepository.find({
       where: {
         id: In(data.permissions)
       }
     });
    
    • In operator는 배열 (즉, data.permisions 배열) 내부의 값과 일치하는 값을 찾아 반환한다. role 에 해당하는 권한이 단일권한이라면 findOne({where: {id: id}})와 같이 작성할 수도 있지만, 다중 권한을 가지는 경우 다음과 같이 작성해주는 것이 좋다.

      해당 파트의 소제목을 "findByIds()를 통한 개선" 이라고 작성한 것엔 이유가 있다. 기존엔 findByIds() 옵션 메서드를 활용하여

      const permissions = await this.permissionRepository.findByIds(data.permissions);

      아래와 같이 작성하였다. 하지만 버전 업그레이드 이후 deprecated 됨에 따라 findwhere절을 통해 수정할 수 있다.


  1. 최종 수정된 role 객체를 반환한다.


✔ 컨트롤러단의 update 액션함수

// role.controller.ts

  @Put(':id')
  async update(
    @Param('id') id: number,000000000
    @Body() data: Partial<Role>,
  ): Promise<Role> {
    return this.roleService.update(id, data);
  }

객체 복제의 방법과 원리는 비슷하지만 훨씬 깔끔한 코드라 생각한다.


다음 포스팅 예고

생각정리는 다음 포스팅의 마지막에 다루고자 한다. 글이 길어지는 것을 감안해 여기서 끊었지만 사실 중요한 부분은 다음 포스팅이 되지 않을까 싶다. 이번 포스팅에서 우린 RolePermission의 관계를 정의하는데 있어 @ManyToMany 를 사용하여 나타내었지만, 사실 이러한 접근은 좋은 접근이라 할 수 없다.

이것은 Typeorm 뿐 아니라 JPA에서도 항상 언급되는 부분이다. 그럼 도대체 어떻게 "다대다" 관계를 구축하고 또 어떤 식으로 레코드 생성 및 업데이트를 구현하는지 다음 포스팅에서 알아보도록 하자.

profile
You better cool it off before you burn it out / 티스토리(Kotlin, Android): https://nemoo-dev.tistory.com

0개의 댓글