[Nest, Typeorm] ManyToMany 관계 개선 #2 (feat. @ManyToMany없이 "다대다" 구현하기 )

DatQueue·2023년 3월 16일
1
post-thumbnail

시작하기에 앞서

먼저, 이번 포스팅은 이전 포스팅 "ManyToMany 관계 개선 #1 (feat. @ManyToMany를 사용할 경우)" 의 연장선에서 진행한다.


이전 포스팅 클릭 ✔


꼭 이전 포스팅 글을 보고 이번 글을 읽는 것을 추천한다. (이해하기에 매끄럽습니다.)

이번 포스팅에선 이전에 다루었던 @ManyToMany 를 이용한 RolePermission"다대다[N:M]" 관계를 @ManyToMany 데코레이터를 사용하지 않고 나타내보는 과정을 가져보도록 한다.

또한, 그에 더하여 쿼리적으로(데이터베이스를 고려하여) 최선의 테이블을 가공하기 위한 코드를 개선 및 작성해보도록 한다. ==> 중요!


💥 @ManyToMany(N:M)를 통한 연관관계 매핑의 문제?

ManyToMany(N:M) 매핑 관계에 대해 처음 알게 되었을땐 그저 편리한 기능이라 생각하였지만 구글에 ManyToMany에 대해 검색하면 항상 단골로 나오는 멘트가 "실무 단계에서는 절대 사용하면 안된다" 이다.

어째서일까?

발생하는 문제는 다양할 수 있지만 특정 몇 가지를 중심으로 기술하고자 한다.


Join Table에 대한 직접적 제어 불가능

해당 문제는 우리가 여태껏 다루었던 내용의 연장선이다.

우린, ManyToMany 관계에서 매핑 테이블에 대한 직접적 수정을 하길 원했지만 ORM 차원에서 해당 작업은 바로 수행될 수 없었다. 매핑 테이블은 물론 가상 테이블이 아닌 실제 만들어진 중간 테이블이지만 우리가 "엔터티"로써 만들어주진 않았다. 오로지, 연관 관계에 의해 자동으로 생성된 테이블이다.

또한, Join Table에 데이터를 추가할 때도 문제가 된다. 만약 중간 매핑 테이블에 "사용자가 권한(permissions)을 얻은 일시" 혹은 "권한의 업데이트 시간" 등의 정보를 추가하고자 할 때 문제가 된다. 보통 이러한 추가 정보는 중간 테이블에 저장되는 것이 일반적인데, ManyToManyJoin Table의 경우 이러한 작업을 해주기 어렵다.


✔ 연결 테이블의 필요에 따른 테이블의 크기 증가

ManyToMany 관계에서는 연결 테이블이 필요하므로 테이블의 크기가 증가하고 이는 성능 문제를 야기할 수 있다고 한다.

하지만 생각해보면 ManyToMany의 관계를 개선하기 위해 (추후 해볼 작업) 중간 엔터티를 두어 ManyToOneOneToMany 관계로 나눈다고 해도 추가 테이블이 생기니까 이로 인해 테이블 크기가 증가하게 된다.

"그럼, 똑같이 테이블의 크기가 증가하니까 의미가 없지 않을까?" 라고 생각하였지만 ManyToMany 관계에서의 테이블 크기의 증가는 ManyToOne 관계에서의 증가보다 더 심각할 수 있다는 것을 알게 되었다.

바로 위의 관점에서 나온 문제의 연장선으로 설명할 수 있게 되는데, 만약 ManyToMany의 중간 테이블에 추가 정보가 저장된다고 할 때, 이 경우 해당 Join Table의 크기는 Role 엔터티와 Permissions 엔터티의 레코드수를 곱한 만큼 커진다.

따라서, ManyToMany 관계에서 추가 데이터를 저장할 경우, Join Table의 크기가 엄청나게 커질 수 있고 결국 이는 성능 문제를 야기하게 된다. 물론 우리의 예시같이 작은 상황에선 상관 없을 수 있겠지만 각 엔터티의 레코드 수가 늘어날수록 그 만큼 곱의 결과로써 커지게 된다.

반면에 ManyToOne, OneToMany로 나누고, 중간 매핑 엔터티를 생성하는 경우, 해당 엔터티의 테이블 크기는 오로지 해당 엔터티에 대한 레코드 값만 다루므로 앞전과 비교해 부하가 덜 쌓인다고 할 수 있다.


