커뮤니티 게시글

자유게시판

AI 분석 3번째.

nike 2026-08-06 18:26 조회 82 댓글 1 수정됨

이거 좀 중독 같아요.

# 블록 시스템 분석


## 결론


Mublo 블록 시스템은 이 프로젝트에서 가장 제품성이 강한 Core 기능이다. 단순 WYSIWYG가 아니라

**도메인 콘텐츠를 배치하는 서버 렌더링 composition engine**에 가깝다. Core는 행과 칸,

레이아웃, 저장·복구, 캐시를 소유하고 Board·Shop·Plugin은 콘텐츠 타입과 item provider,

renderer, skin을 공급한다.


데이터 무결성, 편집 동시성, 미설치 확장 보존, 캐시와 asset의 결합, preview 격리까지 고려돼

있다. 반면 시각 편집기의 client state가 큰 단일 View script에 많이 모여 있는 것은 줄 수가

많아서가 아니라 독립적으로 변경·테스트하기 어려운 책임 결합 때문에 유지보수 부담이다.


`MubloItemLayout.js`는 서버가 만든 콘텐츠를 다시 소유하지 않고 list/slide/none 표현만 입힌다.

이 제한된 책임과 lifecycle 정리가 잘 돼 있어 블록 시스템의 좋은 client runtime 경계다.


## 1. 블록의 두 사용 방식


같은 Row/Column 구조가 두 곳에 쓰인다.


```text

위치 기반

  기존 Front frame의 topbar/subhead/left/right/contenthead/...에 삽입


페이지 기반

  BlockPage 하나를 여러 BlockRow로 구성해 독립 페이지 생성

```


`block_rows`는 `position` 또는 `page_id` 중 정확히 하나만 가져야 한다. Service가 상호배타를

검증하고, page를 참조할 때 같은 domain의 page인지 다시 확인한다. 위치 블록과 페이지 빌더를

별도 시스템으로 복제하지 않은 것이 핵심이다.


## 2. 데이터 모델


```text

BlockPage

  ├─ page code / SEO / access level

  ├─ header·footer 사용 여부

  └─ 전체·좌·우·3단 layout 설정


BlockRow

  ├─ domain

  ├─ page 또는 frame position

  ├─ menu scope / main-screen scope

  ├─ 폭·높이·margin·padding·background

  ├─ sort order / active

  └─ revision_no


BlockColumn

  ├─ row / domain / order / width

  ├─ padding·background·border·title

  ├─ single content mirror

  └─ single 또는 stack mode


BlockColumnContent (stack mode)

  ├─ stable content_id

  ├─ title / type / kind / skin

  ├─ config / selected items

  └─ active / sort order

```


초기 schema는 한 칸에 콘텐츠 하나를 두는 모델이었다. 이후 `block_column_contents`를 추가해

한 칸 안에 콘텐츠를 세로로 쌓을 수 있게 확장했다. 기존 `block_columns`의 콘텐츠 필드는

첫 active stack item의 mirror로 유지해 과거 reader와 단일 콘텐츠 경로를 깨지 않는다.


### 도메인 무결성


애플리케이션 소유권 검사만 믿지 않는다.


- `(page_id, domain_id)`

- `(row_id, domain_id)`

- `(column_id, domain_id)`


복합 unique/FK를 추가해 page → row → column → content가 같은 domain 안에서만 연결되게 한다.

기존 데이터에 교차 도메인 참조가 있으면 migration이 임의 삭제·이동하지 않고 실패한다. tenant

격리를 데이터베이스가 마지막으로 보장하는 좋은 방식이다.


## 3. 콘텐츠 타입 registry


`BlockRegistry`가 콘텐츠 타입의 계약을 보관한다.


- type code와 CORE/PLUGIN/PACKAGE kind

- renderer class

- config form

- skin base path

- item provider

- admin script

- `noCache`, `kitPortable`, `maxItems`

- UI capability: skin/items/count/style/AOS/customConfig

- editor adapter와 inline/modal mode


Core 기본 타입은 HTML, image, movie, outlogin, menu, include 등이고 Board, Shop, Banner,

FAQ 같은 확장이 자기 타입을 같은 registry에 등록한다.


