Backend 12

JDBC의 반복에서 JPA로 넘어가기

DB는 SQL로 다루는 것 같은데 Java 객체를 저장한다는 말이 어색하다.

근거 · 교안 p77-p84

JDBC의 반복에서 JPA로 넘어가기 대표 이미지

1장: JDBC의 반복, 그 불편함의 시작

솔라가 노트북을 들고 거실로 나왔다. 화면에는 검은 배경에 흰 글씨가 가득했다. 무언가 골똘히 들여다보던 솔라는 미간을 찌푸린 채 소파에 앉아 책을 읽던 언니, 루나를 불렀다.

“언니, 잠깐 이것 좀 봐줄 수 있어?”

루나가 고개를 들자 솔라는 노트북을 소파 테이블 위로 밀었다. 화면에는 데이터베이스에서 특정 ID를 가진 회원 한 명의 정보를 가져오는 자바 코드가 떠 있었다. 스무 줄은 족히 넘어 보였다.

“이거 봐. 그냥 회원 한 명 찾아오는 간단한 기능인데, 코드가 왜 이렇게 길어? DB 연결하고, SQL 준비하고, 실행하고, 결과 받아서 객체에 넣어주고, 또 연결 끊고… 원래 다 이런 거야?”

솔라의 목소리에는 짜증보다 체념이 섞여 있었다. 원래 이런 거라면 어쩔 수 없지, 하는 듯한 말투였다. DB에 접근하려면 이 정도 수고는 당연한 절차라고 여기는 것 같았다.

“음, 그 코드만 보면 충분히 그렇게 생각할 수 있겠다.”

루나는 화면을 잠시 들여다보더니, 노트북을 자기 쪽으로 더 당기는 대신 솔라에게 다시 밀었다.

“우리가 직접 한번 만들어볼까? 딱 회원 한 명의 정보만 가져오는 코드로. 그러면 이 과정이 정말 ‘당연한’ 건지, 아니면 그냥 ‘익숙한’ 건지 알 수 있을 거야.”

루나의 제안에 솔라는 잠시 망설이다 키보드에 손을 올렸다. “좋아. 어디서부터 시작하면 돼?”

“가장 간단한 Member 클래스부터 시작하자. idname만 가지고 있는 걸로.”

솔라는 금방 Member 클래스를 만들었다. 그러자 루나는 빈 findMember 메소드를 만들고 그 안을 채워보자고 했다. 솔라는 기억을 더듬으며 코드를 써 내려가기 시작했다.

먼저 SQL 문을 문자열로 작성했다.

String sql = "SELECT id, name FROM member WHERE id = ?";

“일단 SQL은 간단하네.” 솔라가 말했다.

“그다음은?” 루나가 물었다.

“음… DB 커넥션을 얻고, PreparedStatement를 만들어야지.”

솔라는 try-catch 블록을 열고 그 안에 커넥션을 가져오고 SQL을 준비하는 코드를 넣었다. 키보드를 두드리는 소리가 경쾌했지만, finally 블록을 열어 자원 해제 코드를 넣을 차례가 되자 솔라의 손가락이 잠시 멈칫했다.

“아, 맞다. ResultSet, PreparedStatement, Connection 전부 닫아줘야 하지. 순서도 중요하고. 이거 하나라도 빼먹으면 큰일 나는데.”

솔라는 투덜거리며 close() 메소드를 세 번 호출하는 코드를 꼼꼼하게 넣었다. 이제 try 블록 안의 핵심 로직을 채울 차례였다.

pstmt.setLong(1, memberId); ResultSet rs = pstmt.executeQuery();

결과를 받아오는 ResultSet 객체까지는 순조로웠다. 하지만 바로 다음 줄에서 솔라의 표정이 다시 굳어졌다. 데이터베이스의 조회 결과를 자바 Member 객체로 옮겨 담아야 했다.

“결과가 있으면… rs.next()로 확인하고…”

