"monday.com 좋은데, 딱 이 기능 하나가 없네."
monday.com과 같은 프로젝트 관리 통합플랫폼을 쓰다 보면 누구나 한 번쯤 하는 말입니다. 그리고 대부분은 아쉬움에서 끝납니다. 그런데 바로 그 지점이 기회입니다.
monday.com에서는 없으면 직접 만들어 붙일 수 있으니까요. 이 글은 실제로 그렇게 만든 플러그인 하나를 처음부터 끝까지 뜯어보면서, monday.com이 무엇이고 플러그인을 어떻게 만드는지를 A부터 Z까지 정리한 기록입니다.
monday.com을 한 번도 안 써본 분도, 플러그인이라는 걸 만들어본 적 없는 분도 이 글 하나로 “아, 나도 우리 팀이 원하는 기능을 직접 붙일 수 있겠다”는 감을 잡을 수 있게 쓰는 것이 목표입니다. 그래서 중간중간 개념 설명을 충분히 깔아두었습니다.
monday.com이 처음이시라면 → 1장부터 (약 20분)
monday.com은 아는데 플러그인 개발이 처음이라면 → 3장부터 (약 15분)
본 게시글은 실제 프로젝트를 진행하면서 구현한 플러그인을 토대로 작성한 내용입니다. 게시글 내 모든 코드는 설명을 위한 의사코드일뿐이며 특정 내용만 발췌해 다듬은 것입니다.
본격적으로 코드를 보기 전에, monday.com부터 짚고 넘어가겠습니다. 이걸 모르면 플러그인이 어디에 어떻게 붙는지 감이 안 잡히거든요.
monday.com은 한마디로 “일하는 방식을 직접 조립하는 도구”입니다. 회사에서는 보통 Work OS(Work Operating System)라고 부릅니다. 엑셀 시트 같기도 하고, 칸반 보드 같기도 하고, 프로젝트 관리 툴 같기도 한데, 사실 그 전부가 될 수 있습니다. 핵심은 데이터를 표 형태로 쌓아두고, 그 표를 원하는 방식으로 바라보고, 자동화한다는 점입니다.
구조는 생각보다 단순합니다. 위에서부터 차례대로 보면 이렇습니다.
| 개념 | 설명 | 비유 |
|---|---|---|
| Workspace | 팀/부서 단위의 가장 큰 공간 | 사무실 건물 |
| Board | 하나의 작업 표 | 사무실 안의 화이트보드 한 장 |
| Group | 보드 안에서 아이템을 묶는 묶음 | 화이트보드의 “이번 주 / 다음 주” 구역 |
| Item | 표의 한 행. 작업·고객·자산 등 하나의 레코드 | 포스트잇 한 장 |
| Column | 아이템의 속성 하나 (상태, 날짜, 담당자, 파일…) | 포스트잇에 적힌 항목들 |
| Update | 아이템에 달리는 댓글/대화 | 포스트잇 뒤에 붙은 메모 |

그러니까 “보드를 열면 → 그룹으로 나뉜 아이템(행)들이 있고 → 각 아이템은 여러 컬럼(상태, 날짜, 담당자, 첨부파일 등)을 가진다”가 전부입니다. 이 멘탈 모델만 잡으면 나머지는 쉽습니다.

그리고 여기가 중요한데요 — monday.com은 이 화면들 곳곳에 개발자가 만든 화면을 끼워 넣을 수 있는 자리를 열어뒀습니다. 보드 위에 새 탭으로, 아이템을 열었을 때 상세 영역에, 대시보드 위젯으로요. 바로 그 “끼워 넣는 화면”이 플러그인입니다.
monday.com에서는 플러그인을 공식적으로 앱(App)이라고 부릅니다. (이 글에서는 둘을 같은 뜻으로 섞어 씁니다.) 공식 문서의 표현을 빌리면, 앱은 “monday 플랫폼 위에 새로운 빌딩 블록을 얹는 것”입니다. 즉 monday가 기본으로 주지 않는 기능을, 내가 직접 만들어 플랫폼에 붙이는 것이죠.
처음 헷갈리는 지점이 바로 이겁니다. 둘의 관계는 이렇습니다.
예를 들어 우리가 만든 앱은 피처가 두 개입니다. 하나는 아이템에 붙는 파일 업로더 화면(Item View), 다른 하나는 관리자가 설정을 바꾸는 화면(Admin View)이죠. 둘 다 한 앱 안에 들어 있습니다.
monday가 제공하는 주요 피처 타입은 대략 이렇습니다. (이 중 하나를 고르는 것이 앱 설계의 첫걸음입니다.)
| 피처 타입 | 어디에 뜨나 | 쓰임새 |
|---|---|---|
| Board View | 보드 제목 아래 탭 | 보드 전체 데이터를 새로운 방식으로 시각화 (칸반, 지도, 차트…) |
| Item View | 아이템을 열었을 때 상세 영역 | 아이템 하나에 종속된 기능 (← 우리가 쓴 것) |
| Dashboard Widget | 대시보드 | 여러 보드의 데이터를 모아 보여줌 |
| Integration Recipe | 자동화 설정 | “이 일이 생기면 → 저 일을 한다” 트리거-액션 |
| Admin/Administration View | 관리자(Administration) 영역 | 계정 단위 설정·관리 화면 (← 우리가 쓴 것) |
| AI Feature / Doc Action 등 | 각 영역 | AI 기능, 문서 액션 등 |
여기가 이 글에서 제일 하고 싶은 이야기입니다.
협업 툴은 아무리 잘 만들어도 모든 팀의 모든 요구를 다 담을 수는 없습니다. 어느 팀은 대용량 영상 파일을 주고받아야 하고, 어느 팀은 특정 부서만 파일을 받게 막아야 하고, 어느 팀은 자기들만의 결재 흐름이 필요합니다. 이걸 전부 기본 기능으로 넣으면 제품은 누더기가 됩니다.
그래서 monday는 반대로 갔습니다. “빈틈은 너희가 채워라. 대신 채울 수 있는 길을 활짝 열어주겠다.” 그 길이 바로 앱 프레임워크고, 그래서 통계상 49%의 계정이 최소 한 개의 앱을 설치해 쓰고 있습니다. 빈틈을 내가 원하는 모양으로 메우는 순간, 그건 우리 팀에게 딱 맞는 도구가 됩니다. 잘 만들면 마켓플레이스에 올려 다른 팀에게 팔 수도 있고요.
우리 이야기로 돌아오면 — 우리는 “아이템 하나에 대용량 파일을 마음껏 올리고 받는 기능”이 필요했습니다. 그래서 Item View + Admin View를 가진 앱을 직접 만들었죠. 다음 장에서 그 “없던 기능”이 구체적으로 무엇이었는지 보겠습니다.

