Skip to content

BE 개발 스택과 기술적 고민

Hyunjun KIM edited this page Nov 16, 2024 · 1 revision

개발 스택

메인 프레임워크

NestJS vs. Express.js

코드 퀄리티와 일관된 디자인 패턴 적용을 위해 NestJS 선택

의견 강화 및 추가 고려 사항:

  • 모듈러 아키텍처: NestJS는 Angular에서 영감을 받은 모듈러 구조를 가지고 있어 코드의 재사용성과 유지보수성이 높습니다. 이로 인해 대규모 애플리케이션에서도 코드 구조를 명확하게 유지할 수 있습니다.
  • 의존성 주입(DI): NestJS는 내장된 DI 컨테이너를 제공하여 컴포넌트 간의 결합도를 낮추고 테스트 용이성을 높입니다.
  • 데코레이터 활용: TypeScript의 데코레이터 기능을 적극 활용하여 코드의 가독성과 선언적 프로그래밍이 가능합니다.
  • 유닛 테스트 및 통합 테스트 지원: NestJS는 테스트 프레임워크와의 통합이 원활하여 안정적인 코드 품질 관리에 도움이 됩니다.
  • 커뮤니티와 생태계: 활발한 커뮤니티와 풍부한 공식 및 서드파티 모듈을 통해 개발 생산성을 높일 수 있습니다.

Express.js를 선택하지 않은 이유에 대한 고려:

  • 구조의 자유로움: Express.js는 경량 프레임워크로 유연성이 높지만, 프로젝트 규모가 커질수록 코드 구조 관리가 어려울 수 있습니다.
  • 일관성 부족: 개발자마다 코드 스타일이나 패턴이 달라질 수 있어 팀 내에서 일관된 코드베이스 유지가 어려울 수 있습니다.

Nestia? NestJS-tRPC? 아예 안쓰거나?

  • FE-BE 간 Type-safety 보장? 추가 문서 없이 API의 리턴 타입을 바로 추론 가능하게 하는 듯

  • https://github.com/samchon/nestia

    • 국내 개발자 분이 만드심!

    https://kscodebase.tistory.com/663

  • Nestia 성능이 이상함… 이게 말이 되나

    • Runtime validator is 20,000x faster than class-validator
    • JSON serialization is 200x faster than class-transformer
    • 같은 분이 작성하신 typia 라이브러리가 하는 일인듯
    • https://typia.io/docs/
  • 학습 곡선이 좀 있어보이는데 적용하면 좋아보이긴 함

  • 도와줘 GPT

    Nestia:

    • 성능 최적화: Nestia는 NestJS 애플리케이션의 성능을 향상시키는 데 초점을 맞추고 있습니다.
    • 자동 SDK 생성: 서버 사이드의 컨트롤러를 기반으로 클라이언트 사이드 코드를 자동으로 생성해주는 기능을 제공합니다.
    • 타입 안전성 보장: 타입스크립트의 기능을 활용하여 클라이언트와 서버 간의 타입 안전성을 확보합니다.
    • 보일러플레이트 코드 감소: 반복되는 코드를 줄여 개발 속도를 높일 수 있습니다.

    NestJS tRPC:

    • 엔드 투 엔드 타입 안전성: tRPC를 사용하면 백엔드부터 프론트엔드까지 전체적으로 타입 안전한 API를 구축할 수 있습니다.
    • 간소화된 API 개발: RESTful 엔드포인트나 GraphQL 없이도 간단하게 API를 만들 수 있습니다.
    • 유연성: NestJS에 국한되지 않고 다양한 프론트엔드 프레임워크와 함께 사용할 수 있어, 향후 프레임워크를 변경하더라도 유연하게 대응할 수 있습니다.
    • 커뮤니티 지원: tRPC는 성장하는 커뮤니티를 가지고 있어 자료나 지원을 받기 용이합니다.

    고려 사항:

    • 프로젝트 요구사항: 성능 최적화와 자동 SDK 생성이 중요하다면 Nestia가 더 적합할 수 있습니다.
    • 개발 스타일: 엔드 투 엔드 타입 안전성과 함수형 프로그래밍 스타일을 선호한다면 tRPC가 더 맞을 수 있습니다.
    • 학습 곡선: 팀의 기존 지식과 경험에 따라 배우기 쉬운 도구를 선택하는 것이 좋습니다.

    추천 사항:

    • 프로토타입 제작: 가능하다면 두 옵션 모두로 작은 규모의 프로토타입을 만들어보고 어느 것이 더 적합한지 평가해보세요.
    • 커뮤니티 및 지원: 두 도구의 문서화 수준과 커뮤니티 활동을 확인하여 필요할 때 도움을 받을 수 있는지 확인하세요.
    • 장기적인 유지보수: 꾸준히 업데이트되고 관리되는 도구인지 확인하여 장기적인 안정성을 고려하세요.

데이터베이스와 ORM