💥 코드를 통해 ManyToMany[N:M] 관계를 개선해보자.

메인 타이틀에서 언급하였듯이 RolePermissionManyToMany 관계를 OneToMany / ManyToOne 관계로 변경하기로 한다. 중간 테이블(엔터티)을 생성하여 RolePermission 이라는 테이블로 만들고, 해당 모델에선 ManyToOne 관계를 설정하고, RolePermissionOneToMany를 설정해준다.


> 모델 (Entity) 설정

RolePermission

import { Permission } from "../../permission/model/permission.entity";
import { Role } from "./role.entity";
import { Entity, ManyToOne, PrimaryGeneratedColumn } from "typeorm";

@Entity('role_permissions')
export class RolePermission {
  @PrimaryGeneratedColumn()
  id: number;
  
  // Role과 다대일 관계설정
  @ManyToOne(() => Role, role => role.rolePermissions, { onDelete: 'CASCADE' })
  role: Role;
  
  // Permission과 다대일 관계설정
  @ManyToOne(() => Permission, permission => permission.rolePermissions, { onDelete: 'CASCADE'})
  permission: Permission;
} 

Role

import { User } from "../../user/model/user.entity";
import { Column, Entity, OneToMany, PrimaryGeneratedColumn } from "typeorm";
import { RolePermission } from "./role_permission.entity";
import { Exclude } from "class-transformer";

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

  @Column()
  name: string;

  @OneToMany(() => User, user => user.role)
  users: User[];
  
  // RolePermission과 일대다 관계설정
  @OneToMany(() => RolePermission, rolePermission => rolePermission.role)
  rolePermissions: RolePermission[];
}

Permission

import { RolePermission } from "../../role/model/role_permission.entity";
import { Column, Entity, OneToMany, PrimaryGeneratedColumn } from "typeorm";
import { Exclude } from "class-transformer";

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

  @Column()
  name: string;
  
  // RolePermission과 일대다 관계설정
  @OneToMany(() => RolePermission, rolePermission => rolePermission.permission)
  rolePermissions: RolePermission[];
}

주석으로 단 부분이 연관관계 매핑을 위한 핵심 코드들이다. 크게 일일이 설명하진 않겠다. (아마 다 아실겁니다.. ㅎㅎ)


> 과정 1) 순환참조에러의 발생?? -- 무한한 재귀 호출

update 로직

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

    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.rolePermissions) {
      const permissions = await this.permissionRepository.find({
        where: {
          id: In(data.rolePermissions)
        }
      })
      const rolePermissions = permissions.map(permission => {
        const rolePermission = new RolePermission();
        rolePermission.role = role;
        rolePermission.permission = permission;
        return rolePermission;
      })
      role.rolePermissions = rolePermissions;
    }

    return await this.roleRepository.save(role);
  }

이전 포스팅에서 작성한 update 함수와의 차이점은 중간 테이블로 생성한 RolePermission에 직접 접근해야한다는 것이다.

업데이트 요청 시 받게되는 permissions 배열을 순회하며 각 요소를 (즉, permission 객체 하나하나를) 해당 맵 함수 내부에서 생성한 RolePermission에 씌워주게 된다.

그 후, 최종 업데이트 된 rolePermissions 객체 배열을 role.rolePermissions = rolePermissions를 통해 응답시 포함시켜주도록 한다.


✔ 순환 참조 에러의 발생과 원인

위와 같이 서비스 로직을 작성한 후 포스트맨에서 업데이트 요청을 날리게 되면 (혹은 생성, 삭제 요청도 포함 요청하는 방법은 이전 포스팅 참고 ) 다음과 같은 에러를 마주하게 된다.

RangeError: Maximum call stack size exceeded
    at C:\Users\ASUS\Desktop\Nest.JS\nest-admin\node_modules\src\TransformOperationExecutor.ts:506:57
    at Array.filter (<anonymous>)
    at TransformOperationExecutor.getKeys (C:\Users\ASUS\Desktop\Nest.JS\nest-admin\node_modules\src\TransformOperationExecutor.ts:505:21)
    at TransformOperationExecutor.transform (C:\Users\ASUS\Desktop\Nest.JS\nest-admin\node_modules\src\TransformOperationExecutor.ts:150:25)
    
    // ... ...

