Spring Data

[JPA와 모던 자바 데이터 저장 기술] 자바 ORM 표준 JPA 프로그래밍 - 기본편 강의 (1)

Woonys 2023. 1. 1. 21:50
반응형

SQL 중심적인 개발이 왜 문제냐?

 

애플리케이션을 만들 때는 비즈니스 도메인의 복잡성을 단순화하기 위해 Java, Python과 같은 객체 지향 언어를 사용한다. 반면 데이터베이스를 다룰 때는 객체 지향과는 전혀 다른 패러다임을 적용한다. 대표적으로 관계형 DB를 생각해보자. Oracle, MySQL 등을 사용한다.

 

그런데 애플리케이션은 결국 서버와 DB로 구성되어 있고, 서버에서 다루는 데이터는 결국 DB로 저장된다. 그런데 이때 데이터를 서버에서는 데이터를 객체로 감싸지만, 이렇게 객체로 관리하려면 관계형 DB의 도움이 반드시 필요하다. 즉, 지금 시대는 객체를 관계형 DB에서 관리한다고 봐도 과언이 아니다.

 

문제는 관계형 DB에서 데이터를 가져와 객체로 관리하려면 반드시 SQL문을 작성해야 한다는 점이다. 이게 왜 문제가 될까? 자바 코드에서 객체로 관리한들, 이 객체를 DB에 집어넣으려면 반복적인 CRUD 코드를 작성해야 하기 때문이다.

  • 자바 객체의 데이터를 일일이 꺼내서 SQL문을 통해 DB에 집어넣거나
  • 반대로 DB에 저장되어 있는 데이터를 하나씩 꺼내서 객체에 집어넣거나

 

예시를 살펴보자. 아래 Member라는 객체가 있다.

public class Member {
        private String memberId;
        private String name;
}

이 객체의 데이터를 DB에 저장하려면 어떻게 해야 할까? Member에 있는 필드값을 하나씩 꺼내다가 SQL문에 다 집어넣어줘야 한다.

--CREATE
INSERT INTO MEMBER(MEMBER_ID, NAME) VALUES;
--READ
SELECT MEMBER_ID, NAME FROM MEMBER M;
--UPDATE
UPDATE MEMBER SET ...

위의 Member 객체 내 필드값이 겨우 2개니까 망정이지, 만약 100개라고 생각해보자. 저 긴 SQL문 작성하고 있을 생각하니 갑갑하기 그지 없다.

게다가 만약 여기서 필드가 하나 더 추가된다면 어떨까?

public class Member {
        private String memberId;
        private String name;
        // 필드 하나 더 추가 (ㅅㅂ..)
        private String tel;
}
--CREATE
INSERT INTO MEMBER(MEMBER_ID, NAME, TEL) VALUES;
--READ
SELECT MEMBER_ID, NAME, TEL FROM MEMBER M;
--UPDATE
UPDATE MEMBER SET ... TEL =?

 

이렇게 모든 SQL문에 일일이 수정해줘야 할 것이다. 여기서 만약 실수로 특정 SQL문에 TEL을 추가하지 않기까지 하면? 끔찍하다. 이것이 SQL 의존적인 개발의 문제점이다. 게다가 백날 객체지향적으로 데이터를 숨기고 한들, 막판에 저장하려면 캡슐화해놨던 상태값을 일일이 다 해체해서 쿼리를 날려야 하지 않나.

 

이 문제의 근본적인 원인은 패러다임의 불일치에서 온다. 자바 애플리케이션은 객체지향 패러다임으로 만들어지는 반면, 관계형 데이터베이스는 객체지향이 아닌 자기만의 패러다임이 존재하기 때문이다. 하지만 자바 애플리케이션이 DB에 종속되어 있기 때문에 어떻게든 관계형 DB한테 고개 숙이고 을질 당할 수 밖에 없는 것..(서럽다) 이 과정에서 개발자는 자바 객체를 관계형 DB에 넣기 위한 매퍼 역할을 자처할 수밖에 없다

(사실상 진짜 을은 개발자였던 걸로)

 

 

객체와 관계형 DB의 차이

 

객체와 관계형 DB의 차이에 대해 더 자세히 파헤쳐보자. 이게 왜 문제인지 이해해야 뒤에 설명할 JPA가 나온 이유를 받아들일 수 있다. 처음부터 좋은 것만 보면 이게 왜 좋은지 알기 힘들다.

1. 상속

 