Renderer는 내부 `BlockColumn` 구현이 아니라 `BlockColumnView` 계약을 받는다. stack content도

원본 column layout과 child content를 합친 view를 만들어 기존 renderer에 전달한다. 따라서

renderer를 single/stack 두 버전으로 만들 필요가 없다.


type code 충돌은 나중 등록한 확장 하나만 생략하고 진단에 기록한다. Core type은 확장보다 먼저

자리잡아 선점을 막는다. renderer class와 interface도 등록 시점에 검사해 오타를 첫 실화면의 빈

블록으로 늦게 발견하지 않는다.


Capability를 명시 데이터로 둔 덕분에 관리자 UI가 `if type === board` 같은 분기를 늘리지 않고

타입이 선언한 설정만 보여준다. 별도 editor adapter도 registry metadata로 연결된다.


## 4. 저장 경로


대표적인 Row 저장 흐름은 다음과 같다.


```text

Admin Controller

  -> request payload mapping

  -> BlockColumnWriteContext 생성

  -> preflight write guard

  -> BlockColumnPayloadNormalizer

       구조 / 타입 / 설정 / HTML / style / 권한 검증

  -> BlockRowService

       domain·page·position 검증

       -> transaction

            변경 전 revision snapshot

            revision_no 조건부 row update

            stable-ID column/content sync

       -> commit

       -> cache invalidation

       -> 오래된 revision과 미사용 image 정리

```


### 쓰기 Context


저장 출처를 interactive, preview, kit import, internal seed로 구분한다. 이에 따라 raw JS,

server include, 미설치 extension type 보존 허용 여부가 달라진다.


- 일반 블록 편집 권한을 받은 운영자는 raw JS 사용 가능

- PHP include block은 super만 가능

- kit import는 unresolved extension type과 raw JS를 보존하지만 include는 금지

- internal seed는 신뢰된 내부 경로라 모두 허용


이것은 raw JS를 “안전한 사용자 콘텐츠”로 보는 정책이 아니다. 블록 편집 권한자를 사이트 코드를

바꿀 수 있는 신뢰 주체로 보는 정책이다. 따라서 이 권한을 일반 콘텐츠 작성 권한과 섞으면 안 된다.


HTML 본문과 slide HTML은 sanitizer를 통과하고, style 값의 `javascript:`, `expression()`,

`@import` 같은 값도 정규화기에서 거부한다. HTML block CSS는 해당 block DOM ID 아래로 scope된다.


### 낙관적 잠금


Row에는 `revision_no`가 있다. 수정 form이 읽은 revision으로

`updateIfRevision(rowId, expectedRevision, data)`를 실행하고 affected row가 1이 아니면 다음 오류를

반환한다.


> 다른 운영자가 먼저 이 행을 수정했습니다. 화면을 새로고침한 뒤 다시 시도해 주세요.


마지막 저장 승자 방식으로 다른 운영자의 편집을 조용히 덮지 않는다. 직접 순서 지정도 row별

revision 조건을 사용한다.


### 변경 이력과 복구


수정·삭제 전에 row와 모든 column/stack content를 JSON snapshot으로 저장한다. 삭제된 row도

원래 ID를 보존한 revision에서 새 row로 복구할 수 있고 `restored_row_id`, `restored_at`을 남긴다.

복구는 현재 schema와 normalizer를 다시 통과하므로 과거 payload를 무검증으로 DB에 꽂지 않는다.


### stable-ID stack 동기화와 legacy 호환


stack이 한 번이라도 들어간 row는 column ID와 content ID를 기준으로 update/create/delete한다.

DB에는 stack column이 있는데 구형 payload가 column ID를 전혀 보내지 않으면 순서로 추측하지

않고 저장을 거부한다. 반대로 기존 stack column ID가 확인된 legacy single edit은 첫 active

content에만 반영하고 나머지를 보존한다.


미설치 확장의 콘텐츠도 기존 ID와 기존 type이 정확히 일치하면 원형 보존한다. 이를 “기존 ID를

빌려 임의의 미등록 type을 주입”하는 통로로 쓰지 못하게 DB 기존값과 대조한다. 확장을 잠시

비활성화했다 다시 켜도 블록 설정을 잃지 않으면서 type 변조는 막는 균형이다.