솔라는 ResultSet에서 id 컬럼과 name 컬럼 값을 꺼내 변수에 담았다. 그리고 new Member()로 객체를 생성한 뒤, setId()setName() 메소드를 호출해 값을 일일이 넣어주었다.

if (rs.next()) {
    Member member = new Member();
    member.setId(rs.getLong("id"));
    member.setName(rs.getString("name"));
    return member;
}

“다 됐다!”

솔라가 외쳤다. 하지만 완성된 코드를 쭉 훑어보는 그녀의 얼굴에는 성취감보다 미묘한 허탈감이 서려 있었다. 고작 회원 한 명 조회하는 기능에 스무 줄이 훌쩍 넘는 코드가 만들어졌다. 수많은 try-catch-finally 구조, 자원 연결과 해제, 그리고 무엇보다 데이터베이스의 테이블 필드를 객체의 속성으로 한 줄 한 줄 옮겨 심는 수작업까지.

“잠깐만,” 솔라가 화면을 뚫어져라 쳐다보며 말했다. “이건 필드가 idname 두 개뿐이라 이 정도지, 만약 회원 정보에 주소, 이메일, 가입일 같은 필드가 스무 개쯤 있다면… rs.get..., member.set... 이 코드를 스무 번이나 반복해야 한다는 거잖아?”

그녀의 시선이 날카로워졌다.

“어디 그뿐인가? 회원을 ‘수정’하는 기능을 만들 때도 거의 똑같은 코드를 또 써야 해. SQL 문만 UPDATE로 바뀌고, ResultSet을 객체로 바꾸는 대신 객체의 값을 PreparedStatement에 채워 넣는 것만 다르지, 연결하고 해제하는 반복 작업은 그대로잖아. 회원 ‘삭제’도, ‘전체 조회’도 마찬가지고.”

처음의 체념은 온데간데없었다. 솔라는 방금 전까지 ‘원래 다 이런가?‘라며 당연하게 여겼던 코드의 반복적인 패턴을 정확히 짚어내고 있었다. 그저 긴 코드가 아니었다. 의미 없이 반복되는 기계적인 작업의 나열이었다.

그때 루나가 조용히 자신의 노트북을 열어 솔라 옆에 나란히 놓았다. 루나의 화면에는 몇 줄 안 되는 코드가 적혀 있었다.

Member member = jpa.find(Member.class, memberId);

“이건… 뭐야?”

솔라의 눈이 동그래졌다. 솔라가 방금 끙끙대며 작성한 스무 줄 넘는 코드가 단 한 줄로 표현되어 있었다. try-catch-finally도, ResultSet을 객체로 변환하는 지루한 과정도 보이지 않았다.

“이게… 다야? 정말 이걸로 끝이라고?”

“응. 같은 기능이야.” 루나가 짧게 답했다.

솔라는 자신의 화면과 루나의 화면을 번갈아 보았다. 충격에 빠진 듯 잠시 아무 말도 하지 못했다. 자신이 공들여 쌓아 올린 모래성이 파도 한 번에 쓸려나간 기분이었다. 처음의 체념은 날아갔지만, 그 자리를 채운 것은 시원함이 아닌 더 큰 의문이었다.

“이렇게 간단할 리가 없어. 그럼 아까 우리가 썼던 그 수많은 연결 코드, SQL, 데이터 변환 코드는 다 어디로 간 거야? 분명히 누군가는 그 일을 해야 할 텐데.”

솔라는 루나의 노트북 화면을 가리키며 말했다. 그 한 줄짜리 코드 뒤에 거대한 무언가가 숨겨져 있는 것만 같았다.

“이 편리함은 공짜가 아닐 것 같은데… 이런 불편함을 이렇게까지 근본적으로 줄이는 방법이 정말 있는 거긴 해?“

2장: 객체와 관계형 DB, 그 태생적 불일치

솔라는 자신의 노트북 화면을, 그리고 그 옆에 놓인 루나의 노트북 화면을 번갈아 보았다. 스무 줄이 넘는 자신의 코드와 단 한 줄짜리 루나의 코드. 그 극명한 대비가 만들어낸 침묵은 무거웠다. 솔라는 탁 소리가 나게 노트북을 덮어버렸다.

