MUBLO · SYSTEM NOTE

ABOUT

SYSTEM NOTE

확장 — 코어를 고치지 않고 기능을 더하는 법

wwiz 2026-08-11 11:57 20분 읽기 조회 84 수정됨

게시판에 신고 기능을 하나 붙여야 한다고 해봅시다.

본문 아래에 신고 버튼을 놓고, 접수 내용을 저장하고, 일정 기준에 해당하는 글은 방문자에게 보이지 않게 해야 합니다. 글이 삭제되면 남은 신고 상태도 정리해야 하고, 관리자가 처리할 화면도 필요합니다.

가장 빠른 방법은 게시판 코드를 직접 고치는 것입니다.
본문 화면에 버튼을 넣고, 게시글 조회 서비스에 차단 조건을 추가하고, 삭제 처리 뒤에 신고 데이터 정리를 한 줄 붙입니다.

첫 작업만 보면 합리적입니다. 필요한 코드가 이미 있는 곳에 몇 줄을 더하면 되기 때문입니다.

문제는 그 몇 줄이 누구의 코드가 되느냐입니다.

게시판 패키지가 업데이트되면 원본 변경과 추가한 변경을 다시 비교해야 합니다. 조회 조건이 바뀌면 차단 코드를 옮겨야 하고, 버튼 영역의 구조가 바뀌면 화면 수정도 다시 맞춰야 합니다. 한 고객에게만 필요했던 기능이 원본의 일부가 되면, 이후 모든 수정에서 함께 검토해야 할 항목이 됩니다.

기능 하나를 만드는 비용보다 그 기능을 원본과 계속 합쳐 두는 비용이 더 오래 남습니다.

기능은 참여하되 원본을 소유하지 않습니다

스킨은 전달받은 값을 표현하는 자리입니다.
전달되지 않은 값이나 새로운 규칙이 필요하면 기능 쪽 계약을 넓혀야 한다고도 했습니다.

그 계약을 넓히는 자리가 확장입니다.

머블로에서 Package는 게시판이나 쇼핑처럼 하나의 업무 영역을 소유합니다.
Plugin은 독립된 기능을 제공하거나, 특정 Package가 열어둔 자리에 참여합니다. 게시판 신고 기능은 Board의 내부 파일이 아니라 Board에 종속된 Plugin으로 들어 있습니다.

이 구분은 폴더를 보기 좋게 나누기 위한 것이 아닙니다.
누가 데이터를 소유하고, 누가 규칙을 결정하며, 어디까지 호환성을 약속할지 정하기 위한 구분입니다.

신고 Plugin은 신고 접수와 처리 상태, 도메인별 블라인드 기록을 소유합니다.
Board는 게시글이 언제 조회되고 삭제되는지, 현재 도메인에서 그 글에 접근할 수 있는지를 결정합니다.
 신고 기능이 필요하다는 이유로 Plugin이 Board의 테이블과 조회 코드를 함께 소유하지는 않습니다.

Package는 Core의 하위 모듈이 아닙니다

머블로에 Board와 Shop이 함께 들어 있다고 해서,
모든 Package가 Core 개발자가 만드는 하위 모듈이라는 뜻은 아닙니다.

Core가 소유하는 것은 요청을 실행하고 확장을 발견·검증·로딩하는 규칙입니다.
Package가 소유하는 것은 자기 업무 영역의 데이터와 정책, 화면과 공개 API입니다.

어떤 개발자는 예약 업무를 Package로 만들 수 있고, 다른 개발자는 재고 관리 Package를 만들 수 있습니다. 그 Package는 자체 Controller와 Service, Repository, 테이블, 화면과 Migration을 가집니다. Core는 그 업무가 무엇인지 알지 못하고, 알아야 할 이유도 없습니다.

이렇게 나눈 이유는 기능의 종류가 늘어날 때마다 Core의 승인과 배포를 거쳐야 하는 구조를 만들지 않기 위해서입니다.
예약 정책이 바뀌었다고 실행 규칙을 만드는 쪽이 예약 업무까지 함께 수정할 이유는 없습니다.
실행 규칙의 변경과 업무 기능의 변경은 서로 다른 개발자가 서로 다른 주기로 결정할 수 있어야 합니다.

