2026년 4월 25일 · 글 장대철 · Lead Engineer
임상 SaaS를 직접 빌드하며 마주친 기술 의사결정 5가지
SpeechMap · plate-engine · language-screening 3개 임상 프로덕트를 운영하며 발견한, 임상 도메인 특유의 의사결정 5건. patch-package, RLS 한계, FK 무결성, 한글 PDF, 동의 워크플로우.
- 임상 SaaS
- React-PDF
- patch-package
- Supabase RLS
- 멀티테넌시
- 음성 분석

결론 한 단락
Songstark은 SpeechMap(음성 분석 엔진), plate-engine(영양·대사 임상 결과지), language-screening-platform-2025(한림대 영유아 언어발달 검사) 3개 임상 프로덕트를 운영한다. 도메인 특유의 의사결정 5건을 정리한다 — React-PDF의 한글 NFD 패치, RLS만으로 부족한 멀티테넌시, FK 체인 무결성, 동의 워크플로우의 코드화, Cloud Run vs Vercel 분리.
1. patch-package로 React-PDF의 한글 NFD 분해 이슈 해결
문제
@react-pdf/textkit이 한글을 NFD(Normalization Form D)로 분해해 PDF 출력 시 자모가 분리된다. "한글" → "ㅎㅏㄴㄱㅡㄹ"처럼 자모가 따로 그려져 폰트 metrics가 깨진다.
시도한 우회들
- ✗ 폰트 변경: Pretendard, Noto Sans KR, S-Core Dream — 모두 같은 증상
- ✗ 텍스트 사전 정규화:
text.normalize('NFC')— textkit이 다시 NFD로 재분해 - ✗ React-PDF 버전 다운그레이드: 동일 버그가 v3 전체에 존재
실제 해결
patch-package로 node_modules/@react-pdf/textkit/dist/cjs/index.js의 정규화 라인을 수정. 패치 파일을 리포에 commit하고 postinstall 훅에서 자동 적용한다.
// package.json
{
"scripts": {
"postinstall": "patch-package"
}
}
// patches/@react-pdf+textkit+5.x.x.patch
-text = unicode.normalize(text, 'NFD');
+// NFD breaks Korean rendering — keep NFC
+text = unicode.normalize(text, 'NFC');
이 패턴 자체는 평범하지만, 임상 결과지 PDF가 한글 포함이라는 게 라이브러리 작성자에게는 edge case라는 도메인 차이가 핵심이다. 영문 위주 라이브러리를 한국 의료에 쓸 때 비슷한 패턴이 자주 등장한다.
plate-engine 적용
plate-engine의 12종 검사(OAP/AAP/vitamin_profile/fatty_acids 등) × Light(5p) / Premium(12p+) × ko/en/ru = 30+ PDF 변형이 모두 이 패치 위에서 돈다. patch-package가 없으면 결과지 사업이 성립 불가능했다.
2. Supabase RLS만으로 부족한 멀티테넌시
문제
플레이트 의원의 plate-engine은 멀티테넌트(다수 클리닉) SaaS다. 처음에는 RLS만으로 모든 권한을 처리하려 했다.
-- 단순 RLS: 자기 센터 데이터만 읽기/쓰기
CREATE POLICY "center isolation"
ON test_results
USING (center_id = current_setting('app.center_id')::uuid);
이걸로 99%는 해결됐다. 그러나 다음이 RLS만으로는 안 됐다.
- Platform admin impersonation — 플랫폼 운영자가 특정 센터의 시점에서 들어가야 함 (지원 요청 대응)
- Toss 결제 정산 — 센터별로 매출이 분리되지만 정산은 통합 계산
- 광역 통계 — "전국 모든 센터의 검사 트렌드" 같은 집계는 모든 센터를 횡단
실제 해결
3계층 권한 시스템을 RLS + RPC + 별도 service role로 나눴다.
- clinician: RLS로 자기 센터만 (기본)
- center_admin: RLS로 자기 센터 + 결제 정보
- platform_admin: service role + RPC로 전 센터 + impersonation token 발급
-- platform_admin이 특정 센터로 impersonate하는 RPC
CREATE FUNCTION impersonate_center(target_center_id uuid)
RETURNS text AS $$
DECLARE
token text;
BEGIN
IF NOT current_setting('app.role') = 'platform_admin' THEN
RAISE EXCEPTION 'unauthorized';
END IF;
-- 일회성 임시 JWT 발급, 1시간 유효
token := generate_impersonation_jwt(target_center_id, '1 hour');
-- audit log
INSERT INTO impersonation_logs (admin_id, target_center_id, expires_at)
VALUES (auth.uid(), target_center_id, now() + interval '1 hour');
RETURN token;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
교훈: RLS는 데이터 격리에 강하지만, "역할별 다른 권한 모델"에는 보강이 필요하다. RPC + audit log + 임시 토큰 패턴이 표준이다.
3. FK 체인 무결성 — 연구 데이터의 생명선
한림대 language-screening-platform-2025 사례
플랫폼은 다음 FK 체인을 유지한다.
children (아동 등록)
↓
yearly_screenings (연도별 기초검사, 2025/2026 이월)
↓
in_depth_assessments (심화검사)
↓
therapy_assignments (치료 배정)
↓
recordings (녹음)
↓
research_dataset (연구 데이터)
각 단계는 이전 단계의 FK를 보유한다. 연도별 재검사 시 "같은 아동의 작년 검사"를 찾아야 하는데, 이 로직을 클라이언트 코드에 두면 race condition이 발생한다 (동시 두 명이 같은 아동 등록 시도 등).
실제 해결
PL/pgSQL RPC로 등록·이월·재검사 로직을 단일 트랜잭션에 묶는다.
CREATE FUNCTION register_or_carryover_screening(
p_child_id uuid,
p_year int,
p_screening_data jsonb
) RETURNS uuid AS $$
DECLARE
v_screening_id uuid;
v_prior_year_id uuid;
BEGIN
-- 같은 해의 중복 검사 차단
IF EXISTS (
SELECT 1 FROM yearly_screenings
WHERE child_id = p_child_id AND year = p_year
) THEN
RAISE EXCEPTION 'duplicate screening for year %', p_year;
END IF;
-- 작년 검사가 있으면 prior_screening_id로 연결 (이월)
SELECT id INTO v_prior_year_id
FROM yearly_screenings
WHERE child_id = p_child_id AND year = p_year - 1
ORDER BY created_at DESC LIMIT 1;
INSERT INTO yearly_screenings (child_id, year, prior_screening_id, data)
VALUES (p_child_id, p_year, v_prior_year_id, p_screening_data)
RETURNING id INTO v_screening_id;
RETURN v_screening_id;
END;
$$ LANGUAGE plpgsql;
여기에 Jest + ts-jest 단위 테스트로 fixture 스키마와 동기화. 4년 운영 + 111 PR 동안 FK 체인이 깨진 적이 없다.
4. 동의 워크플로우의 코드화
문제
개인정보보호법 + 의료법은 동의 → 수집 → 이용 → 폐기 4단계를 각각 다른 동의로 받고, 각 단계의 보관 기간을 다르게 둘 것을 요구한다. 이걸 "동의 체크박스 1개"로 처리하면 법적 리스크가 발생한다.
실제 해결
각 동의를 별도 row로 분리하고, 모든 데이터 접근은 동의 row 존재를 SQL 레벨에서 검증한다.
CREATE TABLE consents (
id uuid PRIMARY KEY,
user_id uuid REFERENCES users(id),
purpose text NOT NULL, -- 'collection' | 'use' | 'third_party_share' | 'retention_extension'
granted_at timestamp with time zone NOT NULL,
expires_at timestamp with time zone,
withdrawn_at timestamp with time zone,
consent_version text NOT NULL -- 약관 버전 추적
);
-- RLS: 개인정보 수집 전제는 'collection' 동의 + withdrawn_at IS NULL
CREATE POLICY "collection requires consent"
ON personal_data
FOR INSERT
WITH CHECK (
EXISTS (
SELECT 1 FROM consents
WHERE user_id = NEW.user_id
AND purpose = 'collection'
AND withdrawn_at IS NULL
AND (expires_at IS NULL OR expires_at > now())
)
);
자동 폐기는 cron job으로 처리. 두루바른 사회적협동조합·재단 밴드·강원특별자치도교육청에서 실전 운영 중이다.
5. Cloud Run vs Vercel — 임상 워크로드의 분리
문제
SpeechMap 음성 분석 엔진은 Python(Parselmouth · Librosa · PyDub) + MFA(Montreal Forced Aligner)로 돌아간다. Vercel은 Python 워크로드 + 4GB 이상 메모리 + 음성 처리 라이브러리에 부적합하다.
실제 해결
엔진(Python)과 웹(Next.js)을 분리.
- 엔진: Cloud Run, 컨테이너, 4GB / 2vCPU, Artifact Registry
- 웹: Vercel sin1 region, Next.js 16, Supabase
- 호출: 중앙화된 엔진 URL 환경변수 (
SPEECHMAP_ENGINE_URL)
// lib/speechmap-client.ts
const ENGINE_URL = process.env.SPEECHMAP_ENGINE_URL!;
export async function analyzeAudio(audioBuffer: ArrayBuffer) {
const response = await fetch(`${ENGINE_URL}/analyze`, {
method: 'POST',
body: audioBuffer,
headers: { 'content-type': 'audio/wav' },
});
if (!response.ok) throw new Error(`engine: ${response.statusText}`);
return response.json();
}
Cloud Run의 콜드스타트가 임상 환경에서 문제가 됐다. 첫 호출이 30초 걸리면 임상가가 대기한다. 항상 1개 인스턴스 유지 설정 + 클라이언트 측 retry 로직으로 해소.
async function analyzeWithRetry(audio: ArrayBuffer, retries = 3) {
for (let i = 0; i < retries; i++) {
try {
return await analyzeAudio(audio);
} catch (err) {
if (i === retries - 1) throw err;
await new Promise(r => setTimeout(r, 1000 * Math.pow(2, i)));
}
}
}
정리
임상 SaaS는 일반 B2B SaaS와 도메인이 다르다. 라이브러리 호환성(한글 PDF), 권한 모델(RLS + RPC + impersonation), FK 무결성(PL/pgSQL), 동의(코드 레벨 강제), 워크로드 분리(Python/Cloud Run + Next.js/Vercel) — 5가지 모두 일반 웹 외주에서는 만나지 않는 결정이다.
여러분이 임상·의료 SaaS를 만드신다면 문의 보내기 · 임상 SaaS 솔루션에서 더 깊이 이야기할 수 있다.
