한눈에
Cancer Genome Viewer. TCGA 등 암 유전체·약물유전체 데이터를 통합해 보여 주는
R Shiny 웹서비스다. 2026-09-05 에 ContextBio 앱으로 편입됐다 — 그 전에는 git.sysmed.kr
의 별도 저장소였고 /data/webservice/CGV/src/bg_aju_cgv 에서 직접 돌았다.
| 항목 | 값 |
|---|---|
| 저장소 | contextBio/CGV (bare ContextBio/CGV.git) |
| 호스트 | c1 |
| 운영 워크트리 | ContextBio/CGV — main. Shiny :3838, 게이트 127.0.0.1:8810 (systemd user cgv-gate) |
| 개발 워크트리 | dev/CGV — dev. Shiny :3839 (/cgv-dev), 게이트 127.0.0.1:8811 (systemd user cgv-gate-dev) |
| 운영 주소 | 진입 https://contextbio.ai/cgv → 본체 https://cgv.sysmed.kr |
| 개발 주소 | 진입 https://dev-contextbio.web.app/cgv → 본체 https://c1.sysmed.kr/cgv-dev |
| 틀 | R Shiny · shiny-server (run_as shiny) + 게이트 Flask/gunicorn (gate/) |
| 데이터 | /mnt/S3/data/webservice/CGV/ (3.8TB) — 워크트리에는 심볼릭 링크 |
| 로그인 | contextBio 통합계정 — 검증은 게이트가, R 은 /gate/whoami 로 묻기만 |
| 릴리즈 | deploy/release.sh |
| 배포 확인 | /gate/version |
deploy/shiny-server.conf 와 deploy/apache/ 가 서버 설정의 정본이다. /etc 에만
두면 왜 그 값인지가 코드 옆에 남지 않는다. 지금 /etc/shiny-server/shiny-server.conf 는
저장소 판과 같다.
주소와 요청 경로
contextbio.ai/cgv (Firebase 진입 페이지)
│ location.href = <backend>/gate/enter#token=<ID토큰>
▼
apache cgv.sysmed.kr:443 ─┬─ /gate/whoami → 막힘 (Require all denied)
├─ /gate/* → 127.0.0.1:8810 게이트
├─ Upgrade=websocket → ws://127.0.0.1:3838
└─ / → 127.0.0.1:3838 shiny-server
└ app_dir ContextBio/CGV
- 진입 페이지만 사이트 저장소에 있다. 원본은 CGV 저장소의
frontpage/index.html이고, 사이트 저장소의build_subapp.py가cgv항목(backend: https://cgv.sysmed.kr)으로 굽는다. 본체는 c1 의 Shiny 다.build_subapp.py의top_bar.docs는 꺼져 있다 (공개 문서 쪽이 없으므로 켜면 404 단추가 생긴다). - 운영 vhost(
deploy/apache/cgv.sysmed.kr_ssl.conf)는 세션 쿠키 (__Secure-cgv_session)가 없는 첫 화면(/) 요청을https://contextbio.ai/cgv로 302 한다. 이것은 보안 경계가 아니라 비용 절감이다 — 쿠키도 없는 요청 하나로 R 프로세스를 깨우지 않으려는 것. 실제 차단은 게이트의 서명 검증과, 세션이 없으면 입장 안내만 그리는 Shiny 가 한다. - 개발 미러는 전용 도메인 없이 c1 vhost 안의 조각(
deploy/apache/c1-cgv-dev.conf)으로 얹힌다./cgv-dev/gate/→:8811/gate/(접두사를 떼고 넘긴다),/cgv-dev→:3839/cgv-dev(접두사 유지). 응답 헤더X-CGV-Node: C1은 운영 vhost 가 붙이는 진단용이다.
데이터 — 코드만 옮기고 데이터는 그대로
데이터가 3.8TB(CGV.db 2.3T + data/db 1.1T + data/legacy 271G)라 옮기지 않았다.
원본은 /mnt/S3/data/webservice/CGV/ 에 있고 워크트리는 링크로 가리킨다.
<워크트리>/data/
db -> /mnt/S3/data/webservice/CGV/data/db 공유 (운영·개발 같은 원본)
legacy -> /mnt/S3/data/webservice/CGV/data/legacy 공유
manager/ 워크트리별 사본 (80K)
dbAnalysis.rda dbListTableDF.rda dbMetaTableDF.rda dbTableDF.rda
data/manager 는 데이터셋 목록·분석 목록이다. 워크트리마다 따로 두는 이유는 개발이 운영
목록을 덮어쓰지 않게 하려는 것이다. 반대로 db·legacy 는 운영과 개발이 같은 원본을
보므로, 개발에서 그 아래를 쓰는 코드는 곧 운영 데이터를 건드린다.
R 은 config.yml 의 두 값으로 자리를 찾는다(R/common.R).
default:
cgv_data_path: './data/manager'
core_data_path: './data/db'
저장소 밖에 있는 실행 환경
아래는 .gitignore 대상이라 워크트리마다 손으로 만든다. 릴리즈 스크립트는 config.yml·
gate/.env·gate/.venv/bin/gunicorn 이 없으면 재기동 전에 멈춘다.
| 파일 | 무엇 |
|---|---|
config.yml |
cgv_data_path(manager) · core_data_path(db) |
.Renviron |
CGV_GATE_URL · CGV_BASE_PATH · CGV_SITE_ORIGIN — R 이 기동 때 읽는다 |
gate/.env |
CGV_GATE_SECRET(필수) · CGV_GATE_PORT · CGV_APP_ORIGIN · CGV_SITE_ORIGIN · (개발만) CGV_BASE_PATH |
gate/.venv/ |
게이트 파이썬 환경 (gate/requirements.txt — Flask·gunicorn) |
data/ |
위의 심볼릭 링크와 manager 사본 |
운영과 개발이 갈리는 값:
| 변수 | 운영 | 개발 |
|---|---|---|
CGV_GATE_URL (R) |
http://127.0.0.1:8810 |
http://127.0.0.1:8811 |
CGV_BASE_PATH |
(비어 있음) | /cgv-dev |
CGV_SITE_ORIGIN |
https://contextbio.ai |
https://dev-contextbio.web.app |
CGV_APP_ORIGIN (게이트) |
https://cgv.sysmed.kr |
https://c1.sysmed.kr |
인증 — R 은 검증하지 않는다
로그인은 contextBio 통합계정이고 검증은 gate/(Flask)가 한다. R 은 쿠키를 게이트에
넘겨 "이 세션은 누구인가"만 묻는다(R/contextbio.R). 검증(서명·만료·폐기·정지·클레임)의
정본인 contextbio_auth.py 가 파이썬이라서다 — R 로 다시 쓰면 네 번째 검증 구현이 생기고,
BioWrit·PepDesigner·AURORA 세 곳이 모두 validSince 확인을 빠뜨렸던 전례처럼 그중 하나는
더 관대해진다.
진입 릴레이 (LABIS 와 같은 구조)
contextbio.ai/cgv진입 페이지가 통합계정 ID 토큰을 얻어<backend>/gate/enter#token=<ID토큰>으로 브라우저를 보낸다./gate/enter는 입력 없는 한 장짜리 페이지다. 프래그먼트에서 토큰을 읽고 주소창에서 곧바로 지운 뒤, 같은 오리진으로POST /gate/session {idToken}한다./gate/session이cb.verify(token, check_revoked=True)로 검증하고, 이메일 미확인이면 403email_unverified,Identity.can("cgv")가 거짓이면 403service_not_allowed. 통과하면 서명 쿠키를 심고<BASE_PATH>/로 보낸다.- Shiny 가 그 쿠키로
/gate/whoami에 신원을 묻는다.
토큰을 프래그먼트로 건네는 이유는 서버·접근 로그·Referer 에 남지 않아서다. 쿠키를 교차 오리진 XHR 로 심지 않는 이유는 contextbio.ai → cgv.sysmed.kr 가 서드파티 문맥이라 브라우저가 갈수록 막기 때문이다 — 그래서 브라우저를 데려와 같은 오리진에서 심는다.
세션 쿠키
- 이름
__Secure-cgv_session(평문 오리진이면cgv_session), HttpOnly · Secure · SameSite=Lax, path<BASE_PATH>/, 수명 12시간(CGV_SESSION_MAX_AGE, LABIS 와 같은 값). - 쿠키에는 토큰이 아니라 검증이 끝난 신원(
uid·email·name·admin·plan)만itsdangerous로 서명해 담는다. 그래서 계정 정지·클레임 변경은 다음 로그인까지 반영되지 않는다. 즉시 끊으려면 Firebase 에서 계정을 정지하고, 전원을 끊으려면CGV_GATE_SECRET을 바꿔 게이트를 재기동한다(발급된 쿠키가 모두 무효 — 비상 로그아웃). - 게이트는
CGV_GATE_SECRET없이는 기동을 거부한다(fail closed). 기본값을 지어내면 그 값이 모든 배포의 위조 열쇠가 된다.
게이트 라우트
| 라우트 | 무엇 |
|---|---|
GET /gate/version |
service·version(기동 시점 커밋)·site·cookie·base — 배포 확인용 |
GET /gate/enter |
프래그먼트 토큰을 같은 오리진 POST 로 넘기는 페이지 |
POST /gate/session |
토큰 검증 → 쿠키 발급 |
GET /gate/whoami |
Shiny 전용 — Apache 가 프록시하지 않는다 |
GET/POST /gate/logout |
쿠키 삭제, JSON 응답 |
GET /gate/signout |
쿠키 삭제 후 진입 화면으로 302 — Shiny 의 로그아웃 단추가 이것을 쓴다 |
게이트는 127.0.0.1 에만 바인드한다(gate/start-gate.sh, gunicorn workers 2 · threads 4).
/gate/whoami 의 접근 통제는 이 바인드와 Apache 의 ProxyPass /gate/whoami ! +
Require all denied 두 겹이다. 열어 두면 남의 브라우저에서 세션 주인을 조회할 자리가 생긴다.
vhost 를 손볼 때 이 차단이 /gate/ 규칙보다 앞에 살아 있는지 확인한다 — 먼저 맞는
규칙이 이긴다.
shiny-server 뒤에서는 쿠키가 R 까지 오지 않는다
shiny-server 는 브라우저의 SockJS 연결을 자기가 끝내고 R 프로세스로 자기 WebSocket 을 새로
열며, 그때 브라우저 헤더를 넘기지 않는다. 그래서 server 함수의
session$request$HTTP_COOKIE 는 항상 비어 있다. 2026-09-05 에 통합로그인이 성립하지 않은
원인이 이것이었다.
페이지 HTTP 요청은 헤더째 프록시되므로 쿠키가 온다. 그래서 신원 확인은 ui 함수
(app.R 의 ui <- function(request))에서 하고, 결과를 일회용 티켓으로 server 함수에 넘긴다.
ui(request) ── cb.whoami(쿠키) → cb.ticket.issue() → 숨은 입력 cbTicket
server ── cb.ticket.claim(isolate(input$cbTicket)) (server/00.03.contextbio.R)
- 티켓은
/dev/urandom24바이트, 프로세스 메모리에만 있고 한 번 쓰면 지워지며CGV_TICKET_TTL(기본 120초)이 지나면 무효다. HttpOnly 쿠키를 페이지에 싣지 않으려는 설계다. - 앱마다 R 프로세스가 하나라 ui 와 server 가 같은 프로세스에서 돈다. 그사이 프로세스가 죽으면 방문자는 입장 안내 화면을 본다 — 다시 누르면 된다.
cb.identity(session)은 shiny-server 없이runApp()으로 직접 띄울 때(개발)만 값을 낸다.
권한
admin클레임 → CGV 의administrator, 그 밖은basic(cb.permission()). CGV 안에 별도 관리자 명단을 두지 않는다 — 통합계정에서 관리자를 내리면 여기서도 내려가야 한다. 관리자는 사이드바에 Database·Analysis Controller 가 함께 보인다.- 이용 자격은
Identity.can("cgv")가 정한다 — 관리자 통과,apps클레임이 없으면 통과 (가입 개방), 있으면cgv포함 여부. gate/contextbio_auth.py는 사본이다. 여기서 고치지 않는다. 정본은 사이트 저장소이고python tools/sync_auth.py --write가dev/CGV/gate/로 복사한다.
콜드 스타트와 warm 타이머
R 프로세스 기동에 15초 안팎이 든다. 2026-09-05 dev 워크트리 실측으로 라이브러리
13.4초 + 코어 .rda 3.0초 = 16.8초, 최대 메모리 약 590MB(deploy/shiny-server.conf
주석). 저장소 CLAUDE.md 는 library(tidyverse) 를 stringr+tibble 로 바꾼 뒤 약 14초
(10.9s + 3.1s)로 적고 있다. 대부분이 ComplexHeatmap·survminer·survival 같은 분석 필수
패키지라 더 줄일 여지가 거의 없다. 라이브러리를 더 얹으면 이 값이 늘어난다.
그래서 값을 줄이는 대신 사용자가 그 값을 내지 않게 한다.
| 장치 | 값 | 이유 |
|---|---|---|
app_idle_timeout |
3600 (shiny-server.conf) |
워밍업 주기가 유휴 회수에 확실히 이기도록. 0(무제한)은 아무도 안 쓰는 앱이 600~760MB 를 영구히 쥐므로 피했다 |
cgv-warm.timer |
OnCalendar=*:0/10, Persistent=true, RandomizedDelaySec=30s |
10분마다 정각 기준. OnBootSec 계열은 오래 켜진 기계에 나중에 설치하면 첫 기준점이 없어 스케줄되지 않았다(NEXT n/a) |
cgv-warm.sh |
http://127.0.0.1:3838/ 에 직접 요청, 연결 거부도 재시도(기본 20회·2초) |
데워야 할 것은 R 프로세스라 Apache·TLS 를 거치지 않는다. 재기동 직후 포트가 아직 안 열렸을 때가 가장 필요한 순간이다 |
cgv-warm.sh 는 5초 이상 걸리면 warm: 콜드 스타트를 겪음 (N초) 을 남긴다(데워져 있으면
0.1초대, 식은 채로 뜨면 15~19초). 이 줄이 자주 보이면 타이머 주기나 app_idle_timeout 을
의심한다. 워밍업은 운영 포트(3838)만 두드린다(CGV_WARM_PORT 기본값) — 개발 미러는
데우지 않는다.
기동과 배포
게이트와 워밍업은 systemd user 유닛(sudo 불필요), shiny-server 는 시스템 서비스 (재기동에 sudo 필요)다.
./deploy/install-gate.sh # 게이트 유닛 설치 — main 워크트리면 cgv-gate, 아니면 cgv-gate-dev
./deploy/install-warm.sh # cgv-warm.service/.timer 설치 — 설치한 워크트리의 cgv-warm.sh 를 가리킨다
./deploy/release.sh # 운영 워크트리에서: main 에 dev 머지 → 태그 → 푸시 → 재기동 → 검증
./deploy/release.sh --dry-run # 무엇이 나가는지만
- 유닛 파일에는 경로가 박혀 있지 않다(
__GATE_SH__·__WARM_SH__). 설치 스크립트가 설치한 워크트리 기준으로 채운다. 유닛 이름을 브랜치로 가르는 이유는 같은 이름이면 나중에 설치한 쪽이 앞의 것을 조용히 밀어내기 때문이다. release.sh는 main 체크아웃이 아니거나 추적 파일 변경이 있거나main..dev가 비어 있으면 멈춘다. 이어서systemctl --user restart cgv-gate→sudo systemctl restart shiny-server→cgv-warm.sh→ 검증 순서로 돈다.- 커밋만으로는 반영되지 않는다. 게이트는 프로세스 기동 때, Shiny 는 R 프로세스가 새로 뜰 때 코드를 읽는다.
- shiny-server 하나가 3838(운영)과 3839(개발)를 함께 낸다 —
sudo systemctl restart shiny-server는 양쪽 R 프로세스를 모두 내린다. 개발 게이트는systemctl --user restart cgv-gate-dev. - 첫 전환(자체 계정 → 통합계정)의 한 번짜리 절차와 되돌리기는
deploy/CUTOVER.md에 있다. 이후 배포에는 쓰지 않는다.
운영 점검
# 게이트 — version 은 기동 시점의 커밋이다. HEAD 와 다르면 옛 코드가 돌고 있다
curl -s http://127.0.0.1:8810/gate/version # 운영
curl -s http://127.0.0.1:8811/gate/version # 개발 (base 가 /cgv-dev)
# whoami 가 바깥에 닫혀 있는지 — 403/404 여야 한다
curl -s -o /dev/null -w '%{http_code}\n' https://cgv.sysmed.kr/gate/whoami
# 쿠키 없는 첫 화면 — 302 → contextbio.ai/cgv
curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' https://cgv.sysmed.kr/
# 유닛·타이머
systemctl --user status cgv-gate cgv-gate-dev
systemctl --user list-timers cgv-warm.timer
journalctl --user -u cgv-warm -n 20 # 콜드 스타트를 겪었는지
journalctl --user -u cgv-gate -f # 게이트 접근·오류 로그
# 포트 — 게이트는 127.0.0.1, Shiny 는 3838/3839
ss -ltn | grep -E ':(3838|3839|8810|8811)\b'
Shiny 로그는 /var/log/shiny-server, Apache 로그는 error-cgv_ssl.log·access-cgv_ssl.log
다. R 쪽은 [SC]00.03.contextbio.R 입장: <이메일> (<권한>) 또는 세션 없음 — 입장 안내 화면
을 남긴다. 브라우저 확인은 https://contextbio.ai/cgv → 로그인 → 입장 → 분석 화면이
로그인 화면 없이 열리는지까지다.
알아 둘 함정
- 게이트가 죽으면 아무도 들어오지 못한다. 설계상 맞는 동작이다(fail closed) — R 은 게이트 호출이 실패하면 로그인하지 않은 것으로 본다. 입장이 막히면 게이트부터 본다.
hash패키지가get·assign등을 S4 제네릭으로 덮는다. 환경을 다루는 코드는base::get처럼 네임스페이스를 붙여 부른다. 2026-09-05 에unused arguments (envir=…)로 실제로 죽었다.USER는 reactiveValues 다. 반응 문맥 밖에서 되읽으면 세션이 죽는다 — 값을 지역 변수에 먼저 담는다(server/00.03.contextbio.R).- 옛 자체 계정 코드를 되살리지 않는다. 아이디·비밀번호
.rda방식은 삭제됐고, 그 전에도 로그인 화면은display:none에 스크립트가 게스트 로그인을 자동 클릭하던 — 사실상 무인증 공개였다. 옛 자리/data/webservice/CGV/src/bg_aju_cgv는 되돌릴 자리로 남아 있고, 그쪽data/manager/dbCredentials.rda에는 옛 가입자의 비밀번호 해시가 있다 (안정되면 지울 대상 —deploy/CUTOVER.md). db·legacy는 운영과 개발이 공유한다. 개발에서 그 아래에 쓰면 운영 데이터가 바뀐다.- c1 vhost 맨 아래의 catch-all rewrite 가 모든 요청을 RStudio 로 넘긴다. 개발 조각의
RewriteRule ^/cgv-dev - [L]이 없거나 조각을 catch-all 아래에 Include 하면/cgv-dev가 RStudio 로 간다(rewrite 가 proxy 보다 앞 단계). - 사이트 배포 검사와 이름.
tools/check_release.py는 비활성 앱 이름(cgv포함)이 보이는 쪽이 있으면 사이트 배포 전체를 실패시킨다. 위키(docs/wiki/)를 예외로 두는 규칙이 있어야 이 쪽이 배포될 수 있다 — 위키 밖(플레이북·연구·회사 화면)에 CGV 이름이나/cgv링크를 싣지 않는다.