객체와 관계형 데이터베이스의 차이
- 상속
- 연관관계
- 데이터 타입
- 데이터 식별 방법
상속
관계형 디비는 기본적으로 객체에서 생각하는 상속관계가 없다.
그래서 상속관계를 디비에 저장할 수 없다.
하지만 관계형 디비에서 객체의 상속처럼 데이터를 저장하고 사용할 수 있는 방식이 있다.
부모 테이블을 만들고 자식 테이블을 만드는 슈퍼타입 서브타입 관계를 사용하는 방식이다.
Album 저장
객체의 상속관계인 Album을 디비에 저장한다고 해보자.
그러면 우선 객체의 상속관계에 있는 Album과 Item 객체를 분해해야 한다.
INSERT INTO ITEM ...
INSERT INTO ALBUM ...
그리고 디비에 ITEM 테이블 ALBUM 테이블 2개로 나눠져 있기 때문에 위에처럼 삽입 쿼리를 두 개를 작성해서 호출해줘야 한다.
Album 조회
이번엔 Album을 조회한다고 해보자.
1. 각각의 테이블에 따른 조인 SQL 작성...
2. 각각의 객체 생성...
3. 상상만 해도 복잡
4. 더 이상의 설명은 생략한다.
5. 그래서 DB에 저장할 객체에는 상속 관계 안쓴다.
위 내용을 통해 디비에서 상속 관계의 데이터를 저장하고 관리하기 어렵다는 걸 알았다.
만약 위 데이터를 자바 컬렉션에 저장한다고 가정해 보자.
저장은 단순히 list.add(album); 하면 끝이다.
조회는 Album album = list.get(albumId); 하면 끝이다.
또는 부모 타입으로 조회 후 다형성 활용하려면 아래 코드와 같이 작성하면 된다.
Item item = list.get(albumId);
객체 세상에서는 상속관계의 데이터를 저장하고 조회하기 쉽다!
연관관계
객체는 참조를 사용한다 그래서 member.getTeam()과 같이 데이터를 꺼내 사용하면 된다.
테이블은 참조 개념이 없다. 그래서 외래키를 사용해서 연관관계를 맺는다.
그리고 데이터를 가져올 때는 JOIN ON M.TEAM_ID = T.TEAM_ID와 같이 조인을 해서 가져와야 한다.
그러면 이것 때문에 어떤 문제가 발생하느냐?
연관관계에 있는 멤버 객체와 팀 객체를 테이블에 저장하려면 객체를 테이블에 맞춰서 모델링을 많이 한다.
아래 Member 클래스를 보면 Team에 대한 참조를 가지고 있는 게 아니라 Team의 id를 가지고 있다.
class Member {
String id; //MEMBER_ID 컬럼 사용
Long teamId; //TEAM_ID FK 컬럼 사용 //**
String username;//USERNAME 컬럼 사용
}
class Team {
Long id; //TEAM_ID PK 사용
String name; //NAME 컬럼 사용
}
위와 같이 클래스를 만들어야 테이블에 맞춘 객체 저장이 편리하다.
객체는 참조로 연관관계를 맺는다 그래서 위와 같은 방식은 객체다운 모델링이 아니다.
객체다운 모델링
class Member {
String id; //MEMBER_ID 컬럼 사용
Team team; //참조로 연관관계를 맺는다.//**
String username;//USERNAME 컬럼 사용
Team getTeam() {
return team;
}
}
class Team {
Long id; //TEAM_ID PK 사용
String name; //NAME 컬럼 사용
}
Member는 Team이라는 참조를 가지고 있다.
그래서 member.getTeam()을 통해 바로 꺼내서 사용할 수 있다.
이렇게 해야 객체다운 모델링인데 이렇게 하면 데이터베이스 insert 하기가 굉장히 까다로워진다.
객체 모델링 저장
객체다운 모델링을 한다면 Member에는 Team의 id가 없다.
그래서 Insert 하기 위해서는 member.getTeam(). getId() 해서 가져와야 한다.
위와 같이 로직을 적으면 굉장히 번잡해진다.
객체 모델링 조회
SELECT M.*, T.* FROM MEMBER M JOIN TEAM T ON M.TEAM_ID = T.TEAM_ID
public Member find(String memberId) {
//SQL 실행 ...
Member member = new Member();
//데이터베이스에서 조회한 회원 관련 정보를 모두 입력
Team team = new Team();
//데이터베이스에서 조회한 팀 관련 정보를 모두 입력
//회원과 팀 관계 설정
member.setTeam(team); //**
return member;
}
디비에서 조인해서 가져온 데이터를 객체 모델링에 저장하려면 위와 같이 과정이 번거롭다.
만약에 member와 team을 디비에 보관하는 게 아니라 Java 컬렉션에 저장한다고 해보자.
list.add(member);
Member member = list.get(memberId);
Team team = member.getTeam();
컬렉션에 저장한다면 위와 같이 매우 편리하게 저장하고 조회할 수 있다.
자바 객체 세상 안에서는 디비의 테이블에 맞춰서 모델링한 객체를 관리하기 굉장히 번거롭다.
왜냐면 객체와 디비 간에 패러다임의 불일치들이 있기 때문에 서로 연관관계를 바라보는 게 다르다.
그런데 객체 세상에서의 자바 컬렉션을 사용한다면 저장하고 조회하는 게 간단하다. 즉 복잡한 변환과정이 필요 없다.
또한 객체는 자유롭게 객체 그래프를 탐색할 수 있어야 한다.
member.order.orderItem.item.prcie...
이렇게 . . . 해서 참조로 객체를 자유롭게 탐색할 수 있어야 한다.
하지만 데이터베이스에 객체를 보관하다 보면 처음 실행하는 SQL에 따라 탐색 범위가 결정된다.
SQL에 따라 탐색 범위가 결정된다는 소리는 아래와 같다.
아래처럼 member와 team을 조인해서 데이터를 가져왔다고 해보자.
그러면 당연히 member와 team에 관련한 데이터만 가져온다.
그래서 member.getTeam()을 하면 값이 있지만 member.getOrder()을 하면 당연히 값이 없다.
SELECT M.*, T.* FROM MEMBER M
JOIN TEAM T ON M.TEAM_ID = T.TEAM_ID
member.getTeam(); //OK
member.getOrder(); //null
SQL에 따라 탐색 범위 결정되기에 엔티티 신뢰 문제가 발생한다.
memberDAO.find(memberId)를 통해 member를 가져왔다고 해보자.
그러면 SQL에 따라 탐색 범위가 결정되기에 getTeam(), getOrder(). getDelivery()를 통해 값이 반드시 있다고 보장할 수 없다. 즉 엔티티 신뢰문제가 발생한다.
그래서 다음 계층을 들어가서 코드를 까봐야 한다. 서비스 로직을 짜는데 DAO 로직을 까봐야 한다는 소리다.
class MemberService {
...
public void process() {
Member member = memberDAO.find(memberId);
member.getTeam(); //???
member.getOrder().getDelivery(); // ???
}
}
모든 객체를 미리 로딩할 수는 없다.
상황에 따라 동일한 회원 조회 메서드를 여러 벌 생성해야 한다.
memberDAO.getMember(); //Member만 조회
memberDAO.getMemberWithTeam();//Member와 Team 조회
//Member,Order,Delivery
memberDAO.getMemberWithOrderWithDelivery();
따라서 계층형 아키텍처 진정한 의미의 계층 분할이 어렵다.
String memberId = "100";
Member member1 = memberDAO.getMember(memberId);
Member member2 = memberDAO.getMember(memberId);
member1 == member2; //다르다.
class MemberDAO {
public Member getMember(String memberId) {
String sql = "SELECT * FROM MEMBER WHERE MEMBER_ID = ?";
...
//JDBC API, SQL 실행
return new Member(...);
}
}
데이터베이스에 객체를 저장하고 관리하는 방식이라고 했을 때 똑같은 memberId로 조회한 member1, member2가 있다고 하자 이 member1과 member2는 서로 다른 객체이다.
String memberId = "100";
Member member1 = list.get(memberId);
Member member2 = list.get(memberId);
member1 == member2; //같다.
반면 컬렉션을 사용하는 경우 똑같은 memberId로 조회한 member1과 member2는 같은 객체이다.
객체답게 모델링할수록 매핑 작업만 늘어난다.
객체를 자바 컬렉션에 저장하듯이 DB에 저장할 수는 없을까?
이러한 문제를 해결하는 게 바로 JPA - Java Persistence API이다.
JPA?
- Java Persistence API
- 자바 진영의 ORM 기술 표준
ORM?
- Object-relational mapping(객체 관계 매핑)
- 객체랑 관계형 데이터베이스랑 맵핑을 해준다는 뜻이다.
- 맵핑이란 중간에서 뭔가를 해준다는거다.
- ORM을 쓰게되면 객체는 객체대로 설계 관계형 데이터베이스는 관계형 데이터베이스대로 설계가 가능하다.
- ORM 프레임워크가 중간에서 다른 부분들을(패러다임의 불일치 등) 매핑을 해서 해결 해준다.
- 대중적인 언어에는 대부분 ORM 기술이 존재
JPA는 애플리케이션과 JDBC 사이에서 동작
개발자가 직접 JDBC API를 사용했다면 JPA가 대신 JDBC API를 사용한다고 보면 된다.
JPA 동작 - 저장
member 객체를 저장한다고 생각해 보자.
member 객체를 memberDAO에 넘기고 memberDAO가 JPA에게 member entity를 넘기면 JPA가 알아서 member 객체를 분석을 해서 insert sql을 만들고 JDBC API를 사용해서 디비에 insert sql을 보낸다.
그리고 가장 중요한 패러다임 불일치를 해결해 준다.
조회도 마찬가지다.
JPA 소개
자바 진영에서 EJB라는 게 있다. 크게 두 가지 기능이 존재한다.
- 현재 스프링에서 사용하는 컨테이너 기술 같은 EJB 영역
- 지금의 JPA라고 볼 수 있는 엔티티 빈
JPA는 엔티티 빈으로부터 출발을 한 거다.
기술이 너무 복잡하고 성능이 안 나와서 EJB의 엔티티 빈은 거의 모든 개발자들이 쓰지 않았다.
그러다가 개빈 킹이라는 개발자가 하이버네이트(오픈소스)라는 기술을 만들었다.
자바 진영에서는 개빈 킹을 스카웃하여 자바 표준 ORM 기술로 JPA를 만들었다.
JPA는 표준 명세
표준 명세는 인터페이스의 모음이라고 보면 된다.
- JPA는 인터페이스의 모음
- JPA 2.1 표준 명세를 구현한 3가지 구현체
- 하이버네이트, EclipseLink, DataNucleus
JPA를 왜 사용해야 하는가?
- SQL 중심적인 개발에서 객체 중심으로 개발
- 생산성
- 유지보수
- 패러다임의 불일치 해결
- 성능
- 데이터 접근 추상화와 벤더 독립성
- 표준
앞서 SQL 중심적인 개발을 할 경우 굉장히 개발하기 난해하고 복잡하다는 걸 설명했다.
그래서 JPA를 사용하여 객체 중심으로 개발한다면 생산성과 유지보수를 챙길 수 있다.
또한 JPA는 객체 모델링과 테이블 모델링 간의 패러다임의 불일치를 해결하고 성능 관련해서도 이점이 있다.
생산성 - JPA와 CRUD
- 저장: jpa.persist(member)
- 조회: Member member = jpa.find(memberId)
- 수정: member.setName(“변경할 이름”)
- 삭제: jpa.remove(member)
JPA 사용 시 마치 자바의 컬렉션을 사용하는 것처럼 데이터 관리가 매우 간편해진다.
유지보수
기존: 필드 변경 시 모든 SQL 수정
JPA: 필드만 추가하면 됨, SQL은 JPA가 처리
JPA와 패러다임의 불일치 해결
- JPA와 상속
- JPA와 연관관계
- JPA와 객체 그래프 탐색
- JPA와 비교하기
JPA와 상속
JPA와 상속 - 저장
슈퍼타입, 서브타입으로 테이블을 설계하고 아이템 객체 인스턴스를 데이터베이스에 보관하려면 Insert 쿼리를 아이템 테이블에도 넣고 앨범 테이블에도 넣어야 한다.
jpa 사용 시 jpa.persist(album)을 하면 된다.
그러면 jpa가 알아서 insert into 해서 아이템 테이블에도 넣고 앨범 테이블에도 넣는다.
개발자가 직접 해주던 그 많은 맵핑 과정을 jpa가 다 해주게 된다.
JPA와 상속 - 조회
조회도 마찬가지다.
jpa.find()을 통해 조회 시 jpa가 알아서 다 조인해 주고(상속관계 시) 데이터를 가져온다.
JPA와 연관관계, 객체 그래프 탐색
연관관계 사용 시 원래는 외래키 값을 넣고 그래야 하는데 jpa에서는 연관관계를 참조를 쓸 수 있다.
신뢰할 수 있는 엔티티, 계층
class MemberService {
...
public void process() {
Member member = memberDAO.find(memberId);
member.getTeam();//자유로운 객체 그래프 탐색
member.getOrder().getDelivery();
}
}
JPA와 비교하기
String memberId = "100";
Member member1 = jpa.find(Member.class, memberId);
Member member2 = jpa.find(Member.class, memberId);
member1 == member2; //같다.
컬렉션에서 같은 인덱스로 조회한 값은 당연히 같은 인스턴스가 나오는 것처럼 JPA를 통해 값을 꺼내오면 == 비교했을 때 같다. 단 전제가 있는데 동일한 트랜잭션에서 조회한 엔티티는 같음을 보장해 준다.
JPA의 성능 최적화 기능
- 1차 캐시와 동일성(identity) 보장
- 트랜잭션을 지원하는 쓰기 지연(transactional write-behind)
- 지연 로딩(Lazy Loading)
1차 캐시와 동일성 보장
- 같은 트랜잭션 안에서는 같은 엔티티를 반환 - 약간의 조회 성능 향상
- DB Isolation Level이 Read Commit이어도 애플리케이션에서 Repeatable Read 보장
중간에 기술이 껴있으면 성능상 얻을 수 있는 이점은 아래와 같다.
- 모아서 보내는게 가능(buffer writing)
- 캐시 기능
jpa도 애플리케이션과 디비 사이에 껴있는 기술이기에 위와 같은 이점을 줄 수 있다.
String memberId = "100";
Member m1 = jpa.find(Member.class, memberId); //SQL
Member m2 = jpa.find(Member.class, memberId); //캐시
println(m1 == m2) //true
첫 번째 find()에서는 조회 SQL문을 보내고 member 인스턴스를 가져온다.
두 번째 find()에서는 SQL을 날리지 않고 jpa가 메모리 상에서 member 인스턴스를 가져온다.
그래서 == 비교하면 같다고 나오고 두번 조회했지만 SQL문이 한 번만 나간다.
다만 조건이 있는데 같은 데이터베이스 트랜잭션 안에서만 성립이 된다.
캐시 추가 설명
JPA는 1차 캐시, 2차 캐시라는 것을 제공한다.
1차 캐시
먼저 JPA의 1차 캐시는 쉽게 이야기해서 1명의 고객의 요청이 들어오고 나갈 때 까지 유지되는 생존범위가 매우 짦은 캐시이다. 이것은 애플리케이션 서버 메모리에 저장된다.
(더 정확히는 애플리케이션에서 데이터베이스 트랜잭션을 시작하고 종료할 때 까지 유지되는 캐시)
이런 1차 캐시는 사실 일부 성능 향상이 있기는 하지만, 사용 목적이 성능 향상 때문에 사용하는 것은 아니고, JPA의 내부 메커니즘을 유지하기 위해서 사용된다. 그리고 여러명이 동시에 같은 캐시에 접근할 수 없다.
2차 캐시
본격적인 캐시는 여러 사용자가 동시에 같은 데이터를 요청하면 애플리케이션 메모리나 캐시 별도의 캐시 서버에 저장되어 있는 데이터를 조회하는 것이 빠르다.
JPA는 엔티티 단위로 공유 가능한 캐시를 제공하는데 이것을 2차 캐시라 한다.
그런데 이런 2차 캐시는 너무 복잡하고, 다루기가 쉽지 않아서, 실무에서는 애플리케이션 레벨의 캐시를 더 많이 사용한다.
애플리케이션 레벨 캐시
JPA를 사용하더라도, JPA가 제공하는 캐시가 아니라 마이바티스 같은 구조를 사용할 때와 동일한 캐시 구조도 사용할 수 있다.
트랜잭션을 지원하는 쓰기 지연 - INSERT
- 트랜잭션을 커밋할 때까지 INSERT SQL을 모음
- JDBC BATCH SQL 기능을 사용해서 한 번에 SQL 전송
transaction.begin(); // [트랜잭션] 시작
em.persist(memberA);
em.persist(memberB);
em.persist(memberC);
//여기까지 INSERT SQL을 데이터베이스에 보내지 않는다.
//커밋하는 순간 데이터베이스에 INSERT SQL을 모아서 보낸다.
transaction.commit(); // [트랜잭션] 커밋
트랜잭션 커밋하기 전까지 데이터를 버퍼에 모아서 JDBC의 BATCH SQL 기능을 사용해서 한번에 전송할 수 있다.
지연 로딩과 즉시 로딩
멤버랑 팀이라는 연관관계가 있다고 가정해 보자.
멤버를 조회할 때 팀을 같이 쓸때도 있고 안쓸때도 있다.
멤버를 조회할 때 팀을 항상 같이 쓰면 조인해서 가져오는게 좋다.
반면 멤버를 조회할때 팀을 같이 안 쓴다면 멤버만 조회하는 게 성능상 좋다.
즉 멤버 조회 시 팀을 같이 안 쓴다면 연관된 거를 같이 미리 당겨올 필요가 없다.
위에처럼 상황마다 다르기 때문에 JPA는 지연 로딩과 즉시 로딩 두 가지를 다 지원한다.
- 지연 로딩: 객체가 실제 사용될때 로딩
- 즉시 로딩: JOIN SQL로 한번에 연관된 객체까지 미리 조회
지연 로딩 로직 설명
memberDAO에서 member를 조회하면 member만 조회하는 SQL문이 나간다.
그리고 team.getName()과 같이 실제 팀을 쓸 때 team을 조회하는 SQL문이 나간다.
즉시 로딩 로직 설명
멤버 조회 시 팀도 같이 필요해서 멤버를 조회할때 팀도 같이 조회해줘 라고 JPA에 설정을 하면 즉시 로딩이라는 전략이 동작한다.
멤버를 조회하는 순간 팀도 같이 조회한다.
즉 member.find()를 통해 멤버 조회시 팀도 조인해서 데이터를 가져오는 SQL문이 나간다.
참고
김영한님의 자바 ORM 표준 JPA 프로그래밍 - 기본 편 강의를 보고 정리한 내용입니다.
'JPA > JPA 기본' 카테고리의 다른 글
6. 다양한 연관관계 매핑 (0) | 2024.08.10 |
---|---|
5. 연관관계 매핑 기초 (0) | 2024.08.10 |
4. 엔티티 매핑 (0) | 2024.08.09 |
3. 영속성 관리 - 내부 동작 방식 (0) | 2024.08.08 |
2. JPA 시작하기 (1) | 2024.08.08 |