라이브러리 서비스 구현하기 with Composite Pattern

semin·2024년 3월 24일
post-thumbnail

요구사항

스포티파이는 음악, 앨범, 아티스트, 플레이리스트, 폴더를 관리할 수 있는 다음과 같은 라이브러리를 제공한다.

이미지 출처 : 요즘 IT, 스포티파이 UX - 노래추천이 기가 막히다며?

라이브러리에서 관리할 수 있는 도메인에 대한 요구사항은 다음과 같다.

음악

음악은 플레이리스트에만 담길 수 있다. '좋아요 표시한 곡' 은 별도로 생성하는 플레이리스트는 아니지만, 구현 시 플레이리스트 처럼 구현하는 것으로 결정하였다.

앨범, 아티스트

앨범과 아티스트는 좋아요를 눌러 라이브러리에 추가할 수 있다. 폴더나 그룹으로 따로 관리하는 기능은 존재하지 않는다.

플레이리스트

플레이리스트에는 음악을 저장할 수 있으며 폴더로 관리할 수 있다. 다른 사람이 생성한 플레이리스트도 라이브러리에 담을 수 있다.

폴더

폴더에는 플레이리스트나 폴더를 담을 수 있다.

폴더 구조 구현을 DB 계층에서 Tree 자료구조, 애플리케이션 도메인 계층에서 Composite Pattern을 활용하려고 한다.
음악 서비스와 라이브러리 서비스를 분리하여 구현 하였는데 개별 DB를 사용하며 GraphQL Federation을 활용할 예정이다.

구현

라이브러리 노드를 생성하고 조회하는 기능을 구현해보자.
사용자 기능은 아직 구현하지 않은 관계로, 임시 reference를 부여한 뒤 사용자 서비스 구현 후 연결할 예정이다.

도메인 구현

우선 노드의 타입을 enum으로 정의하였다.

LibraryNodeType

enum class LibraryNodeType(val isLeaf: Boolean) {
    MUSIC(true),
    ALBUM(true),
    ARTIST(true),
    PLAYLIST_REFERENCE(true),
    PLAYLIST(false),
    FOLDER(false)
}

사용자가 자식 노드를 관리할 수 없는 노드 타입을 리프노드로 정의하였다.
플레이리스트는 생성한 사람만 관리할 수 있고, 다른 사람들은 그것을 참조하는 형태로 설계하였다.

다음으로 Composite Pattern의 컴포넌트 추상 클래스를 정의하였다.

LibraryNode

abstract class LibraryNode : BaseTimeDocument {
    var userId: String
        private set

    var type : LibraryNodeType
        private set

    var parentId : String?
        private set

    abstract fun addChild(libraryNode: LibraryNode)
}

그 다음으로 LeafNode, FolderNode, PlaylistNode를 각각 구현하였다.

LibraryLeafNode

@Document
class LibraryLeafNode : LibraryNode {
    var referenceId: String
        private set

    override fun addChild(libraryNode: LibraryNode) {
        throw RuntimeException()
    }

    private fun validate(type: LibraryNodeType, parentId: String?) {
  		...
    }
}

리프노드에는 child를 추가할 수 없으므로 예외가 발생하도록 처리하였다.

LibraryFolderNode

@Document
class LibraryFolderNode : LibraryNode {
    var name: String
        private set

    var children: Set<String>
        private set

    override fun addChild(libraryNode: LibraryNode) {
        if (libraryNode.type.isLeaf) {
            throw RuntimeException()
        }
}

LibraryPlaylistNode

@Document
class LibraryPlaylistNode : LibraryNode {
	var creatorId: String
    	private set
        
	var creatorName: String
    	private set

    var name: String
        private set

    var children: Set<String>
        private set

