개요
프레임워크를 사용하면 HTTP 서버는 쉽게 만들 수 있다. 하지만 "왜 이렇게 설계되어 있는지"는 파악하기 어렵다.
이번 프로젝트는 TCP 소켓 위에서 HTTP/1.1 서버를 직접 구현하며, 각 계층이 가지는 책임과 데이터의 흐름을 파악하는 데 중점을 두었다.
전체 구조 (Architecture)
서버는 단순히 하나의 커다란 코드가 아니라, 서로 다른 책임을 가진 5개의 핵심 계층으로 구성된다.
- Server : TCP 연결 수락 및 Coroutine 기반 Connection 관리
- RequestParser : 스트림(Bytes)을 분석하여 의미있는 객체(HttpRequest)로 변환
- HTTP Models : 요청과 응답의 상태를 정의하는 도메인 모델
- Routing & Handler : URL에 맞는 로직을 찾아 실행하는 컨트롤러
- ResponseWriter : 처리 결과를 다시 HTTP 규격에 맞는 텍스트로 직렬화
※ GitHub Link : https://github.com/proLmpa/HTTP-Server
1. Server -- 모든 HTTP 흐름의 시작과 끝
HTTP 서버의 핵심 책임은 TCP connection의 생명주기 관리다.
- 주요 역할 : accept(), keep-alive 여부 판단, idle timeout 처리
- 핵심 설계
- 오직 이 계층만이 연결을 유지할지 끊을지를 결정한다.
- 하위 계층(Router, Handler)은 TCP 소켓의 존재를 전혀 몰라야 한다.
핵심 동작은 아래의 3가지다.
// 1. HTTP API 등록
private val router = Router().apply {
register(HttpMethod.GET, "/time", TimeHandler::handle)
register(HttpMethod.POST, "/text/{id}", textHandler::create)
register(HttpMethod.GET, "/text/{id}", textHandler::get)
register(HttpMethod.GET, "/textall", textHandler::getAll)
register(HttpMethod.DELETE, "/text/{id}", textHandler::delete)
register(HttpMethod.GET, "/image", ImageHandler::handle)
}
// 2. 서버 connection 시작
fun start() {
val serverSocket = ServerSocket(port)
println("Listening on $port")
while (true) {
val socket = serverSocket.accept()
scope.launch {
handleConnection(socket)
}
}
}
// 3. 요청 처리
while (true) {
val request = RequestParser.parse(input) ?: break
val response = router.route(request)
ResponseWriter.write(output, finalResponse)
val keepAlive = shouldKeepAlive(request)
if (!keepAlive) break
}
2. RequestParser -- 스트림에서 HttpRequest(의미) 추출
# HTTP Request 구조 (RFC 기준)
POST /text/hello HTTP/1.1\r\n
Host: localhost:8080\r\n
Content-Type: text/plain\r\n
Content-Length: 11\r\n
\r\n
hello world
2.1 HTTP 파서의 본질적인 문제
TCP는 데이터의 경계가 없는 스트림(Stream)이다.
파서는 이 연속된 바이트 뭉치에서 HTTP 메시지의 끝이 어디인지를 정확히 찾아내야 한다.
그래서 RequestParser는 반드시 다음을 보장해야 한다.
- Header와 Body의 정확한 분리
- Header는 \r\n\r\n 기준
- Body는 Content-Length 기준
- Body를 절대 선소비 하지 않을 것
object RequestParser {
fun parse(input: InputStream): HttpRequest? {
// 1. Split header and body
val raw = readUntilDoubleCRLF(input) ?: return null
val lines = raw.split("\r\n")
// 2. Request line
val (method, path, version) = parseRequestLine(lines.first())
// 3. Headers
val headers = mutableMapOf<String, String>()
for (i in 1 until lines.size) {
if (lines[i].isEmpty()) break
val (k, v) = parseHeader(lines[i])
headers[k.lowercase()] = v
}
// 4. Body (Content-Length 기준)
val body = parseBody(headers, input)
return HttpRequest(
method = HttpMethod.valueOf(method),
path = path,
version = version,
headers = headers,
body = body
)
}
}
2.2 Header 읽기 -- \r\n\r\n 까지만
private fun readUntilDoubleCRLF(input: InputStream): String?
- Byte 단위로 읽는다.
- \r\n\r\n을 발견하면 즉시 중단한다.
- Body bytes는 절대 읽지 않는다.
2.3 Body 읽기 -- Content-Length 기준
private fun parseBody(headers: Map<String, String>, input: InputStream) : ByteArray?
- Content-Length가 없으면 body 없음 (예: GET 요청 시)
- 정확히 그 길이만 읽는다. 정해진 범위에 못 미치거나 초과하면 다음 요청이 깨진다.
3. Http Models -- 데이터 규격화
파싱된 결과물은 HttpRequest 객체에 담긴다.
이 시점부터 서버 로직은 문자열이 아닌 의미 단위 객체를 다루게 된다.
3.1 HttpRequest - 요청 파싱 결과
data class HttpRequest (
val method: HttpMethod,
val path: String,
val version: String,
val headers: Map<String, String>,
val body: ByteArray?,
val pathParams: Map<String, String> = emptyMap()
)
여기서 중요했던 기준은 아래와 같다.
- Http Request는 파싱의 결과물
- 네트워크, 소켓, 스트림은 전혀 모른다.
- Hanlder는 이 객체만 보고 판단한다.
3.2 HttpStatus - 상태코드와 상태 메시지 명시
enum class HttpStatus(
val code: Int,
val reason: String
) {
OK(200, "OK"),
CREATED(201, "Created"),
NO_CONTENT(204, "No Content"),
BAD_REQUEST(400, "Bad Request"),
NOT_FOUND(404, "Not Found"),
METHOD_NOT_ALLOWED(405, "Method Not Allowed"),
INTERNAL_SERVER_ERROR(500, "Internal Server Error"),
}
상태 코드를 enum으로 고정했다. 이를 통해:
- 상태 코드 직렬화 실수 방지
- Handler가 문자열을 직접 만들 필요 X
- ResponseWriter는 표현만 담당
3.3 HttpResponse - 불변 객체 + 의미 기반 생성
data class HttpResponse(
val status: HttpStatus,
val headers: MutableMap<String, String>,
val body: ByteArray? = null
)
HttpResponse의 설계 원칙은 "HttpResponse는 절대 수정되지 않는다"는 것이다.
따라서
- Headers는 Map (immutable)
- ResponseWriter는 응답을 절대 변경하지 않음
그래서 HTTP 서버에서도 헤더 값 추가는 HttpResponse.withHeader()처럼 새 객체를 만들어 덧씌운다.
fun withHeader(key: String, value: String): HttpResponse =
copy(headers = headers + (key to value))
이 설계 덕분에 이후 'Connection: keep-alive' 정책 추가 시, 기존 구조를 훼손하지 않으면서 새 헤더를 추가할 수 있다.
4. Routing -- HTTP 요청과 Handler 로직 매핑
Router는 들어온 HttpRequest의 Method와 Path를 보고 "이 요청을 누구에게 보낼 것인지" 결정한다.
- Http Method + Path 기반 매칭
- Path Parameter 지원 : /text/{id} 같은 패턴을 분석하여 실제 값(예: id=123)을 추출한다.
- 추상화 : 추출된 패러미터는 HttpRequest에 다시 주입되어, Handler가 복잡한 URL 파싱 없이 바로 로직을 실행하도록 돕는다.
class Router {
private val routes = mutableListOf<Route>()
...
fun route(request: HttpRequest): HttpResponse {
for (route in routes) {
val params = route.match(request) ?: continue
return try {
route.handler(request.copy(pathParams = params))
} catch (_: Exception) {
internalServerError()
}
}
return notFound()
}
}
data class Route(
val method: HttpMethod,
val segments: List<String>,
val handler: Handler
) {
fun match (request: HttpRequest): Map <String, String>? {
// 1. Http Method 식별
if (request.method != method) return null
// 2. Path를 segment 단위로 분해 (예: /text/{id}, /textall, /time)
val reqSeg = request.path.trim('/').split("/")
if (segments.size != reqSeg.size) return null
// 3. Http 요청 parameter와 Route segments 비교 검증
val params = mutableMapOf<String, String>()
segments.zip(reqSeg).forEach { (p, a) ->
if (p.startsWith("{")) {
params[p.substring(1, p.length - 1)] = a
} else if (p != a) return null
}
return params
}
}
이를 통해 Handler는 더 이상 URL 문자열을 파싱할 필요가 없다.
5. Handler -- API 로직 수행
Handler는 API의 실제 기능을 수행한다.
소켓이나 통신 방식은 전혀 몰라도 되며, 오직 HttpRequest를 받아 HttpResponse를 반환하는 데만 집중한다.
- 예: 시간 정보 조회, 이미지 파일 전송, In-memory 저장소 데이터 CRUD
object TimeHandler {
fun handle(request: HttpRequest): HttpResponse {
if (request.method != HttpMethod.GET) {
return HttpResponse.methodNotAllowed()
}
val now = ZonedDateTime.now()
.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME)
val json = """{"time":"$now"}"""
val body = json.toByteArray(Charsets.UTF_8)
return HttpResponse.okJson(body)
}
}
class TextHandler(
private val textStore: TextStore
) {
fun create(request: HttpRequest): HttpResponse {
if (request.method != HttpMethod.POST) {
return HttpResponse.methodNotAllowed()
}
val textId = request.pathParams["id"]
?: return HttpResponse.badRequest("id path parameter is required")
val body = request.body
?: return HttpResponse.badRequest("request body is required")
val text = body.toString(Charsets.UTF_8)
textStore.put(textId, text)
return HttpResponse.created("/text/$textId")
}
...
}
object ImageHandler {
private const val IMAGE_PATH = "/image.jpg"
fun handle(request: HttpRequest): HttpResponse {
if (request.method != HttpMethod.GET) {
return HttpResponse.methodNotAllowed()
}
val stream = javaClass.getResourceAsStream(IMAGE_PATH) ?: return HttpResponse.notFound()
val bytes = try {
stream.use { it.readBytes() }
} catch (_: Exception) {
return HttpResponse.internalServerError()
}
return HttpResponse(
status = HttpStatus.OK,
headers = mapOf(
"Content-Type" to "image/jpeg",
"Content-Length" to bytes.size.toString(),
"Content-Disposition" to "attachment; filename=\"image.jpg\""
),
body = bytes
)
}
}
6. ResponseWriter -- HttpResponse를 bytes로 직렬화
객체 형태의 HttpResponse를 RFC 규격에 맞는 문자열과 바이트로 변환하여 클라이언트에게 보낸다.
- Status Line → Headers → CRLF → Body 순서로 정확히 전송해야 한다.
# HTTP Response 구조 (RFC 기준)
HTTP/1.1 200 OK\r\n
Content-Type: application/json\r\n
Content-Length: 27\r\n
Connection: close\r\n
\r\n
{"time":"2025-01-01"}
object ResponseWriter {
fun write(output: OutputStream, response: HttpResponse) {
// 1. Status Line + Header 작성
val sb = StringBuilder()
sb.append("HTTP/1.1 ")
.append(response.status.code)
.append(' ')
.append(response.status.reason)
.append("\r\n")
response.headers.forEach { (key, value) ->
sb.append(key).append(": ").append(value).append("\r\n")
}
// 2. Header-Body 구분선 (\r\n) 작성
sb.append("\r\n")
// 3. String -> ByteArray 변환 후 한 번에 write
output.write(sb.toString().toByteArray(Charsets.US_ASCII))
// 4. Body는 그대로 write
response.body?.let {
output.write(it)
}
output.flush()
}
}
ResponseWriter의 원칙은 다음과 같다:
- Response를 수정하지 않는다.
- 정책을 결정하지 않는다.
- 로직을 가지지 않는다.
마무리 (교훈)
- 계층화 : Handler가 소켓을 직접 다루지 않게 설계함으로써, 로직이 단순해지고 테스트가 용이해졌다.
- 프로토콜의 엄격함 : HTTP는 매우 구체적인 규칙(예: \r\n, Content-Length) 위에 세워져 있으며, 하나만 어긋나도 통신 전체가 무너진다.
- Connection 유지의 복잡성 : keep-alive를 지원하기 위해 파서가 얼마나 정교하게 데이터를 읽어야 하는지 체감할 수 있었다.
다음 구현 사항
현재 구현된 기본 구조 위에 다음과 같은 기능을 추가하며 고도화할 예정이다.
- Logging & Metrics : 연결이 끊긴 이유(Timeout 등)를 명확히 기록
- max-requests-per-connection : keep-alive 최대 연결 제한
- HEAD / OPTIONS 메서드 추가
- Query Parameter : /search?q=kotlin 형태의 요청 지원
- Chunked Transfer Encoding : 대용량 데이터를 조각내어 전송 (난이도 ▲)
'Backend' 카테고리의 다른 글
| [Backend] 10주차 내용 정리 (0) | 2026.01.22 |
|---|---|
| [Backend] Kotlin 기반 HTTP 서버 구현 (요약) (1) | 2026.01.19 |
| [Backend] 고성능 시스템에서 전송 속도를 통제하는 방법 (0) | 2025.12.31 |
| [Backend] BitTorrent Protocol : 거대 파일을 조각 내어 공유·분산하기 (1) | 2025.12.29 |
| [Backend] TCP 스트림 기반 파일 전송 기능 구현 - CHAT/FILE multiplexing, 단일 Writer, backpressure 구현 기록 (1) | 2025.12.19 |