Skip to content

Conversation

jinhokim98
Copy link
Contributor

@jinhokim98 jinhokim98 commented Oct 6, 2025

issue

모달이 필요해서 feature/#134 브랜치를 rebase하고 그 위에 작업을 쌓았습니다. 134 브랜치를 먼저 머지해야합니다.

구현 사항

문서 수정 시에 내가 수정하던 도중 다른 사람이 문서를 수정했을 때 덮어씌워지는 문제가 있었습니다.
이 문제를 개선하고자 문서 충돌 기능을 구현했습니다.

문서 충돌 판단 기준

문서 수정하기 페이지에 진입했을 때 서버에서 최신의 문서를 가져옵니다 (최신 버전을 기준으로 수정해야하므로)
이 때 현재 문서의 최신 버전 latestVersion을 가져오도록 기능이 추가되었습니다. 이를 사용해서 판단합니다.
사용자가 문서를 수정하고 버튼을 누를 때, 페이지 진입 시 가져왔던 버전과 버튼을 누를 시점의 버전이 다르다면 내가 수정하던 도중 다른 사람이 문서를 수정한 경우입니다.

이 때 충돌 모달을 띄워서 사용자에게 충돌을 직접 해결하도록 했습니다. 충돌 표시는 git과 유사하게 했습니다.
(크루위키를 사용하는 유저들은 모두 개발자임으로 git 충돌 해결하는 방법은 알겠죠?ㅋㅋㅋㅋㅋㅋ)

문서 충돌 해결방법

git merge나 rebase conflict의 경우 current, incoming 두 가지 정보를 보여줍니다.
비슷하게 incoming을 최신 버전, current를 내 작업으로 처리해서 사용자에게 보여줍니다.
구분을 <<<<<와 ------으로 했으며 이 문구가 모두 지워지면 충돌 해결입니다.

TuiEditor onChange 외부에서 넣어줄 수 있도록 개선

문서 충돌 해결 모달 내에서도 TuiEditor를 사용합니다.
이전에 TuiEditor를 수정하기, 작성하기 페이지에서만 사용했기 때문에 내부에서 zustand로 onChange를 받아와서 컴포넌트 내에서 수정해주었습니다. 그러나 이제 이 두 페이지 외 모달에서도 TuiEditor를 사용해야했기 때문에 onChange를 외부에서 받아와서 사용할 수 있도록 개선했습니다. (이 방향성이 맞긴하지)

시연 영상

참고로 dev 환경 캐시 업데이트 문제로 수정 완료 시 바로 최신 데이터가 보이지 않아요.

1. 정상 수정

2025-10-06.20-29-26.mp4

2. 문서 충돌 상황

2025-10-06.20-29-57.mp4

3. 문서 재충돌 상황

2025-10-06.20-30-31.mp4

🫡 참고사항

아래는 제가 기능을 구현하면서 작성한 기능명세서입니다.
초안을 작성하고 AI에게 검토 및 보완을 부탁했는데 퀄리티가 꽤 좋은 것 같아서 공유합니다.
이 내용을 읽으면 더 이해가 잘 될거에요.

문서 충돌 해결 기능 명세서

1. 개요

사용자가 문서를 편집하고 저장할 때, 다른 사용자가 먼저 동일한 문서를 수정하여 발생할 수 있는 데이터 유실(덮어쓰기) 문제를 방지합니다. 충돌이 감지되면 사용자에게 두 버전의 차이점을 보여주는 UI를 제공하고, 사용자가 직접 내용을 병합하여 안전하게 저장할 수 있도록 돕는 기능을 구현합니다.


2. 사용자 시나리오

2.1. 충돌이 발생하는 경우

  1. 사용자 A가 '문서 X' (버전 1)의 편집 페이지에 진입합니다.
  2. 사용자 B가 동시에 '문서 X' (버전 1)의 편집 페이지에 진입합니다.
  3. 사용자 A가 내용을 수정한 뒤 '작성완료' 버튼을 클릭하여 버전 2를 생성합니다.
  4. 사용자 B가 내용을 수정한 뒤 '작성완료' 버튼을 클릭합니다.
  5. 시스템은 사용자 B가 편집을 시작한 버전(1)과 현재 최신 버전(2)이 다름을 감지합니다.
  6. 사용자 B에게 '문서 충돌 해결' 모달이 나타납니다.
  7. 모달 내 편집기에는 **사용자 A가 수정한 내용(최신 버전)**과 **사용자 B가 수정한 내용(내 작업)**이 충돌 마커(e.g., <<<<<, >>>>>)와 함께 표시됩니다.
  8. 사용자 B는 편집기 내에서 두 내용을 확인하고, 직접 텍스트를 수정하여 최종 버전을 만듭니다. (충돌 마커를 모두 제거합니다.)
  9. 충돌 마커가 모두 제거되면 '충돌 해결 완료' 버튼이 활성화됩니다.
  10. 사용자 B가 '충돌 해결 완료' 버튼을 클릭하면, 병합된 내용으로 문서가 최종 저장됩니다.