“모르겠어. 이건 그냥 눈속임 같아.”

솔라는 덮어버린 노트북을 테이블 저편으로 밀어내며 말했다.

“그 한 줄짜리 코드 뒤에 결국 누군가 내가 썼던 것과 비슷한 코드를 작성해 둔 거겠지. 편리해 보이지만, 본질적인 문제는 그대로인 거 아냐? 어차피 자바 객체랑 데이터베이스 테이블은 다른 거니까, 중간에서 누군가는 그 둘을 계속 번역해 줘야 하잖아. 그게 내가 직접 하느냐, 아니면 어떤 라이브러리가 대신 해주느냐의 차이일 뿐이지.”

솔라의 목소리에는 날이 서 있었다. DB 접근이란 원래 객체와 테이블, 두 개의 다른 세계를 잇는 고된 번역 작업과 같다고, 그녀는 단정하고 있었다. 그 번거로움의 총량은 변하지 않는다는 체념 섞인 확신이었다.

루나는 말없이 듣고 있다가, 테이블 위에 있던 메모지와 펜을 집어 들었다. 코드 대신, 네모와 선으로 된 그림을 그리기 시작했다.

“번역 작업이라는 말, 맞는 말이야. 그런데 그 번역이 왜 그렇게 번거로울까? 우리가 ‘객체’와 ‘테이블’을 어떻게 다르게 생각하고 있는지부터 그려보자.”

루나는 메모지 한쪽에 객체 세상이라고 쓰고, 그 아래에 두 개의 네모 상자를 그렸다. 하나는 Team, 다른 하나는 Member.

// 객체 세상
class Team {
    Long id;
    String name;
    List<Member> members; // <-- 팀에 소속된 멤버들 목록
}

class Member {
    Long id;
    String name;
    Team team; // <-- 멤버가 소속된 팀
}

“객체지향적으로 생각하면, 한 팀은 여러 명의 멤버를 가질 수 있어. 이렇게 Team 객체 안에 Member 객체 리스트를 바로 넣을 수 있지. 반대로 Member 객체 안에서 자기가 속한 Team 객체를 직접 참조할 수도 있고. 아주 직관적이지?”

솔라는 고개를 끄덕였다. 당연한 이야기였다.

“자, 그럼 이 TeamMember를 데이터베이스에 저장하려면 어떻게 해야 할까?”

루나는 메모지 다른 한쪽에 데이터베이스 세상이라고 쓰고, 솔라를 바라보았다.

“그야 테이블 두 개 만들면 되지.” 솔라가 즉시 대답했다. “하나는 TEAM 테이블, 다른 하나는 MEMBER 테이블.”

“그리고 둘 사이의 관계는 어떻게 표현할 건데? TEAM 테이블 안에 MEMBER 리스트를 넣을 수 있는 컬럼 타입이 있어?”

루나의 질문에 솔라는 잠시 말을 멈췄다. 데이터베이스 테이블은 정해진 타입의 값(숫자, 문자열, 날짜 등)만 저장할 수 있었다. 객체 리스트 같은 복잡한 구조를 한 번에 넣을 수는 없었다.

“아니, 없지. 그러니까… MEMBER 테이블에 TEAM_ID라는 컬럼을 추가해야 해. 어떤 팀에 소속된 멤버인지 알려주는, 일종의 꼬리표 같은 거지. 외래 키(Foreign Key) 말이야.”

솔라는 익숙하게 관계형 데이터베이스의 설계 방식을 설명했다. 하지만 그녀의 표정은 점점 미묘해졌다.

-- 데이터베이스 세상
CREATE TABLE TEAM (
    TEAM_ID BIGINT PRIMARY KEY,
    NAME VARCHAR(255)
);

