BioPlayground

🧬
목록으로

고성능 서열 뷰어 — WebAssembly로 100만 bp를 브라우저에서 실시간 렌더

Rust로 성능 임계 로직을 짜서 WASM으로 컴파일, JavaScript와 브릿지해 브라우저에서 대용량 DNA 서열 뷰어를 만듭니다. IGV.js급 성능의 원리.

심화
|
100
|
검증 완료 (2026-07)
서열 뷰어WebAssemblyIGVgenome browsercanvas 렌더링고성능 웹Rust wasm
진행률0/19 (0%)

고성능 서열 뷰어 — WebAssembly로 100만 bp를 브라우저에서 실시간 렌더

이 토픽을 마치면

교과서에서 배운 WebAssembly, DOM, Big-O 복잡도 를 엮어서, 대용량 DNA 서열을 브라우저에서 부드럽게 렌더링하는 뷰어를 직접 만들 수 있습니다. IGV.js 같은 실전 도구 뒤의 원리를 코드로 이해합니다.

이 글은 교육용 일반 예제 입니다. 실전 서열 뷰어는 훨씬 정교하지만, 그 심장부의 성능 최적화 원리를 여기서 다룹니다.


"브라우저가 얼어붙었어요" — 순진한 렌더의 함정

여러분이 100만 bp DNA 서열을 브라우저에 표시하려 한다고 합시다.

첫 시도:

javascript
function renderSequence(sequence) {
  const container = document.getElementById("viewer");
  for (let i = 0; i < sequence.length; i++) {
    const span = document.createElement("span");
    span.textContent = sequence[i];
    span.className = `base-${sequence[i]}`;
    container.appendChild(span);
  }
}

renderSequence(oneMillionBp);

이 코드를 실행하면 브라우저가 5초 넘게 프리즈한 뒤 겨우 렌더됩니다. 그리고 페이지 스크롤도 심하게 버벅입니다.

문제:

문제 1: DOM 노드 100만 개. 각 span의 메모리 오버헤드가 최소 몇 KB. 전체 몇 GB.

문제 2: DOM 삽입이 O(n²)에 가깝습니다. appendChild 하나가 이전 노드들 레이아웃 재계산.

문제 3: 문자열 순회가 JS에서 느립니다. 100만 문자 반복이 native보다 훨씬 늦습니다.

진짜 접근은 세 축입니다:

  1. DOM 대신 Canvas 로 렌더 (100만 노드 없음)
  2. 가상화 — 뷰포트 안의 서열만 렌더 (O(뷰포트) 복잡도)
  3. WebAssembly — 성능 임계 로직을 native 속도로

블랙박스에서 부품으로

부품 1: Big-O 관점에서의 서열 뷰어

서열 뷰어의 각 프레임에서 필요한 계산량을 분석합니다.

순진한 접근: 매 프레임 서열 전체를 스캔. O(n).

가상화: 뷰포트에 들어갈 부분만 렌더. O(뷰포트).

인덱스: 특정 위치 조회를 O(1)로.

100만 bp에서 뷰포트가 예를 들어 200 bp라면:

  • O(n) = 100만 연산
  • O(뷰포트) = 200 연산
  • 5,000배 빠름

이 차이가 60fps 실시간 렌더의 열쇠입니다.

부품 2: Canvas 기반 렌더

DOM 대신 하나의 canvas를 씁니다.

javascript
function initCanvas(container) {
  const canvas = document.createElement("canvas");
  canvas.width = container.clientWidth;
  canvas.height = 100;
  container.appendChild(canvas);
  return canvas.getContext("2d");
}