2.2. 충돌이 발생하지 않는 경우

  1. 사용자 A가 '문서 X' (버전 1)의 편집 페이지에 진입합니다.
  2. 사용자 A가 내용을 수정한 뒤 '작성완료' 버튼을 클릭합니다.
  3. 시스템은 사용자가 편집을 시작한 버전(1)과 현재 최신 버전(1)이 동일함을 확인합니다.
  4. 정상적으로 수정 내용이 저장되고 버전 2가 생성됩니다.

3. 기능 명세

3.1. 버전 충돌 감지

  • 시점: 문서 편집 페이지에서 '작성완료' 버튼 클릭 시.
  • 전제 조건:
    • 문서 편집 페이지 로드 시, 해당 문서의 내용과 **현재 버전(originalVersion)**을 클라이언트 상태로 저장하고 있어야 합니다.
  • 프로세스:
    1. '작성완료' 버튼 클릭 이벤트를 가로챕니다.
    2. GET /document/{uuid}/log API를 호출하여 문서의 최신 로그 정보를 가져옵니다.
    3. API 응답에서 가장 최근 로그의 버전(latestVersion)을 확인합니다.
    4. 클라이언트에 저장된 originalVersion과 서버에서 받은 latestVersion을 비교합니다.
    5. originalVersion !== latestVersion 이면 충돌로 간주하고, 3.2의 '충돌 해결 UI'를 실행합니다.
    6. originalVersion === latestVersion 이면 정상 저장 로직을 수행합니다.

3.2. 충돌 해결 UI (모달)

  • 구현 방식: React Portal (CreatePortal)을 사용하여 document.body의 최상단에 렌더링합니다.
  • 구성 요소:
    1. 모달 컨테이너:
      • 모달 외부의 어두운 배경(Overlay)을 가집니다.
      • 배경 클릭 시 모달이 닫히고, 문서 저장 작업은 취소됩니다. 사용자에게 "저장되지 않은 변경사항이 있습니다. 정말 나가시겠습니까?"와 같은 확인(confirm) 메시지를 띄우는 것을 권장합니다.
    2. 제목: "문서 충돌 해결"
    3. 설명: "다른 사용자가 문서를 수정했습니다. 아래 내용을 병합하여 저장해주세요."
    4. 편집기(Editor):
      • 3.3의 비교 알고리즘을 통해 생성된 병합용 텍스트를 표시합니다.
      • 최신 버전(Remote)과 내 작업(Local) 내용은 시각적으로 구분되어야 합니다.
        • <<<<< 최신 버전 ~ ===== 구간: 배경색 (e.g., rgba(255, 0, 0, 0.1))
        • ===== ~ >>>>> 내 작업 구간: 배경색 (e.g., rgba(0, 0, 255, 0.1))
    5. '충돌 해결 완료' 버튼:
      • 초기 상태: 비활성화(disabled).
      • 활성화 조건: 편집기 내의 텍스트에서 <<<<<, =====, >>>>> 패턴의 문자열이 모두 사라졌을 때 활성화됩니다. (실시간으로 편집기 내용을 파싱하여 상태를 업데이트해야 합니다.)
      • 클릭 이벤트: 클릭 시, 3.4의 '충돌 해결 및 저장' 로직을 실행합니다.
    6. '취소' 버튼 또는 닫기(X) 아이콘:
      • 클릭 시 모달이 닫히고, 문서 저장 작업은 취소됩니다. (외부 영역 클릭과 동일)

3.3. 충돌 내용 비교 알고리즘

  • 목표: 두 텍스트(서버의 최신 버전, 사용자의 현재 편집 내용)를 비교하여 Git과 유사한 충돌 형식의 문자열을 생성합니다.
  • 알고리즘 제안:
    • Levenshtein 알고리즘은 두 문자열 간의 유사도를 측정하는 데는 유용하지만, 어떤 내용이 추가/삭제/변경되었는지 시각적으로 보여주는 'diff' 결과물을 만드는 데는 적합하지 않습니다.
    • Line-by-line Diff 알고리즘 (예: Myers Diff Algorithm)을 사용하는 것을 권장합니다. 이 방식은 줄 단위로 변경 사항을 추적하여 Git이 보여주는 것과 유사한 결과를 생성할 수 있습니다.
    • 추천 라이브러리: diff-match-patch 또는 jsdiff 와 같은 검증된 라이브러리 사용을 권장합니다. 이 라이브러리들은 두 텍스트를 비교하여 변경된 부분을 상세히 알려주므로, 이를 가공하여 <<<<< 형식의 결과물을 쉽게 만들 수 있습니다.
  • 프로세스:
    1. 충돌이 감지되면, 서버로부터 최신 버전의 문서 내용(remoteContent)을 추가로 가져옵니다. (GET /document/{uuid})
    2. 사용자가 편집 중인 내용(localContent)과 remoteContent를 Diff 라이브러리에 전달합니다.
    3. 라이브러리가 반환한 diff 결과를 파싱하여 아래 형식의 문자열로 재조합합니다.
      공통 내용...
      <<<<< 최신 버전
      서버의 최신 내용 중 다른 부분
      =====
      내가 수정한 내용 중 다른 부분
      >>>>> 내 작업
      공통 내용...

