Mini Project 5 04
책 카드, 영속 모델로 변환되는 과정 완전 정복
책 카드에는 제목, 저자, 장르, 태그, 표지가 한꺼번에 보인다. 이것이 왜 Entity, Repository, datasource profile로 나뉘는지 한 번에 들어오지 않는다.
근거 · 교안 JPA 모델링/DB 설정 파트
1장: 책 카드, 백엔드 Book Entity로 피어나다
솔라의 모니터에는 보기 좋게 완성된 ‘책 카드’ 하나가 떠 있었다. AI가 생성한 근사한 표지 이미지 아래로 제목, 저자, 장르와 태그가 깔끔하게 정돈된 모습. 완결된 하나의 작은 작품처럼 보였다. 하지만 솔라의 시선은 화면의 카드가 아닌, 그 옆에 열린 프로젝트 문서의 한 줄에 머물러 있었다.
5차 backend는 화면의 책 카드 필드를 Book Entity, JpaRepository, H2 local profile, Supabase PostgreSQL profile로 내려 저장 가능한 domain model을 만든다.
분명 한글로 쓰인 문장이었지만, 머릿속에서는 의미 있는 그림으로 연결되지 않았다. ‘책 카드’라는 눈앞의 통합된 결과물과, 그것을 만들기 위해 동원된다는 ‘Entity’, ‘Repository’, ‘Profile’ 같은 분리된 부품들 사이의 간극이 너무 크게 느껴졌다. 마치 잘 그린 그림 한 장을 설명하는데, 쓰인 물감, 캔버스 재질, 붓의 종류를 하나하나 나열하며 ‘이것이 그림이다’라고 말하는 듯한 기분이었다.
“언니, 이거 좀 이상하지 않아?”
솔라가 옆자리의 루나에게 모니터를 가리키며 물었다.
“화면에서는 그냥 이 예쁜 책 카드 하나잖아. 근데 이걸 데이터베이스에 저장하려고 백엔드에서는 엔티티, 레포지토리, 프로파일… 뭐가 이렇게 잔뜩 필요하대? 그냥 이 카드 모양 그대로 ‘찰칵’ 찍어서 보관하면 안 되는 거야?”
솔라의 말에는 순수한 궁금증과 함께 약간의 불만도 섞여 있었다. 눈에 보이는 단순하고 예쁜 것이, 왜 보이지 않는 곳에서는 이토록 복잡한 여러 조각으로 나뉘어야만 하는지 이해할 수 없다는 투였다.
루나는 솔라의 질문에 바로 답하는 대신, 키보드를 몇 번 두드려 새로운 파일을 화면에 띄웠다. Book.java 라는 이름의 파일이었다.
“이게 그 ‘복잡한 조각’ 중 첫 번째야. 우리가 만든 책의 설계도, ‘Book Entity’지.”
파일 안에는 클래스 선언과 함께 여러 변수들이 줄지어 정의되어 있었다.
public class Book {
private Long id;
private String title;
private String author;
// ...
private List<String> genres;
private List<String> tags;
private String coverImageUrl;
// ...
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
“자, 솔라. 저기 보이는 책 카드랑 이 설계도를 한번 맞춰볼까?”
루나의 제안에 솔라는 다시 모니터의 책 카드와 코드를 번갈아 보기 시작했다. 처음에는 그저 단어의 나열처럼 보였던 코드들이 조금씩 제자리를 찾아가는 느낌이었다.
“음… title은 책 제목, author는 저자. 이건 쉽네. coverImageUrl은 저 표지 이미지 주소일 거고.”
솔라는 마우스를 움직여 책 카드의 각 부분을 가리키며 코드의 필드와 짝을 맞추기 시작했다. 그러다 장르와 태그 부분에서 잠시 멈칫했다.
“어, 잠깐. genres랑 tags는 그냥 글자가 아니라 List<String>이네. 목록 형태구나. 아, 그래서 책 카드에 ‘판타지’, ‘성장’ 이렇게 여러 개를 넣을 수 있었던 거구나. 그냥 ‘판타지, 성장’이라고 한 줄로 저장하는 게 아니라.”
“정확해.” 루나가 솔라의 발견에 미소로 답했다. “그리고 코드에서는 @ElementCollection 같은 특별한 표시를 써서 ‘이 필드는 여러 개의 값을 담는 목록이야’라고 JPA에게 알려줘. 그러면 JPA는 데이터베이스에 Book 테이블과는 별도로 book_genres 같은 작은 테이블을 따로 만들지. 그 테이블에는 어떤 책에 속한 장르인지 알려주는 ID와, ‘판타지’, ‘성장’ 같은 실제 장르 이름이 한 줄씩 따로따로 저장되는 거야. 한 칸에 모든 정보를 욱여넣는 게 아니라, 관계를 만들어서 깔끔하게 정리하는 거지.”
‘여러 개를 담을 수 있는 주머니’라는 솔라의 생각이 단순한 비유가 아니라, 실제로 데이터베이스에서 별도의 공간으로 구현된다는 설명이었다. 화면에서는 쉼표로 나열된 평면적인 텍스트가, 보이지 않는 백엔드에서는 체계적인 관계를 가진 입체적인 구조로 관리되고 있었다.
“맞아. 그리고 여기, id, createdAt, updatedAt 같은 필드도 봐봐. 이건 책 카드에는 안 보이는 정보지?”
루나의 말에 솔라는 고개를 끄덕였다.
“그러네. 이건 왜 있는 거지? 독자에게 보여줄 정보도 아닌데.”
“데이터베이스가 수많은 책들 속에서 이 책을 유일하게 식별하고(id), 언제 처음 만들어졌는지(createdAt), 언제 마지막으로 정보가 수정됐는지(updatedAt) 기록하기 위해 필요하거든. 말하자면 서점 주인이 재고 관리를 위해 몰래 붙여두는 관리용 스티커 같은 거야.”
‘관리용 스티커’라는 비유에 솔라는 무릎을 쳤다.
“아하! 알겠다. 그러니까 백엔드의 ‘Book Entity’라는 건, 프론트엔드의 책 카드를 그냥 사진 찍듯 저장하는 게 아니구나. 눈에 보이는 정보들을 하나하나 분해해서 ‘제목’, ‘저자’처럼 이름표를 붙여 정리하고, 심지어 눈에 보이지 않는 관리용 정보까지 덧붙여서 만든, 아주 체계적인 ‘데이터 명세서’ 같은 거였어.”
이제 솔라에게 Book.java 파일은 더 이상 복잡하고 의미 없는 코드 조각이 아니었다. 그것은 화면 속 예쁜 책 카드의 정보를 어떻게 분해하고 재구성해서, 컴퓨터가 이해하고 관리하기 쉬운 형태로 만들 것인가에 대한 명확한 계획서로 보였다. ‘하나의 덩어리’가 아닌, 잘 구조화된 데이터의 집합이라는 사실을 깨닫자 처음의 답답함이 시원하게 풀려나갔다.
“그래, 바로 그거야. 그렇게 통합된 정보를 의미 있는 단위로 분해하고 재구성해서 데이터를 구조화하는 거지. 재료를 깔끔하게 손질하는 과정처럼 말이야.”
루나가 미소 지으며 말했다. 솔라는 만족스러운 표정으로 고개를 끄덕이다가, 문득 새로운 질문이 떠올랐다.
“좋아. 이제 ‘책’이라는 데이터 재료는 완벽하게 손질됐어. 이 Book이라는 잘 다듬어진 객체. 그런데… 이 객체를 실제 데이터베이스라는 냉장고에 넣고 꺼내는 건 누가, 어떻게 하는 거지? 그냥 저절로 들어가지는 않을 거 아냐?”
2장: JpaRepository: 엔티티와 데이터베이스의 대화 중개자
솔라의 질문은 허공에 잠시 머물렀다. 잘 다듬어진 Book이라는 데이터 재료. 이것을 ‘데이터베이스’라는 냉장고에 넣고 꺼내는 건 누구의 역할일까.
루나는 말없이 키보드를 두드려 Book.java 파일 옆에 새로운 파일을 나란히 띄웠다. 솔라의 시선이 자연스럽게 새 파일로 옮겨갔다. 파일의 이름은 BookRepository.java였다. 하지만 파일의 내용은 솔라의 예상을 완전히 빗나갔다. 안에는 복잡한 데이터 처리 로직은커녕, 거의 아무것도 적혀있지 않았다.
public interface BookRepository extends JpaRepository<Book, Long> {
// 여기에 메서드를 추가할 수 있습니다.
}
“이게… 전부라고?”
솔라는 어이없다는 듯 중얼거렸다. ‘데이터를 넣고 꺼내는 역할’이라고 해서, 적어도 데이터를 옮기는 트럭이나, 재료를 정리하는 요리사 같은 복잡한 코드를 상상했다. 하지만 눈앞에 있는 것은 텅 빈 ‘인터페이스’ 선언 한 줄이 전부였다.
“이게 어떻게 일을 해? 그냥 껍데기만 있는 거 아니야? 데이터베이스에 INSERT나 SELECT 같은 명령을 보내는 코드가 하나도 없잖아. 이러면 아무 일도 안 일어나는 거 아냐?”
솔라는 BookRepository가 실제 SQL 쿼리를 생성해서 데이터베이스와 통신하는 아주 복잡하고 영리한 무언가일 거라 짐작했다. 하지만 텅 빈 파일은 그 짐작이 틀렸다고 말하는 듯했다. 마치 냉장고 문을 열고 닫을 전담 직원을 불렀더니, ‘저는 문을 열고 닫을 수 있습니다’라고 적힌 명함 한 장만 덩그러니 놓여있는 기분이었다.
“거의 비어있는 게 맞아. 하지만 가장 중요한 정보는 저 첫 줄에 다 있어.”
루나는 extends JpaRepository<Book, Long> 부분을 가리켰다.
“솔라, 네가 만약 조립식 가구를 샀다고 생각해 봐. 설명서에 ‘표준 M5 나사를 사용해 조립하세요’라고만 쓰여 있고, 정작 나사는 들어있지 않다면 어떨까?”
“황당하지. M5 나사가 뭔지, 어디서 파는지 내가 다 알아봐야 하잖아.”
“바로 그거야. 반대로, 가구 상자 안에 ‘표준 공구 세트’가 통째로 들어있다면? 망치, 드라이버, 렌치가 종류별로 다 들어있는 거지. 넌 그냥 설명서에 ‘드라이버 사용’이라고 쓰여 있으면 공구함에서 드라이버를 꺼내 쓰기만 하면 돼. 직접 드라이버를 만들 필요 없이.”
루나는 다시 코드를 가리켰다.
“extends JpaRepository라는 선언이 바로 그 ‘표준 공구 세트’를 받아오는 것과 같아. 우리는 Book이라는 재료를 다룰 것이고, 그 재료의 고유 식별자(id)는 Long 타입이라는 사실만 알려주면(JpaRepository<Book, Long>), JPA가 알아서 그 재료를 다룰 때 필요한 기본 공구들을 전부 제공해 줘.”
루나는 다른 서비스 코드 파일을 열어 한 줄을 보여주었다.
bookRepository.save(newBook);
“이것 봐. 우리는 그냥 ‘저장해 줘’(save)라고 말하기만 하면 돼. ‘어떤 테이블에 어떤 컬럼들을 INSERT 해야 하고…’ 같은 복잡한 SQL은 한 줄도 쓰지 않았어. JpaRepository라는 공구 세트 안에 이미 save라는 잘 만들어진 도구가 들어있기 때문이야. 우리는 그걸 그냥 가져다 쓰는 거지. findById, findAll, delete 같은 도구들도 마찬가지고.”
그제야 솔라는 BookRepository의 정체를 깨달았다. BookRepository는 데이터베이스 작업을 실제로 수행하는 복잡한 공장이 아니었다. 오히려 Book이라는 특정 재료를 다루기 위해 JPA가 제공하는 표준 공구 세트를 받아 와서, 필요할 때 쓸 수 있도록 대기하는 ‘작업대’나 ‘창구’에 가까웠다. 복잡한 SQL을 숨기고 있는 게 아니라, 이미 약속된 표준 도구를 사용하겠다는 ‘선언’ 그 자체였던 것이다.
“아…! 그러니까 BookRepository는 일꾼이 아니라, 일꾼이 쓸 연장통이었구나. 우리는 그냥 ‘Book 데이터를 저장할 연장통이 필요해’라고 선언만 하면, JPA가 알아서 save, find 같은 만능 연장들을 가득 채워서 주는 거네!”
솔라의 목소리에 감탄이 묻어났다. 데이터베이스 작업을 추상화한다는 것의 의미가 피부로 와 닿았다. 개발자는 더 이상 데이터베이스마다 다른 자질구레한 SQL 문법에 신경 쓰지 않고, ‘저장한다’, ‘찾는다’ 같은 핵심적인 ‘행위’에만 집중할 수 있게 된다.
“맞아. 그게 바로 ‘영속화 추상화’의 힘이야. 데이터베이스와의 대화를 직접 하는 대신, Repository라는 잘 훈련된 중개자를 통하는 거지.”
루나의 말에 솔라는 고개를 끄덕이며 만족스러운 표정을 지었다. 이제 Book이라는 잘 손질된 재료(Entity)와, 그 재료를 냉장고에 넣고 꺼낼 잘 갖춰진 작업대(Repository)가 모두 준비되었다. 퍼즐 조각이 하나씩 맞춰지는 기분이었다. 하지만 그 순간, 새로운 의문이 고개를 들었다.
“좋아, 재료도 있고, 완벽한 도구 세트도 갖춰졌어. 그런데… 이 도구들이 연결될 ‘냉장고’는 어디에 있는 거지? 우리 집 작은 냉장고(H2)를 쓸지, 아니면 동네 마트의 대형 냉장창고(Supabase)를 쓸지, 이 Repository는 어떻게 아는 거야?”
3장: Datasource Profile: 환경에 맞춰 유연하게 DB 연결하기
솔라의 질문은 두 사람 사이의 공기를 가만히 흔들었다. BookRepository라는 만능 공구 세트가 있다면, 이 도구들이 접속할 대상, 즉 ‘냉장고’는 어떻게 정해지는 걸까.
루나는 솔라의 질문에 답하는 대신, BookRepository.java 파일이 열려 있던 화면을 분할했다. 그리고는 프로젝트 탐색기에서 두 개의 새로운 파일을 찾아 나란히 띄웠다. 왼쪽에는 application-local.yaml, 오른쪽에는 application-supabase.yaml. 두 파일 모두 비슷한 형식의 설정값들로 채워져 있었지만, 내용은 미묘하게 달랐다.
솔라는 혼란스러운 눈으로 두 파일을 번갈아 보았다. 두 개의 냉장고 설명서가 동시에 펼쳐진 것 같았다. 하나는 로컬(local)용, 다른 하나는 서 파베이스(supabase)용. Repository라는 도구는 하나인데, 연결할 데이터베이스의 설명서가 두 개나 있다니.
“언니, 이건 왜 두 개나 있어? 하나는 개발용, 하나는 실제 서비스용인 것 같긴 한데… 그럼 우리가 개발할 때는 supabase 파일 이름을 잠시 다른 걸로 바꿔두거나, 배포할 때는 local 파일을 지우거나 해야 하는 거야? 둘 다 활성화되어 있으면 충돌할 거 아냐?”
솔라의 추측은 그럴듯했다. 두 개의 설정이 동시에 존재한다면, 애플리케이션이 어느 장단에 맞춰야 할지 모를 테니, 개발자가 수동으로 하나를 비활성화해야 한다고 생각하는 건 자연스러웠다. H2와 Supabase 데이터베이스가 동시에 사용될 리는 만무했다.
루나는 고개를 저으며 왼쪽의 application-local.yaml 파일을 가리켰다.
“이 파일을 먼저 자세히 볼까? 특히 datasource 아래 url 부분을.”
솔라는 루나의 손가락을 따라 시선을 옮겼다.
# application-local.yaml
spring:
h2:
console:
enabled: true
datasource:
url: jdbc:h2:mem:bookdb
jpa:
hibernate:
ddl-auto: create
“url: jdbc:h2:mem:bookdb… 이건 내 컴퓨터 메모리 안에서만 작동하는 H2 데이터베이스에 연결하라는 뜻이네. 그리고 ddl-auto: create는… 앱을 시작할 때마다 데이터베이스를 새로 만들라는 거고. 테스트하기엔 편하겠다. 데이터가 꼬여도 껐다 켜면 그만이니까.”
솔라는 이제 설정 파일의 의미를 제법 능숙하게 읽어냈다. 이어서 시선을 오른쪽의 application-supabase.yaml 파일로 옮겼다.
# application-supabase.yaml
spring:
datasource:
driver-class-name: org.postgresql.Driver
url: ${SUPABASE_DB_URL}
username: ${SUPABASE_DB_USER}
password: ${SUPABASE_DB_PASSWORD}
jpa:
hibernate:
ddl-auto: update
“이쪽은 완전히 다르네. 드라이버도 PostgreSQL 용이고, URL이나 사용자 정보는 ${...} 같은 변수로 되어 있어. 외부 어딘가에 있는 진짜 데이터베이스에 접속하는 거구나. ddl-auto: update인 걸 보면, 기존 데이터를 지우지 않고 변경된 부분만 반영해서 실제 데이터를 안전하게 유지하려는 거고.”
두 파일의 목적이 명확하게 대비되었다. 하나는 빠르고 가볍게 테스트하기 위한 일회용 DB 설정, 다른 하나는 데이터를 영구적으로 보관하기 위한 실제 서비스용 DB 설정. 솔라는 차이점을 완벽히 이해했지만, 그래서 더 궁금해졌다. 어떻게 이 둘을 ‘선택’하는 걸까.
“바로 그거야. 두 설정은 각각 다른 환경을 위한 ‘레시피’인 셈이지.”
루나는 application- 뒤에 붙은 이름, local과 supabase를 차례로 짚었다.
“우리는 애플리케이션을 시작할 때, 딱 한 마디만 덧붙여주면 돼. ‘local 프로파일로 실행해 줘’ 하고. 그러면 스프링 부트는 수많은 설정 파일 중에서 이름에 -local이 붙은 이 레시피를 자동으로 찾아 읽고, 나머지는 무시해. 반대로 서버에 배포할 때는 ‘supabase 프로파일로 실행해 줘’ 라고 알려주지. 그럼 이번엔 -supabase 레시피를 선택하고 나머지는 거들떠보지도 않아. 우리가 파일을 지우거나 이름을 바꿀 필요 없이, 프로파일 이름만으로 연결 대상을 자솔라재로 바꾸는 거야.”
그제야 솔라의 머릿속에 흩어져 있던 퍼즐 조각들이 마지막으로 맞춰졌다. JpaRepository는 어떤 데이터베이스와 대화하는지 알 필요가 없었다. 그저 표준적인 방식(save, findById 등)으로 자신의 임무를 수행할 뿐이다. 그러면 스프링 부트가 실행 환경(프로파일)에 맞는 ‘연결 설정’을 알아서 찾아, Repository의 요청을 올바른 데이터베이스로 전달해 주는 구조였다.
“아! 알겠다! BookRepository는 어떤 콘센트에든 꽂을 수 있는 표준 플러그를 가진 전자기기 같은 거였어. 그리고 이 yaml 파일들은 각기 다른 모양의 콘센트, 즉 H2나 Supabase에 맞는 ‘여행용 어댑터’였던 거구나! 우리는 그냥 앱을 켤 때 어떤 어댑터를 끼울지만 정해주면 끝나는 거였네!”
솔라는 무릎을 탁 쳤다. 개발 환경과 배포 환경에 따라 데이터베이스 연결을 유연하게 관리하는 이 전략이 명쾌하게 이해되었다.
루나가 미소 지으며 마지막 질문을 던졌다. 마치 모든 준비가 끝났으니, 최종 점검을 해보자는 듯이.
“자, 솔라. 그럼 처음으로 돌아가 보자. 여기 새로운 책 카드 정보가 있어. 제목은 ‘고래의 꿈’, 저자는 김하늘, 장르는 ‘SF’. 이 카드를 저장하기 위해 네 노트북에서 ‘실행’ 버튼을 눌렀다고 상상해 봐. 어떤 일이 순서대로 일어날까?”
솔라는 잠시 숨을 고르고, 머릿속으로 전체 과정을 그리기 시작했다. 이제는 막막한 암호문이 아닌, 명확한 설계도로 보였다.
“먼저, 프론트엔드에서 온 ‘고래의 꿈’ 카드 정보는 백엔드에서 Book 엔티티 객체로 변환될 거야. title 필드에 ‘고래의 꿈’, author에 ‘김하늘’, genres 리스트에 ‘SF’가 담기겠지. 둘째, bookRepository.save() 메서드가 호출되면, JpaRepository가 이 Book 객체를 받아서 데이터베이스에 저장하라는 명령어로 바꿔줄 거야. SQL을 한 줄도 안 썼지만 말이야. 마지막으로, 내 노트북에서 실행했으니 스프링 부트는 local 프로파일을 활성화할 거고, application-local.yaml 설정에 따라 이 모든 정보는 내 컴퓨터 메모리의 H2 데이터베이스에 저장될 거야. 만약 이 코드를 그대로 서버에 올려서 실행했다면, 그땐 supabase 프로파일이 활성화돼서 실제 PostgreSQL 데이터베이스에 영구적으로 기록됐을 거고.”
솔라는 확신에 찬 목소리로 대답을 마쳤다. 루나는 조용히 고개를 끄덕였다. 솔라는 다시 모니터 한구석에 있던 프로젝트 문서의 첫 문장을 바라보았다.
5차 backend는 화면의 책 카드 필드를 Book Entity, JpaRepository, H2 local profile, Supabase PostgreSQL profile로 내려 저장 가능한 domain model을 만든다.
처음에는 도무지 이해할 수 없었던 그 문장이, 이제는 너무나도 당연하고 논리적인 하나의 이야기처럼 읽혔다. 화면의 한 조각 정보가 어떻게 분해되고, 어떤 도구로 다듬어지며, 상황에 맞는 어떤 창고에 저장되는지에 대한 완벽한 여정이었다. 솔라는 이제 그 지도를 읽을 수 있게 되었다.