Streams API 2. 상태와 백프레셔의 의미론

작성일:2026.03.13|조회수:9

Streams API 2. 상태와 백프레셔의 의미론

스트림을 처음 배울 때는 읽고 쓰는 예제가 꽤 단순해 보인다. getReader()로 읽고, getWriter()로 쓰고, pipeTo()로 연결하면 끝나는 것처럼 느껴진다. 실제로 짧은 데모는 이 정도만 알아도 돌아간다. 그러나 실무에서 스트림 코드를 망가뜨리는 원인은 거의 언제나 더 깊은 층에 있다. 스트림이 왜 잠기는지, 왜 어떤 종료는 정상이고 어떤 종료는 취소인지, 왜 다운스트림이 느리면 업스트림도 멈춰야 하는지 같은 의미론을 놓치면, 코드는 겉보기에는 맞아도 쉽게 멈추고 새고 꼬인다.

이 두 번째 글의 목표는 바로 그 층을 다루는 것이다. 여기서는 메서드 사용법보다 상태 전이와 전파 규칙을 먼저 본다. Web Streams API를 완전히 이해하려면 ReadableStream, WritableStream, TransformStream을 각각 독립적인 객체로 보는 데서 멈추면 안 된다. 이들은 서로 연결될 때 생기는 압력, 락, 종료 전파를 포함해 하나의 제어 시스템을 이룬다. 따라서 이번 글은 "왜 이런 규칙이 필요한가"를 중심으로 잠금, pipeTo, cancel, abort, close, 큐, desiredSize, highWaterMark를 차례로 정리하겠다.

스트림은 상태 기계이다

가장 먼저 고정해야 할 사실은 스트림이 상태 기계(state machine)라는 점이다. ReadableStream은 읽을 수 있는 상태였다가 정상 종료된 상태가 될 수 있고, 에러 상태로 전이할 수도 있다. WritableStream 역시 쓰기 가능 상태였다가 닫힌 상태나 에러 상태로 간다. 이 전이를 정확하게 이해하지 못하면, 코드에서 "왜 더 이상 안 읽히지?" "왜 writer가 거부되지?" 같은 현상을 그때그때 임기응변으로만 해석하게 된다.

ReadableStream을 예로 들면 큰 흐름은 이렇다. 처음에는 데이터를 계속 공급할 수 있는 readable 상태에 있다. 생산자가 controller.close()를 호출하면 닫힘으로 향하는 정상 종료 경로가 시작된다. 이미 큐에 들어간 데이터는 여전히 읽을 수 있지만, 새 데이터는 더 이상 들어오지 않는다. 반면 생산자가 controller.error(reason)를 호출하면 스트림은 에러 상태로 전이하고, 이후 읽기 작업은 실패하게 된다. 즉, 정상 종료와 실패 종료는 모두 "더 이상 새로 읽히지 않는다"는 점에서는 같아 보이지만 의미는 완전히 다르다.

이 차이는 왜 중요한가. 정상 종료는 소비자가 끝까지 읽었다는 의미를 만들 수 있지만, 에러 종료는 중간에 데이터 보장이 깨졌다는 뜻이기 때문이다. 이 둘을 같은 "종료"로 뭉뚱그리면 파이프라인 전체의 복구 전략을 세울 수 없다.

스트림에서 상태 기계를 먼저 강조하는 이유도 여기에 있다. 메서드 호출 순서는 겉으로 드러난 행동일 뿐이고, 실제 안정성은 그 호출이 어떤 상태 전이를 일으키는지에 달려 있다. 예를 들어 이미 에러 상태로 전이한 스트림에 정상 종료를 기대하는 코드는 논리적으로 맞을 수가 없다. 반대로 정상 종료를 실패처럼 취급하면 상위 계층은 불필요한 재시도나 복구 절차를 밟게 된다. 결국 상태를 정확히 이해한다는 것은 예외를 줄이는 일이 아니라, 시스템이 어떤 종류의 끝을 맞이했는지 구분할 수 있게 되는 일이다.