이것은 자바스크립트 코드에서 재귀 함수가 무한 반복될 때 주로 발생하는 에러이다. "Stack overflow" 로써 일반적으로 함수 호출 스택에 할당된 메모리 공간을 초과하여 발생하게 된다. 즉, 스택이 최대 크기에 도달하게 되면서 "Maximum call stack size exceeded" 오류가 발생하는 것이다.

그럼 우리의 경우에서, 다음과 같은 에러가 왜 발생하는 것일까?

이것은 "ManyToMany" 관계를 위해 설정한 엔터티의 참조에 의해 발생하게 된다.

@Entity('roles')
export class Role {

  // ...
  
  @OneToMany(() => RolePermission, rolePermission => rolePermission.role)
  rolePermissions: RolePermission[];
}

---------------------------------------------------------------------

@Entity('permissions')
export class Permission {
  
  // ...
  
  @OneToMany(() => RolePermission, rolePermission => rolePermission.permission)
  rolePermissions: RolePermission[];
}

---------------------------------------------------------------------

@Entity('role_permissions')
export class RolePermission {
  
  // ...
  
  @ManyToOne(() => Role, role => role.rolePermissions, { onDelete: 'CASCADE' })
  role: Role;
  
  @ManyToOne(() => Permission, permission => permission.rolePermissions, { onDelete: 'CASCADE'})
  permission: Permission;
}

현재 Role 엔터티와 RolePermission 엔터티는 서로 OneToMany 관계이며, RolePermission 엔터티와 Permission 엔터티 또한 OneToMany 관계이다.

따라서 Role 엔터티에서 RolePermission 엔터티를 조회할 때, RolePermission 엔터티에서 Permission 엔터티를 조회하고, 다시 Permission 엔터티는 RolePermission을 조회하는 과정이 반복된다.

즉, 상호 참조 관계에 있어 "순환 참조 에러(Circular Reference Error)" 가 발생하는 것이다.

실제로, 해당 RangeError가 발생하기 전, 먼저 마주하게 되는 에러가 있다. (정확히 말하면 해당 에러 먼저 발생 후 --> RangeError)

TypeError: Converting circular structure to JSON
    --> starting at object with constructor 'Role'
    |     property 'rolePermissions' -> object with constructor 'Array'
    |     index 0 -> object with constructor 'RolePermission'
    --- property 'role' closes the circle

이러하 순환 참조 관계가 형성이 되어 있으면, 클래스 인스턴스를 JSON으로 변환하게 될 시 무한루프에 빠져, 처음 보게 되었던 RangeError가 발생하게 되는 것이다.

결국, JSON.stringify()를 하는 과정에서 해당 관계가 자리잡고 있으면, 직렬화가 어렵다는 것이고, 참조를 처리하지 못한다는 것이다.


@Exclude를 통한 해결

그렇다면 우리는 어떻게 해당 문제를 해결해 줄 수 있을까?

이 문제를 해결하기 위해서는, 클래스 인스턴스를 JSON 객체로 변환 시 해당 상호 참조 관계를 끊어야 한다. 이를 위해 우리는 class-transformer에서 제공하는 @Exclude() 데코레이터를 사용할 수 있다.

@Exclude() 데코레이터가 지정된 필드는 클래스 인스턴스를 JSON 객체로 변환 시 제외되므로, 상호 참조가 끊어지게 된다.

@Entity('roles')
export class Role {

  // ...
  
  @Exclude({toPlainOnly: true})  // Exclude
  @OneToMany(() => RolePermission, rolePermission => rolePermission.role)
  rolePermissions: RolePermission[];
}

---------------------------------------------------------------------

@Entity('permissions')
export class Permission {
  
  // ...
  
  @Exclude({toPlainOnly: true})  // Exclude
  @OneToMany(() => RolePermission, rolePermission => rolePermission.permission)
  rolePermissions: RolePermission[];
}

---------------------------------------------------------------------

@Entity('role_permissions')
export class RolePermission {
  
  // ...
  
  @ManyToOne(() => Role, role => role.rolePermissions, { onDelete: 'CASCADE' })
  role: Role;
  