monday에도 파일 컬럼은 있습니다. 아이템에 파일을 첨부하는 기본 기능이죠. 그런데 실무에서 대용량 파일을 본격적으로 주고받기 시작하면 금방 한계에 부딪힙니다. 또한 저희가 진행했던 프로젝트는 고객이 요구한 핵심 기능 중 하나가 파일 접근에 대한 보안이었습니다. monday가 기본 제공하는 파일 첨부는 해당 파일이 monday가 관리하는 스토리지에 저장되는데 이는 보안상 외부 노출에 대한 위험과 감사에도 자유롭지 못하죠.
그 뿐만 아니라 다음과 같은 제약들이 기본 기능에는 존재합니다.
이게 우리가 마주한 “없던 기능”이었습니다. 그래서 만든 것이 커스텀 파일 업로더입니다. 우리가 채운 기능을 정리하면 이렇습니다.
| 기능 | 기본 파일 컬럼 | 커스텀 파일 업로더 |
|---|---|---|
| 대용량 파일 (최대 10GB) | ❌ | ✅ 10MB 청크로 쪼개 업로드 |
| 여러 청크 동시 업로드 | ❌ | ✅ 속도 향상 |
| 업로드 이어받기(resume) | ❌ | ✅ 끊긴 지점부터 재개 |
| 일괄(ZIP) 다운로드 | ❌ | ✅ 선택 후 한 번에 |
| 팀별 다운로드/삭제 권한 | ❌ | ✅ 전체/특정 팀 허용·제외 |
| 파일 자체에 대한 보안 적용 | ❌ | ✅ 파일 관리 서버를 중간에 두어 AIP 적용이 가능 |
| 파일 확장자 제한 | △ | ✅ 허용/차단 목록 |
| 드래그 앤 드롭 | △ | ✅ |
| monday 테마(라이트/다크) 자동 적용 | — | ✅ |
핵심은 “기본 기능으로는 안 되는 일을, 플러그인으로 정확히 그 팀이 원하는 모양으로 채웠다”는 것입니다. 이게 바로 플러그인의 가치죠. 이제 이걸 어떻게 만들었는지, 진짜로 처음부터 따라가 보겠습니다.
코드를 짜기 전에 monday 쪽에 “나 앱 하나 만들 거예요” 하고 등록부터 해야 합니다. 순서는 이렇습니다.
monday 계정에서 프로필 사진 → Developers로 들어가면 개발자 센터(Developer Center)가 열립니다. 무료 개발자 계정으로도 충분히 시작할 수 있습니다. 여기서 Create App 버튼을 눌러 앱 껍데기를 하나 만듭니다. 이 앱이 나중에 우리가 만들 피처(Item View, Admin View)를 담는 그릇이 됩니다.


monday는 mapps라는 커맨드라인 도구(CLI)를 제공합니다. 로컬 개발용 터널을 열고, 빌드한 코드를 monday로 밀어 올리고, 버전을 승격하는 일을 전부 이걸로 합니다.
npm install -g @mondaycom/apps-cli
설치하면 mapps 명령을 쓸 수 있습니다.
CLI가 내 계정으로 작업하려면 인증이 필요합니다. 개발자 센터의 Developers > API token 화면에서 토큰을 발급받아 한 번만 등록해 주면 됩니다.
mapps init -t <YOUR_API_TOKEN>

자, 이제 monday 쪽 준비는 끝났습니다. 코드로 넘어가 보겠습니다.
우리 플러그인은 평범한 React 단일 페이지 앱(SPA)입니다. 특별할 게 없어요. monday 안에서 <iframe>으로 띄워질 뿐, 그 안은 그냥 React입니다. 그래서 진입장벽이 낮습니다. React를 짜 본 분이라면 절반은 먹고 들어가는 셈이죠.
처음 시작할 땐 어디서부터 시작해야 하는지 감이 안 오실 수 있습니다. monday는 이런 분들을 위해 스켈레톤 프로젝트를 제공해 줍니다. 위에서 앱을 만들었으면 해당 앱에 들어가면 이런 화면이 보입니다.

여기서 좌측 사이드바에 Features 버튼을 클릭하세요. 위에서 설명드린 것처럼 이 앱에서 어떤 기능을 가질 것인가?를 정의하는 곳입니다.


만약 보드 내 아이템 화면에 새로운 기능을 추가하고 싶으면 Features → Item View 를 선택하면 됩니다.

Item View 카드를 선택하고 다음으로 넘어가면 아래와 같은 화면이 보이는데요. 여기서 Quickstart – ReactJS를 클릭합니다.


위 화면에서 보이는 명령어를 터미널에 입력하시면 됩니다. 이 작업은 명령어를 입력하신 터미널 경로에 스켈레톤 프로젝트를 만들어 줍니다. 거기서부터 시작하시면 더 빠르게 플러그인 개발이 가능합니다.
명령어를 입력하시면 결과로 아래 화면과 유사한 화면이 보이실 겁니다.

파란색 네모로 표시한 URL이 입력하라는 URL입니다. 이 URL을 아래와 같이 입력한 다음 작업을 마치면 됩니다.

