Backend 19

Controller와 Repository 연결: 실제 DB 데이터 활용

Controller가 임시 데이터를 반환하던 단계와 DB에서 실제 데이터를 가져오는 단계가 어떻게 이어지는지 모르겠다.

근거 · 교안 p122-p127

Controller와 Repository 연결: 실제 DB 데이터 활용 대표 이미지

1장: 임시 데이터의 한계: 왜 실제 DB가 필요한가?

솔라는 노트북 화면에 떠 있는 코드를 만족스럽게 바라보고 있었다. BookController.java. 실행 버튼을 누르고 웹 브라우저를 열어 주소를 입력하자, 깔끔하게 정돈된 JSON 데이터가 화면을 채웠다. 『코스모스』, 『사피엔스』, 『총, 균, 쇠』. 직접 코드 안에 List 형태로 만들어 둔 책 목록이었다.

“좋아, 일단 책 목록은 잘 나오네.”

클라이언트의 요청(GET /books)에 따라 약속된 데이터를 응답하는 것. 컨트롤러의 역할에 충실한 코드였다. 솔라는 자신이 만든 작은 세계가 잘 작동하는 것을 확인하고 뿌듯한 미소를 지었다.

옆에서 지켜보던 언니 루나가 찻잔을 내려놓으며 물었다. “잘 되네. 그럼 이제 그 서버, 한번 껐다가 다시 켜볼래?”

솔라는 당연하다는 듯이 대답했다. “껐다 켜도 똑같지. 코드를 바꾼 게 아니니까.”

“음, 그럼 사용자가 새 책을 추가하거나, 기존 책을 삭제하는 기능을 넣는다고 상상해봐. 그리고 나서 서버를 다시 시작하면 어떻게 될까?”

솔라의 손가락이 잠시 멈칫했다. 사용자가 책을 추가한다… POST 요청을 처리하는 메서드를 BookController에 만들면 될 것이다. 요청 본문에 담겨 온 책 데이터를 컨트롤러 안에 선언된 List<Book> 객체에 add하면 되겠지. 삭제도 마찬가지다. DELETE 요청으로 특정 ID의 책을 리스트에서 지우면 된다. 머릿속으로 코드가 그려졌다. 간단했다. 하지만…

서버를 껐다가 다시 켜면?

그 순간, 방금 전까지 단단하게 느껴졌던 자신의 코드에 보이지 않는 균열이 가는 것을 느꼈다. 컨트롤러에 List<Book> 형태로 만들어 둔 임시 데이터 저장소는 애플리케이션이 시작될 때마다 코드에 적힌 그대로 새로 생성된다. 사용자가 힘들게 추가한 책 정보는 서버 재시작과 함께 공기 중으로 사라져 버린다. 방금 삭제했던 책은 마법처럼 다시 목록에 나타날 것이다. 데이터가 ‘유지’되지 않는다.

“아….”

솔라가 나지막이 탄식했다.

“이건 진짜 데이터가 아니구나. 그냥 프로그램이 살아있는 동안에만 존재하는 유령 같은 거네. 장난감 가게를 열었는데, 문 닫으면 진열했던 장난감들이 전부 사라지는 거랑 똑같잖아.”

그녀는 방금 전까지 만족스럽게 바라보던 자신의 코드를 다시 쳐다보았다.

@RestController
public class BookController {

    // 임시 데이터
    private List<Book> books = Arrays.asList(
            new Book(1L, "코스모스", "칼 세이건"),
            new Book(2L, "사피엔스", "유발 하라리"),
            new Book(3L, "총, 균, 쇠", "제레드 다이아몬드")
    );

    @GetMapping("/books")
    public List<Book> getBooks() {
        return books;
    }
}

이제는 그 코드가 어딘가 미완성인 설계도처럼 보였다. 외부 요청에 응답하는 기능은 있지만, 가장 중요한 ‘상태’를 기억하지 못하는 구조. 지금은 책이 3권뿐이지만, 수천, 수만 권의 책 정보를 이렇게 코드에 직접 박아둘 수도 없는 노릇이었다.