PostgreSQL vs. MySQL

  1. JSONB 데이터 타입:
    • 이진 저장: JSON 데이터를 이진 형식으로 저장하여 검색과 인덱싱이 빠릅니다.
    • 인덱싱 가능: GIN, GiST 등 다양한 인덱스를 통해 JSON 필드 내의 특정 키나 값을 기반으로 인덱싱할 수 있습니다.
    • 효율적인 쿼리: JSON 데이터를 대상으로 한 다양한 연산자와 함수(>, >>, @>, #>, jsonb_path_query 등)를 제공합니다.
  2. 풍부한 JSON 함수 및 연산자:
    • 데이터 변환 및 조작: JSON 데이터를 부분적으로 업데이트하거나 병합하는 기능을 제공합니다.
    • 고급 쿼리 지원: JSON 경로 쿼리, 존재 여부 검사, 배열 요소 접근 등 다양한 기능을 지원합니다.
  3. 성능 및 안정성:
    • 최적화된 저장 및 검색: JSON 데이터를 처리하는 데 있어 성능 최적화가 잘 되어 있습니다.
    • 대용량 데이터 처리: 대량의 JSON 데이터를 효율적으로 관리할 수 있습니다.
  • MySQL의 JSON 지원:
    • MySQL은 5.7 버전부터 JSON 데이터 타입을 지원하기 시작했습니다.
    • JSON 데이터를 텍스트로 저장하지만, 내부적으로는 최적화를 통해 성능을 향상시켰습니다.
    • 그러나 PostgreSQL에 비해 JSON 함수와 연산자의 다양성이 부족하며, 인덱싱 옵션도 제한적입니다.
  • 제한 사항:
    • 인덱싱의 제약: MySQL에서는 JSON 필드 내의 특정 키에 대한 인덱싱이 제한적입니다.
    • 함수 및 연산자 부족: PostgreSQL만큼 풍부한 JSON 조작 기능을 제공하지 않습니다.

TypeORM vs. etc.

생산성을 위한 ORM 활용. NestJS와 통합이 가장 잘되는 TypeORM 선택

의견 강화 및 추가 고려 사항:

  • 완벽한 NestJS 통합:
    • 모듈화된 구조: TypeORM은 NestJS와의 통합을 고려하여 설계되어 설정과 사용이 용이합니다.
    • 데코레이터 기반 엔티티 정의: 데코레이터를 사용하여 직관적이고 가독성 높은 엔티티를 정의할 수 있습니다.
  • 다양한 데이터베이스 지원:
    • 멀티 데이터베이스 호환성: PostgreSQL뿐만 아니라 MySQL, MariaDB 등 다양한 DB를 지원하여 데이터베이스 변경에 유연합니다.
  • 활발한 커뮤니티와 문서화:
    • 풍부한 자료: 공식 문서와 예제가 잘 갖추어져 있어 학습 곡선이 완만합니다.
    • 커뮤니티 지원: StackOverflow 등에서 문제 해결에 대한 지원을 받기 용이합니다.
  • 기능적 장점:
    • Lazy Loading 및 Eager Loading 지원: 관계된 데이터를 효율적으로 로딩할 수 있습니다.
    • 트랜잭션 관리: 복잡한 트랜잭션을 손쉽게 관리할 수 있습니다.
    • 마이그레이션 도구: 데이터베이스 스키마 변경을 안전하고 효율적으로 적용할 수 있습니다.

다른 ORM과의 비교:

  • Sequelize: JavaScript 기반의 ORM으로, TypeScript 지원이 제한적이며 NestJS와의 통합이 TypeORM만큼 원활하지 않습니다.
  • Prisma: 최근 각광받는 ORM이지만, 데코레이터를 사용하지 않고 스키마 파일로 모델링하므로 NestJS와의 일관성이 떨어질 수 있습니다.

객체(DTO/DAO/Entity) 관련 라이브러리

  • class-validator
    • 데코레이터 기반의 유효성 검사
      • 선언적 유효성 검사: 클래스 속성에 데코레이터를 사용하여 유효성 검사 규칙을 정의함으로써 코드의 가독성과 유지보수성을 높입니다.
      • 풍부한 내장 검사기: 이메일 형식 검사, 최소/최대 길이, 숫자 범위 등 다양한 내장 검사기를 제공하여 별도의 로직 없이 유효성 검사가 가능합니다.
    • NestJS와의 통합
      • 파이프(Pipe) 활용: NestJS의 파이프 기능과 결합하여 컨트롤러 진입 전에 자동으로 유효성 검사를 수행할 수 있습니다.
      • 에러 메시지 커스터마이징: 유효성 검사 실패 시 반환되는 에러 메시지를 사용자 정의하여 사용자 경험을 개선할 수 있습니다.
  • class-transformer
    • 객체 변환 및 직렬화
      • 플레인 객체를 클래스 인스턴스로 변환: 입력 받은 JSON 데이터를 DTO(Data Transfer Object)로 변환하여 타입 안정성과 코드 자동 완성 기능을 활용할 수 있습니다.
      • 노출 필드 제어: @Exclude, @Expose 데코레이터를 사용하여 응답 객체에서 노출할 필드와 숨길 필드를 제어할 수 있습니다.
    • 데이터 타입 변환
      • 자동 타입 캐스팅: 문자열을 숫자나 불리언 등으로 자동 변환하여 데이터 일관성을 유지할 수 있습니다.
      • 중첩 객체 변환 지원: 복잡한 객체 구조에서도 재귀적으로 변환이 가능하여 계층적인 데이터 구조를 손쉽게 관리할 수 있습니다.
  • nestia 쓰면 둘 다 안써도됨 → typia 내장

WebSocket & Socket.io

WebSocket을 사용하는 이유:

  1. 실시간 양방향 통신:
    • 양방향 통신: 클라이언트와 서버 모두 데이터를 주고받을 수 있어, 서버에서 클라이언트로 즉각적인 알림이나 업데이트를 보낼 수 있습니다.
  2. 낮은 지연 시간:
    • 빠른 데이터 전송: 연결 설정에 대한 오버헤드가 없으므로, 데이터 전송이 빠르고 지연 시간이 적습니다.
    • 실시간 반응성: 사용자 간의 변경 사항이 즉시 반영되어 실시간 협업에 적합합니다.
  3. 효율적인 리소스 사용:
    • 낮은 네트워크 부하: HTTP 요청의 헤더 오버헤드가 없으므로, 네트워크 트래픽이 감소합니다.
    • 서버 부하 감소: 지속적인 연결로 인해 연결 설정과 해제에 따른 부하가 줄어듭니다.
  4. CRDT와의 시너지 효과:
    • 즉각적인 업데이트 전파: CRDT 알고리즘은 변경 사항을 다른 클라이언트에 빠르게 전파해야 하므로, WebSocket의 실시간 통신이 도움이 됩니다.
    • 충돌 해결의 용이성: 실시간으로 변경 사항을 교환함으로써 충돌을 최소화하고, 발생한 충돌도 신속하게 해결할 수 있습니다.

REST API를 사용하는 경우의 한계:

  1. 단방향 통신:
    • 클라이언트 중심: REST는 클라이언트가 요청하고 서버가 응답하는 방식으로, 서버에서 클라이언트로의 실시간 푸시가 어렵습니다.
    • 지속적인 연결: WebSocket은 서버와 클라이언트 간에 지속적인 연결을 유지하여 실시간 데이터 교환이 가능합니다.
    • 폴링 필요: 실시간성을 확보하기 위해서는 클라이언트가 주기적으로 서버에 폴링해야 하며, 이는 비효율적입니다.
  2. 높은 지연 시간과 오버헤드:
    • 반복적인 연결 설정: 각 요청마다 새로운 HTTP 연결을 설정하므로 오버헤드가 발생합니다.
    • 지연 시간 증가: 폴링 주기에 따라 변경 사항 반영에 지연이 생길 수 있습니다.
  3. 리소스 낭비:
    • 불필요한 요청 증가: 폴링 방식은 변경 사항이 없더라도 서버에 요청을 보내므로 서버와 네트워크 리소스를 소모합니다.

**Socket.io 사용:**

  • 실시간 양방향 통신 지원
    • WebSocket 기반의 추상화: 클라이언트와 서버 간의 실시간 통신을 쉽게 구현할 수 있도록 WebSocket을 추상화하여 제공합니다.
    • 자동 폴백 메커니즘: 클라이언트의 환경에 따라 WebSocket이 지원되지 않는 경우 HTTP Long Polling 등으로 자동 전환하여 호환성을 보장합니다.
    • 이벤트 기반 통신: 클라이언트와 서버 간에 커스텀 이벤트를 정의하여 명확하고 관리하기 쉬운 통신 구조를 만들 수 있습니다.
  • NestJS와의 원활한 통합
    • @nestjs/platform-socket.io 패키지: NestJS에서 공식적으로 지원하는 모듈로, 기존의 모듈과 컨트롤러 구조를 그대로 활용할 수 있습니다.
    • 미들웨어 및 가드 적용: 인증 및 권한 부여 로직을 미들웨어나 가드로 적용하여 보안성을 높일 수 있습니다.
  • 스케일링 및 확장성
    • 클러스터링 지원: 여러 개의 서버 인스턴스 간에 세션 정보를 공유할 수 있어 수평 확장이 용이합니다.
    • Redis 어댑터: Redis를 사용하여 다중 서버 환경에서도 실시간 통신을 안정적으로 유지할 수 있습니다.
  • 고려 사항
    • 버전 호환성 관리: 클라이언트와 서버의 Socket.io 버전이 일치해야 하며, 버전 차이로 인한 호환성 문제에 주의해야 합니다.
    • 네임스페이스와 룸 관리: 대규모 애플리케이션에서는 네임스페이스와 룸을 체계적으로 관리하여 성능과 유지보수성을 확보해야 합니다.

기술적 고민

에디터 내용 저장

  • NoSQL? → 진짜 개발도 빠르고 쓰기도 빨라서 프로토타이핑은 좋을 것 같긴 함

    Graph Data Structure in Relational Database.pdf

    • 그런데 동준님이 올려주신 문서 보면, 저자는 RDB만의 장점이 더 중요하다고 보는 듯
    • 트랜잭션, 마이그레이션, 스케일링 …

https://www.slideshare.net/slideshow/models-for-hierarchical-data/4179181#1

https://orkhan.gitbook.io/typeorm/docs/tree-entities

  • Postgres jsonb + TypeORM 인접 리스트 연결 방식
import { Entity, Column, PrimaryGeneratedColumn, ManyToOne, OneToMany } from 'typeorm';

@Entity() export class DocumentNode { @PrimaryGeneratedColumn() id: number;

@Column() type: string;

@Column('jsonb', { nullable: true }) attrs: any;

@Column('text', { nullable: true }) text: string;

@ManyToOne(() => DocumentNode, node => node.children) parent: DocumentNode;

@OneToMany(() => DocumentNode, node => node.parent) children: DocumentNode[]; }

  • TypeORM nested set 예제 코드
    • 읽기에 매우 강하지만, 쓰기에 최악인듯
import {
    Entity,
    Tree,
    Column,
    PrimaryGeneratedColumn,
    TreeChildren,
    TreeParent,
    TreeLevelColumn,
} from "typeorm"

@Entity() @Tree("nested-set") export class Category { @PrimaryGeneratedColumn() id: number

@Column()
name: string

@TreeChildren()
children: Category[]

@TreeParent()
parent: Category

}

  • TypeORM Materialized Path 예제 코드
    • 사용 자체는 nested set 이랑 같은데, 좀 더 간단하고 효율적이라는 것 같음
import {
    Entity,
    Tree,
    Column,
    PrimaryGeneratedColumn,
    TreeChildren,
    TreeParent,
    TreeLevelColumn,
} from "typeorm"

@Entity() @Tree("materialized-path") export class Category { @PrimaryGeneratedColumn() id: number

@Column()
name: string

@TreeChildren()
children: Category[]

@TreeParent()
parent: Category

}

  • TypeORM Closure table 예제 코드

    • 성민님이 올려주신 걸 ORM 단에서 간단하게 작성할 수 있음
    import {
        Entity,
        Tree,
        Column,
        PrimaryGeneratedColumn,
        TreeChildren,
        TreeParent,
        TreeLevelColumn,
    } from "typeorm"
    

    @Entity() @Tree("closure-table") export class Category { @PrimaryGeneratedColumn() id: number

    @Column()
    name: string
    
    @TreeChildren()
    children: Category[]
    
    @TreeParent()
    parent: Category
    

    }

    • 아래와 같이 인접한 부모 자식 테이블을 직접 지정할 수 있는듯
    @Tree("closure-table", {
        closureTableName: "category_closure",
        ancestorColumnName: (column) => "ancestor_" + column.propertyName,
        descendantColumnName: (column) => "descendant_" + column.propertyName,
    })
    

GPT가 말아주는 각 방식의 장단점

Adjacency List

  • 장점:
    • 구현이 간단하고 이해하기 쉽습니다.
    • 부모-자식 관계를 나타내기 위해 자기 참조 외래 키를 사용합니다.
  • 단점:
    • 깊은 트리를 한 번에 로드하기 어렵습니다. 이는 조인의 제한 때문입니다.
    • 재귀적인 쿼리가 필요하여 성능 이슈가 발생할 수 있습니다.
  • 로우 레벨 차이:
    • 테이블 내에서 부모를 가리키는 외래 키 컬럼을 사용합니다.
    • 각 노드는 자신의 부모 ID를 저장하며, 이를 통해 트리 구조를 형성합니다.

Nested Set

  • 장점:
    • 읽기 성능이 매우 우수합니다.
    • 전체 트리나 서브트리를 한 번의 쿼리로 가져올 수 있습니다.
  • 단점:
    • 쓰기 작업(삽입, 삭제, 이동)이 복잡하고 비용이 높습니다.
    • 트리 구조 변경 시 모든 노드의 좌우 값(left, right)을 업데이트해야 합니다.
    • 다중 루트를 지원하지 않습니다.
  • 로우 레벨 차이:
    • 각 노드에 leftright 값을 저장하여 계층 구조를 나타냅니다.
    • 트리 구조의 변경 시 좌우 값의 재계산이 필요합니다.

Materialized Path (Path Enumeration)

  • 장점:
    • 구현이 간단하고 효율적입니다.
    • 읽기와 쓰기 모두 비교적 성능이 좋습니다.
  • 단점:
    • 경로 문자열의 길이가 트리의 깊이에 따라 길어질 수 있습니다.
    • 문자열 비교를 사용하므로 대규모 트리에서 성능 이슈가 발생할 수 있습니다.
  • 로우 레벨 차이:
    • 각 노드에 루트부터 자신까지의 경로를 문자열로 저장합니다.
    • 경로는 구분자와 함께 부모들의 ID를 연결한 형태입니다.

Closure Table

  • 장점:
    • 읽기와 쓰기 모두 효율적입니다.
    • 복잡한 계층 구조와 다중 루트를 효과적으로 관리할 수 있습니다.
    • 조인 없이도 빠르게 조상이나 자손을 조회할 수 있습니다.
  • 단점:
    • 추가적인 테이블(Closure Table)이 필요합니다.
    • 데이터의 중복 저장으로 인해 테이블 크기가 커질 수 있습니다.
  • 로우 레벨 차이:
    • 별도의 Closure Table을 사용하여 모든 부모-자식 경로를 저장합니다.
    • Closure Table은 ancestordescendant 컬럼을 가지며, 각 노드 쌍의 관계를 저장합니다.

그래프를 관계형 데이터베이스(RDB)로 모델링하기 위해서는 Closure Table 구조를 채택하는 것이 가장 적합합니다.

  • 이유:
    • Closure Table은 복잡한 계층 구조와 다대다 관계를 효과적으로 표현할 수 있습니다.
    • 그래프의 특성상 노드들이 서로 다중 경로로 연결될 수 있는데, Closure Table은 이러한 다중 경로를 모두 저장하고 관리할 수 있습니다.
    • 읽기와 쓰기 성능이 모두 우수하여 그래프 데이터의 빈번한 변경과 조회에 적합합니다.
    • 조상이나 자손 노드를 빠르게 조회할 수 있어 그래프 탐색에 유리합니다.
  • 추가 고려사항:
    • Closure Table을 사용하면 모든 노드 간의 경로를 저장하므로 테이블의 크기가 커질 수 있습니다. 따라서 대규모 그래프에서는 인덱싱과 최적화가 필요합니다.
    • 그래프의 사이클(cycle)을 관리하기 위해 추가적인 로직이 필요할 수 있습니다.

ㄴ Closure Table

Closure Table 방식에 대한 자세한 설명

1. Closure Table 방식의 소개

Closure Table(클로저 테이블) 방식은 트리나 계층적 구조를 데이터베이스에 저장하고 효율적으로 조회하기 위한 설계 패턴 중 하나입니다. 이 방식은 각 노드 간의 모든 조상-자손 관계를 별도의 테이블에 저장하여 복잡한 계층 구조에서도 빠르고 효율적인 읽기 및 쓰기 작업을 가능하게 합니다.

2. Closure Table의 작동 방식

Closure Table 방식에서는 두 가지 주요 테이블이 있습니다:

  • 엔티티 테이블: 실제 데이터를 저장하는 테이블로, 각 노드는 고유한 ID와 필요한 속성을 가집니다.
  • 클로저 테이블: 노드 간의 모든 조상-자손 관계를 저장하는 테이블로, 일반적으로 다음과 같은 컬럼을 가집니다:
    • ancestor: 조상 노드의 ID
    • descendant: 자손 노드의 ID
    • depth (선택적): 조상 노드로부터 자손 노드까지의 거리(레벨)

클로저 테이블은 모든 가능한 조상-자손 쌍을 저장하기 때문에, 특정 노드의 모든 조상이나 자손을 빠르게 조회할 수 있습니다.

3. 데이터베이스 레벨에서의 구현

  • 엔티티 테이블 생성

    CREATE TABLE category (
        id INT PRIMARY KEY,
        name VARCHAR(255)
    );
    
  • 클로저 테이블 생성

    CREATE TABLE category_closure (
        ancestor INT,
        descendant INT,
        depth INT,
        PRIMARY KEY (ancestor, descendant),
        FOREIGN KEY (ancestor) REFERENCES category(id),
        FOREIGN KEY (descendant) REFERENCES category(id)
    );
    

4. 데이터 삽입과 클로저 테이블 업데이트

새로운 노드를 삽입할 때, 클로저 테이블에 해당 노드와 관련된 모든 조상-자손 관계를 추가해야 합니다.

예를 들어, 노드 A에 자식 노드 B를 추가할 경우:

  • 엔티티 테이블에 노드 B를 삽입합니다.
  • 클로저 테이블에 다음과 같은 관계를 추가합니다:
    • B의 조상은 자신(B): (B, B, 0)
    • B의 조상은 A: (A, B, 1)
    • A의 모든 조상에 대해, 해당 조상과 B 사이의 관계를 추가합니다.

5. 쿼리 수행 방법

  • 모든 자손 조회

    특정 노드의 모든 자손을 조회하려면 클로저 테이블에서 ancestor가 해당 노드인 레코드를 찾으면 됩니다.

    SELECT c.*
    FROM category c
    JOIN category_closure cc ON c.id = cc.descendant
    WHERE cc.ancestor = :nodeId;
    
  • 모든 조상 조회

    특정 노드의 모든 조상을 조회하려면 클로저 테이블에서 descendant가 해당 노드인 레코드를 찾으면 됩니다.

    SELECT c.*
    FROM category c
    JOIN category_closure cc ON c.id = cc.ancestor
    WHERE cc.descendant = :nodeId;
    
  • 특정 깊이의 노드 조회

    depth 컬럼을 활용하여 특정 깊이의 노드를 조회할 수 있습니다.

    SELECT c.*
    FROM category c
    JOIN category_closure cc ON c.id = cc.descendant
    WHERE cc.ancestor = :nodeId AND cc.depth = 1;
    

6. Closure Table의 장점

  • 효율적인 읽기와 쓰기 성능
    • 트리 구조의 깊이에 관계없이 일정한 시간 복잡도로 조상 및 자손을 조회할 수 있습니다.
    • 복잡한 계층 구조에서도 빠른 쿼리 수행이 가능합니다.
  • 다양한 트리 및 그래프 구조 지원
    • 다중 루트 트리, 포리스트(forest), 심지어 사이클이 있는 그래프도 관리할 수 있습니다.
  • 유연한 쿼리
    • 특정 깊이의 노드 조회, 경로 길이 계산 등 다양한 쿼리가 가능합니다.

7. Closure Table의 단점

  • 저장 공간 증가
    • 모든 조상-자손 관계를 저장하기 때문에 노드 수가 많을수록 클로저 테이블의 크기가 기하급수적으로 증가할 수 있습니다.
  • 데이터 일관성 유지의 복잡성
    • 노드의 삽입, 삭제, 이동 시 클로저 테이블을 적절하게 업데이트해야 하며, 이는 복잡한 로직을 필요로 합니다.
  • 트리 구조 변경 시 오버헤드 발생
    • 노드의 부모 변경 등의 트리 구조 수정 시 관련된 많은 레코드를 업데이트해야 합니다.

8. 예시로 보는 Closure Table의 활용

예제 트리 구조

- 1 (가)
  - 2 (나)
    - 4 (다)
    - 5 (라)
  - 3 (마)

엔티티 테이블 (category)

id name
1
2
3
4
5

9. 다른 방법과의 비교

  • Adjacency List와의 비교
    • Adjacency List는 부모 노드만을 참조하므로, 재귀적인 쿼리가 필요하여 깊은 트리에서는 성능 이슈가 발생할 수 있습니다.
    • Closure Table은 모든 조상-자손 관계를 저장하므로, 복잡한 쿼리 없이도 빠른 조회가 가능합니다.
  • Nested Set과의 비교
    • Nested Set은 읽기 작업은 빠르지만, 쓰기 작업(노드 추가, 삭제)이 복잡하고 비용이 높습니다.
    • Closure Table은 쓰기 작업에서도 비교적 효율적이며, 트리 구조의 변경에 더 유연합니다.
  • Materialized Path와의 비교
    • Materialized Path는 경로를 문자열로 저장하므로, 문자열 처리에 따른 성능 저하가 있을 수 있습니다.
    • Closure Table은 정수형 ID를 사용하므로 쿼리 성능이 더 우수합니다.

10. Closure Table을 활용한 그래프 모델링

Closure Table은 트리뿐만 아니라 일반적인 그래프 구조를 모델링하는 데에도 유용합니다. 그래프에서 노드 간의 관계를 효과적으로 저장하고 조회할 수 있으며, 특히 순환 구조(cycles)를 가진 그래프에서도 적절한 관리가 가능합니다.

  • 사이클 처리
    • Closure Table은 사이클이 있는 그래프에서도 모든 경로를 저장하므로, 별도의 추가 작업 없이도 사이클을 관리할 수 있습니다.
  • 다대다 관계 표현
    • 노드 간의 복잡한 연결 관계를 효과적으로 저장하며, 다양한 경로 탐색 쿼리를 지원합니다.

11. 결론

Closure Table 방식은 계층적 데이터나 그래프 구조를 관계형 데이터베이스에서 효율적으로 관리하기 위한 강력한 방법입니다. 복잡한 구조에서도 빠른 조회와 유연한 트리 구조 변경을 지원하므로, 대규모의 계층적 데이터를 다루는 시스템에서 특히 유용합니다. 그러나 저장 공간의 증가와 데이터 일관성 유지를 위한 복잡성이 있으므로, 시스템의 요구사항에 따라 적절한 방법을 선택하는 것이 중요합니다.

# 개발 스택

메인 프레임워크

NestJS vs. Express.js

코드 퀄리티와 일관된 디자인 패턴 적용을 위해 NestJS 선택

의견 강화 및 추가 고려 사항:

  • 모듈러 아키텍처: NestJS는 Angular에서 영감을 받은 모듈러 구조를 가지고 있어 코드의 재사용성과 유지보수성이 높습니다. 이로 인해 대규모 애플리케이션에서도 코드 구조를 명확하게 유지할 수 있습니다.
  • 의존성 주입(DI): NestJS는 내장된 DI 컨테이너를 제공하여 컴포넌트 간의 결합도를 낮추고 테스트 용이성을 높입니다.
  • 데코레이터 활용: TypeScript의 데코레이터 기능을 적극 활용하여 코드의 가독성과 선언적 프로그래밍이 가능합니다.
  • 유닛 테스트 및 통합 테스트 지원: NestJS는 테스트 프레임워크와의 통합이 원활하여 안정적인 코드 품질 관리에 도움이 됩니다.
  • 커뮤니티와 생태계: 활발한 커뮤니티와 풍부한 공식 및 서드파티 모듈을 통해 개발 생산성을 높일 수 있습니다.

Express.js를 선택하지 않은 이유에 대한 고려:

  • 구조의 자유로움: Express.js는 경량 프레임워크로 유연성이 높지만, 프로젝트 규모가 커질수록 코드 구조 관리가 어려울 수 있습니다.
  • 일관성 부족: 개발자마다 코드 스타일이나 패턴이 달라질 수 있어 팀 내에서 일관된 코드베이스 유지가 어려울 수 있습니다.

Nestia? NestJS-tRPC? 아예 안쓰거나?

  • FE-BE 간 Type-safety 보장? 추가 문서 없이 API의 리턴 타입을 바로 추론 가능하게 하는 듯

  • https://github.com/samchon/nestia

    • 국내 개발자 분이 만드심!

    https://kscodebase.tistory.com/663

  • Nestia 성능이 이상함… 이게 말이 되나

    • Runtime validator is 20,000x faster than class-validator
    • JSON serialization is 200x faster than class-transformer
    • 같은 분이 작성하신 typia 라이브러리가 하는 일인듯
    • https://typia.io/docs/
  • 학습 곡선이 좀 있어보이는데 적용하면 좋아보이긴 함

  • 도와줘 GPT

    Nestia:

    • 성능 최적화: Nestia는 NestJS 애플리케이션의 성능을 향상시키는 데 초점을 맞추고 있습니다.
    • 자동 SDK 생성: 서버 사이드의 컨트롤러를 기반으로 클라이언트 사이드 코드를 자동으로 생성해주는 기능을 제공합니다.
    • 타입 안전성 보장: 타입스크립트의 기능을 활용하여 클라이언트와 서버 간의 타입 안전성을 확보합니다.
    • 보일러플레이트 코드 감소: 반복되는 코드를 줄여 개발 속도를 높일 수 있습니다.

    NestJS tRPC:

    • 엔드 투 엔드 타입 안전성: tRPC를 사용하면 백엔드부터 프론트엔드까지 전체적으로 타입 안전한 API를 구축할 수 있습니다.
    • 간소화된 API 개발: RESTful 엔드포인트나 GraphQL 없이도 간단하게 API를 만들 수 있습니다.
    • 유연성: NestJS에 국한되지 않고 다양한 프론트엔드 프레임워크와 함께 사용할 수 있어, 향후 프레임워크를 변경하더라도 유연하게 대응할 수 있습니다.
    • 커뮤니티 지원: tRPC는 성장하는 커뮤니티를 가지고 있어 자료나 지원을 받기 용이합니다.

    고려 사항:

    • 프로젝트 요구사항: 성능 최적화와 자동 SDK 생성이 중요하다면 Nestia가 더 적합할 수 있습니다.
    • 개발 스타일: 엔드 투 엔드 타입 안전성과 함수형 프로그래밍 스타일을 선호한다면 tRPC가 더 맞을 수 있습니다.
    • 학습 곡선: 팀의 기존 지식과 경험에 따라 배우기 쉬운 도구를 선택하는 것이 좋습니다.

    추천 사항:

    • 프로토타입 제작: 가능하다면 두 옵션 모두로 작은 규모의 프로토타입을 만들어보고 어느 것이 더 적합한지 평가해보세요.
    • 커뮤니티 및 지원: 두 도구의 문서화 수준과 커뮤니티 활동을 확인하여 필요할 때 도움을 받을 수 있는지 확인하세요.
    • 장기적인 유지보수: 꾸준히 업데이트되고 관리되는 도구인지 확인하여 장기적인 안정성을 고려하세요.

데이터베이스와 ORM

PostgreSQL vs. MySQL

  1. JSONB 데이터 타입:
    • 이진 저장: JSON 데이터를 이진 형식으로 저장하여 검색과 인덱싱이 빠릅니다.
    • 인덱싱 가능: GIN, GiST 등 다양한 인덱스를 통해 JSON 필드 내의 특정 키나 값을 기반으로 인덱싱할 수 있습니다.
    • 효율적인 쿼리: JSON 데이터를 대상으로 한 다양한 연산자와 함수(>, >>, @>, #>, jsonb_path_query 등)를 제공합니다.
  2. 풍부한 JSON 함수 및 연산자:
    • 데이터 변환 및 조작: JSON 데이터를 부분적으로 업데이트하거나 병합하는 기능을 제공합니다.
    • 고급 쿼리 지원: JSON 경로 쿼리, 존재 여부 검사, 배열 요소 접근 등 다양한 기능을 지원합니다.
  3. 성능 및 안정성:
    • 최적화된 저장 및 검색: JSON 데이터를 처리하는 데 있어 성능 최적화가 잘 되어 있습니다.
    • 대용량 데이터 처리: 대량의 JSON 데이터를 효율적으로 관리할 수 있습니다.
  • MySQL의 JSON 지원:
    • MySQL은 5.7 버전부터 JSON 데이터 타입을 지원하기 시작했습니다.
    • JSON 데이터를 텍스트로 저장하지만, 내부적으로는 최적화를 통해 성능을 향상시켰습니다.
    • 그러나 PostgreSQL에 비해 JSON 함수와 연산자의 다양성이 부족하며, 인덱싱 옵션도 제한적입니다.
  • 제한 사항:
    • 인덱싱의 제약: MySQL에서는 JSON 필드 내의 특정 키에 대한 인덱싱이 제한적입니다.
    • 함수 및 연산자 부족: PostgreSQL만큼 풍부한 JSON 조작 기능을 제공하지 않습니다.

TypeORM vs. etc.

생산성을 위한 ORM 활용. NestJS와 통합이 가장 잘되는 TypeORM 선택

의견 강화 및 추가 고려 사항:

  • 완벽한 NestJS 통합:
    • 모듈화된 구조: TypeORM은 NestJS와의 통합을 고려하여 설계되어 설정과 사용이 용이합니다.
    • 데코레이터 기반 엔티티 정의: 데코레이터를 사용하여 직관적이고 가독성 높은 엔티티를 정의할 수 있습니다.
  • 다양한 데이터베이스 지원:
    • 멀티 데이터베이스 호환성: PostgreSQL뿐만 아니라 MySQL, MariaDB 등 다양한 DB를 지원하여 데이터베이스 변경에 유연합니다.
  • 활발한 커뮤니티와 문서화:
    • 풍부한 자료: 공식 문서와 예제가 잘 갖추어져 있어 학습 곡선이 완만합니다.
    • 커뮤니티 지원: StackOverflow 등에서 문제 해결에 대한 지원을 받기 용이합니다.
  • 기능적 장점:
    • Lazy Loading 및 Eager Loading 지원: 관계된 데이터를 효율적으로 로딩할 수 있습니다.
    • 트랜잭션 관리: 복잡한 트랜잭션을 손쉽게 관리할 수 있습니다.
    • 마이그레이션 도구: 데이터베이스 스키마 변경을 안전하고 효율적으로 적용할 수 있습니다.

다른 ORM과의 비교:

  • Sequelize: JavaScript 기반의 ORM으로, TypeScript 지원이 제한적이며 NestJS와의 통합이 TypeORM만큼 원활하지 않습니다.
  • Prisma: 최근 각광받는 ORM이지만, 데코레이터를 사용하지 않고 스키마 파일로 모델링하므로 NestJS와의 일관성이 떨어질 수 있습니다.

객체(DTO/DAO/Entity) 관련 라이브러리

  • class-validator
    • 데코레이터 기반의 유효성 검사
      • 선언적 유효성 검사: 클래스 속성에 데코레이터를 사용하여 유효성 검사 규칙을 정의함으로써 코드의 가독성과 유지보수성을 높입니다.
      • 풍부한 내장 검사기: 이메일 형식 검사, 최소/최대 길이, 숫자 범위 등 다양한 내장 검사기를 제공하여 별도의 로직 없이 유효성 검사가 가능합니다.
    • NestJS와의 통합
      • 파이프(Pipe) 활용: NestJS의 파이프 기능과 결합하여 컨트롤러 진입 전에 자동으로 유효성 검사를 수행할 수 있습니다.
      • 에러 메시지 커스터마이징: 유효성 검사 실패 시 반환되는 에러 메시지를 사용자 정의하여 사용자 경험을 개선할 수 있습니다.
  • class-transformer
    • 객체 변환 및 직렬화
      • 플레인 객체를 클래스 인스턴스로 변환: 입력 받은 JSON 데이터를 DTO(Data Transfer Object)로 변환하여 타입 안정성과 코드 자동 완성 기능을 활용할 수 있습니다.
      • 노출 필드 제어: @Exclude, @Expose 데코레이터를 사용하여 응답 객체에서 노출할 필드와 숨길 필드를 제어할 수 있습니다.
    • 데이터 타입 변환
      • 자동 타입 캐스팅: 문자열을 숫자나 불리언 등으로 자동 변환하여 데이터 일관성을 유지할 수 있습니다.
      • 중첩 객체 변환 지원: 복잡한 객체 구조에서도 재귀적으로 변환이 가능하여 계층적인 데이터 구조를 손쉽게 관리할 수 있습니다.
  • nestia 쓰면 둘 다 안써도됨 → typia 내장

WebSocket & Socket.io

WebSocket을 사용하는 이유:

  1. 실시간 양방향 통신:
    • 양방향 통신: 클라이언트와 서버 모두 데이터를 주고받을 수 있어, 서버에서 클라이언트로 즉각적인 알림이나 업데이트를 보낼 수 있습니다.
  2. 낮은 지연 시간:
    • 빠른 데이터 전송: 연결 설정에 대한 오버헤드가 없으므로, 데이터 전송이 빠르고 지연 시간이 적습니다.
    • 실시간 반응성: 사용자 간의 변경 사항이 즉시 반영되어 실시간 협업에 적합합니다.
  3. 효율적인 리소스 사용:
    • 낮은 네트워크 부하: HTTP 요청의 헤더 오버헤드가 없으므로, 네트워크 트래픽이 감소합니다.
    • 서버 부하 감소: 지속적인 연결로 인해 연결 설정과 해제에 따른 부하가 줄어듭니다.
  4. CRDT와의 시너지 효과:
    • 즉각적인 업데이트 전파: CRDT 알고리즘은 변경 사항을 다른 클라이언트에 빠르게 전파해야 하므로, WebSocket의 실시간 통신이 도움이 됩니다.
    • 충돌 해결의 용이성: 실시간으로 변경 사항을 교환함으로써 충돌을 최소화하고, 발생한 충돌도 신속하게 해결할 수 있습니다.

REST API를 사용하는 경우의 한계:

  1. 단방향 통신:
    • 클라이언트 중심: REST는 클라이언트가 요청하고 서버가 응답하는 방식으로, 서버에서 클라이언트로의 실시간 푸시가 어렵습니다.
    • 지속적인 연결: WebSocket은 서버와 클라이언트 간에 지속적인 연결을 유지하여 실시간 데이터 교환이 가능합니다.
    • 폴링 필요: 실시간성을 확보하기 위해서는 클라이언트가 주기적으로 서버에 폴링해야 하며, 이는 비효율적입니다.
  2. 높은 지연 시간과 오버헤드:
    • 반복적인 연결 설정: 각 요청마다 새로운 HTTP 연결을 설정하므로 오버헤드가 발생합니다.
    • 지연 시간 증가: 폴링 주기에 따라 변경 사항 반영에 지연이 생길 수 있습니다.
  3. 리소스 낭비:
    • 불필요한 요청 증가: 폴링 방식은 변경 사항이 없더라도 서버에 요청을 보내므로 서버와 네트워크 리소스를 소모합니다.

[**Socket.io](http://Socket.io) 사용:**

  • 실시간 양방향 통신 지원
    • WebSocket 기반의 추상화: 클라이언트와 서버 간의 실시간 통신을 쉽게 구현할 수 있도록 WebSocket을 추상화하여 제공합니다.
    • 자동 폴백 메커니즘: 클라이언트의 환경에 따라 WebSocket이 지원되지 않는 경우 HTTP Long Polling 등으로 자동 전환하여 호환성을 보장합니다.
    • 이벤트 기반 통신: 클라이언트와 서버 간에 커스텀 이벤트를 정의하여 명확하고 관리하기 쉬운 통신 구조를 만들 수 있습니다.
  • NestJS와의 원활한 통합
    • @nestjs/platform-socket.io 패키지: NestJS에서 공식적으로 지원하는 모듈로, 기존의 모듈과 컨트롤러 구조를 그대로 활용할 수 있습니다.
    • 미들웨어 및 가드 적용: 인증 및 권한 부여 로직을 미들웨어나 가드로 적용하여 보안성을 높일 수 있습니다.
  • 스케일링 및 확장성
    • 클러스터링 지원: 여러 개의 서버 인스턴스 간에 세션 정보를 공유할 수 있어 수평 확장이 용이합니다.
    • Redis 어댑터: Redis를 사용하여 다중 서버 환경에서도 실시간 통신을 안정적으로 유지할 수 있습니다.
  • 고려 사항
    • 버전 호환성 관리: 클라이언트와 서버의 Socket.io 버전이 일치해야 하며, 버전 차이로 인한 호환성 문제에 주의해야 합니다.
    • 네임스페이스와 룸 관리: 대규모 애플리케이션에서는 네임스페이스와 룸을 체계적으로 관리하여 성능과 유지보수성을 확보해야 합니다.

기술적 고민

에디터 내용 저장

https://www.slideshare.net/slideshow/models-for-hierarchical-data/4179181#1

https://orkhan.gitbook.io/typeorm/docs/tree-entities

  • Postgres jsonb + TypeORM 인접 리스트 연결 방식
import { Entity, Column, PrimaryGeneratedColumn, ManyToOne, OneToMany } from 'typeorm';

@Entity()
export class DocumentNode {
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  type: string;

  @Column('jsonb', { nullable: true })
  attrs: any;

  @Column('text', { nullable: true })
  text: string;

  @ManyToOne(() => DocumentNode, node => node.children)
  parent: DocumentNode;

  @OneToMany(() => DocumentNode, node => node.parent)
  children: DocumentNode[];
}
  • TypeORM nested set 예제 코드
    • 읽기에 매우 강하지만, 쓰기에 최악인듯
import {
    Entity,
    Tree,
    Column,
    PrimaryGeneratedColumn,
    TreeChildren,
    TreeParent,
    TreeLevelColumn,
} from "typeorm"

@Entity()
@Tree("nested-set")
export class Category {
    @PrimaryGeneratedColumn()
    id: number

    @Column()
    name: string

    @TreeChildren()
    children: Category[]

    @TreeParent()
    parent: Category
}
  • TypeORM Materialized Path 예제 코드
    • 사용 자체는 nested set 이랑 같은데, 좀 더 간단하고 효율적이라는 것 같음
import {
    Entity,
    Tree,
    Column,
    PrimaryGeneratedColumn,
    TreeChildren,
    TreeParent,
    TreeLevelColumn,
} from "typeorm"

@Entity()
@Tree("materialized-path")
export class Category {
    @PrimaryGeneratedColumn()
    id: number

    @Column()
    name: string

    @TreeChildren()
    children: Category[]

    @TreeParent()
    parent: Category
}
  • TypeORM Closure table 예제 코드

    • 성민님이 올려주신 걸 ORM 단에서 간단하게 작성할 수 있음
    import {
        Entity,
        Tree,
        Column,
        PrimaryGeneratedColumn,
        TreeChildren,
        TreeParent,
        TreeLevelColumn,
    } from "typeorm"
    
    @Entity()
    @Tree("closure-table")
    export class Category {
        @PrimaryGeneratedColumn()
        id: number
    
        @Column()
        name: string
    
        @TreeChildren()
        children: Category[]
    
        @TreeParent()
        parent: Category
    }
    • 아래와 같이 인접한 부모 자식 테이블을 직접 지정할 수 있는듯
    @Tree("closure-table", {
        closureTableName: "category_closure",
        ancestorColumnName: (column) => "ancestor_" + column.propertyName,
        descendantColumnName: (column) => "descendant_" + column.propertyName,
    })

GPT가 말아주는 각 방식의 장단점

Adjacency List

  • 장점:
    • 구현이 간단하고 이해하기 쉽습니다.
    • 부모-자식 관계를 나타내기 위해 자기 참조 외래 키를 사용합니다.
  • 단점:
    • 깊은 트리를 한 번에 로드하기 어렵습니다. 이는 조인의 제한 때문입니다.
    • 재귀적인 쿼리가 필요하여 성능 이슈가 발생할 수 있습니다.
  • 로우 레벨 차이:
    • 테이블 내에서 부모를 가리키는 외래 키 컬럼을 사용합니다.
    • 각 노드는 자신의 부모 ID를 저장하며, 이를 통해 트리 구조를 형성합니다.

Nested Set

  • 장점:
    • 읽기 성능이 매우 우수합니다.
    • 전체 트리나 서브트리를 한 번의 쿼리로 가져올 수 있습니다.
  • 단점:
    • 쓰기 작업(삽입, 삭제, 이동)이 복잡하고 비용이 높습니다.
    • 트리 구조 변경 시 모든 노드의 좌우 값(left, right)을 업데이트해야 합니다.
    • 다중 루트를 지원하지 않습니다.
  • 로우 레벨 차이:
    • 각 노드에 leftright 값을 저장하여 계층 구조를 나타냅니다.
    • 트리 구조의 변경 시 좌우 값의 재계산이 필요합니다.

Materialized Path (Path Enumeration)

  • 장점:
    • 구현이 간단하고 효율적입니다.
    • 읽기와 쓰기 모두 비교적 성능이 좋습니다.
  • 단점:
    • 경로 문자열의 길이가 트리의 깊이에 따라 길어질 수 있습니다.
    • 문자열 비교를 사용하므로 대규모 트리에서 성능 이슈가 발생할 수 있습니다.
  • 로우 레벨 차이:
    • 각 노드에 루트부터 자신까지의 경로를 문자열로 저장합니다.
    • 경로는 구분자와 함께 부모들의 ID를 연결한 형태입니다.

Closure Table

  • 장점:
    • 읽기와 쓰기 모두 효율적입니다.
    • 복잡한 계층 구조와 다중 루트를 효과적으로 관리할 수 있습니다.
    • 조인 없이도 빠르게 조상이나 자손을 조회할 수 있습니다.
  • 단점:
    • 추가적인 테이블(Closure Table)이 필요합니다.
    • 데이터의 중복 저장으로 인해 테이블 크기가 커질 수 있습니다.
  • 로우 레벨 차이:
    • 별도의 Closure Table을 사용하여 모든 부모-자식 경로를 저장합니다.
    • Closure Table은 ancestordescendant 컬럼을 가지며, 각 노드 쌍의 관계를 저장합니다.

그래프를 관계형 데이터베이스(RDB)로 모델링하기 위해서는 Closure Table 구조를 채택하는 것이 가장 적합합니다.

  • 이유:
    • Closure Table은 복잡한 계층 구조와 다대다 관계를 효과적으로 표현할 수 있습니다.
    • 그래프의 특성상 노드들이 서로 다중 경로로 연결될 수 있는데, Closure Table은 이러한 다중 경로를 모두 저장하고 관리할 수 있습니다.
    • 읽기와 쓰기 성능이 모두 우수하여 그래프 데이터의 빈번한 변경과 조회에 적합합니다.
    • 조상이나 자손 노드를 빠르게 조회할 수 있어 그래프 탐색에 유리합니다.
  • 추가 고려사항:
    • Closure Table을 사용하면 모든 노드 간의 경로를 저장하므로 테이블의 크기가 커질 수 있습니다. 따라서 대규모 그래프에서는 인덱싱과 최적화가 필요합니다.
    • 그래프의 사이클(cycle)을 관리하기 위해 추가적인 로직이 필요할 수 있습니다.

ㄴ Closure Table

Closure Table 방식에 대한 자세한 설명

1. Closure Table 방식의 소개

Closure Table(클로저 테이블) 방식은 트리나 계층적 구조를 데이터베이스에 저장하고 효율적으로 조회하기 위한 설계 패턴 중 하나입니다. 이 방식은 각 노드 간의 모든 조상-자손 관계를 별도의 테이블에 저장하여 복잡한 계층 구조에서도 빠르고 효율적인 읽기 및 쓰기 작업을 가능하게 합니다.

2. Closure Table의 작동 방식

Closure Table 방식에서는 두 가지 주요 테이블이 있습니다:

  • 엔티티 테이블: 실제 데이터를 저장하는 테이블로, 각 노드는 고유한 ID와 필요한 속성을 가집니다.
  • 클로저 테이블: 노드 간의 모든 조상-자손 관계를 저장하는 테이블로, 일반적으로 다음과 같은 컬럼을 가집니다:
    • ancestor: 조상 노드의 ID
    • descendant: 자손 노드의 ID
    • depth (선택적): 조상 노드로부터 자손 노드까지의 거리(레벨)

클로저 테이블은 모든 가능한 조상-자손 쌍을 저장하기 때문에, 특정 노드의 모든 조상이나 자손을 빠르게 조회할 수 있습니다.

3. 데이터베이스 레벨에서의 구현

  • 엔티티 테이블 생성

    CREATE TABLE category (
        id INT PRIMARY KEY,
        name VARCHAR(255)
    );
    
  • 클로저 테이블 생성

    CREATE TABLE category_closure (
        ancestor INT,
        descendant INT,
        depth INT,
        PRIMARY KEY (ancestor, descendant),
        FOREIGN KEY (ancestor) REFERENCES category(id),
        FOREIGN KEY (descendant) REFERENCES category(id)
    );
    

4. 데이터 삽입과 클로저 테이블 업데이트

새로운 노드를 삽입할 때, 클로저 테이블에 해당 노드와 관련된 모든 조상-자손 관계를 추가해야 합니다.

예를 들어, 노드 A에 자식 노드 B를 추가할 경우:

  • 엔티티 테이블에 노드 B를 삽입합니다.
  • 클로저 테이블에 다음과 같은 관계를 추가합니다:
    • B의 조상은 자신(B): (B, B, 0)
    • B의 조상은 A: (A, B, 1)
    • A의 모든 조상에 대해, 해당 조상과 B 사이의 관계를 추가합니다.

5. 쿼리 수행 방법

  • 모든 자손 조회

    특정 노드의 모든 자손을 조회하려면 클로저 테이블에서 ancestor가 해당 노드인 레코드를 찾으면 됩니다.

    SELECT c.*
    FROM category c
    JOIN category_closure cc ON c.id = cc.descendant
    WHERE cc.ancestor = :nodeId;
    
  • 모든 조상 조회

    특정 노드의 모든 조상을 조회하려면 클로저 테이블에서 descendant가 해당 노드인 레코드를 찾으면 됩니다.

    SELECT c.*
    FROM category c
    JOIN category_closure cc ON c.id = cc.ancestor
    WHERE cc.descendant = :nodeId;
    
  • 특정 깊이의 노드 조회

    depth 컬럼을 활용하여 특정 깊이의 노드를 조회할 수 있습니다.

    SELECT c.*
    FROM category c
    JOIN category_closure cc ON c.id = cc.descendant
    WHERE cc.ancestor = :nodeId AND cc.depth = 1;
    

6. Closure Table의 장점

  • 효율적인 읽기와 쓰기 성능
    • 트리 구조의 깊이에 관계없이 일정한 시간 복잡도로 조상 및 자손을 조회할 수 있습니다.
    • 복잡한 계층 구조에서도 빠른 쿼리 수행이 가능합니다.
  • 다양한 트리 및 그래프 구조 지원
    • 다중 루트 트리, 포리스트(forest), 심지어 사이클이 있는 그래프도 관리할 수 있습니다.
  • 유연한 쿼리
    • 특정 깊이의 노드 조회, 경로 길이 계산 등 다양한 쿼리가 가능합니다.

7. Closure Table의 단점

  • 저장 공간 증가
    • 모든 조상-자손 관계를 저장하기 때문에 노드 수가 많을수록 클로저 테이블의 크기가 기하급수적으로 증가할 수 있습니다.
  • 데이터 일관성 유지의 복잡성
    • 노드의 삽입, 삭제, 이동 시 클로저 테이블을 적절하게 업데이트해야 하며, 이는 복잡한 로직을 필요로 합니다.
  • 트리 구조 변경 시 오버헤드 발생
    • 노드의 부모 변경 등의 트리 구조 수정 시 관련된 많은 레코드를 업데이트해야 합니다.

8. 예시로 보는 Closure Table의 활용

예제 트리 구조

- 1 (가)
  - 2 (나)
    - 4 (다)
    - 5 (라)
  - 3 (마)

엔티티 테이블 (category)

id name
1
2
3
4
5

클로저 테이블 (category_closure)

ancestor descendant depth
1 1 0
1 2 1
1 3 1
1 4 2
1 5 2
2 2 0
2 4 1
2 5 1
3 3 0
4 4 0
5 5 0

예시 쿼리

  • 노드 1의 모든 자손 조회

    SELECT c.*
    FROM category c
    JOIN category_closure cc ON c.id = cc.descendant
    WHERE cc.ancestor = 1 AND cc.depth > 0;
    

    결과

    id name
    2
    3
    4
    5
  • 노드 4의 모든 조상 조회

    SELECT c.*
    FROM category c
    JOIN category_closure cc ON c.id = cc.ancestor
    WHERE cc.descendant = 4 AND cc.depth > 0;
    

    결과

    id name
    1
    2

9. 다른 방법과의 비교

  • Adjacency List와의 비교
    • Adjacency List는 부모 노드만을 참조하므로, 재귀적인 쿼리가 필요하여 깊은 트리에서는 성능 이슈가 발생할 수 있습니다.
    • Closure Table은 모든 조상-자손 관계를 저장하므로, 복잡한 쿼리 없이도 빠른 조회가 가능합니다.
  • Nested Set과의 비교
    • Nested Set은 읽기 작업은 빠르지만, 쓰기 작업(노드 추가, 삭제)이 복잡하고 비용이 높습니다.
    • Closure Table은 쓰기 작업에서도 비교적 효율적이며, 트리 구조의 변경에 더 유연합니다.
  • Materialized Path와의 비교
    • Materialized Path는 경로를 문자열로 저장하므로, 문자열 처리에 따른 성능 저하가 있을 수 있습니다.
    • Closure Table은 정수형 ID를 사용하므로 쿼리 성능이 더 우수합니다.

10. Closure Table을 활용한 그래프 모델링

Closure Table은 트리뿐만 아니라 일반적인 그래프 구조를 모델링하는 데에도 유용합니다. 그래프에서 노드 간의 관계를 효과적으로 저장하고 조회할 수 있으며, 특히 순환 구조(cycles)를 가진 그래프에서도 적절한 관리가 가능합니다.

  • 사이클 처리
    • Closure Table은 사이클이 있는 그래프에서도 모든 경로를 저장하므로, 별도의 추가 작업 없이도 사이클을 관리할 수 있습니다.
  • 다대다 관계 표현
    • 노드 간의 복잡한 연결 관계를 효과적으로 저장하며, 다양한 경로 탐색 쿼리를 지원합니다.

11. 결론

Closure Table 방식은 계층적 데이터나 그래프 구조를 관계형 데이터베이스에서 효율적으로 관리하기 위한 강력한 방법입니다. 복잡한 구조에서도 빠른 조회와 유연한 트리 구조 변경을 지원하므로, 대규모의 계층적 데이터를 다루는 시스템에서 특히 유용합니다. 그러나 저장 공간의 증가와 데이터 일관성 유지를 위한 복잡성이 있으므로, 시스템의 요구사항에 따라 적절한 방법을 선택하는 것이 중요합니다.

개발 문서

⚓️ 사용자 피드백과 버그 기록
👷🏻 기술적 도전
📖 위키와 학습정리
🚧 트러블슈팅

팀 문화

🧸 팀원 소개
⛺️ 그라운드 룰
🍞 커밋 컨벤션
🧈 이슈, PR 컨벤션
🥞 브랜치 전략

그룹 기록

📢 발표 자료
🌤️ 데일리 스크럼
📑 회의록
🏖️ 그룹 회고
🚸 멘토링 일지
Clone this wiki locally