본문으로 건너뛰기
안동민 개발노트 아이콘

안동민 개발노트

본문 시작
29장 : 애노테이션 HTTP 실행기

회원 서비스 계층

회원 등록·목록 요구를 전송 계층 입력, 업무 규칙, 저장 의미, HTML 표현으로 분해하고 컨트롤러가 HTTP 변환만 담당하도록 작은 서비스 경계를 설계합니다.

콘솔 회원 프로그램을 웹으로 옮길 때 바뀌는 것은 입출력 방식입니다. 회원 ID의 유일성, 이름과 나이의 유효 범위, 등록된 회원을 조회한다는 업무 의미는 HTTP와 무관합니다. 이 규칙까지 컨트롤러에 넣으면 HTML 화면, JSON API, 일괄 가져오기 도구가 각각 같은 검사를 다시 구현하게 됩니다. 반대로 컨트롤러는 쿼리와 폼 값을 Java 값으로 바꾸고 서비스 결과를 HTTP 상태와 표현으로 번역하는 경계에 집중해야 합니다.

요구사항을 화면 목록이 아니라 유스케이스로 먼저 적어 봅니다. register(id, name, age)는 유효한 새 회원을 하나 저장하거나 구체적인 거절 이유를 반환합니다. findAll()은 호출자가 수정할 수 없는 회원 스냅샷을 제공합니다. 홈과 등록 폼은 화면 이동 표현이며 도메인 규칙이 아닙니다. 이 분해를 해 두면 CSS나 템플릿 엔진이 바뀌어도 회원 정책은 그대로 남습니다.

컨트롤러 책임 과부하

다음 반례는 짧지만 책임이 너무 많습니다. 컨트롤러가 age 문자열을 파싱하고 중복 정책을 Map 덮어쓰기로 결정하며, 사용자 이름을 이스케이프하지 않고 HTML에 넣습니다. 저장 구현을 파일이나 데이터베이스로 바꾸려면 HTTP 클래스를 수정해야 합니다. 실패가 NumberFormatException인지 중복인지 구별하는 결과 모델도 없습니다.

bad/FatMemberController.java
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) {
        System.out.println("compile-only mixed controller counterexample");
    }
}

분리의 목적은 클래스 수를 늘리는 것이 아닙니다. 각 변경 이유를 한 경계에 모으는 것입니다. 폼 필드 이름이 바뀌면 컨트롤러 바인딩을 고칩니다. 나이 정책이 바뀌면 서비스를 고칩니다. 저장 기술이 바뀌면 저장소 구현을 바꿉니다. HTML 구조가 바뀌면 렌더러를 바꿉니다. 어느 한 수정이 다른 계층의 구체 타입까지 연쇄적으로 끌고 가지 않도록 의존 방향을 세웁니다.

회원 record 생성자에 모든 검증을 넣는 선택도 가능합니다. 다만 중복 ID처럼 저장 상태를 봐야 하는 규칙은 한 객체만으로 판단할 수 없습니다. 값 자체의 불변식과 유스케이스 정책을 구분하면 검증 위치가 자연스러워집니다.

등록 결과 모델

아래 MemberApplicationService는 HTTP를 알지 못합니다. 저장소 인터페이스에만 의존하고 입력을 정규화한 뒤 모든 위반을 모읍니다. 유효하면 insertIfAbsent()로 중복을 원자적으로 판정합니다. 컨트롤러는 Created, Rejected, Duplicate를 각각 201, 400, 409로 매핑할 수 있습니다.

src/MemberApplicationService.java
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());
    }
}

insertIfAbsent()가 중요한 이유는 findById()save() 두 호출로 중복을 검사하면 그 사이에 다른 스레드가 같은 ID를 저장할 수 있기 때문입니다. 인메모리 구현은 synchronized로 한 임계 구역을 만들었습니다. 데이터베이스 구현이라면 고유 제약이 최종 보증을 제공하고, 제약 위반을 Duplicate 결과로 번역합니다. 서비스의 계약은 유지되지만 구현별 원자성 수단은 달라집니다.

거절 목록은 사용자가 한 번에 여러 입력을 고칠 수 있게 합니다. 모든 실패를 예외로 표현할 필요는 없습니다. 예상 가능한 유효성 결과는 값으로 다루면 정상 제어 흐름과 장애 스택 추적을 분리할 수 있습니다. 저장소 연결 끊김처럼 처리할 수 없는 기반 시설 장애는 예외 경계에서 500으로 번역합니다.