누가 들어올 수 있는지는 Package가 정합니다

Package 개발자는 한 걸음 더 나아가,
자기 Package를 다른 개발자가 참여할 수 있는 자리로 열 수 있습니다.
Board가 게시글 조회 Contract와 공식 Event를 제공하면,
다른 개발자는 신고·북마크·추가 검색·내보내기 같은 종속 Plugin을 만들 수 있습니다.
이때 관계는 Core와 Plugin만의 관계가 아닙니다.

Core → Package → 종속 Plugin이라는 한 층이 더 생깁니다.
Core는 Package를 실행하고,
Package 개발자는 자기 기능의 확장 지점을 정하며,
Plugin 개발자는 그 공개 지점 위에서 별도의 제품을 만듭니다.

종속 Plugin의 발견도 Package가 결정합니다.
Core는 Package 내부의 Plugin 디렉토리를 마음대로 훑지 않습니다.
Package가 Plugin을 수용하겠다고 선언한 경우에만 어떤 Plugin이 있는지 묻고,
그 응답에 포함된 Plugin만 발견합니다.

디렉토리를 자동으로 훑는 편이 더 간단해 보일 수 있습니다.
하지만 그렇게 하면 Package 개발자가 열기로 결정하지 않은 내부 경로까지
Core가 확장 지점으로 만들어 버립니다.
누가 들어올 수 있는지는 Core가 결정하는데 호환성을 유지할 책임은 Package에 남는 구조가 됩니다.

수용 여부와 발견 목록을 Package가 답하게 한 것은 결정권과 책임을 같은 곳에 두기 위해서입니다.
Package 개발자는 공개할 Contract와 Event, 수용할 Plugin의 범위를 정합니다.
Plugin 개발자는 검증한 부모 Package 버전을 Manifest에 선언합니다.
Core는 그 호환성과 부모·설치 위치가 일치하는지 확인하고, 부모를 먼저 활성화하며,
부모가 비활성화되거나 부팅에 실패하면 종속 Plugin의 실행과 Route·Asset 노출을 함께 막습니다.

Core가 하는 일은 심판이지 편집이 아닙니다.

생태계를 소유하는 쪽은 Core가 아니라, 자기 업무 영역을 연 Package 개발자입니다.

두 기능 사이에는 두 개의 통로가 있습니다.

확장이 다른 기능과 연결되는 방법은 크게 Contract와 Event로 나뉩니다.

Contract는 답을 받아야 하는 호출에 씁니다.
신고를 접수하기 전에 게시글이 실제로 존재하는지,
현재 도메인에서 접근할 수 있는지 확인하는 일이 그렇습니다.
신고 Plugin은 Board의 Repository를 직접 조회하지 않고 공개된 게시글 조회 Contract를 호출합니다.

Event는 원래 작업의 흐름에 참여할 때 씁니다.
게시글 버튼을 모으는 순간, 글을 보여주기 직전, 파일을 내려주기 직전, 글 삭제가 끝난 뒤에 Board가 Event를 발행합니다. 신고 Plugin은 필요한 Event만 구독합니다.

하나의 방식으로 합치지 않은 데에는 이유가 있습니다.
모든 일을 직접 호출로 만들면 Board가 신고 Plugin의 존재와 호출 순서를 알아야 합니다.
반대로 모든 일을 Event로 만들면 게시글 하나를 조회해 결과를 받아야 하는 단순한 요청까지 누가 답할지 불분명해집니다.

호출하는 쪽이 특정한 답을 필요로 하면 Contract, 원래 기능이 참여 지점만 열어두고 누가 들어올지는 몰라도 되면 Event를 씁니다.

둘의 차이는 문법이 아니라 의존의 방향입니다.

같은 Event에 여러 확장이 참여하면서 실제 순서가 필요하다면 구독할 때 우선순위를 명시합니다. 먼저 설치됐거나 먼저 발견됐다는 우연한 순서를 실행 규칙으로 쓰지 않습니다. 같은 우선순위의 작업은 서로 순서가 바뀌어도 결과가 같아야 합니다.

실제 신고 Plugin은 이렇게 붙습니다

