Ayden's journal

09. lexical order를 활용한 무한 깊이의 대댓글 구현

댓글 기능의 모든 것을 재귀적으로 구현했던 이 포스트에서 이어집니다

 

lexical order 혹은 lexicographic order라고도 부르는 사전적 정렬은 ─ 다른 언어에서는 어떤지 모르겠지만 적어도 자바스크립트에서는 ─ 유니코드를 기준으로 오름차순으로 정렬하는 행위를 말한다. 배열을 정렬하는 sort 메소드의 경우 콜백 함수를 지정하지 않으면 사전적 정렬을 수행한다. 사전적 정렬에 따르면 acquaintance는 언제나 b보다 앞서며, 이러한 사전적 정렬의 특성을 활용하면 아주 간단하게 무한한 깊이의 대댓글을 구현할 수 있다.

미리 남겨놓지만 lexical order를 활용하는 것이 모든 문제를 해결하는 만능의 도구는 아니다. 재귀적으로 구현하는 방식이 그 구조에 따른 문제점을 내포하고 있었던 것처럼, lexical order를 활용하는 방식 역시 필연적으로 그 구조에 따른 문제점을 내포하고 있다. 이 문제점이 무엇인지는 뒤따르는 글에서 다루도록 하겠다.

 

핵심 아이디어

사전적 정렬에 따르면 1로 시작하는 어떤 문자열은 반드시 2보다 앞선다. 이러한 사전적 정렬의 핵심적인 특성을 활용하면 댓글의 깊이를 구현할 수 있다. 여기서 말하는 댓글의 깊이란 이런 것인데, 가령 게시글에 직접 달린 댓글은 깊이가 0이고, 그 댓글에 달린 대댓글은 깊이가 1이며, 이런 식으로 깊이가 1씩 늘어나게 된다.

나는 문자열 내에서 깊이를 구현하는 방식으로 마침표를 사용했다. 어떤 댓글의 id가 1 혹은 2라면 그 댓글의 깊이는 0이고, 1.1.1이나 2.1.3이라면 깊이가 2인 것이다. 문자열 내의 마침표 개수가 곧 댓글의 깊이를 나타내며, 또한 이러한 방법을 통해 댓글과 대댓글의 관계를 명확하게 나타낼 수 있다(3.3.2는 언제나 3.3의 대댓글이며, 1.1.1.1은 언제나 1.1.1의 대댓글이다).

게시글
ㄴ 댓글 // 1
ㄴㄴ 대댓글 // 1.1
ㄴㄴㄴ 대대댓글 // 1.1.1
ㄴㄴㄴ 대대댓글 // 1.1.2
ㄴㄴ 대댓글 // 1.2
ㄴ댓글 // 2
ㄴㄴ대댓글 // 2.1

마침표 깊이 표기법에는 추가적인 이점이 몇 가지 있는데, 대표적으로 관계 테이블을 사용하지 않고도 부모 자식을 확인할 수 있다는 점이 있겠다. 만약 3.3.2 댓글이 어떤 댓글에 달린 것인지를 알고 싶으면 id가 3.3인 댓글을 조회하면 되고, 3.3의 대댓글을 찾고 싶으면 3.3.을 포함하는 id를 조회하는 식이다.

 

모델 설계

재귀적으로 설계한 이전의 모델과 달리 이번 모델은 그 어떤 테이블과도 연결할 필요가 없다. 심지어는 게시물 테이블과의 연결도 필요 없다. 이유는 간단한데, 댓글의 id 값을 유니크하게 만들기 위해 게시물의 id를 가져다 사용할 것이기 때문이다. 게시물의 id가 이미 uuid이기 때문에, 그 뒤에 마침표 깊이 표기법을 이어붙여도 여전히 유니크함을 보장할 수 있다.

* 경고 : 위의 문단에서 '게시물 테이블과의 연결도 필요 없다'고 주장한 것은 prisma의 contains를 사용하는 경우를 상정하고 쓰여졌습니다. 다만, contains를 사용할 경우 인덱스에 접근하지 못할 가능성이 높아, 전체 테이블 스캔이 발생하며 이로 인해 데이터셋 크기와 비례하여 성능 저하가 발생할 수 있습니다. 따라서 정상적인 경우 게시물의 id를 가져다 사용할 지라도 게시물 테이블과 관계를 연결하는 것은 여전히 중요합니다. 또한, 어떤 테이블과도 연결할 필요가 없다는 표현과는 달리 실제로는 작성자를 확인할 수 있도록 유저 테이블과 연결할 필요가 있습니다.