function renderViewport(ctx, sequence, startBp, endBp, bpWidth) {
  ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
  
  const colors = { A: "#66c2a5", C: "#fc8d62", G: "#8da0cb", T: "#e78ac3" };
  
  for (let i = startBp; i < endBp && i < sequence.length; i++) {
    const x = (i - startBp) * bpWidth;
    const base = sequence[i];
    ctx.fillStyle = colors[base] || "#ccc";
    ctx.fillRect(x, 20, bpWidth, 40);
    
    if (bpWidth >= 8) {
      ctx.fillStyle = "black";
      ctx.font = "12px monospace";
      ctx.fillText(base, x + 2, 45);
    }
  }
}

canvas 하나가 렌더된 부분을 그림으로 갖고 있습니다. 스크롤 이벤트에서 startBp, endBp 만 갱신해 다시 그리면 됩니다.

부품 3: WebAssembly로 성능 임계 로직 오프로드

canvas 렌더 자체는 JS로 충분합니다. 그러나 GC 함량 계산, 모티프 검색, 정렬 같은 성능 임계 작업이 있으면 WASM이 유리합니다.

Rust로 GC 함량 계산 예시:

rust
// src/lib.rs
use wasm_bindgen::prelude::*;

#[wasm_bindgen]
pub fn gc_content_window(sequence: &str, window_size: usize) -> Vec<f32> {
    let bytes = sequence.as_bytes();
    let n = bytes.len();
    if n < window_size {
        return vec![];
    }
    
    let mut result = Vec::with_capacity(n - window_size + 1);
    let mut gc_count: usize = 0;
    
    for i in 0..window_size {
        if bytes[i] == b'G' || bytes[i] == b'C' {
            gc_count += 1;
        }
    }
    result.push(gc_count as f32 / window_size as f32);
    
    for i in window_size..n {
        if bytes[i] == b'G' || bytes[i] == b'C' {
            gc_count += 1;
        }
        if bytes[i - window_size] == b'G' || bytes[i - window_size] == b'C' {
            gc_count -= 1;
        }
        result.push(gc_count as f32 / window_size as f32);
    }
    
    result
}

빌드:

bash
wasm-pack build --target web

pkg/ 폴더에 .wasm 과 JS 브릿지가 나옵니다.

브라우저에서 사용:

javascript
import init, { gc_content_window } from "./pkg/sequence_viewer.js";

async function main() {
  await init();
  
  const sequence = "ATGCGATCGATCG...".repeat(100000);
  
  console.time("WASM GC");
  const gcArray = gc_content_window(sequence, 100);
  console.timeEnd("WASM GC");
  
  console.log(`Computed GC for ${gcArray.length} windows`);
}

100만 bp에서 sliding window GC 계산:

  • 순수 JS: ~500ms
  • WASM: ~15ms
  • ~30배 빠름

부품 4: 스크롤 이벤트와 render loop

부드러운 스크롤 = 60fps = 프레임당 16ms 이내.

javascript
class SequenceViewer {
  constructor(container, sequence) {
    this.ctx = initCanvas(container);
    this.sequence = sequence;
    this.startBp = 0;
    this.bpWidth = 4;
    this.viewportBp = Math.floor(this.ctx.canvas.width / this.bpWidth);
    
    this.attachEvents(container);
    this.render();
  }
  
  attachEvents(container) {
    container.addEventListener("wheel", (e) => {
      e.preventDefault();
      const delta = Math.sign(e.deltaY) * 10;
      this.startBp = Math.max(0, Math.min(this.sequence.length - this.viewportBp, this.startBp + delta));
      requestAnimationFrame(() => this.render());
    });
  }
  
  render() {
    const endBp = Math.min(this.sequence.length, this.startBp + this.viewportBp);
    renderViewport(this.ctx, this.sequence, this.startBp, endBp, this.bpWidth);
  }
}

핵심: requestAnimationFrame 으로 브라우저 렌더 사이클에 정렬. 60fps 자동.


페이딩 — 여러분이 채워야 할 세 빈칸

빈칸 1: 다중 트랙 렌더

서열 위에 read alignment, gene annotation 같은 여러 트랙을 겹쳐 그립니다.