이 URL이 중요한 핵심은 아닙니다. 그럼에도 불구하고 이게 무엇을 의미하는지 궁금해하실 분들을 위해 간략하게 말씀드리면 monday 플러그인을 개발하고 제대로 개발이 됐는지 확인하기 위해 monday.com에 플러그인을 등록하고 monday.com에서 플러그인을 띄워서 확인하겠죠? 5장 들어서면서 초입에 말씀드린 iframe 관련 내용을 떠올려 주세요.
monday.com은 플러그인을 iframe 형태로 제공합니다. 그리고 iframe에 앱을 띄우기 위해선 주소가 필요한데요. 그 주소가 바로 monday.com에서 임시로 제공해주는 이 주소입니다. 내부적으로 이러한 형태라고 봐주시면 됩니다.
<iframe src="https://xxxx.apps-tunnel.monday.app">개발 과정에서 내 로컬 개발 서버와 연결되는 터널을 열고 작업 내용을 바로바로 확인하고 싶을 때 유용한 방식입니다. 프로덕션으로 배포를 하게 되면 monday CDN이 호스팅하고 주소가 그쪽으로 대체되기 때문에 이 주소의 역할은 끝나요.
여기까지가 스켈레톤 프로젝트를 시작하는 단계. 이제 진짜 코드를 뜯어보겠습니다.
빌드 도구는 Vite, UI는 monday의 디자인 시스템 Vibe(@vibe/core)를 썼습니다. 의존성을 보면 이 앱의 정체가 다 드러납니다.
package.json
{
"name": "file-uploader",
"type": "module",
"scripts": {
"start": "npm run init && npm run stop && concurrently \"npm run server\" \"npm run expose\"",
"deploy:build": "vite build",
"deploy:push": "mapps code:push --client-side -d \"build\"",
"deploy": "npm run deploy:build && npm run deploy:push",
"init": "mapps init -l",
"expose": "mapps tunnel:create -p 8301",
"server": "vite",
"stop": "kill-port 8301 && kill-port 4049 && kill-port 4040"
},
"dependencies": {
"@mondaycom/apps-sdk": "^2.1.2",
"@vibe/core": "^3.19.0",
"monday-sdk-js": "^0.5.5",
"react-router-dom": "^7.12.0"
},
"devDependencies": {
"@mondaycom/apps-cli": "^4.9.2",
"@vitejs/plugin-react": "^4.3.2",
"vite": "^6.2.2",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"sass": "^1.54.8"
}
}monday 관련해서 기억하실 패키지는 세 개입니다.
@mondaycom/apps-cli — 위에서 설치한 mapps CLI. 빌드/배포에 씁니다.monday-sdk-js — 플러그인(iframe)이 부모 monday 화면과 대화하는 클라이언트 SDK. 이 글의 주인공입니다.@vibe/core — monday의 React UI 컴포넌트 라이브러리(Vibe)입니다. 버튼, 진행바 같은 걸 monday 룩 그대로 쓰게 해줍니다. (예전엔 monday-ui-react-core라는 이름이었습니다.)vite.config.js
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig(() => {
return {
build: {
outDir: 'build', // 빌드 결과물이 들어갈 폴더 (배포 시 이 폴더를 올린다)
},
plugins: [react()],
server: {
port: 8301,
allowedHosts: ['.apps-tunnel.monday.app'], // ← 이게 핵심
},
};
});allowedHosts에 .apps-tunnel.monday.app을 넣은 것이 포인트입니다. 로컬 개발할 때 mapps tunnel:create로 터널을 열면, monday는 xxx.apps-tunnel.monday.app 같은 주소로 내 로컬 서버(8301 포트)에 접속합니다. 이 호스트를 허용해 주지 않으면 Vite가 막아버려서 화면이 안 뜹니다. (여기서 한 번 막히면 원인 찾느라 한참 헤매게 됩니다.)
나머지는 React 표준 그대로입니다.
index.html — #root에 React를 꽂고, 모듈 엔트리를 불러옵니다.
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<script type="module" src="/src/index.jsx"></script>
</body>src/index.jsx — React 18 방식으로 앱을 마운트합니다.
import "./init"
import React from "react";
import { createRoot } from "react-dom/client";
import "./index.css";
import App from "./App";
const root = createRoot(document.getElementById("root"));
root.render(<App />);src/init.js — 딱 한 줄이지만 의외로 중요합니다.
window.global ||= window;일부 라이브러리는 브라우저에 global이라는 전역 객체가 있다고 가정하고 동작합니다(원래 Node.js 환경의 것이죠). 브라우저엔 그게 없어서, 없으면 window를 가리키도록 폴리필을 깔아줍니다. 이걸 안 해주면 특정 라이브러리에서 global is not defined 에러가 터집니다. 한 줄짜리지만 안 깔면 앱이 안 뜨는, 그런 류의 코드입니다.
이제 monday에게 “내 앱은 이렇게 생겼고, 이런 권한이 필요하고, 이런 화면(피처)을 가진다”고 선언할 차례입니다. 그 선언서가 바로 매니페스트(manifest)입니다. 코드를 짜는 것만큼이나 중요한 파일이죠. 여기에 적은 대로 monday가 앱을 인식합니다.
monday-manifests/manifest.json (식별 정보는 예시값으로 치환했습니다)
{
"version": "1.0.0",
"app": {
"name": "mTRM",
"slug": "my-team_mtrm",
"description": "mTRM 파일 업로드/다운로드 전용 아이템뷰",
"color": "#6DC83C",
"oauth": {
"scopes": [
"me:read",
"boards:read", "boards:write",
"users:read",
"teams:read",
"assets:read",
"updates:read", "updates:write",
"account:read"
// … 실제로는 필요한 scope를 더 선언했다
]
},
"features": [
{
"key": "mtrm",
"type": "AppFeatureItemView",
"name": "mTRM",
"schemaVersion": "11",
"build": { "kind": "view" },
"data": {
"mode": "iframe",
"name": "mTRM",
"query_parmas": true,
"isMobileSupported": false
}
},
{
"key": "mtrm-settings-admin",
"type": "AppFeatureAdminView",
"name": "mTRM Settings",
"schemaVersion": "3",
"build": { "url": "/settings", "kind": "view" },
"data": { "name": "mTRM Settings" }
}
]
}
}하나씩 뜯어보겠습니다.
app — 앱의 기본 정보
name / slug — 표시 이름과 고유 식별자입니다. slug는 보통 팀이름_앱이름 형태로 짓습니다.description / color — 마켓플레이스·설치 화면에 보이는 설명과 강조색입니다.oauth.scopes — 권한 범위여기가 초보자분들이 자주 빠뜨리는 부분입니다. monday API는 선언한 scope만큼만 허용합니다. 보드를 읽으려면 boards:read, 팀 목록을 조회하려면 teams:read, 파일/자산에 접근하려면 assets:read가 있어야 합니다. 우리 앱이 GraphQL로 사용자의 팀을 조회하고(users:read, teams:read) 파일을 다루기 때문에(assets:read) 그에 맞는 scope를 선언했습니다.
여기서 살짝의 팁은, scope는 필요한 만큼만 선언하는 것이 원칙입니다. 너무 넓게 잡으면 설치 시 사용자가 불안해하고, 마켓플레이스 심사에서도 지적받습니다. 다만 개발 중엔 어떤 scope가 필요한지 헷갈리니 일단 넓게 잡고 나중에 줄이는 것도 현실적인 방법입니다.
— 우리가 만든 두 화면features
배열 안에 피처 두 개가 들어 있습니다.
AppFeatureItemView (아이템 뷰) — 아이템을 열었을 때 뜨는 메인 업로더 화면입니다.
mode: "iframe" — iframe으로 렌더링합니다. (우리 React 앱이 여기 들어갑니다.)query_parmas: true — monday가 현재 아이템·보드 같은 컨텍스트를 URL 쿼리로 넘겨줍니다.isMobileSupported: false — 모바일 미지원(데스크톱 전용)입니다. 대용량 파일 업로더라 모바일은 빼는 것이 합리적이었습니다.AppFeatureAdminView (어드민 뷰) — 관리자 설정 화면입니다.
build.url: "/settings" — 이 피처는 우리 앱의 /settings 경로로 라우팅됩니다. (라우팅은 7장에서 봅니다.)즉 같은 React 앱 하나가, 진입 피처에 따라 다른 경로(/ vs /settings)로 렌더링됩니다. 한 앱에 두 화면을 담는 전형적인 패턴이죠.
AppFeatureItemView

AppFeatureAdminView