  @ManyToOne(() => Permission, permission => permission.rolePermissions, { onDelete: 'CASCADE'})
  permission: Permission;
}

RolePermission에서 RolePermission에 접근하기 위해 지정해준 속성 rolePermisions@Exclude()를 주입시켜준다. 어짜피 RolePermission은 또 다시 RolePermission을 참조하게 되므로 해당 부분을 제외시킴으로써 JSON 직렬화시 순환 참조를 막는 것이다.

하지만 이렇게 될 시 순환 참조에 의한 에러는 막을 수 있지만, 우리가 클라이언트에게 값을 보내주는 JSON 응답에 있어서는 기존과 같은 정보를 전달하지 못하게된다. RolePermission 객체에 해당하는 정보를 전부 제외시켜줬기 때문이다.

그럼 어떻게 처리하면 좋을까?

해답은 응답시엔, 즉 JSON 변환 시엔 원하는 값을 응답해주기위해 특정 조치를 취해주어야한다.


toResponseObject() 호출

import { User } from "../../user/model/user.entity";
import { Column, Entity, OneToMany, PrimaryGeneratedColumn } from "typeorm";
import { RolePermission } from "./role_permission.entity";
import { Exclude } from "class-transformer";

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

  @Column()
  name: string;

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

  @Exclude({toPlainOnly: true})
  @OneToMany(() => RolePermission, rolePermission => rolePermission.role)
  rolePermissions: RolePermission[];

  toResponseObject(): any {
    const { id, name, rolePermissions } = this;
    const responseObject = {
      id,
      name,
      rolePermissions: rolePermissions.map(rp => rp.permission),
    };
    return responseObject;
  }
}

Role 엔터티에서 다음과 같이 toResponseObject() 메서드를 추가한다.

위와 같이, 제외시킨 permission 객체 즉, rolePermissions를 받아오기 위한 일련의 과정을 추가해준다.

그 후, 서비스 로직에서 최종 리턴시킬 role 객체에 해당 메서드를 호출시켜주면 된다.

return role.toResponseObject();

✔ 인터셉터를 통한 응답 객체 제어

이전 과정까지가 끝일 것이라 생각했지만, 마지막 단계가 남았다. 바로 "인터셉터 주입" 이다.

인터셉터를 호출하기 전, 만약 업데이트 후 응답객체를 얻게 되면 어떠한 모습으로 나올까?

우린, 아래와 같은 응답 객체를 클라이언트에게 보내주고 한다. 물론 클라이언트의 요구에 영향을 받겠지만 아래와 같은 형태가 깔끔하지 않을까 싶다.

{
    "id": 11,
    "name": "Test수정1",
    "rolePermissions": [
        {
            "id": 1,
            "name": "view_users"
        },
        {
            "id": 2,
            "name": "edit_users"
        }
    ]
}

하지만 인터셉터를 컨트롤러단에 주입하지 않고 그냥 요청을 하게 되면 @Exclude()가 먹지 않는 상태가 반환되게 된다.

{
  "id": 2,
  "name": "edit_users",
  "rolePermissions": [
      {
        "id": 203
      },
      {
        "id": 211
      },
      {
        "id": 219
      }
  ]
}

아마, 위와 같은 형태일 것이다. 더군다나, 여기서 보게 되는 role_permissions 테이블의 각 레코드 id 값은 수정 후 레코드의 pkid도 아니고, 기존의 레코드 id 값이다.

자, 이제 느낌이 올 것이다.

사실, @Exclude() 데코레이터를 사용함으로써 앞선 "순환 참조 에러(Circular reference error)" 가 발생한 것은 아니다.

이미 우리는 응답 객체인 role을 리턴 시에 toResponseObject()를 사용하여 응답 객체를 제어해주었다. 즉, 여기서 이미 에러는 막은 것이고 @Exclude()를 사용하여 응답 시 위와 같은 불필요한(중복되는) 정보인 rolePermissions제거해 주는 것이다.

그리고, 해당 @Exclude() 데코레이터를 유효하게 하기 위해선 컨트롤러의 액션함수 혹은 컨트롤러 자체에 인터셉터를 주입시키면 되는 것이다.

