Mini Project 5 05
CRUD 요청의 생명주기: Controller, Service, Repository, 그리고 Frontend
CRUD endpoint를 만든다는 말은 익숙하지만 Controller, Service, Repository가 각각 왜 필요한지, frontend 호출과 어떻게 맞물리는지 헷갈린다.
근거 · 교안 CRUD API/Postman 검증 파트
1장: Controller: HTTP 요청의 첫 관문
솔라는 모니터에 떠 있는 한 문장을 조용히 바라보고 있었다. 익숙한 단어들이었지만, 그 조합은 어쩐지 모래알처럼 서걱거리는 느낌을 주었다.
"백엔드는 /books 요청을 BookController, BookService, BookRepository로 나누어 처리한다."
마치 “아침 식사는 빵, 버터, 잼으로 구성된다”는 말처럼 당연하게 들렸다. 하지만 솔라는 이 문장 앞에서 멈춰 섰다. 빵과 버터와 잼은 각자의 맛과 역할이 뚜렷한데, Controller, Service, Repository는 정말 그럴까? 코드를 그저 세 개의 파일로 나누어 정리하는 것 이상의 의미가 있는 걸까? 그냥 Controller 하나가 모든 일을 처리하면 안 되는 이유라도 있을까? 오히려 일을 더 복잡하게 만드는 것 같았다.
“그냥… 단순히 코드를 분류하기 위한 이름표 같은 거 아닐까?”
솔라가 혼잣말처럼 중얼거릴 때, 조용히 다가온 루나가 솔라의 화면을 들여다보았다. 루나는 잠시 솔라의 시선이 머무는 곳을 따라가더니, 복잡한 아키텍처 다이어그램 대신 코드 에디터의 파일 탐색기를 가리켰다.
“솔라, 거기서 딱 한 파일만 열어보자. BookController.java.”
솔라는 루나의 말에 따라 파일을 열었다. 화면에 코드가 나타났지만, 루나는 전체를 읽으라고 하지 않았다.
“Service나 Repository는 잠시 잊어봐. 이 파일, BookController가 주로 대화하는 상대가 누구인 것 같아? 코드 안에서 가장 자주 눈에 띄는 표현들이 뭐야?”
솔라는 의아했지만, 지시대로 코드에 집중하기 시작했다. 파일 전체를 훑어보니 일정한 패턴이 보였다. @RestController, @RequestMapping("/books"), @GetMapping, @PostMapping, @PathVariable, @RequestBody… 그리고 ResponseEntity라는 단어도 눈에 띄었다.
“음… 이건 거의 다… HTTP랑 관련된 것들이네. URL 주소, GET이나 POST 같은 요청 방식, 요청 몸체에 담긴 데이터… 그리고 클라이언트에게 돌려줄 응답 형식.”
솔라가 단어들을 하나씩 짚으며 말했다. 처음에는 그저 코드의 일부라고 생각했던 것들이, 다시 보니 모두 외부 세계와의 소통 규칙처럼 보였다. 마치 식당 입구에 서서 손님을 맞는 직원 같았다. ‘몇 분이세요?’, ‘예약하셨나요?’, ‘주문은 이쪽입니다.’ 같이, 식당 안의 요리사나 매니저와는 다른 종류의 일을 하고 있었다.
“맞아. 이 BookController는 데이터베이스나 우리 서비스의 복잡한 규칙에 대해서는 거의 아무것도 몰라. 오직 HTTP라는 언어로 찾아오는 손님을 어떻게 맞이하고, 그들의 요청을 어떻게 이해하고, 또 어떻게 응답을 돌려줄지에만 관심이 있지.”
루나의 말에 솔라는 BookController의 한 메서드를 가리켰다.
@PostMapping
public ResponseEntity<Book> createBook(@RequestBody BookCreateRequest request) {
Book newBook = bookService.create(request);
return ResponseEntity.created(URI.create("/books/" + newBook.getId())).body(newBook);
}
“그럼 이 부분은… 손님이 ‘새 책을 만들어줘’라고 POST 요청을 보내면, 그 요청에 담긴 정보(@RequestBody)를 bookService라는 곳에 넘겨주는 거네. 자기가 직접 책을 만들지 않고.”
“정확해. 그리고 bookService가 일을 끝내고 새로 만들어진 책을 돌려주면, Controller는 그걸 다시 HTTP 응답 형식에 맞춰 포장해서(ResponseEntity.created(...)) 손님에게 돌려주지. ‘요청하신 책이 여기 이 주소에 만들어졌습니다’ 하고 알려주는 역할까지.”
솔라는 고개를 끄덕였다. 이제야 Controller, Service, Repository가 단순한 파일 분리가 아닐 수도 있겠다는 생각이 들었다. Controller는 명확한 자기만의 역할이 있었다. 바로 HTTP 요청이라는 외부 세계의 언어를 내부 시스템이 이해할 수 있는 언어로 번역하고, 그 결과를 다시 외부로 전달하는 관문. 시스템의 가장 바깥에서 모든 종류의 웹 요청을 받아내는 첫 번째 수문장이었다.
“알겠다. Controller는 우리 시스템의 얼굴이자 현관문이구나. 데이터베이스에 뭐가 있든, 우리 서비스의 규칙이 얼마나 복잡하든 상관없이, 오직 HTTP라는 약속을 사용해서 바깥 세상과 소통하는 책임만 지는 거야.”
솔라의 목소리에 막혔던 무언가가 풀려나간 듯한 시원함이 묻어났다. 이제 ‘백엔드는 요청을 세 계층으로 나누어 처리한다’는 문장이 다르게 보였다. 적어도 BookController가 왜 따로 존재해야 하는지는 명확해졌다. 그는 우리 시스템의 대화 전문가, ‘HTTP 입구 판단자’였다.
하지만 안도감도 잠시, 솔라의 시선은 다시 코드의 한가운데로 향했다.
bookService.create(request);
“좋아. 현관문 역할은 알겠어. 그런데 현관문을 통과한 손님은 bookService라는 부서로 안내되네. 대체 이 부서는 무슨 일을 하는 곳일까? 그리고 그 옆에 있다는 Repository는 또 뭐고? 이 둘은 어떻게 다른 거지?“
2장: Service & Repository: 규칙과 저장소 접근
솔라의 질문이 채 끝나기도 전에, 루나는 키보드에 손을 얹었다. 솔라가 보고 있던 BookController.java 파일은 화면 한쪽으로 밀려났고, 그 옆으로 두 개의 새로운 파일이 나란히 열렸다. 왼쪽에는 BookService.java, 오른쪽에는 BookRepository.java.
루나는 아무 말 없이 화면을 둘로 나눈 채 솔라를 바라보았다. 마치 두 개의 그림을 걸어놓고 어느 쪽이 다른지 찾아보라는 듯한 침묵이었다. 솔라는 루나의 의도를 알아차리고 화면에 시선을 고정했다. bookService.create(...). 컨트롤러에서 본 그 한 줄이 이제는 거대한 두 개의 코드 덩어리로 눈앞에 펼쳐져 있었다.
“음… 둘 다 책(Book) 데이터를 다루는 건 똑같아 보이는데…”
솔라가 입을 열었다. BookService에도 createBook, findBook 같은 메서드가 있었고, BookRepository는 JpaRepository<Book, Long>을 상속받고 있어 findById, findAll, save 같은 기능들이 암시되어 있었다. 언뜻 보기에 둘은 비슷한 일을 하는 쌍둥이처럼 보였다. 데이터를 찾고, 만들고, 저장하는 일. 그렇다면 왜 굳이 두 개의 부서로 나눈 걸까? 데이터를 저장소에 넣는 단순한 작업인데, 중간에 Service라는 계층이 하나 더 끼어 있는 건 번거롭게만 느껴졌다.
“컨트롤러에서 바로 Repository를 호출해서 저장하면 더 간단한 거 아냐? Service는 그냥 데이터를 전달만 하는 것 같은데. 이건 그냥… 일을 복잡하게 만드는 것 같아.”
솔라의 목소리에는 컨트롤러의 역할을 이해했을 때의 시원함 대신, 다시금 짙어진 의구심이 묻어났다.
“솔라, BookService 파일에서 update 메서드를 한번 자세히 볼래? 그리고 BookRepository에 있는 save와 비교해봐. Repository의 save는 우리가 직접 코드를 짜지 않았다는 걸 기억하고.”
루나는 특정 코드 라인을 짚는 대신, 솔라가 스스로 차이를 발견하도록 유도했다. 솔라는 먼저 BookService.java의 update 메서드로 시선을 옮겼다.
// BookService.java
@Transactional
public Book update(Long id, BookUpdateRequest request) {
Book book = bookRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("책을 찾을 수 없습니다."));
book.setTitle(request.getTitle());
book.setAuthor(request.getAuthor());
book.setUpdatedAt(LocalDateTime.now()); // <-- 이 줄은 뭐지?
return bookRepository.save(book);
}
코드를 읽어 내려가던 솔라의 눈이 한 줄 위에서 멈췄다. setUpdatedAt(LocalDateTime.now()). 책의 제목과 저자를 바꾸는 것 외에, ‘수정된 시간’을 현재 시간으로 설정하는 코드가 있었다. 요청받은 데이터를 그대로 저장하는 게 아니었다. 저장하기 직전, 무언가 추가적인 작업을 하고 있었다.
“어라? 책 정보를 수정하라는 요청에는 ‘수정 시간’을 바꾸라는 말이 없었는데, 서비스가 알아서 현재 시간을 기록하네.”
이번에는 BookRepository를 보았다. 그 파일은 텅 비어있다시피 했다.
// BookRepository.java
public interface BookRepository extends JpaRepository<Book, Long> {
}
메서드 선언만 있을 뿐, save가 실제로 어떻게 동작하는지에 대한 코드는 한 줄도 없었다. Repository는 그냥 ‘저장해줘’라는 명령을 받으면, 어떻게든 데이터베이스에 저장할 뿐, ‘수정 시간을 기록해야 한다’ 같은 규칙에는 전혀 관심이 없어 보였다.
그 순간, 두 계층의 역할이 선명하게 갈라지는 것을 느꼈다.
“아…! 알겠다. Repository는 정말 그냥 창고지기구나. 물건을 넣으라면 넣고, 꺼내라면 꺼낼 뿐, 그 물건에 ‘제조일자’ 스티커를 붙여야 하는지, ‘취급주의’ 딱지를 붙여야 하는지는 전혀 신경 쓰지 않아. 그냥 저장이라는 행위 자체에만 충실한 거야.”
솔라는 흥분한 목소리로 말을 이었다.
“하지만 Service는 달라. Service는 우리 프로젝트의 ‘규칙’을 아는 매니저야. 컨트롤러가 ‘이 책 정보 좀 수정해줘’라고 데이터를 넘기면, Service는 ‘알겠습니다. 그런데 우리 회사 규칙상, 정보를 수정하면 반드시 수정 시간을 기록해야 합니다’라며 updatedAt 필드를 채워 넣는 거지. 그런 다음에야 창고지기인 Repository에게 ‘자, 이제 저장해’라고 명령하는 거고!”
단순히 데이터를 전달하는 중간 다리가 아니었다. Service는 비즈니스 로직, 즉 ‘우리 서비스가 따라야 할 고유한 규칙’이 살아 숨 쉬는 공간이었다. ‘책을 삭제할 때 실제로 지우지 않고 ‘삭제됨’ 상태로 바꾸기’, ‘책을 등록할 때 기본 표지 이미지 설정하기’ 같은 규칙들이 모두 Service의 책임이었던 것이다. Repository는 그저 데이터베이스와 소통하는 법만 아는 기술 전문가일 뿐이었다.
루나는 조용히 고개를 끄덕였다. 이제 솔라는 BookController, BookService, BookRepository라는 이름표 뒤에 숨은 각자의 명확한 ‘책임’을 보고 있었다.
“맞아. Service는 비즈니스 규칙이 놓일 자리이고, 여러 데이터베이스 작업을 하나의 덩어리로 묶는 트랜잭션 경계가 되기도 해. 반면, Repository는 오직 데이터 저장소에 접근하는 방법에만 집중하지. 이렇게 역할을 나누면 나중에 데이터베이스를 바꾸거나, 새로운 비즈니스 규칙이 추가되어도 서로에게 미치는 영향을 최소화할 수 있어.”
컨트롤러는 HTTP 대화 전문가, 서비스는 비즈니스 규칙 전문가, 리포지토리는 데이터 저장소 전문가. 이제 백엔드의 세 계층은 솔라에게 단순한 파일 묶음이 아니라, 각자의 전문 분야를 가진 협업팀처럼 보였다.
탄탄한 백엔드 팀의 모습이 머릿속에 그려지자 안도감이 들었다. 하지만 동시에 새로운 의문이 고개를 들었다. 이 유기적인 팀플레이는 전부 서버라는 보이지 않는 공간 안에서 일어나는 일이다.
“좋아, 이제 요청이 서버 안에서 어떻게 흘러가는지는 알겠어. 그런데… 맨 처음 사용자가 우리 웹사이트에서 ‘책 추가’ 버튼을 누르는 그 순간부터, 이 모든 과정이 어떻게 시작되는 거지? 그리고 이 팀이 열심히 일해서 얻은 결과는 어떻게 다시 사용자 화면의 변화로 이어지는 거야? 프론트엔드는 이 백엔드 팀이랑 어떻게 대화하는 거지?“
3장: CRUD 체인의 완성: Frontend와의 유기적 연결
솔라의 질문이 남긴 여운은 책상 위를 조용히 맴돌았다. 모니터에는 여전히 세 개의 파일, Controller, Service, Repository가 각자의 역할을 뽐내듯 나란히 열려 있었다. 이 견고한 백엔드 삼인조가 어떻게 협력하는지는 이제 선명하게 이해됐다. 하지만 이들만의 완벽한 팀플레이는 결국 서버라는 보이지 않는 무대 위에서 벌어지는 연극일 뿐이었다. 관객, 즉 사용자는 이 무대를 볼 수 없다. 사용자는 오직 자신의 스크린, 프론트엔드라는 창문만 볼 뿐이다.
루나는 말없이 새 창을 하나 열었다. 검은 바탕에 주황색 로고가 그려진, 익숙하지만 낯선 도구였다. Postman. 개발자들이 API가 잘 작동하는지 테스트하기 위해 쓰는 도구. 루나는 BookController.java 파일의 @PostMapping("/books") 어노테이션과 Postman의 주소창을 나란히 화면에 배치했다. 그리고 주소창에 http://localhost:8080/books라고 타이핑했다.
“프론트엔드와 백엔드는 서로 다른 언어로 만들어진 별개의 시스템 같지. 마치 한국어를 쓰는 사람과 영어를 쓰는 사람이 만난 것처럼. 둘 사이의 소통은 불가능하거나, 아주 복잡하고 마법 같은 번역기가 필요할 거라고 생각하기 쉬워.”
루나가 Postman 화면에서 요청 방식을 POST로 바꾸며 말했다.
“하지만 실제로는 마법이 아니야. 둘은 아주 구체적이고 명확한 ‘계약’을 통해 대화해.”
솔라는 루나의 손가락이 멈춘 곳을 보았다. 백엔드 컨트롤러 코드에 적힌 /books라는 주소와 Postman에 입력된 주소가 정확히 일치했다. 이건 우연이 아니었다.
“백엔드 팀의 첫 관문인 컨트롤러가 세상에 외치는 거야. ‘저희는 /books라는 주소로 POST 방식의 편지를 받습니다!’ 라고. 그럼 누구든지 그 주소와 형식에 맞춰 편지를 보내면, 우리 팀이 처리해주겠다고 약속하는 거지.”
루나는 Postman의 ‘Body’ 탭을 눌러 JSON 형식으로 간단한 내용을 입력했다.
{
"title": "Postman에서 보낸 책",
"author": "테스터"
}
“이게 바로 편지의 내용이야. 컨트롤러가 @RequestBody로 받기로 약속했던 바로 그 데이터지.”
루나가 ‘Send’ 버튼을 클릭했다. 잠시 후, Postman의 아래쪽 화면에 응답이 나타났다.
{
"id": 4,
"title": "Postman에서 보낸 책",
"author": "테스터",
"createdAt": "2023-10-27T14:30:00",
"updatedAt": "2023-10-27T14:30:00"
}
솔라의 눈이 커졌다. 브라우저 화면도, 프론트엔드 코드도 없이, 오직 Postman이라는 도구 하나로 백엔드 팀 전체를 움직인 것이다. 이 요청은 Controller를 통과하고, Service의 손을 거쳐 updatedAt이라는 비즈니스 규칙이 적용되었으며, Repository를 통해 데이터베이스에 안전하게 저장되었다. 그리고 그 결과가 ‘id: 4’라는 증거와 함께 돌아왔다. 프론트엔드는 이 과정 어디에도 없었다.
“아…”
탄식이 터져 나왔다. 마법이 아니었다. 약속이었다. 프론트엔드와 백엔드는 서로 다른 세상이 아니었다. 그저 이 ‘HTTP API’라는 공통의 약속, 즉 계약서를 사용하는 수많은 고객 중 하나일 뿐이었다. Postman이 그 계약서를 지켜 백엔드와 통신할 수 있다면, 프론트엔드도 당연히 할 수 있었다.
“그럼… 프론트엔드에 있는 ‘책 추가’ 버튼을 누르는 건, 결국 이 Postman의 ‘Send’ 버튼을 누르는 것과 똑같은 일을 자바스크립트 코드로 실행하는 것뿐이겠구나!”
솔라는 방금 전까지 미지의 영역으로 느껴졌던 프론트엔드 코드가 갑자기 선명하게 예측되기 시작했다. 어딘가에 fetch나 axios 같은 함수를 써서, http://localhost:8080/books 주소로, POST 방식으로, 책 제목과 저자가 담긴 JSON 데이터를 보내는 코드가 분명히 있을 것이다.
루나는 기다렸다는 듯 프론트엔드 프로젝트의 bookService.js 파일을 열어 보여주었다. 솔라의 예상 그대로였다. Postman에서 했던 작업이 자바스크립트 언어로 고스란히 적혀 있었다.
“이제 알겠어. 전체 흐름이 보여.”
솔라는 눈을 감고 요청의 여정을 머릿속에 그렸다.
- 사용자가 웹 화면에서 ‘저장’ 버튼을 누른다.
- 프론트엔드 자바스크립트는 약속된 주소(
POST /books)로 HTTP 요청을 보낸다. - 백엔드의
Controller가 그 요청을 받아,Service에게 일을 넘긴다. Service는 비즈니스 규칙(수정 시간 기록 등)을 적용하고,Repository를 통해 데이터베이스에 저장한다.- 성공적으로 처리된 결과 데이터가 다시
Controller를 거쳐 HTTP 응답으로 포장되어 프론트엔드에게 돌아온다. - 프론트엔드는 그 응답 데이터를 받아 화면에 새로운 책을 그려준다.
완벽한 왕복 여행이었다. 이제 '백엔드는 요청을 세 계층으로 나누어 처리하고 Postman과 frontend에서 같은 API를 검증한다'는 문장은 더 이상 분해해서 이해해야 할 지식이 아니었다. 그것은 솔라의 머릿속에서 유기적으로 살아 움직이는 하나의 완성된 그림, ‘전체 흐름 추적기’가 되었다.
솔라는 비어있는 새 문서를 열었다. 그리고 키보드로 새로운 기능 요구사항을 타이핑하기 시작했다. ‘책 삭제 기능 구현’. 이전 같았으면 막막했을 것이다. 하지만 지금 솔라의 머릿속에는 명확한 작업 목록이 떠올랐다.
- FE: 삭제 버튼 추가. 클릭 시
bookService.deleteBook(id)호출. 해당 함수는 백엔드에DELETE /books/{id}요청 전송. - BE-Controller:
@DeleteMapping("/books/{id}")어노테이션을 가진 메서드 구현.bookService.delete(id)호출. - BE-Service:
delete(id)메서드 구현. (규칙: 실제로 데이터를 지우지 않고, ‘삭제됨’ 상태로 변경). - BE-Repository: 서비스 로직을 위해
findById,save등 기존 메서드 활용.
더 이상 각 계층은 흩어진 부품이 아니었다. 솔라는 이제 새로운 요청이 들어왔을 때, 프론트엔드의 버튼 클릭부터 데이터베이스의 변화, 그리고 다시 화면의 갱신까지, 전체 흐름을 따라 각 전문가(계층)에게 어떤 일을 맡겨야 할지 명확히 지시할 수 있는 프로젝트 매니저가 된 기분이었다.