0307 - 0313

0307

한꺼번에 객체 생성하기

객체를 생성할 때는 속성을 하나씩 추가하기보다는 여러 속성을 포함해서 한꺼번에 생성해야 타입 추론에 유리하다.
변수를 선언할 때 초기값을 기준으로 변수 타입이 추론되기 때문에 존재하지 않는 속성을 추가할 수는 없다.
이 문제는 객체를 한번에 정의하면 해결할 수 있다.
객체를 반드시 제각각 나눠서 만들어야 한다면, 타입 단언문을 사용해 타입 체커를 통과하게 할 수 있다.

const pt = {} as Point;
pt.x = 3;
pt.y = 4;

물론 이 경우도 선언할 때 한꺼번에 만드는 게 더 낫다.
작은 객체들을 조합해서 큰 객체를 만들어야 하는 경우에도 여러 단계를 거치는 것은 좋지 않은 생각이다.

해결법

스프레드 연산자 ...를 사용하면 큰 객체를 한꺼번에 만들어 낼 수 있다.
스프레드 연산자를 사용하면 타입 걱정 없이 필드 단위로 객체를 생성할 수도 있다.
이때 모든 업데이트마다 새 변수를 사용하여 각각 새로운 타입을 얻도록 하는 게 중요하다.

const pt0 = {};
const pt1 = { ...pt0, x: 3 };
const pt: Point = { ...pt1, y: 4 };

이 방법은 간단한 객체를 만들기 위해 우회하기는 했지만, 객체에 속성을 추가하고 타입스크립트가 새로운 타입을 추론할 수 있게 해 유용하다.

타입에 안전한 방식으로 조건부 속성을 추가하려면 속성을 추가하지 않는 null 또는 {}으로 객체 전개를 사용하면 된다.

declare let hasMiddle: boolean;
const firstLast = { first: "Harry", last: "Truman" };
const president = { ...firstLast, ...(hasMiddle ? { middle: "S" } : {}) };

전개 연산자로 한꺼번에 여러 속성을 추가할 수도 있다.

declare let hasDates: boolean;
const nameTitle = { name: "Khufu", title: "Pharaoh" };
const pharaoh = {
  ...nameTitle,
  ...(hasDates ? { start: -2589, end: -2566 } : {}),
};

이 때 책에서는 마치 이에 대한 결과로 pharaoh의 타입이 유니온 타입으로 나오는 것처럼 설명되어 있는데, 실제로는 선택적 필드로 결과가 도출된다.
아마도 유니온 타입으로 결과가 나올 경우 속성을 사용할 수 없다는 문제점이 발생해서 바뀐 것 같다.

책에서는 유니온 타입으로 결과가 나올 경우 선택적 필드 방식으로 표현할 수 있도록 지원하는 헬퍼 함수를 소개한다.

function addOptional<T extends object, U extends object>(
  a: T,
  b: U | null
): T & Partial<U> {
  return { ...a, ...b };
}

일관성 있는 별칭 사용하기

const borough = { name: "Brooklyn", location: [40.688, -73.979] };
const loc = borough.location;
  • borough.location 배열에 loc이라는 별칭을 만들었다. 별칭의 값을 변경하면 원래 속성값에서도 변경된다.
  • 별칭을 남발하면 제어 흐름을 분석하기 어렵다. 타입스크립트에서도 별칭을 신중하게 사용해야 코드를 잘 이해할 수 있고, 오류도 쉽게 찾을 수 있다.
interface Coordinate {
  x: number;
  y: number;
}

interface BoundingBox {
  x: [number, number];
  y: [number, number];
}

interface Polygon {
  exterior?: Coordinate[];
  holes?: Coordinate[][];
  bbox?: BoundingBox;
}

const polygon: Polygon = {
  exterior: [],
  holes: [],
};

function isPointInPolygon(polygon: Polygon, pt: Coordinate) {
  const box = polygon.bbox;
  if (polygon.bbox) {
    if (
      pt.x < box.x[0] ||
      pt.x > box.x[1] ||
      pt.y < box.y[0] ||
      pt.y > box.y[1]
    ) {
      return false;
    }
  }
}
  • 이 코드는 polygon.bbox를 여러 부분에서 반복해서 사용하는데, 이를 변수로 뽑아내서 사용하는 것이다.
  • 위 코드에서는 오류가 발생하는데, box 가 undefined일 수 있다는 것이다.
  • 그 이유는 기본적으로 polygon.bbox는 BoundingBox 타입이거나 undefined로 추론되는데, 속성 체크는 polygon.bbox의 타입을 정제했지만 box는 그렇지 않았기 때문이다.
  • 별칭을 일관성 있게 사용한다는 기본 원칙을 지키면 이를 방지할 수 있다.
  • 객체 비구조화 할당을 이용하면 보다 간결한 문법으로 일관된 이름을 사용할 수 있다.

객체 비구조화 할당 사용 시 주의점

  • bbox 속성이 아니라 x와 y가 선택적 속성일 경우에는 속성 체크가 더 필요하다. 따라서 타입 경계에 null 값을 추가하는 것이 좋다.
  • bbox에는 객체이므로 선택적 속성이 적합했지만 holes는 배열이기에 그렇지 않다. holes가 선택적이었다면, 값이 없거나 빈 배열이었을 것인데 값을 없게 하는 것보다 빈 배열로 두는 것이 값이 없음을 나타내기에 더 좋은 방법이다.
  • 함수 호출은 객체 속성의 타입 정제를 무효화할 수 있다는 점을 주의해야 한다. 속성보다 지역 변수를 사용하면 타입 정제를 믿을 수 있다.

비동기 코드에는 콜백 대신 async 함수 사용하기

콜백보다는 프로미스가 코드를 작성하기 쉽고, 타입을 추론하기 쉽기에 프로미스나 async/await를 사용해야 한다.
예를 들어, 병렬로 페이지를 로드하고 싶다면 Promise.all을 사용해서 프로미스를 조합하면 된다.

async function fetchPages() {
  const [response1, response2, response3] = await Promise.all([
    fetch(url1),
    fetch(url2),
    fetch(url3),
  ]);
}
  • 이런 경우 await와 구조 분해 할당이 잘 어울린다.
  • 타입스크립트는 response 변수 각각의 타입을 Response로 추론한다.
  • 콜백 스타일로 동일한 코드를 작성하려면 더 많은 코드와 타입 구문이 필요하다.