잠금은 왜 필요한가

스트림에서 가장 처음 부딪히는 제약 중 하나는 잠금(lock)이다. ReadableStream에서 getReader()를 호출하면 해당 스트림은 잠긴다. WritableStream에서 getWriter()를 호출해도 마찬가지이다. 한 시점에 하나의 능동 reader, 하나의 능동 writer만 허용된다.

이 규칙은 초심자에게 자주 불편하게 느껴진다. "어차피 비동기인데 여러 군데서 읽으면 안 되나"라는 생각이 들 수 있다. 그러나 잠금이 없다면 청크 소유권이 애매해진다. 두 reader가 동시에 같은 스트림을 읽는다면 어떤 청크가 누구에게 가야 하는가. 순서를 어떻게 유지할 것인가. 한쪽이 중간에 취소하면 다른 쪽은 어떤 상태가 되는가. 잠금은 이런 모호함을 제거하기 위한 장치이다.

TS
const stream = new ReadableStream<string>({
  start(controller) {
    controller.enqueue('a');
    controller.close();
  },
});

const reader1 = stream.getReader();
// const reader2 = stream.getReader();
// 위 호출은 스트림이 이미 잠겨 있기 때문에 실패한다.

await reader1.read();
reader1.releaseLock();

이 제약은 스트림의 일관성을 지키는 대신, 복제가 필요할 때는 명시적으로 tee() 같은 도구를 사용하게 만든다. 즉, 표준은 "동시에 읽고 싶으면 그 의도를 API에 드러내라"고 요구한다. 나중에 tee()를 볼 때 이 원리를 기억하면, 왜 단순히 reader 둘을 붙이는 방식이 아니라 분기 API가 따로 존재하는지 자연스럽게 이해된다.

잠금을 엄격하게 거는 이유는 단순히 API를 보수적으로 만들기 위해서가 아니다. 스트림은 기본적으로 순서가 의미를 갖는 흐름이다. 그런데 두 소비자가 한 흐름을 동시에 잡고 있으면, 청크의 소유권뿐 아니라 종료와 취소 책임도 애매해진다. 누가 먼저 읽은 청크를 소비한 것으로 볼 것인가, 누가 cancel()을 호출하면 그것이 전체 스트림 생명주기를 끝내는가 같은 질문이 즉시 생긴다. 잠금은 이 애매함을 설계 차원에서 미리 제거한다. 불편함을 감수하고 대신 의미를 선명하게 만든 셈이다.

disturbed stream이라는 개념

스트림을 한 번 읽기 시작하면, 그 스트림은 이제 pristine한 원본이 아니다. WHATWG 표준과 Node 문서에는 이를 설명하는 개념으로 disturbed stream이라는 표현이 등장한다. 이는 이미 소비가 시작되었거나 취소된 스트림을 가리키며, 다시 처음 상태의 원본처럼 다루지 못한다는 뜻이다.

이 개념이 실전에서 가장 크게 드러나는 곳은 fetch() 응답이다. Response.body는 보통 한 번만 안전하게 소비할 수 있다. 그래서 response.text()를 호출한 뒤 다시 response.json()을 호출하는 식의 코드는 실패한다. 이미 본문 소비가 시작되었기 때문이다. 이 모델은 엄격해 보이지만, 사실 데이터 소유권을 명확히 하기 위한 것이다. 네트워크 바디는 배열처럼 되감기 가능한 구조가 아니기 때문이다.

따라서 스트림을 다룰 때는 언제나 "이 스트림은 아직 원본인가, 아니면 이미 읽기 시작했는가"를 의식해야 한다. 단순히 객체 참조가 남아 있다고 해서 재사용 가능한 것은 아니다.

