프로젝트/장기 프로젝트

부스패치 #3. TypeORM DB 칼럼 스네이크 케이스 변경 대응

Chipmunks 2024. 7. 25. 01:26
728x90

 

안녕하세요.

부스패치 개발자 다람쥐입니다.

 

스네이크 케이스와 카멜 케이스가 모두 있는 DB 칼럼

 

여러 테이블의 칼럼명이 스네이크 케이스와 카멜 케이스가 혼용되어 있었습니다.

금일 모두 스네이크 케이스로 변경하는 작업을 했습니다.

데이터를 새로 적재하는 과정에서 스네이크 케이스로 변경했습니다.

참고로 원천 데이터를 지속적으로 적재하는 곳은 이미 스네이크 케이스를 사용하고 있습니다.

 

Nodejs 애플리케이션에서 어떻게 대응했는지 기술합니다.

 

스네이크 케이스와 카멜 케이스

스네이크 케이스는 'my_name_is_chipmunk' 와 같이 모두 소문자에 단어마다 구분되는 문자가 언더바('_') 입니다.

반면 카멜 케이스는 'myNameIsChipmunk' 와 같이 첫 글자는 소문자로 시작하지만, 다음 단어의 첫 글자는 대문자로 시작합니다.

 

기존 TypeORM 엔티티 칼럼 수정법

부스패치 ORM 라이브러리로 TypeORM 을 사용하고 있습니다.

TypeScript (또는 JavaScript) 언어에선 변수명을 카멜 케이스를 사용합니다.

기본적으로 엔티티 필드명과 데이터베이스 칼럼명과 동일합니다.

 

import { Entity, Column, PrimaryColumn } from "typeorm";

@Entity("quick_sale")
export class QuickSale {
  @PrimaryColumn("text")
  articleNo!: string;

  @Column("text")
  naverId!: string;

  @Column("text")
  danjiName!: string;
  
  ...
}

 

위 필드명으로 카멜 케이스의 데이터베이스 칼럼 ('articleNo', 'naverId', 'danjiName')을 조회하게 됩니다.

따라서 스네이크 케이스로 변경할 경우 직접 명시해야 합니다.

import { Entity, Column, PrimaryColumn } from "typeorm";

@Entity("quick_sale")
export class QuickSale {
  @PrimaryColumn("text", { name: "article_no" })
  articleNo!: string;

  @Column("text", { name: "naver_id" })
  naverId!: string;

  @Column("text", { name: "danji_name" })
  danjiName!: string;
  
  ...
}

 

분석 테이블을 이용하는 경우 테이블 칼럼 수가 30개는 족히 넘어갑니다.

알파벳 A-Z 보다 많은 수의 각 필드에 { name: "XXX_XXX" } 을 설정하는 건 고통입니다.

 

추가로 필터 쿼리를 위한 Dynamic Query 작성 시 칼럼명을 직접 명시하고 있습니다.

엔티티 필드명과 마찬가지로 수정 범위에 들어갑니다.

엔티티와 필터 쿼리까지 40개 이상의 변경 범위가 들어갑니다.

엔티티 필드명과 맞지 않는 경우 애플리케이션 실행이 되지 않습니다. 🥺

 

const {
  ...,
  supplyPyeong_min,
  supplyPyeong_max,
  ...,
} = req.query;

const queryBuilder = await myDataSource
  .getRepository(QuickSale)
  .createQueryBuilder("quicksale")
  .leftJoinAndSelect("quicksale.XXX", "XXX");

let selectedQuickSale = await queryBuilder.select();

...

selectedQuickSale.andWhere(
  new Brackets((qb) => {
    if (supplyPyeong_min != undefined) {
      qb.andWhere("quicksale.supply_pyeong >= :supplyPyeong_min", {
        supplyPyeong_min,
      });
    }

    if (supplyPyeong_max != undefined) {
      qb.andWhere("quicksale.supply_pyeong <= :supplyPyeong_max", {
        supplyPyeong_max,
      });
    }
  })
);

...

 

스네이크 케이스 칼럼 자동 대응

스네이크 케이스 칼럼을 자동으로 대응하는 법은 없을까요?

TypeORM 에서 NamingStrategy를 지원합니다.

'typeorm-naming-strategies' 라이브러리를 설치합니다.

$ yarn add typeorm-naming-strategies

 

TypeORM DataSource 설정 시 추가합니다.

import { DataSource } from "typeorm";
import { SnakeNamingStrategy } from "typeorm-naming-strategies";

export const myDataSource = new DataSource({
  ...
  namingStrategy: new SnakeNamingStrategy(),
  ...
});

 

이후 name 속성 설정 없이 엔티티에서 카멜케이스 필드명을 그대로 사용할 수 있습니다.

엔티티 클래스에서 어노테이션을 일일이 수정하지 않아도 되어 편리해졌습니다.

import { Entity, Column, PrimaryColumn } from "typeorm";

@Entity("quick_sale")
export class QuickSale {
  @PrimaryColumn("text")
  articleNo!: string;

  @Column("text")
  naverId!: string;

  @Column("text")
  danjiName!: string;
  
  ...
}

 

SnakeNamingStrategy 동작

