티스토리 뷰

객체지향 프로그래밍은 추상화, 캡슐화, 상속, 다형성 등 복잡성을 제어하기 위한 다양한 방법을 제공합니다. 그래서 대부분 큰 시스템을 구축한 곳에서는 객체지향 언어를 채택하여 개발하고 있습니다.

 

애플리케이션에서 사용하는 도메인 모델을 객체로 정의하게 되면 위와 같은 객체지향 프로그래밍의 장점을 활용할 수 있습니다. 객체를 정의하여 인스턴스화 하더라도 이는 메모리에 상주하기 때문에 애플리케이션이 종료하는 시점에 같이 사라지게 됩니다. 따라서 이 메모리에 상주시킬 데이터를 영구적으로 보관하기 위한 장소가 필요합니다.

 

객체는 속성과 기능을 가지는데 기능은 이미 클래스에 정의되어 있으므로 속성만 영구적인 보관 장소에서 불러와 인스턴스화 시키면 속성과 기능을 모두 사용할 수 있는 상태가 됩니다. 이때 영구적인 보관장소로 가장 많이 사용하는 것이 데이터베이스나 파일입니다. 객체의 모든 속성에 대해 데이터베이스나 파일에 단순하게 저장할 수 있다면 모든 게 완벽하겠죠? 저장했다가 불러와서 필요한 기능을 사용하면 되기 때문입니다. 하지만 객체는 그렇게 호락호락하지 않습니다. 맨 위에서 말하고 있는 객체지향 프로그래밍의 특징 중 일부만 포함시켰다 하더라도 단순하게 저장시키는 개념으로는 이 문제를 해결하기 어렵습니다.

 

예를 들어, A라는 객체가 B라는 객체를 필드 변수로 가지고 있다고 하면 A 객체를 저장할 때 B 객체도 같이 저장해줘야 합니다. B 객체의 레퍼런스 주소만 저장했다고 한다면 A 객체를 불러왔을 때 B 객체가 가지는 속성에 대해서는 정확히 인지할 수 없기 때문입니다.

 

자바에서는 이러한 문제를 해결하기위해 직렬화(serialization)와 역직렬화(deserialization)를 제공합니다. 직렬화는 객체를 파일로, 역직렬화는 그 반대로 변환할 수 있게 해주는 기능입니다. 객체를 직렬화 시켜서 파일로 저장한 다음 그 파일을 열어보신 분들은 아마 잘 알고 계실 것입니다. 바이트코드로 작성되어있기 때문에 파일 내에서 무엇을 찾기가 불가능하고 이를 활용하기 위해선 다시 애플리케이션에서 역직렬 화하여 메모리에 상주시킨 뒤에 비교 작업 등을 진행할 수 있습니다.

 

그렇다면 파일 대신 데이터베이스에 객체를 통째로 저장한다면 어떻게 될까요? 관계형 데이터베이스가 아닌 NoSQL 데이터베이스에서는 이러한 기능을 제공합니다만 결국은 메모리 기반이기 때문에 많고 오랫동안 유지해야하는 데이터를 보관하기엔 무리가 있습니다. 반면에 관계형 데이터베이스는 데이터 중심으로 구조화되어 있고 추상화, 상속, 다형성 같은 개념이 존재하지 않습니다.

 

따라서 객체와 데이터베이스는 서로 목적이 다르고 표현하는 방법이나 사용하는 방법 또한 상이합니다. 이는 곧 객체 구조를 데이터베이스의 테이블에 정확히 저장하는 것은 불가능하다는 것을 의미합니다.

 

아래 자세한 예를 통해 살펴보겠습니다.

1. 상속

객체의 상속 관계

객체는 위의 그림처럼 상속 관계를 가질 수 있습니다. 하지만 데이터베이스 테이블간에는 상속 관계가 존재하지 않습니다. 슈퍼 타입, 서브타입을 지정하여 비슷한 흉내는 낼 수 있지만 이 또한 완벽하진 않습니다.

package io.lcalmsky.jpa.background.paradigm.domain;

import lombok.Data;

@Data
public class Content {
    private Long id;
    private String name;
    private Integer price;
}
package io.lcalmsky.jpa.background.paradigm.domain;

import lombok.Data;
import lombok.EqualsAndHashCode;

@Data
@EqualsAndHashCode(callSuper = true)
public class Book extends Content {
    private String author;
    private String publisher;
}
package io.lcalmsky.jpa.background.paradigm.domain;