엔터티에 toResponseObject()와 같은 메서드를 작성하기보다 커스텀 인터셉터로써 구현하는것이 가장 깔끔하고 좋지만, 일단 넘어가고 (오버헤드일 수도 있단 생각...) nestjs/commonClassSerilalizeInterceptor를 사용할 수 있었다.

// role.controller.ts

@UseInterceptors(ClassSerializerInterceptor) // <-- 인터셉터 주입
@Controller('roles')
export class RoleController {
  constructor(private roleService: RoleService) {}
  
  //혹은 업데이트 액션 함수 바로 위에 주입시켜도 무방합니다. 
}

자, 이렇게 하면 원하는 응답 객체를 에러없이 불러올 수 있게 된다.


> 과정 2) 코드 개선을 통한 테이블 최적화

업데이트를 구현하는 서비스로직을 단지 위와 같이 작성해주게 되면, 쿼리 파라미터로 받을 특정 roleid에 따라 permission 값을 수정하려 할 시, role_permissions 테이블의 기존 레코드 role_idnull을 띄우게 된다.

사실, 이건 에러라기보다 당연한 경우다. 우리가, 수정 전 기존의 레코드에 대해 삭제하는 과정을 수행해주지 않았기 때문이다.

하지만, 이렇게 할 시 불필요한 레코드가 점점 쌓이게 되고, 테이블의 크기는 점점 커지게 된다.

즉, 이에 따라 role_id에 따른 수정 전 기존의 레코드는 "삭제"해주고 새로 수정한 레코드는 "추가"해주는 방식을 구현하기로 하였다.


✔ 방법 1) 기존 레코드 전부 삭제 -> 수정된 레코드 전부 추가

// role.service.ts

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

    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.rolePermissions) {
      const rolePermissionIds = data.rolePermissions as unknown as number[];
      const currentRolePermissions = await this.rolePermissionRepository.find({
        where: {
          role: {
            id: id
          }
        },
        relations: ['permission', 'role']
      });
      const permissions = await this.permissionRepository.find({ where: { id: In(rolePermissionIds) },relations: ['rolePermissions'] } );
      const rolePermissions = permissions.map(permission => {
        const rolePermission = new RolePermission();
        rolePermission.permission = permission;
        rolePermission.role = role;
        return rolePermission;
      })

      // Find the old RolePermissions to remove
      role.rolePermissions = [...rolePermissions];
      await this.rolePermissionRepository.remove(currentRolePermissions);
      await this.rolePermissionRepository.save(rolePermissions);

    await this.roleRepository.save(role);
    return role.toResponseObject();
  }

나머지 부분은 전부 동일하고, 수정되어야 할 부분은 아래와 같다.

      const currentRolePermissions = await this.rolePermissionRepository.find({
        where: {
          role: {
            id: id
          }
        },
        relations: ['permission', 'role']
      });
      
      // Find the old RolePermissions to remove
      role.rolePermissions = [...rolePermissions];
      await this.rolePermissionRepository.remove(currentRolePermissions);
      await this.rolePermissionRepository.save(rolePermissions);

말했다시피, 기존의 레코드는 삭제해야한다.

삭제하게 될 currentRolePermissionsRolePermission[]을 타입으로 가지는 배열로써 컨트롤러에서 param으로 받아올 role_id를 통해 해당 role_id에 따른 레코드 배열을 조회한다.

연관관계의 데이터를 불러오기 위해 필수적으로 relations관계를 추가한다.

그 후, role.rolePermissions = [...rolePermissions]를 통해 업데이트를 진행 한 새로운 값들만 role 객체안에 받아준다.

만약, 여기서 아래와 같이 작성하여 기존의 레코드 배열 또한 받게된다면

role.rolePermissions = [...currentRolePermissions, ...rolePermissions]

응답 시 null을 가진 permission 값이 불러와질 것이다.

그 후, 최종적으로 아래의 작업을 통해, 기존 레코드는 role_permissions 테이블에서 제거시켜주고, 업데이트 후의 레코드를 생성시켜준다.

await this.rolePermissionRepository.remove(currentRolePermissions);
await this.rolePermissionRepository.save(rolePermissions);

✔ 방법 2) 온전한 업데이트 과정만을 통한 테이블 최적화

"방법 1)"로 만족하고 끝내고 싶었으나, 뭔가 찜찜하다. 위의 방법은 요청받은 role_id에 따라 permission_id가 기존에 존재하는 값이든 아니든 전부 삭제하고, 업데이트를 통한 새로운 값을 전부 추가(insert)해주는 방식이다.

