Songstark Logo

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-packagenode_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만으로는 안 됐다.

  1. Platform admin impersonation — 플랫폼 운영자가 특정 센터의 시점에서 들어가야 함 (지원 요청 대응)
  2. Toss 결제 정산 — 센터별로 매출이 분리되지만 정산은 통합 계산
  3. 광역 통계 — "전국 모든 센터의 검사 트렌드" 같은 집계는 모든 센터를 횡단

실제 해결

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 솔루션에서 더 깊이 이야기할 수 있다.