폼 본문 읽기와 컴포지션 루트
HTML 폼이 보낸 본문을 바이트 단위로 정확히 읽어 디코딩하고, 쿼리 값과 섞지 않고 보관하며, 저장소·서비스·컨트롤러를 프로그램 시작 지점의 한곳에서 조립하는 방법을 배웁니다.
앞 글의 컨트롤러는 폼 값이 이미 Map에 담겨 있다고 가정했습니다. 하지만 브라우저가 실제로 보내는 것은 바이트입니다. HTML 폼은 따로 지정하지 않으면 application/x-www-form-urlencoded라는 형식으로 값을 보내므로, 회원 등록 폼을 제출하면 요청 본문에 다음과 같은 바이트가 실립니다.
id=min-1&name=%EC%A7%84&age=27모양은 URL의 쿼리 문자열과 같습니다. 이름과 값은 =로 잇고 쌍과 쌍 사이는 &로 나눕니다. 한글 진은 UTF-8 바이트 EC A7 84를 퍼센트 인코딩한 %EC%A7%84로 바뀌었습니다. 퍼센트 인코딩과 쿼리를 나누는 방법은 요청 경로와 쿼리 문자열 파싱에서 다루었습니다.
하지만 쿼리와 폼 본문은 실려 오는 위치가 다릅니다. 쿼리는 요청 줄의 대상 주소 안에 들어 있고, 폼 본문은 헤더 다음에 오며 Content-Length와 Content-Type 헤더가 정한 대로 읽어야 합니다. 이 글에서는 먼저 폼 본문을 정확히 읽고 디코딩하는 코드를 만듭니다. 그다음 그렇게 얻은 폼 값을 쿼리 값과 섞지 않고 보관하는 방법을 봅니다. 마지막으로 앞 글에서 나눈 저장소, 서비스, 컨트롤러를 한곳에서 조립합니다.
Content-Length는 문자 수가 아니라 바이트 수
본문을 읽는 가장 쉬운 방법은 Reader로 Content-Length만큼 문자를 읽는 것처럼 보입니다. 다음 코드는 Content-Length 크기의 char 배열을 만들고 read를 한 번 불러 채웁니다. main은 본문 name=진 바로 뒤에 같은 연결로 들어온 다음 요청 GET /next가 이어진 상황을 만들어, 이 방법으로 무엇이 읽히는지 보여 줍니다.
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.Reader;
import java.nio.charset.StandardCharsets;
public final class CharacterCountFormReader {
static String readBody(Reader reader, int contentLength) throws IOException {
char[] characters = new char[contentLength];
int read = reader.read(characters);
if (read != contentLength) {
throw new IOException("short character read");
}
return new String(characters);
}
public static void main(String[] args) throws IOException {
byte[] wire = "name=진GET /next".getBytes(StandardCharsets.UTF_8);
int contentLength = "name=진".getBytes(StandardCharsets.UTF_8).length;
Reader reader = new InputStreamReader(
new ByteArrayInputStream(wire), StandardCharsets.UTF_8);
System.out.println("Content-Length=" + contentLength);
System.out.println("body=" + readBody(reader, contentLength));
}
}readBody는 read가 채운 칸 수가 contentLength와 같은지만 확인합니다. 그래서 읽은 내용이 틀렸더라도 칸 수만 맞으면 예외 없이 통과합니다.
Content-Length=8
body=name=진GEname=진은 UTF-8로 8바이트이므로 Content-Length는 8입니다. 그런데 Reader는 바이트를 문자로 디코딩한 뒤에 돌려주기 때문에, 길이 8인 char 배열에는 문자 8개가 들어갑니다. 3바이트였던 진이 문자 하나가 되면서 두 칸이 남았고, 그 자리를 다음 요청의 G와 E가 채웠습니다. 같은 판독기로 다음 요청을 읽으면 T /next부터 시작하는 깨진 요청이 됩니다.
원래의 바이트 스트림에서 다음 요청을 읽으려 하면 사정은 더 나쁩니다. InputStreamReader는 성능을 위해 바이트를 미리 넉넉히 읽어 두므로, 이 예제에서는 다음 요청 전체가 이미 판독기의 내부 버퍼로 사라져 스트림에 남은 바이트가 0입니다. 게다가 read를 한 번 부르면 배열이 가득 찬다는 약속도 없습니다. 이 예제는 바이트가 모두 메모리에 있어서 한 번에 채워졌지만, 네트워크에서는 그때까지 도착한 만큼만 돌려줄 수 있으므로 정상적인 요청에서도 short character read 오류가 날 수 있습니다.
참고로 브라우저가 보내는 폼 본문은 한글까지 모두 퍼센트 인코딩되어 ASCII 문자만 담기므로, 바이트 수와 문자 수가 같아 이 차이가 드러나지 않을 때가 많습니다. 하지만 서버는 모든 클라이언트가 그렇게 보낸다고 믿을 수 없고, text/plain이나 JSON 본문에는 UTF-8 바이트가 그대로 들어 있습니다. 같은 문제는 HTTP 본문 읽기: Content-Length와 chunked에서도 다루었습니다.
본문을 바이트로 읽는 순서
HTTP/1.1에서 본문의 길이는 바이트로 정해집니다. Content-Length는 본문이 몇 바이트인지 알려 주는 헤더이고, 서버는 정확히 그만큼 읽은 뒤에야 다음 요청을 읽기 시작할 수 있습니다. 그래서 본문은 바이트를 돌려주는 InputStream으로 읽고, 문자로 바꾸는 일은 바이트를 다 모은 다음에 합니다.
읽기 전에 먼저 Content-Length 값부터 검사합니다. 값이 음수이거나 서버가 허용하는 최대 크기보다 크면 한 바이트도 읽지 않고 거절합니다. 공격자가 아주 큰 값을 보내 서버가 거대한 배열을 만들게 하는 일을 막기 위해서입니다. 값이 괜찮으면 남은 바이트 수가 0이 될 때까지 read를 반복해서 부르는데, 한 번의 read가 요청한 양보다 적게 돌려주는 것은 정상적인 일입니다. 다 읽기 전에 스트림이 끝나 read가 -1을 돌려주면, 클라이언트가 선언한 것보다 적게 보낸 잘린 요청이므로 400으로 거절합니다.
Transfer-Encoding: chunked처럼 본문 길이를 다른 방식으로 알리는 헤더도 있습니다. 이 글의 작은 서버처럼 이 방식을 지원하지 않는다면, 헤더를 조용히 무시하고 Content-Length만 믿어서는 안 되고 요청을 명시적으로 거절해야 합니다. HTTP/1.1 명세(RFC 9112)는 이해하지 못하는 전송 코딩을 받은 서버가 501 Not Implemented로 응답하도록 권합니다. 앞뒤에 놓인 서버들이 본문 길이를 서로 다르게 해석하면, 한 요청 안에 다른 요청을 숨겨 보내는 요청 스머글링(request smuggling) 공격이 가능해지기 때문입니다.
Content-Type은 매개변수까지 나눠서 해석한다
Content-Type 값에는 ; 뒤에 매개변수가 붙을 수 있습니다. 예를 들어 application/x-www-form-urlencoded; charset=UTF-8은 형식이 폼이고 문자 집합이 UTF-8이라는 뜻입니다. 그래서 헤더 전체를 application/x-www-form-urlencoded와 문자열 그대로 비교하면 이런 올바른 요청을 놓칩니다. 세미콜론 앞의 '타입/하위 타입' 부분만 떼어 내고, 이 부분은 대소문자를 구분하지 않으므로 소문자로 맞춘 뒤 비교해야 합니다. charset 매개변수는 따로 꺼내 본문을 문자열로 바꿀 때 씁니다.
브라우저는 보통 폼 본문의 Content-Type에 charset을 붙이지 않고, 페이지의 문자 인코딩(오늘날에는 대부분 UTF-8)으로 값을 인코딩합니다. 그래서 아래 예제는 charset이 없으면 UTF-8을 씁니다. 다만 예제는 charset에 적힌 이름을 Charset.forName에 그대로 넘기므로, JDK가 아는 문자 집합이면 무엇이든 받아들입니다. 실제 서버라면 지원할 문자 집합 목록을 한곳에 정해 두고 그 밖의 값은 거절하는 편이 안전합니다.
폼 본문을 읽고 디코딩하는 코드
아래 FormRequestDecoder는 지금까지의 규칙을 코드로 옮긴 것입니다. 헤더를 해석하는 일은 이미 끝났다고 보고, Content-Length 값과 Content-Type 문자열을 인수로 받습니다. 코드는 세 부분으로 나뉩니다.
MediaType.parse:Content-Type을;로 나눠 앞부분을 소문자로 바꾸고,charset매개변수가 있으면 그 문자 집합을, 없으면 UTF-8을 고릅니다. 헤더가 없으면BadRequest를 던집니다.readExactly: 길이가 0 이상MAX_BODY_BYTES이하인지 확인한 뒤, 남은 바이트가 0이 될 때까지 반복해서 읽습니다. 중간에 스트림이 끝나면EOFException을 던집니다.decode: 미디어 타입을 확인하고 본문 바이트를 읽어 문자열로 바꾼 다음, 쌍마다 이름과 값을 퍼센트 디코딩해Map에 모읍니다.
디코딩 순서에도 이유가 있습니다. 본문을 &와 =로 나누는 일은 반드시 퍼센트 디코딩보다 먼저 해야 합니다. 디코딩을 먼저 하면 값 안에 %26으로 인코딩돼 있던 &나 %3D로 인코딩돼 있던 =가 구분 문자로 오해받기 때문입니다. 또 쌍을 첫 번째 =에서만 나누므로, a=b=c처럼 값에 =가 그대로 들어와도 값은 b=c로 남습니다.
같은 이름이 여러 번 나올 수 있어서 결과는 Map<String, List<String>>에 담습니다. 예를 들어 같은 name을 가진 체크박스를 여러 개 선택하면 그 이름의 쌍이 선택한 개수만큼 전송됩니다. 각 이름과 값은 URLDecoder.decode로 디코딩하는데, 이 메서드는 폼 형식의 규칙대로 +를 공백으로 바꾸고 %xy 형태로 이어진 바이트를 주어진 문자 집합으로 디코딩합니다. main은 같은 이름이 두 번 나오는 tag와, 인코딩된 =와 +가 들어 있는 note까지 담은 본문을 디코딩합니다.
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.EOFException;
import java.io.IOException;
import java.io.InputStream;
import java.net.URLDecoder;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.TreeMap;
public final class FormRequestDecoder {
static final int MAX_BODY_BYTES = 8 * 1024;
record MediaType(String value, Charset charset) {
static MediaType parse(String header) {
if (header == null || header.isBlank()) {
throw new BadRequest("Content-Type is required");
}
String[] parts = header.split(";");
String value = parts[0].trim().toLowerCase(Locale.ROOT);
Charset charset = StandardCharsets.UTF_8;
for (int index = 1; index < parts.length; index += 1) {
String parameter = parts[index].trim();
int equals = parameter.indexOf('=');
if (equals > 0 && parameter.substring(0, equals).trim().equalsIgnoreCase("charset")) {
String name = parameter.substring(equals + 1).trim().replace("\"", "");
charset = Charset.forName(name);
}
}
return new MediaType(value, charset);
}
}
static final class BadRequest extends RuntimeException {
BadRequest(String message) {
super(message);
}
BadRequest(String message, Throwable cause) {
super(message, cause);
}
}
static byte[] readExactly(InputStream input, int contentLength) throws IOException {
if (contentLength < 0 || contentLength > MAX_BODY_BYTES) {
throw new BadRequest("body length outside 0.." + MAX_BODY_BYTES);
}
ByteArrayOutputStream output = new ByteArrayOutputStream(contentLength);
byte[] buffer = new byte[Math.min(1024, Math.max(1, contentLength))];
int remaining = contentLength;
while (remaining > 0) {
int read = input.read(buffer, 0, Math.min(buffer.length, remaining));
if (read < 0) {
throw new EOFException("body ended with " + remaining + " bytes remaining");
}
output.write(buffer, 0, read);
remaining -= read;
}
return output.toByteArray();
}
static Map<String, List<String>> decode(
InputStream input,
int contentLength,
String contentType) throws IOException {
MediaType mediaType = MediaType.parse(contentType);
if (!mediaType.value().equals("application/x-www-form-urlencoded")) {
throw new BadRequest("unsupported media type: " + mediaType.value());
}
byte[] bytes = readExactly(input, contentLength);
String body = new String(bytes, mediaType.charset());
Map<String, List<String>> parameters = new LinkedHashMap<>();
if (body.isEmpty()) {
return Map.of();
}
for (String pair : body.split("&", -1)) {
int equals = pair.indexOf('=');
String encodedKey = equals < 0 ? pair : pair.substring(0, equals);
String encodedValue = equals < 0 ? "" : pair.substring(equals + 1);
try {
String key = URLDecoder.decode(encodedKey, mediaType.charset());
String value = URLDecoder.decode(encodedValue, mediaType.charset());
parameters.computeIfAbsent(key, ignored -> new ArrayList<>()).add(value);
} catch (IllegalArgumentException exception) {
throw new BadRequest("invalid percent encoding", exception);
}
}
Map<String, List<String>> result = new LinkedHashMap<>();
parameters.forEach((key, values) -> result.put(key, List.copyOf(values)));
return Map.copyOf(result);
}
public static void main(String[] args) throws Exception {
String wire = "id=min-1&name=%EC%A7%84&tag=java&tag=http¬e=a%3Db+c";
byte[] bytes = wire.getBytes(StandardCharsets.UTF_8);
Map<String, List<String>> form = decode(
new ByteArrayInputStream(bytes),
bytes.length,
"application/x-www-form-urlencoded; charset=UTF-8");
System.out.println(new TreeMap<>(form));
System.out.println("wireBytes=" + bytes.length);
}
}decode에는 본문 스트림과 함께 Content-Length에 해당하는 바이트 수와 Content-Type 헤더 값을 넘깁니다. 헤더에 charset=UTF-8을 적었으므로 퍼센트 인코딩된 바이트는 UTF-8로 해석됩니다.
{id=[min-1], name=[진], note=[a=b c], tag=[java, http]}
wireBytes=54name의 %EC%A7%84는 UTF-8 세 바이트로 복원된 뒤 진이 되었습니다. 두 번 전송된 tag는 보낸 순서대로 [java, http]에 모였고, note의 a%3Db+c는 %3D가 =로, +가 공백으로 바뀌어 a=b c가 되었습니다. 둘째 줄의 54는 전송된 본문의 바이트 수입니다. 퍼센트 인코딩 덕분에 본문이 모두 ASCII 문자라서 문자 수와 바이트 수가 같습니다.
출력할 때 new TreeMap<>(form)을 쓴 데도 이유가 있습니다. decode가 마지막에 쓰는 Map.copyOf는 반복 순서가 정해져 있지 않은 맵을 만들어서, 같은 프로그램도 실행할 때마다 키 순서가 달라질 수 있습니다. 실제로 form을 그대로 여러 번 출력해 보면 {note=[a=b c], tag=[java, http], id=[min-1], name=[진]}처럼 순서가 바뀌어 나오기도 합니다. 그래서 출력할 때만 TreeMap으로 옮겨 키 이름 순으로 정렬했습니다. 아래 그림의 표는 키를 전송된 순서대로 보여 주며, 출력 순서와는 관계가 없습니다.
폼 본문의 디코딩 결과
폼 값의 복원
폼 본문의 디코딩 결과
전송된 폼 쌍을 디코딩하고 같은 키의 값을 목록으로 모읍니다. 쿼리 매개변수와는 별도로 보관합니다.
id
- 전송된 값
- min-1
- 디코딩된 값 목록
- [min-1]
name
- 전송된 값
- %EC%A7%84
- 디코딩된 값 목록
- [진]
tag
- 전송된 값
- java와 http를 각각 전송
- 디코딩된 값 목록
- [java, http]
note
- 전송된 값
- a%3Db+c
- 디코딩된 값 목록
- [a=b c]
tag의 두 값은 java → http 순서로 모입니다. Map의 키 출력 순서는 보장되지 않으며, +는 공백으로 복원됩니다.
이 디코더가 하지 않는 일
decode는 헤더 문자열을 직접 읽지 않고 이미 해석된 int 길이를 받으므로, 요청 헤더를 다루는 일은 이 코드에 없습니다. Transfer-Encoding을 처리하거나 Content-Length 헤더가 여러 개 온 요청을 거절하는 일은 요청을 읽는 상위 계층이 맡습니다. 읽기 시간 제한을 두는 일과 오류를 HTTP 응답으로 바꾸는 일도 마찬가지입니다.
오류 분류도 단순합니다. 지원하지 않는 미디어 타입과 너무 큰 본문을 모두 BadRequest로 던지지만, 실제 서버라면 각각 415 Unsupported Media Type과 413 Content Too Large로 구분하는 편이 정확합니다. 연습 문제의 해답이 그렇게 구분합니다. 또 charset에 잘못된 이름이 오면 Charset.forName이 던진 IllegalCharsetNameException이나 UnsupportedCharsetException이 BadRequest로 감싸지지 않은 채 그대로 빠져나갑니다.
readExactly가 몇 가지 입력에서 어떻게 동작하는지 정리하면 다음과 같습니다.
| 스트림의 바이트 | contentLength | 결과 |
|---|---|---|
abcd | 3 | abc를 돌려주고, d는 스트림에 남음 |
abcd | 5 | 4바이트를 읽은 뒤 EOFException: body ended with 1 bytes remaining |
| 상관없음 | -1 | 읽기 전에 BadRequest: body length outside 0..8192 |
3바이트만 읽은 경우 스트림에 남은 d가 다음 요청의 첫 바이트인지, 클라이언트가 길이를 잘못 선언한 본문의 일부인지는 readExactly가 판단할 수 없습니다. 그것은 헤더를 검증해 메시지가 어디서 끝나는지 정하는 상위 계층의 몫입니다. 이 함수가 하는 일은 정해진 바이트 수를 하나도 더하거나 빼지 않고 읽는 것뿐입니다.
readExactly가 ByteArrayOutputStream을 쓴 것은 읽는 과정을 보여 주기 위해서입니다. 길이를 이미 검증했으니 new byte[contentLength]로 배열을 만들고 위치(offset)를 옮겨 가며 채워도 되고, Java 11부터는 InputStream.readNBytes(int)가 이 반복을 대신해 줍니다. readNBytes는 스트림이 일찍 끝나면 예외 대신 더 짧은 배열을 돌려주므로, 돌려받은 길이가 contentLength와 같은지 확인해야 합니다. 어느 방법이든 핵심은 짧은 read를 정상으로 여기고 남은 바이트가 0이 될 때까지 반복하는 것입니다. TCP는 바이트의 흐름일 뿐 보낸 쪽의 패킷 단위를 지켜 주지 않으므로, 네트워크 InputStream은 도착한 데이터에 따라 얼마든지 작은 조각을 돌려줄 수 있습니다.
디코더가 정해야 할 정책
빈 이름과 빈 값을 어떻게 다룰지도 정해야 합니다. 지금 코드는 id=min-1&처럼 끝에 &가 붙은 본문을 받으면 이름이 빈 문자열인 키를 하나 더 만들어 {=[], id=[min-1]}을 돌려줍니다. flag처럼 = 없이 이름만 온 쌍은 값이 빈 문자열인 flag=[]가 됩니다. 출력에서 []처럼 보이는 것은 빈 문자열 하나가 든 목록입니다. 이런 쌍을 무시할지, 오류로 볼지, 지금처럼 받아들일지는 디코더가 정할 정책입니다.
입력 크기도 바이트 수만으로는 막기 어렵습니다. 8,192바이트 안에도 a=&를 2,700번 넘게 넣을 수 있고, 그러면 작은 본문 하나가 문자열 객체 수천 개로 불어납니다. 그래서 바이트 수뿐 아니라 매개변수 개수, 이름 길이, 값 길이에도 상한을 둡니다.
더 깊이: URLDecoder가 거절하는 입력과 받아들이는 입력
URLDecoder.decode(String, Charset)는 Java 10부터 있는 메서드로, 잘못된 입력을 두 종류로 나눠 다룹니다. x=%나 x=%G1처럼 % 뒤에 16진수 두 자리가 오지 않는 잘못된 퍼센트 구문에는 IllegalArgumentException을 던지고, 위 디코더는 이를 BadRequest로 바꿉니다. 반면 x=%C3은 구문은 올바르지만 바이트 C3 하나만으로는 UTF-8 문자를 만들 수 없습니다. 공식 문서는 이런 잘못된 바이트를 문자 집합의 대체 값으로 바꾼다고 설명하며, 실제로 결과는 오류 없이 대체 문자 U+FFFD 하나가 됩니다.
즉 URLDecoder는 퍼센트 구문은 검사하지만 문자 인코딩은 엄격하게 검사하지 않습니다. 잘못된 UTF-8을 거절해야 한다면 %xy를 직접 바이트로 풀어낸 다음 CharsetDecoder로 디코딩합니다. StandardCharsets.UTF_8.newDecoder()로 만든 디코더는 기본적으로 잘못된 바이트를 보고하도록(CodingErrorAction.REPORT) 설정되어 있어서, 대체 문자를 넣는 대신 MalformedInputException을 던집니다.
쿼리 값과 폼 값을 섞지 않기
쿼리와 폼 값을 한 Map에 차례로 넣는 구현이 많습니다. 쿼리 값을 먼저 넣고 폼 값을 넣으면, 이름이 같을 때 폼 값이 쿼리 값을 덮어씁니다. 예를 들어 POST /members?id=query 요청의 본문에 id=form이 들어 있으면 서비스에는 form이 전달되는데, 합쳐진 Map만 보고는 어느 쪽 값이 쓰였는지 알 수 없습니다.
그래서 두 출처를 RequestParameters(query, form)처럼 따로 보관하고, 어느 출처를 읽을지는 엔드포인트마다 컨트롤러가 분명히 정하는 편이 안전합니다. 예를 들어 검색 GET은 쿼리만 읽고, 등록 POST는 폼만 읽는 규칙을 둘 수 있습니다. 두 출처를 모두 허용해야 한다면 같은 이름이 양쪽에 오는 요청을 거절하거나, 어느 쪽이 우선하는지 문서로 정해 둡니다.
같은 이름에 값이 여러 개 온 경우도 마찬가지입니다. 값 목록을 String 하나로 줄일 때는 첫 값을 쓸지, 마지막 값을 쓸지, 중복 자체를 거절할지 하나를 골라야 합니다. 그리고 이 선택은 애플리케이션 전체에서 같아야 합니다. 예를 들어 role=user&role=admin이 왔을 때 권한 검사 필터는 첫 값 user를 보고 통과시키고 업무 코드는 마지막 값 admin을 쓴다면, 권한 검사를 우회할 수 있습니다. 이런 공격을 HTTP 매개변수 오염(HTTP parameter pollution)이라고 합니다.
필드가 아예 오지 않은 것과 빈 값이 온 것도 구분해야 합니다. 텍스트 입력 칸을 비우고 제출하면 name=처럼 빈 값이 전송되지만, 체크박스는 선택하지 않으면 그 이름이 본문에 아예 나타나지 않습니다. 파서가 이 둘을 모두 null로 합쳐 버리면 컨트롤러는 "이름을 입력하세요"와 "요청에 이름 필드가 없습니다"를 구분해 알려 줄 수 없습니다. 그래서 요청을 읽는 단계에서는 정보를 되도록 잃지 않게 보관하고, 컨트롤러의 바인딩 단계에서 유스케이스에 필요한 값으로 줄입니다.
객체를 한곳에서 조립하는 컴포지션 루트
앞 글의 MemberApplicationService는 생성자로 저장소를 받았습니다. 서비스는 어떤 저장소가 들어올지 모르는 채로 그것을 쓰기만 하므로, 누군가는 InMemoryMemberRepository를 만들어 서비스에 넘겨야 합니다. 이처럼 프로그램에는 두 가지 역할이 있습니다. 하나는 조립 역할로, 어떤 구현을 쓸지 고르고 객체를 만들어 순서대로 연결합니다. 객체를 얼마나 오래 쓸지, 즉 수명(lifetime)을 정하는 것도 이 역할입니다. 다른 하나는 넘겨받은 객체를 불러 기능을 수행하는 사용 역할입니다.
조립 코드를 프로그램 시작 지점 가까운 한곳에 모은 것을 컴포지션 루트(composition root)라고 합니다. 옵션에 맞는 구현 고르기에서 처음 본 개념인데, 웹 애플리케이션에서는 저장소에서 컨트롤러까지 이어지는 연결이 길어지므로 더 중요해집니다. 다음 예제에서 저장소의 구체 클래스를 아는 곳은 assemble 메서드뿐입니다. 예제는 다음 부분으로 되어 있습니다.
MemoryMemberRepository:ArrayList에 회원을 보관하는 저장소입니다.LoggingMemberRepository: 다른 저장소를 감싸서, 호출이 올 때마다 그 이름을 기록한 뒤 감싼 저장소에 그대로 넘깁니다. 같은MemberRepository인터페이스를 구현하므로 서비스는 감싼 것인지 아닌지 구분하지 못합니다. 이렇게 같은 인터페이스로 감싸 기능을 덧붙이는 방식을 데코레이터(decorator)라고 합니다.MemberService와MemberController: 앞 글의 서비스와 컨트롤러를 줄인 것입니다.assemble: 저장소, 로깅 저장소, 서비스, 컨트롤러 순서로 객체를 만들어 연결합니다.
import java.util.ArrayList;
import java.util.List;
public final class ApplicationAssembly {
record Member(String id, String name) {
}
interface MemberRepository {
void save(Member member);
List<Member> findAll();
}
static final class MemoryMemberRepository implements MemberRepository {
private final List<Member> members = new ArrayList<>();
@Override
public void save(Member member) {
members.add(member);
}
@Override
public List<Member> findAll() {
return List.copyOf(members);
}
}
static final class LoggingMemberRepository implements MemberRepository {
private final MemberRepository delegate;
private final List<String> events = new ArrayList<>();
LoggingMemberRepository(MemberRepository delegate) {
this.delegate = delegate;
}
@Override
public void save(Member member) {
events.add("save:" + member.id());
delegate.save(member);
}
@Override
public List<Member> findAll() {
events.add("findAll");
return delegate.findAll();
}
List<String> events() {
return List.copyOf(events);
}
}
static final class MemberService {
private final MemberRepository repository;
MemberService(MemberRepository repository) {
this.repository = repository;
}
void register(String id, String name) {
repository.save(new Member(id, name));
}
List<Member> list() {
return repository.findAll();
}
}
static final class MemberController {
private final MemberService service;
MemberController(MemberService service) {
this.service = service;
}
String create(String id, String name) {
service.register(id, name);
return "created:" + id;
}
String index() {
return service.list().toString();
}
}
record Application(MemberController controller, LoggingMemberRepository repository) {
}
static Application assemble() {
MemoryMemberRepository storage = new MemoryMemberRepository();
LoggingMemberRepository observed = new LoggingMemberRepository(storage);
MemberService service = new MemberService(observed);
MemberController controller = new MemberController(service);
return new Application(controller, observed);
}
public static void main(String[] args) {
Application application = assemble();
System.out.println(application.controller().create("min-1", "Min"));
System.out.println(application.controller().index());
System.out.println(application.repository().events());
}
}assemble이 돌려준 Application은 컨트롤러와 함께 로깅 저장소의 참조도 들고 있습니다. 그래서 main은 마지막 줄에서 서비스를 거치지 않고 그 기록을 바로 꺼내 볼 수 있습니다.
created:min-1
[Member[id=min-1, name=Min]]
[save:min-1, findAll]첫 줄은 컨트롤러의 create가 서비스를 거쳐 회원을 저장한 뒤 돌려준 문자열이고, 둘째 줄은 index가 서비스와 저장소를 거쳐 얻은 목록입니다. 셋째 줄이 이 예제의 요점입니다. 서비스는 평소처럼 save와 findAll을 불렀을 뿐인데, 그 사이에 끼워 넣은 LoggingMemberRepository가 두 호출을 기록했습니다. 로깅을 넣으려고 고친 곳은 assemble 한 곳뿐이고, 서비스와 컨트롤러 코드는 한 줄도 바뀌지 않았습니다.
한편 컨트롤러는 인터페이스가 아니라 MemberService라는 구체 클래스를 직접 받습니다. 저장소를 인터페이스 뒤로 숨겼다고 해서 모든 의존을 인터페이스로 바꿔야 하는 것은 아닙니다. 서비스 구현을 바꿔 끼울 일이 없다면 구체 클래스에 의존해도 괜찮습니다. 저장소 구현을 바꿀 수 있게 하는 일과 서비스를 추상화하는 일은 따로 판단할 선택입니다.
객체의 수명과 시작·종료 순서
컴포지션 루트는 다른 곳에서 new를 금지하는 규칙이 아닙니다. 서비스 안에서 new Member(id, name)으로 값을 만드는 것은 자연스러운 일입니다. 컴포지션 루트에 모으는 것은 저장소처럼 여러 구현 가운데 하나를 골라야 하는 객체의 생성입니다.
조립할 때는 객체의 수명도 함께 정합니다. 요청마다 새 저장소를 만들면 메모리 저장소의 내용이 요청이 끝날 때마다 사라지고, 데이터베이스 저장소라면 연결 풀을 매번 새로 만드는 낭비가 생깁니다. 반대로 요청마다 따로 있어야 하는 가변 상태를 프로그램 전체가 공유하는 객체 하나에 보관하면, 동시에 처리되는 요청들의 값이 서로 섞입니다. 인터페이스 선언에는 이런 수명 정보가 드러나지 않으므로, 조립 코드에서 정하고 주석이나 문서로 밝혀 두어야 합니다.
순서도 중요합니다. 조립을 마친 뒤 라우트 계획을 만들고, 그다음에 HTTP 서버를 시작해 포트를 엽니다. 저장소 연결 확인, 컨트롤러 생성, 애노테이션 검증 가운데 하나라도 실패하면 포트를 열지 않고 프로그램을 끝내야 합니다. 그래야 잘못 조립된 서버가 요청을 받기 시작한 뒤에야 실패하는 일이 없습니다. 종료할 때는 반대 순서를 따릅니다. 먼저 새 요청을 받지 않게 하고 처리 중인 요청이 끝나기를 기다린 다음, 만든 순서의 역순으로 실행기와 저장소를 닫습니다.
위 ApplicationAssembly는 이 가운데 객체 조립만 보여 주는 예제입니다. 라우트 계획 만들기, 포트 열기, 종료 절차는 실행하지 않습니다. 내부의 ArrayList도 동시 요청을 위한 동기화를 하지 않습니다.
정리
- 폼 본문은
Content-Length가 알려 준 바이트 수만큼InputStream으로 반복해서 읽고, 다 모은 뒤에 문자로 바꿉니다.Reader로 문자 수를 세어 읽으면 다음 요청을 침범할 수 있습니다. Content-Type은;앞의 미디어 타입과charset매개변수를 나눠 해석하고, 길이가 음수이거나 너무 크면 읽기 전에 거절합니다.- 폼 본문은
&와=로 먼저 나눈 다음 퍼센트 디코딩하며, 같은 이름의 값은 목록으로 모읍니다.URLDecoder는 잘못된 퍼센트 구문은 거절하지만 잘못된 UTF-8 바이트는 대체 문자로 바꿉니다. - 쿼리 값과 폼 값은 따로 보관합니다. 어느 쪽을 읽을지와 중복 값을 어떻게 줄일지는 애플리케이션 전체에서 한 가지 규칙으로 정합니다.
- 구현을 고르고 연결하는 코드는 컴포지션 루트 한곳에 모으고, 객체의 수명과 시작·종료 순서도 그곳에서 정합니다.
연습 문제
미디어 타입을 키로 알맞은 디코더를 고르는 디스패처를 작성해 보세요. 요구 사항은 다음과 같습니다.
- 폼(
application/x-www-form-urlencoded)과text/plain만 지원하고, 다른 형식은 415로 거절합니다. - 본문이 32바이트를 넘으면 413으로 거절합니다.
- 형식이 잘못된 폼은 400으로 거절합니다.
- 쿼리와 본문은 결과에 별도의
Map으로 남깁니다.
해답 보기
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.util.LinkedHashMap;
import java.util.Locale;
import java.util.Map;
public final class MediaTypeDispatchSolution {
sealed interface DecodeResult permits Decoded, Rejected {
}
record Decoded(Map<String, String> query, Map<String, String> body) implements DecodeResult {
Decoded {
query = Map.copyOf(query);
body = Map.copyOf(body);
}
}
record Rejected(int status, String reason) implements DecodeResult {
}
interface BodyDecoder {
Map<String, String> decode(byte[] body);
}
static final class FormDecoder implements BodyDecoder {
@Override
public Map<String, String> decode(byte[] body) {
String text = new String(body, StandardCharsets.UTF_8);
Map<String, String> values = new LinkedHashMap<>();
if (text.isEmpty()) {
return values;
}
for (String pair : text.split("&", -1)) {
int equals = pair.indexOf('=');
String key = equals < 0 ? pair : pair.substring(0, equals);
String value = equals < 0 ? "" : pair.substring(equals + 1);
String decodedKey = URLDecoder.decode(key, StandardCharsets.UTF_8);
String decodedValue = URLDecoder.decode(value, StandardCharsets.UTF_8);
if (values.putIfAbsent(decodedKey, decodedValue) != null) {
throw new IllegalArgumentException("duplicate form key: " + decodedKey);
}
}
return values;
}
}
static final class PlainTextDecoder implements BodyDecoder {
@Override
public Map<String, String> decode(byte[] body) {
return Map.of("text", new String(body, StandardCharsets.UTF_8));
}
}
static final class Dispatcher {
private static final int MAX_BYTES = 32;
private final Map<String, BodyDecoder> decoders;
Dispatcher() {
decoders = Map.of(
"application/x-www-form-urlencoded", new FormDecoder(),
"text/plain", new PlainTextDecoder());
}
DecodeResult decode(Map<String, String> query, String contentType, byte[] body) {
if (body.length > MAX_BYTES) {
return new Rejected(413, "body exceeds " + MAX_BYTES + " bytes");
}
String mediaType = contentType.split(";", 2)[0]
.trim()
.toLowerCase(Locale.ROOT);
BodyDecoder decoder = decoders.get(mediaType);
if (decoder == null) {
return new Rejected(415, "unsupported media type: " + mediaType);
}
try {
return new Decoded(query, decoder.decode(body));
} catch (IllegalArgumentException exception) {
return new Rejected(400, exception.getMessage());
}
}
}
public static void main(String[] args) {
Dispatcher dispatcher = new Dispatcher();
byte[] form = "name=%EC%A7%84&age=27".getBytes(StandardCharsets.UTF_8);
System.out.println(dispatcher.decode(Map.of("source", "web"),
"application/x-www-form-urlencoded; charset=UTF-8", form));
System.out.println(dispatcher.decode(Map.of(), "application/json", new byte[] { 1, 2 }));
System.out.println(dispatcher.decode(Map.of(), "text/plain", new byte[40]));
}
}실행 결과:
Decoded[query={source=web}, body={name=진, age=27}]
Rejected[status=415, reason=unsupported media type: application/json]
Rejected[status=413, reason=body exceeds 32 bytes]Dispatcher.decode는 크기, 미디어 타입, 본문 형식을 이 순서로 검사하고, 처음 걸린 조건의 결과를 바로 돌려줍니다. 첫 줄은 세 검사를 모두 통과한 폼이라 쿼리와 본문이 각각의 Map에 담긴 Decoded가 되었습니다. 둘째 줄의 JSON은 2바이트라 크기 검사는 통과했지만 지원하지 않는 형식이라 415입니다. 셋째 줄은 지원하는 text/plain인데도 40바이트라서 미디어 타입을 보기도 전에 413으로 끝났습니다. 크기 문제와 형식 문제가 함께 있으면 크기 제한이 먼저 적용되는 것입니다. 첫 줄의 body는 Map.copyOf로 만든 맵이라, 실행할 때마다 name과 age의 순서가 바뀔 수 있습니다.
각 검사가 어떤 조건에서 끝나고 언제 다음 단계로 넘어가는지 단계별로 확인해 보세요.
본문 디스패처의 판정 순서
본문 32바이트 초과를 먼저 검사하고 media type 선택 후 parser 오류를 처리합니다. 413·415·400의 첫 거절 위치와 별도 query/body map의 생성 순서를 단계로 설명할 수 있습니다.
본문 디스패처의 판정 순서
본문 판정의 우선순위
본문 디스패처의 판정 순서
위에서 먼저 해당하는 조건이 결과를 결정합니다. 크기 검사를 통과해야 미디어 타입과 폼 문법을 검사합니다.
크기
- 조건
- body.length > 32
- 결과
- 413 · 디코더 조회 안 함
미디어 타입
- 조건
- 폼·text/plain 이외
- 결과
- 415 · 디코딩 안 함
폼 문법
- 조건
- 퍼센트 구문 오류 또는 중복 키
- 결과
- 400
디코딩 완료
- 조건
- 앞선 검사 통과
- 결과
- Decoded · query와 body 분리
32바이트 제한과 형식 오류가 함께 있으면 413이 우선합니다. 두 디코더는 UTF-8을 사용하고 query와 body를 합치지 않습니다.
이 예의 조건
- 정상 Content-Type·query·UTF-8 조건이며 HTTP 프레이밍은 상위 계층입니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 전체 읽기 정보 | 본문 디스패처의 판정 순서 본문 디스패처의 판정 순서 본문 디스패처의 판정 순서 본문 판정의 우선순위 본문 디스패처의 판정 순서 위에서 먼저 해당하는 조건이 결과를 결정합니다. 크기 검사를 통과해야 미디어 타입과 폼 문법을 검사합니다. 크기 조건 body.length > 32 결과 413 · 디코더 조회 안 함 미디어 타입 조건 폼·text/plain 이외 결과 415 · 디코딩 안 함 폼 문법 조건 퍼센트 구문 오류 또는 중복 키 결과 400 디코딩 완료 조건 앞선 검사 통과 결과 Decoded · query와 body 분리 32바이트 제한과 형식 오류가 함께 있으면 413이 우선합니다. 두 디코더는 UTF-8을 사용하고 query와 body를 합치지 않습니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 바이트 크기 가드 body.length>32이면 타입 조회 전에 끝냅니다. |
|
|
| 타입 선택 지원 타입이 없으면 415로 끝냅니다. |
|
|
| 해석 결과 구문·중복 IllegalArgumentException이면 400입니다. |
|
|
본문 디스패처의 판정 순서
본문 32바이트 초과를 먼저 검사하고 media type 선택 후 parser 오류를 처리합니다. 413·415·400의 첫 거절 위치와 별도 query/body map의 생성 순서를 단계로 설명할 수 있습니다.
본문 디스패처의 판정 순서
본문 판정의 우선순위
본문 디스패처의 판정 순서
위에서 먼저 해당하는 조건이 결과를 결정합니다. 크기 검사를 통과해야 미디어 타입과 폼 문법을 검사합니다.
크기
- 조건
- body.length > 32
- 결과
- 413 · 디코더 조회 안 함
미디어 타입
- 조건
- 폼·text/plain 이외
- 결과
- 415 · 디코딩 안 함
폼 문법
- 조건
- 퍼센트 구문 오류 또는 중복 키
- 결과
- 400
디코딩 완료
- 조건
- 앞선 검사 통과
- 결과
- Decoded · query와 body 분리
32바이트 제한과 형식 오류가 함께 있으면 413이 우선합니다. 두 디코더는 UTF-8을 사용하고 query와 body를 합치지 않습니다.
이 예의 조건
- 정상 Content-Type·query·UTF-8 조건이며 HTTP 프레이밍은 상위 계층입니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 전체 읽기 정보 | 본문 디스패처의 판정 순서 본문 디스패처의 판정 순서 본문 디스패처의 판정 순서 본문 판정의 우선순위 본문 디스패처의 판정 순서 위에서 먼저 해당하는 조건이 결과를 결정합니다. 크기 검사를 통과해야 미디어 타입과 폼 문법을 검사합니다. 크기 조건 body.length > 32 결과 413 · 디코더 조회 안 함 미디어 타입 조건 폼·text/plain 이외 결과 415 · 디코딩 안 함 폼 문법 조건 퍼센트 구문 오류 또는 중복 키 결과 400 디코딩 완료 조건 앞선 검사 통과 결과 Decoded · query와 body 분리 32바이트 제한과 형식 오류가 함께 있으면 413이 우선합니다. 두 디코더는 UTF-8을 사용하고 query와 body를 합치지 않습니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 바이트 크기 가드 body.length>32이면 타입 조회 전에 끝냅니다. |
|
|
| 타입 선택 지원 타입이 없으면 415로 끝냅니다. |
|
|
| 해석 결과 구문·중복 IllegalArgumentException이면 400입니다. |
|
|
해답에는 단순하게 만든 부분이 있습니다. charset 매개변수를 해석하지 않고 두 디코더 모두 UTF-8을 쓰며, 잘못된 UTF-8 바이트를 엄격하게 거절하지도 않습니다. 또 이미 메모리에 다 읽어 들인 바이트 배열의 크기를 검사하므로, 실제 서버에서는 앞에서 본 것처럼 본문을 읽기 전에 Content-Length 값으로 먼저 거절해야 메모리를 아낄 수 있습니다. 선언한 Content-Length와 실제로 받은 바이트 수가 같은지도 스트림 단계에서 먼저 확인해야 합니다. 반면 쿼리와 본문을 끝까지 따로 담았기 때문에, 컨트롤러는 어느 출처를 허용할지 분명하게 정할 수 있습니다.
FormDecoder의 중복 처리 정책은 FormRequestDecoder와 다릅니다. FormRequestDecoder는 같은 이름의 값을 목록으로 모으지만, FormDecoder는 디코딩한 이름이 이미 있으면 IllegalArgumentException을 던지고 디스패처가 이를 400으로 바꿉니다. 어느 쪽이 옳다기보다, 앞에서 본 것처럼 애플리케이션 전체가 한 가지 정책을 쓰는 것이 중요합니다.
확인 문제
다음 입력을 넣으면 어떤 결과가 나올지 예상해 보세요. 쿼리에는 source=web이 들어 있고, 따로 언급하지 않으면 미디어 타입과 문자 집합은 정상이라고 가정합니다.
- 해답의 디스패처에
application/json으로 33바이트 본문과 32바이트 본문을 각각 보냅니다. - 해답의 디스패처에
text/plain으로 이모지😀8개와 9개를 각각 보냅니다. - 폼 본문
x=1&%78=2를 이 글의FormRequestDecoder와 해답의FormDecoder에 각각 넘깁니다. - 해답의 디스패처에 폼 본문
x=%와x=%C3을 각각 보냅니다.
해설 보기
- 33바이트 본문은 크기 검사에서 걸려 413입니다. 32바이트 본문은 크기 검사를 통과하지만 JSON을 처리할 디코더가 없어 415입니다. 크기 검사는 32바이트를 넘을 때만 거절하므로 정확히 32바이트인 본문은 통과합니다.
😀는 UTF-8로 4바이트입니다. 8개는 32바이트라 크기 검사를 통과해text에 이모지 8개가 담긴Decoded가 되고, 9개는 36바이트라 413입니다. 글자 수가 아니라 바이트 수로 검사한다는 것을 보여 주는 예입니다.%78은x를 퍼센트 인코딩한 것입니다.FormRequestDecoder는 두 이름을 모두x로 디코딩한 뒤 값을 모아{x=[1, 2]}를 돌려줍니다. 해답의FormDecoder는 디코딩한 이름x가 두 번째로 나올 때 중복으로 보고 예외를 던지므로, 결과는Rejected[status=400, reason=duplicate form key: x]입니다. 디코딩하기 전의 표기만 비교했다면x와%78을 다른 이름으로 보아 중복을 놓쳤을 것입니다.x=%는%뒤에 16진수 두 자리가 없는 잘못된 구문이라URLDecoder가IllegalArgumentException을 던지고 400이 됩니다.x=%C3은 구문이 올바르므로 예외가 나지 않습니다. UTF-8 문자로 완성되지 않는 바이트C3이 대체 문자 U+FFFD로 바뀐Decoded가 됩니다. 앞의 "더 깊이"에서 본 것처럼 폼 구문 검사와 문자 인코딩 검사는 서로 다른 검사입니다.
다음 글
이것으로 애노테이션으로 선언한 라우트부터 인수 해석, 서비스 계층, 폼 본문 해석과 조립까지 작은 HTTP 실행기의 흐름을 모두 살펴보았습니다. 다음 장 람다와 함수형 인터페이스에서는 "무엇을 할지"라는 행동 자체를 값처럼 메서드에 넘기는 방법을 배웁니다.