플러그인은 monday 화면 안에 iframe으로 떠 있습니다. 그러면 “내가 지금 어떤 아이템 위에 떠 있는지”, “지금 보는 사람이 누군지”를 어떻게 알 수 있을까요? 정답은 monday-sdk-js입니다.
우리 앱의 루트 컴포넌트 App.jsx가 이 일을 합니다.
src/App.jsx
import mondaySdk from "monday-sdk-js";
import "@vibe/core/tokens";
import { Flex, Loader, Text } from "@vibe/core";
// ...
const monday = mondaySdk(); // SDK 인스턴스 생성 (자격증명 불필요!)
const App = () => {
const [context, setContext] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
monday.execute("valueCreatedForUser");
// 1) 세션 토큰 획득 → 우리 백엔드 인증에 사용
monday.get('sessionToken').then((res) => {
if (res.data) {
uploadService.setSessionToken(res.data);
}
});
// 2) 컨텍스트 구독: 어떤 아이템/보드/유저/테마인지
monday.listen("context", (res) => {
setContext(res.data); // { itemId, boardId, workspaceId, user, theme ... }
setLoading(false);
});
// 혹시 컨텍스트가 영영 안 오는 예외 상황 대비 5초 타임아웃
const timeout = setTimeout(() => setLoading(false), 5000);
return () => clearTimeout(timeout);
}, []);
// ...
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<FileUploader context={context} />} />
<Route path="/settings" element={<AdminSettings context={context} />} />
</Routes>
</BrowserRouter>
);
};여기서 짚어볼 포인트가 몇 개 있습니다.
mondaySdk() — 자격증명이 필요 없습니다
const monday = mondaySdk(); 한 줄이면 끝입니다. API 키도, 토큰도 안 넣습니다. monday가 이 앱을 자기 화면 안에서 띄워줬다는 사실 자체가 인증이기 때문이죠. 이걸 Seamless Authentication(무자각 인증)이라고 부릅니다. 사용자가 이미 monday에 로그인해 있으니, SDK 호출은 그 사용자의 권한으로 자동 실행됩니다. (대신 6장에서 선언한 scope 범위 안에서만요.)
monday.get('sessionToken') — 우리 백엔드를 인증할 때
위의 무자각 인증은 monday API를 부를 때 얘기입니다. 그런데 우리 앱은 파일을 받아주는 별도 백엔드 서버도 호출합니다(9장 참고). 그 서버 입장에선 “지금 요청 보낸 사람이 진짜 monday 사용자가 맞아?”를 검증해야 합니다. 그래서 monday.get('sessionToken')으로 서명된 세션 토큰을 받아, 모든 백엔드 요청 헤더에 Authorization: Bearer <token>으로 실어 보냅니다. 백엔드는 이 토큰을 검증해 신원을 확인하죠.
monday.listen("context", ...) — 내가 떠 있는 무대 정보
context에는 이런 것들이 들어옵니다.
itemId, boardId, workspaceId — 지금 이 플러그인이 붙어 있는 아이템/보드/워크스페이스user — 지금 보고 있는 사람(id, name, isAdmin 등)theme — light / dark / blackget이 아니라 listen을 쓴 이유는, 사용자가 테마를 바꾸거나 컨텍스트가 변할 때 계속 최신값을 받기 위해서입니다. 받은 테마는 <body>에 클래스(light-app-theme 등)로 붙여, 플러그인이 monday의 다크/라이트 모드를 그대로 따라가게 했습니다. 디테일이지만 이게 있어야 “기본 기능처럼” 자연스럽습니다.
라우팅
react-router-dom으로 /는 파일 업로더, /settings는 관리자 설정으로 나눴습니다. 6장에서 어드민 뷰 피처에 build.url: "/settings"라고 적은 것이 여기서 연결됩니다.
UI는 monday의 디자인 시스템 Vibe(@vibe/core)로 짰습니다. 왜 굳이 Vibe냐고요? 직접 CSS로 버튼을 그려도 되지만, 그러면 “딱 봐도 외부 앱” 티가 납니다. Vibe를 쓰면 버튼·진행바·체크박스가 monday 기본 UI와 똑같이 생겨서, 사용자가 이질감 없이 “원래 있던 기능”처럼 받아들입니다. 접근성도 기본으로 챙겨 주고요.
FileUploader.jsx가 쓰는 Vibe 컴포넌트들입니다.
import {
Button, IconButton, Text, Loader,
LinearProgressBar, Icon, Flex, Checkbox
} from '@vibe/core';
import {
Upload, CloseSmall, Delete, File, Check, Download, Retry, Alert
} from '@vibe/icons';Flex로 레이아웃을 잡고, LinearProgressBar로 업로드 진행률을, Checkbox로 일괄 다운로드 선택을 처리합니다. 아이콘도 @vibe/icons에서 그대로 가져다 씁니다.
파일을 올리는 방법은 두 가지입니다. 드롭존에 끌어다 놓거나, 클릭해서 파일 선택창을 열거나죠. 둘 다 결국 같은 검증 함수(validateFiles)를 거쳐 같은 큐에 들어갑니다.
src/components/FileUploader/FileUploader.jsx
// 드래그 중 — 시각적 하이라이트만 켜고 브라우저 기본동작(파일 열기) 차단
const handleDragOver = (e) => { e.preventDefault(); setIsDragging(true); };
const handleDragLeave = (e) => { e.preventDefault(); setIsDragging(false); };
// 드롭 — 놓인 파일을 검증 후 큐에 추가
const handleDrop = (e) => {
e.preventDefault();
setIsDragging(false);
const files = e.dataTransfer?.files;
if (files?.length > 0) {
const validFiles = validateFiles(files);
if (validFiles.length > 0) addFiles(validFiles);
}
};
// <input type="file"> 선택 — 동일하게 검증 후 큐에 추가
const handleFileSelect = useCallback((event) => {
const files = event.target.files;
if (files?.length > 0) {
const validFiles = validateFiles(files);
if (validFiles.length > 0) addFiles(validFiles);
}
event.target.value = ''; // 같은 파일 재선택 시에도 onChange가 또 발생하도록 초기화
}, [addFiles, validateFiles]);마지막 event.target.value = '' 한 줄을 눈여겨보세요. 이게 없으면 같은 파일을 두 번 연속 선택했을 때 onChange가 안 터지는 브라우저 동작에 당합니다. 사소하지만 안 넣으면 “왜 두 번째 선택이 안 먹지?” 하고 30분 날리게 되는 부분이죠.
validateFiles는 두 가지를 봅니다 — 파일 크기와 확장자입니다. 확장자 규칙은 관리자 설정(11장)에서 정한 모드(허용/차단)를 따릅니다.
const validateFiles = useCallback((files) => {
const validFiles = [];
const errors = [];
Array.from(files).forEach((file) => {
// 1) 크기 제한 (기본 10GB)
if (file.size > maxFileSize) {
errors.push(`${file.name}: 최대 크기(${formatFileSize(maxFileSize)})를 초과합니다.`);
return;
}
// 2) 확장자 제한
const ext = getFileExtension(file.name).toLowerCase().replace('.', '');
if (fileRestriction.mode === FILE_RESTRICTION_MODE.BLOCKLIST) {
if ((fileRestriction.blockedExtensions || []).includes(ext)) {
errors.push(`${file.name}: 허용되지 않는 파일 형식입니다. (${ext})`);
return;
}
} else if (fileRestriction.mode === FILE_RESTRICTION_MODE.ALLOWLIST) {
const allowed = fileRestriction.allowedExtensions || [];
if (allowed.length > 0 && !allowed.includes(ext)) {
errors.push(`${file.name}: 허용되지 않는 파일 형식입니다. (허용: ${allowed.join(', ')})`);
return;
}
}
validFiles.push(file);
});
if (errors.length > 0) {
notify({ domain: 'ATTACHMENT', type: 'error', rawMessage: errors.join('\n') });
}
return validFiles;
}, [maxFileSize, fileRestriction, notify]);검증을 통과한 파일만 addFiles로 업로드 큐에 들어가고, 거부된 파일은 한꺼번에 토스트로 알려줍니다. 토스트는 뒤에서 볼 monday.execute('notice', ...)로 띄웁니다.


