PHpullh

LANGUAGE COMPARISON

HTTP를 언어별로 비교하기

같은 개념을 실제 예제로 나란히 확인하고, 각 언어의 개별 설명으로 이어갈 수 있습니다.

HTTP 클라이언트에서 언어차보다 큰 것

HTTP 요청 코드는 다섯 언어 모두 겉모습이 비슷합니다. URL을 만들고, 요청을 보내고, 응답 본문을 읽습니다. 그런데 실제로 운영에서 문제를 만드는 것은 이 세 줄이 아니라 그 주변의 기본값입니다. 타임아웃이 걸려 있는가, 커넥션을 재사용하는가, 4xx와 5xx를 무엇으로 취급하는가. 여기에 대한 답이 언어와 라이브러리마다 다르고, 기본값을 그대로 쓰면 대부분 위험한 쪽입니다.

표준 라이브러리 지원부터 갈립니다. Go는 net/http가 클라이언트와 서버를 모두 표준으로 제공합니다. Java는 11부터 java.net.http.HttpClient가 들어와 서드파티 없이 현대적인 요청을 보낼 수 있고, Kotlin은 JVM 위에서 이것을 그대로 씁니다. PHP는 cURL 확장이 사실상 표준 위치를 차지하며 curl_multi로 병렬 요청까지 다룹니다. Python은 표준 urllib이 있지만 실무에서는 requests나 httpx 같은 라이브러리를 쓰는 것이 일반적입니다.

Kotlin

네트워크 I/O (기본)

Java의 HttpURLConnection과 Kotlin 확장으로 기본적인 HTTP 요청을 수행합니다.

import java.net.HttpURLConnection
import java.net.URL

fun httpGet(urlStr: String): Result<String> = runCatching {
    val url = URL(urlStr)
    val conn = url.openConnection() as HttpURLConnection
    conn.apply {
        requestMethod = "GET"
        connectTimeout = 5000
        readTimeout = 5000
        setRequestProperty("Accept", "application/json")
    }

    try {
        if (conn.responseCode == 200) {
            conn.inputStream.bufferedReader().use { it.readText() }
        } else {
            throw RuntimeException("HTTP ${conn.responseCode}: ${conn.responseMessage}")
        }
    } finally {
        conn.disconnect()
    }
}

fun httpPost(urlStr: String, body: String): Result<String> = runCatching {
    val conn = URL(urlStr).openConnection() as HttpURLConnection
    conn.apply {
        requestMethod = "POST"
        doOutput = true
        setRequestProperty("Content-Type", "application/json")
    }
    conn.outputStream.use { it.write(body.toByteArray()) }
    conn.inputStream.bufferedReader().use { it.readText() }
}

fun main() {
    httpGet("https://httpbin.org/get").fold(
        onSuccess = { println("응답: ${it.take(200)}...") },
        onFailure = { println("오류: ${it.message}") }
    )
}

Python

HTTP 요청 (requests/httpx)

HTTP 클라이언트로 외부 API를 호출하고 응답을 처리합니다.

# requests 라이브러리 패턴 시뮬레이션
from urllib.request import urlopen, Request
from urllib.error import URLError
import json

# 기본 GET 요청
def http_get(url: str, headers: dict = None) -> dict:
    req = Request(url, headers=headers or {})
    try:
        with urlopen(req, timeout=10) as resp:
            return {
                "status": resp.status,
                "body": resp.read().decode("utf-8"),
                "headers": dict(resp.headers),
            }
    except URLError as e:
        return {"error": str(e)}

# requests 스타일 API 시뮬레이션
class Response:
    def __init__(self, status=200, data=None):
        self.status_code = status
        self._data = data or {}

    def json(self):
        return self._data

    def raise_for_status(self):
        if self.status_code >= 400:
            raise Exception(f"HTTP {self.status_code}")