조금 더 쉽게 말하자면, 현재 role_permissions 테이블이 아래와 같다고 하자.

role_id 1에 대해 permission_id1,2를 갖는 상황이다.

"방법 1)"을 통해 role_id 1에 대해 permission_id1,3의 값으로 수정 요청이 들어온다면 어떻게 될까?

위와 같은 레코드 구조를 가지게 될 것이다.

permission_id 가 [1,2]에서 --> [1,3]으로 수정되었다. permission_id = 1은 수정 전과 후가 동일하다. 그런데도 굳이, 삭제 후 새로운 레코드로 다시 생성해 주어야할까?

불필요한 쿼리문의 호출이라 생각이 들었다. 굳이 불필요한 쿼리는 최대한 날려주지 않는 것이 (만약, 대용량 레코드 건이라 가정했을 경우) 부하를 줄이는데 좋을것임은 틀림없다.

즉, 우리는 같은 role_id에 대해 수정 전(현재 테이블)과 후(업데이트 요청)가 동일한 permission_id를 가지는 경우에 대해선 그대로 유지해주고, 다를 경우(업데이트가 일어난 경우)에 대해서만 기존 레코드 삭제 및 새로운 레코드 추가의 작업을 수행해야 할 것이다.

수정하게 될 업데이트 로직을 작성하기 전, 어떠한 객체가 필요하고, 어떠한 가공을 할 지 먼저 생각해보는 과정은 필수이다.

그럼 변화가 일어난 로직을 확인해보자. ( 다른 부분은 전부 동일 )

// Update role permissions
    
  if (data.rolePermissions) {
      const roleId = id;
      // 결과로 만들어야할 permission Id 목록
      const permissionIds = data.rolePermissions as unknown as number[];
      // 현재 포함되길 원하는 permission 중 있는 거 조회
      const currentPermissions = await this.permissionRepository.find({
        where: {
          id: In(permissionIds)
        },
        relations: ['rolePermissions']
      });
      // 현재 roleId 의 전체 Permission 쌍 조회
      const currentRolePermissions = await this.rolePermissionRepository.find({
        where: {
          role: {
            id: roleId
          }
        },
        relations: ['role', 'permission']
      });
      // 현재 들고 있는 Permission id 목록 생성
      const existPermissionIds: number[] = currentRolePermissions.map(rp => rp.permission.id)
      // 삭제해야할 Permission 목록 생성 (가지고 있는 RolePermission 중 요청에 없는 거)
      const removalRolePermissions: RolePermission[] = currentRolePermissions
        .filter(rp => !permissionIds.includes(rp.permission.id))
      // 현재 들고 있는 RolePermission중 남아있어야 할 것
      const retainedRolePermissions: RolePermission[] = currentRolePermissions.filter(rp => permissionIds.includes(rp.permission.id));
      // 생성해야할 Permission id 목록 생성 (요청 id중 가지고있지 않은 거)
      const newPermissionIds: number[] = permissionIds.filter(id => !existPermissionIds.includes(id))
      // 새로 입력해야 할 rolePermission 생성
      const newRolePermissions = currentPermissions
        .filter(permission => newPermissionIds.includes(permission.id)) // 생성할 PermissionId 목록에 포함되는 permission 만 선택
        .map(permission => {
          const rp = new RolePermission();
          rp.role = role;
          rp.permission = permission;
          return rp;
        })
      role.rolePermissions = [...retainedRolePermissions, ...newRolePermissions];
      // 삭제
      await this.rolePermissionRepository.remove(removalRolePermissions)
      // 생성
      await this.rolePermissionRepository.save(newRolePermissions)
    }

방법 #1에선 currentRolePermissions(현재 테이블에 들고 있는 레코드 값들)을 전부 조회해 삭제하는 작업을 거쳤지만, 방법#2에선 "그대로 유지해야할 값들(retainedRolePermissions)"과 "삭제해야할 값들(removalRolePermissions)" 로 나누어 작업하였다.

또한, 아래 작업을 통해 현재 테이블에 없는 permission_id를 구한 뒤