이후의 내용은 모두 댓글 테이블과 게시물 테이블을 연결하는 방식으로 수정되었으며, 초기 아이디어에 대한 흔적을 남기기 위해 모델 설계 부분은 새로 작성하는 대신 경고 문구를 추가하였습니다.

model Comment {
  // PostgreSQL의 TEXT 데이터 타입을 사용하여 무제한 길이 사용
  id        String   @id @unique @db.Text 
  comment   String
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
  userId    String
  user      User     @relation(fields: [userId], references: [id])
  reportId  String
  report    Report   @relation(fields: [reportId], references: [id])
}

 

쿼리 조회

재귀적으로 쿼리를 조회하는 경우 결과는 무한 차원의 배열이 되며, 이에 따라 페이지네이션을 구현하기가 사실상 불가능에 가까웠다. 하지만 마침표 깊이 표기법을 사용하는 경우 쿼리 조회 결과는 1차원 배열이며, 각 댓글의 깊이는 오직 마침표의 갯수로만 결정된다. 따라서 아주 간단하게 페이지네이션을 구현할 수 있다.

@Injectable()
export class CommentRepository {
  constructor(private prisma: PrismaService) {}

  getComments(reportId: string, offset: number, limit: number) {
    return this.prisma.comments.findMany({
      where: { reportId },
      orderBy: { id: 'asc' },
      skip: offset,
      take: limit,
      include: {
        user: {
          select: {
            id: true,
            nickname: true,
            profileImage: true,
          },
        },
      },
    });
  }
}

실제로 댓글을 조회해보면 id의 사전적 정렬에 따라 올바른 순서대로 댓글과 대댓글이 출력되는 것을 확인할 수 있다.

+ 커서 방식과 오프셋 방식의 구현 방식에 대해서는 prisma 카테고리의 [ pagination ] 포스트에서 조금 더 자세히 다루고 있으므로 궁금한 사람들은 이쪽을 확인해보자.

 

lexical order가 가진 구조적 문제

지금까지의 내용만으로 생각해보면 lexical order는 만능이며 모든 문제를 해결하여 무한한 갯수의 무한한 대댓글을 아주 손쉽게 처리할 수 있는 것처럼 생각된다. 하지만 실제로는 그렇지 않다. 생각해보면 간단한데, 단순히 숫자를 1씩 올리는 거라면 id가 1인 댓글에 두 번째 대댓글은 id가 1.2가 될 것이며 열 번째 댓글의 id는 1.10이 되기 때문이다. 사전적 정렬에 따르면 1.10은 1.2보다 앞서 정렬되는데, 열 번째 댓글이 두 번째 댓글보다 앞서 배치되는 이러한 현상은 우리가 의도한 것과 맞지 않다.

이것이 바로 lexical order가 가진 구조적인 문제이며, 이를 우회하기 위해서는 보통 고정 길이 패딩이라는 방식을 사용하게 된다. 이는 댓글의 아이디를 1.2 대신 000000001.000000002와 같은 식으로 고정된 길이를 갖게 만드는 방법이다. 고정 길이 패딩을 사용하면 열 번째 댓글은 000000001.000000010이 되며, 이는 사전적 정렬에 따라 정확히 아홉번째 댓글의 뒤에 배치될 것이다.

 

하지만 고정 길이 패딩 방식 역시 구조적인 문제를 가지고 있으며, 위의 예시에서는 하나의 댓글에 대해 999999999개의 대댓글만 달릴 수 있다는 문제가 있다. 물론 유튜브와 같은 초 거대 플랫폼에서조차 단일 영상에 달린 0깊이 댓글이 1억 개를 넘기는 경우가 흔치 않다. 방탄소년단의 뮤직비디오마저도 0깊이 댓글은 천육백만개를 넘지 않으니 말 다했지.

그러니 고정 길이 패딩이 구조적인 문제를 가지고 있다고 해도 충분한 길이를 확보할 수 있다면 현실적으로는 크게 문제가 되지는 않으리라고 생각된다. 내가 만드는 서비스에서라면 길이가 5 정도만 돼도 충분하지 않을까? 고정 길이 패딩을 사용하여 다시 작성된 lexical order의 예시는 아래에서 확인할 수 있다.