## 5. 공개 렌더링


`FrontViewRenderer`가 frame 위치에 `BlockRenderService::renderPosition()`을 호출하거나,

BlockPage controller가 `renderPage()`를 호출한다.


### 2단계 캐시


```text

1단계: 위치 또는 page -> row ID 목록 캐시

2단계: row ID -> HTML + 필요한 CSS/JS asset 목록 캐시

```


row HTML만 저장하면 cache hit에서 skin asset 등록이 사라진다. Mublo는 row가 렌더링 중 등록한

CSS/JS를 capture해서 HTML과 함께 저장하고, hit 때 AssetManager에 다시 등록한다. 서버 렌더링

블록 cache에서 흔히 생기는 “내용은 나오는데 CSS가 사라짐”을 구조적으로 해결했다.


로그인 widget과 현재 메뉴처럼 요청 상태에 따라 달라지는 type은 registry의 `noCache`로 row

전체 cache를 끈다. cache variant도 지원해 같은 row가 브랜드/문맥별로 다른 HTML을 가질 수 있다.


목록 cache가 cold일 때 여러 row의 column을 `IN` query로 미리 읽고, stack contents도 column별

N+1 대신 batch preload한다. warm row는 column query 자체를 하지 않는다.


### 렌더 실패 격리


- inactive row/column/content는 제외

- 알 수 없는 type 또는 renderer 예외는 해당 content만 빈 출력/진단 placeholder

- stack child 하나의 실패가 형제 content를 막지 않음

- 모든 column output이 비면 row 자체를 출력하지 않음

- editor preview에서는 빈 칸과 오류 위치를 placeholder로 표시

- production에는 내부 오류 문구를 노출하지 않음


이 격리는 페이지 전체 가용성을 우선한다. 대신 production의 빈 영역이 원인 없이 보일 수 있으므로

`storage/logs/block_error.log`와 확장 diagnostics 관측이 중요하다.


## 6. Front frame과의 결합


블록 위치는 단순 header/footer 둘만이 아니다.


```text

topbar

Header

subhead

LayoutOpen

  left | contenthead -> page content -> contentfoot | right

LayoutClose

subfoot

Footer

```


`position_menu`로 특정 메뉴에만 보이게 할 수 있고 `__index__` 성격의 main-screen scope도 따로

처리한다. 좌우 블록은 실제 layout type이 sidebar를 가질 때만 렌더된다.


BlockPage가 가진 header/footer와 layout 설정은 Front renderer의 `_pageConfig`로 전달돼 메뉴

override나 도메인 기본값보다 우선한다. 블록 페이지가 HTML 조각만 만드는 것이 아니라 frame

조립 정책에도 참여한다.


## 7. 관리자 편집기


두 가지 편집 경험이 있다.


1. `BlockRow` form: row/column 설정과 modal preview 중심

2. `BlockEditor`: 실제 Front를 same-origin iframe으로 띄우고 render marker를 읽어 행·칸을

   시각적으로 선택하는 전체 편집 화면


전체 편집기는 다음 기능을 코드상 제공한다.


- 화면/메뉴/position context 전환

- 실제 Front iframe 위 hit-test overlay

- row와 column/stack content 편집

- drag reorder

- optimistic revision token과 변경 이력/삭제 복구

- HTML/image/movie modal adapter

- extension capability에 따른 동적 inspector

- block kit preview/apply

- frame part draft/publish/revert

- AI HTML과 asset 보조 기능

- 여러 viewport에서 HTML 품질 검사


별도 preview modal은 `sandbox="allow-scripts"` iframe을 사용한다. preview 안의 script가 관리자

parent document에 접근하지 못하게 하고, `postMessage`도 정확한 iframe window와 제한된 숫자

height를 검사한다.


### 유지보수 평가


`views/Admin/Blockeditor/Index.php`에 editor shell, client state, iframe interaction, row inspector,

frame editor, AI 작업이 크게 모여 있다. 이 문제는 파일이 길다는 사실이 아니다. 한 기능 수정이

같은 closure state와 DOM lookup, fetch 흐름을 공유해 독립 module test가 어려운 점이 부담이다.


이미 capability, adapter, content stack, preview iframe, HTML editor 일부는 공용 JS module로