BoardReport Plugin은 게시글 화면의 액션을 모으는 Event에 신고 버튼을 추가합니다.
프론트 화면의 마지막 부분에는 신고 창의 HTML과 스크립트를 넣습니다.
JavaScript가 동작하지 않을 때는 링크가 별도 접수 화면으로 이어집니다.

글을 보여주기 전에는 블라인드 기록을 확인해 방문자의 열람을 차단합니다.
첨부파일도 같은 기준으로 막습니다.
본문만 가리고 이미 퍼진 파일 주소는 열어두는 반쪽짜리 처리가 되지 않게 하기 위해서입니다.

글이 삭제된 뒤에는 대기 중인 신고 상태를 정리하고 블라인드 표시를 걷어냅니다.
신고 이력 자체는 없애지 않습니다. 어떤 조치가 있었는지 남기는 일은 신고 Plugin의 책임이기 때문입니다.

이 과정에서 Board의 Controller, Service, Repository, 스킨은 한 줄도 수정하지 않습니다.
Board는 신고 기능을 알지 못한 채 게시글의 생애에 필요한 Event를 발행하고,
Plugin은 자신에게 필요한 순간에만 참여합니다.

이벤트가 유용한 이유는 코드를 흩어 놓을 수 있어서가 아닙니다.
원래 기능이 추가 기능의 목록을 소유하지 않아도 되기 때문입니다.
내일 다른 Plugin이 게시글 아래에 인쇄 버튼을 더하거나,
삭제된 글의 검색 색인을 정리해도 Board에 Plugin 이름을 추가할 필요가 없습니다.

공개 표면은 Package가 정하고, 일부러 작게 정합니다

Board 안에는 게시글을 조회하는 Service와 Repository, 데이터베이스 행을 표현하는 Entity가 이미 있습니다.
Plugin이 이것들을 바로 가져다 쓰게 하면 공개 API를 따로 만들지 않아도 됩니다.

하지만 내부 클래스의 메서드 하나를 사용하게 허용하는 순간, 그 메서드와 반환 타입과 내부 조회 방식은 더 이상 자유롭게 바꿀 수 없는 약속이 됩니다.
내부 구현을 정리할 때마다 Plugin 전체를 함께 조사해야 합니다. 자기 코드의 소유권이 조용히 밖으로 넘어가는 자리입니다.

그래서 머블로는 내부 Service마다 Interface를 만들지 않습니다.

Plugin이 실제로 필요한 동작만 작은 Contract로 꺼냅니다.

신고 Plugin이 Board에 게시글을 물어 돌려받는 값도 Board Entity가 아닙니다.
게시글 번호, 도메인 번호, 게시판 번호, 제목만 담은 readonly Snapshot입니다.
Plugin은 이 값을 바꿀 수 없고, Board의 저장 상태를 우연히 수정할 수도 없습니다.

이 선택은 다소 번거롭습니다.
내부 Entity에 이미 있는 값을 Snapshot으로 한 번 더 옮겨 담아야 하고,
Plugin에 새 값이 필요해지면 Contract와 DTO를 명시적으로 넓혀야 합니다.

그 수고를 감수하는 이유는 양쪽의 변경 속도를 분리하기 위해서입니다.
Board는 테이블과 Entity를 정리할 수 있고, Plugin은 약속된 값만을 기준으로 동작할 수 있습니다.
공개 표면이 작을수록 유지해야 할 약속도 작습니다.

경계는 문서로만 권고하지 않습니다.
저장소의 검사기는 Package와 Plugin의 운영 코드가 임포트하는 심볼을 모아 안정 API 표면 밖의 것을 찾습니다. 종속 Plugin이 부모 Package에서 임포트할 수 있는 것은 공개 Contract, readonly DTO, 공식 Event 세 곳뿐이고, 동봉 확장의 위반 허용치는 0건입니다.

지금 편한 내부 호출 하나가 다음 업데이트의 고정 조건이 되지 않도록, 의존할 수 있는 자리를 코드 검사로 닫아둔 것입니다.
Package가 공개하기로 결정하지 않은 것은 Plugin이 우연히도 쓸 수 없어야, 공개 표면을 정하는 권한이 실제로 Package에 있습니다.