이 개념은 스트림을 값처럼 다루고 싶은 자바스크립트 습관과 자주 충돌한다. 우리는 보통 함수 인자나 변수에 객체를 담아두면 나중에도 같은 상태로 꺼내 쓸 수 있다고 기대한다. 그러나 스트림은 객체이면서 동시에 소비되는 수명 주기 자원이다. 따라서 참조 동일성과 사용 가능성은 전혀 같은 말이 아니다. 이 차이를 초기에 받아들이면 fetch 바디 재사용 오류나 Node 쪽 disturbed 체크도 훨씬 자연스럽게 보이기 시작한다.

close, cancel, abort는 왜 다 다른가

스트림 학습에서 가장 자주 섞이는 세 단어가 close, cancel, abort이다. 셋 다 끝내는 행동처럼 보이기 때문이다. 그러나 실제 의미는 서로 다르며, 이 차이를 무시하면 종료 처리와 자원 정리가 금방 엉킨다.

TS
const stream = new ReadableStream<number>({
  start(controller) {
    controller.enqueue(1);
    controller.enqueue(2);
    controller.enqueue(3);
  },
  cancel(reason) {
    console.log('consumer stopped:', reason);
  },
});

const reader = stream.getReader();
const first = await reader.read();
console.log(first.value); // 1
await reader.cancel('enough data');

위 예제에서 소비자는 첫 청크만 읽고 나머지는 버린다. 이것은 "종료"이긴 하지만, 결코 정상 완료가 아니다. 스트림 설계에서 이 구분이 중요한 이유는, 자원을 어떻게 정리해야 하는지와 상위 계층에 무엇을 보고해야 하는지가 달라지기 때문이다.

실무에서는 이 차이가 모니터링과 로깅 전략에도 직접 영향을 준다. close는 성공 메트릭으로 기록할 수 있지만, cancel은 사용자 조기 종료나 상위 계층의 전략적 중단으로 기록하는 편이 맞다. abort는 아예 실패 계열 신호로 봐야 한다. 세 경로를 구분하지 않으면 시스템은 "왜 종료되었는가"를 잃어버리고, 결국 장애 분석 때 모든 종료가 비슷하게 보이게 된다. 스트림 API가 이 셋을 명확히 나눈 이유는 단순한 취향이 아니라 운영 의미를 살리기 위해서이다.

pipeTo와 전파 규칙

스트림이 진짜 어려워지는 순간은 파이프라인을 연결할 때이다. pipeTo()는 편리한 API지만, 그 안에서는 생각보다 많은 일이 일어난다. 업스트림 읽기, 다운스트림 쓰기, 백프레셔 관찰, 종료 전파, 에러 전파, 취소 전파가 모두 함께 움직인다. 이 복합적인 상호작용을 모른 채 pipeTo()를 단순 복사 도구로만 생각하면, 예상치 못한 중단이나 조기 종료를 이해하기 어렵다.

기본 원칙은 이렇다. 업스트림이 정상 종료되면 다운스트림도 보통 닫힌다. 다운스트림이 에러나 abort로 실패하면 업스트림은 보통 cancel된다. 업스트림이 에러를 내면 다운스트림은 보통 abort된다. 즉, 한쪽의 종료는 다른 쪽으로 전파된다. pipeTo()가 단순 루프보다 강력한 이유가 바로 여기에 있다. 표준이 이미 이 전파 규칙을 정해 두었기 때문이다.

TS
// pipeTo는 종료를 자동 전파함
await readable.pipeTo(writable);

겉보기에는 한 줄이지만, 사실상 다음과 같은 질문들에 대한 답이 모두 내장되어 있다. 읽는 쪽이 끝나면 쓰는 쪽도 닫을 것인가. 쓰는 쪽이 실패하면 읽는 쪽은 계속 둘 것인가. 사용자가 외부에서 중단 신호를 보내면 어느 방향으로 끊을 것인가. Web Streams API는 여기에 대해 기본값을 제공하고, 필요하다면 옵션으로 바꿀 수 있게 한다.