import lombok.Data;
import lombok.EqualsAndHashCode;

import java.util.List;

@Data
@EqualsAndHashCode(callSuper = true)
public class Movie extends Content {
    private String director;
    private List<String> actors;
}
package io.lcalmsky.jpa.background.paradigm.domain;

import lombok.Data;
import lombok.EqualsAndHashCode;

@Data
@EqualsAndHashCode(callSuper = true)
public class Music extends Content {
    private String singer;
    private String composer;
}

위의 그림을 코드로 표현한 것 입니다.

Book, Movie, Music과 같은 하위 클래스들을 데이터베이스에 저장하기 위해선 두 가지 SQL문이 필요합니다.

insert into book values(...)
insert into content values(...)

JDBC API를 이용했을 경우 반드시 위와 같이 두 번의 연동이 필요하고 이를 직접 호출해줘야 합니다.

조회 또한 JOIN을 사용해야 하고 사용자가 직접 해당하는 클래스에 매핑시켜줘야 합니다.

 

앞선 포스팅에서의 비유처럼 자바 컬렉션을 사용했다면 어떻게 될까요?

package io.lcalmskyl.jpa.background.paradigm.domain;

import io.lcalmsky.jpa.background.paradigm.domain.Book;
import io.lcalmsky.jpa.background.paradigm.domain.Content;
import io.lcalmsky.jpa.background.paradigm.domain.Music;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

import java.util.ArrayList;
import java.util.List;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;

public class ContentCollectionTests {

    private static List<Content> contents;

    @BeforeAll
    public static void setup() {
        contents = new ArrayList<>();
        Book book = new Book();
        book.setId(1L);
        book.setAuthor("jaime");
        book.setPublisher("tistory");
        book.setName("jaime-note");
        book.setPrice(0);

        contents.add(book);
    }

    @Test
    @DisplayName("Content 컬렉션에 Music 객체 저장")
    public void givenContents_whenSaveIntoCollection_thenSaveSuccess() {
        Music anysong = new Music();
        anysong.setComposer("zico");
        anysong.setSinger("zico");
        anysong.setId(2L);
        anysong.setName("anysong");
        anysong.setPrice(500);
        contents.add(anysong);

        assertEquals(contents.size(), 2);
    }

    @Test
    @DisplayName("Content 컬렉션에서 Book 객체 찾기")
    public void givenCollection_whenRetrieveContents_thenFindSuccessfully() {
        assertTrue(contents.stream()
                .anyMatch(c -> c.getName().equals("jaime-note")));
    }
}

저장과 조회가 매우 간단히 해결됩니다.

JPA에서의 상속

JPA는 개발자 대신 상속에 관한 문제를 해결해줍니다. 위의 예제처럼 마치 컬렉션을 사용하듯이 객체를 조회하고 저장합니다.

jpa.persist(anysong);

이렇게만 소스 코드를 작성하여도 두 테이블에 두 개의 INSERT 문을 실행하여 저장합니다.

Book book = jpa.find(Book.class, bookId);

이렇게 조회하면 알아서 Content 속성에 해당하는 값 또한 JOIN 해서 결과를 변환합니다.

2. 참조와 의존 관계

객체는 참조를 사용하여 다른 객체와 의존성을 가집니다. 반면에 데이터베이스는 외래 키(FK)를 사용하여 JOIN을 통해 연관된 테이블을 조회합니다.

객체 간 의존 관계

객체는 참조를 가지는 쪽이 참조하는 쪽으로 단방향으로만 접근할 수 있는 반면에 데이터베이스에서는 FK를 이용해 역방향으로도 JOIN 할 수 있습니다.

테이블에 맞춰서 객체를 모델링하면 어떻게 될까요?

package io.lcalmsky.jpa.background.paradigm.domain.model;

import lombok.Data;

@Data
public class Player {
    private Long id;
    private String name;
    private Long teamId;
}
package io.lcalmsky.jpa.background.paradigm.domain.model;

import lombok.Data;

@Data
public class Team {
    private Long id;
    private String name;
    private String country;
}

이렇게 수정하고 나면 데이터베이스 테이블과 매팽시키기에는 매우 편리해집니다. 반면에 Player에서 teamId만 가지고는 Team을 참조할 수 없는 문제가 발생합니다.

 

이번엔 객체지향 적으로 모델링해보겠습니다.

package io.lcalmsky.jpa.background.paradigm.domain.model;

import lombok.Data;

@Data
public class Player {
    private Long id;
    private String name;
    private Team team;
}

