POSTABOUT

양방향 데이터 동기화 이슈를 SSOT 관점으로 해결하기

2026-05-20

2026년 2월, 양방향 링크 데이터를 사용하는 기능에서 한쪽 데이터만 갱신되는 문제가 발생했다.
이 글은 불일치가 발생한 원인과, 기준 데이터를 하나로 정해 해결한 과정을 정리한 기록이다.

기능을 개발하다 보면 두 데이터가 서로를 참조해야 하는 경우가 있다.

예를 들어 GroupItem이 서로 연결되는 구조라고 해보자.

  • Group은 자신에게 연결된 Item 목록을 가진다.
  • Item은 자신이 연결된 Group 목록을 가진다.

처음에는 양쪽 데이터를 모두 가지고 있는 구조가 자연스럽게 보였다.
각 화면에서 필요한 값을 바로 조회할 수 있고, 데이터도 직관적으로 보였기 때문이다.

하지만 링크를 추가하고 해제하는 기능이 들어가면서 문제가 발생했다.
양쪽 값을 모두 직접 수정하다 보니, 특정 케이스에서 두 데이터가 서로 다른 상태를 가지게 됐다.


문제 상황

문제는 양방향으로 연결된 데이터를 수정하는 과정에서 발생했다.

예시로 아래와 같은 구조가 있다고 가정한다.

type Group = {
  id: string;
  linkedItemIds: string[];
};
 
type Item = {
  id: string;
  linkedGroupIds: string[];
};

Group은 연결된 Item의 id 목록을 가지고 있고, Item은 자신이 연결된 Group의 id 목록을 가지고 있다.

const groups: Group[] = [
  {
    id: "group-a",
    linkedItemIds: ["item-1", "item-2"],
  },
  {
    id: "group-b",
    linkedItemIds: ["item-2", "item-3"],
  },
];
 
const items: Item[] = [
  {
    id: "item-1",
    linkedGroupIds: ["group-a"],
  },
  {
    id: "item-2",
    linkedGroupIds: ["group-a", "group-b"],
  },
  {
    id: "item-3",
    linkedGroupIds: ["group-b"],
  },
];

이 상태에서 group-aitem-2의 연결을 해제하면, 정상적으로는 아래 두 값이 함께 변경되어야 한다.

const groupA = {
  id: "group-a",
  linkedItemIds: ["item-1"],
};
 
const item2 = {
  id: "item-2",
  linkedGroupIds: ["group-b"],
};

하지만 실제 구현에서는 데이터 수정 경로가 여러 곳에 있었다.

  • Group 기준 화면에서 Item 연결을 수정하는 경우
  • Item 기준 화면에서 Group 연결을 수정하는 경우
  • 목록에서 여러 데이터를 한 번에 연결하는 경우
  • 수정 후 일부 데이터만 다시 조회하는 경우

수정 경로가 늘어나면서 양쪽 값을 항상 같은 규칙으로 갱신하기 어려워졌다.


재현 케이스

문제가 된 케이스는 연결 해제 후 한쪽 데이터만 갱신되는 상황이었다.

group-aitem-2의 연결을 해제했을 때, Group 데이터는 변경되었지만 Item 데이터에는 이전 연결 값이 남아 있었다.

const groups: Group[] = [
  {
    id: "group-a",
    linkedItemIds: ["item-1"],
  },
];
 
const items: Item[] = [
  {
    id: "item-2",
    linkedGroupIds: ["group-a", "group-b"],
  },
];

group-a 입장에서는 item-2와 연결이 끊어진 상태다.
그런데 item-2 입장에서는 여전히 group-a와 연결되어 있다고 보고 있다.

즉, 같은 관계를 두 데이터가 서로 다르게 알고 있는 상태가 됐다.


원인 분석

처음에는 단순히 양쪽 값을 모두 수정하면 된다고 생각했다.

const unlinkItemFromGroup = (
  groupId: string,
  itemId: string,
  groups: Group[],
  items: Item[],
): void => {
  const group = groups.find((group) => group.id === groupId);
  const item = items.find((item) => item.id === itemId);
 
  if (group) {
    group.linkedItemIds = group.linkedItemIds.filter((id) => id !== itemId);
  }
 
  if (item) {
    item.linkedGroupIds = item.linkedGroupIds.filter((id) => id !== groupId);
  }
};

예제만 보면 문제가 없어 보인다.
하지만 실제 코드에서는 같은 수정 규칙을 여러 화면과 함수에서 반복해서 지켜야 했다.

1. 양방향 직접 수정

양방향 데이터를 모두 직접 수정하려면, 링크가 변경되는 모든 지점에서 같은 규칙을 적용해야 한다.

// Group 기준 화면에서 연결 해제
group.linkedItemIds = group.linkedItemIds.filter((id) => id !== itemId);
item.linkedGroupIds = item.linkedGroupIds.filter((id) => id !== groupId);
 
// Item 기준 화면에서 연결 해제
item.linkedGroupIds = item.linkedGroupIds.filter((id) => id !== groupId);
group.linkedItemIds = group.linkedItemIds.filter((id) => id !== itemId);