snake-naming.strategy.d.ts 타입 정의 파일을 확인합니다.

import { DefaultNamingStrategy, NamingStrategyInterface } from 'typeorm';
export declare class SnakeNamingStrategy extends DefaultNamingStrategy implements NamingStrategyInterface {
    tableName(className: string, customName: string): string;
    columnName(propertyName: string, customName: string, embeddedPrefixes: string[]): string;
    relationName(propertyName: string): string;
    joinColumnName(relationName: string, referencedColumnName: string): string;
    joinTableName(firstTableName: string, secondTableName: string, firstPropertyName: string, secondPropertyName: string): string;
    joinTableColumnName(tableName: string, propertyName: string, columnName?: string): string;
    classTableInheritanceParentColumnName(parentTableName: any, parentTableIdPropertyName: any): string;
    eagerJoinRelationAlias(alias: string, propertyPath: string): string;
}

 

typeorm에서 제공하는 NamingStrategyInterface를 구현하고 있습니다.

테이블명, 칼럼명, 관계명, 조인 칼럼명 등을 처리합니다.

 

snake-naming-strategies 라이브러리 내부 코드는 간단합니다.

typeorm에서 제공하는 StringUtils을 사용하여 스네이크 케이스로 변환합니다.

모든 매개변수 끼리 언더바('_')로 연결한 다음, 'snakeCase' 메소드를 호출하는 식입니다.

...
const typeorm_1 = require("typeorm");
const StringUtils_1 = require("typeorm/util/StringUtils");
class SnakeNamingStrategy extends typeorm_1.DefaultNamingStrategy {
    tableName(className, customName) {
        return customName ? customName : StringUtils_1.snakeCase(className);
    }
    columnName(propertyName, customName, embeddedPrefixes) {
        return (StringUtils_1.snakeCase(embeddedPrefixes.concat('').join('_')) +
            (customName ? customName : StringUtils_1.snakeCase(propertyName)));
    }
    relationName(propertyName) {
        return StringUtils_1.snakeCase(propertyName);
    }
    joinColumnName(relationName, referencedColumnName) {
        return StringUtils_1.snakeCase(relationName + '_' + referencedColumnName);
    }
    joinTableName(firstTableName, secondTableName, firstPropertyName, secondPropertyName) {
        return StringUtils_1.snakeCase(firstTableName +
            '_' +
            firstPropertyName.replace(/\./gi, '_') +
            '_' +
            secondTableName);
    }
    joinTableColumnName(tableName, propertyName, columnName) {
        return StringUtils_1.snakeCase(tableName + '_' + (columnName ? columnName : propertyName));
    }
    classTableInheritanceParentColumnName(parentTableName, parentTableIdPropertyName) {
        return StringUtils_1.snakeCase(parentTableName + '_' + parentTableIdPropertyName);
    }
    eagerJoinRelationAlias(alias, propertyPath) {
        return alias + '__' + propertyPath.replace('.', '_');
    }
}
...

 

TypeORM의 StringUtils 코드의 snakeCase 메소드는 다음과 같습니다.

/**
 * Converts string into snake_case.
 *
 */
function snakeCase(str) {
    return (str
        // ABc -> a_bc
        .replace(/([A-Z])([A-Z])([a-z])/g, "$1_$2$3")
        // aC -> a_c
        .replace(/([a-z0-9])([A-Z])/g, "$1_$2")
        .toLowerCase());
}
exports.snakeCase = snakeCase;

 

두 번째 replace 코드부터 볼까요?

카멜 케이스를 스네이크 케이스로 변환하는 정규식입니다.

'articleNo' 에서 'eN'을 'e_n'으로 변환합니다.

따라서 'article_no' 가 되는 식입니다.

 

첫 번째 replace 코드는 연속된 대문자를 분리합니다.

왜 필요한지 의문이었어요.

아래 케이스를 볼까요?

TestSnakeCase11_MabyISTheBEST_SnakeIS22case_IS33CaseSNAKE00

> test_snake_case11_maby_is_the_best_snake_is22case_is33_case_snake00

TYPEORMShouldExecute

> typeorm_should_execute

 

 

IS 같은 be 동사가 있거나,

고유명사가 대문자로 포함된 경우를 변환합니다.

 

'STh' -> 'S_Th'

'MSh' -> 'M_Sh'

 

으로 다음 단어의 첫 대문자와 고유 명사 (또는 be 동사)를 분리하고 있네요.

 

마무리

패키지에서 TypeORM의 StringUtils을 이용한 게 인상적이었습니다.

TypeORM 관계자가 만들었을 수도 있고,

또다른 누군가가 StringUtils을 이용하다 이를 오픈 소스 패키지로 만들었을 수도요!

 

정규식을 표현할 때, 위의 주석으로 간단한 케이스 설명을 표시해 두는 것도 좋았습니다.

주석이 필요한 곳을 한 수 배웠네요~

 

데이터 전처리 파이썬 코드를 모두 뒤져가며

데이터 칼럼 작업을 해준 팀원분께 박수를 보냅니다...

 

오늘의 교훈 : DB 칼럼은 처음부터 스네이크 케이스로 만들자!