“데이터 중복은 절대 악(Evil)이다”
대학교 데이터베이스 수업 시간, 교수님은 침을 튀기며 강조하셨다. “제1 정규형, 제2 정규형, 제3 정규형… 중복된 데이터는 저장 공간을 낭비하고 무결성을 해친다. 쪼개고 또 쪼개라!”
나는 그 가르침을 성실히 따랐다. 졸업 프로젝트를 할 때 테이블을 10개, 20개로 잘게 쪼갰다. ‘User’, ‘Address’, ‘City’, ‘Zipcode’… 주소 하나를 저장하는 데도 테이블이 3개가 필요할 정도로 내 DB 설계는 ‘교과서적으로’ 완벽했다.
하지만 실무에서 이 완벽한 설계는 ‘재앙’이 되었다. 고객 목록 하나를 조회하려는데 ‘JOIN’을 5번이나 걸어야 했다. 쿼리는 복잡해졌고, 속도는 느려졌으며, 무엇보다 자바 객체로 데이터를 옮겨 담는 과정이 너무나 고통스러웠다.
“아니, 그냥 화면에 보여줄 데이터 한 줄 가져오는 건데 왜 이렇게 코드가 복잡해?”
그제야 알았다. 학교에서는 ‘저장(Save)’의 효율성을 가르쳤지만, 실무에서는 ‘조회(Read)’의 효율성이 훨씬 중요하다는 것을. 그리고 객체지향 언어(Java)와 관계형 데이터베이스(RDB) 사이에는 건널 수 없는 강이 흐르고 있다는 것을.

패러다임의 불일치: 네모와 동그라미
우리가 겪는 고통의 근본적인 원인은 ‘패러다임의 불일치(Impedance Mismatch)’다.
- 자바(객체): 그래프 형태다. ‘Member’가 ‘Team’을 가지고 있고, ‘Team’ 안에 다시 ‘List<Member>’가 있다. 참조(Reference)를 통해 자유롭게 이동한다.
- DB(테이블): 엑셀 같은 표(Grid) 형태다. 외래키(FK)를 통해 연결되지만, 기본적으로는 남남이다. 합치려면 ‘JOIN’이라는 비싼 비용을 치러야 한다.
학부 시절, 나는 이 둘을 억지로 끼워 맞추기 위해 SQL을 직접 짰다. 자바 객체를 쪼개서 DB에 넣고(INSERT), DB에서 SELECT로 꺼낸 데이터를 ResultSet에서 한 줄씩 읽어와 Set이나 List 같은 자바 컬렉션에 일일이 옮겨 담는 지루한 반복 작업을 했다. 개발자가 아니라 ‘데이터 번역가’가 된 기분이었다.
이 지루한 반복 작업을 해결하기 위해 등장한 것이 바로 JPA(Java Persistence API), 즉 ORM(Object-Relational Mapping) 기술이다.
JPA와 ORM: 객체로 DB를 다루는 기술
ORM은 말 그대로 ‘객체(Object)와 관계형 DB(Relational)를 연결(Mapping)해주는 기술’이다. 질문한 대로, ‘DB 테이블을 자바 객체처럼 정의하고 다루는 것’이 핵심이다.
우리는 더 이상 ‘CREATE TABLE’ 쿼리를 짤 필요가 없다. 대신 자바 클래스를 만들고 ‘@Entity’라는 스티커를 붙인다. 그러면 JPA(자바의 ORM 표준)가 이 클래스를 보고 “아, 이런 모양의 테이블이 필요하구나” 하고 DB에 테이블을 자동으로 만들어준다.
데이터를 저장할 때도 SQL을 쓰는 게 아니라, 자바 컬렉션에 넣듯이 ‘repository.save(member)’라고 하면 끝이다. 개발자는 철저히 ‘객체 지향적인 관점’에서 코드를 짜고, 지저분한 SQL 번역 작업은 JPA에게 떠넘기는 것이다.
하지만 ‘Re: Booting’ 시리즈에서 배웠듯이, 편리함에는 항상 대가가 따른다. JPA라는 자동 번역기를 너무 믿은 나머지, 나는 ‘N+1 문제’라는 시한폭탄을 내 코드에 심어버렸다.
[Code Verification] N+1 문제, 쿼리 폭탄
JPA를 처음 쓰는 주니어 개발자가 100% 겪게 되는 문제다. 상황은 간단하다. “모든 회원과 그 회원이 소속된 팀 이름을 출력하시오.”
// 1. 모든 멤버를 조회한다. (쿼리 1번 발생)
List<Member> members = memberRepository.findAll();
for (Member member : members) {
// 2. 각 멤버의 팀 이름을 출력한다.
// 이때, 멤버가 100명이면 팀 정보를 가져오기 위한 쿼리가 100번 더 나간다!
System.out.println(member.getTeam().getName());
}
기대했던 쿼리: SELECT * FROM Member JOIN Team ... (딱 1번)
실제 발생한 쿼리:
SELECT * FROM Member(회원 목록 가져옴 – 1번)SELECT * FROM Team WHERE id = 1(첫 번째 회원의 팀 조회)SELECT * FROM Team WHERE id = 2(두 번째 회원의 팀 조회)- … (멤버 수만큼 반복)
회원이 100명이면 쿼리가 101번(1 + N) 나간다. 회원이 1만 명이면? 쿼리 1만 1개가 DB를 강타한다. 이것이 바로 서버를 뻗게 만드는 ‘N+1 문제’다. JPA가 개발자 편하라고 데이터를 ‘그때그때(Lazy)’ 가져오려다 발생한 참사다.