# 실사용 패턴 (requests 설치 필요)
# import requests
# resp = requests.get("https://api.example.com/users",
#     params={"page": 1},
#     headers={"Authorization": "Bearer TOKEN"},
#     timeout=10,
# )
# resp.raise_for_status()
# users = resp.json()

# httpx (비동기 지원)
# import httpx
# async with httpx.AsyncClient() as client:
#     resp = await client.get("https://api.example.com")

resp = Response(200, {"users": [{"name": "Alice"}]})
print(f"상태: {resp.status_code}")
print(f"데이터: {resp.json()}")

Go

HTTP 클라이언트

net/http로 HTTP 요청을 보냅니다. 타임아웃 설정과 응답 처리를 다룹니다.

package main

import (
	"context"
	"fmt"
	"io"
	"net/http"
	"time"
)

func main() {
	// 커스텀 클라이언트 (타임아웃 필수!)
	client := &http.Client{
		Timeout: 10 * time.Second,
	}

	// GET 요청
	resp, err := client.Get("https://httpbin.org/get")
	if err != nil {
		fmt.Println("에러:", err)
		return
	}
	defer resp.Body.Close()

	body, _ := io.ReadAll(resp.Body)
	fmt.Printf("상태: %d, 크기: %d bytes\n", resp.StatusCode, len(body))

	// context로 취소 가능한 요청
	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()

	req, _ := http.NewRequestWithContext(ctx, "GET", "https://httpbin.org/get", nil)
	req.Header.Set("Accept", "application/json")

	resp2, err := client.Do(req)
	if err != nil {
		fmt.Println("에러:", err)
		return
	}
	defer resp2.Body.Close()
	fmt.Println("상태:", resp2.StatusCode)
}

Java

HTTP Client (java.net.http)

Java 11+의 내장 HTTP 클라이언트로 REST API를 호출합니다.

import java.net.http.*;
import java.net.URI;
import java.time.Duration;

public class HttpClientDemo {
    public static void main(String[] args) throws Exception {
        // HttpClient 생성 (재사용 권장)
        HttpClient client = HttpClient.newBuilder()
            .version(HttpClient.Version.HTTP_2)
            .connectTimeout(Duration.ofSeconds(10))
            .followRedirects(HttpClient.Redirect.NORMAL)
            .build();

        // GET 요청
        HttpRequest getReq = HttpRequest.newBuilder()
            .uri(URI.create("https://httpbin.org/get"))
            .header("Accept", "application/json")
            .timeout(Duration.ofSeconds(30))
            .GET()
            .build();

        HttpResponse<String> response = client.send(
            getReq, HttpResponse.BodyHandlers.ofString());
        System.out.println("상태: " + response.statusCode());
        System.out.println("본문: " + response.body().substring(0, 100));

        // POST 요청
        String json = "{\"name\":\"test\",\"value\":42}";
        HttpRequest postReq = HttpRequest.newBuilder()
            .uri(URI.create("https://httpbin.org/post"))
            .header("Content-Type", "application/json")
            .POST(HttpRequest.BodyPublishers.ofString(json))
            .build();

        // 비동기 요청
        client.sendAsync(postReq, HttpResponse.BodyHandlers.ofString())
            .thenApply(HttpResponse::body)
            .thenAccept(body -> System.out.println("비동기: " + body.length()))
            .join();
    }
}

PHP

stream context 로 HTTP 요청

간단한 HTTP 요청을 별도 라이브러리 없이 보내는 stream context 예제입니다.

<?php
$context = stream_context_create([
    'http' => [
        'method' => 'GET',
        'timeout' => 2,
        'header' => "Accept: application/json
",
    ],
]);

$body = @file_get_contents('https://example.com', false, $context);
echo $body === false ? 'failed' : 'ok';

JavaScript

요청 작업 감싸기

요청 두 개를 흉내 낸 프로미스를 묶어 결과를 한 번에 받습니다. HTTP 호출을 감싸는 얇은 함수를 만들 때 이런 병렬 호출 지점이 생깁니다. fetch는 404나 500에도 거부되지 않으므로, 래퍼 안에서 res.ok를 직접 확인해 예외로 바꿔야 합니다.