예를 들어 preventClose, preventAbort, preventCancel 옵션을 사용하면 기본 전파 방향을 일부 끊을 수 있다. 이 옵션들은 편의 기능이 아니라 책임 경계 조정 장치이다. 한 단계의 실패가 전체 시스템 실패를 의미하지 않을 때, 또는 다운스트림 교체 전략을 직접 구현하고 싶을 때 사용한다. 다만 이 옵션을 남용하면 파이프라인이 왜 안 닫히는지 스스로도 설명하기 어려워진다. 기본 전파 규칙을 이해한 뒤 정말 필요한 경우에만 예외를 두는 편이 안전하다.

따라서 pipeTo()를 사용할 때는 항상 묻는 편이 좋다. 이 체인에서 한 단계가 죽으면 전체도 같이 죽어야 하는가. 아니면 일부 단계만 교체하면 되는가. 기본 전파 규칙은 대부분의 경우 합리적이지만, 분기 저장이나 복구 가능한 다운스트림처럼 예외도 있다. 중요한 것은 옵션 이름을 외우는 것이 아니라, 전파 방향을 설계 문제로 보는 관점이다. 파이프라인은 단순 연결선이 아니라 실패 도메인을 정의하는 구조이기 때문이다.

큐와 queuing strategy는 왜 필요한가

스트림 내부에는 큐가 있다. 이것은 구현 세부사항이 아니라 공개 의미론에 가까운 개념이다. 청크가 생산되는 속도와 소비되는 속도가 항상 같지 않기 때문에, 중간 완충 지대가 필요하다. 다만 이 큐는 무한 버퍼가 아니다. 큐가 얼마나 찼는지, 얼마나 더 받을 수 있는지에 따라 스트림은 pull()을 더 호출할지 멈출지, writer가 계속 써도 될지 기다려야 할지를 판단한다.

이때 등장하는 것이 queuing strategy이다. 대표적으로 highWaterMarksize()가 있다. highWaterMark는 내부적으로 허용할 목표 큐 크기 비슷한 기준이고, size(chunk)는 각 청크가 큐에서 얼마나 큰 비용으로 계산될지를 정한다. 청크 개수를 기준으로 셀 수도 있고, 바이트 수를 기준으로 셀 수도 있다.

TS
const stream = new ReadableStream<string>(
  {
    pull(controller) {
      controller.enqueue('chunk');
    },
  },
  {
    highWaterMark: 4,
    size(chunk) {
      return chunk.length;
    },
  },
);

이 예제는 개념 설명용 단순화 버전이지만 메시지는 분명하다. 큐는 "몇 개 들어 있느냐"만이 아니라 "얼마나 무거운 청크들이 들어 있느냐"도 중요하다. 만약 1KB 청크와 10MB 청크를 똑같이 1개로 센다면 실제 메모리 압박을 전혀 반영하지 못한다. 따라서 어떤 단위로 압력을 계산할 것인지는 스트림 설계의 핵심 선택 중 하나이다.

이 부분은 의외로 도메인 모델링과도 닮아 있다. 애플리케이션이 무엇을 비용으로 보는가에 따라 압력 계산 방식도 달라진다. 텍스트 라인 스트림이라면 줄 수 기준이 더 직관적일 수 있고, 바이너리 전송이라면 바이트 수가 훨씬 적절하다. 즉, queuing strategy는 저수준 튜닝 파라미터가 아니라 청크 비용 모델을 명시하는 장치라고 보는 편이 맞다. 이 관점으로 보면 size()는 뜬금없는 함수가 아니라, 시스템이 무엇을 무겁다고 판단하는지 드러내는 규칙이 된다.

desiredSize는 무엇을 말해주는가

