접수창구를 지나 드디어 만난 사람 #
4화에서 손님은 부서 접수창구(Interceptor)를 통과했다. 신분증을 한 번 더 확인받고 방문증을 받아 든 채였다. 이제 손님은 복도를 따라 걸어가 마침내 그 사람 앞에 앉는다. 담당 직원이다. 여기까지 오는 데 문을 세 개나 거쳤지만, 정작 손님이 온 이유는 지금부터 시작이다.
이 담당 직원이 기술 용어로는 컨트롤러(@RestController 혹은 @Controller)다. 안내데스크(DispatcherServlet)가 "이 방문은 몇 층 몇 번 방으로"라고 정확히 지목해서 보낸 사람이 바로 이 직원이다. 손님이 들고 온 신청서(이름, 날짜, 첨부 서류 같은 것들)를 직원이 굳이 한 글자씩 손으로 옮겨 적지는 않는다. 스프링이 신청서 양식을 미리 보고 필요한 값을 알아서 꺼내 직원 손에 쥐여준다. 개발자들은 이걸 파라미터 바인딩이라고 부른다.
담당 직원은 혼자 다 하지 않는다 #
여기서 흔한 오해가 하나 있다. 담당 직원이 신청서를 받았으니 그 자리에서 혼자 다 처리할 거라는 오해다. 실제로는 안 그렇다. 직원은 신청서 내용을 보고 판단이 필요한 부분을 업무 규칙을 훤히 아는 동료에게 넘긴다. 이 동료가 서비스(Service)다. "이 사람이 이 서류를 받을 자격이 있는지", "포인트를 얼마나 깎아줘야 하는지" 같은 판단은 전부 이 동료 몫이다.
동료도 혼자 다 하지 않는다. 판단에 필요한 자료가 있으면 자료 보관실에 요청서를 보낸다. 이게 리포지토리(Repository)고, 그 뒤에 실제 서류함이 데이터베이스다. 자료 보관실 담당자는 요청받은 서류만 찾아서 건네줄 뿐, 그 서류로 뭘 판단할지는 관여하지 않는다.
- 담당 직원(Controller) — 손님을 맞고, 결과물을 어떤 형태로 돌려줄지 정한다
- 업무 규칙 동료(Service) — 실제 판단과 처리, 여러 자료를 종합하는 일을 맡는다
- 자료 보관실(Repository) — 서류(데이터)를 찾아오거나 새로 채워 넣는다
이렇게 나뉘어 있는 이유는 1화에서 나온 그 이유와 같다. 한 사람이 접수도 하고 판단도 하고 서류도 뒤진다면, 업무 규칙 하나만 바뀌어도 그 사람의 하루 전체가 흔들린다. 판단 로직(Service)이 따로 있으면 그 부분만 고치면 되고, 다른 담당 직원(다른 Controller)도 같은 동료에게 판단을 맡길 수 있다. 데이터를 다루다 문제가 생겼을 때 되돌리는 작업(@Transactional)도 보통 이 동료 선에서 처리한다.
예를 들면 이런 식이다 #
말로만 설명하면 감이 안 잡히니 아주 짧게만 코드로 옮겨본다.
@RestController
class MemberController {
@GetMapping("/mypage")
Member getMypage(Long memberId) {
return memberService.findMember(memberId); // 판단은 동료(Service)에게
}
}직원(Controller)이 하는 일은 딱 두 줄이다. 넘어온 값을 동료에게 그대로 넘기고, 동료가 찾아준 결과를 돌려주는 것. 동료 안에서 무슨 판단이 벌어지는지, 자료 보관실에서 서류를 어떻게 찾아오는지는 이 직원이 알 필요가 없다. 간혹 자료 보관실에서 서류를 못 찾거나 동료가 이상한 값을 만나 처리를 못 하는 경우도 있는데, 이럴 땐 총무팀 격인 전역 예외 처리(@ControllerAdvice)가 대신 나서서 정중하게 사유를 안내한다. 존재만 짚고 넘어간다.
나갈 때는 정확히 반대 순서로 #
담당 직원이 결과물을 손님에게 건넸다고 해서 손님이 그 자리에서 바로 문밖으로 나가는 건 아니다. 들어올 때 지났던 문들을 그대로 정확히 반대 순서로 되짚어 나간다. 들어올 때 마지막으로 지난 문이 접수창구였다면, 나갈 때는 그 접수창구를 제일 먼저 다시 지난다는 뜻이다.
- 담당 직원(Controller)이 결과물을 반환한다
- 접수창구(Interceptor)가
postHandle로 한 번 더 확인한다 — "이 손님, 나가는 것도 기록해뒀나?" - 안내데스크(DispatcherServlet)가 결과물을 손님이 알아볼 수 있는 형식(JSON이나 화면)으로 바꾼다
- 접수창구가
afterCompletion으로 "이 손님 용무 끝, 기록 마감"을 처리한다 - 경비실(Filter)을 다시 지나 건물 밖으로 나간다
들어올 때와 나갈 때가 대칭을 이루는 이유는 단순하다. 접수창구에서 방문 기록을 남겼다면, 그 기록을 마감하는 것도 결국 접수창구 몫이다. 경비실에서 신원을 확인했다면, 나갈 때 인원수를 세는 것도 경비실 몫이다. 문 하나가 들어올 때 한 일과 나갈 때 하는 일은 항상 짝을 이룬다.
그래서 이게 왜 중요한데 #
하나는 계층을 나누는 실무적 이유다. 신청서 검증 로직이 바뀌었는데 그 로직이 컨트롤러 안에 파묻혀 있으면, 그 컨트롤러를 쓰는 다른 화면까지 전부 손대야 할 수도 있다. Service로 분리해두면 그 부분만 고치면 되고, 여러 컨트롤러가 같은 동료에게 판단을 맡기고 있었다면 한 번 고친 게 전부에 반영된다.
다른 하나는 역순 흐름을 알아야 디버깅과 응답 가공이 쉬워진다는 점이다. 응답 헤더에 값 하나를 추가하고 싶은데 "어느 관문에서 추가해야 하나" 고민될 때, 나가는 순서가 접수창구 → 안내데스크 → 경비실이라는 걸 알면 답이 바로 나온다. 로그를 봤는데 손님이 나가는 기록(afterCompletion)만 있고 들어오는 기록이 없다면, 그건 애초에 손님이 접수창구까지 오지도 못했다는 뜻이다. 순서를 모르면 이런 단서들이 그냥 잡음으로 지나간다.
시리즈를 닫으며 #
다섯 화에 걸쳐 손님 한 명이 건물에 들어와 나가기까지의 동선을 따라왔다. 경비실(Filter)에서 신원을 거르고, 안내데스크(DispatcherServlet)가 담당자를 지목하고, 접수창구(Interceptor)가 한 번 더 확인하고, 담당 직원(Controller)이 동료(Service)와 자료 보관실(Repository)의 도움을 받아 볼일을 처리한 다음, 왔던 길을 그대로 되짚어 나간다.
버튼 하나 눌렀을 뿐인 그 0.2초 동안 이 정도 절차가 순서대로 돌아간다는 걸 알면, 다음에 "응답이 왜 이상하게 나오지"라는 질문을 받았을 때 어디부터 들여다봐야 할지 감이 잡힐 것이다. 관문 하나하나의 이름을 다 외울 필요는 없다. 손님이 지나는 순서, 그리고 그 순서가 나갈 때 그대로 뒤집힌다는 것. 이 지도 하나면 충분하다.