추출돼 있다. 다음 분리는 기능 경계를 기준으로 하는 것이 좋다.


- context/iframe controller

- selection overlay와 hit-test

- row/column draft store

- inspector renderers

- frame editor

- AI asset/editor client


단순히 4천 줄을 10개 파일로 기계 분할하는 것은 의미가 없다. 상태 소유자와 공개 메서드가

분리되고 browser test가 각 module 계약을 검증할 때만 개선이다.


## 8. 블록 킷


BlockKit은 page, position, main screen 구성을 이동 가능한 JSON으로 내보내고 적용한다.


- dry-run과 실제 apply 분리

- Core version과 required provider 검사

- target page/position/main screen 유효성 검사

- replace 전에 기존 row snapshot

- append/replace 모드

- 도메인 종속 image 제거 또는 portability 정책 적용

- script 포함 여부와 setup 필요 사유 표시

- application history와 rollback


`kitPortable=false` type의 item은 다른 도메인에서 의미 없는 참조를 그대로 옮기지 않는다.

미설치 provider는 무작정 실패시키는 대신 dry-run에서 필요한 setup을 설명한다.


## 9. `MubloItemLayout.js`


### 책임


서버 renderer가 만든 다음 계약만 해석한다.


```html

<div class="mublo-item-layout"

     data-pc-style="slide"

     data-mo-style="list"

     data-pc-cols="4"

     data-mo-cols="2">

  <ul><li>...</li></ul>

</div>

```


JS는 item data나 HTML을 다시 요청하거나 다시 그리지 않는다. 직접 자식 `ul > li`에만

list/slide/none layout을 적용한다. Board, Shop, Banner, Core image/HTML slide가 같은 runtime을

공유한다.


### mode 전환


- PC/Mobile breakpoint 기본값 768px

- `list`: Swiper를 제거하고 기존 server DOM 표시

- `none`: Swiper를 제거하고 영역 숨김

- `slide`: 기존 DOM에 Swiper class를 부착하고 인스턴스 생성

- Swiper가 없으면 list로 안전하게 폴백


PC와 Mobile 모두 slide여도 column 수, autoplay, loop, cover 정책이 다르면 breakpoint를 넘을 때

Swiper를 파괴하고 새 option으로 만든다. 단순 `update()`만 호출해 오래된 option이 남는 문제를

피한다.


### 접근성과 동작 안정성


- Swiper keyboard와 a11y 활성화

- `prefers-reduced-motion`이면 autoplay 비활성화

- autoplay 중 focus가 들어오면 정지, 영역을 완전히 벗어나면 재시작

- autoplay 최대 30초로 server normalization과 동일하게 clamp

- item 수가 `slidesPerView` 이하이면 loop/autoplay 억제

- image load 완료 후 가장 낮은 실제 높이로 slide cover 높이를 맞춤

- 비동기 image 완료에 generation token을 사용해 폐기된 mode 결과가 되살아나지 않게 함


### lifecycle과 성능


페이지의 모든 instance가 resize listener 하나와 debounce를 공유한다. instance별 callback만 Set에

등록한다. DOM element 중복 초기화는 `WeakMap`, 기존 ID API는 plain object로 함께 관리한다.


`MutationObserver`는 동적으로 추가된 블록을 초기화하고 제거된 블록은 다음을 정리한다.


- Swiper instance

- focus handler

- resize/motion callback

- global instance map

- element WeakMap


AJAX/visual editor가 블록 DOM을 자주 교체해도 listener와 instance가 계속 쌓이지 않는다.


### 평가와 작은 개선 여지


이 파일은 책임이 잘 제한돼 있다. server DOM을 보존하고 progressive enhancement로 동작하며,

Swiper 부재와 reduced motion에도 안전하다. Browser test도 PC/MO none 전환, slide option 재생성,

cover, reduced motion, 동적 제거 cleanup을 직접 검증한다.


개선 여지는 다음 정도다.


1. `MutationObserver`가 `document.body` 전체 subtree를 본다. 대규모 동적 DOM 앱에서 mutation이

   많아지면 block root나 editor preview root 단위 observer 선택지를 둘 수 있다.