객체에서 상속은 객체들 간의 관계를 구축하는 방법으로, 말 그대로 부모 클래스로부터 속성과 동작을 물려받는 방식이다. 이러면 상속받은 객체의 요소를 쉽게 재사용할 수 있다는 장점이 있다. 아래 상속 예시를 보자.

 

 

아이템이라는 부모 객체로부터 각각 앨범, 영화, 책이라는 자식 객체가 나왔다. 이들은 부모 객체의 필드값인 id, 이름과 가격을 물려받았으며, 그 외에 자기만의 필드값을 갖고 있다. 이놈들을 관계형 DB에서 관리하려면 테이블을 어떻게 설계하면 좋을까? 앨범, 영화, 책 모두 기본적으로 Item의 자식으로서 id, 이름, 가격을 갖고 있어야 할텐데 이에 대한 정보는 ITEM 테이블에 들어갈 것이다. 따라서 각 자식 테이블에는 그 외의 각자 필드값에 대한 데이터를 저장할 수 있는 컬럼이 추가되어야 할 것이며, item과 각 자식 테이블을 잇기 위한 id값을 PK이자 FK로 가져갈 것이다.

이때 Album 테이블에 Album 객체의 데이터를 저장한다고 해보자. 어떤 과정을 거쳐야 할까?

public class Album {
        private Long id;
        private String name;
        private int price;
        private String artist;
}
  • 위의 Album 객체에 들어있는 상태값을 모두 꺼낸다.
Long id = Album.getId();
String name = Album.getName();
int price = Album.getPrice();
String artist = Album.getArtist();
  • ITEM 테이블에 대한 Insert 문을 작성한다.
INSERT INTO ITEM(id, name, price) VALUE;
  • 뒤이어 ALBUM 테이블에 저장하기 위한 INSERT 문을 작성한다.
INSERT INTO ALBUM(id, artist) VALUE;

벌써 지치지 않나…여기서 Album 객체를 조회하려면 어떨까?

  • 각각 테이블에 따른 JOIN 문 작성
  • 각각 객체(Item, Album) 생성

노답이다.. 그래서 DB에 저장할 객체에는 상속 관계를 쓰지 않는다.

그런데 만약 이를 자바 컬렉션(ex. 리스트)에 저장한다고 생각해보자.

List<Album> albumList = new ArrayList<>();
albumList.add(album);

개 간단한데…?

이번에는 리스트에서 조회해보자.

Album album = albumList.get(albumId);

위의 SQL 문을 작성하는 방법에 비해 훨씬 편하다는 걸 알 수 있다.

2. 연관관계

 

이번에는 객체 간 연관관계를 객체와 테이블이 각각 어떻게 연결하는지 살펴보자.

  • 객체의 경우 참조를 사용한다.

 

위 이미지와 같이 Member 객체 내에 필드값으로 아예 Team 객체 자체가 들어있다. 물론 이 Team 객체는 실제로 Member 객체 내부에 있는 게 아니라 다른 곳에 저장되어 있으며 Member 객체 내부에는 해당 Team 객체의 위치를 참조할 수 있는 값이 들어있다. 이 참조를 통해 Team 객체를 불러온다(member.getTeam()).

  • 테이블 간 연관관계는 외래 키를 사용한다.

 

반면 테이블에서는 외래 키(FK)를 사용한다. 해당 데이터는 JOIN 문을 이용해 가져온다.(JOIN ON M.TEAM_ID = T.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 컬럼에 사용
}

 

원래 Member에서는 Team 객체가 필드로 들어갔던 것과 달리, SQL 중심의 객체에서는 Long 타입의 team ID가 들어갔다. Team 객체로 저장한다 한들, 결국 테이블 관점에서는 FK인 TEAM_ID가 필요하기 때문에 애초부터 필드값으로 ID를 담는 게 편리하기 때문이다. 하지만 이는 객체지향적인 모델링이라고 할 수 없다. 그렇다고 객체지향적인 모델링으로 할 경우 SQL문을 날리기 더 까다로워지기 때문에 위의 방안이 그나마 절충안인 셈.. 이것이 SQL 의존적인 개발로 인한 객체지향 패러다임의 파괴 현상이다.

3. 엔티티 신뢰 문제로 인한 계층 분할의 어려움

 

우리가 앞서 만든 Member 엔티티에 대해 서비스 레이어에서 특정 메소드를 작성했다.

class MemberService {
        ...
        public void process() {
                Member member = memberDAO.find(memberId);
                member.getTeam();
                member.getOrder().getDelivery();
        }
}

