Backend 22
Controller와 Service: 단순한 폴더가 아닌 책임의 분리
Controller에서 Repository를 바로 호출하면 더 간단한데 왜 Service를 따로 둘까?
근거 · 교안 p142-p148
1장: Controller-Repository 직접 호출의 ‘간결함’ 탐색
솔라가 노트북 화면을 들여다보며 미간을 살짝 찌푸렸다. 화면에 떠 있는 것은 몇 줄 안 되는 코드 스니펫이었다. 명료하고, 군더더기 없어 보였다. 한참 동안 코드를 바라보던 솔라가 옆에 앉아 책을 읽던 루나에게 노트북을 휙 돌려 보였다.
“언니, 이것 좀 봐. 너무 간단하지 않아? UserController에서 바로 UserRepository를 호출해서 데이터를 저장하고 끝. 중간에 UserService는 왜 끼워 넣는 거야? 그냥 파일만 늘어나고 코드 몇 줄 더 쓰는 것뿐이잖아.”
솔라의 목소리에는 순수한 의문이 담겨 있었다. 그녀가 가리킨 코드는 정말로 직관적이었다. 웹 브라우저에서 온 요청을 받고, 데이터베이스에 저장하고, 성공했다는 응답을 보내는 흐름이 한눈에 들어왔다. 불필요한 중간 단계가 없는 것처럼 보였다.
루나는 잠시 책에서 눈을 떼고 화면을 들여다보았다. 솔라의 말처럼 코드는 짧고 명쾌했다.
“음, 어디 보자.”
루나는 잠시 생각에 잠기더니, 솔라에게 다시 노트북을 돌려주었다.
“그럼 우리도 그렇게 한번 만들어 볼까? 아주 간단하게, 새 사용자를 저장하는 기능만.”
“진짜? 그래도 돼?”
솔라는 신이 나서 키보드에 손을 올렸다. 마치 자신의 생각이 맞았다는 것을 증명할 기회를 얻은 아이 같았다. 그녀는 금세 가상의 UserController 코드를 머릿속에 그리기 시작했다.
@PostMapping("/users")
public ResponseEntity<Void> saveUser(UserRequestDto request) {
User user = new User(request.getName(), request.getEmail());
userRepository.save(user);
return ResponseEntity.ok().build();
}
“봐, 됐지? 요청을 받아서, User 객체를 만들고, UserRepository로 저장하고, 성공 응답. 깔끔하잖아. 중간에 Service가 있었다면, 괜히 userService.saveUser(user) 같은 메서드를 한 번 더 호출해야 했을 거야.”
솔라는 의기양양하게 말했다. 그녀의 손가락 끝에서 완성된 논리는 완벽해 보였다. 코드 라인 수도 적고, 데이터의 흐름도 Controller에서 Repository로 직접 이어지니 이해하기 쉬웠다.
루나는 조용히 고개를 끄덕이며 솔라의 코드를 바라보았다. 그리고는 나지막이 물었다.
“만약에, 그 이메일 주소로 이미 가입한 사용자가 있으면 어떡하지?”
“아.”
솔라의 손가락이 순간 멈칫했다. 하지만 곧바로 다시 움직였다.
“그건 간단해. 저장하기 전에 리포지토리로 한번 찾아보면 되지.”
솔라는 재빨리 코드를 수정했다. userRepository.save(user)를 호출하기 전, userRepository.findByEmail(request.getEmail())을 호출해서 이미 사용자가 있는지 확인하는 로직을 추가했다. 만약 사용자가 존재한다면 예외를 던지도록 했다.
if (userRepository.findByEmail(request.getEmail()).isPresent()) {
throw new IllegalArgumentException("이미 사용 중인 이메일입니다.");
}
User user = new User(request.getName(), request.getEmail());
userRepository.save(user);
“자, 이렇게. 한두 줄 추가됐지만 여전히 컨트롤러 안에서 다 해결되네. 아직 간단해.”
솔라는 여전히 자신의 주장을 굽히지 않았다. 하지만 처음의 의기양양함은 살짝 꺾여 있었다. 루나는 솔라가 추가한 코드를 가만히 가리켰다.
“지금 Controller가 두 가지 일을 하고 있는 거네.”
“두 가지 일?”
“응. 하나는 외부에서 온 HTTP 요청을 받아서 응답을 처리하는 일. 그리고 또 하나는, ‘이 이메일을 써도 되는가?‘하고 우리 서비스의 규칙을 바탕으로 판단하는 일.”
루나의 말에 솔라는 다시 화면을 들여다보았다. @PostMapping 어노테이션이나 ResponseEntity 같은 것들은 분명 HTTP 통신과 관련된 부분이었다. 그런데 방금 추가한 이메일 중복 확인 로직은 어딘가 결이 달랐다. 그것은 웹 요청을 어떻게 다룰지에 대한 것이 아니라, ‘사용자 가입’이라는 기능의 정책, 즉 비즈니스 규칙에 대한 판단이었다.
처음에는 그저 ‘간단하다’고 생각했던 코드가 다르게 보이기 시작했다. 서로 다른 성격의 책임이 한 공간에 뒤섞여 있는 모습이었다. 간결하다고 생각했던 것은 사실 책임의 혼재였다.
솔라는 입을 꾹 다물었다. 더 이상 ‘간단하다’는 말이 쉽게 나오지 않았다. 그녀의 머릿속에 새로운 질문이 떠올랐다.
“그러네. 컨트롤러에서 리포지토리를 바로 호출하는 게 마냥 간단한 것만은 아니구나… 그럼, 대체 Service는 왜 따로 두는 거지? 뭔가 다른 이유가 있는 건가?“
2장: Controller, 네 진짜 역할은 무엇이니?
솔라는 다시 노트북 화면으로 시선을 돌렸다. 어제 의기양양하게 작성했던 코드가 이제는 어색하게 느껴졌다. 커서는 if (userRepository.findByEmail...) 이라고 적힌 줄 옆에서 무심하게 깜빡이고 있었다. 처음에는 그저 한두 줄 추가된 편리한 로직이라고 생각했는데, 루나 언니의 말을 듣고 나니 마치 제자리가 아닌 곳에 억지로 끼워 넣은 가구처럼 보였다.
HTTP 요청을 처리하는 코드와 서비스의 규칙을 판단하는 코드가 뒤섞여 있다는 사실을 인지하자, ‘간단함’이라는 단어는 설득력을 잃었다. 대신 ‘책임’이라는 단어가 머릿속을 맴돌았다. 이 코드 덩어리는 대체 무엇을 책임져야 하는 걸까?
그때, 루나가 조용히 새 노트를 한 장 꺼내 솔라 앞에 놓았다. 노트에는 간결하게 몇 글자가 적혀 있었다.
[추가 요구사항]
1. 사용자 이름(name)은 비워둘 수 없다.
2. 이메일(email)은 유효한 형식이어야 한다.
“방금 새로운 기획안이 나왔다고 상상해 봐. 이 두 가지 규칙을 추가해야 한다면, 솔라 너는 어디에 이 코드를 넣을 거야?”
솔라는 잠시 고민했다. ‘사용자 이름이 비었는지’, ‘이메일 형식이 맞는지’ 확인하는 것. 전부 사용자가 보낸 요청 데이터에 대한 검증이었다.
“이것도 요청이 들어왔을 때 확인해야 하는 거니까… 컨트롤러가 맞지 않을까?”
솔라는 자신 없는 목소리로 대답하며, 다시 UserController 코드를 수정하기 시작했다. saveUser 메서드 상단에 몇 줄의 코드를 더했다.
// UserController.java
@PostMapping("/users")
public ResponseEntity<Void> saveUser(UserRequestDto request) {
// 1. 이름이 비었는지 확인
if (request.getName() == null || request.getName().isBlank()) {
throw new IllegalArgumentException("이름은 필수입니다.");
}
// 2. 이메일 형식 확인 (간단한 예시)
if (!request.getEmail().contains("@")) {
throw new IllegalArgumentException("유효하지 않은 이메일 형식입니다.");
}
// 기존의 이메일 중복 확인 로직
if (userRepository.findByEmail(request.getEmail()).isPresent()) {
throw new IllegalArgumentException("이미 사용 중인 이메일입니다.");
}
User user = new User(request.getName(), request.getEmail());
userRepository.save(user);
return ResponseEntity.ok().build();
}
“자, 이렇게. 요청 데이터를 받았을 때 바로 검사하는 거니까. 아직 컨트롤러 안에서 모두 처리할 수 있어.”
솔라는 완성된 코드를 보여주었다. 메서드는 처음보다 훨씬 길어지고 복잡해졌다. 루나는 고개를 끄덕이지도, 젓지도 않았다. 대신 펜을 들어 솔라가 수정한 코드의 여러 부분을 조용히 가리켰다.
처음에는 @PostMapping("/users") 어노테이션을, 그다음엔 메서드의 파라미터인 UserRequestDto request를, 그리고 솔라가 방금 추가한 이름과 이메일 형식 검사 로직을, 마지막으로 ResponseEntity.ok().build()를 차례로 짚었다.
“이것들이 공통적으로 신경 쓰고 있는 게 뭘까? 무엇에 대한 이야기들이지?”
솔라는 루나의 펜 끝을 따라 자신의 코드를 다시 읽어 내려갔다.
@PostMapping("/users")… 이건 ‘/users’라는 주소로 들어오는 ‘HTTP POST’ 요청을 받겠다는 뜻.
UserRequestDto request… 이건 ‘HTTP 요청 본문’에 담겨 온 JSON 데이터를 자바 객체로 바꿔달라는 의미.
if (request.getName().isBlank())… 이건 요청 본문에 들어있는 ‘name’ 필드가 비어있는지 확인하는 것.
if (!request.getEmail().contains("@"))… 이것도 요청 본문의 ‘email’ 필드가 형식에 맞는지 보는 것.
ResponseEntity… 이건 처리 결과를 ‘HTTP 응답’으로 만들어 돌려주겠다는 약속.
하나씩 짚어보던 솔라의 눈이 동그래졌다. 흩어져 있던 점들이 하나의 선으로 이어지는 순간이었다.
“아…! 이건 전부 다… 웹 브라우저 같은 외부 클라이언트랑 어떻게 소통할지에 대한 이야기네. 어떤 주소로 요청을 받고, 어떤 데이터를 어떤 형식으로 받아서, 그게 올바른 형식인지 검사하고, 어떻게 응답을 돌려줄지… 전부 HTTP라는 약속에 대한 거구나.”
솔라의 목소리에 깨달음의 흥분이 실렸다. 컨트롤러는 단순히 일을 처리하는 작업자가 아니었다. 외부 세계와 내부 시스템 사이의 경계에 서서, 정해진 프로토콜에 따라 소통을 중개하는 ‘문지기’ 혹은 ‘통역가’ 같은 역할이었다.
그제야 솔라는 어제 추가했던 이메일 중복 확인 로직을 다시 바라보았다. 새로 추가한 유효성 검사 코드와 나란히 있었지만, 그 본질은 완전히 달랐다.
“그런데 ‘이미 가입된 이메일인지’ 확인하는 건… 이건 HTTP 약속이 아니잖아. 우리 웹사이트의 규칙이지. 클라이언트가 이메일을 ‘test@example.com’으로 보내든 ‘hello@world.com’으로 보내든 HTTP 관점에서는 둘 다 유효한 문자열일 뿐이니까. 그걸 우리 데이터베이스랑 비교해서 ‘사용해도 된다, 안 된다’를 결정하는 건… 문지기의 일이 아닌 것 같아.”
솔라는 스스로 내린 결론에 놀란 듯 잠시 말을 멈췄다. 컨트롤러의 진짜 책임이 무엇인지 명확해지자, 다른 책임들이 오히려 더 낯설게 보이기 시작했다. 간결함의 문제가 아니었다. 각자의 역할과 정체성에 대한 문제였다.
“그럼 이 ‘우리 서비스만의 규칙’을 판단하는 일은 대체 누구의 책임이야? 그게… 언니가 말했던 ‘서비스’ 계층이 하는 일이야? 그 ‘비즈니스 로직’이라는 게 바로 이런 건가?“
3장: Service, 그 비즈니스 로직의 실체
솔라의 마지막 질문이 공중에 맴돌았다. 컨트롤러가 HTTP 통신을 맡은 문지기라면, ‘우리 서비스만의 규칙’을 판단하는 일은 누구의 책임인가. ‘비즈니스 로직’이라는 말의 실체는 무엇인가.
루나는 솔라의 질문에 바로 답하는 대신, 옆에 있던 메모 패드를 끌어왔다. 그리고는 익숙한 온라인 쇼핑몰의 로고를 장난스럽게 그린 뒤, 그 아래에 ‘상품 주문하기’라는 제목을 적었다. 곧이어 펜 끝에서 주문이 처리되는 과정이 순서대로 나열되기 시작했다.
사용자가 ‘주문하기’ 버튼을 누른다. (상품 ID, 수량 전달)DB에서 해당 상품의 정보를 가져온다.요청한 수량만큼 재고가 남아 있는지 확인한다.DB에서 사용자의 정보를 가져온다.사용자의 등급(e.g., VIP, 일반)에 따라 할인율을 계산한다.외부 결제 시스템에 결제를 요청한다.결제가 성공하면, 상품의 재고를 주문 수량만큼 줄인다.DB에 새로운 주문 내역을 기록한다.사용자에게 ‘주문 완료’ 알림을 보낸다.
루나는 말없이 펜을 내려놓고 패드를 솔라 쪽으로 밀어주었다. 솔라는 눈으로 목록을 빠르게 훑었다. 생각보다 과정이 복잡했다.
“이게… ‘비즈니스 로직’이야?”
솔라가 물었다. 그녀의 목소리에는 아직 확신이 없었다.
“DB에서 데이터 가져오고, 재고 줄이고, 주문 내역 기록하고… 2번, 4번, 7번, 8번 같은 건 결국 Repository가 하는 일이잖아. 그냥 Repository를 여러 번 호출하는 것뿐인데, 이걸 굳이 ‘서비스’라는 계층으로 따로 만들 이유가 있어?”
솔라의 관점에서는 여전히 모든 것이 데이터베이스 작업의 연장선으로 보였다. Repository는 DB와 소통하는 창구. 그러니 여러 번 소통하면 될 뿐, 새로운 책임자가 필요해 보이진 않았다. 그녀의 learnerMisread는 명확했다. 비즈니스 로직이란 그저 여러 개의 데이터 CRUD(Create, Read, Update, Delete) 작업의 묶음일 뿐이라는 생각.
루나는 솔라의 말에 고개를 끄덕이며, 다시 펜을 들었다. 그리고는 목록의 3번, 5번, 6번 항목 옆에 작은 별표(★)를 그렸다.
“그럼 이 별표 친 항목들은 어때? 이것도 단순한 ‘데이터베이스 작업’일까?”
솔라는 다시 목록을 들여다보았다.
3번: 재고가 남아 있는지 확인한다.
5. 사용자 등급에 따라 할인율을 계산한다.
6. 외부 결제 시스템에 결제를 요청한다.
단순히 데이터를 가져오거나 저장하는 것과는 미묘하게 달랐다. 3번은 가져온 재고 데이터를 바탕으로 ‘주문이 가능한가?’라는 판단을 내려야 했다. 5번 역시 사용자 정보를 바탕으로 ‘얼마를 할인해줘야 하는가?’를 판단하고 계산하는 과정이었다. 6번은 아예 우리 시스템 내부가 아닌 외부 시스템과의 통신이었다.
“아…”
솔라의 입에서 나지막한 탄성이 터져 나왔다.
“단순히 DB에 ‘이거 줘’, ‘이거 저장해’라고 시키는 게 아니구나. 3번처럼 재고가 부족하면 주문 절차를 중단시켜야 하고, 5번처럼 VIP 고객이면 가격을 다르게 매겨야 하고… 이건 데이터베이스가 스스로 결정할 수 있는 게 아니네.”
그녀는 한 걸음 더 나아갔다.
“그리고 이 모든 과정이 하나의 묶음처럼 움직여야 해. 만약 6번 ‘결제’ 단계에서 실패했는데, 7번 ‘재고 감소’가 먼저 실행되어 버리면 큰일 나잖아. 누군가는 이 전체 과정을 처음부터 끝까지 지휘하면서, 문제가 생겼을 때 ‘모든 걸 없던 일로 되돌려!’라고 명령해야 해. Repository는 그냥 ‘저장해 줘’라는 명령을 수행할 뿐, 이런 전체적인 흐름이나 조건부 판단은 책임지지 않으니까.”
솔라는 비로소 깨달았다. 서비스 계층의 본질은 단순히 Repository를 호출하는 대리인이 아니었다. 그것은 ‘주문’이라는 하나의 비즈니스 목표를 달성하기 위해, 여러 도구(Repository, 외부 API 등)를 사용해 복잡한 작업 순서를 조율하고, 곳곳에 숨어있는 비즈니스 규칙에 따라 의사결정을 내리는 ‘총괄 책임자’ 혹은 ‘프로세스 설계자’의 역할이었다.
이메일 중복 확인은 그저 아주 작은 비즈니스 로직의 한 조각에 불과했다. 컨트롤러가 외부 세계의 요청을 받아 내부로 전달하는 ‘문지기’라면, 서비스는 그 요청을 받아 우리 시스템의 핵심적인 규칙과 절차에 따라 실질적인 ‘업무’를 처리하는 전문가였다.
솔라는 루나가 그려준 주문 프로세스 다이어그램을 손가락으로 쭉 훑었다. 이제는 단순한 목록이 아니라, 각기 다른 책임을 가진 역할들이 협력하는 하나의 유기적인 흐름으로 보였다.
“알겠어. 컨트롤러는 HTTP 요청을 처리하는 책임, 서비스는 우리 서비스의 핵심 규칙을 판단하고 여러 작업을 조율하는 책임. 둘은 명백히 다른 일을 하고 있었구나.”
스스로 내린 결론에 고개를 끄덕이던 솔라의 얼굴에 새로운 의문이 떠올랐다. 역할이 다르다는 것은 이제 알겠다. 하지만 여전히 해결되지 않은 궁금증이 있었다.
“그런데… 역할이 다른 건 알겠는데, 그래서 이걸 굳이 다른 파일, 다른 클래스로 나눠야 해? 컨트롤러 안에 이 모든 로직을 다 넣어도 프로그램은 돌아가잖아. 코드가 그냥 좀 길어지는 것뿐 아니야? 이렇게 책임을 나누면 대체 뭐가 그렇게 좋아지는 건데?”
4장: Controller가 뚱뚱해지면 생기는 일
솔라는 이전 대화의 여운이 채 가시지 않은 채, 다시 노트북을 마주했다. 화면에는 루나 언니가 어제 그려주었던 ‘상품 주문하기’ 프로세스 목록이 떠 있었다. 역할이 다르다는 건 이제 머리로는 이해했다. 하지만 ‘그래서 굳이 나눠야 하는가?’라는 질문은 여전히 몸으로 납득되지 않았다.
“그래, 한번 해보자.”
솔라는 혼잣말을 하며 새로운 OrderController 파일을 열었다. 그녀의 주장을 직접 코드로 증명해 보일 참이었다. 프로그램은 돌아가잖아, 코드가 좀 길어지는 것뿐 아니야? 그녀는 그 목록을 그대로 코드로 옮기기 시작했다.
// OrderController.java
@PostMapping("/orders")
public ResponseEntity<String> placeOrder(OrderRequestDto request) {
try {
// 1. DB에서 상품 정보 조회
Product product = productRepository.findById(request.getProductId());
// 2. 재고 확인
if (product.getStock() < request.getQuantity()) {
return ResponseEntity.badRequest().body("재고가 부족합니다.");
}
// 3. DB에서 사용자 정보 조회
User user = userRepository.findById(request.getUserId());
// 4. 사용자 등급에 따른 할인율 계산
int price = product.getPrice();
if ("VIP".equals(user.getGrade())) {
price = price * 0.9; // 10% 할인
}
// 5. 외부 결제 시스템에 결제 요청
paymentGateway.processPayment(price * request.getQuantity());
// 6. 상품 재고 감소
product.decreaseStock(request.getQuantity());
productRepository.save(product);
// 7. DB에 주문 내역 기록
Order newOrder = new Order(user, product, request.getQuantity());
orderRepository.save(newOrder);
// 8. 사용자에게 알림 발송 (가정)
notificationManager.send(user, "주문이 완료되었습니다.");
return ResponseEntity.ok("주문 성공");
} catch (PaymentFailedException e) {
return ResponseEntity.status(500).body("결제에 실패했습니다.");
} catch (Exception e) {
return ResponseEntity.status(500).body("알 수 없는 오류가 발생했습니다.");
}
}
한참 동안 키보드를 두드린 끝에, 하나의 거대한 메서드가 완성되었다. 솔라는 의자 등받이에 몸을 기대며 완성된 코드를 훑어보았다. 무려 서른 줄이 넘는 긴 메서드였지만, 어쨌든 하나의 흐름으로 주문 과정을 모두 담아냈다.
“봐, 언니. 되잖아. 역할이 다른 건 알겠지만 컨트롤러 안에 다 넣어도 프로그램은 돌아가. 이렇게 한 곳에 있으니까 오히려 위에서부터 아래로 쭉 읽으면 되니까 흐름 파악은 더 쉬운 것 같기도 하고.”
솔라는 자신의 창조물을 변호하듯 말했다. 그녀의 눈에는 이 코드가 조금 길고 투박할지는 몰라도, ‘작동하는’ 코드라는 사실이 중요해 보였다.
루나는 말없이 솔라의 노트북 화면을 들여다보았다. 거대해진 placeOrder 메서드를 잠시 응시하던 그녀는, 비평 대신 옆에 있던 메모장을 가져와 펜으로 두 가지 질문을 적어 솔라 앞에 내밀었다.
시나리오 1: 'VIP 고객 할인 로직'이 올바르게 계산되는지 확인하고 싶다면?
시나리오 2: 나중에 '장바구니 총액 미리보기' 기능에서도 이 할인 로직을 그대로 사용하고 싶다면?
솔라는 첫 번째 질문을 소리 내어 읽었다. “‘VIP 고객 할인 로직’만 테스트하려면…?”
그녀는 자신의 코드를 다시 들여다보았다. 할인 로직은 메서드 중간에 껴있는 if ("VIP".equals(user.getGrade())) 이 한 줄짜리 코드 블록이었다. 이 작은 부분을 테스트하기 위한 과정을 머릿속으로 그려보기 시작했다.
“음… 일단 가짜 HTTP 요청 객체를 만들어서 placeOrder 메서드를 호출해야겠지. 그러려면 이 메서드가 필요로 하는 productRepository와 userRepository가 있어야 하니까… 가짜 목(Mock) 객체를 만들어서 ‘이런 상품 정보를 돌려줘’, ‘저런 사용자 정보를 돌려줘’라고 미리 설정해둬야 해. 아, 중간에 외부 결제 시스템이랑 알림 시스템도 호출하니까, 걔네들도 전부 가짜 객체로 막아둬야 하고….”
말을 이어가던 솔라의 미간이 좁혀졌다. 단순히 10% 할인이 맞는지 확인하고 싶을 뿐인데, 그 준비 과정이 마치 오케스트라 전체를 무대에 올리는 것처럼 거창했다. HTTP, 데이터베이스, 외부 시스템까지, 테스트와는 전혀 상관없는 부분들까지 전부 신경 써야 했다.
그녀는 두 번째 시나리오로 눈을 돌렸다. “‘장바구니’에서 재사용하려면…?”
“이 할인 로직 부분을… 복사해서 장바구니 쪽 컨트롤러에 붙여넣으면… 되나?”
스스로 질문을 던졌지만, 곧바로 문제점이 보였다.
“만약에 VIP 할인 정책이 10%에서 15%로 바뀐다면? 그럼 주문 컨트롤러도 수정하고, 장바구니 컨트롤러도 수정해야 하네. 두 군데나! 만약 한 곳이라도 빼먹으면… 고객은 주문할 때랑 장바구니에서 볼 때랑 다른 가격을 보게 될 거야. 이건 큰일이지.”
순간, ‘간결함’과 ‘편리함’을 위해 한데 모아놓았던 코드 덩어리가 전혀 다른 모습으로 보이기 시작했다. 그것은 깔끔하게 정돈된 상자가 아니라, 온갖 종류의 케이블이 뒤엉켜 있는 거대한 매듭이었다.
솔라의 입에서 나지막한 탄식이 흘러나왔다.
“아… 이게 그냥 코드가 길다는 차원의 문제가 아니었구나. 모든 책임이 서로의 발목을 잡고 있었어. ‘할인율 계산’이라는 작은 기능 하나를 테스트하거나 재사용하고 싶을 뿐인데, HTTP 요청 처리, 재고 확인, 결제 로직까지 전부가 하나의 덩어리로 엉겨 붙어서 떨어지질 않네.”
컨트롤러는 외부 세계와의 소통을 책임지는 ‘문지기’여야 했다. 하지만 지금 솔라가 만든 컨트롤러는 문지기 역할은 물론, 회계, 재고 관리, 고객 응대까지 도맡아 하는, 과도하게 많은 책임을 짊어진 모습이었다. 너무 많은 일을 하느라 몸집이 비대해져, 간단한 움직임 하나조차 둔하고 어려워진 것이다.
“컨트롤러가 해야 할 일보다 너무 많은 일을 하니까… 엄청 뚱뚱해졌어. 이게 바로 ‘Fat Controller’ 문제구나.”
스스로 내린 결론에 솔라는 고개를 가로저었다. 역할 분리가 단순히 파일을 나누는 폴더 정리나 코드의 미학 문제가 아니었다. 그것은 변화에 유연하게 대응하고, 코드의 신뢰성을 확보하기 위한 생존 전략에 가까웠다.
솔라는 방금 작성했던 거대한 placeOrder 메서드를 망설임 없이 지워나갔다. 빈 화면을 보며 그녀가 입을 열었다.
“알겠어, 언니. 이렇게 하면 안 돼. 명확하게 책임을 나눠야겠어. 그런데… 그럼 제대로 나눈 구조는 대체 어떤 모습이야? 사용자 요청이 들어오면 컨트롤러를 거쳐서, 서비스를 지나, 리포지토리까지… 이 전체적인 그림, 데이터가 흘러가는 진짜 지도가 보고 싶어.”
5장: 3계층 아키텍처: 요청과 판단의 책임 지도
솔라의 마지막 말이 남긴 정적을 깬 것은 루나의 움직임이었다. 솔라는 뚱뚱한 컨트롤러 코드를 지워버린 빈 화면을 멍하니 바라보고 있었다. 명확한 책임 분리가 필요하다는 것은 알았지만, 그 결과물이 어떤 모습이어야 하는지에 대한 막막함이 느껴졌다. “진짜 지도”가 필요했다.
루나는 말없이 새하얀 A4 용지를 한 장 가져와 테이블 위에 놓았다. 그리고는 굵은 펜으로 종이를 삼등분하는 두 개의 세로줄을 그었다. 곧이어 텅 빈 세 개의 칸 위에 각각 제목이 달리기 시작했다. 왼쪽부터 차례대로 Controller, Service, Repository. 마치 앞으로 지어질 건물의 설계도처럼, 뼈대만 앙상하게 그려진 구조였다.
“네가 보고 싶어 하던 지도야.”
루나가 펜을 솔라에게 건네며 말했다.
“사용자가 우리 웹사이트에 회원 가입을 하려고 해. 그 요청이 출발해서, 다시 사용자에게 ‘가입 완료’ 응답이 돌아가기까지의 여정을 여기에 직접 그려봐. 각 역참에서 어떤 일이 벌어지는지, 네가 이해한 대로.”
솔라는 펜을 받아 들고 잠시 망설였다. 텅 빈 세 개의 칸은 단순한 폴더 구분처럼 보이기도 했다. 하지만 방금 전 ‘뚱뚱한 컨트롤러’의 실패를 겪은 그녀는 이것이 단순한 파일 정리가 아님을 직감했다. 이건 책임의 흐름을 그리는 일이었다.
솔라는 심호흡 한번 하고 펜 끝을 종이 왼편 바깥쪽에 가져다 댔다.
“자, 시작은 여기. 사용자의 웹 브라우저.”
솔라는 사용자 (웹 브라우저)라고 적고, 첫 번째 칸인 Controller를 향해 화살표를 쭉 그었다.
“사용자가 아이디와 비밀번호를 입력하고 ‘가입하기’ 버튼을 누르면, HTTP 요청이 우리 서버의 문지기, 즉 컨트롤러에게 가장 먼저 도착해.”
그녀는 Controller 칸 안에 작은 글씨로 목록을 적기 시작했다.
1. HTTP 요청 받기 (@PostMapping)2. 요청 데이터(JSON)를 자바 객체(DTO)로 변환하기3. 데이터 형식 검사하기 (ex: 이메일 형식이 맞는가?)
“컨트롤러는 문지기니까, 외부에서 온 요청의 형식이 올바른지, 필요한 값이 빠지진 않았는지 같은 겉모습만 확인하고 안으로 들여보내. 여기까지가 컨트롤러의 책임.”
이제 흐름은 다음 칸으로 넘어가야 했다. 솔라는 Controller 칸에서 Service 칸으로 넘어가는 두 번째 화살표를 그렸다.
“컨트롤러는 실질적인 업무 처리는 하지 않아. 자기가 받은 요청 데이터를 다음 책임자인 서비스에게 넘겨주지.”
솔라의 펜이 Service 칸 안에서 다시 움직였다. 이전 장에서 루나와 나누었던 ‘상품 주문’ 시나리오가 떠올랐다. 서비스는 단순한 심부름꾼이 아니라, 핵심 규칙을 아는 전문가였다.
4. 우리 서비스의 규칙(비즈니스 로직) 적용하기‘이미 존재하는 이메일인가?’ 확인가입 축하 쿠폰 발급하기 (만약 이런 정책이 있다면)
5. 데이터를 가공해서 DB에 저장할 형태로 만들기 (Entity 변환)
“서비스는 컨트롤러에게서 받은 데이터를 가지고, 우리 서비스의 진짜 규칙을 적용해. 이메일이 중복되는지 확인하는 것처럼. 그리고 모든 규칙을 통과했다면, 데이터베이스에 저장할 수 있는 최종 형태로 데이터를 준비시켜.”
마지막 단계였다. 솔라는 Service 칸에서 마지막 칸인 Repository로 향하는 세 번째 화살표를 그었다.
“준비가 끝난 데이터는 데이터베이스 창고 관리인, 리포지토리에게 전달돼. ‘이것 좀 저장해 줘’ 하고.”
Repository 칸 안에는 간결하게 한 줄이 적혔다.
6. 데이터베이스에 저장 (INSERT a new user)
이제 요청의 여정은 끝났다. 하지만 일은 아직 끝나지 않았다. 솔라는 다시 펜을 거꾸로 움직였다. 이번에는 Repository에서 Service로, 그리고 Service에서 Controller로 돌아오는 점선 화살표를 그렸다. ‘처리 결과 보고’라고 작게 메모를 달았다.
“리포지토리는 저장 결과를 서비스에게 보고하고, 서비스는 모든 업무가 끝났다고 컨트롤러에게 알려줘. 그럼 최종적으로 컨트롤러가 사용자에게 응답을 보내주면… 끝.”
솔라는 Controller 칸의 목록 제일 아래에 마지막 책임을 추가했다.
7. 처리 결과를 HTTP 응답으로 만들어 보내기 (ResponseEntity)
펜을 내려놓은 솔라는 자신이 완성한 지도를 가만히 들여다보았다. 왼쪽에서 오른쪽으로 흐르는 명확한 명령의 흐름과, 오른쪽에서 왼쪽으로 돌아오는 보고의 흐름. 그 순간, 그녀는 중요한 사실을 깨달았다.
“아… 이건 그냥 파일을 세 개의 폴더로 나누는 게 아니구나. 이건 일방통행 도로 같은 거네. 컨트롤러는 서비스에게만 말을 걸고, 서비스는 리포지토리에게만 말을 걸어. 반대로 리포지토리는 서비스가 누구인지 전혀 모르고, 서비스도 이 요청이 HTTP에서 온 건지, 아니면 다른 시스템이 시킨 건지 알 필요가 없어. 각자 바로 아래 계층에만 책임을 넘기고, 그 결과만 보고받는 구조인 거야.”
그녀가 처음 가졌던 오해, 3계층 구조가 단순히 코드를 정리하는 폴더링일 뿐이라는 생각이 완전히 부서지는 순간이었다. 이것은 책임의 위임 방향과 정보의 흐름을 규정하는 엄격한 약속이자 설계도였다.
루나가 조용히 고개를 끄덕였다. 그때, 솔라가 마치 새로운 게임의 규칙을 발견한 아이처럼 눈을 빛내며 말했다.
“그럼 언니, 만약에 ‘사용자 닉네임 변경’ 기능을 추가해야 한다면…”
솔라는 새로운 펜을 집어 들고, 방금 그린 지도 위에 새로운 요청의 흐름을 덧그릴 준비를 했다.
“일단, 사용자가 닉네임을 바꾸고 싶다는 요청을 보내겠지? 이건 외부와의 소통이니까 당연히 컨트롤러의 일. 컨트롤러는 요청에 닉네임 값이 비어있지 않은지 정도만 확인할 거야.”
그녀는 지도 위 컨트롤러 칸에 닉네임 변경 요청 받기라고 썼다.
“그리고 컨트롤러는 서비스에게 ‘이 사용자의 닉네임을 이걸로 바꿔줘’라고 요청하겠지. 그럼 서비스가 실제 규칙을 검사할 거야. 예를 들어 ‘욕설이 포함되어 있는지’, ‘다른 사람과 중복되는지’ 같은 비즈니스 규칙 말이야. 모든 규칙을 통과하면, 서비스가 리포지토리에게 ‘이 사용자의 닉네임을 업데이트해 줘’라고 시키고. 그럼 끝!”
솔라는 망설임 없이 각 계층의 빈 공간에 새로운 책임들을 정확히 배치했다. 더 이상 코드를 어디에 두어야 할지 망설이지 않았다. 어떤 책임이 누구의 일인지 판단하는 기준, 바로 이 ‘책임의 지도’가 그녀의 머릿속에 완성되었기 때문이다. 간결함의 문제가 아니라, 각자의 정체성과 역할에 대한 문제라는 것을 이제는 명확히 알 수 있었다.