루나가 고개를 끄덕였다. “정확해. 지금 솔라 네가 만든 데이터는 ‘메모리 위’에만 존재해. 애플리케이션의 생명주기와 운명을 같이 하는 거지. 그래서 우리는 데이터가 사라지지 않고 영원히 보관될 수 있는, 별도의 ‘저장소’가 필요한 거야. 그게 바로 데이터베이스의 역할이고.”

“데이터베이스….”

솔라는 고개를 끄덕였다. 이제 왜 데이터베이스가 필요한지는 명확하게 와 닿았다. 더 이상 컨트롤러가 임시 데이터만 돌려주는 게 당연하다고 생각되지 않았다. 컨트롤러는 단지 문지기일 뿐, 진짜 데이터는 안전한 금고에 보관되어야 했다.

“알겠어, 언니. 데이터가 사라지면 안 되니까 DB가 필요하다는 건 이제 확실히 알겠어. 그런데… 어떻게 연결하는 거지? 다음 과정이 BookControllerBookRepository라는 걸 ‘주입’하는 거라던데, 대체 ‘주입’한다는 게 무슨 뜻이야? 그냥 변수 하나 선언하는 거랑 다른 건가? 그게 어떻게 저 멀리 있는 데이터베이스까지 연결을 만들어 주는 거지?”

솔라의 시선은 BookController 코드의 임시 데이터 목록에서 떠나, 아직 보이지 않는 Repository라는 존재를 향하고 있었다. 유령 데이터를 진짜로 만드는 열쇠는 바로 그 ‘주입’이라는 낯선 행위에 달려있는 것 같았다.

2장: Controller와 Repository, ‘주입’으로 연결하다

루나는 솔라의 질문에 말없이 스케치북과 펜을 가져왔다. 그리고는 아무런 설명 없이 두 개의 네모 상자를 나란히 그렸다. 왼쪽 상자 위에는 ‘BookController’라고 적고, 오른쪽 상자 위에는 ‘BookRepository’라고 적었다. 두 상자는 서로 닿지 않은 채, 마치 각자의 섬처럼 종이 위에 덩그러니 놓여 있었다.

이 단순한 그림이 솔라가 방금 전까지 품었던 생각의 풍경과 정확히 일치했다. 문지기 역할을 하는 컨트롤러, 그리고 어딘가에 존재할 데이터 금고지기인 리포지토리. 둘 다 필요하다는 건 알겠지만, 둘 사이에는 아무런 연결고리가 없었다. 솔라는 고개를 끄덕이며 중얼거렸다. “지금 내 머릿속이 딱 저 상태야. 두 개가 따로따로 떠다니는 느낌.”

“그럼, 솔라 네가 만약 프로그래머라면, 저 둘을 어떻게든 협력하게 만들어야 해. BookControllerBookRepository의 기능을 사용해야 하니까. 가장 먼저 어떤 코드가 떠올라?”

“음… BookController 안에서 BookRepository 객체를 직접 만드는 거?” 솔라가 조심스럽게 대답했다. 그녀의 머릿속에는 익숙한 자바 코드가 스쳐 지나갔다.

// BookController.java
public class BookController {

    private BookRepository bookRepository = new BookRepository(); // 이렇게?

    // ...
}

“컨트롤러가 필요하니까, 컨트롤러가 직접 리포지토리를 ‘생성’하는 거지. new 키워드를 사용해서.”

루나는 솔라의 대답을 듣고는 그림에 무언가를 추가하기 시작했다. ‘BookController’ 상자 안에 작은 ‘BookRepository’ 상자를 하나 더 그렸다. 마치 캥거루 주머니 속 아기 캥거루처럼 보였다.

“그 방식의 문제는, BookControllerBookRepository를 너무 잘 알아야 한다는 거야.” 루나가 말했다. “BookRepository를 어떻게 만들어야 하는지, 데이터베이스 연결 정보는 어디서 가져와야 하는지, 그 모든 생성 과정을 BookController가 책임져야 해. 만약 리포지토리를 만드는 방식이 조금이라도 바뀌면? 컨트롤러 코드도 같이 수정해야 하지. 둘은 너무 단단하게 묶여버려서, 하나를 바꾸면 다른 하나도 영향을 받아. 이걸 ‘강한 결합’이라고 불러.”