위에서 member 객체는 DAO 계층으로부터 가져왔다. 그런데 앞서 살펴본 것처럼 Member 클래스에는 Team 객체라던지 Order, Delivery 객체가 들어간 게 아니라 그것들의 필드값이 들어가있었다. 따라서 getter 메소드를 호출한다고 하더라도 실제로 Team 객체가 잘 불러들어왔는지, Delivery 객체를 잘 가져왔는지 확신할 수 없다. 이것이 엔티티 내 필드 값의 신뢰 문제이다.

 

우리가 컨트롤러-서비스-엔티티-리포지토리와 같이 계층을 분할하는 것은 책임 주도 설계를 위함인데, 정작 각 레이어 사이를 왔다갔다하는 엔티티가 해당 책임을 제대로 지고 있는지 확신할 수 없으니 이렇게 계층을 분할하기가 어려운 것.

4. 비교하기 - 동일 객체임에도 다르게 인식

특정 id를 가진 Member 객체를 각각 다른 변수명을 갖는 객체에 대입해보자.

String memberId = "100";
Member member1 = memberDAO.getMember(memberId);
Member member2 = memberDAO.getMember(memberId);

 

member1과 member2는 동일한 id를 갖고 있으니 같은 객체로 취급하는 게 맞다. 하지만 실질적으로 member1 == member2false이다. 아래 getter 메소드를 살펴보면 왜인지 알 수 있다.

 