아무도 남의 등록 순서를 관찰하지 않습니다

확장이 활성화되면 Provider는 register()boot() 두 단계를 거칩니다.
register()에서는 서비스 정의를 Container에 등록합니다.
활성 Package 전체와 Plugin 전체의 등록이 끝난 뒤,
boot()에서 Event 구독자와 블록 타입, 사이트맵 제공자 같은 연결을 붙입니다.

처음부터 한 번에 실행하는 편이 더 단순해 보입니다.
하지만 먼저 실행된 확장이 아직 등록되지 않은 다른 확장의 서비스를 필요로 한다면 설치 순서가 곧 동작 조건이 됩니다.
관리 화면에서 보이는 순서를 바꾸거나 새 확장을 하나 추가한 것만으로 결과가 달라질 수 있습니다.

정의와 연결을 나누면 boot 시점에는 필요한 서비스 정의가 모두 준비돼 있습니다.
그러면서도 같은 층의 Package끼리, Plugin끼리는 로딩 순서를 보장하지 않습니다.
순서를 보장하면 직접 참조에 기대는 코드도 한동안 정상처럼 동작하고, 그 의존이 뒤늦게 드러나기 때문입니다.

순서를 알 수 없게 둔 것은 불친절이 아니라 경계입니다.
한 Package가 다른 Package의 등록 시점을 관찰할 수 있으면, 관찰당한 쪽은 공개하지도 않은 내부 순서까지 호환성으로 지켜야 합니다.

서로 필요한 기능은 공개 Contract로 찾고, 참여할 시점은 Event로 구독합니다.
정상적인 확장이라면 누가 먼저 등록됐는지를 관찰할 이유가 없습니다.

설치되어 있다는 것과 이 사이트에서 쓰는 것은 다릅니다

확장 코드는 서버에 한 번 배포되지만, 활성 상태는 도메인마다 다릅니다.
요청이 들어오면 머블로는 먼저 도메인을 확정하고,
그 도메인에서 활성화한 Package와 Plugin만 register·boot 합니다.
비활성 확장의 Route와 Asset도 노출하지 않습니다.

코드가 디스크에 있다는 이유만으로 모든 사이트의 실행에 참여하게 하면 도메인별 구성이 성립하지 않습니다.
쇼핑 기능을 쓰지 않는 사이트에도 주문 Route가 생기고, 알림 채널을 켜지 않은 사이트에서도 구독자가 실행될 수 있습니다.

활성 확장만 로드하는 구조에서는 등록 결과가 곧 현재 사이트의 기능 목록이 됩니다.
Board가 활성화된 도메인에서만 게시판 사이트맵 제공자가 Contract에 등록되므로,
사이트맵은 Board 테이블을 직접 알지 않고도 해당 URL을 포함합니다.
다른 도메인에서 Board를 끄면 제공자 자체가 등록되지 않습니다.

어떤 주소가 공개 콘텐츠인지 가장 잘 아는 쪽은 그 주소를 만든 확장입니다.
Core는 여러 제공자의 결과를 모으는 규칙만 소유하고,
모든 Package의 테이블과 URL 규칙을 알지 않습니다.

실패해도 남의 등록까지 무너뜨리지 않습니다

Provider가 서비스 세 개와 Event 구독자 두 개를 등록한 뒤, 마지막 블록 타입 등록에서 멈출 수도 있습니다.
앞에서 등록한 것들이 그대로 남으면 관리 화면에는 블록이 보이는데 실행할 서비스는 없거나, 실패한 구독자가 계속 Event를 받거나, 같은 이름을 쓰려는 다른 확장의 등록을 막을 수 있습니다.
“확장 로딩 실패” 한 건이 요청 전체의 불완전한 상태로 번집니다.

머블로는 확장 하나의 register와 boot를 소유자 단위로 추적합니다.
Container 정의, Contract, Event 구독, 블록 타입과 위젯처럼 Core에 붙인 항목은 등록할 때 되돌리는 방법도 함께 기록합니다.