CREATE TABLE MEMBER (
    MEMBER_ID BIGINT PRIMARY KEY,
    NAME VARCHAR(255),
    TEAM_ID BIGINT, -- TEAM 테이블의 TEAM_ID를 참조하는 외래 키
    FOREIGN KEY (TEAM_ID) REFERENCES TEAM(TEAM_ID)
);

“바로 그거야.” 루나가 말했다. “객체 세상에서는 team.getMembers()처럼 객체 참조를 통해 다른 객체로 바로 이동할 수 있어. 이걸 ‘객체 그래프 탐색’이라고 해. 하지만 데이터베이스 세상에서는 참조가 아니라 외래 키를 사용해서 관계를 맺지. MEMBER를 조회한 다음에, 그 TEAM_ID를 가지고 다시 TEAM 테이블을 조회해야만 비로소 어떤 팀인지 알 수 있는 거야.”

순간 솔라의 머릿속에서 무언가 번쩍했다.

“잠깐만. 그럼 내가 만약 ‘팀 A’의 정보를 객체로 가져오고 싶으면… 먼저 SELECT * FROM TEAM WHERE NAME = '팀 A'로 팀 정보를 가져와. 그리고 그 팀의 TEAM_ID를 가지고 다시 SELECT * FROM MEMBER WHERE TEAM_ID = ? 라는 SQL을 날려서 소속된 멤버들을 전부 가져와야 하네.”

“그리고 끝이 아니지.” 루나가 덧붙였다.

“가져온 멤버 ResultSet을 하나씩 돌면서 Member 객체를 만들고, 그걸 List에 담아서, 처음에 만들었던 Team 객체의 setMembers() 메소드로 넣어줘야… 비로소 Team 객체 하나가 완성되는 거네.”

솔라는 자신의 말을 끝맺으며 허탈하게 웃었다. 객체 세상의 자연스러운 team.getMembers()를 구현하기 위해, 데이터베이스 세상에서는 여러 번의 조회와 수많은 데이터 변환 작업이 필요했다. 방금 전 JDBC 코드에서 겪었던 그 지루한 반복의 근본적인 원인이 바로 눈앞에 그려져 있었다.

객체는 참조를 통해 관계를 맺고 자유롭게 객체 그래프를 탐색하지만, 관계형 데이터베이스는 외래 키를 통해 관계를 맺고 조인(JOIN)이나 추가적인 SQL을 통해 데이터를 가져와야만 한다. 두 세계가 데이터를 바라보고 관계를 맺는 방식에 근본적인 차이가 있었던 것이다. 사람들은 이 차이를 ‘객체-관계 패러다임의 불일치’라고 불렀다.

“알겠다.” 솔라가 메모지에 그려진 두 개의 ‘세상’을 가리키며 말했다.

“문제는 단순히 코드가 긴 게 아니었어. 우리는 계속해서 이 두 개의 다른 세상을 오가며 ‘번역’을 하고 있었던 거야. 객체 모델이 조금이라도 복잡해지면, 이 번역을 위한 SQL과 자바 코드는 훨씬 더 끔찍해지겠지. JDBC의 반복적인 코드는 이 불일치 때문에 생기는 어쩔 수 없는 현상이었구나.”

처음의 ‘원래 다 이렇지’라는 체념도, ‘그냥 눈속임일 뿐’이라는 냉소도 사라졌다. 솔라는 이제 문제의 핵심을 정확히 마주하고 있었다. 반복적인 코드는 결과일 뿐, 원인은 두 패러다임의 태생적인 불일치였다.

솔라는 다시 루나의 노트북 화면으로 시선을 돌렸다. 그 한 줄짜리 코드는 이제 더 이상 간단한 눈속임으로 보이지 않았다.

Member member = jpa.find(Member.class, memberId);

“그렇다면 저 한 줄은…” 솔라가 입을 열었다. ”…이 끔찍한 ‘번역’ 과정을 알아서 다 처리해준다는 뜻이네. 내가 객체는 객체답게, DB는 신경 쓰지 않고 다룰 수 있도록 말이야.”