프로미스를 직접 생성해야 할 때, 선택의 여지가 있다면 일반적으로 프로미스를 생성하기보다는 async/await를 사용해야 한다.

  • 일반적으로 더 간결하고 직관적인 코드가 된다.
  • async 함수는 항상 프로미스를 반환하도록 강제된다.
  • 함수는 항상 동기 또는 비동기로 실행되어야 하며 절대 혼용해서는 안 된다.
    • async를 사용하면 항상 비동기 코드를 작성하는 셈이 되어 일관적인 동작을 강제하게 된다.




0308

커넥션 관리

TCP 커넥션

모든 HTTP 통신은 패킷 교환 네트워크 프로토콜들의 계층화된 집합인 TCP/IP를 통해 이루어진다.
세계 어디서든 클라이언트 애플리케이션은 서버 애플리케이션으로 TCP/IP 커넥션을 맺을 수 있다.
커넥션이 맺어지면 클라이언트와 서버 컴퓨터 간에 주고받는 메시지들은 손실 혹은 손상되거나 순서가 바뀌지 않고 안전하게 전달된다.

URL을 입력받은 브라우저는 다음과 같은 단계로 요청과 응답이 일어난다.

  1. 브라우저가 호스트 명을 추출한다.
  2. 브라우저가 호스트 명에 대한 IP 주소와 포트 번호를 얻는다.
  3. 브라주저가 IP의 포트 번호로 TCP 커넥션을 생성한다.
  4. 요청과 응답이 일어난다.
  5. 브라우저가 커넥션을 끊는다.

신뢰할 수 있는 데이터 전송 통로인 TCP

  • HTTP 커넥션은 몇몇 사용 규칙을 제외하고는 TCP 커넥션에 불과하다.
  • TCP 커넥션은 인터넷을 안정적으로 연결해준다.
  • TCP 커넥션의 한쪽에 있는 바이트들은 반대쪽으로 순서에 맞게 정확히 전달된다.

TCP 스트림은 세그먼트로 나뉘어 IP 패킷을 통해 전송된다

  • TCP는 IP 패킷이라고 불리는 작은 조각을 통해 데이터를 전송한다.
  • HTTP가 메시지를 전송하고자 할 경우, 현재 연결되어 있는 TCP 커넥션을 통해서 메시지 데이터의 내용을 순서대로 보낸다.
  • TCP는 세그먼트라는 단위로 데이터 스트림을 잘게 나누고, 세그먼트를 IP 패킷이라고 불리는 봉투에 담아서 인터넷을 통해 데이터를 전달한다.

IP 패킷은 다음과 같은 정보를 포함한다.

  • IP 패킷 헤더(보통 20 바이트)
  • TCP 세그먼트 헤더(보통 20 바이트)
  • TCP 데이터 조각(0 혹은 그 이상의 바이트)

TCP 커넥션 유지

  • 컴퓨터는 항상 TCP 커넥션을 여러 개 가지고 있다. TCP는 포트 번호를 통해서 이런 여러 개의 커넥션을 유지한다.
  • IP 주소는 해당 컴퓨터에 연결되고, 포트 번호는 해당 애플리케이션으로 연결된다.
  • TCP 커넥션은 네 가지 값으로 식별한다.
    • <발신지 IP, 발신지 포트, 수신자 IP, 수신자 포트>
    • 이는 유일한 값으로 서로 다른 두 개의 TCP 커넥션은 값이 모두 같을 수 없다.

TCP 소켓 프로그래밍

운영체제는 TCP 커넥션의 생성과 관련된 여러 기능을 제공한다.
소켓 API는 유닉스 운영체제용으로 먼저 개발되었지만, 지금의 소켓 API의 다양한 구현체들 덕분에 대부분의 운영체제와 프로그램 언어에서 이를 사용할 수 있게 되었다.
소켓 API를 사용하면, TCP 종단 데이터 구조를 생성하고, 원격 서버의 TCP 종단에 그 종단 데이터 구조를 연결하여 데이터 스트림을 읽고 쓸 수 있다.
TCP API는, 기본적인 네트워크 프로토콜의 핸드셰이킹, 그리고 TCP 데이터 스트림과 IP 패킷 간의 분할 및 재조립에 대한 모든 세부사항을 외부로부터 숨긴다.

TCP의 성능에 대한 고려

HTTP는 TCP 바로 위의 계층이기 때문에 HTTP 트랜잭션의 성능은 그 아래 계층인 TCP 성능에 영향을 받는다.
이를 이해하려면 TCP 프로토콜 내부를 자세히 알아야 한다.

HTTP 트랜잭션 지연

클라이언트나 서버가 너무 많은 데이터를 내려받거나 복잡하고 동적인 자원들을 실행하지 않는 한, 대부분의 HTTP 지연은 TCP 네트워크 지연 때문에 발생한다.

HTTP 트랜잭션을 지연시키는 원인

  1. 클라이언트는 URI에서 웹 서버의 IP 주소와 포트 번호를 알아내야 하는데, URI에 기술되어 있는 호스트에 방문한 적이 최근에 없으면 DNS 이름 분석 인프라를 사용하여 호스트 명을 IP 주소로 변환하는데 시간이 소요된다.
  2. 다음으로, 클라이언트는 TCP 커넥션 요청을 서버에게 보내고 서버가 커넥션 허가 응답을 회신하기를 기다린다. 수백 개의 HTTP 트랜잭션이 만들어지면 소요시간은 크게 증가할 것이다.
  3. 요청 메시지가 인터넷을 통해 전달되고 서버에 의해 처리 후, HTTP 응답을 보내는 데에도 시간이 소요된다.

성능 관련 중요 요소

가장 일반적인 TCP 관련 지연

  • TCP 커넥션의 핸드셰이크 설정
  • 인터넷의 혼잡을 제어하기 위한 TCP의 느린 시작
  • 데이터를 한데 모아 한 번에 전송하기 위한 네이글 알고리즘
  • TCP의 편승(피기백) 확인응답을 위한 확인응답 지연 알고리즘
  • TIME_WAIT 지연과 포트 고갈