3.4. 충돌 해결 및 저장

  • 시점: '충돌 해결 완료' 버튼 클릭 시.
  • 프로세스:
    1. 충돌 해결 편집기 내부의 최종 텍스트 전체를 가져옵니다.
    2. 기존의 문서 수정 API (POST /document/{uuid})를 호출합니다.
    3. API 요청 본문(body)에 최종 텍스트를 담아 전송합니다.
    4. API 호출이 성공하면, 사용자에게 "성공적으로 저장되었습니다"와 같은 피드백을 주고 편집 페이지를 벗어나거나 뷰 페이지로 이동시킵니다.
    5. [중요] 재충돌 방지: '충돌 해결 완료' 버튼을 눌러 저장하는 시점에도 그 사이에 또 다른 버전이 생겼을 수 있습니다. 따라서 저장 직전에 GET /document/{uuid}/log를 다시 호출하여 latestVersion이 이전 단계(3.1)에서 확인한 latestVersion과 동일한지 한 번 더 확인하는 로직을 추가하는 것을 강력히 권장합니다. 만약 또 충돌했다면, 사용자에게 "병합하는 동안 새로운 변경사항이 생겼습니다. 페이지를 새로고침하여 다시 시도해주세요." 와 같은 메시지를 보여주고 프로세스를 처음부터 다시 시작하도록 유도합니다.

4. 테스트 케이스

4.1. 정상 케이스

  • TC-N-01: 충돌 없이 문서가 정상적으로 저장된다.
  • TC-N-02: 충돌 발생 후, 사용자가 내용을 성공적으로 병합하고 '충돌 해결 완료' 버튼을 눌러 저장에 성공한다.
  • TC-N-03: 충돌 해결 UI에서 충돌 마커(<<<<< 등)가 하나라도 남아있으면 '충돌 해결 완료' 버튼이 비활성화 상태를 유지한다.
  • TC-N-04: 충돌 마커를 모두 제거하면 '충돌 해결 완료' 버튼이 즉시 활성화된다.

4.2. 예외 케이스

  • TC-E-01: '작성완료' 클릭 시 /document/{uuid}/log API 호출에 실패하면, 사용자에게 "최신 버전을 확인하는데 실패했습니다. 잠시 후 다시 시도해주세요." 와 같은 에러 메시지를 표시한다.
  • TC-E-02: 충돌 해결 UI를 띄우기 위해 최신 문서 내용을 가져오는 API (GET /document/{uuid}) 호출에 실패하면, "최신 내용을 가져오는데 실패했습니다." 에러 메시지를 표시한다.
  • TC-E-03: 충돌 해결 모달의 외부 영역이나 '취소' 버튼을 클릭하면, 저장 프로세스가 중단되고 모달이 닫힌다.
  • TC-E-04: '충돌 해결 완료' 버튼을 눌러 최종 저장을 시도했으나 API 호출에 실패하면, "저장에 실패했습니다." 에러 메시지를 표시하고 편집기 내용은 그대로 유지한다.
  • TC-E-05 (재충돌): 충돌을 해결하고 저장하는 사이 다른 사용자가 또 문서를 수정했을 경우(3.4의 재충돌 방지 로직), "병합하는 동안 새로운 변경사항이 생겼습니다..." 메시지를 표시하고 저장을 막는다.

4.3. 경계 케이스

  • TC-B-01: 완전히 비어있는 문서에서 두 사용자가 각각 내용을 추가하고 저장하여 충돌이 발생했을 때, diff가 정상적으로 표시되는지 확인한다.
  • TC-B-02: 문서 전체 내용이 완전히 다르게 수정되었을 때, 문서 전체가 충돌 블록으로 감싸져 표시되는지 확인한다.
  • TC-B-03: 문서의 맨 첫 줄과 맨 마지막 줄에서 동시에 수정이 일어나 충돌했을 때, diff가 정상적으로 표시되는지 확인한다.
  • TC-B-04: 한 사용자는 내용을 추가하고 다른 사용자는 내용을 삭제하여 충돌이 발생했을 때, diff가 정상적으로 표시되는지 확인한다.