솔라는 ‘강한 결합’이라는 단어를 곱씹었다. 컨트롤러는 그저 책 목록을 달라는 요청을 전달하는 문지기 역할만 하고 싶었는데, 어느새 데이터베이스 금고를 만드는 기술자가 되어버린 셈이었다. 부담스럽고, 자신의 역할과 맞지 않는 일이었다.

“그럼 ‘주입’은 다른 방식이야?”

“응. ‘주입’은 컨트롤러가 직접 만들지 않는 방식이야.”

루나는 스케치북의 그림을 수정했다. ‘BookController’ 상자 안에 그렸던 작은 ‘BookRepository’를 지웠다. 그리고 원래 있던 두 개의 큰 상자 바깥을 감싸는 더 큰 테두리를 그렸다. 그 테두리 위에는 ‘스프링 컨테이너(Spring Container)’라고 적었다.

“이제 BookControllernew로 직접 만들지 않아. 대신 이렇게 선언만 하는 거야. ‘나는 BookRepository가 필요해!’라고.”

루나가 수정한 BookController 코드를 보여주었다.

@RestController
public class BookController {

    private final BookRepository bookRepository; // '필요하다'고 선언만 한다.

    // 생성자를 통해 '주입' 받는다.
    public BookController(BookRepository bookRepository) {
        this.bookRepository = bookRepository;
    }

    // ...
}

“봐. new가 사라졌어. 대신 생성자를 통해 BookRepository를 받고 있지. 이게 바로 ‘의존성 주입(Dependency Injection)’이야. 컨트롤러가 자신의 의존성(dependency), 즉 자신이 필요로 하는 객체(BookRepository)를 외부에서 주입(injection)받는 거지.”

루나는 그림에서 ‘스프링 컨테이너’라고 적힌 큰 테두리에서 ‘BookRepository’ 상자로 화살표를 그었다. ‘①생성’이라고 적었다. 그리고 다시 컨테이너에서 ‘BookController’ 상자로 화살표를 그었다. ‘②생성’이라고 적었다. 마지막으로, 이미 생성된 ‘BookRepository’ 상자에서 ‘BookController’ 상자로 이어지는 화살표를 그리고 그 위에 ‘③주입’이라고 썼다.

솔라의 눈이 동그래졌다. “아! 그러니까… 내가 직접 만드는 게 아니라, 저 바깥의 ‘스프링 컨테이너’라는 관리자가 리포지토리를 알아서 만들고, 컨트롤러를 만들 때 ‘이거 네가 필요하다고 했던 리포지토리야’ 하면서 건네주는 거구나!”

“바로 그거야.”

솔라는 두 개의 그림을 번갈아 보았다. 컨트롤러가 리포지토리를 직접 만들던 첫 번째 그림과, 스프링 컨테이너가 둘을 연결해주던 두 번째 그림. 차이는 명확했다. 컨트롤러는 더 이상 리포지토리의 복잡한 생성 과정을 알 필요가 없었다. 그저 자신의 생성자 파라미터로 들어온, 이미 완벽하게 준비된 ‘진짜’ 리포지토리를 사용하기만 하면 됐다.

“그럼 저 bookRepository는 이미 데이터베이스랑 연결까지 다 끝난 상태인 거네. 스프링 컨테이너가 다 알아서 해준 거니까.”

“정확해. 컨트롤러는 이제 문지기 역할에만 충실할 수 있게 된 거지. 데이터 금고는 전문가가 만들어서 손에 쥐여준 거고.”

‘주입’은 단순히 변수를 선언하는 행위가 아니었다. 객체들 사이의 ‘관계’를 맺어주는 방식에 대한 완전히 다른 관점이었다. 책임을 분리하고, 각자가 자신의 역할에만 집중할 수 있도록 만드는 정교한 시스템이었다. 솔라는 이제 BookController 코드에 덩그러니 놓인 bookRepository 필드가 더 이상 외롭게 보이지 않았다. 보이지 않는 거대한 관리 시스템이 그 둘을 단단히 연결해주고 있음을 이해했기 때문이다.

“좋아, 연결은 알겠어. 그럼 이제 저 주입된 리포지토리를 진짜로 써봐야겠네. 컨트롤러가 bookRepository.findAll() 같은 메서드를 호출하면… 그 명령은 정확히 어떤 길을 통해서 데이터베이스까지 가는 거야? 이 화살표 다음엔 무슨 일이 벌어지는 거지?”