문제는 이런 로직이 한 곳에만 있지 않다는 점이다.
화면이 늘어나거나 수정 케이스가 늘어날수록 “양쪽 값을 같이 맞춘다”는 규칙이 여러 함수에 흩어진다.
그중 한 곳에서만 반영이 누락되어도 데이터가 바로 어긋날 수 있다.

2. 복구 기준 부재

불일치가 발생했을 때 더 큰 문제는 어느 쪽 값을 믿어야 하는지 판단하기 어렵다는 점이었다.

const group = {
  id: "group-a",
  linkedItemIds: ["item-1"],
};
 
const item = {
  id: "item-2",
  linkedGroupIds: ["group-a", "group-b"],
};

양쪽 값을 모두 원본 데이터처럼 다루면, 불일치가 생겼을 때 복구 기준이 사라진다.
문제의 원인은 양방향 데이터 자체가 아니라, 두 데이터를 모두 수정 가능한 원본처럼 다루고 있었다는 점이었다.

3. 화면별 데이터 차이

양방향 데이터 불일치는 바로 눈에 띄지 않을 수 있다.
예를 들어 Group 기준 화면에서는 linkedItemIds만 사용한다면 정상처럼 보인다.

group.linkedItemIds; // ["item-1"]

하지만 Item 기준 화면에서는 다른 결과가 보일 수 있다.

item.linkedGroupIds; // ["group-a", "group-b"]

각 화면에서는 자기 데이터만 보고 있기 때문에, 한 화면에서는 정상이고 다른 화면에서는 이상한 상태가 된다.
따라서 이 문제는 UI 표시 문제가 아니라, 데이터의 수정 책임과 복구 기준을 정해야 하는 문제로 봐야 했다.


해결 방향

해결 방향은 양쪽 값을 더 꼼꼼히 동시에 수정하는 것이 아니었다.
신뢰할 수 있는 기준 데이터를 하나로 정하고, 반대편 데이터는 기준 데이터에서 다시 만드는 방식이 더 적합했다.

이 문제를 정리하면서 SSOT, 즉 Single Source of Truth라는 개념을 적용할 수 있었다.
이번 구조에서는 Group.linkedItemIds를 기준 데이터로 두고, Item.linkedGroupIds는 기준 데이터로부터 만들어지는 파생 데이터로 다뤘다.

type Group = {
  id: string;
  linkedItemIds: string[]; // 기준 데이터
};
 
type Item = {
  id: string;
  linkedGroupIds: string[]; // 기준 데이터로부터 만들어지는 파생 데이터
};

역할은 아래처럼 나눴다.

데이터역할
Group.linkedItemIds실제 수정 기준
Item.linkedGroupIds기준 데이터로부터 만들어지는 파생 데이터
rebuildItemLinksFromGroupsGroup 기준으로 Item의 링크 정보를 다시 구성

즉, Item.linkedGroupIds를 직접 수정하지 않는다.
Item 입장에서 연결된 Group 목록이 필요하더라도, 그 값은 Group.linkedItemIds를 기준으로 다시 만들어준다.


구현 방법

1. 기준 데이터와 파생 데이터

먼저 데이터의 책임을 나눴다.

type Group = {
  id: string;
  linkedItemIds: string[];
};
 
type Item = {
  id: string;
  linkedGroupIds: string[];
};

Group.linkedItemIds는 수정 가능한 기준 데이터로 사용한다.

const groups: Group[] = [
  {
    id: "group-a",
    linkedItemIds: ["item-1", "item-2"],
  },
  {
    id: "group-b",
    linkedItemIds: ["item-2", "item-3"],
  },
];

반면 Item.linkedGroupIds는 직접 수정하지 않고, 동기화 함수에서 다시 채우는 값으로 둔다.

const items: Item[] = [
  {
    id: "item-1",
    linkedGroupIds: [],
  },
  {
    id: "item-2",
    linkedGroupIds: [],
  },
  {
    id: "item-3",
    linkedGroupIds: [],
  },
];

2. 파생 데이터 재구성

Group.linkedItemIds를 기준으로 Item.linkedGroupIds를 다시 구성하는 함수를 만들었다.

const getItemById = (items: Item[], itemId: string): Item | undefined => {
  return items.find((item) => item.id === itemId);
};
 
const rebuildItemLinksFromGroups = (groups: Group[], items: Item[]): void => {
  items.forEach((item) => {
    item.linkedGroupIds = [];
  });
 
  for (const group of groups) {
    for (const itemId of group.linkedItemIds) {
      const item = getItemById(items, itemId);
 
      if (!item) continue;
 
      if (!item.linkedGroupIds.includes(group.id)) {
        item.linkedGroupIds.push(group.id);
      }
    }
  }
};

이 함수는 크게 세 단계로 동작한다.

  1. 모든 Item.linkedGroupIds를 초기화한다.
  2. Group.linkedItemIds를 기준으로 전체 링크 관계를 다시 순회한다.
  3. Item에 연결된 Group id를 다시 채운다.

