고성능 서열 뷰어 — WebAssembly로 100만 bp를 브라우저에서 실시간 렌더
이 토픽을 마치면
교과서에서 배운 WebAssembly, DOM, Big-O 복잡도 를 엮어서, 대용량 DNA 서열을 브라우저에서 부드럽게 렌더링하는 뷰어를 직접 만들 수 있습니다. IGV.js 같은 실전 도구 뒤의 원리를 코드로 이해합니다.
이 글은 교육용 일반 예제 입니다. 실전 서열 뷰어는 훨씬 정교하지만, 그 심장부의 성능 최적화 원리를 여기서 다룹니다.
"브라우저가 얼어붙었어요" — 순진한 렌더의 함정
여러분이 100만 bp DNA 서열을 브라우저에 표시하려 한다고 합시다.
첫 시도:
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보다 훨씬 늦습니다.
진짜 접근은 세 축입니다:
- DOM 대신 Canvas 로 렌더 (100만 노드 없음)
- 가상화 — 뷰포트 안의 서열만 렌더 (O(뷰포트) 복잡도)
- 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를 씁니다.
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 함량 계산 예시:
// 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
}빌드:
wasm-pack build --target webpkg/ 폴더에 .wasm 과 JS 브릿지가 나옵니다.
브라우저에서 사용:
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 이내.
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 같은 여러 트랙을 겹쳐 그립니다.
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으로 노출.
#[wasm_bindgen]
pub fn find_motif(sequence: &str, motif: &str) -> Vec<usize> {
// TODO: sequence에서 motif가 나타나는 모든 위치 반환
// 힌트: sequence.match_indices(motif).map(|(i,_)| i).collect()
vec![]
}브라우저에서 사용:
const positions = find_motif(sequence, "GAATTC"); // EcoRI 절단 부위
positions.forEach(pos => drawMarker(pos));빈칸 3: 성능 벤치마크
순수 JS 구현과 WASM 구현의 성능 비교.
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] = 완성 코드로 제공.