솔라의 손가락은 루나가 마지막으로 그은 ‘③주입’ 화살표의 끝, BookController 상자 안에 자리 잡은 bookRepository를 가리키고 있었다. 연결된 통로를 어떻게 활용할지에 대한 궁금증이 시작된 순간이었다.

3장: 주입된 Repository로 DB 조회 및 조작하기

솔라의 손가락 끝이 멈춘 곳, 스케치북 위 ‘③주입’ 화살표가 가리키는 BookController 안의 bookRepository 자리에서 루나의 펜이 움직이기 시작했다. 루나는 말없이 BookRepository 상자 오른편에 큼직한 원통을 그렸다. 마치 데이터가 담기는 통처럼 생긴 그 그림 위에 ‘데이터베이스(DB)’라고 썼다. 이제 그림은 컨트롤러, 리포지토리, 그리고 실제 데이터 금고까지 세 단계의 구조를 갖추게 되었다. 이전 장에서 완성했던 연결 다이어그램이 더 큰 그림의 일부였음을 보여주는 변화였다.

루나는 솔라의 노트북을 가리켰다. 화면에는 임시 데이터를 반환하던 옛 BookController 코드가 열려 있었다.

// 이전 BookController.java
@GetMapping("/books")
public List<Book> getBooks() {
    // 임시 데이터 목록을 그대로 반환
    return books;
}

“컨트롤러가 주입받은 리포지토리를 사용하게 되면, 저 코드는 이렇게 바뀌게 돼.”

루나는 스케치북의 ‘BookController’ 상자 안 코드를 수정해서 보여주었다.

// 새로운 BookController.java
@GetMapping("/books")
public List<Book> getBooks() {
    // 주입받은 리포지토리를 사용해 DB에 있는 모든 책을 조회
    return bookRepository.findAll();
}

솔라는 바뀐 코드를 유심히 들여다봤다. 임시 데이터 목록 books를 반환하던 부분이 bookRepository.findAll() 호출로 깔끔하게 대체되었다. 문지기가 자신의 주머니에서 가짜 열쇠를 꺼내주는 대신, 전문 금고지기에게 ‘모든 보물 목록을 달라’고 정식으로 요청하는 모습이었다.

findAll()… 모든 걸 찾아달라는 뜻이네. 이 한 줄이 어떻게 저기 데이터베이스까지 가는 거지? BookRepository는 우리가 그냥 interface로만 만들어 둬서 안이 텅 비어 있잖아. 아무런 코드도 없는데, 어떻게 데이터베이스에서 데이터를 가져오는 방법을 아는 거야?”

솔라의 질문은 정확했다. BookRepository 인터페이스에는 메서드 선언만 있을 뿐, 실제 SQL 쿼리를 실행하는 코드는 어디에도 보이지 않았다. 마치 목적지만 말해주고 이동수단은 알려주지 않은 것과 같았다.

루나는 그림 위에서 펜을 움직여 데이터의 흐름을 그리기 시작했다.

“솔라 네 말이 맞아. BookRepository 자체는 텅 빈 설계도일 뿐이야. 하지만 스프링 컨테이너가 의존성을 주입해 줄 때, 그냥 이 설계도를 주는 게 아니야. 이 설계도를 바탕으로 ‘실행 가능한 객체’를 만들어서 주지. 스프링 데이터 JPA라는 기술이 바로 그 일을 해.”

루나는 BookRepository 상자와 ‘DB’ 원통 사이에 작은 톱니바퀴 아이콘을 그렸다. 그리고 그 위에 ‘스프링 데이터 JPA 구현체’라고 썼다.

“이 구현체는 아주 똑똑해서, 우리가 JpaRepository를 상속받아 만든 BookRepository의 메서드 이름을 분석해. findAll이라는 이름을 보면, ‘아, Book 엔티티에 해당하는 테이블의 모든 데이터를 조회하라는 뜻이구나’ 하고 스스로 해석하는 거지.”

루나는 bookRepository.findAll() 호출에서 시작된 화살표가 톱니바퀴를 거쳐 ‘DB’ 원통으로 향하게 그렸다. 그리고 그 화살표 위에 SQL 쿼리를 써넣었다. SELECT * FROM book.

