스프링부트

JDBC와 MyBatis

masxer 2025. 7. 22. 11:12

데이터베이스 사용

JDBC는 자바 프로그램 내에서 데이터베이스와 연결하고 데이터베이스에 SQL 문을 수행한 후 그 결과를 받아 처리하기 위한 API 규격으로, 대부분의

데이터베이스 개발사에서 JDBC 드라이브를 제공한다. 따라서 JDBC API 규격을 사용해 개발한 자바 프로그램이라면 데이터베이스가 변경 되더라도 데이터베이스 연결을 위한 서버 주소와 계정 정보만 변경하면 되므로 호환성이 좋다

자바 애플리케이션 > JDBC API > JDBC 드라이버 > 데이터베이스

JDBC를 통해 데이터베이스에 있는 정보를 조회하려면 제일 먼저 각 데이터베이스 개발사에서 제공하는 JDBC 드라이버를 프로젝트에 포함시켜야 한다.

먼저 데이터베이스에서 가져온 정보를 담을 클래스를 생성한다.

public class Member {
    private Long id;
    private String name;
    private String email;
    private Integer age;

    public Member(Long id, String name, String email, Integer age) {
        this.id = id;
        this.name = name;
        this.email = email;
        this.age = age;
    }

    @Override
    public String toString() {
        return "Member{" +
                "id=" + id +
                ", name='" + name + '\'' +
                ", email='" + email + '\'' +
                ", age=" + age +
                '}';
    }
}

build.gradle에

implementation 'com.mysql:mysql-connector-j:9.2.0'

추가해야된다.

그 다음 데이터베이스를 연결해 데이터베이스에 저장된 내용을 가져온다.

package com;

import java.sql.*;

public class Main {
    public static void main(String[] args) throws ClassNotFoundException, SQLException {
        Class.forName("com.mysql.jdbc.Driver");
        Connection connection = DriverManager.getConnection("jdbc:mysql://localhost:3306/mydb", "myuser", "mypass");
        PreparedStatement preparedStatement = connection.prepareStatement("SELECT * FROM member");
        ResultSet resultSet = preparedStatement.executeQuery();
        while(resultSet.next()) {
            var user = new Member(
                    resultSet.getLong("id"),
                    resultSet.getString("name"),
                    resultSet.getString("email"),
                    resultSet.getInt("age")
            );
            System.out.println(user);
        }
        connection.close();
    }
}

JDBC URL은 'jdbc:데이터베이스 종류://주소'의 형식을 사용한다.

Spring Data JDBC

자바 코드로 직접 데이터베이스에 연결하고 SQL문을 수행한 결과를 객체의 프로퍼티로 매핑해 자바 객체로 만들 수 있는데 데이터베이스에 접속하고 필요한 SQL 문을 수행한 후 결과를 객체로 매핑하고 접속을 종료하는 일은 보일러플레이트 코드에 해당한다.

Spring Data JDBC는 이러한 보일러플레이트 코드를 없애고, 사용자가 오직 데이터베이스 연동 작업을 수행하고 그 결과를 처리하는 일에만 집중하 수 있도록 도와준다.

Spring Data JDBC는 '커넥션 풀(connection pool)'을 사용하기 때문에 더욱 효율적으로 커넥션을 관리할 수 있다.

직접 JDBC 드라이버를 로딩하고 데이터베이스에 접속했지만, Spring Data JDBC에서는 이 모든 작업을 Spring Data JDBC가 수행한다.

따라서 우리는 애플리케이션 설정 파일에서 접속할 데이터베이스 URL과 계정 정보만 설정하면 된다.

이러한 연결 정보는 데이터 소스라고 부른다. 스프링 부트 애플리케이션의 설정 파일은 생성한 프로젝트의 resources 폴더에 있는 application.properties 파일이다.

spring.application.name=jdbc

#data source
spring.datasource.url=jdbc:mysql://localhost:3306/mydb
spring.datasource.username=myuser
spring.datasource.password=mypass

#enable jdbc log
logging.level.org.springframework.jdbc=DEBUG