기존 Item.linkedGroupIds를 부분 수정하지 않고 먼저 초기화하는 이유는, 이미 잘못 남아 있던 연결 값까지 함께 제거하기 위해서다.

3. 링크 해제 흐름

링크를 해제할 때는 Group.linkedItemIds만 수정한다.

const unlinkItemFromGroup = (
  groupId: string,
  itemId: string,
  groups: Group[],
  items: Item[],
): void => {
  const group = groups.find((group) => group.id === groupId);
 
  if (!group) return;
 
  group.linkedItemIds = group.linkedItemIds.filter((id) => id !== itemId);
 
  rebuildItemLinksFromGroups(groups, items);
};

Item.linkedGroupIds는 직접 건드리지 않는다.

// 하지 않음
item.linkedGroupIds = item.linkedGroupIds.filter((id) => id !== groupId);

기준 데이터인 Group.linkedItemIds를 수정한 뒤, 동기화 함수를 호출해 Item.linkedGroupIds를 다시 만든다.

4. 링크 추가 흐름

링크를 추가할 때도 같은 규칙을 적용한다.

const linkItemToGroup = (
  groupId: string,
  itemId: string,
  groups: Group[],
  items: Item[],
): void => {
  const group = groups.find((group) => group.id === groupId);
 
  if (!group) return;
 
  if (!group.linkedItemIds.includes(itemId)) {
    group.linkedItemIds.push(itemId);
  }
 
  rebuildItemLinksFromGroups(groups, items);
};

추가할 때도 Item.linkedGroupIds는 직접 수정하지 않는다.

  • 쓰기는 Group.linkedItemIds에만 한다.
  • Item.linkedGroupIds는 다시 계산한다.

이 규칙으로 링크 추가/해제 흐름에서 수정 책임을 한쪽으로 제한했다.


고려사항

1. 기준 데이터 선택

이번 예제에서는 Group.linkedItemIds를 기준 데이터로 정했다.
어떤 데이터를 기준으로 둘지는 기능의 흐름에 따라 달라질 수 있다.

  • 사용자가 주로 어느 화면에서 링크를 수정하는가
  • 생성/수정 API는 어느 데이터를 기준으로 동작하는가
  • 서버 응답에서 더 신뢰할 수 있는 값은 무엇인가
  • 불일치가 발생했을 때 어떤 값으로 복원하는 것이 자연스러운가

중요한 것은 특정 엔티티를 기준으로 삼는 것이 아니라, 하나의 수정 기준을 명확히 정하는 것이다.

2. 파생 데이터 초기화

파생 데이터는 기존 값을 믿고 부분 수정하는 방식보다, 기준 데이터에서 다시 만드는 방식이 안전했다.

item.linkedGroupIds = item.linkedGroupIds.filter((id) => id !== groupId);

위처럼 변경된 부분만 제거하면, 이전에 잘못 들어간 값이 남아 있을 수 있다.
그래서 재구성 함수에서는 먼저 값을 초기화한 뒤 기준 데이터로 다시 채웠다.

items.forEach((item) => {
  item.linkedGroupIds = [];
});

초기화 후 재구성하면 이전에 남아 있던 잘못된 링크도 함께 정리된다.

3. 수정 경로 제한

동기화 함수를 만들더라도 다른 코드에서 다시 Item.linkedGroupIds를 직접 수정하면 같은 문제가 반복될 수 있다.

그래서 링크 관계의 수정 규칙을 아래처럼 제한했다.

링크 관계의 수정은 기준 데이터에서만 한다.
반대편 데이터는 기준 데이터로부터 동기화한다.

이 규칙을 코드 전체에서 지켜야, 화면이나 함수가 추가되어도 같은 불일치가 다시 발생하지 않는다.


결과

수정 후 링크 추가/해제 흐름은 기준 데이터인 Group.linkedItemIds만 변경하도록 정리됐다.
Item.linkedGroupIds는 매번 Group.linkedItemIds를 기준으로 재구성했다.

이 방식으로 얻은 결과는 다음과 같았다.

  • 양방향 링크 데이터의 수정 기준이 명확해졌다.
  • 링크 추가/해제 시 양쪽 값을 모두 직접 수정하지 않아도 됐다.
  • 불일치가 발생해도 기준 데이터를 바탕으로 다시 복원할 수 있게 됐다.
  • 화면별로 흩어질 수 있던 보정 로직을 하나의 동기화 흐름으로 모을 수 있었다.
  • “어떤 값이 맞는 값인가?”를 판단하는 기준이 생겼다.

정리

이 이슈의 핵심은 양방향 배열을 어떻게 동시에 수정하느냐가 아니었다.
문제의 원인은 양방향 데이터 자체가 아니라, 두 데이터를 모두 수정 가능한 원본처럼 다루고 있었다는 점이었다.

따라서 Group.linkedItemIds를 기준 데이터로 두고, Item.linkedGroupIds는 기준 데이터에서 재구성하는 방식으로 변경했다.

양방향 데이터 구조를 다룰 때는 “어떻게 양쪽을 동시에 수정할까?”보다 “어느 쪽을 기준으로 둘 것인가?”를 먼저 정해야 한다.