2. 전역 `window.MubloItemLayout`과 전역 `Swiper`에 의존한다. 현재 비번들링 배포에는 적합하지만

   ESM build를 도입하면 adapter 주입 방식이 테스트와 version 교체에 더 유리하다.

3. `resize`는 viewport 기준이다. 블록이 container query 기반 layout으로 진화하면

   `ResizeObserver`가 더 정확하다.


이들은 현재 결함이라기보다 다음 UI 아키텍처로 갈 때의 경계다.


## 설계 평가


### 잘된 점


- 위치형과 페이지형 블록을 하나의 모델로 통합했다.

- tenant 관계를 복합 FK로 DB에서도 강제한다.

- optimistic lock과 revision snapshot이 운영자 동시 편집·복구를 다룬다.

- stack 도입 시 stable ID, legacy mirror, unresolved type 보존을 함께 설계했다.

- 저장 source별 신뢰 정책이 normalizer의 최종 방어선까지 전달된다.

- renderer 계약이 single/stack과 Core/확장을 같은 방식으로 받는다.

- list cache와 row HTML cache를 분리하고 asset도 함께 cache한다.

- no-cache type, cache variant, batch preload가 실제 CMS 렌더링 문제를 겨냥한다.

- preview가 실제 Front asset과 `MubloItemLayout`을 사용하면서 관리자 문서와 sandbox로 격리된다.

- client item layout은 DOM 소유권과 cleanup 책임이 명확하다.


### 남은 부담과 개선 가치


1. 시각 편집기 client state를 기능 module 단위로 분리하고 module/browser contract test를 늘릴 것.

2. raw JS 허용이 블록 편집 신뢰 정책임을 관리자 권한 문서와 UI에 명확히 표시할 것.

3. block render 오류가 production에서 빈 출력으로 흡수되므로 운영 diagnostics 알림을 강화할 것.

4. cache invalidation이 많은 placement/menu scope를 포괄하므로 새 position이나 variant를 추가할 때

   무효화 contract test를 먼저 추가할 것.

5. revision JSON과 image reference 정리 정책의 보존 기간·용량 제한을 운영 설정으로 명시할 것.


## 최종 판단


블록 시스템은 **이 프로젝트의 대표 강점**이다. 기능 수가 많아서가 아니라, 편집 가능한 동적

콘텐츠가 야기하는 동시성, tenant 무결성, 확장 부재, cache asset, preview script 문제를 실제

경계로 처리하고 있기 때문이다.


`MubloItemLayout.js`도 이 구조와 잘 맞는다. 서버 renderer의 결과를 지우고 client rendering으로

대체하지 않고, 이미 의미 있는 HTML 위에 반응형 동작만 더한다. Core 중심 설계로 봤을 때

서버와 브라우저의 역할 분담이 설득력 있다.


## 주요 근거 파일


- `database/migrations/003_create_block_tables.sql`

- `database/migrations/019_add_block_row_revisions.sql`

- `database/migrations/021_create_block_column_contents.sql`

- `database/migrations/023_enforce_block_domain_integrity.sql`

- `src/Core/Block/BlockRegistry.php`

- `src/Contract/Block/BlockColumnView.php`

- `src/Service/Block/BlockRowService.php`

- `src/Service/Block/BlockColumnContentService.php`

- `src/Service/Block/BlockColumnPayloadNormalizer.php`

- `src/Service/Block/BlockContentPayloadNormalizer.php`

- `src/Service/Block/BlockRenderService.php`

- `src/Service/Block/BlockKitApplier.php`

- `src/Service/Block/BlockPreviewService.php`

- `src/Controller/Admin/BlockEditorController.php`

- `views/Admin/Blockeditor/Index.php`

- `public/assets/js/MubloItemLayout.js`

- `public/assets/js/admin/block-content-capabilities.js`

- `public/assets/js/admin/block-content-editor-adapters.js`

- `public/assets/js/admin/block-preview-iframe.js`

- `tests/Browser/item-layout.spec.js`

- `tests/Browser/block-editor.spec.js`

- `tests/Unit/Service/Block/*Test.php`

- `tests/Integration/Block*Test.php`

글쓰기

댓글 1

우아한삽질 2026-08-07 10:50
열심히 알아 봐 주시는 것 같아 기분이 좋네요 !