모델 객체 매핑

데이터베이스의 회원 테이블과 연동할 모델 객체를 작성한다. 회원 아이디와 이름, 이메일, 나이 프로퍼티를 갖는 객체를 작성하고 클래스 이름 위에 @Table 애노테이션을 사용하고 아이디 프로퍼티에 @Id 애노테이션을 사용함으로써 Spring Data JDBC에서 데이터베이스 테이블과 연동하는 객체임을 선언한다.

모델 객체를 생성할 때는 일반적으로 @Data, @Builder, @AllArgsConstructor, @NoArgsConstructor라는 4개의 애노테이션을 함께 사용하는 것이 편리하다.

package com.example.jdbc;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.data.annotation.Id;
import org.springframework.data.relational.core.mapping.Table;

@Table
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class Member {
    @Id
    private Long id;
    private String name;
    private String email;
    private Integer age;
}

@Table 애노테이션이 사용된 클래스와 데이터베이스 테이블을 매핑할 때의 클래스 이름은 테이블 이름으로 매핑하고 ,프로퍼티 이름은 컬럼 이름으로 매핑한다. 즉, Member는 MEMBER, name은 NAME으로 매핑되며, 만약 클래스 이름이나 프로퍼티 이름이 카멜 표기법으로 두 단어 이상 사용되었다면 VipMember는 VIP_MEMBER, displayName은 DISPLAY_NAME과 같이 '_' 기호를 사용해 테이블 이름과 컬럼 이름으로 매핑한다.

만약 직접 테이블 이름이나 컬럼 이름을 매핑한다면 @Table("VIP_MEMBER"), @Column("DISPLAY_NAME")과 같이 애노테이션에 매핑할 이름으로 직접 정의하면 된다.

※ 수정으로 적용하는게 자동보다 우선순위를 갖는다 (스프링부트에서의 특징)

리포지토리 작성

데이터베이스와 관련해서는 CRUD가 가장 기본적이다. CRUD는 각각 대응하는 SQL 문을 수행해야 한다.

  • 생성 : INSERT INTO
  • 조회 : SELECT FROM
  • 수정 : UPDATE
  • 삭제 : DELETE FROM

Spring Data JDBC에서는 이러한 기본 동작을 지원하는 CrudRepository라는 인터페이스와 구현체를 제공하므로, SQL 문을 작성하지 않아도 간단하게 데이터를 생성, 조회, 수정, 삭제할 수 있다.

CrudRepository 인터페이스는 제네릭 타입으로 데이터베이스 테이블과 연동할 객체의 클래스 타입과 그 객체의 아이디 타입을 매개변수로 해서 간단하게 리포지터리 인터페이스를 상속함으로써 정의할 수 있다. 인터페이스 이름 위에 @Repository 애노테이션을 붙이면, 스프링 부트가 애플리케이션을 시작할 때 리포지터리 객체를 스프링 빈 객체로 등록해 필요한 곳에서 의존성을 주입받아 사용할 수 있다.

 

MemberRepository.java

package com.example.jdbc;

import org.springframework.data.repository.CrudRepository;
import org.springframework.stereotype.Repository;

@Repository
public interface MemberRepository extends CrudRepository<Member, Long> {
    // 기본적인 CRUD 메서드(savem findById, findAll, deleteById) 자동 제공
}
  • 생성 및 수정 : save()
  • 조회 : findById(), findAll()
  • 삭제 : deleteById(), deleteAll()
  • 전체 개수 조회 : count()

스프링 부트는 커맨드라인 애플리케이션 작성을 지원하기 위해 Application Runner 인터페이스를 제공한다. 다음과 같이 Application Runner인터페이스를 구현하고 클래스 이름에 @Component 애노테이션을 사용해 스프링 빈 객체로 등록하면, 스프링 부트가 애플리케이션을 시작할 때 스프링 컨테이너에서 ApplicationRunner 인터페이스가 구현된 객체를 모두 스캔해 프로그램 실행 옵션을 매개변수로 run() 메서드를 호출한다.

리포지터리에 save()메서드는 객체의 아이디가 null이거나 테이블에 존재하지 않는 데이터라면 INSERT SQL 문을 수행하고, 그 결과로 자동 생성된 아이디는 다시 객체의 아이디 프로퍼티로 반환한다. 반대로 save() 메서드로 전달된 객체의 아이디가 null이 아니라 테이블에 존재하는 데이터라면 UPDATE SQL문을 수행한다.

 

SpringJdbcApplication.java

package com.example.jdbc;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.stereotype.Component;

@Component
@RequiredArgsConstructor
@Slf4j
public class SpringJdbcApplication implements ApplicationRunner {
    private final MemberRepository memberRepository;

    @Override
    public void run(ApplicationArguments args) throws Exception {
        // create
        Member member = Member.builder()
                .name("정혁")
                .email("HyeokJung@hanbit.co.kr").age(10).build();
        memberRepository.save(member);

        //update
        member.setAge(11);
        memberRepository.save(member);
    }
}

findAll(), findById()를 제공하므로 멤버 전체를 조회하거나 특정 아이디로 멤버를 조회하는 메서드를 테스트해 볼 수 있다.

CURD 메서드 외에도 메서드 이름으로 쿼리를 작성할 수 있는 쿼리 메서드 기능을 제공한다.

package com.example.jdbc;

import org.springframework.data.repository.CrudRepository;
import org.springframework.stereotype.Repository;

import java.util.List;

@Repository
public interface MemberRepository extends CrudRepository<Member, Long> {
    // 기본적인 CRUD 메서드(save, findById, findAll, deleteById) 자동 제공
    List<Member> findByName(String name);
    List<Member> findByEmail(String email);
    List<Member> findByNameContaining(String name);

}

또한 단순 비교가 아닌 포함 여부를 검색하는 조건으로 정의할 수 있다.

만약 2개 이상의 프로퍼티를 사용해 검색 조건을 표현하고자 한다면 프로퍼티를 다음과 같이 And 또는 Or로 연결해 메서드를 작성하면 된다.

package com.example.jdbc;

import org.springframework.data.repository.CrudRepository;
import org.springframework.stereotype.Repository;

import java.util.List;

@Repository
public interface MemberRepository extends CrudRepository<Member, Long> {
    // 기본적인 CRUD 메서드(save, findById, findAll, deleteById) 자동 제공
    List<Member> findByNameAndEmail(String name, String email);
    List<Member> findByNameOrEmail(String name, String email);

}

문자열이 아닌 숫자로 된 컬럼에 대해 크기를 비교해 검색하는 메서드도 작성할 수 있다.

package com.example.jdbc;

import org.springframework.data.repository.CrudRepository;
import org.springframework.stereotype.Repository;

import java.util.List;

@Repository
public interface MemberRepository extends CrudRepository<Member, Long> {
    // 기본적인 CRUD 메서드(save, findById, findAll, deleteById) 자동 제공
    List<Member> findByAgeGreaterThan(int age);
    List<Member> findByAgeLessThan(int age);
    List<Member> findByAgeBetween(int age, int max);

}

조금 더 복잡한 쿼리는 직접 SQL을 작성할 수 있다. 애플리케이션에서 호출할 메서드 이름 위에 @Query 애노테이션을 사용해 직접 쿼리문을 작성하는 것이다. 만약 매개변수가 필요하다면 SQL 문에 콜론을 붙여 메서드의 매개변수를 SQL 문으로 전달할 수 있다.

package com.example.jdbc;

import org.springframework.data.jdbc.repository.query.Query;
import org.springframework.data.repository.CrudRepository;
import org.springframework.stereotype.Repository;

import java.util.List;

@Repository
public interface MemberRepository extends CrudRepository<Member, Long> {
    // 기본적인 CRUD 메서드(save, findById, findAll, deleteById) 자동 제공
    @Query("SELECT * FROM member WHERE age >= 13 AND age <= 19")
    List<Member> findTeenAge();

    @Query("SELECT * FROM member WHERE age >= :min AND age <= :max")
    List<Member> findByAgeRange(int min, int max);

}

Spring Data JDBC는 간단하고 가벼운 데이터 접근 방식을 원하는 경우에 사용하는 것이 적합하며, 비교적 복잡하고 다양한 쿼리를 위해서는 MyBatis나 Spring Data JPA 사용을 권장한다.

커넥션 풀 관리

데이터베이스에 쿼리를 수행할 때마다 다시 데이터베이스에 연결해야 한다면 매우 비효율적인데 스프링 데이터에서는 미리 데이터베이스와의 연결을 몇 개 만들어서 풀 형태로 관리한다. 그리고 애플리케이션 필요할 때마다 그 풀에서 연결을 가져와 사용하고, 사용한 후에는 해당 연결을 다시 반납함으로써 매번 데이터베이스에 연결하는 시간을 줄일 수 있다.

스프링 부트에서는 이러한 연결 관리 기능을 모두 자동으로 수행하기 때문에 개발자가 별도로 데이터베이스와의 연결 및 연결 반납을 처리하는 코드를 작성할 필요가 없다.

프로퍼티에서 커스터 마이징도 가능하다

application.properties
#데이터베이스 연결을 최대 20개까지 생성해 애플리케이션이 동시에 20개의 작업을 할 수 있도록 설정
spring.datasource.hikari.maximum-pool-size=20

# 애플리케이션이 데이터베이스 연동을 하지 않고 있는 때일지라도, 언제 갑자기 연동할지 알 수 없으므로 일단 데이터베이스 연결을 최소 10개 유지하도록 설정
spring.datasource.hikari.minimum-idle=10

# HikariCP 로깅 레벨 설정
logging.level.com.zaxxer.hikari=DEBUG

이러한 커넥션 풀 기능은 스프링 데이터를 기반으로 한 Spring Data JDBC와 MyBatis, Spring Data JPA에서도 사용한다.

일반적으로는 커넥션 풀을 설정하지 않고 스프링 부트가 제공하는 디폴트 설정을 해도 무방하지만, 데이터베이스 관리자는 각 애플리케이션별로

커넥션 풀 개수를 할당하는 등의 적절한 설정을 통해 커넥션을 관리해야 한다.

MyBatis

일반적으로 큰 규모의 프로젝트에서는 JDBC나 Spring Data JDBC를 사용하기보다는 MyBatis나 JPA와 같은 프레임워크를 사용하는 경우가 많다.

그중에서도 MyBatis는 데이터베이스 연동을 위한 인터페이스 메서드와 SQL을 매핑하는 기술, SQL 문에 익숙한 개발자라면 필요한 SQL 문을 작성하고 해당 SQL 문과 매핑할 자바 인터페이스를 정의하는 것만으로 간단하게 데이터베이스와 연동할 수 있다.

이번에는 내장되어 있는 데이터베이스인 H2를 사용해보겠다.

스프링 부트에서는 H2 데이터베이스를 자동으로 인식해 사용하므로 애플리케이션 설정에 연결할 데이터베이스, 사용자 이름, 패스워드를 설정하지 않아도 된다. 스프링 부트는 랜덤한 UUID를 발생시켜 내부 데이터베이스를 생성한다.

XML 기반 SQL 매핑

MyBatis는 XML 방식으로 SQL 매퍼를 구성하거나 애노테이션으로 SQL 매퍼를 구성할 수 있다.

XML 방식의 SQL 매핑은 Mapper 인터페이스를 정의하고 그와 짝을 이루는 XML 파일을 작성하는 방법이고, 애노테이션을 사용한 SQL 매핑은 MyBatis를 통해 별도의 XML 파일을 작성하지 않고 Mapper 인터페이스에 애노테이션으로 직접 SQL 매핑하는 방법이다.

model/Member.java

package com.example.MyBatis.model;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class Member {
    private Long id;
    private String name;
    private String email;
    private Integer age;
}

Mapper 인터페이스 작성하기

데이터베이스를 사용해 멤버 객체를 다룰 Mapper 인터페이스를 작성한다. 인터페이스 이름 위에 @Mapper 애노테이션을 사용하고 멤버 데이터를 조회, 생성, 수정, 삭제하기 위해 필요한 메서드들을 정의한다.

  • List selectAll();

위 메서드는 멤버 전체를 조회하는 메서드이다.

또는 아이디와 이메일을 사용해 조회하는 메서드도 정의할 수 있다. 이때 매개변수 앞에 @Param("매개변수 이름") 애노테이션을 사용하면 메서드의 매개변수를 SQL로 전달할 수 있다.

  • Optional selectById("@Param("id") Long id);
  • Optional selectByEmail("@Param("emai") string email);

이런식으로 MemberMapper 인터페이스에는 멤버 테이블과 연동하는 작업을 인터페이스 메서드로 정의하면 된다.

다음은 애플리케이션에서 자주 사용하는 메서드를 정의한 것으로 각 메서드는 XML 설정을 통해 SQL과 매핑할 수 있다. 매퍼 인터페이스는 mapper라는 패키지를 만들고 이곳에 모아 두는 것이 일반적인 관계이다.

mapper\memberMapper.java

@Mapper
public interface MemberMapper {
    List<Member> selectAll();
    Optional<Member> selectById(@Param("id") Long id);
    Optional<Member> selectByEmail(@Param("email") String email);
    List<Member> selectAllOrderByAgeAsc();
    List<Member> selectAllOrderBy(@Param("order") String order, @Param("dir") String dir);
    List<Member> selectByNameLike(@Param("name") String name);
    int selectAllCount();
    int insert(@Param("member") Member member);
    int update(@Param("member") Member member);
    int delete(@Param("member") Member member);
    int deleteById(@Param("id") Long id);
    int deleteAll();
}

XML로 SQL 매핑하기

MemberMapper 인터페이스를 정의했다면 인터페이스의 각 메서드를 호출할 때 실행되어야 하는 SQL을 서로 매핑해 줘야 하는데, 이때는 SQL 매핑을 담당하는 XML 파일을 작성하고, 그 위치를 애플리케이션 설정을 통해 MyBatis에 알려줘야 한다.

resources\mapper\ 하위에 있는 모든 XML 파일을 사용해 Mapper를 구성하는 예시를 봐보자

 

application.properties

mybatis.mapper-locations=classpath:mapper/**/*.xml

 

MemberMapper.xml

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
        PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "https://mybatis.org/dtd/mybatis-3-mapper.dtd">


<mapper namespace="com.example.MyBatis.mapper.MemberMapper">
    <!-- List<Member> selectAll() -->
    <select id="selectAll"> 
        SELECT * FROM member
    </select>

     <!-- Optional<Member> selectById(@Param("id") Long id) -->
    <select id="selectById">
        SELECT * FROM member WHERE id = #{id}
    </select>

     <!--  Optional<Member> selectByEmail(@Param("email") String email) -->
    <select id="selectByEmail">
        SELECT * FROM member WHERE email = #{email}
    </select>

    <!--  List<Member> selectByNameLike(@Param("name") String name) -->
    <select id="selectByNameLike">
        SELECT * FROM member WHERE name LIKE #{name}
    </select>

    <!--  List<Member> selectAllOrderByAgeAsc() -->
    <select id="selectAllOrderByAgeAsc">
        SELECT * FROM member ORDER BY age ASC
    </select>

    <!--List<Member> selectAllOrderBy(@Param("order") String order, @Param("dir") String dir)-->
    <select id="selectAllOrderBy">
        SELECT * FROM member ORDER BY ${order} ${dir}
    </select>

    <!-- int selectAllCount() -->
    <select id="selectAllCount">
        SELECT COUNT(*) FROM member
    </select>

    <!--  int insert(@Param("member") Member member) -->
    <insert id="insert" useGeneratedKeys="true" keyProperty="member.id" keyColumn="id">
        INSERT INTO member(name, email, age) VALUES (#{member.name}, #{member.email}, #{member.age})
    </insert>

    <!--  int update(@Param("member") Member member) -->
    <update id="update">
        UPDATE member
        SET name = #{member.name}, email = #{member.email}, age = #{member.age}
        WHERE id = #{member.id}
    </update>

     <!--  int delete(@Param("member") Member member) -->
    <delete id="delete">
        DELETE FROM member WHERE id = #{member.id}
    </delete>


    <!--  int delete(@Param("id") Long id) -->
    <delete id="deleteById">
        DELETE FROM member WHERE id = #{id}
    </delete>

    <!--  int deleteAll() -->
    <delete id="deleteAll">
        DELETE FROM member
    </delete>
</mapper>

언더스코어로 분리된 컬럼을 언더스코어가 없는 프로퍼티로 매핑하지 못하는데, 일반적으로 2개 이상의 단어로 이루어진 경우 자바에서는 카멜 표기법을 사용하지만 데이터베이스에선 언더스코어로 분리하는 경우가 많다. 따라서 MyBatis에서는 언더스코어를 카멜 표기법으로 매핑시켜 주는 옵션을 활성화하면 display_name을 displayName으로 매핑할 수 있다.

 

mybatis.configuration.map-underscore-to-camel-caes = true

만약 컬럼의 이름과 프로퍼티 이름이 완전히 다르다면, 두 가지 방법을 고려할 수 있다.

1) SQL 문에서 컬럼 이름을 프로퍼티 이름으로 별칭(Alias)을 지정해 조회하는 방식.

ex) display_name 컬럼을 name이라는 프로퍼티 이름으로 매핑하기 위해 다음과 같이 사용 가능

   SELECT *, display_name AS name FROM member

보통 적은 수의 쿼리에서 한두 개의 컬럼 이름만 바꾸고자 할 때 사용한다.

2) 많은 컬럼의 이름을 바꾸고자 할 때는 별도로 컬럼 이름과 프로퍼티 이름의 매핑 정보를 ResultMap을 선언한다.

ResultMap은 여러 개를 선언할 수 있고 각각 id를 부여해 구분할 수 있다.

ResultMap이 매핑하는 객체 타입으로 type="모델 객체의 풀 패키지 이름"을 설정하고, 내부 컬럼 이름과 프로퍼티 이름을 직접 매핑한다.

<resultMap id="memberResult" type="com.example.demo.model.Member">
    <result column="display_name" property = "name"/>
    <result column="primary_contact" property = "email"/>
    <result column="age" property = "age"/>
</resultMap>    

이렇게 매핑 정보를 사용하려는 경우 다음과 같이 ResultMap의 id를 통해 매핑할 수 있다.

<select id="selectAll" resultMap="memberResult">
    SELECT * FROM member
</select>

<select id="selectById" resultMap="memberResult">
    SELECT * FROM member WHERE id=#{id}
</select>

 

데이터베이스 연동

MybatisApplication을 작성하고 MemberMapper나 여기서 다루지 않은 ArticleMapper 객체를 의존성으로 주입받는다. @Component를 사용해 스프링 빈 객체로 등록하고 스프링 부트가 정의한 ApplicationRunner 인터페이스를 구현함으로써 [명령 프롬프트] 또는 셸에서 실행 가능한 애플리케이션을 작성할 수 있다.

@Component
@RequiredArgsConstructor
@Slf4j
public class MyBatisExamApplication implements ApplicationRunner {
    private final MemberMapper memberMapper;
    private final ArticleMapper articleMapper;

    @Override
    public void run(ApplicationArguments args) throws Exception {
        int count = memberMapper.selectAllCount();
        log.info("Member count: {}", count);

        Member member = memberMapper
                .selectByEmail("SeojunYoon@hanbit.co.kr")
                .orElseThrow();
        log.info("Member: {}", member);

        Article article = Article.builder()
                .title("Hello, MyBatis")
                .description("MyBatis is an SQL Mapper framework")
                .memberId(member.getId())
                .build();
        int inserted = articleMapper.insert(article);
        log.info("Inserted: {}", inserted);
    }
}