회원 등록을 서비스 계층으로 분리하기
컨트롤러에 섞여 있던 회원 등록 규칙을 HTTP를 모르는 서비스로 옮기고, 서비스가 돌려준 결과를 컨트롤러가 HTTP 상태 코드와 HTML 응답으로 바꾸는 구조를 만듭니다.
이 장의 앞 글들에서는 애노테이션으로 선언한 라우트를 따라 요청을 컨트롤러 메서드로 보내고, 인수 해석기로 컨트롤러 매개변수 채우기로 그 메서드에 필요한 값을 채워 넣는 실행기를 만들었습니다. 이제 그 실행기 위에서 돌아갈 실제 기능이 필요합니다. 이 글에서는 콘솔에서 하던 회원 등록을 웹으로 옮기면서, 컨트롤러에 무엇을 두고 무엇을 빼야 하는지 살펴봅니다.
회원 프로그램을 웹으로 옮기면 입출력 방식은 완전히 바뀝니다. 키보드 대신 HTTP 요청에서 값을 받고, 콘솔 출력 대신 HTML을 돌려주어야 하기 때문입니다. 하지만 지켜야 할 규칙은 그대로입니다. 회원 ID는 서로 겹치면 안 되고, 이름과 나이는 정해진 범위 안에 있어야 합니다. 이런 규칙은 요청이 HTTP로 들어왔는지 콘솔에서 들어왔는지와 아무 관계가 없습니다.
그런데 이 규칙을 컨트롤러 안에 적어 두면, 나중에 HTML 화면 말고 JSON API나 파일 일괄 가져오기 도구가 생길 때마다 같은 검사를 다시 구현해야 합니다. 그래서 일을 둘로 나눕니다. 컨트롤러는 요청의 문자열을 Java 값으로 바꾸고 결과를 HTTP 응답으로 옮기는 일만 맡습니다. 회원 규칙은 HTTP를 전혀 모르는 서비스(service) 클래스가 맡습니다. 이렇게 업무 규칙을 모아 둔 층을 서비스 계층(service layer)이라고 부릅니다.
서비스가 할 일을 정할 때는 화면 목록보다 사용자가 이 기능으로 하려는 일, 즉 유스케이스(use case)를 먼저 적어 보는 것이 좋습니다. 회원 기능의 유스케이스는 두 가지입니다.
- 회원 등록
register(id, name, age): 유효한 새 회원 한 명을 저장하거나, 저장하지 않은 이유를 구체적으로 돌려줍니다. - 회원 목록 조회
listMembers(): 등록된 회원 목록을 호출한 쪽이 고칠 수 없는 사본으로 돌려줍니다.
홈 화면이나 등록 폼을 보여 주는 일은 이 목록에 없습니다. 그것은 화면 사이를 오가는 표현의 문제이지 회원 규칙이 아니기 때문입니다. 이렇게 나눠 두면 CSS나 HTML 구조를 바꿔도 회원 규칙은 건드릴 필요가 없습니다.
컨트롤러 하나가 모든 일을 맡으면
다음 컨트롤러의 add 메서드는 몇 줄 안 되지만 하는 일이 많습니다. age 문자열을 숫자로 바꾸고, 회원 객체를 만들어 Map에 저장하고, 결과를 HTML 문자열로 만듭니다. 같은 ID가 다시 들어왔을 때 어떻게 할지도 사실상 이 메서드가 정하는데, 따로 정한 규칙이 없어서 Map.put의 동작인 덮어쓰기가 그대로 정책이 되었습니다. main은 요청 세 개를 보내 이 구조에서 실제로 무슨 일이 일어나는지 보여 줍니다.
import java.util.LinkedHashMap;
import java.util.Map;
public final class FatMemberController {
record Member(String id, String name, int age) {
}
private final Map<String, Member> members = new LinkedHashMap<>();
String add(Map<String, String> parameters) {
int age = Integer.parseInt(parameters.get("age"));
Member member = new Member(parameters.get("id"), parameters.get("name"), age);
members.put(member.id(), member);
return "<h1>saved " + member.name() + "</h1>";
}
public static void main(String[] args) {
FatMemberController controller = new FatMemberController();
System.out.println(controller.add(Map.of("id", "min-1", "name", "Min", "age", "31")));
System.out.println(controller.add(Map.of("id", "min-1", "name", "<i>Other</i>", "age", "20")));
System.out.println(controller.members.values());
try {
controller.add(Map.of("id", "kim-2", "name", "Kim", "age", "abc"));
} catch (NumberFormatException exception) {
System.out.println(exception);
}
}
}main의 처음 두 요청은 같은 ID min-1을 쓰고, 마지막 요청은 나이에 숫자가 아닌 abc를 넣습니다. 셋째 줄은 두 요청 뒤에 저장소에 무엇이 남았는지를 보여 줍니다.
<h1>saved Min</h1>
<h1>saved <i>Other</i></h1>
[Member[id=min-1, name=<i>Other</i>, age=20]]
java.lang.NumberFormatException: For input string: "abc"두 번째 요청은 이미 있는 ID min-1로 들어왔는데도 오류 없이 처리됐습니다. 셋째 줄을 보면 처음 등록한 Min은 사라지고 Other가 그 자리를 차지했습니다. 둘째 줄에서는 사용자가 이름에 넣은 <i> 태그가 HTML에 그대로 들어갔습니다. 브라우저는 이것을 글자가 아니라 마크업으로 해석하므로, <i> 대신 <script>를 넣으면 이 페이지를 연 다른 사용자의 브라우저에서 스크립트가 실행됩니다.
마지막 줄은 나이 칸에 숫자가 아닌 값을 넣은 경우입니다. NumberFormatException이 컨트롤러 밖으로 그대로 빠져나가는데, 이 예외를 받은 실행기는 사용자의 입력 실수인지 서버 버그인지 구분할 정보가 없습니다. 그래서 인수 해석기 글에서 본 것처럼 분류되지 않은 예외는 500 응답이 되기 쉽습니다.
구조에도 문제가 있습니다. 저장 방식을 파일이나 데이터베이스로 바꾸려면 HTTP 요청을 다루는 이 클래스를 고쳐야 합니다. 또 등록이 실패했을 때 그 이유가 숫자 형식 오류인지 중복인지 나타낼 방법이 없어서, 호출하는 쪽이 실패의 종류에 따라 다르게 대응할 수도 없습니다.
바뀌는 이유마다 고칠 곳을 한 군데로
클래스를 나누는 목적은 클래스 수를 늘리는 것이 아닙니다. 코드가 바뀌는 이유마다 고칠 곳을 한 군데로 모으는 것입니다. 회원 기능에서 자주 바뀌는 것과 그때 고칠 곳을 짝지으면 다음과 같습니다.
| 바뀌는 것 | 고칠 곳 |
|---|---|
| 폼 필드 이름 | 컨트롤러에서 요청 값을 꺼내는 코드 |
| 나이 허용 범위 같은 회원 규칙 | 서비스 |
| 저장 기술(메모리, 파일, 데이터베이스) | 저장소 구현 |
| HTML 구조 | 렌더러 |
이렇게 되려면 의존하는 방향도 정해야 합니다. 컨트롤러는 서비스를 부르고, 서비스는 저장소 인터페이스만 부릅니다. 메모리나 데이터베이스를 쓰는 실제 저장소 클래스는 그 인터페이스를 구현할 뿐이고, 서비스는 어떤 구현이 연결됐는지 모릅니다.
컨트롤러 ──▶ 서비스 ──▶ MemberRepository (인터페이스)
▲ 구현
InMemoryMemberRepository, 파일 저장소, DB 저장소 …화살표가 한쪽으로만 흐르기 때문에 저장소 구현을 바꿔도 서비스와 컨트롤러는 그대로입니다. 회원 규칙을 바꿀 때도 서비스가 돌려주는 결과의 종류가 같다면 컨트롤러는 고칠 필요가 없습니다. 이렇게 한 곳의 수정이 다른 층의 구체 클래스까지 줄줄이 번지지 않게 됩니다.
검증은 레코드 생성자와 서비스 중 어디에 둘까
Member 레코드의 생성자에서 값을 검사하는 방법도 있습니다. 나이가 1 이상 120 이하여야 한다는 조건처럼 값 하나만 보고 판단할 수 있다면 생성자에 둘 만합니다. 그러면 잘못된 Member 객체는 아예 만들어지지 않습니다. 하지만 "이미 등록된 ID인가"는 Member 객체 하나만 봐서는 알 수 없습니다. 저장소에 무엇이 들어 있는지 봐야 하므로, 이런 규칙은 저장소를 쓸 수 있는 서비스가 판단해야 합니다.
즉 값 자체가 언제나 지켜야 하는 조건과 특정 유스케이스에서 지켜야 하는 정책을 구분하면 검증 위치가 자연스럽게 정해집니다. 이 글의 서비스는 ID 형식, 이름 길이, 나이 범위까지 모두 서비스에서 검사합니다. 생성자에서 예외를 던지면 첫 번째 위반에서 멈추지만, 서비스에서 검사하면 위반을 모두 모아 한 번에 알려 줄 수 있기 때문입니다.
등록 결과를 값으로 돌려주는 서비스
이제 회원 규칙을 서비스로 옮깁니다. 아래 MemberApplicationService에는 HTTP와 관련된 코드가 하나도 없습니다. 코드가 길어 보이지만 다음 네 부분으로 나뉩니다.
MemberRepository: 서비스가 저장소에 요구하는 일을 정한 인터페이스입니다.insertIfAbsent는 같은 ID가 없을 때만 회원을 넣고, 넣었는지를boolean으로 알려 줍니다.InMemoryMemberRepository:LinkedHashMap에 회원을 보관하는 구현입니다. 여러 스레드가 동시에 불러도 안전하도록 메서드마다synchronized를 붙였습니다.Registration과 세 레코드: 등록 결과는 저장에 성공한Created, 입력 규칙을 어긴Rejected, ID가 겹친Duplicate가운데 하나입니다.register와listMembers: 두 유스케이스를 구현한 메서드입니다.
Registration에 붙은 sealed와 permits는 이 인터페이스를 구현할 수 있는 타입을 Created, Rejected, Duplicate 셋으로 제한합니다. 그래서 컴파일러는 등록 결과가 이 셋 중 하나라는 것을 압니다. 이 성질은 뒤의 연습 문제에서 switch로 결과를 나눌 때 쓸모가 있으며, sealed의 자세한 문법은 sealed 계층과 상태 분기에서 다룹니다.
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
public final class MemberApplicationService {
record Member(String id, String name, int age) {
}
interface MemberRepository {
boolean insertIfAbsent(Member member);
Optional<Member> findById(String id);
List<Member> findAll();
}
static final class InMemoryMemberRepository implements MemberRepository {
private final Map<String, Member> members = new LinkedHashMap<>();
@Override
public synchronized boolean insertIfAbsent(Member member) {
if (members.containsKey(member.id())) {
return false;
}
members.put(member.id(), member);
return true;
}
@Override
public synchronized Optional<Member> findById(String id) {
return Optional.ofNullable(members.get(id));
}
@Override
public synchronized List<Member> findAll() {
return List.copyOf(members.values());
}
}
sealed interface Registration permits Created, Rejected, Duplicate {
}
record Created(Member member) implements Registration {
}
record Rejected(List<String> violations) implements Registration {
Rejected {
violations = List.copyOf(violations);
}
}
record Duplicate(String id) implements Registration {
}
private final MemberRepository repository;
MemberApplicationService(MemberRepository repository) {
this.repository = repository;
}
Registration register(String rawId, String rawName, int age) {
String id = rawId == null ? "" : rawId.trim();
String name = rawName == null ? "" : rawName.trim();
List<String> violations = new ArrayList<>();
if (!id.matches("[a-z0-9-]{3,20}")) {
violations.add("id must use 3 to 20 lowercase letters, digits, or hyphens");
}
if (name.isBlank() || name.length() > 40) {
violations.add("name must contain 1 to 40 characters");
}
if (age < 1 || age > 120) {
violations.add("age must be between 1 and 120");
}
if (!violations.isEmpty()) {
return new Rejected(violations);
}
Member member = new Member(id, name, age);
return repository.insertIfAbsent(member)
? new Created(member)
: new Duplicate(id);
}
List<Member> listMembers() {
return repository.findAll().stream()
.sorted(Comparator.comparing(Member::id))
.toList();
}
public static void main(String[] args) {
MemberApplicationService service = new MemberApplicationService(
new InMemoryMemberRepository());
System.out.println(service.register("min-1", "Min", 31));
System.out.println(service.register("min-1", "Other", 20));
System.out.println(service.register("?", "", 0));
System.out.println(service.listMembers());
}
}register는 위에서 아래로 읽으면 됩니다. 먼저 null을 빈 문자열로 바꾸고 앞뒤 공백을 잘라 입력을 정리합니다. 다음으로 ID 형식, 이름 길이, 나이 범위를 차례로 검사합니다. 위반을 발견해도 바로 돌아가지 않고 violations 목록에 모아 둡니다. 위반이 하나라도 있으면 Rejected를 돌려주고, 없을 때만 저장소의 insertIfAbsent를 불러 그 결과에 따라 Created나 Duplicate를 돌려줍니다. ID 검사에 쓴 matches는 문자열 전체가 정규 표현식과 맞아야 참을 돌려줍니다. 여기서 [a-z0-9-]{3,20}은 영문 소문자, 숫자, 하이픈만으로 된 3자 이상 20자 이하의 문자열을 뜻합니다.
실행 결과:
Created[member=Member[id=min-1, name=Min, age=31]]
Duplicate[id=min-1]
Rejected[violations=[id must use 3 to 20 lowercase letters, digits, or hyphens, name must contain 1 to 40 characters, age must be between 1 and 120]]
[Member[id=min-1, name=Min, age=31]]첫 줄은 모든 검사를 통과해 저장된 경우입니다. 두 번째 요청은 이름과 나이는 올바르지만 ID min-1이 이미 있어서 insertIfAbsent가 false를 돌려주었고, 그래서 Duplicate가 되었습니다. 세 번째 요청은 ID ?가 형식에 맞지 않고 이름이 비었으며 나이 0도 범위를 벗어나므로, 위반 세 개가 한꺼번에 담긴 Rejected가 되었습니다. 마지막 줄을 보면 저장소에는 여전히 Min 한 명만 있습니다. 실패한 두 요청은 처음 등록한 회원을 덮어쓰지도 지우지도 않았습니다.
세 요청이 차례로 처리되는 동안 저장소가 어떻게 바뀌는지 단계별로 따라가 보세요.
연속 등록 요청과 저장 상태
같은 저장소에 Min 등록 성공, 같은 id의 Other 거절, 빈 이름과 나이 0 거절이 이어집니다. 뒤의 실패가 최초 회원을 바꾸지 않는 저장소 상태를 추적할 수 있습니다.
연속 등록 요청과 저장 상태
회원 등록의 상태 변화
연속 등록 요청과 저장 상태
세 요청을 순서대로 처리해도 중복과 검증 실패는 첫 번째 회원을 덮어쓰지 않습니다.
등록 검증과 원자 삽입만 저장소에 도달한다
잘못된 세 입력 필드는 Rejected 3위반으로 끝난다. 정상 입력만 insertIfAbsent를 수행하며 첫 Created만 Min 31을 저장하고 중복 Other는 기존 회원을 덮어쓰지 않는다. Created·Duplicate·Rejected는 서비스 결과이며 HTTP 상태 코드가 아니다.
상태별 상세 해설
min-1 · Min · 31
- 업무 결과
- Created
- 저장된 회원
- min-1 / Min / 31
min-1 · Other · 20
- 업무 결과
- Duplicate(min-1)
- 저장된 회원
- 기존 회원 그대로
? · 빈 이름 · 0
- 업무 결과
- Rejected · ID·이름·나이 3개 위반
- 저장된 회원
- 기존 회원 그대로
최종 저장 상태는 min-1 / Min / 31 한 명입니다. Created·Duplicate·Rejected는 서비스 결과이며 HTTP 상태 코드가 아닙니다.
이 예의 조건
- 반환 결과는 서비스 결과이며 HTTP 상태 전송이 아니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 10 | min-1 |
| 11 | Min |
| 12 | 31 |
| 23 | 최종 저장 상태는 min-1 |
| 24 | Min |
| 25 | 31 한 명입니다. Created·Duplicate·Rejected는 서비스 결과이며 HTTP 상태 코드가 아닙니다. |
| 구분 | 내용 |
|---|---|
| 설명 | |
| 전체 읽기 정보 | 연속 등록 요청과 저장 상태 연속 등록 요청과 저장 상태 연속 등록 요청과 저장 상태 회원 등록의 상태 변화 연속 등록 요청과 저장 상태 세 요청을 순서대로 처리해도 중복과 검증 실패는 첫 번째 회원을 덮어쓰지 않습니다. min-1 · Min · 31 업무 결과 Created 저장된 회원 min-1 / Min / 31 min-1 · Other · 20 업무 결과 Duplicate(min-1) 저장된 회원 기존 회원 그대로 ? · 빈 이름 · 0 업무 결과 Rejected · ID·이름·나이 3개 위반 저장된 회원 기존 회원 그대로 최종 저장 상태는 min-1 / Min / 31 한 명입니다. Created·Duplicate·Rejected는 서비스 결과이며 HTTP 상태 코드가 아닙니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 첫 정상 등록 min-1의 Min을 저장합니다. |
|
|
| 중복 거절 같은 id의 Other를 거절합니다. |
|
|
| 검증 거절 잘못된 빈 이름과 나이 0의 요청을 거절합니다. |
|
|
연속 등록 요청과 저장 상태
같은 저장소에 Min 등록 성공, 같은 id의 Other 거절, 빈 이름과 나이 0 거절이 이어집니다. 뒤의 실패가 최초 회원을 바꾸지 않는 저장소 상태를 추적할 수 있습니다.
연속 등록 요청과 저장 상태
회원 등록의 상태 변화
연속 등록 요청과 저장 상태
세 요청을 순서대로 처리해도 중복과 검증 실패는 첫 번째 회원을 덮어쓰지 않습니다.
등록 검증과 원자 삽입만 저장소에 도달한다
잘못된 세 입력 필드는 Rejected 3위반으로 끝난다. 정상 입력만 insertIfAbsent를 수행하며 첫 Created만 Min 31을 저장하고 중복 Other는 기존 회원을 덮어쓰지 않는다. Created·Duplicate·Rejected는 서비스 결과이며 HTTP 상태 코드가 아니다.
상태별 상세 해설
min-1 · Min · 31
- 업무 결과
- Created
- 저장된 회원
- min-1 / Min / 31
min-1 · Other · 20
- 업무 결과
- Duplicate(min-1)
- 저장된 회원
- 기존 회원 그대로
? · 빈 이름 · 0
- 업무 결과
- Rejected · ID·이름·나이 3개 위반
- 저장된 회원
- 기존 회원 그대로
최종 저장 상태는 min-1 / Min / 31 한 명입니다. Created·Duplicate·Rejected는 서비스 결과이며 HTTP 상태 코드가 아닙니다.
이 예의 조건
- 반환 결과는 서비스 결과이며 HTTP 상태 전송이 아니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 10 | min-1 |
| 11 | Min |
| 12 | 31 |
| 23 | 최종 저장 상태는 min-1 |
| 24 | Min |
| 25 | 31 한 명입니다. Created·Duplicate·Rejected는 서비스 결과이며 HTTP 상태 코드가 아닙니다. |
| 구분 | 내용 |
|---|---|
| 설명 | |
| 전체 읽기 정보 | 연속 등록 요청과 저장 상태 연속 등록 요청과 저장 상태 연속 등록 요청과 저장 상태 회원 등록의 상태 변화 연속 등록 요청과 저장 상태 세 요청을 순서대로 처리해도 중복과 검증 실패는 첫 번째 회원을 덮어쓰지 않습니다. min-1 · Min · 31 업무 결과 Created 저장된 회원 min-1 / Min / 31 min-1 · Other · 20 업무 결과 Duplicate(min-1) 저장된 회원 기존 회원 그대로 ? · 빈 이름 · 0 업무 결과 Rejected · ID·이름·나이 3개 위반 저장된 회원 기존 회원 그대로 최종 저장 상태는 min-1 / Min / 31 한 명입니다. Created·Duplicate·Rejected는 서비스 결과이며 HTTP 상태 코드가 아닙니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 첫 정상 등록 min-1의 Min을 저장합니다. |
|
|
| 중복 거절 같은 id의 Other를 거절합니다. |
|
|
| 검증 거절 잘못된 빈 이름과 나이 0의 요청을 거절합니다. |
|
|
여기서 Created, Rejected, Duplicate는 서비스가 돌려준 업무 결과일 뿐 HTTP 상태 코드가 아닙니다. 이 결과를 HTTP 응답으로 옮기는 것은 컨트롤러의 일입니다. 예를 들어 JSON API라면 Created를 201 Created로, Rejected를 400 Bad Request로, Duplicate를 409 Conflict로 바꿀 수 있습니다. 같은 서비스를 HTML 폼에서 쓸 때는 성공 응답이 달라지는데, 이것은 글 뒤쪽의 리다이렉트에서 다룹니다.
검사와 저장을 메서드 하나로 묶은 이유
저장소에서 findById로 먼저 찾아보고 없으면 저장하는 방식이 더 자연스러워 보일 수 있습니다. 하지만 요청을 여러 스레드가 동시에 처리하는 서버에서는 두 호출 사이에 다른 스레드가 끼어들 수 있습니다. 같은 ID로 두 요청이 거의 동시에 들어왔다고 해 봅시다.
| 순서 | 스레드 A (Min 등록) | 스레드 B (Other 등록) | 저장소 |
|---|---|---|---|
| 1 | findById("min-1") → 없음 | 비어 있음 | |
| 2 | findById("min-1") → 없음 | 비어 있음 | |
| 3 | Min 저장 | Min | |
| 4 | Other 저장 | Other (Min을 덮어씀) |
두 스레드 모두 "없음"을 확인했으니 둘 다 저장으로 넘어갔고, 결국 먼저 등록한 Min이 사라졌습니다. 이처럼 확인과 행동 사이에 다른 스레드가 끼어들어 결과가 실행 순서에 따라 달라지는 상황이 경쟁 조건(race condition)입니다. insertIfAbsent는 확인과 저장을 synchronized 메서드 하나 안에서 하므로, 한 스레드가 확인부터 저장까지 마칠 때까지 다른 스레드는 들어올 수 없습니다. 메모리 저장소라면 ConcurrentHashMap의 putIfAbsent로도 같은 일을 한 번의 원자적(atomic) 연산으로 할 수 있습니다.
데이터베이스로 저장소를 바꾸면 방법이 달라집니다. 여러 서버가 같은 데이터베이스를 쓰면 한 JVM 안의 synchronized로는 다른 서버의 요청을 막을 수 없기 때문입니다. 이때는 ID 열에 고유 제약(unique constraint)을 걸어 데이터베이스가 중복 삽입을 거절하게 하고, 저장소 구현이 그 오류를 false로 바꿔 서비스에 알립니다. 서비스 입장에서는 "같은 ID가 없을 때만 넣는다"는 약속이 그대로이고, 그 약속을 지키는 수단만 구현마다 달라집니다.
입력 검사를 중복 검사보다 먼저 하는 이유
register는 입력 검사를 모두 마친 뒤에야 저장소를 부릅니다. 이 순서가 결과를 어떻게 바꾸는지 보겠습니다. min-1을 등록해 둔 서비스에 같은 ID로 빈 이름과 나이 0을 보내면, ID가 겹치는데도 결과는 Duplicate가 아니라 다음과 같은 Rejected입니다.
Rejected[violations=[name must contain 1 to 40 characters, age must be between 1 and 120]]ID min-1은 형식 검사를 통과했고 이름과 나이에서 위반 두 개가 나왔으므로, register는 insertIfAbsent를 부르기 전에 돌아갑니다. 이 순서에는 두 가지 이점이 있습니다. 잘못된 입력은 저장소에 닿지 않으니 덮어쓰기나 불필요한 조회가 생기지 않습니다. 또 사용자는 고쳐야 할 입력을 한 번에 모두 알게 됩니다. 순서가 반대였다면 사용자는 먼저 중복 오류를 보고 ID를 고쳐 다시 보낸 뒤에야 이름과 나이도 틀렸다는 것을 알게 됩니다.
이름 길이 검사에 쓴 String.length()에는 주의할 점이 있습니다. 이 메서드는 사람이 보는 글자 수가 아니라 UTF-16 코드 단위(code unit) 수를 셉니다. 한글 한 글자는 1이지만 "😀".length()는 2이므로, 이모지로만 된 이름은 20개만 넣어도 길이가 40이 됩니다. codePointCount로 코드 포인트를 세면 이 이모지를 1로 셀 수 있지만, 여러 코드 포인트가 합쳐져 한 글자로 보이는 이모지까지 맞게 세지는 못합니다. 그러니 "40자"라는 규칙이 무엇을 세는지부터 정해야 합니다.
예상한 실패는 예외 대신 값으로
모든 실패를 예외로 나타낼 필요는 없습니다. 입력이 규칙에 맞지 않거나 ID가 겹치는 것은 서비스를 정상적으로 쓰는 중에도 늘 일어나는, 예상한 결과입니다. 이런 결과를 Rejected, Duplicate 같은 값으로 돌려주면 호출하는 쪽은 반환값만 보고 분기하면 됩니다. 게다가 Registration이 sealed이므로 결과를 switch로 나눌 때 한 경우라도 빠뜨리면 컴파일 오류가 납니다. 나중에 결과 종류가 늘어나도 처리하지 않은 곳을 컴파일러가 찾아 줍니다.
반대로 저장소 연결이 끊기는 것처럼 서비스가 처리할 방법이 없는 장애는 예외로 던지는 편이 맞습니다. 이런 예외는 실행기의 가장 바깥에서 500 응답으로 바뀝니다. 이렇게 나눠 두면 로그에 남는 스택 트레이스는 정말로 조사해야 할 서버 문제만 가리키게 됩니다.
저장소 인터페이스가 약속해야 할 것
인터페이스를 만들었다고 해서 저절로 결합이 느슨해지지는 않습니다. 예를 들어 파일에 저장하는 구현을 만들면서 인터페이스 메서드에 throws IOException을 붙이거나 파일 경로를 매개변수로 받게 하면, 서비스와 컨트롤러까지 파일이라는 저장 기술을 알게 됩니다. 인터페이스는 insertIfAbsent, findById, findAll처럼 유스케이스에 필요한 일로만 정하는 것이 좋습니다. 구현에서 생긴 IOException 같은 세부 오류는 저장소 전용 예외로 바꿔 던집니다.
findAll이 무엇을 돌려주는지도 중요합니다. 내부 Map의 values()를 그대로 돌려주면, 이 컬렉션은 Map과 연결된 뷰라서 호출한 쪽이 원소를 지우는 순간 저장소에서도 회원이 지워집니다. 또 다른 스레드가 회원을 추가하는 동안 이 뷰를 순회하면 ConcurrentModificationException이 날 수 있습니다. 예제는 List.copyOf로 호출한 순간의 사본(snapshot)을 만들어 돌려줍니다. 이 사본은 수정할 수 없어서 add를 부르면 UnsupportedOperationException이 나고, 나중에 저장소가 바뀌어도 이미 받은 사본에는 반영되지 않습니다.
확인 문제
새 InMemoryMemberRepository로 만든 서비스에서 먼저 listMembers()로 목록을 받아 둡니다. 그다음 올바른 회원 한 명을 등록하고 listMembers()를 다시 부릅니다. 먼저 받아 둔 목록과 새로 받은 목록의 크기는 각각 얼마일까요?
해설 보기
먼저 받아 둔 목록의 크기는 0이고, 새로 받은 목록의 크기는 1입니다. 첫 목록은 저장소가 비어 있던 순간에 만든 사본이므로 뒤에 회원이 등록돼도 바뀌지 않습니다. listMembers가 돌려주는 목록은 Stream.toList()로 만든 수정할 수 없는 목록이기도 해서, 받아 둔 목록에 직접 회원을 추가할 수도 없습니다. 언제 받은 목록인지가 분명하고, 받은 쪽이 저장소를 건드릴 수 없다는 것이 사본을 돌려주는 이유입니다.
정렬 순서도 정해 두어야 합니다. 예제에서 저장소의 findAll은 등록한 순서대로 사본을 주고, ID 순 정렬은 서비스의 listMembers가 합니다. 정렬이 저장소가 지켜야 할 약속인지 화면에 보여 주는 방식의 선택인지를 정해 두지 않으면, 구현을 바꿀 때 순서가 슬그머니 달라질 수 있습니다. 예를 들어 LinkedHashMap 대신 HashMap을 쓰는 구현으로 바꾸면 findAll이 돌려주는 순서가 달라집니다. 회원이 많아져 페이지 단위로 보여 줘야 한다면 전체 목록을 메모리로 가져와 정렬하는 지금의 방식으로는 부족하므로, 페이지 단위로 가져오는 메서드를 저장소 인터페이스에 새로 정의해야 합니다.
메모리, 파일, 데이터베이스 저장소는 같은 인터페이스를 구현해도 성질이 다릅니다. 메모리 저장소는 서버를 다시 시작하면 내용이 모두 사라지므로 운영용 저장소로 쓸 수 없습니다. 파일 저장소라면 대비할 일이 더 많습니다. 여러 프로세스가 같은 파일에 동시에 쓸 수 있고, 쓰는 도중 서버가 멈추면 파일 일부만 기록될 수 있으며, 저장 형식이 바뀌면 예전 파일도 읽을 수 있어야 합니다. 인터페이스는 이런 차이를 코드에서 감춰 줄 뿐 없애 주지는 않으므로, 어떤 구현을 쓸지는 애플리케이션을 조립할 때 분명하게 골라야 합니다. 조립하는 방법은 다음 글에서 다룹니다.
회원 이름을 HTML에 안전하게 넣기
회원 이름에 <script>alert(1)</script>가 들어 있다고 해 봅시다. 앞의 FatMemberController처럼 이름을 문자열 연결로 HTML에 넣으면 브라우저는 이 부분을 데이터가 아니라 마크업으로 해석해 스크립트를 실행합니다. 사용자가 입력한 값이 다른 사용자의 브라우저에서 코드로 실행되는 이런 공격을 크로스 사이트 스크립팅(cross-site scripting, XSS)이라고 합니다.
막는 방법은 HTML이 특별하게 해석하는 문자를 엔티티(entity)로 바꿔 쓰는 것이고, 이를 이스케이프(escape)한다고 합니다. 예를 들어 <를 <로 바꾸면 브라우저는 태그의 시작이 아니라 <라는 글자를 화면에 보여 줍니다. 아래 escapeText는 텍스트 안에서 문제가 되는 &, <, >를 바꾸고, 따옴표로 감싼 속성 값 안에서 문제가 되는 "와 '도 함께 바꿉니다. &를 가장 먼저 바꾸는 데도 이유가 있습니다. <를 <로 바꾼 뒤에 &를 바꾸면 방금 만든 <의 &까지 다시 바뀌어 &lt;가 되어 버립니다.
다만 이 함수 하나로 HTML 안의 모든 위치를 지킬 수는 없습니다. href 같은 URL 속성, <script> 안의 JavaScript, CSS는 해석 규칙이 저마다 달라서 그 문맥에 맞는 인코딩이 따로 필요합니다. 예를 들어 링크 주소로 javascript:alert(1)을 받으면 이 값에는 바꿀 문자가 하나도 없으므로 escapeText를 거쳐도 그대로 남고, href 속성에 들어가면 클릭하는 순간 스크립트가 실행됩니다. 아래 렌더러는 회원 정보를 텍스트 위치에만 넣으므로 escapeText로 충분합니다.
응답 헤더도 맞춰야 합니다. Content-Type에 charset=UTF-8을 적어 본문의 문자 인코딩을 알리고, Content-Length에는 문자 수가 아니라 본문의 바이트 수를 적습니다. 한글 한 글자는 Java 문자열에서 길이 1이지만 UTF-8로는 3바이트이기 때문입니다. 바이트와 문자를 구분해야 하는 이유는 HTTP 본문 읽기: Content-Length와 chunked에서 자세히 보았습니다.
다음 렌더러는 회원 목록을 HTML로 만든 뒤 문자열을 UTF-8 바이트로 바꾸고, 그 바이트 배열의 길이를 Content-Length로 씁니다. Representation 레코드는 응답에 실을 Content-Type과 본문 바이트를 함께 담습니다.
import java.nio.charset.StandardCharsets;
import java.util.List;
public final class SafeMemberPageRenderer {
record Member(String id, String name, int age) {
}
record Representation(String contentType, byte[] body) {
int contentLength() {
return body.length;
}
String text() {
return new String(body, StandardCharsets.UTF_8);
}
}
static String escapeText(String value) {
return value
.replace("&", "&")
.replace("<", "<")
.replace(">", ">")
.replace("\"", """)
.replace("'", "'");
}
static Representation render(List<Member> members) {
StringBuilder html = new StringBuilder();
html.append("<!doctype html><html><body>");
html.append("<h1>Members</h1><ul>");
for (Member member : members) {
html.append("<li>")
.append(escapeText(member.id()))
.append(" | ")
.append(escapeText(member.name()))
.append(" | ")
.append(member.age())
.append("</li>");
}
html.append("</ul></body></html>");
byte[] body = html.toString().getBytes(StandardCharsets.UTF_8);
return new Representation("text/html; charset=UTF-8", body);
}
public static void main(String[] args) {
Representation page = render(List.of(
new Member("min-1", "<Admin>", 31),
new Member("jin-2", "진", 27)));
System.out.println(page.contentType());
System.out.println("bytes=" + page.contentLength());
System.out.println(page.text());
}
}escapeText가 &를 가장 먼저 바꾸는 데는 이유가 있습니다. <를 <로 바꾼 뒤에 &를 바꾸면, 방금 만든 <의 &까지 다시 &로 바뀌어 화면에 <라는 글자가 보이게 됩니다.
text/html; charset=UTF-8
bytes=126
<!doctype html><html><body><h1>Members</h1><ul><li>min-1 | <Admin> | 31</li><li>jin-2 | 진 | 27</li></ul></body></html>세 번째 줄에서 이름 <Admin>은 <Admin>으로 바뀌었으므로, 브라우저는 이것을 태그로 해석하지 않고 <Admin>이라는 글자로 보여 줍니다. 두 번째 줄의 126은 본문의 바이트 수입니다. HTML 문자열 자체의 길이는 124인데, 진 한 글자가 UTF-8로 3바이트를 차지해 2바이트가 늘었습니다. 만약 String.length() 값인 124를 Content-Length에 적으면 브라우저는 본문의 마지막 2바이트를 읽지 않습니다. 연결을 재사용하는 경우에는 남은 2바이트를 다음 응답의 시작으로 잘못 읽을 수도 있습니다.
등록은 POST로, 성공한 뒤에는 리다이렉트로
화면과 기능에 맞는 HTTP 메서드도 정해야 합니다. 회원 목록과 등록 폼을 보여 주는 요청은 서버 상태를 바꾸지 않으므로 GET으로 받고, 회원을 등록하는 요청은 상태를 바꾸므로 POST로 받습니다. 애노테이션 라우트에서 본 것처럼 경로가 /members로 같아도 메서드가 다르면 서로 다른 라우트입니다. GET이 조회만 한다는 약속을 지켜야 브라우저나 캐시가 링크를 미리 읽거나 요청을 다시 보내도 문제가 생기지 않습니다.
그런데 POST로 등록에 성공한 뒤 바로 HTML을 응답하면 문제가 하나 생깁니다. 사용자가 그 화면에서 새로고침을 누르면 브라우저는 마지막 요청인 POST를 다시 보내려 하고, 사용자가 확인을 누르면 같은 등록 요청이 한 번 더 전송됩니다. 회원 등록이라면 중복 오류로 끝나겠지만, 주문이나 결제였다면 같은 일이 두 번 처리될 수 있습니다.
이 문제는 성공 응답으로 HTML 대신 리다이렉트를 보내서 풀 수 있습니다. 서버는 303 See Other 상태와 함께 결과를 볼 주소를 Location 헤더에 담아 보내고, 브라우저는 그 주소로 GET 요청을 보내 화면을 받습니다.
| 순서 | 브라우저가 보내는 요청 | 서버 응답 |
|---|---|---|
| 1 | POST /members (폼 본문 포함) | 303 See Other, Location: /members/min-1 |
| 2 | GET /members/min-1 | 200 OK, 회원 정보 HTML |
| 3 | 새로고침하면 GET /members/min-1을 다시 보냄 | 200 OK, 같은 HTML (등록은 반복되지 않음) |
마지막으로 받은 응답이 GET의 결과이므로 새로고침은 조회를 반복할 뿐 등록을 반복하지 않습니다. 요청 순서를 따서 이 방식을 POST-Redirect-GET, 줄여서 PRG 패턴이라고 부릅니다. 다만 PRG가 막아 주는 것은 새로고침으로 인한 재전송뿐입니다. 사용자가 버튼을 빠르게 두 번 눌러 POST 두 개가 동시에 들어오는 경우는 앞에서 본 insertIfAbsent나 데이터베이스의 고유 제약이 막아야 합니다.
더 깊이: 302가 아니라 303을 쓰는 이유
리다이렉트 상태 코드에는 301, 302, 303, 307, 308이 있습니다. 303은 "결과는 다른 주소에서 GET으로 받아 가라"는 뜻이 명확해서 POST 다음에 쓰기에 알맞습니다. 302 Found는 원래 메서드를 바꾸지 말고 다시 요청하라는 뜻이었지만 브라우저들이 오래전부터 POST를 GET으로 바꿔 보냈고, 현재 HTTP 명세(RFC 9110)도 역사적 이유로 이 변경을 허용합니다. 결국 302에서는 메서드가 바뀔지가 클라이언트에 달려 있는 셈입니다. 반대로 POST를 그대로 유지한 채 다른 주소로 보내야 한다면 307이나 308을 씁니다.
정리
- 회원 ID 중복 금지나 이름·나이 범위 같은 규칙은 HTTP와 무관하므로 서비스에 둡니다. 컨트롤러는 요청 값 변환과 HTTP 응답 만들기만 맡습니다.
- 서비스는 예상한 실패를
Rejected,Duplicate같은 결과 값으로 돌려주고, 이를 HTTP 상태 코드로 바꾸는 일은 컨트롤러가 합니다. 결과 타입을sealed로 만들면 처리하지 않은 경우를 컴파일러가 찾아 줍니다. - 중복 확인과 저장은
insertIfAbsent처럼 한 번에 처리해야 동시에 들어온 요청이 서로 덮어쓰지 않습니다. - 사용자 입력을 HTML에 넣을 때는 이스케이프하고,
Content-Length에는 문자 수가 아니라 UTF-8 바이트 수를 적습니다. - 등록 같은
POST가 성공하면 303 리다이렉트로 응답해 새로고침이 등록을 반복하지 않게 합니다.
연습 문제
폼 값을 받아 회원을 등록하는 컨트롤러를 작성해 보세요. 컨트롤러는 서비스의 결과를 다음 규칙에 따라 HTTP 응답으로 바꾸고, 서비스는 HTTP 상태 코드를 몰라야 합니다.
- 나이를 숫자로 바꿀 수 없으면 400을 돌려줍니다.
- 서비스가 입력 규칙 위반을 알리면 422를 돌려줍니다.
- ID가 겹치면 409를 돌려줍니다.
- 등록에 성공하면 본문 없이
303 See Other와Location: /members/{id}헤더를 돌려줍니다.
400과 422를 나눈 것은 실패의 성격이 다르기 때문입니다. 400 Bad Request는 요청 자체를 해석할 수 없을 때, 예를 들어 나이 칸에 old가 들어와 숫자로 바꿀 수조차 없을 때 씁니다. 422 Unprocessable Content는 요청의 형식은 올바르지만 내용이 규칙에 맞지 않을 때 씁니다. 앞에서처럼 둘 다 400으로 처리하는 API도 많지만, 이 연습에서는 둘을 구분합니다.
해답 보기
import java.util.LinkedHashMap;
import java.util.Map;
public final class PostRedirectGetSolution {
sealed interface Registration permits Created, Invalid, Duplicate {
}
record Created(String id) implements Registration {
}
record Invalid(String message) implements Registration {
}
record Duplicate(String id) implements Registration {
}
interface MemberService {
Registration register(String id, String name, int age);
}
record HttpResult(int status, Map<String, String> headers, String body) {
HttpResult {
headers = Map.copyOf(headers);
}
}
static final class RegistrationController {
private final MemberService service;
RegistrationController(MemberService service) {
this.service = service;
}
HttpResult create(Map<String, String> form) {
int age;
try {
age = Integer.parseInt(form.getOrDefault("age", ""));
} catch (NumberFormatException exception) {
return new HttpResult(400, Map.of(), "age must be an integer");
}
Registration registration = service.register(
form.get("id"),
form.get("name"),
age);
return switch (registration) {
case Created created -> new HttpResult(
303,
Map.of("Location", "/members/" + created.id()),
"");
case Invalid invalid -> new HttpResult(422, Map.of(), invalid.message());
case Duplicate duplicate -> new HttpResult(
409,
Map.of(),
"duplicate id: " + duplicate.id());
};
}
}
static final class MemoryService implements MemberService {
private final Map<String, String> names = new LinkedHashMap<>();
@Override
public Registration register(String id, String name, int age) {
if (id == null || name == null || name.isBlank() || age < 1) {
return new Invalid("invalid member fields");
}
if (names.putIfAbsent(id, name) != null) {
return new Duplicate(id);
}
return new Created(id);
}
}
public static void main(String[] args) {
RegistrationController controller = new RegistrationController(new MemoryService());
System.out.println(controller.create(Map.of(
"id", "min-1", "name", "Min", "age", "31")));
System.out.println(controller.create(Map.of(
"id", "min-1", "name", "Other", "age", "22")));
System.out.println(controller.create(Map.of(
"id", "bad", "name", "Bad", "age", "old")));
}
}실행 결과:
HttpResult[status=303, headers={Location=/members/min-1}, body=]
HttpResult[status=409, headers={}, body=duplicate id: min-1]
HttpResult[status=400, headers={}, body=age must be an integer]컨트롤러의 create는 두 단계로 일합니다. 먼저 폼 문자열에서 나이를 int로 바꾸는데, 여기서 실패하면 서비스를 부르지 않고 바로 400을 돌려줍니다. 세 번째 줄이 이 경우로, age가 old라서 NumberFormatException이 났습니다. 변환에 성공하면 서비스를 부르고, 돌려받은 결과를 switch로 나눠 HTTP 결과로 옮깁니다. 첫 줄은 Created가 303과 Location 헤더가 된 경우이고, 두 번째 줄은 같은 ID가 다시 들어와 Duplicate가 409가 된 경우입니다.
switch에 default가 없다는 점도 보세요. Registration이 sealed이고 세 경우를 모두 다뤘으므로 컴파일러는 이 switch에 빠진 경우가 없다는 것을 압니다. 여기에 결과 타입을 하나 더 추가하면 이 switch에서 컴파일 오류가 나므로, 새 결과를 HTTP 응답으로 옮기는 코드를 빠뜨릴 수 없습니다.
실제 서비스에 옮기기 전에 더 생각할 점이 두 가지 있습니다. 첫째, Location 헤더는 ID를 경로 뒤에 그대로 붙여 만듭니다. 앞의 MemberApplicationService처럼 ID를 영문 소문자, 숫자, 하이픈으로 제한하면 문제가 없습니다. 하지만 공백이나 한글이 들어갈 수 있는 값을 경로에 넣으려면 경로 구간(path segment)에 맞게 퍼센트 인코딩해야 합니다. 둘째, 해답의 MemoryService는 연습을 위해 줄인 구현입니다. ID 형식과 나이 상한을 검사하지 않고 동기화도 하지 않으므로, 여러 요청이 동시에 들어오는 서버에 그대로 쓸 수는 없습니다.
확인 문제
ID lee-3, 이름 Lee, 나이 121을 두 곳에 보낸다고 해 봅시다. 하나는 새로 만든 MemberApplicationService의 register이고, 다른 하나는 해답의 MemoryService를 연결한 RegistrationController의 create입니다. 각각 어떤 결과가 나올까요?
해설 보기
MemberApplicationService는 나이 상한 120을 검사하므로 위반 하나가 담긴 Rejected를 돌려주고 회원을 저장하지 않습니다. 해답의 MemoryService는 나이가 1보다 작은지만 검사하므로 121을 Created로 처리하고, 컨트롤러는 303 응답을 만듭니다. 실제로 실행하면 다음과 같습니다.
Rejected[violations=[age must be between 1 and 120]]
HttpResult[status=303, headers={Location=/members/lee-3}, body=]같은 회원 예제를 다루더라도 두 서비스가 검사하는 범위는 다릅니다. 컨트롤러는 서비스가 돌려준 결과를 옮길 뿐이므로, 애플리케이션을 조립할 때 전체 회원 규칙을 구현한 서비스를 연결해야 원하는 동작을 얻습니다.
다음 글
폼 본문 읽기와 컴포지션 루트에서는 브라우저가 보낸 폼 본문을 바이트 단위로 정확히 읽어 컨트롤러가 받을 값으로 바꾸고, 저장소와 서비스와 컨트롤러를 프로그램 시작 지점의 한곳에서 조립합니다.