    override fun addChild(libraryNode: LibraryNode) {
        if(libraryNode.type != MUSIC) {
            throw RuntimeException()
        }

        this.children = children.plus(libraryNode.id)
    }
}

플레이리스트 노드는 생성한 사용자 본인만 관리할 수 있는 노드이다. 다른 사용자의 플레이리스트를 라이브러리에 추가한 경우 LeafNode에서 reference로 참조하게 된다.

Graphql Schema 정의

스키마에서도 다음과 같이 인터페이스를 활용하였다.

interface LibraryNode {
    id: ID!
    type: LibraryNodeType!
    parentId: String
}

type LibraryLeafNode implements LibraryNode {
    id: ID!
    type: LibraryNodeType!
    parentId: String
    referenceId: String!
}

type LibraryPlaylistNode implements LibraryNode {
    id: ID!
    type: LibraryNodeType!
    parentId: String
    name: String!
    children: [String!]
}

type LibraryFolderNode implements LibraryNode {
    id: ID!
    type: LibraryNodeType!
    parentId: String
    name: String!
    children: [String!]
    childPlaylistCount: Int!
    childFolderCount: Int!
}

인터페이스를 활용하여 Query와 Mutation을 정의하였다.

type Mutation {
    createLibraryNode(libraryNodeInput: LibraryNodeInput) : LibraryNode
    moveLibraryNode(id: String, moveToParentId: String) : LibraryNode
}

type Query {
    getChildren(id: String) : [LibraryNode!]
    goBack(parentIdOfCurNodes:String!) : [LibraryNode!]
}

DTO 구현

DTO는 상속 구조로 만들어 복잡도를 늘리는 것 보다는, 하나의 통합 data class로 구현하여 간편하게 관리할 수 있도록 하였다.

LibraryNodeInput

data class LibraryNodeInput(
    val type: LibraryNodeType,
    val referenceId: String?,
    val parentId: String?,
    val name: String?
) {
    fun toDocument(userId: String = "1"): LibraryNode {
        return when (type) {
            FOLDER -> toFolder(userId)
            PLAYLIST -> toPlaylist(userId)
            else -> toLeaf(userId)
        }
    }

    private fun toFolder(userId: String): LibraryNode {
        if (referenceId != null || name == null) {
            throw RuntimeException()
        }
        return LibraryFolderNode(userId = userId, parentId = parentId, name = name)
    }

    private fun toPlaylist(userId: String): LibraryNode {
        if (referenceId != null || name == null) {
            throw RuntimeException()
        }
        return LibraryPlaylistNode(userId = userId, parentId = parentId, name = name)
    }

    private fun toLeaf(userId: String): LibraryNode {
        if(name != null || referenceId == null) {
            throw RuntimeException()
        }
        return LibraryLeafNode(userId = userId, type = type, parentId = parentId, referenceId = referenceId)
    }
}

input DTO의 경우 각각의 타입에대한 유효성 검증 후 도큐먼트 객체로 변환할 수 있도록 구현하였다.

LibraryNodeResponse

data class LibraryNodeResponse(
    val id: String,
    val type: LibraryNodeType,
    val parentId: String? = null,
    val referenceId: String? = null,
    val name: String? = null,
    val children: List<String>? = null,
) {
    companion object {
        fun from(libraryNode: LibraryNode): LibraryNodeResponse {
            return when(libraryNode.type) {
                FOLDER -> fromFolder(libraryNode)
                PLAYLIST -> fromPlaylist(libraryNode)
                else -> fromLeaf(libraryNode)
            }
        }

        private fun fromFolder(libraryNode: LibraryNode): LibraryNodeResponse {
            val libraryFolderNode =  libraryNode as LibraryFolderNode
            return LibraryNodeResponse(
                id = libraryFolderNode.id,
                type = libraryFolderNode.type,
                parentId = libraryFolderNode.parentId,
                name = libraryFolderNode.name,
                children = libraryFolderNode.children.map { it.id },
                childFolderCount = libraryFolderNode.children.count { it.type == FOLDER },
                childPlaylistCount = libraryFolderNode.children.count() { it.type == PLAYLIST}
            )
        }

        private fun fromPlaylist(libraryNode: LibraryNode): LibraryNodeResponse {
            val libraryPlaylistNode =  libraryNode as LibraryPlaylistNode
            return LibraryNodeResponse(
                id = libraryPlaylistNode.id,
                type = libraryPlaylistNode.type,
                parentId = libraryPlaylistNode.parentId,
                name = libraryPlaylistNode.name,
                children = libraryPlaylistNode.children.toList(),
            )
        }

        private fun fromLeaf(libraryNode: LibraryNode): LibraryNodeResponse {
            val libraryLeafNode =  libraryNode as LibraryLeafNode
            return LibraryNodeResponse(
                id = libraryLeafNode.id,
                type = libraryNode.type,
                parentId = libraryNode.parentId,
                referenceId = libraryLeafNode.referenceId
            )
        }
    }
}

Response DTO 또한 통합 필드로 구현해주었다.

GraphQL TypeResolve

스키마에 정의한 타입과 DTO 타입이 매핑되지 않으므로 이를 매핑할 수 있도록 다음과 같이 설정해주었다.

@Bean
fun additionalRuntimeWiringConfigure(): RuntimeWiringConfigurer {
    return RuntimeWiringConfigurer { builder ->
        builder.type(newTypeWiring("LibraryNode")
                .typeResolver {
                    when (it.getObject<LibraryNodeResponse>().type) {
                        FOLDER -> it.schema.getObjectType("LibraryFolderNode")
                        PLAYLIST -> it.schema.getObjectType("LibraryPlaylistNode")
                        else -> it.schema.getObjectType("LibraryLeafNode")
                    }
                })
    }
}

기능 구현

노드 생성 기능은 간단하기 때문에 조회 기능에 대한 코드만 글에 작성하였다.

정방향 탐색
루트로부터 점점 하위 노드로 탐색하는 기능을 다음과 같이 구현하였다.

@QueryMapping
fun getChildren(@Argument id: String?): Flux<LibraryNodeResponse> {
    return libraryService.findChildren(id)
        .map(LibraryNodeResponse::from)
}

fun findChildren(parentId: String?, userId: String = "2"): Flux<LibraryNode> {
    return when {
        parentId == null -> libraryRepository.findByParentIdIsNullAndUserId(userId)
        else -> libraryRepository.findAllByParentId(parentId)
    }
}

parentId 가 null이면 root 노드를 탐색하고, 그렇지 않으면 parentId를 가지는 자식 노드들을 탐색한다.

뒤로가기(역방향 탐색)

@QueryMapping
fun goBack(@Argument parentIdOfCurNodes: String): Flux<LibraryNodeResponse> {
    return libraryService.goBack(parentIdOfCurNodes)
        .map(LibraryNodeResponse::from)
}

fun goBack(parentIdOfCurNodes: String): Flux<LibraryNode> {
    val parentIdOfParentNode = libraryRepository
        .findById(parentIdOfCurNodes)
        .switchIfEmpty(Mono.error(RuntimeException()))

    return parentIdOfParentNode.flatMapMany { findChildren(it.parentId) }
}
}