저장소 계약

인터페이스가 있다고 자동으로 결합도가 낮아지는 것은 아닙니다. FileMemberRepository의 경로나 직렬화 예외가 인터페이스에 그대로 새면 컨트롤러까지 파일 기술을 알게 됩니다. insertIfAbsent, findById, findAll처럼 유스케이스가 필요한 의미를 계약으로 만들고 구현 세부 오류는 저장소 전용 예외로 번역합니다.

findAll()이 내부 가변 컬렉션을 그대로 반환하면 컨트롤러가 저장소 상태를 우연히 변경할 수 있습니다. 스냅샷을 반환하면 호출 순간의 목록이라는 의미가 분명해집니다. 정렬 순서도 계약인지 표현 선택인지 결정해야 합니다. 예제는 저장소가 삽입 스냅샷만 제공하고 서비스가 ID 정렬을 선택했습니다. 페이지 나누기가 필요해지면 전체 목록을 메모리로 가져오는 계약을 다시 설계해야 합니다.

인메모리, 파일, 데이터베이스 구현은 내구성과 동시성 보장이 다릅니다. 학습용 Map을 운영 저장소와 같은 것으로 보아서는 안 됩니다. 파일에 여러 프로세스가 동시에 쓰는 상황, 부분 쓰기 후 장애, 스키마 버전 변경까지 고려하면 트랜잭션과 복구 정책이 필요합니다. 인터페이스 뒤에 숨겼더라도 구현의 운영 특성은 구성 단계에서 명시적으로 선택해야 합니다.

HTML 출력 인코딩

회원 이름이 <script>를 포함한다고 가정합니다. 문자열 연결로 그대로 HTML에 넣으면 브라우저는 데이터가 아니라 마크업으로 해석합니다. 서버 측 렌더러는 텍스트 노드 문맥에서 &, <, >, 따옴표를 엔터티로 바꿔야 합니다. URL 속성, JavaScript, CSS 문맥은 각각 다른 인코딩 규칙을 요구하므로 한 이스케이프 함수로 모든 위치를 처리하지 않습니다.

다음 렌더러는 텍스트 내용만 다루며 모든 회원 행을 이스케이프합니다. 응답의 Content-Typecharset=UTF-8을 포함하고, 본문 바이트 길이를 기준으로 Content-Length를 계산해야 합니다. 한국어 이름은 Java 문자 수와 UTF-8 바이트 수가 다를 수 있습니다.

src/SafeMemberPageRenderer.java
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("&", "&amp;")
                .replace("<", "&lt;")
                .replace(">", "&gt;")
                .replace("\"", "&quot;")
                .replace("'", "&#39;");
    }

    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());
    }
}

등록 엔드포인트는 POST, 목록과 폼 화면은 GET으로 구분합니다. 경로만 같아도 메서드가 다르면 별도 라우트입니다. GET은 조회 의미를 유지해 링크, 캐시, 재시도가 예측 가능해야 합니다. POST 성공 후 HTML을 즉시 반환하면 사용자가 새로고침할 때 같은 등록 요청이 다시 전송될 수 있습니다. 성공 뒤 목록 URL로 리다이렉트하는 POST-리다이렉트-GET 흐름이 중복 제출 위험을 줄입니다.

연습 문제

폼 값을 받은 컨트롤러를 작성합니다. 숫자 변환 실패는 400, 업무 검증 실패는 422, ID 중복은 409로 매핑합니다. 등록 성공 때는 본문에 성공 HTML을 쓰지 말고 303 See Other/members/{id} Location 헤더를 반환합니다. 서비스는 HTTP 상태를 몰라야 합니다.

해답 보기
src/PostRedirectGetSolution.java
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")));
    }
}

해답에서 컨트롤러는 폼 문자열을 전송 계층 값으로 해석하고 서비스의 의미 결과를 HTTP에 맞게 옮깁니다. 리다이렉트 위치를 만들 때 실제 ID 허용 문자 정책을 적용해야 하며, 자유 문자열을 경로에 넣는다면 경로 구간 인코딩이 추가로 필요합니다.