TCP 커넥션 핸드셰이크 지연

새로운 TCP 커넥션을 열 때면, TCP 소프트웨어는 커넥션을 맺기 위한 조건을 맞추기 위해 연속으로 IP 패킷을 교환한다.
작은 크기의 데이터 전송에 커넥션이 사용된다면 이는 HTTP 성능을 저하시킬 수 있다.

핸드셰이크 과정

  1. 클라이언트는 새로운 TCP 커넥션을 생성하기 위해 작은 TCP 패킷을 서버에게 보낸다. 그 패킷은 SYN이라는 특별한 플래그를 가지는데, 이 요청이 커넥션 생성 요청이라는 뜻이다.
  2. 서버가 커넥션을 받으면 커넥션 요청이 받아들여졌음을 의미하는 SYN과 ACK 플래그를 포함한 TCP 패킷을 클라이언트에게 보낸다.
  3. 마지막으로 클라이언트는 커넥션이 잘 맺어졌음을 알리기 위해 서버에게 확인응답 신호를 보낸다.

즉, 크기가 작은 HTTP 트랜잭션은 50% 이상의 시간을 TCP를 구성하는 데 쓴다.

확인 응답 지연

인터넷 자체가 패킷 전송을 완벽히 보장하지 않기 때문에 TCP는 성공적인 데이터 전송을 보장하기 위해 자체적인 확인 체계를 가진다.
각 TCP 세그먼트는 순번과 데이터 무결성 체크섬을 가진다. 세그먼트 수신자는 세그먼트를 온전히 받으면 작은 확인응답 패킷을 송신자에게 반환한다. 만약 송신자가 특정 시간 안에 확인응답 메시지를 받지 못하면 패킷이 파기되었거나 오류가 있는 것으로 판단하고 데이터를 다시 전송한다.

확인응답은 크기가 작기 때문에, TCP는 같은 방향으로 송출되는 데이터 패킷에 확인응답을 편승시킨다. TCP는 송출 데이터 패킷과 확인응답을 하나로 묶음으로써 네트워크를 좀 더 효율적으로 사용한다.
확인응답이 같은 방향으로 가는 데이터 패킷에 편승되는 경우를 늘리기 위해서, 많은 TCP 스택은 확인 응답 지연 알고리즘을 구현한다. 확인응답 지연은 송출한 확인응답을 특정 시간 동안 버퍼에 저장해 두고, 확인응답을 편승시키기 위한 송출 데이터 패킷을 찾는다. 만약 일정 시간 안에 송출 데이터 패킷을 찾지 못하면 확인응답은 별도 패킷을 만들어 전송된다.

편승한 패킷을 찾으려고 하면 해당 방향으로 송출될 패킷이 많지 않기 때문에, 확인응답 지연 알고리즘으로 인한 지연이 자주 발생한다.
운영체제에 따라 다르지만, 지연의 원인이 되는 확인응답 지연 관련 기능을 수정하거나 비활성화할 수 있다.

TCP 느린 시작

TCP 커넥션은 시간이 지나면서 자체적으로 튜닝되어서, 처음에는 최대 속도를 제한하고 데이터가 성공적으로 전송됨에 따라 속도 제한을 높여나간다. 이렇게 조율하는 것을 TCP 느린 시작이라고 부르며, 인터넷의 급작스러운 부하와 혼잡을 방지하는 데 쓰인다.

  • TCP가 한 번에 전송할 수 있는 패킷의 수를 제한한다.
    • 패킷이 성공적으로 전달되는 각 시점에 송신자는 추가로 2개의 패킷을 더 전송할 수 있는 권한을 얻는다.
  • HTTP 트랜잭션에서 전송할 데이터 양이 많으면 모든 패킷을 한 번에 전송할 수 없다. 한 개의 패킷만 전송하고 확인응답을 받으면 2개의 패킷, 이런식으로 점차 늘려가게 된다. 이를 혼잡 윈도를 연다라고 한다.
  • 이 혼잡제어 기능 때문에, 새로운 커넥션은 이미 어느 정도 데이터를 주고 받은 튜닝된 커넥션보다 느리다.

네이글 알고리즘과 TCP_NODELAY

  • 네이글 알고리즘은 네트워크 효율을 위해서, 패킷을 전송하기 전에 많은 양의 TCP 데이터를 한 개의 덩어리로 합친다.
  • 세그먼트가 최대 크기(LAN 상에서 1,500 바이트 정도, 인터넷상에서는 수백 바이트 정도다)가 되지 않으면 전송을 하지 않는다.
  • 다만 다른 모든 패킷이 확인응답을 받았을 경우에는 최대 크기보다 작은 패킷의 전송을 허락한다.
  • 다른 패킷들이 아직 전송 중이면 데이터는 버퍼에 저장된다.
  • 전송되고 나서 확인응답을 기다리던 패킷이 확인응답을 받았거나 전송하기 충분할 만큼의 패킷이 쌓였을 때 버퍼에 저장되어 있던 데이터가 전송된다.

네이글 알고리즘은 HTTP 성능 관련해 여러 문제를 발생시킨다.

  1. 크기가 작은 HTTP 메시지는 패킷을 채우지 못하기 때문에, 앞으로 생길지 생기지 않을지 모르는 추가적인 데이터를 기다리며 지연된다.
  2. 네이글 알고리즘은 확인응답 지연과 함께 쓰일 경우 형편없이 동작한다. 네이글 알고리즘은 확인 응답이 도착할 때까지 데이터를 멈추고 있는 반면, 확인응답 지연 알고리즘은 확인응답을 100~200밀리초 지연시킨다.

HTTP 애플리케이션은 성능 향상을 위해 HTTP 스택에 TCP_NODELAY 파라미터 값을 설정하여 네이글 알고리즘을 비활성화하기도 한다.
이 설정을 하면 작은 크기의 패킷이 너무 많이 생기지 않도록 큰 크기의 데이터 덩어리를 만들어야 한다.

TIME_WAIT의 누적과 포트 고갈