솔라는 잠시 생각에 잠겼다.

“근데 대체 어떻게? JPA는 내가 만든 Team 클래스가 데이터베이스의 TEAM 테이블과 연결되고, List<Member> 필드가 MEMBER 테이블의 TEAM_ID 외래 키와 관련 있다는 걸 어떻게 아는 거지? 이 불일치를 어떻게 ‘자동으로’ 해결해준다는 거야?“

3장: JPA의 자동 매핑, 불일치를 해소하다

솔라의 시선은 지난 챕터에서 루나와 함께 그렸던 메모지에 꽂혀 있었다. ‘객체 세상’과 ‘데이터베이스 세상’. 두 세계의 근본적인 차이를 보여주는 그 그림은 이제 너덜너덜해져 있었다. 솔라는 그 그림을 치우는 대신, 자신의 노트북을 열어 어제 작성했던 TeamMember 클래스를 화면에 띄웠다.

// 객체 세상
class Team {
    Long id;
    String name;
    List<Member> members;
}

class Member {
    Long id;
    String name;
    Team team;
}

이 평범한 자바 클래스가 어떻게 데이터베이스 테이블과 연결된다는 말인가. 솔라는 더 이상 질문만 하고 있을 수 없었다. 그녀는 Member 클래스 위에 무언가 코드를 추가하려 손을 올렸지만, 이내 멈췄다. 무엇을, 어떻게 알려줘야 하는지 전혀 감이 잡히지 않았다. 마치 한국어를 전혀 모르는 외국인에게 한국어로 길을 설명하려는 막막함이었다.

“JPA라는 번역기한테… 이 Member 클래스는 DB에 저장해야 할 대상이라고 어떻게 알려주지? 그리고 이 id 필드가 테이블의 기본 키(Primary Key)라는 건 또 어떻게 알려줘야 하고?”

솔라는 혼잣말을 중얼거리며 키보드 위를 맴돌았다. 마법이 아니라면, 분명 JPA와 개발자 사이에 어떤 약속, 즉 소통 방법이 있을 터였다.

그때, 솔라의 혼잣말을 들은 루나가 조용히 다가와 솔라의 화면을 들여다보았다.

“좋은 지점이야. JPA는 우리가 만든 모든 클래스를 멋대로 데이터베이스에 저장하지 않아. 개발자가 ‘이 클래스는 내가 관리할 객체이니 데이터베이스와 연결해줘’라고 명확하게 표시를 해줘야 해. 일종의 특별한 이름표를 붙여주는 거지.”

루나는 솔라가 수정하려던 Member 클래스 선언부 바로 윗줄을 가리켰다.

“여기에 @Entity라고 한번 붙여볼래?”

솔라는 반신반의하며 @Entity라는, 골뱅이 기호로 시작하는 낯선 코드를 입력했다.

import javax.persistence.Entity;

@Entity
public class Member {
    //...
}

“이게… 이름표라고?”

“응. @Entity는 ‘이 클래스는 평범한 자바 객체가 아니라, JPA가 관리하는 ‘엔티티’다’라고 선언하는 약속이야. 이제 JPA는 이 Member 클래스를 데이터베이스 테이블과 연결할 대상으로 인지하기 시작해.”

솔라의 눈이 반짝였다. 막막했던 소통의 첫 단추가 끼워지는 느낌이었다.

“좋아. 그럼 테이블의 기본 키는? 이 id 필드가 MEMBER 테이블의 MEMBER_ID 기본 키 컬럼이라는 건 어떻게 알려줘?”

“그것도 마찬가지야. 해당 필드 위에 이름표를 붙여주면 돼.”

루나의 말에 따라 솔라는 id 필드 위에 @Id 라는 어노테이션을 추가했다.

@Entity
public class Member {
    @Id
    private Long id;
    private String name;
    // ...
}

“아하! @Entity로 테이블과 매핑될 클래스를 지정하고, @Id로 그 테이블의 기본 키가 될 필드를 지정하는 거구나.”