javascript
class MultiTrackViewer extends SequenceViewer {
  constructor(container, sequence, tracks) {
    super(container, sequence);
    this.tracks = tracks;
    this.ctx.canvas.height = 100 + tracks.length * 30;
  }
  
  render() {
    super.render();
    
    // TODO: 각 트랙을 서열 아래에 그림
    // 트랙 = { name: string, features: [{start, end, color, label}] }
    // 뷰포트 범위 안의 features만 그림
    for (let i = 0; i < this.tracks.length; i++) {
      // TODO: 각 feature의 (start, end)를 canvas 좌표로 변환하고 사각형 그리기
    }
  }
}

힌트: const xStart = (feature.start - this.startBp) * this.bpWidth; const xEnd = (feature.end - this.startBp) * this.bpWidth; if (xEnd < 0 || xStart > canvas.width) continue;.

빈칸 2: WASM 모티프 검색

앞서 다룬 trie를 Rust로 구현해 WASM으로 노출.

rust
#[wasm_bindgen]
pub fn find_motif(sequence: &str, motif: &str) -> Vec<usize> {
    // TODO: sequence에서 motif가 나타나는 모든 위치 반환
    // 힌트: sequence.match_indices(motif).map(|(i,_)| i).collect()
    vec![]
}

브라우저에서 사용:

javascript
const positions = find_motif(sequence, "GAATTC");  // EcoRI 절단 부위
positions.forEach(pos => drawMarker(pos));

빈칸 3: 성능 벤치마크

순수 JS 구현과 WASM 구현의 성능 비교.

javascript
async function benchmark() {
  const sequence = "ATCGATCG".repeat(125_000);  // 100만 bp
  
  // TODO 1: 순수 JS GC 계산 함수 구현 (참고)
  function gcContentJs(seq, window) {
    // 여러분이 구현
  }
  
  // TODO 2: JS · WASM 각각 시간 측정
  // console.time / console.timeEnd
  
  // TODO 3: 결과가 같은지 검증 (samples[0], samples[500000], samples[999900] 등)
  
  // TODO 4: 결과 표시
}

성찰 — 실전 서열 뷰어와의 차이

IGV.js: Integrative Genomics Viewer의 JavaScript 포트. BAM, VCF, BED 등 여러 표준 포맷 지원. 백엔드 서버 없이 클라이언트만으로 작동.

Ensembl · UCSC Genome Browser: 웹 기반 유전체 브라우저의 대표. 백엔드 인프라가 정교하고 여러 트랙 데이터가 캐시됨.

JBrowse 2: 근대 아키텍처의 유전체 브라우저. WebWorker, WASM, React 조합. 매우 큰 데이터셋 지원.

Deep learning 시각화: 최근 AlphaFold 같은 도구가 3D 구조 시각화를 요구. WebGL/WebGPU가 필요.

Streaming 처리: 실전은 전체 서열을 브라우저에 로드하지 않습니다. 필요한 부분만 서버에서 요청하는 range request 패턴. Web Sockets/HTTP2 push.


확장 프로젝트

1. FASTA 로더: 사용자가 FASTA 파일을 드래그 앤 드롭하면 뷰어에 로드.

2. GFF/BED 파서: gene annotation 파일을 파싱해 트랙으로 표시.

3. Search UI: 텍스트 입력으로 모티프 검색. 결과를 미니맵에 하이라이트.

4. Export: 현재 뷰포트를 PNG/SVG로 저장.


이 편의 부품 지도

  • [F] WebAssembly: Rust → wasm-pack → 브라우저 로드. JS-WASM 브릿지.
  • [F] DOM: 최소 DOM 조작. Canvas 하나로 100만 요소 대체.
  • [F] Big-O: O(n) → O(뷰포트) 가상화. 성능 예산 개념.
  • [W] HTML/JS 기초: 이벤트 핸들링, requestAnimationFrame (완성 스크립트 제공).

[F] = 여러분이 직접 구현 / [W] = 완성 코드로 제공.