TCP 커넥션의 종단에서 TCP 커넥션을 끊으면, 종단에서는 커넥션의 IP 주소와 포트 번호를 메모리의 작은 제어영역에 기록해 놓는다.
이 정보는 같은 주소와 포트 번호를 사용하는 새로운 TCP 커넥션이 일정 시간 동안에는 생성되지 않게 하기 위한 것으로, 보통 세그먼트의 최대 생명주기의 두 배 정도(2MSL이라 불리며 보통 2분 정도)의 시간 동안만 유지된다.
실제로 이 알고리즘은 특정 커넥션이 생성되고 닫힌 다음, 그와 같은 IP 주소와 포트 번호를 가지는 커넥션이 2분 이내에 또 생성되는 것을 막아준다.

일반적으로 2MSL의 커넥션 종료 지연이 문제가 되지는 않지만, 성능시험을 하는 상황에서는 문제가 될 수 있다.
성능 측정 대상 서버는 클라이언트가 접속할 수 있는 IP 주소의 개수를 제한하고, 그 서버에 접속하여 부하를 발생시킬 컴퓨터의 수는 적기 때문이다.
TIME_WAIT로 인해 순간순간 포트를 재활용하는 것이 불가능해지고, 유일한 커넥션을 생성하기 위해 발신지 포트만 변경할 수 있으므로 포트 고갈이 발생한다.




0309

OSI 7 계층

이전에 공부했던 핵심 cs 개념들을 위주로 복습하고자 정리하게 되었다.

웹사이트에서 원하는 정보를 얻기 위해 웹브라우저에 주소를 쳐서 들어갈 때, 인터넷이 연결된 환경에서 주소를 입력하면 총 7단계를 걸쳐 해당 정보가 있는 지점에 도달하게 된다. 이 각각의 계층을 OSI 7계층이라고 한다.

1계층 - Physical Layer

  • 0과 1의나열을 아날로그 신호로 바꾸어 전선을 흘려 보내고(인코딩), 아날로그 신호가 들어오면 0과 1의 나열로 해석하여(디코딩) 물리적으로 연결된 두 대의컴퓨터가 0과 1의 나열을 주고받을 수 있게 해주는 모듈
  • 1계층 모듈은 하드웨어적으로 구현되어 있다.

1계층에 속하는 기술만으로는 여러 대의 컴퓨터가 통신할 수 없다.
전 세계의 컴퓨터들을 연결한 것을 인터넷이라고 한다.

  • 같은 네트워크에 있는 여러 대의 컴퓨터들이 데이터를 주고받기 위해 필요한 모듈
  • Framing은 Data-link Layer에 속하는 작업들 중 하나이다.
    • Framing - 0과 1로 이루어진 데이터를 끊어 읽기 위해 데이터의 앞 뒤에 특정한 비트열을 붙이는 작업
  • LAN 카드에 이와 같은 기술이 구현되어 있다. 1계층과 마찬가지로 하드웨어적으로 구현.

3계층 - Network Layer

  • 수많은 네트워크들의 연결로 이루어지는 inter-network 속에서 어딘가에 있는 목적지 컴퓨터로 데이터를 전송하기 위해 IP 주소를 이용해서 길을 찾고(라우팅) 자신 다음의 라우터에게 데이터를 넘겨주는 것
  • 운영체제의 커널에 소프트웨어적으로 구현되어 있다.

4계층 - Transport Layer

  • 포트 번호를 사용해서 도착지 컴퓨터의 최종 도착지인 프로세스에까지 데이터가 도달하게 하는 모듈
  • 운영체제의 커널에 소프트웨어적으로 구현되어 있다.

5계층 - Application Layer

현대의 인터넷은 기존 OSI Layer 7 Model이 아닌 TCP/IP 모델을 따르고 있다.
이에 따라 기존 Application, Presentation, Session Layer들은 Application Layer하나로 합쳐지게 되었다.

대표적인 프로토콜로는 HTTP가 존재한다.




0310

HTTP 커넥션 관리

흔히 잘못 이해하는 Connection 헤더

HTTP는 클라이언트와 서버 사이에 프락시 서버, 캐시 서버 등과 같은 중개 서버가 놓이는 것을 허락한다.
어떤 경우에는, 두 개의 인접한 HTTP 애플리케이션이 현재 맺고 있는 커넥션에만 적용될 옵션을 지정해야 할 때가 있다.
HTTP Connection 헤더 필드는 커넥션 토큰을 쉼표로 구분하여 가지고 있으며, 그 값들은 다른 커넥션에 전달되지 않는다.

Connection 헤더에는 다음 세 가지 종류의 토큰이 전달될 수 있다.

  • HTTP 헤더 필드 명은, 이 커넥션에만 해당되는 헤더들을 나열한다.
  • 임시적인 토큰 값은, 커넥션에 대한 비표준 옵션을 의미한다.
  • close 값은, 커넥션이 작업이 완료되면 종료되어야 함을 의미한다.

커넥션 토큰이 HTTP 헤더 필드 명을 가지고 있으면, 해당 필드들은 현재 커넥션만을 위한 정보이므로 다음 커넥션에 전달하면 안된다.
Connection 헤더에 있는 모든 헤더 필드는 메시지를 다른 곳으로 전달하는 시점에 삭제되어야 한다.
Connection 헤더에는 홉별 헤더 명을 기술하는데, 이것을 헤더 보호하기라고 한다. 홉은 각 서버를 의미하며 홉별은 특정 두 서버간에만 영향을 미치고 다른 서버 간에는 영향을 미치지 않음을 뜻한다.

순차적인 트랜잭션 처리에 의한 지연

커넥션 관리가 제대로 이루어지지 않으면 TCP 성능이 매우 안 좋아질 수 있다.
예를 들어 3개의 이미지가 있는 웹페이지가 있다고 가정했을 때, 각 트랜잭션이 새로운 커넥션을 필요로 한다면, 커넥션을 맺는데 발생하는 지연과 함께 느린 시작 지연이 발생할 것이다.
순차적 로드 방식의 또 다른 단점은, 특정 브라우저의 경우 객체를 화면에 배치하려면 객체의 크기를 알아야 하기 때문에, 모든 객체를 내려받기 전까지는 텅 빈 화면을 보여준다는 것이다.