desiredSize는 많은 초심자가 가장 추상적으로 느끼는 속성이다. 하지만 뜻은 단순하다. 내부 큐 관점에서 "얼마나 더 받아도 되는가"를 나타내는 신호이다. 양수면 아직 여유가 있다는 뜻이고, 0 또는 음수면 충분히 찼거나 이미 넘쳤다는 뜻이다.

중요한 점은 desiredSize가 단순 통계값이 아니라 흐름 제어 신호라는 사실이다. 생산자는 이 값을 보고 계속 만들지 잠시 멈출지 판단할 수 있다. writer 역시 ready와 함께 이런 압력 상태를 반영한다. 결국 백프레셔는 추상 이론이 아니라, 바로 이런 신호들이 업스트림으로 전달되면서 구현된다.

TS
const stream = new ReadableStream<number>({
  pull(controller) {
    if ((controller.desiredSize ?? 0) <= 0) {
      return;
    }

    controller.enqueue(Math.random());
  },
});

물론 실제 환경에서 이 코드만으로 완벽한 흐름 제어가 되지는 않는다. 하지만 사고방식은 정확하다. 생산자는 "내가 만들 수 있으니 계속 만든다"가 아니라 "지금 받아들일 여지가 있으니 만든다"는 기준으로 움직여야 한다. 이 관점이 바로 스트림 모델의 핵심이다.

바로 이 때문에 desiredSize는 디버깅 지표로도 유용하다. 값이 계속 음수 근처에서 머문다면 다운스트림이 이미 숨이 차 있다는 뜻일 수 있다. 반대로 항상 큰 양수라면 생산이 너무 드물거나 청크 크기 계산이 실제 비용을 반영하지 못하고 있을 수 있다. 물론 이것만 보고 단정할 수는 없지만, 적어도 스트림이 어디에서 압력을 느끼는지 추적하는 데 좋은 출발점이 된다.

백프레셔는 왜 체인 전체의 문제인가

백프레셔를 버퍼 크기 정도로만 이해하면 반드시 놓치는 점이 있다. 백프레셔는 로컬한 큐 문제이면서 동시에 체인 전체의 속도 조절 문제이다. 예를 들어 업스트림이 응답 바디를 읽고, 중간 변환기가 텍스트를 줄 단위로 나누고, 다운스트림이 느린 데이터베이스에 쓰고 있다고 해보자. 이때 진짜 병목은 데이터베이스일 수 있다. 그러면 변환기도 무한히 결과를 쌓아두면 안 되고, 업스트림도 계속 네트워크를 읽어와 메모리로 밀어 넣으면 안 된다. 결국 가장 느린 단계의 압력이 업스트림까지 거슬러 올라가야 한다.

이것이 pipeTo()pipeThrough()가 중요한 이유이다. 이 API들은 단순히 데이터를 옮기는 것이 아니라, 압력을 체인 전체에 걸쳐 자동으로 조정한다. 따라서 Web Streams API에서 백프레셔는 최적화 옵션이 아니라 정합성의 일부이다. 이를 무시하면 코드는 처음에는 빨라 보여도, 부하가 커지는 순간 메모리 급증이나 지연 폭발로 돌아온다.

실무에서 백프레셔를 우습게 보면 흔히 두 가지 증상이 나타난다. 하나는 메모리는 계속 늘어나는데 처리 속도는 오르지 않는 상황이다. 다른 하나는 어느 순간부터 지연이 갑자기 폭증하는 상황이다. 둘 다 업스트림이 다운스트림의 현실을 무시하고 밀어붙인 결과이다. 따라서 백프레셔를 잘 이해한다는 것은 빠른 코드를 짜는 일보다, 느린 상황에서도 시스템이 무너지지 않게 만드는 일에 가깝다.

TransformStream 안에서도 종료 의미론은 유지된다