이 앱의 심장입니다. 10GB짜리 파일을 어떻게 브라우저에서 안정적으로 올리느냐. 답은 “한 번에 안 올린다”입니다.
파일을 통째로 한 요청에 실으면, 중간에 네트워크가 한 번만 끊겨도 전부 날아갑니다. 서버·프록시의 요청 타임아웃에도 걸리고요. 그래서 파일을 잘게 쪼개서(청크) 여러 번에 나눠 올립니다.
설정값부터 보겠습니다.
src/utils/constants.js
export const DEFAULT_UPLOAD_CONFIG = {
CHUNK_SIZE: 10 * 1024 * 1024, // 청크 하나 = 10MB
CONCURRENT_CHUNKS: 3, // 청크 3개를 동시에 업로드
MAX_RETRIES: 3, // 청크 실패 시 최대 3번 재시도
RETRY_DELAYS: [1000, 2000, 4000], // 재시도 간격(지수 백오프): 1초 → 2초 → 4초
CHUNK_TIMEOUT: 60000,
MAX_CONCURRENT_FILES: 2, // 파일은 최대 2개까지 동시 업로드
MAX_FILE_SIZE: 10 * 1024 * 1024 * 1024, // 파일 1개 최대 10GB
};업로드 한 건의 생애주기는 이렇습니다.
[init] 서버에 "업로드 시작할게" → taskId 발급 (+ 이어받기면 이미 올라간 청크 목록도)
↓
[chunk] 파일을 10MB씩 잘라, 워커 3개가 동시에 청크 업로드 (실패하면 지수 백오프 재시도)
↓
[complete] "청크 다 올렸어, 합쳐줘" 요청
↓
[status] 서버가 병합·후처리하는 동안 3초마다 상태 폴링 (최대 10분) → COMPLETED!
청크를 실제로 받아 디스크에 합치는 일은 별도의 백엔드 서버가 합니다. 이 글은 monday 플러그인(프론트엔드)에 초점을 맞추므로 백엔드 구현은 다루지 않지만, 프론트엔드가 기대하는 API 계약은 알아야 따라 만들 수 있습니다. 우리 앱이 호출하는 엔드포인트는 이렇습니다. (이 규약대로 응답하는 서버를 직접 운영하면 됩니다.)
| 메서드 | 경로 | 역할 | 요청 → 응답 |
|---|---|---|---|
POST | /init | 업로드 작업 시작 | 파일 메타(이름/크기/타입) + 이어받기면 resumeTaskId → { taskId, existingChunks } |
POST | /chunk/{taskId}/{index} | 청크 한 조각 전송 | multipart/form-data의 chunk 필드 → 성공 여부 |
POST | /complete/{taskId} | 청크 병합 요청 | — → 성공 여부 |
GET | /status/{taskId} | 후처리 상태 폴링 | — → { status: 'MERGING' | 'COMPLETED' | 'FAILED' } |
GET | /files/{itemId} | 완료된 파일 목록 | — → 아이템별 파일 목록 |
GET | /pending/{itemId} | 중단된(이어받기) 목록 | — → 미완료 작업 목록 |
GET | /download/{fileId} | 단일 다운로드(스트리밍) | — → 바이너리 |
POST | /download/bulk | 일괄 ZIP 다운로드 | { fileIds } → ZIP 바이너리 |
DELETE | /file/{fileId} | 파일 삭제 | — → 성공 여부 |
DELETE | /cancel/{taskId} | 업로드 취소 | — → 성공 여부 |
프론트엔드 쪽 API 클라이언트는 이렇게 생겼습니다. (모든 요청에 세션 토큰을 자동으로 실어 보냅니다.)
src/services/uploadService.js
async initUpload(itemId, boardId, workspaceId, userId, file, resumeTaskId) {
const payload = {
workspaceId, itemId, boardId, userId,
fileName: file.name,
fileSize: file.size,
contentType: file.type || 'application/octet-stream',
};
if (resumeTaskId != null) payload.resumeTaskId = resumeTaskId; // 이어받기일 때만
const response = await safeFetch(`${apiBaseUrl}/init`, {
method: 'POST',
headers: getHeaders('application/json'),
body: JSON.stringify(payload),
});
const apiResponse = await response.json();
if (!apiResponse.success) throw new Error(apiResponse.message);
return apiResponse.data; // { taskId, existingChunks }
},
async uploadChunk(taskId, chunkIndex, chunkBlob, abortController) {
const formData = new FormData();
formData.append('chunk', chunkBlob); // 10MB Blob 한 조각
const response = await safeFetch(`${apiBaseUrl}/chunk/${taskId}/${chunkIndex}`, {
method: 'POST',
headers: getHeaders(),
body: formData,
signal: abortController?.signal, // 취소 가능하게
});
const apiResponse = await response.json();
if (!apiResponse.success) throw new Error(apiResponse.message);
return apiResponse.data;
},파일을 청크로 자르고, 어떤 청크가 올라갔고/실패했고/재시도 몇 번 했는지 추적하는 일은 ChunkManager 클래스가 전담합니다. 순수 로직이라 테스트하기도 좋습니다.
src/services/chunkManager.js
export class ChunkManager {
constructor(file, taskId, settings = {}) {
this.file = file;
this.chunkSize = settings.chunkSize || DEFAULT_UPLOAD_CONFIG.CHUNK_SIZE;
this.totalChunks = Math.ceil(file.size / this.chunkSize);
this.uploadedChunks = new Set(); // 완료된 청크 인덱스
this.failedChunks = new Map(); // 청크별 실패 횟수
// ...
}
// index번째 청크의 실제 데이터(Blob)를 잘라 반환
getChunk(index) {
const start = index * this.chunkSize;
const end = Math.min(start + this.chunkSize, this.file.size);
return { index, blob: this.file.slice(start, end), start, end, size: end - start };
}
// 아직 안 올라간 청크만 추려낸다 (이어받기의 핵심)
getPendingChunks(existingChunks = []) {
const existing = new Set(existingChunks);
return this.getAllChunkIndices().filter(
(i) => !existing.has(i) && !this.uploadedChunks.has(i)
);
}
// 서버에 이미 올라가 있던 청크는 완료로 미리 표시 → 이어받기 시 건너뜀
setExistingChunks(existingChunks) {
existingChunks.forEach((index) => this.uploadedChunks.add(index));
}
getProgress() { return (this.uploadedChunks.size / this.totalChunks) * 100; }
isComplete() { return this.uploadedChunks.size === this.totalChunks; }
}file.slice(start, end)가 마법의 핵심입니다. 브라우저의 File 객체는 Blob이라서, 메모리에 통째로 올리지 않고도 원하는 구간만 잘라낼 수 있습니다. 10GB 파일이어도 한 번에 메모리에 올라가는 건 10MB짜리 청크 몇 개뿐이죠.
진짜 엔진은 useUploadQueue 훅에 있습니다. 워커 패턴으로 청크 3개를 동시에 올리고, 실패하면 지수 백오프로 재시도합니다.
src/hooks/useUploadQueue.js — 청크 하나를 올리는 함수 (재시도 포함)
const uploadSingleChunk = useCallback(
async (itemId, chunkManager, chunkIndex, taskId) => {
if (chunkManager.isCanceled()) throw new DOMException('Upload aborted', 'AbortError');
const chunk = chunkManager.getChunk(chunkIndex);
const abortController = new AbortController();
chunkManager.abortControllers.set(chunkIndex, abortController);
try {
await uploadService.uploadChunk(taskId, chunkIndex, chunk.blob, abortController);
chunkManager.markChunkUploaded(chunkIndex);
updateItemField(itemId, 'progress', chunkManager.getProgress()); // 진행률 UI 갱신
} catch (error) {
if (error.name === 'AbortError') throw error; // 사용자가 취소한 거면 재시도 X
chunkManager.markChunkFailed(chunkIndex);
if (chunkManager.canRetry(chunkIndex) && !chunkManager.isCanceled()) {
const delay = chunkManager.getRetryDelay(chunkIndex); // 1초 → 2초 → 4초
await sleep(delay);
return uploadSingleChunk(itemId, chunkManager, chunkIndex, taskId); // 재귀로 재시도
}
throw new Error(`Chunk ${chunkIndex} failed after max retries`);
} finally {
chunkManager.abortControllers.delete(chunkIndex);
}
},
[updateItemField]
);src/hooks/useUploadQueue.js — 워커 3개로 동시 업로드
const uploadChunksConcurrently = useCallback(
async (itemId, chunkManager, pendingChunks, taskId) => {
const concurrency = uploadConfig.concurrentChunks; // 3
const chunks = [...pendingChunks];
// 워커: 큐에서 청크를 하나씩 꺼내(shift) 업로드. 다 꺼내거나 취소되면 종료.
const uploadWorker = async () => {
while (chunks.length > 0 && !chunkManager.isCanceled()) {
const chunkIndex = chunks.shift();
if (chunkIndex === undefined) break;
await uploadSingleChunk(itemId, chunkManager, chunkIndex, taskId);
}
};
// 청크 수와 동시성 중 작은 값만큼 워커 생성 → Promise.all로 전부 끝날 때까지 대기
const workers = Array(Math.min(concurrency, pendingChunks.length))
.fill(null)
.map(() => uploadWorker());
await Promise.all(workers);
},
[uploadSingleChunk]
);워커 패턴이 깔끔한 이유는 이렇습니다. 청크가 100개든 3개든, 워커 3개가 알아서 큐에서 하나씩 빼다 쓰기 때문에 동시성이 정확히 3으로 유지됩니다. 빠른 청크가 먼저 끝나면 다음 걸 바로 집어 들고요. 놀고 있는 워커가 없습니다.
청크를 다 올렸다고 끝이 아닙니다. 서버는 조각들을 합치고, 검증하고, 후처리해야 합니다. 이건 시간이 걸리는 비동기 작업이라, 클라이언트는 /status를 3초마다 찔러보며 완료를 기다립니다.
src/hooks/useUploadQueue.js
const waitUntilUploadCompleted = useCallback(async (taskId) => {
const maxWaitMs = 10 * 60 * 1000; // 최대 10분
const intervalMs = 3000; // 3초 간격
const start = Date.now();
while (Date.now() - start < maxWaitMs) {
try {
const status = await uploadService.getUploadStatus(taskId);
if (status.status === 'COMPLETED') return status; // 완료!
if (status.status === 'FAILED') throw new Error(status.errorMessage);
await sleep(intervalMs); // MERGING 등 진행 중 → 잠깐 대기 후 재조회
} catch (e) {
// 폴링 중 일시적 네트워크 오류면 바로 포기하지 않고 타임아웃까지 재시도
console.error('getUploadStatus failed', e);
await sleep(intervalMs);
}
}
throw new Error('업로드 작업이 예상보다 오래 걸리고 있습니다. 잠시 후 새로고침해 주세요.');
}, []);폴링 중 네트워크가 한 번 끊겨도 바로 실패 처리하지 않고 타임아웃까지 계속 재시도하는 것이 포인트입니다. 대용량 업로드는 그 정도로 방어적으로 짜야 사용자가 “어 왜 실패했지?” 하는 일이 줄어듭니다.
여기까지 만들고 나니 이어받기는 거의 거저였습니다. /init을 호출할 때 기존 taskId를 같이 보내면, 서버가 “얘는 이 청크들은 이미 받았어”라며 existingChunks를 돌려줍니다. ChunkManager는 그걸 완료로 표시하고, getPendingChunks가 알아서 안 올라간 청크만 추려냅니다. 990MB까지 올렸다 끊겼으면, 정말 남은 10MB만 다시 올리는 거죠.
업로드를 잘 만들었으면 다운로드도 신경 써야 공평하겠죠. 대용량 파일을 받을 때 진행률 하나 안 보이면 사용자는 “멈춘 건가?” 하고 불안해합니다.
그래서 다운로드도 스트리밍으로 처리하고, 진행률을 두 단계로 나눠 보여줍니다.
streaming — 실제 바이트가 내려오는 단계입니다. 받은 만큼 % 표시를 합니다.preparing — 서버가 파일을 준비(암호화/복호화 등)하는 단계입니다. 진행률을 알 수 없습니다.src/services/uploadService.js
async downloadFile(fileId, { onProgress, onPreparing, onStarted } = {}) {
if (onPreparing) onPreparing(); // 0) "준비 중" 단계 진입
const response = await safeFetch(`${apiBaseUrl}/download/${fileId}`, { headers: getHeaders() });
if (!response.ok) throw Object.assign(new Error(`Download failed ${response.status}`), { status: response.status });
if (onStarted) onStarted(); // 1) 응답 헤더 수신 = 준비 끝, 스트리밍 시작
// 2) Content-Disposition 헤더에서 파일명 파싱 (한글 파일명 대응: filename* 우선)
const disposition = response.headers.get('content-disposition') || '';
let fileName = /* filename*=UTF-8'' 형태 우선 파싱, 없으면 filename="..." */ parseFileName(disposition);
if (!fileName) fileName = 'download.bin';
// 3) 전체 크기
const total = parseInt(response.headers.get('content-length') || '0', 10);
// 4) ReadableStream 리더로 한 조각씩 읽으며 진행률 갱신
const reader = response.body?.getReader?.();
if (!reader) { // 스트리밍 미지원 브라우저 → 통째로 받기(fallback)
const blob = await response.blob();
triggerBrowserDownload(blob, fileName);
return;
}
const chunks = [];
let loaded = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (value) {
chunks.push(value);
loaded += value.length;
if (onProgress && total) onProgress(loaded, total); // % 갱신
}
}
triggerBrowserDownload(new Blob(chunks, { type: response.headers.get('content-type') || 'application/octet-stream' }), fileName);
}위 코드의 파일명 파싱 부분은 설명을 위해 parseFileName(...)으로 압축했습니다. 실제 코드는 filename*=UTF-8''…(한글·특수문자 대응) 형식을 먼저 시도하고, 없으면 filename="…"로 폴백합니다.
response.body.getReader()로 응답을 스트림으로 읽는 것이 핵심입니다. 한 조각(value)씩 받을 때마다 loaded를 더해 진행률을 갱신하고, 다 받으면 Blob으로 합쳐 다운로드를 트리거합니다. 스트리밍을 지원하지 않는 구형 브라우저를 위해 response.blob() 폴백도 남겨뒀습니다.
실제 다운로드를 발동시키는 건 고전적인 <a download> 트릭입니다.
const triggerBrowserDownload = (blob, fileName) => {
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = fileName;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(url); // 메모리 누수 방지: 꼭 해제
};여러 파일을 체크박스로 고른 뒤 한 번에 받는 기능은, 위 downloadFile과 거의 똑같습니다. 차이는 POST /download/bulk로 선택한 fileIds를 보내고, 서버가 ZIP으로 묶어 스트리밍해 준다는 것뿐입니다. 진행률 처리(preparing → streaming) 로직은 그대로 재사용했습니다. UI 쪽 핸들러는 이렇게 생겼습니다.
src/components/FileUploader/FileUploader.jsx
const handleBulkDownload = async () => {
if (bulkDownloading || selectedFileIds.size === 0) return;
const fileIds = Array.from(selectedFileIds);
notify({ domain: 'ATTACHMENT', key: ATTACH.BULK_DOWNLOAD_START, type: 'success' });
setBulkDownloading(true);
setBulkDownloadPhase('preparing');
await uploadService.downloadBulk(fileIds, {
onStarted: () => setBulkDownloadPhase('streaming'),
onProgress: (loaded, total) => setBulkDownloadProgress({ loaded, total }),
});
// ...
};지금까지가 사용자가 보는 화면이었다면, 이번엔 관리자 화면입니다. 6장에서 매니페스트에 AppFeatureAdminView로 선언하고 /settings 경로에 연결해 둔 그 화면이죠. 여기서 두 가지 새로운 monday 기능을 씁니다 — GraphQL API와 **monday.storage**입니다.
monday의 모든 데이터(보드, 아이템, 사용자, 팀…)는 GraphQL API 하나로 읽고 씁니다. SDK의 monday.api()에 쿼리 문자열을 넘기면 끝입니다. 인증은 7장에서 말한 무자각 인증이 알아서 처리해 주고요.
관리자 화면에서는 워크스페이스의 전체 팀 목록을 가져옵니다. 팀별 권한을 설정하려면 선택지가 필요하니까요.
src/components/AdminSettings/AdminSettings.jsx
import mondaySdk from 'monday-sdk-js';
const monday = mondaySdk();
useEffect(() => {
const loadTeams = async () => {
const response = await monday.api(`
query {
teams {
id
name
}
}
`);
setTeams(response?.data?.teams || []);
};
loadTeams();
}, []);정말 이게 전부입니다. query { teams { id name } } — SQL처럼 “필요한 필드만” 콕 집어 가져옵니다. 그게 GraphQL의 매력이죠.
그리고 사용자 화면(FileUploader.jsx) 쪽에서는 현재 로그인한 사용자가 속한 팀을 조회합니다. 권한을 판단해야 하니까요.
src/components/FileUploader/FileUploader.jsx
const response = await monday.api(`
query {
users(ids: [${context.user.id}]) {
teams { id }
}
}
`);
const teams = response?.data?.users?.[0]?.teams || [];
setUserTeams(teams.map(t => t.id));관리자는 다운로드/삭제 각각에 대해 세 가지 모드를 고를 수 있습니다.
이 규칙을 평가하는 함수가 checkPermission입니다. 위에서 가져온 “내 팀 목록”과 관리자가 정한 규칙을 대조하죠.
const checkPermission = useCallback((type) => {
const permission = settings?.teamPermission;
if (!permission) return false; // 설정 로딩 전엔 버튼 숨김 (안전한 기본값)
const mode = type === 'download' ? permission.downloadMode : permission.deleteMode;
const allowedTeams = type === 'download' ? permission.downloadAllowedTeams : permission.deleteAllowedTeams;
const deniedTeams = type === 'download' ? permission.downloadDeniedTeams : permission.deleteDeniedTeams;
if (mode === TEAM_PERMISSION_MODE.ALL) return true;
if (mode === TEAM_PERMISSION_MODE.ALLOW_ONLY) return userTeams.some(id => allowedTeams?.includes(id));
if (mode === TEAM_PERMISSION_MODE.DENY_ONLY) return !userTeams.some(id => deniedTeams?.includes(id));
return false;
}, [settings?.teamPermission, userTeams]);
const canDownload = checkPermission('download');
const canDelete = checkPermission('delete');canDownload / canDelete 결과에 따라 다운로드·삭제 버튼이 아예 보이거나 숨겨집니다. 권한 없는 사람에겐 버튼 자체가 안 보이는 셈이죠. (물론 진짜 방어는 백엔드에서 한 번 더 해야 합니다. 프론트의 버튼 숨김은 UX일 뿐이니까요.)
여기서 좋은 질문 하나. 이 설정값들(서버 URL, 권한 모드, 팀 목록 등)을 어디에 저장할까요? 우리 백엔드 DB에 넣어도 되지만, monday는 더 간단한 길을 줍니다 — monday.storage입니다. 앱 전용 키-값 저장소죠. 계정·앱 단위로 격리되고, 워크스페이스의 모든 뷰에서 공유됩니다.
src/services/settingsService.js
import mondaySdk from 'monday-sdk-js';
const monday = mondaySdk();
const STORAGE_KEY = 'upload_app_settings';
export const settingsService = {
// 저장
async saveSettings(settings, userId) {
const data = { ...settings, updatedAt: new Date().toISOString(), updatedBy: userId };
await monday.storage.setItem(STORAGE_KEY, JSON.stringify(data));
settingsCache = data; // 메모리 캐시 갱신
cacheTimestamp = Date.now();
return { success: true, data };
},
// 조회 (5분 캐시)
async getSettings(forceRefresh = false) {
if (!forceRefresh && settingsCache && (Date.now() - cacheTimestamp < CACHE_TTL)) {
return settingsCache; // 캐시가 살아있으면 그대로
}
const result = await monday.storage.getItem(STORAGE_KEY);
settingsCache = result.data?.value
? this.mergeWithDefaults(JSON.parse(result.data.value)) // 새 설정 항목 대비 기본값 병합
: { ...DEFAULT_SETTINGS };
cacheTimestamp = Date.now();
return settingsCache;
},
};monday.storage.setItem / getItem 두 줄이면 영속 저장이 됩니다. 별도 DB도, 서버도 필요 없죠. 다만 매번 호출하면 느리니 5분짜리 메모리 캐시를 앞에 뒀습니다. 그리고 mergeWithDefaults로, 나중에 설정 항목이 추가돼도 옛날에 저장된 데이터가 깨지지 않게 기본값과 병합합니다. 작지만 운영하다 보면 고마운 디테일입니다.
알림 토스트나 확인 다이얼로그도 직접 만들 필요 없이 monday.execute로 monday 네이티브 UI를 띄웁니다.
src/hooks/useAppMessages.js
// 토스트 알림
monday.execute('notice', { message, type }); // type: 'success' | 'error' | 'info'
// 확인 다이얼로그
const result = await monday.execute('confirm', {
title, message: description,
confirmButton: confirmText, cancelButton: cancelText,
});
if (result.data?.confirm) onConfirm?.();이렇게 하면 우리 앱의 알림이 monday의 다른 알림과 똑같이 생기고 똑같이 동작합니다. 또 한 번 “원래 있던 기능 같다”는 느낌을 만드는 장치죠.