HTTP 커넥션 성능 향상 기술

  • 병렬 커넥션 - 여러 개의 TCP 커넥션을 통한 동시 HTTP 요청
  • 지속 커넥션 - 커넥션을 맺고 끊는 데서 발생하는 지연을 제거하기 위한 TCP 커넥션의 재활용
  • 파이프라인 커넥션 - 공유 TCP 커넥션을 통한 병렬 HTTP 요청
  • 다중 커넥션 - 요청과 응답들에 대한 중재

병렬 커넥션

HTTP는 클라이언트가 여러 개의 커넥션을 맺음으로써 여러 개의 HTTP 트랜잭션을 병렬로 처리할 수 있게 한다.

  • 병렬 커넥션은 페이지를 더 빠르게 내려받는다.
    • 각 커넥션의 지연 시간을 겹치게 하면 총 지연 시간을 줄일 수 있고, 클라이언트의 인터넷 대역폭을 한 개의 커넥션이 다 써버리는 것이 아니라면 나머지 객체를 내려받는 데에 남은 대역폭을 사용할 수 있다.
  • 병렬 커넥션이 항상 더 빠르지는 않다.
    • 클라이언트의 네트워크 대역폭이 좁을 때는 대부분 시간을 데이터를 전송하는 데만 쓸 것이기에, 제한된 대역폭 내에서 각 객체를 전송받는 것은 느리기 때문에 성능상의 장점은 거의 없어진다.
    • 또한 다수의 커넥션은 메모리를 많이 소모하고 자체적인 성능 문제를 발생시킨다.
    • 브라우저는 실제로 병렬 커넥션을 사용하긴 하지만 적은 수의 병렬 커넥션만을 허용한다.
  • 병렬 커넥션은 더 빠르게 느껴질 수 있다.
    • 병렬 커넥션이 실제로 페이지를 더 빠르게 내려받지 않더라도 사용자는 여러 개의 객체가 동시에 보이면서 내려받고 있는 상황을 볼 수 있기 때문에 더 빠르게 내려받고 있는 것처럼 느낄 수 있다.

지속 커넥션

사이트 지역성
웹 클라이언트는 보통 같은 사이트에 여러 개의 커넥션을 맺고, 요청하게 되는 속성

-> 따라서 처리가 완료된 후 TCP 커넥션을 유지하여 앞으로 있을 HTTP 요청을 재사용할 수 있다. 이러한 커넥션을 지속 커넥션이라고 부른다.
이를 통해 커넥션을 맺기 위한 준비작업에 따르는 시간을 절약할 수 있다. 그리고 느린 시작으로 인한 지연을 피함으로써 더 빠르게 데이터를 전송할 수 있다.

지속 커넥션 vs 병렬 커넥션

병렬 켜넥션의 단점

  • 각 트랜잭션마다 새로운 커넥션을 맺고 끊기 때문에 시간과 대역폭이 소요된다.
  • 각각 새로운 커넥션은 TCP 느린 시작 때문에 성능이 떨어진다.
  • 실제로 연결할 수 있는 병렬 커넥션의 수에는 제한이 있다.

지속 커넥션의 장점

  • 커넥션을 맺기 위한 사전 작업과 지연을 줄여주고, 커넥션의 수를 줄여준다.
  • 튜닝된 커넥션을 유지하여 성능이 좋다.
  • 하지만 잘못 관리할 경우, 계속 연결된 상태로 있는 수많은 커넥션이 쌓이게 되어 불필요한 소모를 발생시킬 수 있다.

지속 커넥션은 병렬 커넥션과 함께 사용할 때 가장 효과적이다.
웹 애플리케이션은 적은 수의 병렬 커넥션만을 맺고 그것을 유지한다.
지속 커넥션 타입에는 ‘keep-alive’ 커넥션과, HTTP/1.1의 지속 커넥션이 있다.

HTTP/1.0+의 Keep-Alive 커넥션

HTTP/1.0 keep-alive 커넥션을 구현한 클라이언트는 커넥션을 유지하기 위해서 요청에 Connection:Keep-Alive 헤더를 포함시킨다.
이 요청을 받은 서버는 그다음 요청도 이 커넥션을 통해 받고자 한다면, 응답 메시지에 같은 헤더를 포함시켜 응답한다.
이 헤더가 없으면, 클라이언트는 서버가 keep-alive를 지원하지 않으며, 응답 메시지가 전송되고 나면 서버 커넥션을 끊을 것이라 추정한다.

Keep-Alive 옵션

keep-alive의 동작은 Keep-Alive 헤더의 쉼표로 구분된 옵션들로 제어할 수 있다.

  • timeout 파라미터는 Keep-Alive 응답 헤더를 통해 보낸다. 커넥션이 얼마간 유지될 것인지를 의미하지만 이대로 동작한다는 보장은 없다.
  • max 파라미터는 Keep-Alive 응답 헤더를 통해 보낸다. 이는 커넥션이 몇 개의 HTTP 트랜잭션을 처리할 때까지 유지될 것인지를 의미하지만 이대로 동작한다는 보장은 없다.
  • Keep-Alive 헤더는 진단이나 디버깅을 주목적으로 하는, 처리되지는 않는 임의의 속성들을 지원하기도 한다.

Keep-Alive 커넥션 제한과 규칙

  • keep-alive는 HTTP/1.0에서 기본으로 사용되지는 않기에 요청 헤더를 보내야 한다.
  • 커넥션을 계속 유지하려면 모든 메시지에 해당 헤더를 보내야 한다.
  • 커넥션이 끊어지기 전에 엔터티 본문의 길이를 알 수 있어야 커넥션을 유지할 수 있다.
  • keep-alive 커넥션은 Connection 헤더를 인지하지 못하는 프락시 서버와는 맺어지면 안 된다.

Keep-Alive와 멍청한(dumb) 프락시

Connection 헤더의 무조건 전달
프락시는 Connection 헤더를 이해하지 못해서 해당 헤더들을 삭제하지 않고 요청 그대로를 다음 프락시에 전달한다.