솔라는 마치 암호를 해독하듯, 하나씩 규칙을 발견해 나갔다. 그녀는 문득 궁금해졌다. 만약 객체의 필드 이름과 테이블의 컬럼 이름이 다르면 어떻게 될까?

“언니, 만약에 우리 Member 클래스의 필드는 name인데, 데이터베이스 테이블의 컬럼은 USERNAME이라면 어떻게 해? 그것도 알려줄 수 있어?”

“물론. 그럴 때를 위한 이름표도 있지.”

루나가 알려준 대로 솔라는 @Column(name = "USERNAME") 어노테이션을 name 필드 위에 추가했다.

@Entity
public class Member {
    @Id
    private Long id;
    
    @Column(name = "USERNAME")
    private String name;
    // ...
}

코드를 완성한 솔라는 잠시 숨을 골랐다. 이제 Member 클래스는 더 이상 평범한 자바 객체가 아니었다. 데이터베이스 MEMBER 테이블의 구조와 필드, 관계를 설명하는 상세한 ‘지도’이자 ‘설계도’처럼 보였다.

“알겠다.”

솔라가 무릎을 탁 쳤다.

“‘자동 매핑’이라는 게 SQL 없이 마법처럼 해주는 게 아니었어. 내가 이 어노테이션들로 ‘객체와 테이블의 번역 규칙’을 미리 다 만들어두면, JPA는 그 규칙을 읽고 상황에 맞는 SQL을 ‘자동으로 생성’해서 실행해주는 거였구나!”

더 이상 jpa.find() 한 줄이 마법처럼 보이지 않았다. 그 뒤에서 어떤 일이 벌어질지 명확하게 그려지기 시작했다. JPA는 @Entity가 붙은 Member.class를 보고 MEMBER 테이블을 찾아갈 것이다. 그리고 memberId 값을 가지고 @Id가 붙은 id 필드에 해당하는 컬럼을 조건으로 SELECT SQL을 만들 것이다.

SELECT id, USERNAME FROM MEMBER WHERE id = ?

그리고 그 결과를 다시 Member 객체에 담아 돌려줄 것이다. 그 모든 과정이 이 어노테이션이라는 ‘지도’ 덕분에 가능했다.

솔라는 JDBC로 코드를 짤 때 겪었던 끔찍한 번역 작업을 떠올렸다. 개발자는 ResultSet에서 데이터를 꺼내 객체의 setter를 일일이 호출하며 객체와 테이블 사이를 오가는 번역가 역할을 해야만 했다. 하지만 JPA를 사용하면, 개발자의 역할은 바뀐다. 반복적인 SQL을 작성하는 번역가가 아니라, @Entity라는 잘 설계된 지도를 만드는 설계자가 되는 것이다.

그때 솔라의 눈에 새로운 프로젝트의 요구사항 메모가 들어왔다. [상품(Product) 정보 추가: 상품 ID, 상품명, 가격, 재고 수량 관리 기능]

이전의 솔라였다면, PRODUCT 테이블부터 설계했을 것이다. PRODUCT_IDBIGINT, NAMEVARCHAR… 머릿속으로 SQL부터 그리고 있었을 것이다.

하지만 지금, 솔라는 망설임 없이 새로운 자바 클래스 파일을 열었다. 그리고 가장 먼저 이렇게 입력했다.

@Entity
public class Product {

    @Id
    private Long id;

    private String name;

    private int price;
    
    // ...
}

그녀는 더 이상 SQL로 데이터베이스를 먼저 생각하지 않았다. 먼저 객체를, 객체지향적인 세상을 먼저 그리고 있었다. 데이터베이스와의 연결이라는 지루하고 반복적인 번역 작업은, 이제 자신이 만든 ‘지도’를 읽고 묵묵히 일해 줄 JPA에게 맡기면 된다는 것을 알기 때문이었다. 솔라의 사고방식은 이미 SQL 중심에서 객체 중심으로 옮겨가고 있었다.