다 만들었으면 monday에 올려야겠죠. 배포의 큰 그림은 두 단계입니다.
vite build로 React 앱을 build/ 폴더에 정적 파일로 뽑습니다.mapps code:push로 그 폴더를 monday 인프라에 올립니다.가장 단순하게는 이 두 줄이면 됩니다.
vite build
mapps code:push --client-side -d "build"
--client-side는 “이건 서버 없는 클라이언트(정적) 앱이야”라는 뜻이고, monday가 CDN에 호스팅해 줍니다. 즉 내 서버를 따로 안 띄워도 monday가 우리 React 앱을 대신 서빙해 주는 거죠.
그런데 실무 배포는 한 단계 더 있습니다. monday 앱은 버전 개념이 있습니다.
그래서 “새 코드를 draft로 올리고 → 확인되면 live로 승격”하는 흐름이 안전합니다. 우리는 이걸 deploy.sh 하나로 자동화했습니다.
deploy.sh (앱 ID는 예시값으로 치환)
#!/usr/bin/env bash
set -e
APP_ID=<YOUR_APP_ID> # 개발자 센터에서 확인 가능한 앱 ID
MANIFEST_PATH=./monday-manifests/manifest.json
# 0) 토큰이 있으면(CI 등) 토큰으로 로그인. 로컬은 이미 init 돼 있다고 가정.
if [ -n "$MONDAYCOM_API_TOKEN" ]; then
mapps init -t "$MONDAYCOM_API_TOKEN"
fi
# 1) 매니페스트를 import → 새 draft 버전 생성
mapps manifest:import --appId "$APP_ID" --manifestPath "$MANIFEST_PATH" --verbose
# 2) 방금 만들어진 draft 버전의 ID를 목록에서 추출
NEW_VERSION_ID=$(
mapps app-version:list --appId "$APP_ID" --verbose 2>&1 \
| grep "'draft'" | head -n 1 | awk -F'│' '{gsub(/ /,"",$3); print $3}'
)
[ -z "$NEW_VERSION_ID" ] && { echo "draft 버전 ID를 못 찾음"; exit 1; }
# 3) 빌드
npm run deploy:build
# 4) 빌드 결과를 해당 draft 버전에 푸시
npm run deploy:push -- --appVersionId "$NEW_VERSION_ID"
# 5) 그 버전을 live로 승격
mapps app:promote -a "$APP_ID" -i "$NEW_VERSION_ID"
echo "=== 완료: 버전 $NEW_VERSION_ID 가 라이브로 승격됨 ==="순서를 다시 읽으면 이렇습니다. 매니페스트 import(새 draft 생성) → 그 draft 버전 ID 찾기 → 빌드 → 코드 푸시 → live 승격. 매니페스트가 바뀌든(권한 추가 등) 코드가 바뀌든, 이 스크립트 하나면 새 버전이 라이브까지 올라갑니다.
⚠️ MONDAYCOM_API_TOKEN은 환경변수입니다. 스크립트나 저장소에 토큰 값을 박지 말고, CI/CD라면 파이프라인의 시크릿/Variables에 등록해서 주입하세요.
이걸 GitLab CI에 올리면 “코드 머지하면 자동 배포”가 됩니다.
.deploy-frontend:
stage: deploy
image: node:20
rules:
- changes:
- mondayapp/**
script:
- cd mondayapp/file-uploader
- npm ci
- chmod +x deploy.sh
- sh ./deploy.sh

길게 달려왔습니다. 정리하면 우리가 한 일은 이겁니다.
monday 기본 기능으로는 안 되던 일 — 대용량 파일을 끊김 없이 올리고, 끊기면 이어받고, 여러 개를 한 번에 받고, 팀별로 권한을 거는 것 — 을 플러그인 하나로 정확히 그 모양대로 채웠습니다.
그리고 그 과정에서 monday 플러그인의 거의 모든 핵심을 거쳐 왔습니다.
monday.api()(GraphQL), monday.get('sessionToken'), monday.listen('context'), monday.storage, monday.execute('notice'|'confirm')@vibe/core)로 monday 네이티브처럼mapps로 draft → live생각보다 별것 아니지 않나요? 안쪽은 그냥 React고, monday는 “내가 어떤 아이템 위에 떠 있는지”와 “데이터를 읽고 쓰는 길”만 깔끔하게 열어줄 뿐입니다. React를 짤 줄 알면, 우리 팀이 원하는 기능을 monday 위에 직접 붙일 수 있습니다. 그게 이 글에서 진짜 하고 싶었던 말입니다.
처음엔 “우리 회사용 자잘한 기능”으로 시작하지만, 잘 다듬으면 마켓플레이스에 올려 다른 팀에게도 가치를 줄 수 있습니다. 빈틈을 발견했다면, 그건 불평거리가 아니라 기회입니다.
@mondaycom/apps-cli): https://developer.monday.com/apps/docs/command-line-interface-cli