한눈에
학과와 여러 연구실을 위한 운영 관리 시스템(한국어 UI, 멀티랩). 과제현황·프로젝트·인사· 연구비 수지·성과·구매·장비·인벤토리 + LIMS(시료·실험), 그리고 주간보고·인사평가 같은 ERP 기본 업무.
| 항목 | 값 |
|---|---|
| 저장소 | contextBio/LABIS (bare ContextBio/LABIS.git) |
| 호스트 | c1.sysmed.kr |
| 운영 워크트리 | ContextBio/LABIS — main, :3100, basePath /labis, DB labi, 로그 ~/labis-server.log |
| 개발 워크트리 | dev/LABIS — dev, :3101, basePath /labis-dev, DB labi_dev(운영 복제본), 로그 ~/labis-dev-server.log |
| 백엔드 주소 | c1.sysmed.kr/labis · 개발 c1.sysmed.kr/labis-dev |
| 프론트 | 운영 contextbio.ai/labis · 개발 미러 dev-contextbio.web.app/labis (아래 참조) |
| 스택 | Next.js 15(App Router · Server Actions) · TypeScript · Tailwind CSS 4 |
| DB | PostgreSQL 17(S1 네이티브) + Prisma 6.19.3 — prisma/schema.prisma |
| 로그인 | contextBio 통합계정 ID 토큰 단일 — 자체 계정 없음 |
| 릴리즈 | scripts/release.sh (빌드 포함) |
README 의 "작업 디렉터리는 apps/LABIS 하나" 는 낡았다 — 지금은 다른 앱과 같은
bare + main/dev 이중 워크트리다. README 의 systemd 예시에 남아 있는 apps/LABIS 경로와
"끝나면 dev 로 복귀" 문구도 그렇다. 브랜치를 갈아타지 않는다.
화면과 백엔드가 둘로 나뉜다
| 층 | 실체 |
|---|---|
| 화면 | frontpage/index.html — 한 장짜리 정적 SPA. 해시 라우팅(#/settings 등), 프레임워크·빌드 없음 |
| REST | /api/v1/* — Authorization: Bearer <통합계정 ID 토큰> (src/lib/apiGuard.ts) |
| 기존 화면 | Next.js SSR(src/app/(app)/…) — Auth.js 세션 쿠키. /enter?lab=N 으로 들어온다 |
SPA 는 사이트 저장소의 build_subapp.py 가 상단 줄·푸터·Firebase 브리지를 씌워 굽는다.
백엔드 주소는 cb-backend 메타로 받는다.
LABIS 는 이제 공개(운영) 앱이다. content.py 의 RELEASE_APPS 가
("aurora", "pepdesigner", "labis") 이고, 운영 빌드(deploy.yml)가 contextBio/LABIS main 의
frontpage/ 를 구워 contextbio.ai/labis 에 싣는다. 백엔드는 운영 https://c1.sysmed.kr/labis
(main 워크트리 :3100)다. Firebase main 타깃이 /labis/** 를 서빙하고
/LABIS·/LabIS·/labi 는 /labis 로 리다이렉트한다(firebase.json). 개발 사이트는
deploy-dev.yml 이 LABIS dev 브랜치의 frontpage/ 를 구워 dev-contextbio.web.app/labis 에
싣고, SUBAPP_BACKEND_LABIS=https://c1.sysmed.kr/labis-dev 로 dev 백엔드를 붙인다.
BioWrit 만 개발 전용으로 남는다.
운영 백엔드(:3100)와 main 릴리즈가 곧 공개 화면이 붙는 대상이다(최근 release-20260929-1044).
2026-09-02 에 "dev 에만 배포" 지시가 있었지만 09-03 이후 릴리즈 태그가 이어진다 — 운영
릴리즈를 낼지는 그때그때 확인한다.
로그인 — 자체 계정이 없다
2026-08-31 에 자체 로그인을 전면 삭제했다(이메일+비밀번호·Google·MUSE c1 계정 경로 모두). 통과 수단은 contextBio 통합계정 ID 토큰 하나이고, 받는 자리는 둘이다.
SPA → /api/v1/*
→ apiGuard.ts Bearer 토큰을 요청마다 검증(폐기·클레임 포함, 5분 캐시)
쿠키를 쓰지 않으므로 CSRF 표면이 없다
기존 화면(SSR) → /enter?lab=N → /login (#token= 릴레이)
→ /api/sso/contextbio 토큰 검증 후 Auth.js JWT 세션을 직접 굽는다(12시간)
- 검증은
src/lib/contextbio.ts가 한다.CONTEXTBIO_FIREBASE_API_KEY가 비면 모든 토큰을 거부한다(fail closed). - 토큰은 프래그먼트(
#token=)로 건넨다 — 서버 접근 로그·Referer 에 남지 않는다. - 비밀번호는 어디에도 없다. 계정 관리(비밀번호·프로필)는 contextBio 화면에서 한다.
- 공개 가입이 없다 — 관리자가 초대한 이메일의 통합계정만 통과한다. 클레임
apps가 있으면labis가 들어 있어야 한다(전체관리자는 예외). - 최초 접속의
/setup은 학과관리자 레코드만 만든다. 로그인은 같은 이메일의 통합계정으로 한다. - CORS 허용 오리진은 코드에 고정돼 있다 —
contextbio.ai·www.contextbio.ai·dev-contextbio.web.app. - MUSE(c1 서버 사용자 관리)는 별개 서비스다 — LABIS 의 로그인 수단이 아니다.
조직과 권한
학과(전역) → 연구실 × N → 구성원(Membership). 한 사용자가 여러 랩에 소속될 수 있다.
역할은 학과관리자(User.isDeptAdmin) / PI / 랩매니저 / 연구원(LabRole =
PI·LAB_MANAGER·MEMBER)이다. 모든 페이지·액션이 서버 가드 requireLab 로 활성
랩(사이드바 전환기) + 역할을, REST 는 apiMenuAllowed 로 같은 판정을 한다. 관리 행위는
감사 로그에 남는다. 실장(isDirector)은 멤버십의 별도 표지이며 PI·학과관리자만 지정한다.
그 위에 대시보드 카드 단위 권한이 얹힌다 — 좁히기 전용 층이다. 카드는
과제현황·프로젝트·인사·구매·자산·성과·LIMS 이고 구성원마다 불가·보기·편집 을 정한다.
- 2026-09-27 부터 연구원은 기본 불가(fail closed) 다. 설정이 없으면 프로젝트만 보기·편집이고 나머지는 막힌다. 대시보드와 주간보고 제출은 늘 열린다.
- 랩매니저는 기본 편집, PI·학과관리자는 조정 대상이 아니다(항상 전체 편집).
- 권한은 역할을 넘지 못한다 — 편집이어도 역할이 모자라면 여전히 막힌다.
- 설정 화면은 관리자 설정(
#/settings)의 "대시보드 카드별 접근 권한"(PI 이상).
src/lib/menus.ts 메뉴 목록·카드 묶음(CARD_GROUPS) 단일 원본
src/lib/perm.ts 좁히기 판정 · defaultLevel
requireLab(minRole, menu, need) 페이지·액션 가드
apiMenuAllowed REST 쪽 같은 판정
사이트 앱관리 화면(/labis/admin)의 '앱관리자' 는 학과관리자다. 연구실 단위
운영자(PI·랩매니저)는 자기 랩의 관리자 설정을 쓰고 그 화면에는 오지 않는다.
GET /api/management/users 는 앱관리자에게, 학과관리자 지정·해제(PATCH)는 통합계정
전체관리자에게만 열린다. LABIS 에는 잡 개념이 없어 jobs·kill 액션이 없다.
연구실 수와 폐쇄
연구실은 삭제하지 않고 폐쇄한다. 폐쇄된 랩은 입장 목록·랩 전환기에서 감추고 관리자 설정에서만 보인다(다시 운영으로 돌리는 길이 있어야 한다).
운영·휴면 연구실은 최대 5개다(src/lib/labLimit.ts 의 MAX_LABS). 개설 경로가
관리자 설정·REST(/api/v1/labs)·입장 화면의 셀프 개설 셋이라 상한을 한 곳에 두고 셋
모두 서버에서 막는다. 폐쇄된 랩은 세지 않는다 — 자리를 비우는 유일한 길이 폐쇄다.
코드 맵
frontpage/index.html SPA (화면 전부)
src/lib/apiGuard.ts /api/v1 Bearer 가드 · CORS
src/lib/contextbio.ts 통합계정 토큰 검증
src/lib/auth.ts Auth.js 설정 (SSR 세션)
src/lib/guard.ts requireUser · requireDeptAdmin · requireLab (감사 로그는 audit.ts)
src/lib/managementApi.ts 사이트 앱관리 규약(session·users)
src/lib/queries.ts 조회 (랩 스코프)
src/lib/actions.ts 도메인 CRUD (가드 적용)
src/lib/orgActions.ts 랩·구성원·초대 관리
src/lib/google.ts Sheets API(JWT) · 공개 CSV · 랩별 설정
src/lib/sheetSync.ts 시트 탭 ↔ DB 매핑 (syncRows)
src/lib/grantSync.ts 과제현황 전용 동기화
src/lib/weeklyDocs.ts 주간보고 파일 보관
src/app/api/v1/… SPA 용 REST
src/app/(app)/… 기존 화면 (인증 필요)
scripts/ingest-agent.ts 폴더 수집 에이전트
scripts/sheet-agent.ts 구글시트 수집 에이전트
scripts/release.sh dev → main 머지 · 빌드 · :3100 재기동
scripts/ 는 server-only 모듈을 타면 안 된다. tsx 로 Next 런타임 밖에서 돌아
guard.ts(쿠키·리다이렉트)를 import 하는 순간 죽는다. 감사 로그를 audit.ts 로 떼어낸
이유다.
SPA 에 새 화면·새 조회를 넣으면 fresh(list(...)) / fresh(req(...)) 로 감싼다. 늦게
온 응답이 사라진 DOM 을 건드리면 그 오류가 새 화면의 #err 에 오배송돼, 멀쩡한 화면이
"불러오지 못했습니다" 로 보인다.
시트·폴더로 데이터를 넣는 두 길
둘의 매핑 규칙이 같다(같은 SPECS) — 탭 이름이 항목 이름(과제·참여연구원·마일스톤·
예산집행·시료·실험·장비·인벤토리·휴가 등)과 일치하면 반영된다. 인원 탭은 계정과
결합되어 건너뛴다(내보내기 전용).
전체 교체를 하지 않는다. 예전에는 랩의 행을 deleteMany 로 지우고 다시 넣어 사람이
화면에서 넣은 행이 사라지고 행 id 가 매번 바뀌었다. 지금은 syncRows() 가 코드·장비명·
sourceKey(행 내용에서 만든 자연키)로 갱신·추가하고, 시트에서 사라진 시트 기원 행만
지운다. 화면에서 넣은 행(sourceKey 가 빈 행)은 남는다. 새 항목을 붙일 때 deleteMany
를 쓰지 않는다. 과제현황(grant_participations)은 더 보수적이라 사라진 행도 지우지 않고
sourceMissing 으로만 표시한다.
한 항목을 시트와 폴더 파일로 동시에 관리하지 않는다 — 서로를 '사라진 행' 으로 보고 지운다.
- 구글시트 연동 — 관리자 설정 안의 구획. 항목별 시트 주소를 저장하면 즉시 그 항목
DB 로 반영한다. 워크시트는 URL 의
#gid=> 항목 이름과 같은 탭 > 시트가 하나뿐이면 그 시트 순으로 고른다. 항목별 주소가 없으면 랩 통합 스프레드시트를 쓴다. 시트 가져오기는 명부의 이름으로 사람을 찾으므로 시트의 이름이 같아야 반영된다. - 구글시트 수집 에이전트 — 화면에 등록한 주소를 주기적으로 다시 읽는다. 내용 지문이 그대로면 DB 를 건드리지 않고, 폐쇄한 연구실은 건너뛴다.
- 폴더 수집 에이전트 — 지정 폴더의
.xlsx(워크시트 이름) /.csv(파일 이름)를 읽는다. 변경된 파일만 재처리하고(mtime 추적,--force로 전체), 결과는 감사 로그에 남는다.
npm run sheet # 연결된 모든 랩·항목 1회 (변경분만)
npm run sheet:watch -- --interval 300 # 주기 실행 (초)
npm run ingest -- --dir <폴더> --lab 2 # 폴더 1회 반영
npm run ingest -- --add-source <폴더> 2 "A랩" # 폴더↔랩 매핑 등록
npm run ingest:watch # 감시 모드 (기본 30초)
시트를 읽으려면 서비스 계정 키가 필요하다(GOOGLE_SERVICE_ACCOUNT_FILE, 기본
data/service-account.json). 키는 저장소에 두지 않는다 — 히스토리에서 지운 이력이
있고 .gitignore 로 막혀 있다. 대상 시트·드라이브 폴더는 서비스 계정에 공유돼 있어야 한다.
주간보고 파일은 랩별 제출 폴더 LABIS_WEEKLY_DIR_<labId>(c1 에 마운트된 구글 드라이브)가
있으면 그 아래 <주 월요일>/ 에, 없으면 LABIS_UPLOAD_DIR(기본 data/uploads)/lab-<id>/weekly/<주>
에 둔다. 기존 파일은 덮어쓰지 않는다.
실행과 배포
export PATH=/opt/node/bin:$PATH # Node 24 — 셸 기본 node 는 v12 라 실패한다
npm run dev # 개발 :3101 (.env.development → labi_dev, basePath /labis-dev, .next-dev)
./scripts/release.sh --dry-run # 나갈 커밋만
./scripts/release.sh # dev 푸시 → main 머지 + 태그 → npm ci·build → :3100 재기동
systemd 유닛이 없다 — 운영은 release.sh 가 nohup npm run start 로, 개발은 npm run dev
를 손으로 띄운다. 개발 서버는 distDir=.next-dev 라 운영 빌드(.next)를 덮지 않는다.
release.sh 는 dev·운영 워크트리가 모두 깨끗해야 돈다(.claude/ 는 제외). 서버를 먼저
내린다 — 구동 중에 npm ci 가 node_modules 를 지우다 ENOTEMPTY 로 죽기 때문이고,
내려간 동안(수 분)은 계획된 중단이다. fuser 가 NFS 경로를 훑다 멈춘 적이 있어 시간
제한을 두고 포트를 쥔 프로세스를 직접 내린다(2026-09-27). 빌드가 고쳐 쓰는
next-env.d.ts·tsconfig.json 은 끝나고 원복한다.
.env(운영)·.env.development(개발) 필수값은 DATABASE_URL(Postgres) · AUTH_SECRET ·
APP_URL · CONTEXTBIO_FIREBASE_API_KEY. 값은 워크트리별로 분리돼 있고 커밋하지 않는다.
스키마를 바꿀 때는 dev 에서 npm run db:migrate 로 마이그레이션을 만들고 릴리즈 후
npm run db:deploy.
- 날짜는
YYYY-MM-DD문자열로 저장한다 (date input·시트 연동과 일치). - 워크트리의 파일 권한(mode) 변경이 머지를 막으면
git -c core.fileMode=false <명령>으로 통과시킨다 — 내용은 0줄 바뀐 노이즈다. - 화면 문구가 배포 후에만 이상하면 사이트 빌더를 의심한다.
build_subapp.py의 경로 재작성이 스크립트 문자열까지 건드려3/10개가3/labis/10개로 나온 적이 있다 (2026-09-05). 저장소 파일과 라이브 HTML 을diff하면 드러난다.