뒤로 가기를 하기 위해 현재 노드들의 parentId를 활용해 부모 노드를 조회한 뒤, 부모 노드의 parentId를 활용해 다시 findChildren() 메소드를 호출하여 구현하였다.

인덱스 추가

라이브러리의 트리 구조 모식도를 그려보면 다음과 같은 형태로 그려진다.

사용자가 늘어날수록, 사용자별로 노드를 탐색할 때 성능이 빨라질 것이라고 예상했다.

이를 근거로 인덱스를 생성하기 위해 다음과 같은 방안들을 고민하였다.

  • _id 만 활용한 기능 구현
  • parentId 를 Index로 사용
  • userIdparentId를 Compound Index로 사용

DB에 사용자 500만명, 사용자별로 5개, 총 2500만개의 더미 도큐먼트를 저장한 뒤 각각의 케이스를 테스트 해 보았지만, 생각보다 표본이 적었는지 모두 0~1ms 의 성능을 보였다.

하나의 요청에서는 사용자가 가진 노드 범위 내에서 탐색하기 때문에 userIdparentId를 Compound Index로 사용하는 것이 가장 최적일 것이라고 생각하여 다음과 같이 인덱스를 추가하였다.

인덱스 관리 비용이 생각보다 크다는 것을 알 수 있었다. 만약 DB 서버의 여건이 좋지 않다면 Secondary Index는 삭제하고 _id 를 활용해 기능을 구현하는 것이 더 좋은 방안인 것 같다.