실무 조언: 실용주의 DB 설계
그렇다면 실무에서는 어떻게 해야 할까? 교과서적인 정규화와 JPA의 편리함 사이에서 균형을 잡아야 한다.
- Fetch Join을 애용하라: N+1 문제를 해결하는 가장 확실한 방법은 “처음부터 다 같이 가져와”라고 JPA에게 명시하는 것이다. JPQL의 ‘join fetch’를 사용하면 SQL의 ‘JOIN’처럼 한 방 쿼리로 데이터를 가져온다.
- 때로는 ‘반정규화(De-normalization)’가 답이다: 교수님은 싫어하시겠지만, 실무에서는 성능을 위해 중복 데이터를 허용하기도 한다. 예를 들어 ‘게시글 목록’에서 ‘작성자 이름’을 보여줘야 한다면, ‘User’ 테이블까지 조인하는 대신 ‘Post’ 테이블에 ‘writer_name’ 컬럼을 중복해서 저장해버리는 것이다. 저장 공간(Disk)을 낭비해서 조회 속도(Speed)를 사는 전략이다.
- 즉시 로딩(EAGER)은 독이다: JPA 설정에서 ‘@ManyToOne(fetch = FetchType.EAGER)’를 쓰는 순간 지옥문이 열린다. 필요 없는 데이터까지 무조건 다 끌고 오기 때문이다. 실무에서는 무조건 ‘지연 로딩(LAZY)’을 기본으로 하고, 필요한 경우에만 ‘Fetch Join’을 써야 한다.
마치며: 편해지려면 더 많이 알아야 한다
JPA는 분명 혁명이다. 지겨운 SQL 반복 작업에서 우리를 해방해주었으니까. 하지만 “JPA를 쓰니까 이제 SQL 몰라도 된다”는 생각은 위험하다.
JPA는 마법사가 아니라, 나 대신 SQL을 짜주는 ‘비서’일 뿐이다. 내가 비서에게 일을 잘못 시키면(잘못된 매핑, EAGER 로딩 등), 비서는 묵묵히 100개의 쿼리를 날려서 DB를 죽일 것이다. JPA가 만들어내는 쿼리가 효율적인지 감시하고 튜닝하려면, 역설적이게도 SQL을 더 깊이 알아야 한다. 편안함에는 책임이 따르는 법이다.
이제 우리는 데이터를 객체에 담는 법을 알았다. 그런데 이 객체(‘Entity’)를 그대로 프론트엔드(Vue.js)에 보내줘도 될까? ‘User’ 엔티티에 비밀번호가 들어있다면? 프론트엔드가 원하지 않는 정보까지 다 줘버리면 보안은 어떡하지?
다음 시간에는 데이터를 포장해서 배달하는 기술, ‘DTO(Data Transfer Object)와 REST API 설계’에 대해 이야기해 보자.