멍청한 프락시 거치는 예시

  1. 클라이언트는 프락시에 Connection: Keep-Alive 헤더와 함께 메시지를 보내고, 커넥션을 유지하기를 요청한다.
  2. 멍청한 프락시는 Connection 헤더를 이해하지 못한다. 그리고 다음 서버에 메시지를 그대로 전달한다.
  3. 서버는 헤더를 받았기에, 커넥션을 유지하자고 요청하는 것으로 잘못 판단하게 된다. 그리고 마찬가지로 응답 헤더를 보내준다.
  4. 다시 멍청한 프락시는 클라이언트에 헤더를 포함시켜 응답 메시지를 전달한다. 클라이언트는 프락시가 커넥션을 유지하는 것에 동의했다고 추정한다.
  5. 프락시는 서버가 커넥션을 끊기를 기다리고, 서버는 커넥션을 끊지 않는다.
  6. 다음 요청이 시작되면 프락시는 같은 커넥션 상에서 다른 요청이 오는 경우를 예상하지 못했기에 요청이 무시되고 타임아웃이 나서 커넥션이 끊길 때까지 기다린다.

-> 이런 종류의 잘못된 통신을 피하려면, 프락시는 Connection 헤더와 Connection 헤더에 명시된 헤더들은 절대 전달하면 안된다.
그리고 홉별 헤더들 역시 전달하거나 캐시하면 안 된다.

Proxy-Connection

Proxy-Connection 헤더는 모든 헤더를 무조건 전달하는 문제를 해결하기 위해 나온 헤더이다.
멍청한 프락시는 홉별 헤더를 무조건 전달하기 때문에 문제를 일으킨다.
Connection 헤더 대신 Proxy-Connection 헤더를 사용한다면 멍청한 프락시의 경우 서버에 Proxy-Connection를 그대로 보내 서버가 그것을 무시하기 때문에 문제가 발생하지 않는다. 영리한 프락시라면, Proxy-Connection헤더를 Connection 헤더로 바꿈으로써 원하던 효과를 얻을 수 있다.
이 방식은 클라이언트 서버 사이에 한 개의 프락시만 있는 경우에 동작한다. 멍청한 프락시 양옆에 영리한 프락시가 있다면 잘못된 헤더를 만들어내는 문제가 다시 발생한다.

HTTP/1.1의 지속 커넥션

HTTP/1.1의 지속 커넥션은 기본으로 활성화되어 있다.
트랜잭션이 끝난 다음 커넥션을 끊으려면 Connection: close 헤더를 명시해야 한다.

  • 클라이언트가 요청에 Connection: close 헤더를 포함해 보냈으면, 클라이언트는 그 커넥션으로 추가 요청을 보낼 수 없다.
  • 커넥션에 있는 모든 메시지가 자신의 길이 정보를 정확히 가지고 있을 때에만 커넥션을 지속시킬 수 있다.
  • HTTP/1.1 프락시는 클라이언트와 서버 각각에 대해 별도의 지속 커넥션을 맺고 관리해야 한다.
  • HTTP/1.1 기기는 Connection 헤더의 값과는 상관없이 언제든지 커넥션을 끊을 수 있다.
  • 클라이언트는 전체 응답을 받기 전에 커넥션이 끊어지면, 요청을 반복해서 보내도 문제가 없는 경우에는 요청을 다시 보낼 준비가 되어 있어야 한다.
  • 하나의 사용자 클라이언트는 서버 과부하를 방지하기 위해, 넉넉잡아 두 개의 지속 커넥션만을 유지해야 한다.

파이프라인 커넥션

지속 커넥션을 통해서 요청을 파이프라이닝할 수 있다.
여러 개의 요청은 응답이 도착하기 전까진 큐에 쌓인다.
첫 번째 요청이 네트워크를 통해 지구 반대편에 있는 서버로 전달되면, 이어 두 번째와 세 번째 요청이 전달될 수 있다.
이는 대기 시간이 긴 네트워크상의 왕복으로 인한 시간을 줄여서 성능을 높여준다.

  • 클라이언트는 커넥션이 지속 커넥션인지 확인하기 전까지는 파이프라인을 이어서는 안 된다.
  • 응답은 요청 순서와 같게 와야 한다. HTTP 메시지는 순번이 매겨져 있지 않아서 응답이 순서 없이 오면 순서에 맞게 정렬시킬 방법이 없다.
  • 커넥션이 언제 끊어지더라도, 완료되지 않은 요청이 파이프라인에 있다면 언제든 다시 요청을 보낼 준비가 되어 있어야 한다.
  • 클라이언트는 POST 요청같이 반복해서 보낼 경우 문제가 생기는 요청은 파이프라인을 통해 보내면 안 된다. POST 같은 비멱등 요청을 재차 보내면 문제가 생길 수 있기 때문에, 이러한 메서드로 요청을 보내서는 안 된다.

커넥션 끊기에 대한 미스터리

커넥션 관리(특히 언제 커넥션을 끊는가)에는 명확한 기준이 없다.

마음대로 커넥션 끊기

어떠한 HTTP 클라이언트, 서버, 프락시든 언제든지 TCP 전송 커넥션을 끊을 수 있다.
보통 메시지를 다 보낸 다음 끊지만, 에러가 있는 상황에서는 헤더의 중간이나 다른 엉뚱한 곳에서 끊길 수 있다.

Content-Length와 Truncation

HTTP 응답은 본문의 정확한 크기 값을 가지는 Content-Length 헤더를 가지고 있어야 한다.
클라이언트나 프락시의 커넥션이 끊어졌다는 HTTP 응답을 받은 후, 실제 전달된 엔터티의 길이와 Content-Length의 값이 일치하지 않거나 Content-Length 자체가 존재하지 않으면 수신자는 데이터의 정확한 길이를 서버에게 물어봐야 한다.
만약 수신자가 캐시 프락시일 경우 응답을 캐시하면 안 된다. 프락시는 Content-Length를 정정하려 하지 말고 메시지를 받은 그대로 전달해야 한다.

커넥션 끊기의 허용, 재시도, 멱등성

커넥션은 에러가 없더라도 언제든 끊을 수 있다.
HTTP 애플리케이션은 커넥션이 끊어졌을 때에 적절한 대응할 수 있는 준비가 되어 있어야 한다.
트랜잭션 수행 중 전송 커넥션이 끊기게 되면, 클라이언트는 그 트랜잭션을 재시도 하더라도 문제가 없다면 커넥션을 다시 맺고 한 번 더 전송을 시도해야 한다.