이렇게 수정하고 나면 Player에서 Team을 참조하기가 매우 간편해집니다.

Team team = player.getTeam();

하지만 테이블에서 저장하거나 조회하기는 어려워집니다. 객체는 참조만 가지고 있으면 되지만 테이블끼리는 반드시 FK가 존재해야 하기 때문입니다. 이러한 문제를 해결하기 위해 중간에 열심히 변환하는 작업은 모두 개발자의 몫이겠죠.

JPA에서의 참조와 의존 관계

하지만 JPA는 기존의 자바 컬렉션을 사용하듯이 매우 간단하게 이런 문제를 해결해줍니다.

player.setTeam(team);
jpa.persist(player);
Player player = jpa.find(Player.class, playerId);
Team team = player.getTeam();

player가 team을 참조하도록 설정한 뒤 JPA를 이용해 저장하면 알아서 위의 모든 작업을 해줍니다. 참으로 고마운 존재가 아닐 수 없습니다.

 

사실 앞에서 설명했던 예제는 매우 매우 간단한 상황에 대한 것으로 실제 객체 간 의존 관계가 아래와 같이 얼마든지 복잡해질 수 있습니다.

아주 조금(?) 복잡한 객체 간 의존 관계

이런 관계에 대해 SQL로 직접 처리할 엄두가 나시나요? 캡션으로도 적어놨지만 아주 조금 복잡한 관계일 뿐입니다. 실제로는 몇 배는 더 복잡해 질 수 있죠.

 

JPA에서도 복잡한 것 아니냐고 궁금해하실 수 있습니다. 어쨌든 JOIN을 통해 조회해오는 것은 동일하다고 생각할 수 있으니까요. 기껏 여러 테이블을 조인해서 가져왔는데 막상 사용하지 않는다면 DB를 향한 트랜잭션을 낭비하는 상황이라고 생각할 수 있고 이는 성능에 치명적인 영향을 줄 것입니다.

 

하지만 똑똑한 JPA느님께서는 지연 로딩을 통해 이 문제를 해결해 주십니다. (짝짝짝)

Player player = jpa.find(Player.class, playerId);
Team team = player.getTeam(); // 이 시점에 추가로 SELECT
League league = team.getLeage(); // 이 시점에 추가로 SELECT

player의 이름만 가져오기 위해 player.getName()을 호출했을 경우엔 한 번의 SQL문밖에 실행되지 않습니다.

 

이처럼 객체와 데이터베이스의 모델링은 서로 비슷해 보이지만 다른 성질을 가지고 있고 이러한 차이를 극복하기 위해서는 개발자의 많은 수고(라고 쓰고 노가다 라고 읽는)가 필요합니다. 애플리케이션의 규모가 커질수록 더더욱 복잡해지기 때문에 결국은 테이블에 맞춰서 설계하면서 개발해 본 경험이 다들 있으실 겁니다. 특히 JDBC API를 사용하는 회사에서 일해 본 경험이 있으시다면요. 저는 이런 경험을 해봤고 이를 극복하기 위해 iBatis(당시에는 ganzi나는 기술이었음) 도입을 주장하였으나 외부 라이브러리를 추가하려면 '갑' 회사의 컨펌이 필요했기에 결국엔 JDBC API를 이용해 구현한 뒤 열심히 리팩터링 해가며 그나마 유지보수에 용이한 쪽으로 수정하는 수밖에 없었습니다.

 

하지만 JPA의 사용을 통해 위의 모든 문제점을 극복할 수 있습니다. 물론 JPA를 무턱대고 도입하면 안 되겠죠. 상황에 맞게 도입하려는 시도가 필요한 시점입니다.

 

도입하려면 팀장님도 설득하고 '갑' 회사도 설득하고.. 설득하려면 공부해야죠.

 

서론이 너무 길었습니다.

 

JPA가 무엇인지 본격적으로 다뤄보도록 하겠습니다.

 

본 포스팅은 자바 ORM 표준 JPA 프로그래밍을 참고(+ 내 지식, 내 뇌피셜, TMI)하여 작성하였습니다.

'JPA' 카테고리의 다른 글

스프링 데이터 JPA - 쿼리 메서드(Query Method)  (0) 2021.06.28
JPA란?  (0) 2020.04.03
SQL의 문제점  (0) 2020.04.01
JPA를 공부하는 이유  (2) 2020.03.31
기본 키(Primary Key) 매핑 전략  (0) 2019.10.01
댓글