GraphQL 기능 확인

GraphiQL을 통해 다음과 같이 기능을 테스트 해보았다. 인터페이스를 활용했기 때문에 inline flagment 를 활용할 수 있다.

노드 추가
아래 mutation으로 폴더와 폴더 하위에 플레이리스트를 하나씩 생성하였다.

mutation CreateNode($libraryNodeInput : LibraryNodeInput) {
  createLibraryNode(libraryNodeInput: $libraryNodeInput) {
    id
    type
    ... on LibraryLeafNode {
      parentId
      referenceId
    }
  }
}

정방향 탐색
아래 query를 통해 루트 노드 목록을 조회한 뒤 폴더의 자식 노드를 검색하였다.

query

query GetChildren {
  getChildren {
    id
    type
    ... on LibraryFolderNode {
      name
      children
    }
    
    ... on LibraryPlaylistNode {
      name
      children
    }
    
    ... on LibraryLeafNode {
      parentId
      referenceId
    }
  }
}

결과

  • 루트 노드

    {
      "data": {
        "getChildren": [
          {
            "id": "65fbc0e993340e5fad1cbd69",
            "type": "ALBUM",
            "parentId": null,
            "referenceId": "65fbc0e993340e5fad1cbd68"
          },
          {
            "id": "65ffc7beaebd606e833dc37e",
            "type": "FOLDER",
            "name": "새 폴더",
            "children": [
              "65ffc7d8aebd606e833dc37f"
            ]
          }
        ]
      }
    }
  • 인자로 루트노드의 _id 를 전달한 결과

    {
      "data": {
        "getChildren": [
          {
            "id": "65ffc7d8aebd606e833dc37f",
            "type": "PLAYLIST",
            "name": "노동요",
            "children": []
          }
        ]
      }
    }

뒤로가기(역방향 탐색)

쿼리

query GoBack {
  goBack(parentIdOfCurNodes: "65ffc7beaebd606e833dc37e") {
    id
    type
    ... on LibraryFolderNode {
      name
      children
    }
    
    ... on LibraryPlaylistNode {
      name
      children
    }
    
    ... on LibraryLeafNode {
      parentId
      referenceId
    }
  }
}

결과

{
  "data": {
    "goBack": [
      {
        "id": "65fbc0e993340e5fad1cbd69",
        "type": "ALBUM",
        "parentId": null,
        "referenceId": "65fbc0e993340e5fad1cbd68"
      },
      {
        "id": "65ffc7beaebd606e833dc37e",
        "type": "FOLDER",
        "name": "새 폴더",
        "children": [
          "65ffc7d8aebd606e833dc37f"
        ]
      }
    ]
  }
}

성공적으로 생성, 조회가 되는 것을 확인하였다.

마무리하며

부모, 자식 양방향 참조를 활용한 Tree 구조 데이터 모델링을 해보았다.
다음에는 Apollo Gateway와 Federation을 활용해 노드를 조회할 때 각 노드의 상세 정보도 조회할 수 있도록 구현해보도록 하겠다.

참고 자료

profile
블로그 이전 -> https://choicco.tistory.com/

0개의 댓글