@jinhokim98 jinhokim98 self-assigned this Oct 6, 2025
@jinhokim98 jinhokim98 added 🖥️ FE Frontend ⚙️ feat feature labels Oct 6, 2025
@jinhokim98 jinhokim98 added this to the v3.1.0 milestone Oct 6, 2025
@jinhokim98 jinhokim98 linked an issue Oct 6, 2025 that may be closed by this pull request
1 task
@jinhokim98 jinhokim98 moved this to In Review in crew-wiki-7-FE Oct 6, 2025
@jinhokim98 jinhokim98 changed the title 문서 충돌 기능 구현 문서 수정 Conflict 기능 구현 Oct 6, 2025
Copy link
Contributor

@chosim-dvlpr chosim-dvlpr left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

오 드디어 이 기능이 구현되었군요 ㅋㅋㅋ관련해서 궁금한 점은 코멘트 달아두었습니다!!
고생하셨어요~~~!

Comment on lines 98 to 99
router.push(`${URLS.wiki}/${uuid}`);
router.refresh();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

postDocumentputDocument에서 요청이 성공한 경우 라우팅을 하도록 유도하고 있어서 코드가 중복될 것 같아요!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

오~ 감사합니다. 전혀 모르고 있었네요..!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment on lines 33 to 47
<Modal>
<h2 className="mb-4 text-2xl font-bold">문서 충돌 해결</h2>
<p className="mb-4">다른 사용자가 문서를 수정했습니다. 아래 내용을 병합하여 저장해주세요.</p>
<div className="mb-4">
<TuiEditor initialValue={initialContent} onChange={setContent} />
</div>
<div className="flex justify-end gap-2">
<Button style="tertiary" size="m" onClick={() => modal.close()}>
취소
</Button>
<Button style="primary" size="m" onClick={handleResolve} disabled={!isResolved}>
충돌 해결 완료
</Button>
</div>
</Modal>,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

컴포넌트 부분을 따로 분리하면 좋을 것 같아요!~

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

좋습니다~ 분리해봤어요.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if (newLatest && conflict.version !== newLatest.latestVersion) {
conflictModal.closeWithReject();
alert('병합하는 동안 새로운 변경사항이 생겼습니다. 다시 충돌을 해결해주세요.');
const conflictText = createConflictText(newLatest.contents, resolvedContent);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

충돌이 여러 곳에서 발생해도 conflictText는 모든 충돌을 하나로 묶어 한 번만 생성되는 걸까요?!

Copy link
Contributor Author

@jinhokim98 jinhokim98 Oct 7, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

네 맞아요.. 이게 좀 아쉽긴 하네요 여러 곳에서 잡을 수 있으면 더 좋을 것 같은데
이건 시간이 남으면 더 시도해볼게요!

그래도 충돌은 잡을 수 있으니!

Comment on lines 34 to 55
const handleResolve = async (resolvedContent: string) => {
try {
// 재충돌 방지
const newLatest = await getDocumentByUUIDClient(uuid);

// 충돌 시 새 버전과 새로 불러온 버전이 다르다면 다시 충돌상황
if (newLatest && conflict.version !== newLatest.latestVersion) {
conflictModal.closeWithReject();
alert('병합하는 동안 새로운 변경사항이 생겼습니다. 다시 충돌을 해결해주세요.');
const conflictText = createConflictText(newLatest.contents, resolvedContent);
setConflict({version: newLatest.latestVersion, content: conflictText});
conflictModal.open();
return;
}

await handleSubmit(resolvedContent);
conflictModal.close(true);
} catch (error) {
console.error(error);
alert('저장에 실패했습니다.');
}
};
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이 핸들러의 위치는 65번 라인보다 아래쪽에 위치하면 좋을 것 같아요!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

저도 그 부분이 더 좋았는데, 이게.. 이거 때문에 위에 적었어요..
Block-scoped variable 'handleResolve' used before its declaration.ts(2448)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이 근본적인 원인을 해결했어요. PostHeader에 너무 많은 기능이 있었는데 충돌 기능때문에 더 많아져서, 충돌 관련 로직을 useConflictModal로 분리했습니다. 확실히 더 보기 좋은 것 같아요.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

좋습니다👍


const onSubmit = async () => {
if (mode === 'edit') {
await handleConflictCheck();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이 연산을 수행할 때 로딩이 추가되면 좋을 것 같네용 문서의 길이가 길면 시간이 오래 걸릴 수도 있을 것 같아서요!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

좋아요~ 반영해봤습니다.
d48b6bf

@jinhokim98 jinhokim98 modified the milestones: v3.0.3, v3.1.0 Oct 12, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

Status: In Review

Development

Successfully merging this pull request may close these issues.

문서 수정 Conflict 기능 구현

2 participants