JPA 에 관한 간단한 글
JDBC에서 MyBatis
JPA가 등장하기 전, 자바에서 RDBMS에 연결하던 방식을 먼저 짚고 넘어가 보자. 가장 기본은 JDBC를 이용하는 방법이다. MyBatis나 JPA 등은 모두 이 JDBC를 바탕으로 동작한다. 개발자가 직접 커넥션을 통해 세션을 열고, Statement나 PreparedStatement로 명령을 수행하거나 쿼리를 실행한다. 요청을 마치면 자원을 닫아야 하는데, 자바 특유의 체크 예외 때문에 닫는 과정마저 catch 문으로 감싸야 했다. 자바7부터는 try-with-resources가 도입되었지만 더 이전에는 이를 위해 별도의 유틸리티 메서드를 만들어 관리하곤 했다. 또한, 효율적인 자원 관리를 위해 커넥션 풀을 사용해 커넥션을 획득하고 반환하는 과정이 일반적이었다.
하드코딩 방식은 쿼리 수정 소요 발생시 코드를 직접 고쳐 재배포해야 하는 번거로움이 있다. 이러한 불편함을 해소하기 위해 SQL을 XML에 분리하여 관리하고 바인딩하는 SQL Mapper 방식이 도입되었다.
그 뒤를 이어 등장한 MyBatis(iBATIS)는 위 방식을 더욱 진보시킨 형태였다.
1public class UserDao {23 private static final String URL = "jdbc:mysql://localhost:3306/testdb";4 private static final String USER = "root";5 private static final String PASSWORD = "1234";67 public List<User> selectUsers(String username) {89 List<User> list = new ArrayList<User>();1011 Connection conn = null;12 PreparedStatement pstmt = null;13 ResultSet rs = null;1415 StringBuilder sql = new StringBuilder();16 sql.append("SELECT id, username, email ");17 sql.append("FROM user ");18 sql.append("WHERE 1=1 ");1920 if (username != null && !"".equals(username)) {21 sql.append("AND username LIKE ? ");22 }2324 try {25 Class.forName("com.mysql.jdbc.Driver");26 conn = DriverManager.getConnection(URL, USER, PASSWORD);2728 pstmt = conn.prepareStatement(sql.toString());2930 if (username != null && !"".equals(username)) {31 pstmt.setString(1, "%" + username + "%");32 }3334 rs = pstmt.executeQuery();3536 while (rs.next()) {37 User user = new User();38 user.setId(rs.getInt("id"));39 user.setUsername(rs.getString("username"));40 user.setEmail(rs.getString("email"));41 list.add(user);42 }4344 } catch (Exception e) {45 e.printStackTrace();46 } finally {47 close(rs, pstmt, conn);48 }4950 return list;51 }52 private void close(ResultSet rs,53 PreparedStatement pstmt,54 Connection conn) {5556 try { if (rs != null) rs.close(); } catch (Exception e) {}57 try { if (pstmt != null) pstmt.close(); } catch (Exception e) {}58 try { if (conn != null) conn.close(); } catch (Exception e) {}59 }60}61다만 RDBMS 스키마가 변경될 경우, 문자열로 작성된 쿼리들을 일일이 찾아 수정해야 하는 굉장히 큰 번거로움이 있었다. 쿼리 누락이나 오타를 컴파일 시점에 잡아내기 어렵다는 점도 운영상 큰 부담이었다.
jdbc와 mybatis 와 비교했을떄 jpa 는 테이블과 1:1 매핑되는 클래스를 만들고 해당 클래스의 메타데이터를 통해 쿼리를 짜준다. 따라서 데이터베이스 스키마가 변경되어도 해당 내용이 클래스에 반영되어있다면 쿼리를 직접 변경하지 않아도 된다.(심지어 연동도 된다 ddl-auto). JPA를 ORM 이라고 한다. ORM 은 DB 와 객체의 패러다임 불일치를 해결하기 위해 나왔다. 요컨대 RDB 에서의 엔티티와(table) 프로그램에서의 엔티티(객체)처럼 다루고 싶었던건데 RDB에서 엔티티는 데이터의 효율적인 저장에 관심을 둔다. 따라서 데이터가 중복없이 저장되는게 목적이다. 프로그램, 특히 OOP 에서의 객체는 서로 책임을 가지고 협력하는 존재들이다. 서로가 공개된 행동들이 있고 그 행동을 책임지기 위해 데이터를 가진다. 그렇게 데이터베이스와 프로그래밍 언어는 발전했고 김영한님의 '자바 ORM 표준 JPA 프로그래밍 ' 이라는 책에 따르면 다음과 같은 불일치들이 발생한다.
JPA
JDBC나 MyBatis와 달리, JPA는 테이블과 1:1로 매핑되는 클래스를 만들고 해당 클래스의 메타데이터를 활용해 쿼리를 생성한다. 따라서 데이터베이스 스키마가 변경되더라도 클래스 정보에만 반영하면 쿼리를 직접 수정할 필요가 없다. 심지어 객체가 DB의 테이블이 되도록 하는 옵션(ddl-auto)도 존재한다. JPA와 같은 ORM은 DB와 객체 사이의 '패러다임 불일치'를 해결하기 위해 등장했다. RDBMS의 엔티티는 데이터의 효율적인 저장과 중복 제거에 집중하는 반면, OOP의 객체는 책임과 협력을 바탕으로 상태와 행동을 관리한다. 이처럼 서로 지향점이 다르다 보니 발전 방향도 달라졌고, 이는 필연적인 불일치를 낳았다. 김영한 님의 '자바 ORM 표준 JPA 프로그래밍'에 따르면 구체적으로 다음과 같은 문제들이 발생한다.
-
상속 객체는 다형성 및 인지 부하 감소를 위해 상속 구조를 가지지만, DB에는 상속이라는 개념이 없다. 데이터 중복 방지가 중요한 DB 관점에서는 상속 구조를 테이블 쪼개기나 비정규화 같은 방식으로 처리할 수밖에 없다. JPA를 사용하면 개발자는 상속 관계를 유지하며 객체지향 설계를 지속할 수 있다. 객체를 저장하면 JPA가 INSERT 쿼리를 나누어 전송하고, 조회할 때는 적절한 JOIN 쿼리를 실행해 객체화된 데이터를 다시 조립해준다. 결과적으로 DB의 지원 여부와 관계없이 객체지향적인 설계를 유지할 수 있다는 의미다.
-
연관관계 테이블은 외래 키 하나로 양방향 조인이 가능하지만, 객체는 참조를 타고 한 방향으로만 흐른다. 객체를 테이블 구조에 맞추기 위해 객체 내부에 외래 키 값을 직접 들고 있게 되면, 결국 참조가 아닌 값 기반의 탐색을 하게 되어 객체지향의 이점이 사라진다. JPA에서는 객체에 참조를 걸어준 뒤 애노테이션으로 관계만 정의하면 된다. JPA가 이 참조를 보고 DB에 넣을 때는 외래 키로 변환하고, 조회할 때는 다시 객체 참조로 복원해준다. 덕분에 객체는 데이터베이스의 식별자에 오염되지 않고 순수하게 협력에만 집중할 수 있다.
-
객체 그래프 탐색 OOP가 추구하는 핵심 중 하나는 참조를 통한 자유로운 '객체 그래프 탐색'이다. 하지만 현실적으로는 DB 조회 범위를 고려할 수밖에 없다. 처음에 멤버와 팀만 조인해서 가져왔다면, 나중에
member.getOrder()를 호출하는 순간 데이터가 없어 에러가 발생하거나 null을 마주하게 된다. 그렇다고 모든 데이터를 한 번에 메모리에 다 올릴수도 없는 노릇 아닌가. 여기서부터 '엔티티를 어디까지 믿고 쓸 수 있는가'에 대한 불신이 시작된다.
JPA는 이를 프록시 로 해결한다. 처음에는 빈 껍데기 객체를 넣어두고, 개발자가 실제로 그 객체의 데이터를 사용하는 시점에 쿼리를 날려 내용을 채워주는 '지연 로딩' 방식을 취한다. 개발자 입장에서는 마치 객체 그래프가 처음부터 끝까지 연결되어 있는 듯한 추상화를 경험하게 된다.
1 @Override2 @Transactional3 public void run(String... args) throws Exception {4 System.out.println("===== 예제 시나리오 시작 =====");5 // === 데이터 준비 ===6 System.out.println("[1단계] 테스트용 Team과 Member 데이터를 생성하고 저장합니다.");7 Team teamA = new Team();8 teamA.setName("레드불 레이싱");9 em.persist(teamA);1011 Member member1 = new Member();12 member1.setName("막스 베르스타펜");13 member1.setTeam(teamA);14 em.persist(member1);1516 // 영속성 컨텍스트를 비워 1차 캐시를 제거17 em.flush();18 em.clear();19 System.out.println("---------------------------------");202122 // === 지연 로딩 시나리오 ===23 System.out.println("[2단계] Member를 ID로 다시 조회합니다. (SELECT * FROM Member)");24 Member foundMember = em.find(Member.class, member1.getId());25 System.out.println("Member 조회 성공!");2627 // 이 시점에서는 Member 엔티티만 로딩되고, 연관된 Team 엔티티는 아직 로딩되지 않습니다.28 Team foundTeam = foundMember.getTeam();2930 System.out.println("[3단계] 조회된 Member의 Team 객체 클래스를 확인합니다.");31 System.out.println("Team 객체 타입: " + foundTeam.getClass().getName());32 System.out.println("아직 Team 테이블에 대한 SELECT 쿼리는 실행되지 않았습니다.");3334 // 실제 Team 객체의 데이터(name)에 접근하는 시점35 System.out.println("[4단계] Team의 name에 실제로 접근하는 순간, 지연 로딩이 발생합니다.");36 System.out.println("Team name 조회 요청: foundTeam.getName()");37 String teamName = foundTeam.getName(); // 이 순간 SELECT 쿼리가 실행됨38 System.out.println("조회된 Team name: " + teamName);39 System.out.println("이제서야 Team 테이블에 대한 SELECT 쿼리가 실행되었습니다.");4041 System.out.println("===== 예제 시나리오 종료 =====");42 }4344@Entity45@Getter46class Member {47 @Id48 @GeneratedValue(strategy = GenerationType.IDENTITY)49 private Long id;50 private String name;5152 @Setter53 @ManyToOne(fetch = FetchType.LAZY)54 @JoinColumn(name = "TEAM_ID")55 private Team team;5657}5859@Entity60@Getter61class Team {62 @Id63 @GeneratedValue(strategy = GenerationType.IDENTITY)64 private Long id;65 private String name;6667 @OneToMany(mappedBy = "team")68 private List<Member> members = new ArrayList<>();69 public void addMember(Member member) {70 members.add(member);71 member.setTeam(this);72 }73}74- 동일성 비교: 메모리 주소 vs 기본 키
자바 컬렉션에서 객체를 꺼낼 때 기대하는 상식이 DB 환경에서는 보장되지 않는다. DB 관점에서는 기본 키만 같으면 동일한 데이터지만, SQL을 두 번 실행해 같은 로우를 조회하면 자바에서는 서로 다른 인스턴스가 생성된다. 결과적으로
member1 == member2가 false를 반환하면서 객체지향의 동일성 원칙이 깨지게 된다.
JPA는 1차 캐시를 통해 이를 보장한다. 같은 트랜잭션 범위 내라면 동일한 기본 키로 조회 시 무조건 같은 주소값을 가진 객체를 반환한다. 덕분에 DB 데이터를 다룰 때도 마치 자바 리스트에서 꺼내 쓰는 것처럼 동일성을 보장받을 수 있다.
1public class ApplicationService {2 public void someCommand(int id) {3 Domain domain = repository.findById(id).orElseThrow(() -> throw new Exception());4 Domain sameDomain = repository.getById(id)56 if (domain == sameDomain) System.out.println("같은 객체")7 }8}9보통은 이렇게 활용될수 있다.
1public class 출고Service {2 public void 출고확정(출고Id 출고id) {3 출고 obj = 출고Respository.findById(id).orElseThrow(() -> throw new Exception());4 obj.확정();5 재고service.재고변경(출고id);6 }7}8public class 재고Service {9 public void 재고변경(출고Id 출고id) {10 출고 obj = 출고Respository.findById(id).orElseThrow(() -> throw new Exception());11 재고.변경(obj.getContext())12 }13}14만약 public void 재고변경(출고 obj) 라는 메서드 시그니쳐를 가지고있다면 Service를 호출 하는 쪽이 이미 도메인 객체 출고 에 대해 알고있어야함. 이는 서비스 간 강한 결합을 만들어낸다. 또한 해당 서비스의 자율성이 제한된다. 보통 외부 서비스나 컨트롤러와의 협력 지점에서는 식별자 기반의 인터페이스를 유지하는게 권장된다.
가려진 비용과 부작용
여기까지 보면 JPA가 모든 불일치를 완벽히 해결한 것처럼 보일 수 있다. JPA는 객체와 관계형 데이터베이스의 패러다임 불일치를 훌륭하게 덮어주지만 '추상화'라는 본연의 목적에서 일정 부분 타협하거나 실패한 면이 있다.
-
지연 로딩은 메모리 효율을 높여주지만, 필연적으로 네트워크 및 디스크 I/O를 동반한다. 개발자는 성능 최적화를 위해 의도치 않은 쿼리가 나가는 것을 막아야 하며, 이 과정에서 JPA의 내부 동작 방식을 깊이 파악해야만 한다. 진정한 추상화라면 내부의 I/O 메커니즘이 개발자의 비즈니스 로직에 침투하거나 방해해서는 안 되지만, JPA는 이를 개발자의 몫으로 남겨두었다.
-
프록시와 영속성 컨텍스트라는 '상태'를 통해 불일치를 감추려 시도하지만, 이는 예상치 못한 부작용을 낳기도 한다. 특히 엔티티가 비즈니스 책임까지 떠안게 될 때 문제는 더욱 복잡해진다.
-
양방향 매핑 시 한쪽만 상태를 변경하고 반대쪽을 갱신하지 않으면 데이터 불일치가 발생한다. 외래 키를 가진 쪽이 '주인'이라는 개념은 DB 관점에서는 명확할지 모르나, 이를 객체지향적으로 흉내 내기 위해 방어적인 세터(Setter)를 작성해야 하는 등의 번거로움이 따른다.
-
하이버네이트는 자체적인
PersistentCollection을 사용한다. 이로 인해 외부에서immutable혹은unmodified컬렉션을 주입하면 형변환 오류가 발생할 수 있으며,new ArrayList<>(inputList)와 같은 가드 로직이 강제된다. 이러한 제약 사항들은 JPA의 추상화가 완전하지 않음을 보여주는 단면이다.
설계 원칙과 충돌하는 순간들
- 객체지향적으로 생각하면 Team 객체가
List<Member>를 들고 있으니,team.getMembers().add(member)를 호출하는 것이 자연스럽다. 하지만 DB 세계에서는 MEMBER 테이블의 외래 키(FK)가 실제로 변경되어야 한다.
JPA는 이 충돌을 막기 위해 외래 키를 보유한 쪽을 '연관관계의 주인'으로 정하고, 반대편은 mappedBy를 통해 읽기 전용으로 묶어둔다. 결국 DB에 값이 반영되기 위해서는 member.setTeam(team)을 호출해야 한다.
그런데 이렇게만 하면 메모리 상의 1차 캐시에서는 Team 객체의 리스트에 해당 멤버가 없는 모순이 발생한다. 이를 해결하기 위해 결국 양쪽 객체 상태를 모두 세팅하는 '연관관계 편의 메서드' 를 강제로 작성해야 한다.
객체지향을 돕겠다던 JPA가 오히려 객체 양쪽을 수동으로 동기화하는 보일러플레이트 코드를 요구하는 셈이다.
- 우리는 Repository 인터페이스를 통해 데이터 접근 기술을 우아하게 숨겼다고 믿는다. DIP를 충실히 지켰다고 생각한다. 그러나 서비스 계층에서 조회한 객체를 컨트롤러나 뷰(View)로 넘긴 뒤, 무심코 member.getTeam().getName()을 호출하는 순간 LazyInitializationException이 발생한다.
트랜잭션(영속성 컨텍스트)이 서비스 계층에서 종료되었기 때문에, 껍데기뿐인 프록시 객체가 초기화되지 못하는 것이다.
이를 막겠다고 OSIV(Open Session In View)를 켜면 또 다른 문제가 발생한다. 뷰에서 렌더링용으로 객체 상태를 변경했을 뿐인데 트랜잭션 커밋 시점에 DB 데이터가 변경될 수 있다.
결국 프레젠테이션 계층이 데이터 접근 계층의 동작 방식(프록시 초기화 여부, 영속성 컨텍스트 생명주기)을 이해하고 있어야 한다는 강한 결합이 발생한다. 이는 추상화라기보다 구현 세부사항의 노출에 가깝다.
- 더티 체킹은 편리한 기능이지만, 그 스펙을 정확히 이해하지 못하면 예상치 못한 결과를 맞이하게 된다. JPA는 필드 하나만 변경해도 기본적으로 모든 필드를 업데이트하는 UPDATE 쿼리를 생성한다. 캐싱 전략을 고려한 설계이지만, 실무 관점에서는 직관적이지 않다.
더욱 뼈아픈 것은 화면에서 수정 폼으로 넘어온 데이터(ID가 있는 엔티티)를 다룰 때다. 트랜잭션을 벗어났다가 돌아온 객체는 '준영속(Detached)' 상태가 되어 더티 체킹이 동작하지 않는다.
결국 em.merge()의 내부 동작을 이해하거나, 다시 findById()로 DB에서 객체를 꺼내와 일일이 값을 갈아 끼우는 방어 로직을 작성해야 한다. 상태 기반 추상화는 편리하지만, 그 상태 전이를 이해하지 못하면 오히려 더 많은 코드와 복잡성을 낳는다.
- 지연 쓰기의 무력화 JPA는 커밋 순간까지 INSERT 쿼리를 모아두는 '쓰기 지연 SQL 저장소'를 통해 네트워크 통신을 최적화한다고 설명한다. 하지만 실무에서 가장 많이 쓰는 GenerationType.IDENTITY 전략을 적용하는 순간 이 철학은 상당 부분 무력화된다.
JPA는 영속성 컨텍스트 관리를 위해 PK 값을 미리 알아야 하므로, 쓰기 지연을 포기하고 persist() 호출 시점에 즉시 INSERT 쿼리를 실행한다. 벤더 독립성을 표방하지만, 특정 DB 전략의 제약 앞에서 JPA의 핵심 최적화 기능이 제한되는 아이러니가 발생한다.
결국엔 다 알아야한다
결국 JPA로 돌아가는 시스템을 온전히 제어하려면 알아야 할 것이 매우 많다.JPA의 영속성 메커니즘, JPQL 변환 규칙과 N+1 발생 원리, 플러시 타이밍 같은것들
JPA가 제공하는 JPQL은 결국 다시 문자열 기반이며, 컴파일 타임에 에러를 완전히 잡아내지 못한다. 이를 해결하려 자바 표준으로 등장한 Criteria API는 지나치게 복잡해 실무에서 널리 사용되기 어렵다. 결국 많은 팀이 QueryDSL 같은 별도의 도구를 도입하게 된다.
나만의 사용 원칙
이러한 한계점 때문에 나는 JPA를 사용할 때 다음과 같은 원칙을 지키려 노력한다. JPA가 패러다임 불일치를 해결하는데 실패했다고 생각하고, JPA Entity는 RDBMS 의 테이블과 1:1 매핑되는 책임을 가지는 객체로만 쓴다. 비즈니스 로직은 도메인 레이어에서 처리하는 것을 원칙으로 하되, 비즈니스 구조가 DB 관계와 일치할 때만 엔티티를 비즈니스 객체로 활용하는걸 고려한다. 다만 대다수의 애플리케이션이 DB 관계를 중심으로 비즈니스가 설계되기에 반드시 그래야 한다고는 생각하지않는다.
JPA Entity 가 비즈니스 책임을 가질때 적용될 구체적인 원칙은 다음과 같다.
- 무분별한 연관관계는 맺지 않는다.
- 생명주기가 완벽하게 일치하는 경우에도 연관관계를 "고려"한다.
- 연관관계를 맺더라도
@ManyToOne이외에는 최대한 신중하게 사용한다. CascadeType.ALL,orphanRemoval = true같이 데이터 삭제를 유발하는 설정은 지양한다.- 개발 단계에서도 불필요한 DDL 자동 생성 기능에 의존하지 않는다.
또한, JPA를 사용하며 발생하는 성능 문제는 대부분 '읽기 성능'에 집중된다. 이를 해결하기 위해 단순 조회가 아닌 복잡한 쿼리에는 JPQL이나 QueryDSL을 사용하는 것이 사실상의 표준이다.
JPA가 JPQL과 QueryDSL을 통해 추구하는 핵심 가치는 DB 벤더 독립성이다. 그러나 실제 서비스 수명 주기 동안 RDBMS 벤더를 교체할 확률은 매우 희박하다. 반면, 비즈니스 요구에 맞춰 쿼리를 미세하게 튜닝하고 성능을 확보해야 하는 상황은 매일 마주하는 현실이다.
결국 JPA의 추상화는 발생 가능성이 낮은 '벤더 교체'라는 시나리오를 위해, 당장 필요한 DB 최적화의 기회를 포기하는 선택이 되기도 한다. 예를 들어 PostgreSQL의 RETURNING 구문이나 특정 DB 전용 인덱스 힌트 등은 JPA라는 범용적인 틀 안에서는 제대로 활용하기 어렵다. 이는 미래의 불확실한 유연성을 확보하려다 현재의 확실한 성능적 이점을 놓치는, 본말전도된 비합리적인 엔지니어링 트레이드오프에 가깝다고 생각한다.
그럼에도 불구하고 JPA는 현대 자바 개발 생태계에 큰 기여를 했다고 생각한다. 데이터베이스와의 상호작용을 문자열에서 타입 시스템의 영역으로 끌어올렸기 때문이다. db 스키마가 변경될때 변경 누락된 쿼리가 없는지 걱정하며 배포하지않아도 되고, Spring Data 와의 결합은 findById, findByTitleContaining 같은 단순한 이름 정의만으로 쿼리가 생성되는 편리함을 제공한다.
마무리
JPA가 모든 문제를 해결해주는 실버 불렛은 아니다. 우리는 JPA가 제공하고자 했던 가치와 그 이면의 제약 사항을 냉철하게 이해해야 한다. 기술의 비용과 이득을 명확히 파악하고, 주어진 상황에서 최적해 찾아내는 것 또한 엔지니어의 중요한 역량이기 때문이다.