Beatween을 설계하면서, 단순한 데이터 저장을 넘어서 서비스 목표를 달성할 수 있게 ‘합주서비스를 이용하는 사용자 흐름에 맞는 구조’를 만드는 것이 중요하다고 느꼈습니다. 초기에는 단순하고 빠르게 동작하는 구조를 선택했지만, 실제 기능을 구체화하는 과정에서 사용자 시나리오가 다양해지면서 구조적인 재정비가 필요해졌고, 몇 가지 핵심적인 설계 결정을 내리게 되었습니다. 이 글에서는 그중 가장 중요한 세 가지 구조 변경 과정을 정리하고자 합니다.
1. personal_space와 team_space의 분리 vs 통합
초기에는 개인 스페이스를 따로 테이블로 분리하지 않고, user 테이블이 곧 개인 스페이스의 역할을 하도록 설계했었습니다. 반면 팀 스페이스는 teams 테이블을 별도로 두고 관리했기 때문에, 개인과 팀 스페이스가 서로 다른 방식으로 구성되어 있었습니다. 이처럼 두 스페이스가 이질적인 구조로 운영되다 보니, 스페이스를 기준으로 데이터를 일관되게 참조하거나 처리하기가 구조적으로 어렵다는 한계가 명확히 드러났습니다. 유저가 곡을 생성했을 때 알림을 보내는 처리 흐름에서, 이 구조적 복잡성이 본격적으로 나타났습니다. 알림 테이블에는 스페이스의 종류와 관계없이 하나의 row만 생성되지만, 알림을 실제로 전달하는 방식은 스페이스 유형에 따라 달랐습니다. 개인 스페이스에서는 생성자 본인에게만 알림을 보내면 되지만, 팀 스페이스에서는 해당 팀의 전체 구성원에게 알림을 전송해야 했기 때문에, user_notifications 테이블에는 팀원 수만큼의 row를 추가로 생성해야 했습니다. 따라서 알림 생성 로직에서는 "이 곡이 개인 스페이스에서 생성된 곡인지, 팀 스페이스에서 생성된 곡인지"를 정확히 판단할 수 있어야 했고, 그 판단을 위해 songs 테이블에도 space_type과 같은 보조 필드를 추가하게 되었습니다. 이처럼 판단 로직이 알림을 넘어서 곡 데이터까지 번지게 되면서, 결국 각 테이블마다 스페이스 유형을 구분하기 위한 보조 필드가 하나씩 늘어나기 시작했고, 전반적인 구조는 점점 더 복잡해졌습니다.
이 문제를 해결하기 위해, 개인과 팀 스페이스를 하나의 spaces 테이블로 통합하고, space_type 필드를 통해 PERSONAL과 TEAM을 구분하는 방식으로 구조를 변경하였습니다. 이제는 space_id 하나만 있으면 어떤 스페이스에서 발생한 작업인지 명확하게 알 수 있고, 알림, 곡 생성, 접근 권한 등 다양한 기능에서 중복된 조건 분기 없이 일관된 처리 흐름을 만들 수 있게 되었습니다.
2. 곡 저장 방식: 단일 테이블 vs 원본/복사 테이블 분리
초기에는 곡과 악보를 각각 songs와 sheets라는 하나의 테이블에서 통합 관리하고 있었습니다. 유저가 곡을 생성하면 songs 테이블에 저장되고, 세션 수만큼 악보가 sheets 테이블에 함께 등록되는 구조였습니다. 이때 songs 테이블에는 해당 곡이 DB에 처음 등록된 곡인지, 아니면 기존에 존재하는 곡을 유저가 복사한 것인지를 구분하기 위해 is_original 필드를 사용하였습니다. 예를 들어, 유저 A가 ‘천국’이라는 곡을 생성하면 songs 테이블에 is_original = true로 저장되고, 세션 수만큼의 악보가 AI를 통해 생성되어 sheets 테이블에 삽입됩니다. 이후 유저 B가 동일한 곡명을 생성하면, 유저 A가 생성한 기존 악보들을 조회하여 동일한 구조로 복사한 뒤, sheets 테이블에 새로운 row로 insert하고, 이를 참조하는 형태로 songs 테이블에 is_original = false로 저장합니다. 이 과정에서는 AI 기반 악보 생성을 반복하지 않으며, 기존 데이터를 복제하여 그대로 재사용합니다. 유저가 악보를 편집할 경우에는 해당 세션의 악보만 새로운 row로 추가되며, 편집되지 않은 세션의 악보는 그대로 유지되었습니다. 즉, 복사된 악보는 수정이 발생하는 경우에만 개별적으로 누적 저장되고, 그 외에는 원본 데이터를 공유하는 형태였습니다.
이 구조는 당시 서비스 요구사항을 충족하는 데에는 문제 없다고 판단하였지만, 설계 단계에서 곡 복사, 악보 복사, 편집 여부 등을 다양한 상황별로 분기 처리해야 하는 흐름이 과도하게 복잡하다고 느꼈습니다. 특히 테이블 구조만으로는, 유저가 각자의 복사본을 기준으로 악보를 편집하고, 그 이력이 누적되는 구조를 데이터 모델 차원에서 명확하고 직관적으로 표현하기 어려운 부분이 있었습니다. 그래서 현재는 original_songs, copy_songs, original_sheets, copy_sheets로 구조를 명확히 분리하여, 원본은 보존하고, 유저별 복사본은 독립적으로 관리하는 방식으로 전환하였습니다. 유저가 곡을 생성할 경우, 해당 곡이 원본 DB에 없다면 original_songs에 저장되고, 동시에 copy_songs에도 복사본이 자동 등록됩니다. 악보 또한 original_sheets를 기반으로 복사되어 copy_sheets에 저장되며, 유저는 자신의 복사본 악보만 수정할 수 있도록 구조화하였습니다. 이제는 원본 곡과 복사본이 물리적으로 분리되어 있기 때문에, 조회 시 is_original 여부를 조건으로 판단하거나 불필요한 분기 처리를 하지 않아도 되며, 합주 서비스 상의 사용자 협업 흐름도 훨씬 깔끔하게 유지할 수 있는 구조가 되었다고 판단하고 있습니다.
3. copy_songs의 정규화 설계
초기 설계에서는 copy_songs 테이블이 space_id와 category_id를 모두 직접 참조하는 구조였습니다. 또한 spaces와 categories 역시 1:N 관계로 연결되어 있었기 때문에, 하나의 곡이 스페이스를 직접 참조하는 동시에, 자신이 속한 카테고리를 통해서도 간접적으로 동일한 스페이스를 참조하는 구조가 되었습니다. 이 방식은 copy_songs 테이블에서 스페이스 기준으로 곡을 바로 조회할 수 있기 때문에, 조회 측면에서의 단순성과 효율성이라는 분명한 장점이 있었습니다. 그러나 구조적으로는 동일한 정보를 두 경로로 중복 참조하고 있어, 데이터 흐름을 복잡하게 만들고 관리 포인트를 불필요하게 증가시키는 문제가 있었습니다. 특히 copy_songs.space_id와 categories.space_id는 항상 동일하게 설정되도록 삽입 로직을 관리하면 정합성에 문제가 발생하지는 않지만, 의미상 중복이 존재하고, 이를 동기화하는 책임이 애플리케이션 로직에 전가된다는 점에서 바람직하지 않은 구조였습니다.
무엇보다 저희 서비스에서는 모든 곡은 하나의 카테고리에 반드시 속해야 하며, 각 카테고리는 특정 스페이스에 종속되고, 스페이스가 생성될 때는 해당 스페이스에 귀속된 "기본" 카테고리가 자동으로 함께 생성되도록 설계되어 있습니다. 따라서 copy_songs는 category_id만 가지고 있어도 해당 곡이 어떤 스페이스에 속해 있는지를 카테고리를 통해 추론하는 데 전혀 문제가 없었습니다. 이러한 이유로 최종적으로는 copy_songs에서 space_id를 제거하고, category_id만을 통해 간접적으로 스페이스와 연결되도록 정규화된 구조를 확정하였습니다. 조회 시에는 JOIN이 필요하지만, 데이터 흐름을 단일 경로로 유지하고 정합성을 구조 차원에서 보장할 수 있다는 점에서 장기적인 유지보수와 서비스 확장 측면에서 더 안정적이고 일관된 선택이었다고 판단하였습니다.