게시글
ㄴ 댓글 // 00000
ㄴㄴ 대댓글 // 00000.00000
ㄴㄴㄴ 대대댓글 // 00000.00000.00000
ㄴㄴㄴ 대대댓글 // 00000.00000.00001
ㄴㄴ 대댓글 // 00000.00001
ㄴ댓글 // 00001
ㄴㄴ대댓글 // 00001.00001

한 줄 결론 : lexical order는 구조적인 문제를 가지고 있으니 이를 해결하기 위해 고정 길이 패딩 방식을 결합하여 사용하자.

 

댓글 작성

재귀적으로 댓글 작성 기능을 구현할 때  ─ 적어도 백엔드에서 ─ 는 부모 댓글의 id 값을 가져다가 테이블 연결만 해주면 됐기에 아주 간단했으나, lexical order를 활용하여 댓글 작성 기능을 구현하려면 조금은 번거로운 과정을 거쳐야 한다. 부모 댓글의 id를 알고 있다고 해서 지금 작성하려는 자식 댓글의 id를 바로 알 수 있는 방법이 없기 때문이다. 다행히 자식 댓글의 id를 알아내기 위해 부모 댓글의 모든 댓글을 조회할 필요는 없다. 오름차순으로 조회하여 가장 마지막 댓글만을 가져오면 된다.

// 레포지토리
async getLastComment(reportId: string, path: string) {
  const result = await this.prisma.comments.findMany({
    where: {
      AND: [
        { reportId },
        {
          id: {
            contains:
              path !== 'root' ? `${reportId}-${path}.` : `${reportId}-`,
          },
        },
      ],
    },
    orderBy: { id: 'desc' },
    take: 1,
    select: {
      id: true,
    },
  });

  return result[0];
}

 

만약 자식 댓글이 없다면 (그래서 지금 작성하려는 댓글이 부모 댓글의 첫 자식 댓글이라면) 부모 id에 고정 길이 만큼의 0을 붙여주면 된다. 그렇지 않다면 마지막 자식 댓글의 숫자에 1을 더해준 다음 고정 길이 만큼 0을 채워주면 된다. 이 로직은 복잡하지 않지만 말로 설명하기는 쉽지 않아서, 다음 댓글의 id를 생성하기 위해 작성한 getNextId 함수를 직접 여기에 남겨두겠다. 메서드 체이닝 때문에 복잡해보일 수는 있지만 실제로는 그렇게 복잡하지 않기 때문에 천천히 읽어보면 누구라도 이해할 수 있을 거라고 믿는다.

참고로 getNextId 함수의 파라미터 path는 부모 댓글의 id 값을 나타내며, 게시글에 직접 달리는 0 깊이 댓글의 경우 path가 root이다.

const getNextId = (path: string, comment?: { id: string }) => {
  const PADDING_LENGTH = 5;

  if (path === 'root') {
    if (comment) {
      const part = comment.id.split('-').at(-1).split('.').at(0);

      return (Number(part) + 1).toString().padStart(PADDING_LENGTH, '0');
    } else {
      return ''.padStart(PADDING_LENGTH, '0');
    }
  } else {
    if (comment) {
      const part = comment.id
        .split('-')
        .at(-1)
        .replace(`${path}.`, '')
        .split('.')
        .at(0);

      return (
        path + '.' + (Number(part) + 1).toString().padStart(PADDING_LENGTH, '0')
      );
    } else {
      return `${path}.${''.padStart(PADDING_LENGTH, '0')}`;
    }
  }
};

 

자식 댓글의 id를 알아냈다면 이후는 일사천리다.

// 서비스
async postComment({ comment, reportId, path, userId }: CreateCommentDto) {
  const lastComment = await this.commentRepository.getLastComment(
    reportId,
    path,
  );
  
  const id = getNextId(path, lastComment),

  const result = await this.commentRepository.createComment({
    comment,
    reportId,
    path: id,
    userId,
  });

  return result;
}
// 레포지토리
createComment({ comment, reportId, path, userId }: CreateCommentDto) {
  return this.prisma.comments.create({
    data: {
      id: `${reportId}-${path}`,
      comment,
      user: { connect: { id: userId } },
      report: { connect: { id: reportId } },
    },
  });
}

 

블로그의 정보

Ayden's journal

Beard Weard Ayden

활동하기