
복잡한 버그는 대개 거대한 기능이 아니라 사소한 데이터 변환 구간에서 시작된다. 문자열을 한 번 다듬고, 숫자를 한 번 바꾸고, 그 결과를 다음 단계로 넘기는 단순한 흐름이다. 그런데 조건이 조금씩 추가되는 순간 로직은 빠르게 복잡해진다. 값만 바꾸던 코드가 어느새 값의 부재, 비동기 처리, 실패 전파까지 함께 떠안기 시작한다.
이때부터 함수의 본래 목적은 흐려진다. “이 함수는 무엇을 계산하는가”보다 “어디서 실패하는가”가 먼저 눈에 들어온다. null 체크와 예외 처리, 비동기 체인이 핵심 로직을 감싸며 읽는 사람의 인지 부하를 높인다. 수정은 점점 보수적으로 변하고, 작은 변경도 두려워진다.
함수(Function), 펑터(Functor), 그리고 모나드(Monad)는 이런 상황을 설명하기 위한 추상적 장식이 아니다. 오히려 연산의 종류를 구분하기 위한 실용적인 틀이다. 지금 내가 값을 바꾸고 있는지, 컨텍스트 안의 값을 바꾸고 있는지, 아니면 컨텍스트 자체를 다시 만들고 있는지를 구분하게 해준다. 개념의 난이도보다 중요한 것은 디버깅 난이도를 낮추는 효과다.
프론트엔드 코드의 대부분은 변환이다. API 응답을 화면 모델로 바꾸고, 입력값을 정규화하고, 여러 옵션을 조합해 최종 상태를 만든다. 문제는 값이 항상 단독으로 존재하지 않는다는 점이다. 어떤 값은 없을 수 있고, 어떤 값은 아직 도착하지 않았으며, 어떤 값은 성공과 실패 정보와 함께 전달된다. 우리는 값뿐 아니라 값이 놓인 맥락까지 함께 다루고 있다.
여기서 연산의 층위가 섞이면 코드가 급격히 복잡해진다. 단순한 A → B 변환은 합성으로 깔끔하게 연결할 수 있다. 하지만 중간에 A → M<B> 형태가 끼어들면 이야기가 달라진다. 같은 규칙으로 연결하려는 순간 컨텍스트가 중첩되고, if 분기와 예외 처리, 비동기 로직이 한 함수 안에서 얽힌다. 무엇을 계산하는 코드인지, 어떤 실패를 다루는 코드인지 경계가 흐려진다.
이 구분이 없는 상태에서는 로그를 남겨도 원인을 좁히기 어렵다. 값 변환에서 실패한 것인지, 컨텍스트 생성 단계에서 어긋난 것인지, 비동기 경계에서 깨진 것인지 매번 추적해야 한다. 반대로 연산 층위가 분리되어 있으면 실패 지점을 구조적으로 특정할 수 있다. 디버깅은 추측이 아니라 확인 과정이 된다.
결국 핵심은 이 질문을 분명하게 만드는 것이다. 지금 바뀌는 것은 값인가, 값이 담긴 컨텍스트인가, 아니면 실패 경로를 포함한 전체 흐름인가. 함수, 펑터, 모나드는 이 질문을 더 명확한 언어로 바꿔준다. 이름은 낯설 수 있지만, 실제로는 우리가 이미 머릿속에서 구분하고 있던 연산을 정리해주는 도구에 가깝다.
해결 패턴 1: 함수
함수는 가장 기본적인 변환 모델이다. 입력과 출력의 관계를 명확히 정의하고, 같은 입력에 항상 같은 출력을 반환하도록 설계하면 예측 가능성이 크게 올라간다. 이는 단순한 스타일 문제가 아니라, 디버깅 가능성을 높이는 구조적 선택이다.
type RawProfile = {
displayName: string;
email: string;
};
type Profile = {
displayName: string;
email: string;
};
function normalizeDisplayName(value: string): string {
return value.trim();
}
function normalizeEmail(value: string): string {
return value.trim().toLowerCase();
}
function normalizeProfile(raw: RawProfile): Profile {
return {
displayName: normalizeDisplayName(raw.displayName),
email: normalizeEmail(raw.email),
};
}이 구조의 장점은 문제 발생 시 범위를 빠르게 좁힐 수 있다는 점이다. 출력이 기대와 다르면 어느 단계에서 값이 바뀌었는지 추적이 쉽다. 테스트 역시 입력과 출력만 검증하면 되므로 단위 테스트 설계가 단순해진다.
또한 책임 경계가 명확해지면 코드 리뷰 대화도 간결해진다. “이 함수는 정규화만 담당한다”처럼 역할이 분명하면, 취향이 아니라 설계 기준으로 논의할 수 있다. 이런 기준이 반복될수록 리팩터링 우선순위 역시 스스로 판단할 수 있게 된다.
초기에 자주 발생하는 문제는 한 함수에 너무 많은 책임을 넣는 것이다. 파싱, 검증, 포맷팅, 기본값 처리까지 한 번에 처리하면 함수 하나가 작은 서비스처럼 비대해진다.
function buildPriceLabel(input: string): string {
const parsed = Number.parseFloat(input.replace(/,/g, '').trim());
if (!Number.isFinite(parsed) || parsed < 0) {
return '가격 정보 없음';
}
const rounded = Math.round(parsed);
return `${rounded.toLocaleString('ko-KR')}원`;
}이 코드는 동작하지만, 어떤 단계에서 문제가 발생했는지 한눈에 드러나지 않는다. 파싱 실패인지, 검증 실패인지, 포맷팅 문제인지가 한 덩어리로 묶여 있다.
단계를 분리하면 구조가 훨씬 선명해진다.
function parseNumber(input: string): number | null {
const parsed = Number.parseFloat(input.replace(/,/g, '').trim());
return Number.isFinite(parsed) ? parsed : null;
}
function validateNonNegative(value: number): number | null {
return value < 0 ? null : value;
}
function formatCurrency(value: number): string {
return `${Math.round(value).toLocaleString('ko-KR')}원`;
}
function buildPriceLabel(input: string): string {
const parsed = parseNumber(input);
if (parsed === null) return '가격 정보 없음';
const validated = validateNonNegative(parsed);
if (validated === null) return '가격 정보 없음';
return formatCurrency(validated);
}해결 패턴 2: 펑터
펑터는 “컨텍스트를 유지한 채 내부 값만 바꾸는 구조”다. 이 정의가 추상적으로 느껴진다면 map을 떠올리면 된다. 배열의 map은 배열이라는 바깥 구조를 유지하면서 내부 요소만 변환한다. Promise.then 역시 비동기라는 컨텍스트를 유지한 채 결과 값만 바꾼다. 공통점은 연산 전후에 컨테이너 타입이 깨지지 않는다는 점이다.
핵심 계약은 단순하다. 연산이 끝난 뒤에도 같은 종류의 컨텍스트가 유지되는가. 값은 바뀌어도, 그 값이 담긴 구조는 그대로인가. 이 조건을 만족하면 우리는 내부 값 변환을 안전하게 합성할 수 있다.
type Product = {
id: string;
name: string;
price: number;
};
const products: Product[] = [
{ id: 'p1', name: 'Keyboard', price: 42000 },
{ id: 'p2', name: 'Mouse', price: 25000 },
];
const labels = products.map(
(product) => `${product.name} - ${product.price}원`,
);
// labels: string[]Product[]에 map을 적용하면 결과는 여전히 배열이다. 다만 내부 타입이 Product에서 string으로 바뀐다. 이 구조 덕분에 배열이라는 컨텍스트를 신경 쓰지 않고 값 변환에만 집중할 수 있다.
같은 아이디어를 Option에 적용하면 값의 존재 여부를 안전하게 유지할 수 있다.
type Option<T> = { kind: 'Some'; value: T } | { kind: 'None' };
function some<T>(value: T): Option<T> {
return { kind: 'Some', value };
}
function none<T>(): Option<T> {
return { kind: 'None' };
}
function mapOption<A, B>(option: Option<A>, f: (value: A) => B): Option<B> {
if (option.kind === 'None') return none<B>();
return some(f(option.value));
}
const nickname = some(' coconuts ');
const normalizedNickname = mapOption(nickname, (value) =>
value.trim().toUpperCase(),
);
// Option<string>여기서 중요한 점은 “값의 유무”라는 정보가 연산 과정에서 사라지지 않는다는 것이다. None이면 여전히 None이고, Some이면 내부 값만 변환된다. 성공 경로뿐 아니라 부재 경로까지 함께 보존된다.
실무에서 자주 발생하는 실수는 성공 경로에만 집중하는 것이다. 하지만 유지보수성을 결정하는 것은 오히려 실패·부재 경로다. 컨텍스트를 유지한다는 말은 성공을 예쁘게 만드는 것이 아니라, 실패를 잊지 않도록 강제하는 구조를 만든다는 뜻에 가깝다.
map이라는 이름은 같지만, 컨텍스트마다 의미는 다르다. 배열은 여러 항목을 변환하고, Promise는 비동기 결과를 변환하며, Option은 값 부재를 유지한다. 같은 형태의 연산처럼 보이지만, 보존되는 맥락이 다르다.
const numbers = [1, 2, 3].map((n) => n + 1); // number[]
const asyncValue = Promise.resolve(1).then((n) => n + 1); // Promise<number>
const optionalValue = mapOption(some(1), (n) => n + 1); // Option<number>이 차이를 인식하는 습관은 라이브러리를 학습할 때도 큰 도움이 된다. API 이름이 비슷하더라도, 어떤 컨텍스트를 유지하고 어떤 실패 모델을 전제하는지 먼저 보면 구조가 빠르게 읽힌다. 펑터는 새로운 개념을 추가하는 것이 아니라, 이미 사용하고 있는 연산을 더 정확히 분류하게 만드는 도구다.
해결 패턴 3: 모나드
펑터는 컨텍스트를 유지한 채 내부 값을 변환할 수 있게 해준다. 그러나 변환 함수가 다시 컨텍스트를 반환하는 순간, map만으로는 한계가 드러난다. 결과가 또 다른 컨텍스트에 감싸지면서 중첩이 생기기 때문이다.
예를 들어 Option<number>에 대해 안전한 나눗셈 함수를 적용하면, 결과 타입은 Option<Option<number>>처럼 겹쳐진다. 값의 부재를 표현하는 컨텍스트가 한 번 더 감싸지면서 이후 단계에서 다시 분기를 요구한다. 이 중첩을 평탄화하며 연산을 이어주는 도구가 flatMap이고, 이런 연결 방식을 일반화한 구조를 모나드라고 부른다.
type Option<T> = { kind: 'Some'; value: T } | { kind: 'None' };
function some<T>(value: T): Option<T> {
return { kind: 'Some', value };
}
function none<T>(): Option<T> {
return { kind: 'None' };
}
function mapOption<A, B>(option: Option<A>, f: (value: A) => B): Option<B> {
if (option.kind === 'None') return none<B>();
return some(f(option.value));
}
function reciprocal(value: number): Option<number> {
if (value === 0) return none<number>();
return some(1 / value);
}
const nested = mapOption(some(2), reciprocal);
// nested: Option<Option<number>>이 상태에서는 한 번 더 Option을 벗겨내야 한다. 중첩된 컨텍스트는 연산을 이어갈수록 코드의 복잡도를 키운다. 이를 해결하기 위해 flatMap을 사용한다.
type Option<T> = { kind: 'Some'; value: T } | { kind: 'None' };
function some<T>(value: T): Option<T> {
return { kind: 'Some', value };
}
function none<T>(): Option<T> {
return { kind: 'None' };
}
function flatMapOption<A, B>(
option: Option<A>,
f: (value: A) => Option<B>,
): Option<B> {
if (option.kind === 'None') return none<B>();
return f(option.value);
}
function parsePositiveInt(text: string): Option<number> {
const value = Number.parseInt(text, 10);
if (!Number.isFinite(value) || value <= 0) return none<number>();
return some(value);
}
function reciprocal(value: number): Option<number> {
if (value === 0) return none<number>();
return some(1 / value);
}
const result = flatMapOption(parsePositiveInt('8'), reciprocal);
// result: Option<number>flatMap은 내부 함수가 반환한 컨텍스트를 다시 감싸지 않고 그대로 이어준다. 그 결과, 실패 가능성이 있는 연산을 여러 단계로 연결하더라도 타입은 Option<number>처럼 한 층으로 유지된다. 중첩을 제거하는 동시에 실패 전파 규칙을 일관되게 유지하는 것이다.
모나드의 실무적 가치는 여기에 있다. 실패 가능성이 있는 연산을 표준 방식으로 연결할 수 있다는 점이다. 각 함수는 실패 가능성을 반환 타입으로 드러내고, 연결은 flatMap으로 통일한다. 이렇게 하면 코드 리뷰에서 실패 전파 경로를 빠르게 추적할 수 있고, 어디서 실패가 발생할 수 있는지 구조적으로 파악할 수 있다.
중요한 관점은 실패를 없애는 것이 아니라, 실패를 통제 가능한 형태로 이동시키는 것이다. 모나드적 연결은 실패를 숨기지 않는다. 대신 연산 파이프라인 안에서 예측 가능한 위치로 고정한다. 이 특성은 장애 분석과 사용자 메시지 설계에서도 일관성을 만든다.
비동기 코드에서도 같은 원리가 적용된다. Promise.then은 내부적으로 평탄화를 수행하기 때문에 Promise<Promise<T>>를 매번 수동으로 풀 필요가 없다.
function fetchUserName(userId: string): Promise<string> {
return Promise.resolve(userId === '1' ? 'Alice' : 'Guest');
}
function fetchGreeting(name: string): Promise<string> {
return Promise.resolve(`Hello, ${name}`);
}
const greetingPromise = fetchUserName('1').then((name) => fetchGreeting(name));
// Promise<string>실전 흐름: 함수 → 펑터 → 모나드를 한 번에 연결하기
앞서 본 개념을 실제 흐름에 맞춰 연결해보자. 사용자가 입력한 문자열에서 수량을 파싱하고, 재고를 검증한 뒤, 주문 가능 여부를 비동기로 계산하는 시나리오다. 이 과정에는 값 변환, 실패 가능성, 비동기 처리가 모두 등장한다.
type Option<T> = { kind: 'Some'; value: T } | { kind: 'None' };
function some<T>(value: T): Option<T> {
return { kind: 'Some', value };
}
function none<T>(): Option<T> {
return { kind: 'None' };
}
function mapOption<A, B>(option: Option<A>, f: (value: A) => B): Option<B> {
if (option.kind === 'None') return none<B>();
return some(f(option.value));
}
function flatMapOption<A, B>(
option: Option<A>,
f: (value: A) => Option<B>,
): Option<B> {
if (option.kind === 'None') return none<B>();
return f(option.value);
}
function parseQuantity(input: string): Option<number> {
const value = Number.parseInt(input.trim(), 10);
if (!Number.isFinite(value) || value <= 0) return none<number>();
return some(value);
}
function validateStock(quantity: number, stock: number): Option<number> {
if (quantity > stock) return none<number>();
return some(quantity);
}
function estimateDelivery(quantity: number): Promise<string> {
const days = quantity >= 5 ? 3 : 1;
return Promise.resolve(`${days}일 이내 도착`);
}
async function buildOrderPreview(
input: string,
stock: number,
): Promise<string> {
const normalized = mapOption(parseQuantity(input), (quantity) => quantity);
const valid = flatMapOption(normalized, (quantity) =>
validateStock(quantity, stock),
);
if (valid.kind === 'None') {
return '주문 불가: 수량 또는 재고를 확인하세요.';
}
const delivery = await estimateDelivery(valid.value);
return `주문 가능: ${valid.value}개 / 배송 ${delivery}`;
}이 흐름을 층위별로 나눠 보면 구조가 선명해진다.
먼저 함수 단계에서는 파싱과 검증 규칙이 분리되어 있다. parseQuantity는 문자열을 숫자로 바꾸고, validateStock은 재고 조건만 검사한다. 각 함수는 자신의 책임만 수행하며, 실패 가능성은 Option 타입으로 드러난다.
다음으로 펑터 단계에서는 mapOption이 컨텍스트를 유지한 채 내부 값을 다룬다. 값이 존재하면 변환하고, 존재하지 않으면 그대로 None을 유지한다. 여기서는 “값의 유무”라는 맥락이 연산 중간에 사라지지 않는다.
그 다음 모나드 단계에서는 flatMapOption이 등장한다. validateStock처럼 실패 가능성을 반환하는 함수와 연결할 때 중첩을 만들지 않고 한 층으로 유지한다. 실패 전파 규칙이 통일되기 때문에 분기 지점이 예측 가능해진다.
마지막으로 비동기 단계는 Promise가 이어받는다. 앞선 단계에서 이미 실패 여부가 정리되었기 때문에, 비동기 로직은 성공 경로에만 집중한다. 비동기와 실패 처리, 값 변환이 한 덩어리로 섞이지 않는다.
이 예제를 이해할 때는 전체를 한 번에 보려고 하지 않는 편이 낫다. 먼저 값이 어떻게 만들어지는지 본다. 그 다음 컨텍스트가 어떻게 유지되는지 확인한다. 이후 실패 가능 연산이 어떻게 평탄화되는지 살핀다. 마지막으로 비동기 결과가 어디에서 결합되는지 보면 흐름이 자연스럽게 이어진다.
핵심은 새로운 문법이 아니다. 지금 수행하는 연산이 어느 층위에 속하는지를 구분하는 습관이다. 값 변환, 컨텍스트 유지, 실패 전파, 비동기 경계를 분리하면 코드가 덜 복잡해 보이는 것이 아니라, 실제로 덜 복잡해진다.
실무 초반에 자주 하는 실수와 수정 포인트
개념을 이해해도 실무 코드에서는 모델이 다시 섞이기 쉽다. 특히 실패를 다루는 방식이 일관되지 않을 때 구조가 빠르게 흔들린다. 코드 리뷰에서 자주 보이는 패턴은 대개 다음과 같다.
map 단계에서 갑자기 throw를 사용해 컨텍스트 일관성을 깨뜨리는 경우, Option을 도입해놓고 중간에 다시 null을 섞어 모델을 이중화하는 경우, Promise.then 내부에서 try/catch를 중복해 실패 전파 경로를 흐리는 경우, 작은 변환까지 거대한 유틸 함수에 몰아넣어 테스트 범위를 불필요하게 키우는 경우다.
아래 코드는 여러 실패 모델이 한 흐름 안에서 섞인 예다.
function unsafeParse(input: string): number | null {
const value = Number.parseInt(input, 10);
if (!Number.isFinite(value)) {
throw new Error('잘못된 숫자');
}
return value;
}
function unsafeFlow(input: string): Promise<string> {
try {
const value = unsafeParse(input);
if (value === null) {
return Promise.resolve('값 없음');
}
return Promise.resolve(`값: ${value}`);
} catch {
return Promise.resolve('파싱 실패');
}
}여기서는 예외, null, Promise가 동시에 등장한다. 호출자는 이 함수가 어떤 방식으로 실패하는지 매번 추측해야 한다. 실패 모델이 고정되지 않으면 연산을 연결할수록 복잡도가 증가한다.
실패 모델을 하나로 고정하면 구조는 단순해진다.
type Option<T> = { kind: 'Some'; value: T } | { kind: 'None' };
function some<T>(value: T): Option<T> {
return { kind: 'Some', value };
}
function none<T>(): Option<T> {
return { kind: 'None' };
}
function safeParse(input: string): Option<number> {
const value = Number.parseInt(input, 10);
if (!Number.isFinite(value)) return none<number>();
return some(value);
}
async function safeFlow(input: string): Promise<string> {
const parsed = safeParse(input);
if (parsed.kind === 'None') return '파싱 실패';
return `값: ${parsed.value}`;
}이 버전에서는 실패가 Option으로 일관되게 표현된다. 예외를 던지지 않고, null도 사용하지 않는다. 호출자는 반환 타입만 보고 실패 가능성을 이해할 수 있다.
실무에서 가장 먼저 정해야 할 것은 “실패를 어디서, 어떤 형태로 표현할 것인가”다. 예외로 던질지, 값으로 반환할지, 비동기 거부로 전달할지를 먼저 합의하면 설계가 훨씬 단순해진다. 팀 내에서 이 합의가 깨지면 호출부가 불필요하게 복잡해지고, 디버깅 비용이 커진다.
트레이드오프와 적용 기준
함수, 펑터, 모나드 관점은 코드의 의도를 선명하게 만든다. 특히 데이터 파이프라인이 길어질수록 변환 규칙과 실패 전파 경로를 한눈에 설명하기 쉬워진다. 디버깅과 코드 리뷰가 구조 중심으로 이동한다는 점이 가장 큰 장점이다.
반면 단점도 있다. 팀이 공통 어휘를 갖추지 못한 상태에서 추상 용어를 먼저 도입하면 설명 비용이 구현 비용보다 커질 수 있다. 작은 문제에 과도한 모델을 적용하면 오히려 가독성이 떨어진다. 모든 코드를 모나드적으로 작성하는 것이 목표가 되어서는 안 된다.
그래서 도입은 보통 단계적으로 접근하는 편이 안전하다.
순수 함수 분리와 작은 합성부터 정착한다.
값 부재가 반복되는 구간에
Option과map을 도입한다.실패 연쇄가 많은 구간에
flatMap을 도입한다.비동기 연산은
Promise체인과async/await로 동일 모델을 유지한다.
핵심은 용어를 얼마나 아느냐가 아니라, 지금의 연산이 어떤 층위에 속하는지 구분할 수 있느냐이다. 값 변환인지, 컨텍스트 유지인지, 실패 연쇄인지가 구분되면 복잡한 코드를 다시 작은 단위로 분해할 수 있다. 버그 역시 훨씬 빠르게 좁혀진다.
결국 함수, 펑터, 모나드는 새로운 문법을 배우는 과정이 아니다. 코드를 읽고 설계할 때 사용할 좌표계를 세우는 과정이다. 이 좌표계가 생기면 기능 추가, 버그 수정, 코드 리뷰가 같은 기준 위에서 이루어진다. 그때부터 이 개념들은 이론이 아니라 실무 도구가 된다.
더 읽어보기
2026.03.04
JavaScript를 위한 더 나은 Streams API가 필요하다
이 포스트는 node.js의 코어 컨트리뷰트이며 Cloudflare Workers 팀 소속 개발자 James M Snell이 cloudflare 블로그에 올린 We deserve a better streams API for JavaScript 게시글을 번역한 것이다. 번역하는 과정에서…
2026.01.15
Static Hermes로 JavaScript를 C 코드로 컴파일하기
이 포스트는 parcel의 메인테이너 Devon Govett가 자신의 블로그에 올린 How to compile JavaScript to C with Static Hermes 게시글을 번역한 것이다. 번역하는 과정에서 다소 의역이 있을 수 있으며, 일부 번역에는 사견이 포함되어있기도 하다…
2026.01.03
CSS Color Functions
이 포스트는 Sunkanmi Fafowora가 css-tricks에 올린 CSS Color Functions 게시글을 번역한 것이다. 번역하는 과정에서 다소 의역이 있을 수 있으며, 일부 번역에는 사견이 포함되어있기도 하다.몇 달 전 누군가 저에게 “웹사이트가 돋보이려면 무엇이 필요할까…
2025.12.07
마약 조직으로 이해하는 책임 할당 패턴 GRASP
<갱단과의 전쟁>을 보다가 또 하나의 흥미로운 생각이 들었다. 마약 조직이라는 대상은 도덕적으로 정당화될 수 없지만, 구조적인 관점에서 보면 규모를 키우고 오래 버티기 위해 꽤 일관된 설계를 갖추고 있다는 점이다. 이들의 설계는 멋있어서가 아니라 생존을 위해 필연적으로 그렇게…
2026.04.11
Trie 자료구조
문자열 데이터를 다룰 때 단순히 “이 단어가 있나?”만으로는 부족한 순간이 있다. 자동 완성처럼 특정 접두사로 시작하는 후보를 모아야 할 때도 있고, 어떤 키에 값을 두고 빠르게 찾고 싶을 때도 있다. 이럴 때 가장 자연스럽게 떠올릴 수 있는 자료구조가 바로 Trie이다.Trie는 무엇…
2026.03.19
Streams API 부록 2. 왜 이미지는 위에서 아래로 나타날까
웹 페이지에서 이미지를 로드할 때 흥미로운 장면을 종종 볼 수 있다. 이미지가 한 번에 완전히 나타나는 것이 아니라, 위에서 아래로 조금씩 채워지면서 나타나는 경우가 있기 때문이다. 특히 네트워크가 느리거나 이미지가 큰 경우 이런 현상이 더 분명하게 보인다. 마치 이미지가 위쪽부터 스캔…
2026.03.19
Streams API 부록 1. HTTP 다운로드 진행률은 어떻게 계산될까
파일을 다운로드할 때 가끔 몇 퍼센트 진행되었는지 혹은 진행 막대(progress bar)가 조금씩 채워지는 모습을 볼 수 있다. 그런데 모든 다운로드가 이런 식으로 진행률을 보여 주는 것은 아니다. 어떤 다운로드는 퍼센트가 표시되지만, 어떤 경우에는 진행 막대 없이 로딩 스피너만 계속…
2026.03.13
Streams API 4. 왜 모든 언어에는 Stream API가 존재할까
Streams API를 공부하다 보면 묘한 기시감을 느끼게 된다. JavaScript에서 ReadableStream, WritableStream, TransformStream을 살펴보고 있는데, 어딘가 낯설지 않다. Java를 써 본 사람이라면 InputStream, OutputStre…
댓글
댓글을 불러오는 중...