boot까지 끝나면 그 기록을 버리고 등록을 확정합니다.
중간에 멈추면 실패한 확장이 남긴 항목만 역순으로 되감고, 로드된 확장 목록에서도 제외합니다.
부모 Package가 준비되지 않았다면 그 Package에 종속된 Plugin도 실행하지 않습니다.

추적 단위를 요청이 아니라 소유자로 잡은 이유가 여기 있습니다.
한 확장의 실패가 같은 요청에 올라탄 다른 확장의 등록까지 되감으면,
남의 잘못으로 내 기능이 사라지는 구조가 됩니다.

이것은 데이터베이스의 업무 트랜잭션을 대신하는 기능이 아닙니다.
Migration과 설치 작업은 각 확장이 별도로 멱등성과 복구 방법을 가져야 합니다.
여기서 보장하는 것은 한 요청의 실행 환경에 반쯤 등록된 확장을 남기지 않는 것입니다.

확장 실패를 어디까지 계속 처리할지는 다음 글에서 별도로 다룰 문제입니다.
그 전에 필요한 조건은 계속하든 멈추든 현재 상태를 정확히 정리하는 것입니다.

열어둔 자리에는 책임이 따라옵니다

확장 구조가 있다고 해서 모든 내부 동작을 Plugin에서 바꿀 수 있는 것은 아닙니다.
필요한 Contract나 Event가 없다면 내부 Service를 우회해서 호출하는 대신 공개 지점을 먼저 설계해야 합니다.
어느 시점에 무엇을 넘길지, 차단할 수 있는지, 결과의 최종 검증은 누가 하는지 정해야 합니다.

공개 Event는 한 번 배포되면 발생 시점과 의미도 호환성의 일부가 됩니다.
Contract의 메서드 하나, DTO의 필드 하나도 이후 버전에서 유지해야 할 약속입니다.
확장 지점을 많이 만드는 것이 항상 좋은 설계는 아닙니다.
열어둔 자리는 곧 Package 개발자가 자기 일정으로 지켜야 할 목록이고,
그 목록이 길어질수록 자기 영역을 정리할 자유가 줄어듭니다.

그래서 열어주는 기준은 “나중에 쓸 수도 있는가”가 아니라 현재 다른 기능이 참여해야 하는가입니다.
사용 사례가 생겼을 때 가장 작은 Contract와 가장 분명한 Event를 추가합니다.

고치지 않고도 함께 움직이는 구조

게시글 신고 기능을 붙이기 위해 Board 원본을 고치지 않는 것.
게시글의 내부 Entity 대신 필요한 값만 약속받는 것.
버튼 추가와 조회 차단과 삭제 후 정리를 각자의 Event에서 처리하는 것.
Plugin을 끄면 다음 요청부터 그 연결도 함께 로드되지 않는 것.

코어를 가볍게 유지한다는 말은 기능을 적게 만든다는 뜻이 아닙니다.
새 기능이 들어올 때마다 코어가 그 기능의 이름과 구현을 직접 알아야 하는 구조를 만들지 않는다는 뜻입니다.

네 번째 글에서 다룬 블록 타입도 같은 방식으로 들어옵니다.
활성 확장이 boot 단계에서 자기 블록의 이름과 Renderer를 등록하고,
Core 편집기는 공통 규약으로 목록을 보여줍니다.
Core가 게시판 블록과 FAQ 블록을 하나씩 내장할 필요가 없습니다.

확장은 바깥에 코드를 둘 수 있게 해주는 폴더 규칙이 아닙니다.
서로의 원본을 소유하지 않은 채 Contract로 요청하고, Event로 참여하고, 실패하면 자기 흔적만 정리하는 실행 규약입니다.

그 규약 위에서 Core 개발자는 공통 실행 환경을 고치고, Package 개발자는 자기 업무 영역과 공개 표면을 발전시키며, Plugin 개발자는 부모를 수정하지 않고 새 기능을 더합니다. 소유한 것이 서로 다르므로 배포 주기도 서로 다를 수 있습니다.

확장 구조가 정하는 것은 코드를 어디에 두느냐가 아니라, 그 코드를 누가 바꿀 수 있느냐입니다.

1명이 반응했습니다.

글쓰기

댓글 0

등록된 댓글이 없습니다.