Rapid Diffs
GitLab v19.5요약
Rapid Diffs는 GitLab을 위한 고성능 diff 렌더링 시스템입니다. [!NOTE] 머지 리퀘스트 페이지에는 rapid_diffs_on_mr_show 기능 플래그가 필요합니다. Rapid Diffs의 주된 초점은 체감 성능, 즉 요청부터 화면에 첫 diff가 렌더링되기까지의 시간을 최소화하는 것입니다.
Rapid Diffs는 GitLab을 위한 고성능 diff 렌더링 시스템입니다. Rapid Diffs는 서버에서 렌더링한 HTML, Web Components, HTTP 스트리밍을 사용해 머지 리퀘스트, 커밋, 비교 페이지에서 코드 변경 사항을 표시합니다. 이 접근 방식을 택한 이유는 기본적으로 지연을 참고합니다.
[!NOTE] 머지 리퀘스트 페이지에는
rapid_diffs_on_mr_show기능 플래그가 필요합니다.
서버 및 클라이언트 아키텍처#
Rapid Diffs의 주된 초점은 체감 성능, 즉 요청부터 화면에 첫 diff가 렌더링되기까지의 시간을 최소화하는 것입니다.
diff 파일은 항상 ViewComponent를 사용해 서버에서 렌더링됩니다. 클라이언트는 diff HTML을 직접 구성하지 않습니다. 클라이언트는 서버에서 렌더링한 HTML을 페이지에 배치하고 인터랙션만 추가합니다. 이는 모든 시나리오에 적용됩니다.
- 초기 페이지 로드 시 서버는 첫 배치의 diff 파일을 HTML 응답에 인라인으로 렌더링합니다. 사용자는 콘텐츠를 즉시 확인합니다.
- 스트리밍 중에는 나머지 diff 파일이 서버에서 스트리밍 HTML 응답으로 도착합니다. 클라이언트는 그 HTML을 그대로 라이브 DOM에 렌더링합니다.
- 사용자가 inline/parallel 또는 공백 처리 같은 뷰 설정을 변경하거나 통합이
리로드를 트리거해 다시 로드할 때, 클라이언트는 diff 목록 전체를 서버에서
스트리밍된 새 서버 렌더링 HTML로 교체합니다. 단일 파일은 그 HTML
엔드포인트를 가져와 해당
<diff-file>요소를 교체하는 방식으로 개별 리로드도 가능합니다.
JavaScript는 경량 어댑터 시스템을 통해서만 인터랙션을 추가합니다. 어댑터는 파일을 토글하고, 토론을 관리하며, 메뉴를 제어합니다.
소스 코드 보기
graph TD
A[Rails Controller] --> B[Presenter]
B --> C[AppComponent]
C --> D[DiffFileComponent]
D --> E[ViewerComponent]
E --> F["HTML <diff-file>"]
F --> G["<diff-file-mounted> fires"]
G --> H[Adapters add interactivity]Diff 용어#
Git은 파일 변경 사항을 diff로 나타냅니다. diff 파일은 하나의 파일에 대한
변경 사항을 나타냅니다. diff 파일은 파일 헤더와 하나 이상의 hunk로 구성됩니다.
각 hunk는 변경이 일어난 위치를 알려 주는 @@ 줄로 표시된 hunk 헤더로
시작합니다. hunk 헤더 뒤에는 hunk 줄이 옵니다. 실제로 추가된 줄, 제거된 줄,
변경되지 않은 컨텍스트 줄입니다.
다음 예시는 원시 git diff가 이러한 부분에 어떻게 대응되는지 보여 줍니다.
┌─ File header
│
│ diff --git a/app/models/user.rb b/app/models/user.rb
│ index 4a5e3f1..b7c9d2a 100644
│ --- a/app/models/user.rb
│ +++ b/app/models/user.rb
│
├─ Hunk 1
│ ┌─ Hunk header
│ │ @@ -10,6 +10,7 @@ class User < ApplicationRecord
│ │
│ ├─ Hunk lines
│ │ validates :name, presence: true ← context (unchanged)
│ │ validates :email, presence: true ← context (unchanged)
│ │ + validates :username, uniqueness: true ← added
│ │ validates :role, inclusion: ROLES ← context (unchanged)
│ │
├─ Hunk 2
│ ┌─ Hunk header
│ │ @@ -25,7 +26,7 @@ class User < ApplicationRecord
│ │
│ ├─ Hunk lines
│ │ def display_name ← context
│ │ - name ← removed
│ │ + "#{name} (@#{username})" ← added
│ │ end ← context
Rapid Diffs에서 서버는 이러한 각 부분을 <diff-file> 웹 컴포넌트 안의 HTML
테이블 행으로 렌더링합니다. hunk 헤더는 인터랙티브합니다. 사용자는 헤더를 클릭해
hunk 위나 아래의 숨겨진 컨텍스트 줄을 펼칩니다. hunk 줄은 파일의 이전 버전과
새 버전 양쪽의 줄 번호와 함께 구문 강조된 코드를
표시합니다.
서버 측#
서버 측 코드는 app/components/rapid_diffs/에 있습니다.
AppComponent는 루트 셸입니다.AppComponent는 헤더, 파일 브라우저 사이드바, diff 목록을 렌더링합니다.AppComponent는app_data를 JSON 데이터 속성으로 클라이언트에 전달합니다.DiffFileComponent는 하나의 diff 파일을<diff-file>커스텀 요소 안에 감쌉니다.DiffFileComponent는 올바른 뷰어를 선택하고 파일 메타데이터를data-file-data로 제공합니다.viewers/의 뷰어는 각각 특정 diff 유형을 렌더링합니다. inline 텍스트, parallel 텍스트, 이미지, 미리보기 없음이 그 유형입니다. 뷰어는ViewerComponent를 확장하고text_inline같은 문자열을 반환하는self.viewer_name을 구현합니다. 이 이름은 서버에서 렌더링한 파일을 대응하는 클라이언트 측 어댑터 구성에 매핑합니다.
프레젠터#
프레젠터는 AppComponent와 diff 파일 엔드포인트에 필요한 데이터를 준비합니다.
뷰 레이어를 컨트롤러에 결합하지 않으면서 엔드포인트 URL, 사용자 기본 설정,
구성을 제공합니다.
MergeRequestAppComponent와 CommitAppComponent 같은 페이지별 앱 컴포넌트는
AppComponent를 감싸고 추가 데이터를 주입합니다.
예를 들어 CommitAppComponent는 토론 엔드포인트를 추가합니다.
클라이언트로의 데이터 흐름#
서버는 HTML data-* 속성을 통해 세 개의 레이어로 클라이언트에 데이터를
전달합니다.
- 루트
[data-rapid-diffs]요소의data-app-data. 엔드포인트, 사용자 기본 설정, 앱 구성을 JSON으로 담습니다. 앱을 초기화할 때 한 번 파싱됩니다. - 각
<diff-file>요소의data-file-data. 뷰어 이름, 파일 경로, diff 줄 엔드포인트를 담습니다. 파일이 마운트될 때 한 번 파싱됩니다. - 줄 번호나 액션 식별자처럼 작은 값을 담는, 특정 인터랙티브 요소의
data-*속성.
클라이언트는 쿠키를 통해 서버로 데이터를 되돌려 보냅니다. 쿠키는 파일 브라우저 표시 여부와 사이드바 너비 같은 UI 상태를 유지해, 서버가 레이아웃 이동 없이 올바른 초기 레이아웃을 렌더링하게 합니다.
클라이언트 측#
클라이언트 측 코드는 app/assets/javascripts/rapid_diffs/에 있습니다. 진입점은
app/index.js의 RapidDiffsFacade 이며, 앱을 초기화하고 웹 컴포넌트를 등록한 뒤
스트리밍을 시작합니다. commit_app.js의 CommitRapidDiffsApp 같은 페이지별
서브클래스는 추가 설정으로 파사드를 확장합니다.
클라이언트는 세 개의 레이어로 구성됩니다.
레이어 1, web_components/의 Web Components: <diff-file>과
<diff-file-mounted>. DOM 요소 라이프사이클을 소유합니다. 앱에 대한 참조를 저장하고,
내부 diff 요소를 캐시하며, 모든 이벤트를 어댑터에 위임합니다.
기능 로직은 담지 않습니다.
레이어 2, adapters/의 어댑터: 라이프사이클 이벤트를 구독하고 기능 로직을
수행하는 상태 없는 JavaScript 객체입니다. 어댑터 구성을 통해 뷰어 유형별,
페이지별로 조합됩니다. 어댑터를 참고합니다.
레이어 3, app/과 stores/의 UI 컴포넌트: 토론, 이미지 뷰어, 옵션 메뉴처럼
복잡한 인터랙티브 UI를 처리하는 Vue 컴포넌트와 Pinia
스토어입니다. 어댑터는 이들을 서버가 렌더링한 플레이스홀더 요소에
마운트합니다. Vue 인스턴스를 생성한 어댑터가 그 인스턴스의 라이프사이클을
소유합니다. Pinia 스토어는 뷰 설정, 로드된 파일, 토론 같은 전역 상태를
관리합니다. 어댑터와 Vue 컴포넌트 모두 Pinia 스토어에
접근할 수 있습니다.
소스 코드 보기
graph TD
Server["Server (ViewComponent)"] -- "HTML + data attributes" --> L1["Layer 1: Web Components"]
L1 -- "lifecycle events, clicks, adapter context" --> L2["Layer 2: Adapters"]
L2 -- "mounts Vue into placeholder elements" --> L3["Layer 3: UI Components"]
L2 -- "trigger events, update DOM" --> L1
L3 -- "emit events" --> L2
L2 -. "reads/writes" .-> Stores["Pinia Stores"]
L3 -. "reads/writes" .-> Stores<diff-file> 라이프사이클#
각 diff 파일은 web_components/diff_file.js에 정의된 <diff-file> 커스텀
요소입니다. 이 요소는 Shadow DOM을 사용하지 않습니다. 서버는 자식 요소를 일반
HTML로 렌더링하고, 끝에 있는 센티넬 <diff-file-mounted> 요소가 모든 자식
요소가 준비됐음을 알립니다.
마운트 시퀀스는 브라우저가 <diff-file-mounted>를 만나는 시점에 시작됩니다.
- 요소의
connectedCallback이parentElement.mount(app)을 호출합니다. mount()는 앱에 대한 참조를 저장하고, diff 요소를 캐시하며, 가시성 관찰을 설정한 뒤MOUNTED어댑터 이벤트를 트리거합니다.
이 방식은 커스텀 요소에 특유한 타이밍 문제를 해결합니다. 브라우저가 여는
<diff-file> 태그를 만나면, DOM에 자식 요소가 존재하기 전에 요소를 만들고
connectedCallback을 즉시 호출합니다. 스트리밍 중에는 자식 요소가 점진적으로
도착합니다. 끝에 있는
<diff-file-mounted> 센티넬은 초기화가 실행되기 전에 모든 자식 요소가
준비돼 있음을 보장합니다.
<diff-file>은 사용자가 뷰 설정을 변경할 때, 예를 들어 inline에서
parallel로 전환할 때나 파일을 다시 로드할 때 파괴됩니다. 요소는 DOM에서
제거되고 모든 어댑터 정리 콜백이 실행됩니다. MOUNTED 이벤트는 파괴
시점에 실행할 정리 함수를 등록하는 onUnmounted 콜백을
받습니다. 정리 규칙은
어댑터 및 런타임을
참고합니다.
어댑터#
어댑터는 diff 파일에 동작을 추가하는 평범한 JavaScript 객체입니다.
<diff-file> 이 마운트되면 요소는 data-file-data에서 뷰어 이름을 읽고,
예를 들어 text_inline 이나 image를 읽어 app/adapter_configs/ 아래의
어댑터 구성에서 대응하는 어댑터 목록을 찾습니다. 해당 뷰어에 등록된
어댑터만 그 파일에서 실행되므로, 파일 유형에 따라 서로 다른 동작이
적용됩니다.
각 어댑터는 adapter_events.js에 선언된 라이프사이클 이벤트에 반응합니다.
마운트, 클릭, 가시성 변경, 파일 펼침과 접힘이 그 이벤트입니다. 어댑터는
위임된 클릭 액션을 위한 clicks 객체도 선언합니다. diff 파일 템플릿은
인터랙티브 요소를 data-click="actionName"으로 표시하고, DiffFile 이
클릭을 모든 어댑터의 대응 핸들러로 전달합니다.
어댑터 메서드 안에서 this는 어댑터 컨텍스트로 다시 바인딩됩니다. this는
어댑터 객체를 가리키지 않습니다. 컨텍스트는 앱 데이터, diff DOM 요소, 파싱된
파일 메타데이터, sink 객체를 노출합니다. sink는 어댑터가 상태를 갖지
않아 인스턴스 필드가 없기 때문에 존재합니다. 어댑터는 이벤트 사이의 중간
데이터를 this.sink에 저장하는데, 줄 링크를 다시 작성했는지 추적하는
플래그가 그 예입니다. 전체 컨텍스트 API는 web_components/diff_file.js를
참고합니다.
앱 루트에 있는 하나의 클릭 리스너가 모든 클릭을 받아 가장 가까운
<diff-file>을 찾고 이벤트를 대응하는 clicks 핸들러로 전달합니다. 공유되는
하나의 IntersectionObserver가 해당 핸들러를 선언한 어댑터에
VISIBLE/INVISIBLE을 트리거합니다.
다음 어댑터는 이 패턴들을 함께 보여 줍니다.
import { MOUNTED, VISIBLE } from '~/rapid_diffs/adapter_events';
export const myAdapter = {
[MOUNTED](onUnmounted) {
// Set up resources; clean them up when the diff file is destroyed
const handler = () => { /* ... */ };
this.diffElement.addEventListener('input', handler);
onUnmounted(() => this.diffElement.removeEventListener('input', handler));
},
[VISIBLE]() {
// Runs each time the file scrolls into view
},
clicks: {
myAction(event, button) {
// Responds to elements with data-click="myAction"
},
},
};
스트리밍#
초기 페이지 로드 이후 나머지 diff 파일은 별도의 스트리밍 HTTP 요청으로 도착합니다. 클라이언트는 스트림을 다음과 같이 처리합니다.
- 스트림 URL을
fetch()하거나,startup_js에서 미리 로드한 요청을 재사용합니다. document.createHTMLDocument()로 숨겨진 문서를 만듭니다.- 숨겨진 문서에
document.write()를 호출해 브라우저가 들어오는 HTML을 점진적으로 파싱하게 합니다. - 각
<diff-file>의 파싱이 끝나면, 즉<diff-file-mounted>로 신호가 오면 그 요소를 라이브 DOM으로 옮깁니다.
document.write()는 새 문서에만 호출할 수 있기 때문에 클라이언트가 숨겨진
문서를 만듭니다. 스트리밍된 HTML 응답을 DOM 파서에 직접 전달하는 방법은 이것
하나뿐입니다.
설계 원칙#
이 원칙들이 Rapid Diffs의 모든 설계 결정을 형성합니다.
기본적으로 지연#
주요 지표는 첫 diff 파일이 보이기까지의 시간, 즉 사용자가 페이지를 이동한 뒤 렌더링된 첫 diff를 보는 시점입니다. 서버 측 렌더링은 diff를 화면에 띄우는 가장 짧은 경로입니다. 브라우저는 응답이 도착하는 대로 HTML을 그리며, 첫 렌더링에는 JavaScript가 필요하지 않습니다. 클라이언트 측 방식이라면 무언가 화면에 나타나기 전에 JavaScript를 다운로드하고 코드를 실행하고 데이터를 가져와 DOM을 구성해야 합니다. 서버 렌더링 외에도 이 페이지는 첫 diff 파일을 보여 주는 데 필요한 최소한의 작업만 수행합니다. 나머지는 모두 지연하거나 미리 시작해 필요한 시점에 결과가 준비돼 있게 합니다.
- 스트리밍: 사용자는 서버가 모든 diff 처리를 끝내기 전에 콘텐츠를 볼 수 있어야 합니다. 첫 배치는 페이지 응답에 인라인으로 실려 나갑니다. 나머지 파일은 따로 스트리밍되므로 서버가 첫 페인트를 막지 않습니다.
- 지연 렌더링: 화면 밖의 diff 파일은 레이아웃이나 페인트 비용을 발생시키지
않아야 합니다. 서버가 제공한 행 수와 함께
content-visibility: auto를 사용해, 사용자가 스크롤하지 않은 파일은 렌더링하지 않고 공간만 확보합니다. - 지연 데이터 로딩: 초기 응답에는 diff 파일 자체만 들어갑니다. 파일 브라우저 사이드바, 토론, 옵션 메뉴 같은 보조 UI는 핵심 렌더링 경로가 끝난 뒤에 로드됩니다.
- 지연 초기화: 인터랙티브 컴포넌트는 미리 마운트되지 않아야 합니다. 첫 인터랙션 시점에 마운트하면 파일 수나 줄 수와 무관하게 설정 비용이 일정하게 유지됩니다.
- 이벤트 위임: 요소별 리스너는 페이지의 줄 수와 파일 수에 비례해 늘어납니다.
위임된 클릭 리스너 하나와 공유되는
IntersectionObserver하나로 이벤트 설정을 O(1)로 유지합니다. - 메모리 효율: diff 페이지는 수천 줄로 이뤄진 수백 개의 파일을 담을 수 있습니다. 파일마다 어댑터 인스턴스를 하나씩 할당하면 불필요한 오버헤드가 생깁니다. 어댑터 유형별로 평범한 객체 하나를 재사용하고 언마운트 시 파일별 상태를 버리면 메모리가 보이는 파일 수에 비례해 유지됩니다.
- 프리로딩: JavaScript 번들은 다운로드하고 실행하는 데 시간이 걸립니다. HTML
<head>의 스타트업 호출이 번들 로드 전에fetch()요청을 보내므로, 앱이 초기화될 때 스트리밍 응답이 이미 전송 중입니다.
상속 대신 컴포지션#
Rapid Diffs는 커밋, 리비전 비교, 새 머지 리퀘스트, 머지 리퀘스트의 네 개 페이지에서 동작합니다. 페이지마다 레이아웃, 데이터 소스, 기능 요구 사항이 다릅니다. 머지 리퀘스트 페이지에는 풍부한 인라인 토론과 코드 리뷰 도구가 있습니다. 커밋 페이지에는 기본적인 토론과 커밋 고유의 액션이 있습니다. 비교 페이지에는 둘 다 필요하지 않습니다. 시스템은 페이지별 로직이 경계를 넘어 새지 않게 하면서 이러한 차이를 지원해야 합니다.
컴포지션은 페이지 간 문제를 해결합니다. 기능은 클래스 계층에 녹여 넣는 대신
작고 독립적인 조각으로 조립합니다. 각 조각은 파일 토글, 줄 펼치기, 링크
재작성처럼 좁은 책임 하나만 맡습니다. 머지 리퀘스트 페이지를 위해 추가한
기능은 커밋 페이지에 영향을 주지 않습니다. 조각들이 공유 기반 클래스가 아니라
어댑터 구성을 통해
결합되기 때문입니다. 서버에서도 같은 패턴이 적용됩니다. 페이지별 앱
컴포넌트가 깊은 계층을 확장하는 대신 AppComponent를
감쌉니다.
기능 추가#
아래의 각 레시피는 구체적인 코드 예시와 함께 일반적인 작업을 안내합니다.
클릭 액션 추가#
- Haml 템플릿의 해당 요소에
data-click="yourAction"속성을 추가합니다. - 기존 어댑터에
clicks.yourAction메서드를 추가하거나 새 어댑터를 만듭니다. - 새 어댑터를 만들었다면
app/adapter_configs/의 해당 어댑터 구성에 등록합니다.
예를 들어 헤더 템플릿의 파일 토글 버튼은 data-click="toggleFile"을
사용합니다.
= render Pajamas::ButtonComponent.new(
button_options: { data: { click: 'toggleFile' } }
)
toggleFileAdapter가 이 클릭을 처리합니다.
export const toggleFileAdapter = {
clicks: {
toggleFile(event, button) {
const collapsed = this.diffElement.dataset.collapsed === 'true';
if (collapsed) {
expand.call(this);
} else {
collapse.call(this);
}
},
},
};
어댑터 이벤트 추가#
adapter_events.js에서 이벤트 이름을 내보냅니다.- 내보낸 상수를 키로 사용해 어댑터에 이벤트 핸들러를 추가합니다.
this.trigger(EVENT_NAME, ...args)로 이벤트를 트리거합니다.
예를 들어 expandLinesAdapter는 새 diff 줄을 삽입한 뒤 EXPANDED_LINES를
트리거하므로, lineLinkAdapter 같은 다른 어댑터가 이에 반응할 수 있습니다.
// In expandLinesAdapter, after inserting lines:
this.trigger(EXPANDED_LINES);
// In lineLinkAdapter, rewriting links on the newly inserted lines:
export const lineLinkAdapter = {
[EXPANDED_LINES]() {
handleAllLineLinks.call(this);
},
};
뷰어 추가#
app/components/rapid_diffs/viewers/에ViewerComponent서브클래스를 만듭니다.self.viewer_name을 구현합니다.DiffFileComponent#viewer_component에 뷰어 선택 로직을 추가합니다.- 뷰어가 클라이언트 측 인터랙션을 필요로 하면 어댑터를 만듭니다.
app/adapter_configs/base.js에 새 뷰어 이름을 등록합니다.
예를 들어 PDF 뷰어를 추가하는 경우는 다음과 같습니다.
# app/components/rapid_diffs/viewers/pdf_view_component.rb
module RapidDiffs
module Viewers
class PdfViewComponent < ViewerComponent
def self.viewer_name
'pdf'
end
end
end
end
그다음 DiffFileComponent#viewer_component에서 다음과 같이 작성합니다.
return Viewers::PdfViewComponent if @diff_file.pdf?
그리고 app/adapter_configs/base.js에 뷰어를 등록합니다.
export const VIEWER_ADAPTERS = {
// ...existing viewers...
pdf: [...HEADER_ADAPTERS, pdfAdapter],
};
페이지별 동작 추가#
VIEWER_ADAPTERS를 전개하고 변경이 필요한 뷰어 유형을 재정의하는 어댑터 구성 파일을app/adapter_configs/에 만듭니다.adapterConfig를 그 구성으로 설정하는 파사드 서브클래스를 만듭니다.AppComponent를 추가 데이터와 함께 감싸는 페이지별 앱 컴포넌트를 만듭니다.
예를 들어 커밋 페이지는 기본 옵션 메뉴 어댑터를 커밋 고유의 액션이 포함된 어댑터로 바꾸고, 인라인 토론을 추가합니다.
// app/adapter_configs/commit.js
export const adapters = {
...VIEWER_ADAPTERS,
text_inline: [
...VIEWER_ADAPTERS.text_inline.filter((a) => a !== optionsMenuAdapter),
commitDiffsOptionsMenuAdapter,
inlineDiscussionsAdapter,
],
};
전체 예시는 commit_app.js와 CommitAppComponent를 참고합니다.
어댑터 내부에 Vue 마운트#
드롭다운, 토론, 이미지 뷰어에 Vue가 필요한 기능이라면 어댑터의 MOUNTED
핸들러에서 인스턴스를 마운트합니다. 서버가 렌더링한 플레이스홀더 요소를
대상으로 지정합니다.
-# Server renders a mount point
%div{ data: { my_mount: true } }
import Vue from 'vue';
import { MOUNTED } from '~/rapid_diffs/adapter_events';
import MyComponent from './my_component.vue';
export const myAdapter = {
[MOUNTED]() {
new Vue({
el: this.diffElement.querySelector('[data-my-mount]'),
render: (h) => h(MyComponent, { props: { /* ... */ } }),
});
},
};
새 기능 테스트#
Ruby ViewComponent 스펙#
서버 측 컴포넌트는 render_inline으로 테스트합니다.
- 전체 마운트:
render_inline은 컴포넌트 트리 전체를 테스트합니다. - 얕은 마운트:
allow_next_instance_of는 자식 컴포넌트를 플레이스홀더 HTML로 스텁 처리합니다.DiffFileComponent처럼 복잡한 컴포넌트에는 얕은 마운트를 권장합니다.
it "renders the viewer" do
render_inline(described_class.new(diff_file: diff_file))
expect(page).to have_selector("diff-file")
expect(page).to have_selector("diff-file-mounted")
end
JavaScript 어댑터 및 스토어 스펙#
어댑터를 테스트하려면 인라인 HTML로 실제 DiffFile 커스텀 요소를 마운트하고
위임된 이벤트를 직접 호출합니다.
beforeAll(() => {
customElements.define('diff-file', DiffFile);
});
beforeEach(() => {
document.body.innerHTML = `
<diff-file data-file-data='${JSON.stringify({ viewer: 'text_inline' })}'>
<div class="rd-diff-file"><!-- template --></div>
</diff-file>
`;
document.querySelector('diff-file').mount({
adapterConfig: { text_inline: [myAdapter] },
appData: {},
unobserve: jest.fn(),
});
});
Rapid Diffs 가이드라인#
Rapid Diffs 변경이 모두 따라야 하는 규칙은 Rapid Diffs 가이드라인을 참고합니다.