// 요청 작업 감싸기
Promise.all([Promise.resolve("a"), Promise.resolve("b")]).then(console.log);

TypeScript

요청 작업 감싸기

요청 두 개를 흉내 낸 프로미스를 묶어 결과를 한 번에 받습니다. HTTP 호출을 감싸는 얇은 함수를 만들 때 이런 병렬 호출 지점이 생깁니다. fetch는 404나 500에도 거부되지 않으므로, 래퍼 안에서 res.ok를 직접 확인해 예외로 바꿔야 합니다.

// 요청 작업 감싸기
Promise.all([Promise.resolve("a"), Promise.resolve("b")]).then(console.log);

C#

요청 작업 감싸기

요청 두 개를 흉내 낸 프로미스를 묶어 결과를 한 번에 받습니다. HTTP 호출을 감싸는 얇은 함수를 만들 때 이런 병렬 호출 지점이 생깁니다. fetch는 404나 500에도 거부되지 않으므로, 래퍼 안에서 res.ok를 직접 확인해 예외로 바꿔야 합니다.

// 요청 작업 감싸기
using System;
using System.Threading.Tasks;

var results = await Task.WhenAll(Task.FromResult("a"), Task.FromResult("b"));
Console.WriteLine(string.Join(",", results));

C++

요청 작업 감싸기

요청 두 개를 흉내 낸 프로미스를 묶어 결과를 한 번에 받습니다. HTTP 호출을 감싸는 얇은 함수를 만들 때 이런 병렬 호출 지점이 생깁니다. fetch는 404나 500에도 거부되지 않으므로, 래퍼 안에서 res.ok를 직접 확인해 예외로 바꿔야 합니다.

// 요청 작업 감싸기
#include <chrono>
#include <iostream>
#include <thread>

int main() {
    std::thread worker([] { std::this_thread::sleep_for(std::chrono::milliseconds(1)); });
    worker.join();
    std::cout << "done\n";
}

Rust

요청 작업 감싸기

요청 두 개를 흉내 낸 프로미스를 묶어 결과를 한 번에 받습니다. HTTP 호출을 감싸는 얇은 함수를 만들 때 이런 병렬 호출 지점이 생깁니다. fetch는 404나 500에도 거부되지 않으므로, 래퍼 안에서 res.ok를 직접 확인해 예외로 바꿔야 합니다.

// 요청 작업 감싸기
use std::{thread, time::Duration};
fn main() {
    thread::sleep(Duration::from_millis(1));
    println!("done");
}

기본값을 그대로 두면 안 되는 것들

타임아웃. Go의 http.DefaultClient는 전체 요청 타임아웃이 없습니다. 상대 서버가 응답을 주지 않으면 그 고루틴은 무한정 기다립니다. Python의 requests도 타임아웃 인자를 주지 않으면 기다립니다. 이 상태에서 트래픽이 몰리면 대기 중인 요청이 계속 쌓이고, 결국 우리 서비스가 먼저 죽습니다. 외부 호출에는 항상 명시적인 제한 시간을 주어야 하고, 연결 타임아웃과 응답 읽기 타임아웃을 구분해서 설정할 수 있는지 확인하는 것이 좋습니다.

클라이언트 재사용. 요청마다 새 클라이언트를 만들면 커넥션 풀이 매번 버려지고 TCP 핸드셰이크와 TLS 협상을 처음부터 다시 합니다. Go의 http.Client, Java의 HttpClient, Python의 세션 객체는 모두 재사용을 전제로 설계되어 있습니다. 특히 Java와 Go에서는 클라이언트를 한 번 만들어 공유하는 것이 스레드 안전하며 권장되는 사용법입니다.