파이프라인 커넥션에서 클라이언트는 여러 요청을 큐에 쌓아 놓을 수 있지만, 서버는 아직 처리되지 않고 스케줄이 조정되어야 하는 요청들을 남겨둔 채로 커넥션을 끊어버릴 수도 있다.
응답이 오기 전에 커넥션이 끊기면 클라이언트는 실제로 서버에서 얼만큼 요청이 처리 되었는지 전혀 알 수 없다.
GET과 같은 요청들은 반복적으로 요청하더라도 결과적으로 아무런 영향을 끼치지 않지만, 주문을 POST 하는 부류의 요청들은 반복될 경우 주문이 여러 번 중복될 것이기 때문에 반복을 피해야 한다.
한 번 혹은 여러 번 실행됐는지에 상관없이 같은 결과를 반환한다면 그 트랜잭션은 멱등(idempotent)하다고 한다.
GET, HEAD, PUT, DELETE, TRACE, OPTIONS 메서드들은 멱등하다고 이해하면 된다.

우아한 커넥션 끊기

TCP 커넥션은 양방향이다. TCP 커넥션 양쪽에는 데이터를 읽거나 쓰기 위한 입력 큐와 출력 큐가 있다. 한쪽 출력 큐에 있는 데이터는 다른 쪽의 입력 큐에 보내질 것이다.

전체 끊기와 절반 끊기
애플리케이션은 TCP 입력 채널과 출력 채널 중 한 개만 끊거나 둘 다 끊을 수 있다.

close()를 호출하면 채널의 커넥션을 모두 끊고, shutdown()을 호출하면 하나를 개별적으로 끊는다.

TCP 끊기와 리셋 에러
단순한 HTTP 애플리케이션은 전체 끊기만을 사용할 수 있지만 각기 다른 클라이언트, 서버, 프락시와 통신할 때, 그리고 그들과 파이프라인 지속 커넥션을 사용할 때, 기기들에 예상치 못한 쓰기 에러를 발생하는 것을 예방하기 위해 절반 끊기를 사용해야 한다.

보통은 커넥션의 출력 채널을 끊는 것이 안전하다.
클라이언트에서 더는 데이터를 보내지 않을 것임을 확실할 수 없는 이상, 커넥션의 입력 채널을 끊는 것은 위험하다.
이미 끊긴 입력 채널에 데이터를 전송하면, 서버의 운영 체제는 TCP ‘connection reset by peer’ 메시지를 클라이언트에 보낼 것이다.
대부분 운영체제는 이를 심각한 에러로 취급하여 버퍼에 저장된 읽히지 않은 데이터를 모두 삭제한다.

우아하게 커넥션 끊기
일반적으로 애플리케이션이 우아한 커넥션 끊기를 구현하는 것은 애플리케이션 자신의 출력 채널을 먼저 끊고 다른 쪽에 있는 기기의 출력 채널이 끊기는 것을 기다리는 것이다.
상대방이 절반 끊기를 구현했다는 보장도 없고 절반 끊기를 했는지 검사해준다는 보장도 없다. 따라서 커넥션을 우아하게 끊고자 하는 애플리케이션은 출력 채널에 절반 끊기를 하고 난 후에도 데이터나 스트림의 끝을 식별하기 위해 입력 채널에 대해 상태 검사를 주기적으로 해야 한다.




0311

타입 추론에 문백이 어떻게 사용되는지 이해하기

타입스크립트는 타입을 추론할 때 단순히 값만 고려하지 않는다. 값이 존재하는 곳의 문맥까지도 살핀다.
그런데 문맥을 고려해 타입을 추론하면 가끔 이상한 결과가 나온다. 이때 타입 추론에 문맥이 어떻게 사용되는지 이해하고 있다면 제대로 대처할 수 있다.

문자열을 특정해서 문자열 리터럴 타입의 유니온으로 선언한다고 가정하면

type Language = "JavaScript" | "TypeScript" | "Python";
function setLanguage(language: Language) {
  // ...
}

// 정상
setLanguage("JavaScript");

// 'string' 형식의 인수는 'Language' 형식의 매개 변수에 할당될 수 없습니다.
let language = "JavaScript";
setLanguage(language);
  • 인라인 형태로 함수를 호출했을 때는 해당 타입에 문자열 리터럴 ‘JavaScript’가 할당 가능하므로 정상이다.
  • 그러나 이 값을 변수로 분리해내면, 타입스크립트는 할당 시점에 타입을 추론하여 string은 Language 타입으로 할당이 불가능하므로 오류가 발생한다.
  • 이 문제를 해결하는 방법은 타입 선언에서 language의 가능한 값을 제한하는 방법이 있다.
  • 두 번째 해법은 language를 const로 선언하여 상수로 만드는 것이다.
  • 이처럼 문맥과 값을 분리하면 추후에 근본적인 문제를 발생시킬 수 있다.

튜플 사용 시 주의점

function panTo(where: [number, number]) {
  // ...
}

// 정상
panTo([10, 20]);

// 'number[]' 형식의 인수는 '[number, number]' 형식의 매개 변수에 할당될 수 없습니다.
const loc = [10, 20];
panTo(loc);
  • 여기서도 문맥과 값을 분리하여 loc 변수의 타입을 number[]로 추론하여 문제가 발생한다.
  • any 타입으로 선언하는 것 이외의 오류를 고칠 수 있는 방법은 as const를 사용하는 것이다.
  • 다만 as const로 선언하는 경우 loc을 readonly로 추론하여 너무 과하게 정확하게 추론하게 되고, where 매개변수는 불변이라고 보장하지 않기에 또 다른 오류가 발생한다.
  • 따라서 any를 사용하지 않고 오류를 고칠 수 있는 최선의 해결책은 매개변수에 readonly 구문을 추가하는 것이다.
  • 타입 시그니처를 수정할 수 없는 경우라면 타입 구문을 사용해야 한다.
    • as const는 문맥 손실과 관련한 문제를 깔끔하게 해결할 수 있지만, 만약 타입 정의에 실수가 있다면 오류는 타입 정의가 아니라 호출하는 곳에서 발생한다.

객체 사용 시 주의점

문맥에서 값을 분리하는 문제는 문자열 리터럴이나 튜플을 포함하는 큰 객체에서 상수를 뽑아낼 때도 발생한다.

type Language = "JavaScript" | "TypeScript" | "Python";
interface GovernedLanguage {
  language: Language;
  organization: string;
}