// 생성해야할 Permission id 목록 생성 (요청 id중 가지고있지 않은 거)
const newPermissionIds: number[] = permissionIds.filter(id => !existPermissionIds.includes(id))

아래의 작업에 사용한다.

const newRolePermissions = currentPermissions
	.filter(permission => newPermissionIds.includes(permission.id)) // 생성할 PermissionId 목록에 포함되는 permission 만 선택
    .map(permission => {
        const rp = new RolePermission();
        rp.role = role;
        rp.permission = permission;
        return rp;
     })

해당 부분이 핵심이고, 어떻게 작성해야할지 난항(?)을 겪었던 부분이다.

currentPermissions는 현재 포함되길 원하는 (요청으로 들어온) permission_id중 존재하는 것을 말한다. 여기서 주의할 점은 role_permissions 테이블에 존재하는 것이 아닌, permission테이블에 존재하는 레코드를 뜻한다.

그렇게 구한 해당 permission_id 값의 배열을 filter 함수를 통해 필터링을 한다. 만약, 요청 permission_id[1,2]이고, 해당 데이터가 전부 permission 테이블에 존재한다고 가정하자. 즉, newPermissionIdspermission_id[1,2]를 가진 객체일 것이다. 그 중 새로 생성해야할 permission_id 배열을 가진 newPermissionIds에서 currentPermissions와 공통된 요소를 포함시킨다.

다음으로, map함수를 이용하여 새로운 permission_id를 가지는 Permission 객체를 순회하며 해당 퍼미션 값을 새로운 생성자 RolePermission에 할당해준다.

이렇게 반환한 RolePermission 객체가 바로 새로 추가될 레코드 값이 되는 것이다.

우리는 기존 레코드 중 제거되어야할 레코드, 업데이트 된 레코드 중 추가해주어야 할 레코드를 구할 수 있고 이를 레포지터리에서 생성 및 삭제 작업을 함으로써 최종 업데이트를 구현할 수 있게 되었다.

만약 특정 role_id = 1permission_id [1,2]에 대해, [1,3]으로의 수정시, 테이블의 변화는 아래와 같을 것이다.

※ 업데이트 전 (기존 테이블)

※ 업데이트 후 (수정된 테이블)

방법#1의 경우 수정된 값이 아닌 동일한 정보를 넘겨주어도 전부 delete 후 insert의 과정을 거치는 것에 비해 방법#2는 굳이 트랜잭션을 시작하지 않으니 불필요한 쿼리를 줄였다고 볼 수 있다.


다음 포스팅 예고

RolePermission 이 "다대다" 관계에 있고, Role 객체가 Permission의 상위 객체라고 했을 때, 어떻게 모델을 구축하고 생성 및 업데이트 구현을 처리할 수 있는지 코드로써 알아보았다.

여기서 마무리를 짓고싶고, 나름 코드나 데이터베이스 측면으로 (본인이 할 수 있는)최선을 만든게 아닌가 싶었지만 언뜻 보기에도 불편해보이는 구석이 한 두 군데가 아닌건 사실이다.

뭐, 그냥 위의 방법으로 끝맺을 수 있지만 우리는 업데이트 하는 과정을 거치기 위해 (즉, 가공하는데 필요한 객체들을 얻기 위해) role 레포지터리에도 접근하였고, permission 레포지터리에도 접근하였고, rolePermission 레포지터리에도 접근하였다. 물론 위의 모델 구축으로써는 당연한 방법이겠지만 서비스 로직이 굉장히 복잡하고 가독성 측면에서도 좋지 않아보인다.
여러 레포지터리에 번갈아가며 접근하다보니, 코드를 작성하면서도 불편함을 느끼기 마련이다.

다음 포스팅(마지막 포스팅이 될겁니다)에선 업데이트의 구현 방법은 동일하게 유지하되 조금 더 생산성있고 가독성 측면을 고려한 코드를 구현해보고자 한다.

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

1개의 댓글

comment-user-thumbnail
2023년 12월 26일

안녕하세요, NestJS 공부하다가 들어오게 되었습니다.
In(permissionIds) 와 같이 앞에 In()을 붙이는 이유가 무엇인가요?

* typeorm의 쿼리를 도와주는 오퍼레이터군요. 찾다가 안 나와서 질문했더니 찾아지네요.ㅎㅎ

답글 달기