TransformStream은 중간 변환기일 뿐이지만, 종료와 에러 규칙이 사라지는 것은 아니다. 입력이 정상 종료되면 변환기에도 마무리할 기회가 필요하다. 그래서 flush(controller)가 존재한다. 반대로 변환 중 에러가 나면 출력 스트림 역시 실패해야 한다.

TS
function createLineSplitter() {
  let buffer = '';

  return new TransformStream<string, string>({
    transform(chunk, controller) {
      buffer += chunk;

      const lines = buffer.split('\n');
      buffer = lines.pop() ?? '';

      for (const line of lines) {
        controller.enqueue(line);
      }
    },
    flush(controller) {
      if (buffer.length > 0) {
        controller.enqueue(buffer);
      }
    },
  });
}

이 예제에서 flush()가 없다면 마지막 줄이 개행 문자 없이 끝날 때 데이터가 유실될 수 있다. 즉, 스트림 종료는 단순히 끝났다는 사실만이 아니라, 마지막 잔여 상태를 어떻게 정리할 것인가의 문제이기도 하다. 실무에서 줄 파서, CSV 파서, NDJSON 파서가 자주 마지막 청크를 잘못 처리하는 이유가 바로 여기에 있다.

이 점은 변환기를 설계할 때 꼭 가져가야 할 태도와 연결된다. transform()만 생각하면 변환기는 입력을 받는 함수처럼 보이지만, 실제로는 내부 상태를 가진 작은 상태 기계에 가깝다. 그리고 상태 기계라면 당연히 마지막 정리 경로가 필요하다. flush()는 바로 그 마지막 문이다. 따라서 TransformStream을 만들 때는 입력이 끝나는 순간 무엇을 방출해야 하는지, 무엇을 버려야 하는지를 먼저 묻는 편이 안전하다.

조기 종료와 취소 전파

스트림을 끝까지 읽는 경우만 상정하면 설계가 단순해 보인다. 하지만 실제로는 중간에 충분한 정보가 나와 조기 종료하는 경우가 많다. 예를 들어 첫 헤더 블록만 읽으면 되거나, 특정 토큰이 보이면 이후 데이터는 더 이상 필요 없을 수 있다. 이때는 reader를 조용히 버리는 것으로 끝내면 안 된다. cancel()을 통해 업스트림에 의도를 전달해야 한다.

왜냐하면 업스트림은 여전히 일하고 있을 수 있기 때문이다. 네트워크 요청이 계속 읽히고 있을 수도 있고, 압축 해제가 백그라운드에서 진행 중일 수도 있고, 파일 핸들이 열린 채 남아 있을 수도 있다. 스트림에서 취소는 단순 소비 중지가 아니라 "이 생명주기는 여기서 끝난다"는 계약 통보다. 이 경로를 명시하지 않으면 겉보기 기능은 맞아도 자원 수명이 흐릿해진다.

안티패턴: 전체 버퍼링으로 퇴행하기

스트림 코드를 작성하면서 가장 흔히 저지르는 안티패턴은 결국 모든 청크를 배열에 모은 뒤 한 번에 처리하는 것이다. 물론 때로는 최종적으로 전체 데이터가 필요할 수 있다. 그러나 무심코 이런 구조를 넣는 순간, 스트림의 장점 대부분이 사라진다. 점진 처리, 메모리 절약, 조기 종료, 체인형 변환이라는 이점이 전부 희미해진다.

스트림을 쓴다는 것은 단지 API 형태를 바꾸는 것이 아니다. 가능한 한 청크 단위로 의미 있는 처리를 하겠다는 설계 선택이다. 만약 마지막에 전부 모아야만 한다면, 정말 그 지점까지 스트림이 필요한지 다시 질문해야 한다.

물론 전체 버퍼링이 항상 잘못이라는 뜻은 아니다. 최종 포맷 변환이나 암호 검증처럼 전체 입력이 모두 필요할 수 있다. 중요한 것은 그 사실을 의식적으로 선택했는가이다. 스트림을 쓰고 있다는 이유만으로 자동으로 점진 처리 이점을 얻는 것은 아니다. 마지막에 전부 쌓아버리면 스트림 API를 쓰더라도 사고방식은 여전히 배치 처리에 머물러 있는 셈이다.