function complain(language: GovernedLanguage) {
  // ...
}

// '{ language: string; organization: string; }' 형식의 인수는 'GovernedLanguage' 형식의 매개 변수에 할당될 수 없습니다.
const ts = {
  language: "TypeScript",
  organization: "Microsoft",
};
complain(ts);
  • ts 객체에서 language의 타입은 string으로 추론된다.
  • 이 문제는 ts 변수 선언 시 타입 선언을 추가하거나, 상수 단언을 사용해서 해결한다.

콜백 사용 시 주의점

function callWithRandomNumbers(fn: (n1: number, n2: number) => void) {
  fn(Math.random(), Math.random());
}

// 'a' 매개 변수에는 암시적으로 'any' 형식이 포함됩니다.
const fn = (a, b) => {
  console.log(a + b);
};
callWithRandomNumbers(fn);
  • 콜백을 상수로 뽑아내면 문맥이 소실되고 noImplicitAny 오류가 발생하게 된다.
  • 이런 경우 매개변수에 타입 구문을 추가해서 해결할 수 있다.
  • 또는 가능할 경우 전체 함수 표현식에 타입 선언을 적용하는 것이다.

함수형 기법과 라이브러리로 타입 흐름 유지하기

로대시와 같은 라이브러리를 타입스크립트와 조합하여 사용하면 좋다.
그 이유는 타입 정보가 그대로 유지되면서 타입 흐름이 계속 전달되도록 하기 때문이다.

자바스크립트에서는 프로젝트에 서드파티 라이브러리 종속성을 추가할 때 신중해야 한다.
만약 서드파티 라이브러리 기반으로 코드를 짧게 줄이는 데 시간이 많이 든다면, 서드파티 라이브러리를 사용하지 않는 게 낫기 때문이다.

같은 코드를 타입스크립트로 작성하면 서드파티 라이브러리를 사용하는 것이 무조건 유리하다.
타입 정보를 참고하며 작업할 수 있기 때문에 서드파티 라이브러리 기반으로 바꾸는 데 시간이 훨씬 단축된다.

  • 타입스크립트의 많은 부분이 자바스크립트 라이브러리의 동작을 정확히 모델링하기 위해서 개발되었다. 그러므로 이를 적극 활용하자
  • 타입 흐름을 개선하고, 가독성을 높이고, 명시적인 타입 구문의 필요성을 줄이기 위해 직접 구현하기보다는 내장된 함수형 기법과 로대시 같은 유틸리티 라이브러리를 사용하는 것이 좋다.




0312

유효한 상태만 표현하는 타입 지향

효과적으로 타입을 설계하려면, 유효한 상태만 표현할 수 있는 타입을 만들어 내는 것이 가장 중요하다.
웹 애플리케이션을 만든다고 가정해보자. 애플리케이션에서 페이지를 선택하면, 페이지의 내용을 로드하고 화면에 표시한다.

interface State {
  pageText: string;
  isLoading: boolean;
  error?: string;
}

function renderPage(state: State) {
  if (state.error) {
    return `Error! Unable to load ${currentPage}: ${state.error}`;
  } else if (state.isLoading) {
    return `Loading...`;
  }
  return `<h1>${currentPage}</h1>\n${state.pageText}`;
}
  • 코드를 살펴보면 분기 조건이 명확히 분리되어 있지 않다.
  • isLoading이 true이고 동시에 error 값이 존재하면 로딩 중인 상태인지 오류가 발생한 상태인지 명확히 구분할 수 없다.
  • State 타입은 이와 같이 무효한 상태를 허용하여 코드를 제대로 구현할 수 없게 된다.
interface RequestPending {
  state: "pending";
}

interface RequestError {
  state: "error";
  error: string;
}

interface RequestSuccess {
  state: "ok";
  pageText: string;
}

type RequestState = RequestPending | RequestError | RequestSuccess;

interface State {
  currentPage: string;
  requests: { [page: string]: RequestState };
}

function renderPage(state: State) {
  const { currentPage } = state;
  const requestState = state.requests[currentPage];

  switch (requestState.state) {
    case "pending":
      return `Loading...`;
    case "error":
      return `Error! Unable to load ${currentPage}: ${requestState.error}`;
    case "ok":
      return `<h1>${currentPage}</h1>\n${requestState.pageText}`;
  }
}
  • 여기서는 네트워크 요청 과정 각각의 상태를 명시적으로 모델링하는 태그된 유니온이 사용되었다.
  • 타입 코드 길이가 길어지긴 했지만, 무효한 상태를 허용하지 않도록 크게 개선되었다.
  • 함수의 모호함은 사라지고, 현재 페이지가 무엇인지 명확하며, 모든 요청은 정확히 하나의 상태로 맞아 떨어진다.
  • 이처럼 유효한 상태만 표현하는 타입을 지향해야 한다. 유효한 상태와 무효한 상태를 둘 다 표현하는 타입은 혼란을 초래하기 쉽고 오류를 유발하게 된다.

사용할 때는 너그럽게, 생성할 때는 엄격하게

  • 함수의 매개변수는 타입의 범위가 넓어도 되지만, 결과를 반환할 때는 일반적으로 타입의 범위가 더 구체적이어야 한다.
  • 보통 매개변수 타입은 반환 타입에 비해 범위가 넓은 경향이 있다. 선택적 속성과 유니온 타입은 반환 타입보다 매개변수 타입에 더 일반적이다.
  • 매개변수와 반환 타입의 재사용을 위해서 기본 형태(반환 타입)와 느슨한 형태(매개변수 타입)를 도입하는 것이 좋다.

문서에 타입 정보를 쓰지 않기

  • 누군가 강제하지 않는 이상 주석과 코드는 동기화 되지 않는다. 그러나 타입 구문은 타입스크립트 타입 체커가 타입 정보를 동기화하도록 강제하니 주석과 변수명에 타입 정보를 적는 것은 피하자
  • 값을 변경하지 않는다고 설명하는 주석도 좋지 않다. readonly를 사용하면 된다.
  • 타입이 명확하지 않은 경우는 변수명에 단위 정보를 포함하는 것을 고려하는 것이 좋다.
    • ex) timeMs 또는 temperatureC

태그:

카테고리:

업데이트: