Develop/Spring

ORM은 왜 생겨났는가? JDBC부터 ORM까지 여정

mabubsoragodong 2025. 3. 11. 22:17

ORM이 생겨나게되기까지의 이야기를 정리해보자.

 

Database Driver

먼저 데이터베이스 드라이버에 대한 이야기부터 해야한다.

데이터베이스 드라이버란 DB와 어플리케이션 간 통신을 중걔하는 것이다. 어플리케이션의 요청을 DB가 이해할 수 있는 언어로 변환해준다.

다양한 DB가 있고 DB마다 호환되는 드라이버가 따로 있다. 

 

그럼 드라이버는 어떻게 동작되나?

 

JDBC Manager가 런타임 시점에 

1. getConnection() -> 드라이버에 연결 요청

2. createStatement() -> 어플리케이션의 요청을 SQL 명령으로 변환해서 statement를 생성

3. executeQuery() -> DB가 쿼리를 처리하고 ResultSet을 만들어서 드라이버에게 넘겨줌

4. close() -> 연결을 종료하고 자원을 정리

JDBC

JDBC Driver할때 봤던 JDBC다. 그래서 JDBC가 뭔데?

Java Database Connectivity로 어플리케이션과 DB를 연결하는 기능을 제공하는 라이브러리(API)다. JDBC Driver는 이 API를 구현해서 사용한다.  

Spring Boot는 데이터베이스 연결을 쉽게 구성할 수 있도록 다양한 JDBC 드라이버를 지원한다. 

 

JDBC를 사용하는 방법은 의존성을 build.gradle 파일에 추가해주면 된다.

spring-boot-starter-jdbc

 

JDBC의 주요기능은 다음과 같다.

JDBC API 지원 -> API를 통해 DB에 접근하고 작업을 가능하게 해준다.

DataSource 구성 -> 데이터소스 연결을 위한 설정을 자동으로 구성해준다.

JdbcTemplate -> JDBC 작업을 편리화해준다.(쿼리실행, 결과 세트처리 등등)

 

// JdbcApplication.java

package com.thesun4sky.jdbc;

import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.SQLException;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class JdbcApplication {

	public static void main(String[] args) throws SQLException {
		// 어플리케이션 실행 컨텍스트 생성
		SpringApplication.run(JdbcApplication.class, args);

		// 데이터베이스 연결정보
		String url = "jdbc:h2:mem:test"; 	// spring.datasource.url
		String username = "sa";				// spring.datasource.username

		// connection 얻어오기
		try (Connection connection = DriverManager.getConnection(url, username, null)) {
			try {
				// 테이블 생성 (statement 생성)
				String creatSql = "CREATE TABLE USERS (id SERIAL, username varchar(255))";
				try (PreparedStatement statement = connection.prepareStatement(creatSql)) {
					statement.execute();
				}

				// 데이터 추가 (statement 생성)
				String insertSql = "INSERT INTO USERS (username) VALUES ('teasun kim')";
				try (PreparedStatement statement = connection.prepareStatement(insertSql)) {
					statement.execute();
				}

				// 데이터 조회 (statement 생성 후 rs = resultSet 수신 & next() 조회)
				String selectSql = "SELECT * FROM USERS";
				try (PreparedStatement statement = connection.prepareStatement(selectSql)) {
					var rs = statement.executeQuery();
					while (rs.next()) {
						System.out.printf("%d, %s", rs.getInt("id"), rs.getString("username"));
					}
				}
			} catch (SQLException e) {
				if (e.getMessage().equals("ERROR: relation \"account\" already exists")) {
					System.out.println("USERS 테이블이 이미 존재합니다.");
				} else {
					throw new RuntimeException();
				}
			}
		}
	}
}

 

⭐막간 정보 

JDBC를 이용해서 작업할 때 Statement와 PreparedStatement의 차이

일반적인 SQL 실행과정 (Statement를 사용하는 경우다.)

1. 구문분석(parse) : 문법이 올바른가? 검사

2. 치환(bind) : 입력값을 SQL에 삽입

3. 실행(execute) : SQL 실행

4. 인출(fetch) : 결과 가져오기

 

그러나 PreparedStatement의 경우 

"SELECT name, age FROM TABLE WHERE userId = ?"라는 쿼리라면 분석 후 치환, 실행, 인출을 수행한다. 그 후 두번째 요청부터는 분석단계를 캐싱(자주사용하는 것을 임시저장한 후 거기서 가져오기)하고 생략한다. 그리고 ?에 들어갈 데이터만 새로운 데이터로 바꿔서 수행한다. 

 

따라서 더 빨리 실행된다는 성능적인 장점이 있고 SQL injection을 방지할 수 있다. 

JDBC Template

JDBC로 쿼리를 작성하다보니 문제가 발생한다.

1. 중복되는 SQL코드가 발생한다.

2. DB별 예외에 대한 구분 없이 SQL Exception 처리

3. Connection, Statement 등 자원관리가 필요해진다. 안 해주면 메모리가 터진다.

 

이 문제를 해결하기 위해 Persistance Framework가 등장했다!

 

1. SQL Mapper(먼저 등장): JDBC Template, MyBatis

2. ORM : JPA, Hibernate

SQL Mapper? (=Query Mapper)

SQL문과 객체(Object)의 필드를 매핑해 데이터를 객체화 해주는 것이다. 

 

- Row Mapper

SQL Mapper의 첫번째 주자로 JDBC Template에 RowMapper가 탄생했다.

Row Mapper는

1. 쿼리 수행 결과와 객체의 필드를 매핑한다.

2. Row Mapper로 응답필드를 매핑하는 코드를 재사용 가능하게 한다.

3. Connection, Statement, Resultset의 반복적인 처리를 대신해준다.

 

그럼 Row Mapper 어떻게 사용하는데?

Row Mapper를 상속받아 mapRow() 메서드를 구현해서 사용한다.

// UserRowMapper.java
import java.sql.ResultSet;
import java.sql.SQLException;

import org.springframework.jdbc.core.RowMapper;


public class UserRowMapper implements RowMapper<User> {

	// JDBCTemplate 에서 row 응답을 mapRow() 메서드에 rs 파라미터로 넘겨주어 객체에 매핑하기 쉽도록 도와준다.
  @Override
  public User mapRow(ResultSet rs, int rowNum) throws SQLException {
    var user = new User();
    user.setId(rs.getInt("ID"));
    user.setName(rs.getString("NAME"));
    return user;
  }
}

 

DB에서 넘어온 row 응답을 rs 파라미터로 받아서 객체와 매핑한다. 

 

// 사용자 ID로 User 조회 (Read)
public User findUserById(Long id) {
	return jdbcTemplate.queryForObject(
		"SELECT * FROM users WHERE id = ?",
		new UserRowMapper(),  // 이자리에 매퍼를 생성자로 넣어주면 됨
		id
	);
}

 

jdbcTemplate.queryForObject() 메서드에 2번째 인자로 RowMapper를 넣어주면( new UserRowMapper()에 해당) 해당 RowMapper의 mapRow() 메서드를 사용해 응답하도록 동작한다.

 

그러나! 아직도 결과값을 객체와 매핑하는데 많은 코드가 필요하고 중복된다.

 

MyBatis

SQL Mapper의 두번째 주자로써 RowMapper가 가지고 있는 단점인 반복되는 코드를 줄이고 함께 있는 프로그램코드와 쿼리코드를 분리하여 관리하게 해준다.

 

따라서 MyBatis는 반복적인 JDBC 프로그래밍을 단순화하고 SQL 쿼리들을 XML 파일에 작성해 코드와 SQL을 분리한다.

 

- MyBatis의 동작

 

(1) ~ (3)은 응용 프로그램 시작시 수행되는 프로세스입니다.

(1) 응용 프로그램이 SqlSessionFactoryBuilder를 위해 SqlSessionFactory를 빌드하도록 요청합니다.

(2) SqlSessionFactoryBuilder는 SqlSessionFactory를 생성하기 위한 Mybatis 구성 파일을 읽습니다.

(3) SqlSessionFactoryBuilder는 Mybatis 구성 파일의 정의에 따라 SqlSessionFactory를 생성합니다.

(4) ~ (10)은 클라이언트의 각 요청에 대해 수행되는 프로세스입니다.

(4) 클라이언트가 응용 프로그램에 대한 프로세스를 요청합니다.

(5) 응용 프로그램은 SqlSessionFactoryBuilder를 사용하여 빌드된 SqlSessionFactory에서 SqlSession을 가져옵니다.

(6) SqlSessionFactory는 SqlSession을 생성하고 이를 애플리케이션에 반환합니다.

(7) 응용 프로그램이 SqlSession에서 매퍼 인터페이스의 구현 개체를 가져옵니다.

(8) 응용 프로그램이 매퍼 인터페이스 메서드를 호출합니다.

(9) 매퍼 인터페이스의 구현 개체가 SqlSession 메서드를 호출하고 SQL 실행을 요청합니다.

(10) SqlSession은 매핑 파일에서 실행할 SQL을 가져와 SQL을 실행합니다.

 

SqlSession Factory Builder (1), (2), (3)

  • MyBatis 설정 파일을 읽어와서
  • 설정정보 기반으로 SqlSession Factory 를 생성하는 빌더 객체

MyBatis Config File (2)

  • 매핑해줄 객체가 들어있는 패키지 경로와
  • Mapping File 목록을 지정해주는 설정 파일
<!-- /resources/mybatis-config.xml -->
<!DOCTYPE configuration
        PUBLIC "-//mybatis.org//DTD Config 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-config.dtd">
<configuration>
    <typeAliases>
        <package name="com.thesun4sky.querymapper.domain"/>
    </typeAliases>
    <mappers>
        <mapper resource="mapper/UserMapper.xml"/>
    </mappers>
</configuration>

 

SqlSession Factory (3), (5), (6)

  • 설정에 맞게 SqlSession 을 생성하여 들고있는 객체

SqlSession (6), (7), (9), (10)

  • Mapping File 에서 쿼리를 조회해서 쿼리를 수행하고 응답을 받아올 수 있는 세션 객체

Mapper Interface (8), (9)

  • DB 에서 조회하는 객체와 Java 프로그램의 객체간에 인터페이스를 정의하는 객체
  • 방법1. Dao 클래스 정의
    • SqlSession 를 직접적으로 사용하는 방법
    • SqlSession 멤버 변수로 사용하며 쿼리파일 수행 요청
// UserDao.java
import org.apache.ibatis.session.SqlSession;
import org.springframework.stereotype.Component;

import com.thesun4sky.querymapper.domain.User;

@Component
public class UserDao {

  // SqlSession 멤버 변수로 사용하며 쿼리파일 수행 요청
  private final SqlSession sqlSession;

  public UserDao(SqlSession sqlSession) {
    this.sqlSession = sqlSession;
  }

  public User selectUserById(long id) {
    return this.sqlSession.selectOne("selectUserById", id);
  }

}

 

  • 장점
    • 쿼리문 실행 전에 넣어줄 매개변수와 쿼리 결과값의 변형을 정의할 수 있다.
    • Namespace를 내 마음대로 둘 수 있다.
    • .xml 파일의 쿼리문 id와 mapper 메소드명을 일치시킬 필요가 없다.
  • 단점
    • Sqlsession 객체를 주입받아야 하며, 쿼리문 실행 시 항상 호출해야 한다.
    • 쿼리문 호출 시 sqlsession에 .xml 파일의 namespce와 쿼리문 id를 매개변수로 넘겨야한다.
  • 방법2. Mapper Interface 정의
    • SqlSession 를 간접적으로 사용하는 방법
    • ibatis 에서 구현해주는 org.apache.ibatis.annotations.Mapper 어노테이션을 사용하면 sqlSession 를 사용하여 자동으로 호출해줌
// UserMapper.java
@Mapper
public interface UserMapper {

  User selectUserById(@Param("id") Long id);

}
  • 장점
    • 메소드의 내부 구현이 불필요하다.
    • Sqlsession 객체 주입이 불펼요하다.
    • .xml 파일의 쿼리문 id와 mapper 메소드 명이 일치한다.
  • 단점
    • .xml의 Namespace가 실제 Mapper.java 위치를 가르켜야 한다.
    • 메소드 내부 정의가 불가능하다.

Mapping File (10)

  • SqlSession 가 실행하는 쿼리가 담긴 파일
  • 정의된 인터페이스에 기반해서 수행할 쿼리를 담아두고
  • 쿼리 수행결과를 어떤 인터페이스 매핑할지 정의해놓은 파일
<!-- UserMapper.xml -->
<!DOCTYPE mapper
        PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.thesun4sky.querymapper.mapper.UserMapper">
    <select id="selectUserById" resultType="User">
        select id, name from users where id = #{id}
    </select>
</mapper>

 

그러나 어차피 결국 SQL을 직접 작성해야하고 테이블마다 비슷한 CRUD를 반복, DB 및 테이블에 종속적이다.

 

따라서 ORM이 탄생하게 되었다.

 

ORM

ORM은 DAO, Mapper를 통해서 조작이 아니라 테이블을 아예 하나의 객체와 대응한다. 

따라서 객체와 테이블 간의 매핑을 자동화하고, 상태 동기화, 쿼리 추상화 등 다양한 기능을 제공함으로써 개발 생산성을 높이고 유지보수를 용이하게 만든다. (Entity와 테이블 매핑, SQL 생성 등)

 

JPA가 ORM의 표준을 정의하고 인터페이스라면 Hibernate는 JPA의 구현체이고 JPA의 기능을 실체구현한 것이다.

 

하지만 ORM에도 몇몇 문제점이 있지만 그에 따른 해결책도 있다.

1. 상속 문제

객체는 객체 간 상속관계를 맺을 수 있다. 하지만 테이블은 상속의 개념이 존재하지 않는다. 

-> 연관관계를 이용하자. @OneToMany, @ManyToOne을 이용해서 제약조건을 걸어주자.

 

2. 관계 문제

객체는 참조를 통해 관계를 가지며 방향을 가진다. 그러나 테이블은 외래키를 설정해주고 JOIN으로 조회 시에 참조가 가능하다. 

-> 연관관계 매핑에 뱡향정보를 넣어주자. @JoinColumn, @MappedBy를 이용하자.

 

3. 탐색 문제

객체는 참조를 통해 다른 객체도 탐색 가능하며 콜렉션 순회도 가능하다. 그러나 테이블은 탐색 시 참조하는 만큼 추가 쿼리나 JOIN이 발생해 비효율적이다. 

-> 참조 탐색 시점을 관리하자. @FetchType, fetchJoin()

 

4. 밀도 문제

객체는 크기가 매우 클 수 있다. 그러나 테이블은 기본 데이터 타입만 존재한다. 

-> 크기가 큰 객체는 테이블을 분리해 상속으로 처리하자. @embedded

 

5. 식별성 문제

객체는 객체의 hashCode 혹은 정의한 equals()를 통해 식별하나 테이블에서는 PK로만 식별할 수 있다.

-> PK를 객체 id로 설정하고 EntityManager는 해당 값으로 객체를 식별해 관리하자. @Id, @Generated

 

영속성

영속성이란 데이터를 생성한 프로그램이 종료되어도 사라지지 않는 데이터의 특성을 말한다.

영속성이 없으면? 메모리에서만 존재한다. 

그래서 DB에 저장함으로써 영속성을 부여하는 것이다.

 

영속성 상태

new로 객체 생성 -> 비영속 상태 -> persist(), merge() -> 영속성 컨텍스트에 저장된 상태, 영속상태 -> flush() -> DB에 쿼리가 전송된 상태 -> commit() -> DB에 쿼리가 반영된 상태

 

영속성 컨텍스트란 JPA에서 객체를 저장하고 관리하는 공간을 말한다. DB에 저장하는 것이 아니고 JPA가 DB에 있는 데이터를 메모리에서 관리할 수 있도록 해주는 공간이다. (DB와 메모리 사이)

JPA가 단순히 DB에 저장만 하는 것이 아니라 한번 가져온 데이터를 영속성 컨텍스트에 보관한다.

따라서 같은 데이터를 여러번 조회하면 DB에 다시 접근하지 않고 영속성 컨텍스트에서 응답이 가능하도록 최적화한다. 

또한 Dirty Checking을 통해 flush 전 스냅샷과 현상태를 비교한다.

그리고 UPDATE 쿼리를 쓰기지연 저장소에 추가해준다. 그래서 따로 update를 위한 repository 메서드를 만드는게 아닌 객체 상태만 변경해주면 자동으로 DB에 반영된다. 

 

JPA

그럼 ORM의 JPA를 어떻게 사용하는데?

JpaRepository<Entity, Id>를 상속받은 인터페이스를 구현하고 사용한다.

 

💫막간 유의 사항

JPA로 Enum 타입을 매핑 시 유의사항 

-> @Enumerated(EnumType.STRING)을 사용해 String 타입으로 저장되도록 해주자.

 

@OneToOne 사용 시 유의 사항

-> 꼭 물리적으로 테이블이 분리되어서 이 제약조건을 사용해야 하는지 고민해보자.

 

@OneToMany 사용 시 유의사항

-> 꼭 양방향으로 설정해주자. (다른 쪽은 @ManyToOne을 걸어주라는 소리다.) 그리고 mappedBy로 연관관계의 주인도 설정해주어야 한다.

 

@ManyToMany 사용 시 주의사항

-> 웬만하면 사용하지 말고 따로 매핑테이블을 생성해서 @ManyToOne으로 관리해주자.

 

 

이렇게 JDBC로 DB와 소통하는 것 부터 ORM의 JPA를 이용하는 것 까지의 역사를 살펴보았다.

그동안 JPA 쓰니 편해서 그냥 이거 쓰면 되는 거 아니야? 하면서 막 썼는데 왜 ORM이 생겨났는지, 그 전에는 어떻게 통신했는지, 어떤 불편한 점이 있는지 알게되었다.