마무리

이 글의 핵심은 스트림을 메서드 집합이 아니라 제어 시스템으로 보는 데 있다. 잠금은 소유권과 순서를 지키기 위한 장치이고, close, cancel, abort는 서로 다른 종료 의미를 가진다. 내부 큐와 highWaterMark, size(), desiredSize는 단순 숫자가 아니라 흐름 압력을 계산하는 장치이며, 그 압력은 pipeTo() 체인을 따라 업스트림까지 전파된다. 즉, Web Streams API의 진짜 본질은 읽고 쓰는 동작 그 자체보다도, 그 동작이 언제 어떤 상태에서 누구에게 영향을 주는가를 엄격하게 모델링하는 데 있다.

다음 글에서는 이 의미론을 바탕으로 바이트 스트림, BYOB reader, fetch()와의 통합, TextDecoderStream, Node의 stream/web 상호운용, 실전 디버깅과 성능 관점까지 연결하겠다. 2편이 스트림의 내부 논리를 이해하는 단계였다면, 3편은 그 논리를 실제 네트워크와 런타임 경계에 적용하는 단계가 될 것이다.

더 읽어보기

  • 2026.03.13

    Streams API 3. 바이트 스트림과 실전 파이프라인

    앞선 두 글에서 우리는 Web Streams API의 표면과 의미론을 정리했다. 이제 남은 질문은 하나이다. 이 지식을 실제 어디에 써먹을 것인가. 이 질문에 답하는 순간 스트림 학습은 비로소 완성된다. 표준을 읽고 메서드를 아는 것만으로는 충분하지 않다. fetch() 응답 본문을 어…

  • 2026.03.13

    Streams API 1. 읽기와 쓰기의 출발점

    자바스크립트에서 비동기를 배울 때 우리는 대개 Promise와 async, await부터 익힌다. 이 조합은 한 번의 결과를 기다리는 문제에는 매우 강력하다. 그러나 데이터가 한 번에 끝나지 않고 계속 흘러들어오는 상황에서는 이야기가 달라진다. 네트워크 응답이 길게 이어지거나, 큰 파일…

  • 2026.02.22

    View Transition API

    웹 애플리케이션에서 전환 품질은 기능 완성도와 동등한 수준으로 중요하다. 사용자가 목록에서 항목을 선택해 상세 화면으로 이동할 때, 화면이 자연스럽게 이어지면 서비스는 빠르고 안정적으로 느껴진다. 반대로 동일한 기능이라도 전환이 끊기면 체감 성능과 신뢰도는 동시에 하락한다. 전환은 부가…

  • 2026.04.11

    Trie 자료구조

    문자열 데이터를 다룰 때 단순히 “이 단어가 있나?”만으로는 부족한 순간이 있다. 자동 완성처럼 특정 접두사로 시작하는 후보를 모아야 할 때도 있고, 어떤 키에 값을 두고 빠르게 찾고 싶을 때도 있다. 이럴 때 가장 자연스럽게 떠올릴 수 있는 자료구조가 바로 Trie이다.Trie는 무엇…

  • 2026.03.19

    Streams API 부록 2. 왜 이미지는 위에서 아래로 나타날까

    웹 페이지에서 이미지를 로드할 때 흥미로운 장면을 종종 볼 수 있다. 이미지가 한 번에 완전히 나타나는 것이 아니라, 위에서 아래로 조금씩 채워지면서 나타나는 경우가 있기 때문이다. 특히 네트워크가 느리거나 이미지가 큰 경우 이런 현상이 더 분명하게 보인다. 마치 이미지가 위쪽부터 스캔…

댓글

댓글을 불러오는 중...