[Nest, Typeorm] ManyToMany 관계 개선 #3 (feat. String Unique Key를 통한 코드 개선 )

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

시작하기에 앞서

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


이전 포스팅 클릭 #1 ✔
이전 포스팅 클릭 #2 ✔


이전 포스팅에서 우린 RolePermission"ManyToMany" 관계를 @ManyToMany없이 중간 테이블(엔터티)을 직접 두어 구현하였다. 또한, 중간 테이블(role_permissions)에 업데이트된 permission_id수정하는데 있어서 테이블의 레코드를 어떻게 관리해야하는지 알아보고 데이터베이스적으로 최적(?)의 방법을 시도해보았다.

이번 포스팅에선 이전 포스팅의 마지막에 언급하였던 "코드" 측면에서의 문제점을 바탕으로 조금 더 낫고, 생산성있는 코드를 만들어보고자 한다.


💥 String Unique Key를 통한 코드 개선

> Auto-Increment key가 정답일까?

지금까지 우린 RolePermission 엔터티에서 id 값을 @PrimaryGeneratedColumn을 사용해서 auto-incrementpk로써 설정해주었다.

// rolePermission.entity.ts

@Entity('role_permissions')
export class RolePermission {
  @PrimaryGeneratedColumn()
  id: number;

  // ... ...
}

이에 따라, 직접 id값에 일정한 작업을 수행해주지 않더라도 고유성 및 중복방지에있어 효과를 얻게 된다. 또한 큰 장점으로써 "자동 증가 열 (auto-increment)"의 효과를 볼 수 있게 한다. 새로운 레코드가 추가될 때마다 값이 자동으로 증가하므로 레코드의 순서를 유지하거나 특정 조건에 따라 정렬된 결과를 쉽게 얻을 수 있다.

하지만, "단점?"또한 존재한다.

사실, 단점이라고 단정짓기엔 솔직히 스스로도 와닿진 않고 (아직 대용량의 트래픽을 경험해 본적이 없으므로) 쿼리 성능 측면에서 단점을 얘기하는 것이 목적은 아니지만 아래와 같은 문제가 존재하기 마련이다.


  • 다시 재정렬을 하지 않는 이상, auto-increment로 생성한 id값의 경우, 삭제된 레코드의 id를 재사용하지 않는다. 즉, 삭제와 생성 작업이 테이블에서 많이 수행되는 경우 관리가 힘들어질 수도 있다.

  • 일반적으로 테이블은 pk로 설정한 id를 통해 "인덱스"를 타게 된다. 만약, 테이블의 레코드가 계속 쌓이는 경우 인덱스를 통한 정렬에 있어 성능 저하를 초래할 수 있다.


여기까지 쿼리의 성능 및 데이터베이스의 관리 측면에서 본 @PrimaryGeneratedKey, 즉 auto-increment를 통한 pk의 문제이다.

사실 우리의 코드에서 위의 방법을 적용한다 한들 큰 문제가 되지 않을지도 모른다.

하지만, 업데이트의 요청이 굉장히 빈번하게 일어난다는 가정을 하면 썩 좋지 않을 방법일 수도 있다. 또한, auto-increment를 통한 pk가 꼭 필요한 테이블이 존재하는 반면(주 로직과 관련된 메인 테이블?) 우리가 지금 관리하려 하는 role_permissions 테이블의 경우 꼭 필요한 조건은 아니라 본다. 해당 테이블은 매핑을 위한 중간 테이블이고, 일정한 정렬이 필요하진 않기 때문이다.

또한 밑에서 보게 될 수정된 코드를 확인해보면, 큰 차이는 없을지 몰라도 레코드에 접근하는 과정에 있어, 훨씬 수월하게 접근할 수 있게 된다.

(이것이 주 목표였다. 코드적으로 더 개선을 할 수 있지 않을까 기대하였다.)


> String Unique Key를 통해 개선해보자.

lpad를 사용한 스트링 유니크 키 생성

예전에 "Cursor-based-pagination(커서 기반 페이지네이션)"을 구현할 당시 커스텀 커서값을 sql 내장함수인 lpad를 사용해 만들어낼 수 있었다.

이번에도 동일한 접근을 통해 id 값을 "String Unique Key"로 만들고자 한다.

접근은 아래와 같다.

  • roleId 7자리: lpad(roldId, 7, "0")
  • permissionId 7자리: lpad(permissionId, 7, "0")

@BeforeInsert()를 통한 id값 전처리 및 생성

아래는 typeorm의 @BeforeInsert()를 사용한 수정된 RolePermission 엔터티이다.

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

@Entity('role_permissions')
export class RolePermission {
  @PrimaryColumn()
  id: string;

  @ManyToOne(() => Role, role => role.rolePermissions, { onDelete: 'CASCADE' })
  role: Role;
  
  @ManyToOne(() => Permission, permission => permission.rolePermissions, { onDelete: 'CASCADE'})
  permission: Permission;

  @BeforeInsert()
  private beforeInsert() {
    const roleId = this.role.id;
    const permissionId = this.permission.id;
    this.id = String(roleId).padStart(7, "0") + String(permissionId).padStart(7, "0");
  }
} 

간단히 typeorm에서 제공하는 @BeforeInsert()의 역할및 쓰임에 대해 짚고 넘어가자.

@BeforeInsert : 해당 데코레이터는 Entity에 새로운 row를 추가하기 전에 실행되어야하는 메서드에 주입할 수 있다. 이를 통해 새로운 row가 테이블에 추가되기 전 수행되어야하는 전처리에 해당하는 역할을 수행해낼 수 있다.

우리의 경우는 @BeforeInsert를 사용함으로써, 또한 해당 로직안에 this.id값을 lpad(js의 padstart() 사용)를 통해 조합한 문자열로 생성하게끔 함으로써, id값이 인서트 될시에 "Unique String PK" 를 얻게 될 수 있는 것이다.


✔ 서비스 로직 (update() 함수) 개선

크게 달라지는 것은 없지만 원하는 수정 작업을 수행하는데 있어 필요한 레코드 데이터를 얻는 방법에 변화가 생길 것이다.

여기서 필요한 레코드 데이터란 "기존 테이블에서 유지될 레코및 사라져야할 레코드", 그리고 "요청에따라 추가되어야할 새로운 레코드" 등을 의미한다.

그럼 코드를 통해 확인해보자. 전체를 볼 필요는 없을 것이다.

    // 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']
      });
      
      const requestRolePermissionIds: string[] = currentPermissions.map((rp) => {
        const role_id = roleId;
        const permission_id = rp.id;
        const uniquePrimaryKey: string = String(role_id).padStart(7,"0") + String(permission_id).padStart(7,"0");
        return uniquePrimaryKey;
      });

      const currentRolePermission: RolePermission[] = await this.rolePermissionRepository.find({
        relations: ["role", "permission"]
      });

      const existPermissionIds: number[] = currentRolePermission.map(rp => rp.permission.id);

      const retainedRolePermissions: RolePermission[] = await this.rolePermissionRepository.find({
        where: {
          id: In(requestRolePermissionIds)
        },
        relations: ['role', 'permission']
      });

      const removalRolePermissions: RolePermission[] = await this.rolePermissionRepository.find({
        where: {
          id: Not(In(requestRolePermissionIds))
        },
        relations: ['role', 'permission']
      });

      
      const newPermissionIds: number[] = permissionIds.filter(id => !existPermissionIds.includes(id));

      const newRolePermissions: RolePermission[] = currentPermissions
        .filter(permission => newPermissionIds.includes(permission.id))
        .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)
    }

분명 이것보다 더 좋은 코드가 있을 것이라 생각이 들지만, 일단 위와 같이 구현해 볼 수 있었다. 코드의 양이 더 줄어든 것도 개선의 요지라 할 수 있지만, 구현을 하는데 있어 더 편한 방법으로 접근할 수 있게 끔 만드는 것만으로도 개선이라 할 수 있지 않을까 싶다.

기존엔 retainedRolePermissionsremovalRolePermissions와 같은 레코드 배열을 얻는데 있어, 요청된 롤 아이디인 roleId를 통해 얻은 배열을 직접 filter() 함수를 사용하여 걸러내주었지만, "String Unique Key"를 사용함으로써 id 값만으로 즉시 찾아낼 수 있게 되었다.

먼저, 요청 받은 permission_idrole_id에 따른 조합된 "String Unique Key"값의 배열을 구하고, 해당 값은 id를 의미하게 되므로 즉시 RolePermission 레포지터리에서 where절의 In 옵션을 사용하여 조회할 수 있게 된다.

하지만, 결국 permission의 값을 업데이트해주는 부분인

const newRolePermissions: RolePermission[] = currentPermissions
   .filter(permission => newPermissionIds.includes(permission.id))
   .map(permission => {
    	const rp = new RolePermission();
        rp.role = role;
        rp.permission = permission;
        return rp;
    })

해당 파트에 대한 조금 더 좋은 개선안은 찾지 못하였다.

결국, permission의 값을 업데이트 한다고 하면, rp.permission= permission을 수행해야된다고 보았고, 그러기 위해선 결국 permissionId를 가진 배열또한 이용할 수 밖에 없었다. 즉, newPermissionIds, newPermissionIds 배열을 얻기 위한 existPermissionIds와 같은 배열은 여전히 사용할 수 밖에 없었다.

더 좋은 개선안이 있다면 추후 올려볼 예정이다.

✔ 수정된 role_permissions 테이블 확인

아래와 같이 roleId=12에 대해 permissionId = [1,4]를 가지는 기존 테이블을

permission_id = [1,3,5]로 수정하게 된다면, 아래와 같은 레코드를 가지는 테이블 수정을 확인할 수 있다.


생각정리 및 마무리 ...

"ManyToMany 관계 개선하기"의 마지막 포스팅이다. 더 좋은 코드를 찾을 수 있을지도 모르겠지만 이번 과정을 통해서 ManyToMany 관계인 두 엔터티의 구조를 어떠한 방법으로 구축하고, "업데이트 수행 최적화"에 포커스를 맞춰 디비적으로든, 코드적으로든 최선의 개선을 해보는 작업을 수행하였다.

정말 단순한 수정 작업임이 틀림없지만, 예상과 다르게 생각해야할 내용이 다양한 관점으로 존재한다는 것을 느꼈다. 단순히 단일 테이블의 특정 레코드를 업데이트하는 것과 비교되는 난이도의 작업이었고 동시에 얻는 것 또한 많은 시간이었다.

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

0개의 댓글