“이 톱니바퀴가 findAll()이라는 메서드 호출을 실제 데이터베이스가 알아들을 수 있는 SELECT 쿼리로 번역해주는 거야. 만약 deleteById(1L)를 호출했다면, DELETE FROM book WHERE id=1 이라는 쿼리로 번역해서 전달하겠지.”

그제야 솔라의 얼굴에 놀라움이 번졌다. “세상에… 그럼 내가 SQL을 직접 쓸 필요가 없는 거네? 그냥 약속된 규칙에 따라 메서드 이름만 잘 지으면, 프레임워크가 알아서 다 해준다는 거구나!”

추상적으로만 느껴졌던 ‘DB 연동’ 과정이 눈앞에 그려졌다. 사용자의 GET /books 요청이 컨트롤러에 도착하면, 컨트롤러는 주입받은 리포지토리의 findAll()을 호출한다. 이 호출은 스프링이 만들어준 똑똑한 구현체에 전달되고, 구현체는 SELECT 쿼리를 생성하여 데이터베이스에 보낸다. 데이터베이스는 결과를 반환하고, 이 데이터는 다시 컨트롤러를 거쳐 사용자에게 JSON 형태로 응답된다. 모든 조각이 맞춰지는 순간이었다.

문득 솔라의 표정이 다시 심각해졌다. 새로운 문제를 발견한 것이다.

“알겠어. 흐름은 완벽히 이해했어. 그런데… 그럼 처음엔 데이터베이스가 텅 비어 있잖아. 우리가 맨 처음에 만들었던 ‘코스모스’, ‘사피엔스’ 같은 책들은 이제 어디서 넣어줘야 해? 매번 DB에 직접 접속해서 INSERT 쿼리를 날릴 수는 없는 노릇이고.”

사라진 유령 데이터의 빈자리를 어떻게 채울지에 대한 현실적인 질문이었다.

루나가 미소 지으며 고개를 끄덕였다. “좋은 질문이야. 애플리케이션이 시작될 때, 테스트를 위한 초기 데이터를 넣어주는 방법이 있어. CommandLineRunner라는 걸 사용하는 거야.”

루나는 솔라의 노트북에 새로운 자바 클래스 예시를 보여주었다.

@Component
public class DataInitializer implements CommandLineRunner {

    private final BookRepository bookRepository;

    public DataInitializer(BookRepository bookRepository) {
        this.bookRepository = bookRepository;
    }

    @Override
    public void run(String... args) throws Exception {
        bookRepository.save(new Book(null, "코스모스", "칼 세이건"));
        bookRepository.save(new Book(null, "사피엔스", "유발 하라리"));
        bookRepository.save(new Book(null, "총, 균, 쇠", "제레드 다이아몬드"));
    }
}

“이 코드는 스프링 애플리케이션이 시작될 때 딱 한 번 실행돼. 여기서도 BookRepository를 똑같이 주입받아서, save 메서드로 우리가 원하던 초기 책 데이터를 데이터베이스에 저장해주는 거지.”

솔라는 모든 연결고리가 완성되었음을 느꼈다. 임시 데이터의 한계에서 시작해, 의존성 주입을 통해 컨트롤러와 리포지토리를 연결하고, 이제는 CommandLineRunner로 실제 DB에 초기 데이터까지 채워 넣는 방법까지 알게 되었다.

솔라는 더 이상 질문하지 않았다. 대신 키보드를 자기 앞으로 끌어당겼다. 그녀는 망설임 없이 BookController.java 파일을 열었다. 그리고 몇 줄에 걸쳐 선언되어 있던 임시 List<Book> 데이터를 깨끗하게 삭제했다. 한때 자신의 작은 세계를 지탱하던 유령 데이터가 사라지는 순간이었다. 이어서 getBooks() 메서드의 내용물을 return bookRepository.findAll();로 수정했다.

마지막으로, 그녀는 DataInitializer라는 이름의 새 클래스 파일을 생성하기 시작했다. 화면에 implements CommandLineRunner를 타이핑하는 솔라의 손가락에는 더 이상 막연한 추상이 아닌, 데이터의 흐름을 완벽히 꿰뚫는 명확한 확신이 담겨 있었다. 진짜 데이터를 다루는 개발자로 나아가는 첫걸음이었다.