class MemberDAO {
        public Member getMember(String memberId) {
                String sql = "SELECT * FROM MEMBER WHERE MEMBER_ID = ?";
                ...
                //JDBC API, SQL 실행
                return new Member(...);

 

마지막 return 문을 보면 new 연산자를 이용해 새로운 Member 객체를 생성하고 그 안에 데이터를 집어넣는 것을 볼 수 있다. 즉, member1과 member2는 안에 들어있는 값은 같지만 참조변수의 주소가 다르기 때문에(new로 생성했으니) 다른 객체라고 인식할 수밖에 없는 것. 이 역시 문제다.

JPA가 왜 나왔는지 알겠지? SQL 중심적인 개발에서 객체 중심 개발로 전환

 

앞서 살펴본 것과 같이, 객체답게 모델링하면 할수록 중간에서 매핑 작업만 늘어난다. 개고생하는 건 결국 코드를 작성하는 개발자의 몫인 셈..여기서 만약 객체를 자바 컬렉션에 저장하듯이 DB에 저장한다면 얼마나 편리할까? 이것이 JPA의 탄생 배경이다.

JPA는 인터페이스의 모음으로, 이를 구현하는 구현체는 따로 존재한다. 이중 가장 많이 쓰이는 구현체가 바로 hibernate.

그러면 이제 JPA를 사용하는 게 왜 좋은지 살펴보자.

 

생산성

 

JPA를 이용하면 객체 중심으로 CRUD를 구현할 수 있다. 아래 각 한 줄만으로 CRUD를 구현 가능하다.

  • 저장: jpa.persist(member)
  • 조회: Member member = jpa.find(memberId)
  • 수정: member.setName(”변경할 이름”)
  • 삭제: jpa.remove(member)

유지보수

 

앞서 특정 엔티티를 SQL문으로 변환하는 과정에서 필드값이 추가될 경우, 모든 CRUD SQL문에 해당 필드에 대한 데이터를 추가해줘야 했다. 이제는 그럴 필요가 없다. 우리는 위에서 보는 것처럼 객체를 중심으로 CRUD를 진행하기 때문에 해당 객체 내 필드가 추가되든 말든 객체만 신경쓰면 된다.

객체 - 테이블 간 패러다임 불일치 해결

 

앞서 살펴봤던 <객체와 관계형 DB의 차이>에서 다뤘던 패러다임 불일치 문제를 JPA가 어떻게 해결했는지 살펴보자.

1. JPA와 상속

 

앞서 살펴봤던 Item - Album 간 상속 관계가 맺어졌을 때 이를 어떻게 저장했는지 리마인드해보자. 아래와 같이 부모 - 자식 각각 테이블에 대해 insert 문을 날려야 했다.

  • 위의 Album 객체에 들어있는 상태값을 모두 꺼낸다.
  • ITEM 테이블에 대한 Insert 문을 작성한다.
  • 뒤이어 ALBUM 테이블에 저장하기 위한 INSERT 문을 작성한다.

JPA를 사용하면 어떨까? 개발자가 할 일은 위에서 언급했던 것과 같다.

jpa.persist(album);

이 한 줄만 적으면 나머지 두 번의 INSERT문은 JPA가 알아서 처리해준다..말도 안되게 편리해졌다.

 

조회 역시 마찬가지다. 앨범 객체를 조회하려면 ITEM과 ALBUM을 JOIN으로 묶은 뒤 각 객체를 가져와야 했는데 JPA는 아래 한 줄로 끝난다.

Album album = jpa.find(Album.class, albumId);

2. 연관관계 저장 & 객체 그래프 탐색

연관관계를 저장할 때는 어떨까? 이 역시 객체 중심적으로 가능하다. setter를 통해 연관 객체를 설정한 뒤, 저장하면 다 알아서 해준다.

// 연관관계 저장
member.setTeam(team);
jpa.persist(member);

객체 그래프를 탐색해야 할 때는 어떨까? 그저 getter 메소드면 끝난다.

Member 객체로부터 Team 객체를 조회한다고 해보자. 아래 두 줄이면 끝난다.

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

3. 신뢰할 수 있는 엔티티 & 계층

이렇게 되면 엔티티 내 필드를 믿을 수 있게 되고 계층 간 분리가 명확해진다. 특정 객체의 id로 호출하는 게 아니라 엔티티 내에 명시되어 있는 객체를 기준으로 호출하기 때문이다.

class MemberService {
        ...
        public void process() {
                Member member = memberDAO.find(memberId);
                member.getTeam(); //자유로운 객체 그래프 탐색
                member.getOrder().getDelivery();
        }
}

4. JPA와 비교하기

앞서 SQL 중심의 경우, 동일한 id에 대해 두 객체를 호출했을 경우 사실상 같은 객체임에도 서로 다른 객체로 인식했다. JPA는 그렇지 않다. 동일한 트랜잭션 내에서(이게 중요하다!)라면 같은 id를 기반으로 몇 번을 조회하든 그 엔티티는 모두 같음을 보장해준다.

String memberId = "100";
Member member1 = jpa.find(Member.class, memberId);
Member member2 = jpa.find(Member.class, memberId);

JPA의 성능 최적화 기능

그밖에도 JPA는 성능 최적화를 위해 아래와 같은 기능을 제공한다.

1차 캐시와 동일성 보장

위에서 언급했듯, 동일한 id에 대해 같은 트랜잭션 안에서 엔티티를 여러 번 조회하더라도 항상 같은 엔티티를 반환한다. 따라서 동일한 엔티티를 2번 호출해야 하는 상황이라면 2번의 select문을 날릴 필요가 없어 조회 성능이 향상된다. 즉, 하나의 엔티티를 조회할 때는 몇번을 조회하더라도 항상 SQL 쿼리를 한 번만 날린다는 것.

트랜잭션 지원하는 쓰기 지연

JPA는 트랜잭션을 커밋하기 전까지 SQL 쿼리를 날리지 않고 마치 버퍼처럼 계속해서 모아둔다. 그러다 커밋을 호출하면 그제야 쿼리를 날리는데, 이렇게 하면 여러 번의 쿼리를 날리지 않고 한 번만 쿼리를 날리니 네트워크 통신 비용이 낮아진다.

transaction.begin(); // [트랜잭션] 시작

em.persist(memberA);
em.persist(memberB);
em.persist(memberC);//여기까지 INSERT SQL을 데이터베이스에 보내지 않는다.

//커밋하는 순간 데이터베이스에 INSERT SQL을 모아서 보낸다.
transaction.commit(); // [트랜잭션] 커밋

지연 로딩(Lazy loading)

지연 로딩은 객체가 실제로 사용될 때 해당 객체를 불러오는 것을 말한다. 반대인 즉시 로딩(Eager loading)은 JOIN SQL로 호출할 때 한 번에 연관 객체까지 싹다 미리 조회하는 것을 말한다. 이렇게 되면 JOIN을 사용함에 따른 성능 저하를 피할 수 없다. 이는 특히나 엔티티간 연관관계가 복잡하면 복잡할수록 성능 저하가 심해진다.

 

반면, 지연 로딩을 활용하면 해당 객체를 사용하는 시점에 그 해당 객체에 대해서만 select문을 날리기 때문에 간결한 select 쿼리를 날릴 수 있어 상대적으로 좋다.

마무리: ORM을 잘 다루려면?

이제 JPA라는 무기가 있으니 관계형 DB는 관심 끄고 객체지향만 잘 공부하면 될 것 같지만..실은 그렇지 않다. JPA는 ORM이다. 객체와 관계형 DB를 매핑하는 매퍼다. 따라서 이를 잘 다루려면 객체 뿐만 아니라 RDB 모두 잘 알아야 한다는 것을 상기하자.

반응형