응답 본문 처리. Go에서는 응답 본문을 다 읽고 닫지 않으면 커넥션이 풀로 돌아가지 않아 서서히 새 나갑니다. 상태 코드만 보고 함수를 빠져나가는 코드가 전형적인 누수 지점입니다. 다른 언어에서도 스트리밍 응답을 중간에 버리면 비슷한 문제가 생깁니다.

4xx는 실패지만 예외는 아닙니다

많은 언어에서 HTTP 상태 코드 404나 500은 성공적으로 받은 응답입니다. 요청과 응답이 정상적으로 오갔으니 라이브러리 관점에서는 오류가 아닙니다. 그래서 예외 기반으로 짠 코드가 오류 상태를 그대로 통과시키고, 빈 본문을 파싱하다가 엉뚱한 곳에서 터집니다. Python requests의 raise_for_status처럼 상태 코드를 예외로 승격시키는 도구를 쓰거나, Go와 Java에서는 상태 코드 검사를 요청 함수 안에 함께 넣어 두어야 합니다. 네트워크 오류와 애플리케이션 오류는 재시도 전략이 다르므로 두 층을 구분해 다루는 편이 좋습니다.

재시도를 붙일 때는 두 가지를 함께 정해야 합니다. 첫째, 어떤 실패를 재시도할 것인가. 연결 실패와 5xx, 429는 후보지만 4xx 대부분은 같은 요청을 다시 보내도 결과가 같습니다. 둘째, 같은 요청을 두 번 보내도 안전한가. 결제나 주문 생성처럼 부작용이 있는 호출은 멱등성 키 없이 재시도하면 중복이 생깁니다. 여기에 지수 백오프와 지터를 넣지 않으면, 상대 서버가 잠깐 흔들렸을 때 모든 클라이언트가 동시에 재시도해 상황을 악화시킵니다.

병렬 호출은 언어별 동시성 모델을 그대로 따라갑니다. Go는 고루틴, Python은 asyncio 기반 클라이언트, Java는 비동기 요청 API, PHP는 curl_multi가 그 자리를 채웁니다. 다만 동시 요청 수를 제한하지 않으면 상대 서버의 레이트 리밋에 먼저 걸리므로, 세마포어나 워커 수 제한을 함께 두어야 합니다. 동시성 모델 자체의 비교는 Async 비교에서, 응답 파싱은 JSON 비교에서 이어집니다.

서버 쪽에서 보이는 같은 문제

이 비교는 요청을 보내는 쪽에 초점을 두고 있지만, 서버를 만들 때도 같은 항목이 반대 방향으로 나타납니다. 읽기 타임아웃과 쓰기 타임아웃을 걸지 않으면 느린 클라이언트 하나가 연결을 오래 붙잡고, 요청 본문 크기를 제한하지 않으면 메모리를 소진시킬 수 있습니다. Go의 http.Server는 타임아웃 필드를 명시적으로 채워야 하고, 다른 스택에서도 앞단 프록시나 프레임워크 설정에 같은 값이 존재합니다.

또 하나 자주 빠뜨리는 것이 헤더입니다. 콘텐츠 타입을 정확히 지정하지 않으면 클라이언트가 본문을 잘못 해석하고, 캐시 관련 헤더를 비워 두면 중간 캐시가 예상과 다르게 동작합니다. 클라이언트와 서버 양쪽에서 실제로 오간 헤더를 한 번은 눈으로 확인해 보는 것이, 라이브러리 문서를 읽는 것보다 문제를 빨리 좁힙니다.

운영 관점에서 하나 더 챙길 것은 관측입니다. 외부 호출은 우리 코드가 통제하지 못하는 구간이므로, 대상별 응답 시간과 상태 코드 분포를 지표로 남겨 두지 않으면 장애가 났을 때 상대 서버 문제인지 우리 문제인지 구분하는 데만 시간을 씁니다. 요청마다 상관관계 식별자를 헤더에 실어 보내면 상대 팀과 로그를 맞춰 볼 때도 훨씬 빨리 좁혀집니다.