inflearn

[인프런] 김영한의 스프링 입문 - 순수 Jdbc, JdbcTemplate, MyBatis, JPA, Spring Data JPA

hail2y 2024. 7. 3. 13:56

종강하고 며칠 지나지 않은 시점에서 스프링 입문 강의를 정리차 다시 들었다. 직전 학기에 한 강의에서는 Maven, Eclipse를 이용하여 도서관리 프로그램을 체계적으로 짰고, 다른 강의에서는 Gradle, Intellij를 기반으로 스프링을 가볍게 훑었다. 다른 글들에서 각각에 대해 정리해 두기도 했지만 강의에 나온 걸 바탕으로 하여(MyBatis는 학교에서 배운 코드를 참고하여) 다시 총정리해 볼 것이다.  

 

제목에서 알 수 있듯이 강의는 인프런에서 김영한 님의 '스프링 입문-코드로 배우는 스프링 부트, 웹 MVC, DB접근 기술'을 참고하였다.

이 강의는 입문 강의이기 때문에 간단한 데이터베이스인 h2를 사용하였는데 이것은 db 앱을 따로 설치하는 것이 아닌 웹 콘솔을 통해 접근한다.

https://lordofkangs.tistory.com/310

 

[ DB ] H2 DB를 사용하는 이유

H2 데이터베이스는 간단한 프로젝트나 테스트용으로 적합한 데이터베이스이다. H2의 장점은 Embeded모드를 지원하기 때문이다. Application과 DB의 통신 Oracle , MySQL, MariaDB의 작동원리는 위 그림과 같

lordofkangs.tistory.com

1. 순수 Jdbc

Jdbc를 사용할 것이기 때문에 build.gradle 파일에 jdbc와 h2 라이브러리를 추가한다.

자바는 기본적으로 db와 붙으려면 jdbc 드라이버와 연동해야 한다.

build.gradle은 프로젝트를 빌드하기 위해 사용되고 주로 dependency를 추가한다. 

dependencies {
  implementation 'org.springframework.boot:spring-boot-starter-jdbc'
  runtimeOnly 'com.h2database:h2'
 }

 

resources/application.properties 스프링 부트가 데이터베이스 접속 정보를 인식할 수 있도록 연결 설정을 추가한다. 

스프링 부트는 런타임에 application.properties에 있는 외부 프로퍼티를 애플리케이션과 바인딩해 준다. 

 spring.datasource.url=jdbc:h2:tcp://localhost/~/test
 spring.datasource.driver-class-name=org.h2.Driver
 spring.datasource.username=sa

 

그리고 db에 붙으려면 'javax.sql.DataSource'의 dataSource가 필요하다. 그래서 위와 같이 데이터베이스 접속정보를 만들어 놓으면 스프링부트가 이를 가지고 dataSource를 만들어 놓는다. 사용할 때에는 스프링을 통해 DI(의존성 주입)하면 된다. 

 

데이터베이스와 연결되는 소켓 정보는 dataSource.getConnection()을 통해 얻는다. connection을 가져올 때나 릴리즈할 때는 DataSourceUtils.getConnection(), DataSourceUtils.releaseConnection(conn, dataSource)로 사용한다. 그 이유는 이전에 사용하던 connection과 같은 것을 사용하기 위함이다. 

 

repository를 작성하는 큰 흐름은, 

 

0. sql 문을 작성한다. 

1. 데이터베이스와 연결되는 소켓 정보를 가져오고,

2. 여기에다가 sql문을 전달한다.

3. sql 문에 따라 필요한 정보가 있을 시 값 세팅 (where 문 안에 들어갈 추가 정보)

4. 실행

5. ResultSet(rs)에 결과 담기

6. rs에 있는 정보 꺼내 메서드가 요구하는 값에 따라 적절히 반환 처리

 

이렇듯 단계가 복잡하니 코드가 길어지고 그 속에 중복 코드도 많이 존재하게 된다.  이 방법은 20년 이상 전에 사용하던 방식으로 지금은 많이 안 쓴다고 한다.  

 

코드 예제는 대략 이렇다. 

@Override
public Member save(Member member) {
   String sql = "insert into member (name) values (?)";

    Connection conn = null;
    PreparedStatement pstmt = null;
    ResultSet rs = null;

    try {
        conn = getConnection();
        pstmt = conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS);

        pstmt.setString(1, member.getName());

        pstmt.executeUpdate();
        rs = pstmt.getGeneratedKeys();

        if (rs.next()) {
            member.setId(rs.getLong(1));
        } else {
            throw new SQLException("id 조회 실패");
        }
        return member;
    } catch (Exception e) {
        throw new IllegalStateException(e);
    } finally {
        close(conn, pstmt, rs);
    }
}
@Override
public Optional<Member> findByName(String name) {
    String sql = "select * from member where name = ?";

    Connection conn = null;
    PreparedStatement pstmt = null;
    ResultSet rs = null;

    try {
        conn = getConnection();
        pstmt = conn.prepareStatement(sql);
        pstmt.setString(1, name);

        rs = pstmt.executeQuery();

        if (rs.next()) {
            Member member = new Member();
            member.setId(rs.getLong("id"));
            member.setName(rs.getString("name"));
            return Optional.of(member);
        } else {
            return Optional.empty();
        }
    } catch (Exception e) {
        throw new IllegalStateException(e);
    } finally {
        close(conn, pstmt, rs);
    }
}

 

2. JdbcTemplate

  • 순수 Jdbc와 동일한 환경설정
  • 위의 순수 Jdbc에서 반복되는 코드들을 줄일 수 있다
  • JdbcTemplate은 DI를 받을 수 없고 DataSource를 인젝션 받아 새로운 객체를 생성한 후에 넣어주는 것이 일반적
  • RowMapper는 익명함수로 정의하여 삽입하지 않고 메서드로 따로 정의할 수 있다. 특히 람다를 이용하여 더 간단히 구현 가능  
private final JdbcTemplate;

public JdbcTemplateMemberRepository(DataSource dataSource) { 
    jdbcTemplate = new JdbcTemplate(dataSource);
}

@Override
public Member save(Member member) {
    SimpleJdbcInsert jdbcInsert = new SimpleJdbcInsert(jdbcTemplate);
    jdbcInsert.withTableName("member").usingGeneratedKeyColumns("id");
    Map<String, Object> parameters = new HashMap<>();
    parameters.put("name", member.getName());
    Number key = jdbcInsert.executeAndReturnKey(new MapSqlParameterSource(parameters));
    member.setId(key.longValue());
    return member;
}
    
@Override
public Optional<Member> findByName(String name) {
    List<Member> result = jdbcTemplate.query("select * from member where name = ?", memberRowMapper(), name);
    return result.stream().findAny();
}

private RowMapper<Member> memberRowMapper() {
    return (rs, rowNum) -> {
        Member member = new Member();
        member.setId(rs.getLong("id"));
        member.setName(rs.getString("name"));
        return member;
	};
}

 

Maven으로 했던 수업에서 이 JdbcTemplate 방식을 썼는데 거기에선 RowMapper를 일일이 다 타이핑해서 진행했다... 

 

3. MyBatis

  • 구현 클래스 없이 인터페이스만 선언하여 사용 가능
  • Mapper 인터페이스를 정의하고 XML 매퍼 파일을 작성하여 리포지터리 함수/기능과 sql을 매핑한다.
  • 이 둘을 연결하기 위해 XML 매퍼 파일의 namespace와, 애너테이션 @Mapper를 이용한다. 
  • 마찬가지로 mybatis dependency를 build.gradle에 추가한다. 
dependencies {
  implementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter:3.0.3'
}

 

application.properties에 typeAlias 대상과 mapper xml 파일이 어디있는지를 적어 놓는다. 추가로 mybatis가 어떤 과정으로 실행되는지를 살펴보기 위해 디버그 문들을 넣어 주었다. -- (feat.chatGPT)

mybatis.configuration.map-underscore-to-camel-case=true
mybatis.type-aliases-package=com.example.member.domain
mybatis.mapper-locations=mappers/*.xml

logging.level.org.mybatis=DEBUG
logging.level.org.springframework.jdbc.datasource.DataSourceTransactionManager=DEBUG
logging.level.org.springframework.jdbc.core.JdbcTemplate=DEBUG
logging.level.java.sql.Connection=DEBUG

 

매핑 설정 정보 파일인 mybatis-config.xml에는 위에서 언급한 typeAlias에 대한 정보가 있다. 이것을 사용하는 이유는, xml 매퍼 파일에서 sql의 반환 타입으로 사용자가 정의한 객체를 사용하고 싶을 때 풀패키지명을 적어야 하는데 매번 풀 경로를 써야 하는 것을 피하기 위해 혹은 한번에 깔끔하게 관리하기 위해서가 있을 수 있다. 참고로 이 config 파일을 작성할 때 혹시나 내용을 아무것도 안 넣더라도 <configuration></configuration>을 써야 한다고 아래 글에서 언급된다. 

<configuration>
    <typeAliases>
        <typeAlias type="hello.hello_spring.domain.Member" alias="Member" />
    </typeAliases>
</configuration>

 

mapper.xml과 mybatis-config.xml dtd(문서타입정의) 작성 시 doctype 뒤에  나오는 문서 속성 정보를 잘 맞춰쓰기로 한다. 

https://seungjenote.tistory.com/entry/Spring-Mybatis-%EC%97%90%EB%9F%AC-5%EA%B0%80%EC%A7%80%EB%A7%8C-%EC%95%8C%EB%A9%B4-%ED%95%B4%EA%B2%B0

 

Spring Mybatis 에러 5가지만 알면 해결!!

Spring Mybatis Error 해결 방법 Spring Mybatis 에러는 제 경험상 5가지만 체크하면 99.8% 정도는? 해결 가능! Error : java.lang.IllegalArgumentException: Mapped Statements collection does not contain value for​ ~이 녀석을 이젠

seungjenote.tistory.com

<!DOCTYPE mapper
        PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="hello.hello_spring.repository.MyBatisMemberRepository">
    <insert id="save" parameterType="Member" useGeneratedKeys="true" keyColumn="id" keyProperty="id">
        insert into member
            (name)
        values
            (#{name})
    </insert>

    <select id="findAll" resultType="Member">
        select *
        from
        member
    </select>

    <select id="findById" parameterType="Long" resultType="Member">
        select *
        from member
        where id = #{id}
    </select>

    <select id="findByName" parameterType="String" resultType="Member">
        select *
        from member
        where name = #{name}
    </select>
</mapper>

 

참고로 스프링 입문 강의에서는 MyBatis에 대한 구현 방법은 나오지 않았는데 JPA와 Spring Data JPA를 먼저 구현했다가 자꾸 MyBatis가 아닌 JPA 쪽 오류가 뜨거나, 실행은 되는데 이게 MyBatis로 되는 건지 Spring Data JPA로 되는 건지 의심이 되면서 쉽지 않았다... 결론적으로 디버그 코드를 통해 MyBatis로 실행되는 것을 확인은 했지만 코드의 깔끔함과, 실행만을 위해 주석으로 도배해 놨던 걸 되돌리기 위해서 새로운 프로젝트로 다시 해 봐야겠다. 여기저기 강의 자료와 인터넷 자료를 참고하면서 MyBatis 지식을 습득하는 중인데 문서 보면서 이해를 한 다음에 관련 글을 써 봐야지!

4. JPA

  • sql 문을 직접 작성하지 않아도 된다 = jpa가 직접 만들어 실행해 준다
  • sql과 데이터 중심의 설계에서 객체 중심의 설계로 패러다임을 전환한다
  • 개발 생산성을 크게 높일 수 있다

앞에서 build.gradle에 dependency를 추가했는데 이번에는 여기에 data-jpa 라이브러리를 추가한다. 앞서 추가한 jdbc와 jpa를 모두 포함한다. 미리 얘기하자면 이 라이브러리를 추가함으로써 스프링부트가 jpa의 핵심인 EntityManager를 현재 연결하고자 하는 데이터베이스와 연결해서 자동으로 만들어 준다. 

dependencies {
    	...
//	implementation 'org.springframework.boot:spring-boot-starter-jdbc'
	implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
}

 

그리고 application.properties 스프링 부트에 jpa 설정 정보를 추가로 적어준다. 첫 번째 설정은 jpa가 날리는 sql을 볼 수 있게 하고, 두 번째 설정은 jpa가 객체를 보고 테이블까지 자동으로 생성할 수도 있는데 이미 테이블은 db에서 만들었기 때문에 none으로 막아둔 것이다. 자동으로 생성하게 하려면 'create'를 적는다.  

spring.jpa.show-sql=true
spring.jpa.hibernate.ddl-auto=none

 

JPA는 ORM(Object Relational Mapping) 기술에 대한 인터페이스인데 Entity라는 것을 매핑해야 한다. 객체와 관계형 데이터베이스를 매핑한다는 것인데 객체를 통해 db data를 다룬다고 보면 된다. 일종의 테이블인 셈이다. @ID로 PK(기본키)를 매핑해 주어야 하고 추가적으로 db에서 자동 생성해 주는 값이라는 것을 표현해 주기 위한 strategy=GenerationType.Identity를 써 주었다. 

여기에서는 또 다른 속성인 name에 별다른 애너테이션을 붙이지 않았는데 현재 만든 데이터베이스 테이블에 컬럼 이름이 같기 때문이다. 만약 테이블의 컬럼에 다른 이름이 붙여졌다면 @Column(name="")으로 연결해 준다. 

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;

@Entity
public class Member {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String name;

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }
}

 

앞서 스프링 부트가 자동 생성해 준 EntityManager를 생성자를 통해 DI를 해 주면 된다. 다시 말해 JPA를 쓰려면 EntityManager를 주입받아야 한다. JPA에서 insert는 특별하게 em.persist()로 처리하는데 이는 객체를 영속성 컨텍스트에 저장함으로써 commit을 하면 db에 반영되도록 한다. 내부적으로 id까지 다 처리해 준다. PK를 통해 select문을 한다고 하면 find()로 아래와 같이 처리해 주면 된다.

이외에 PK로 하는 쿼리가 아닌 것들은 JPQL(JPA Query Language)로 sql 문을 써주어야 한다. em.createQuery("select m from Member m", Member.class)를 보면 일반적인 sql 문과는 살짝 다르다는 것을 알 수 있는데, 객체(entity)를 대상으로 쿼리를 날리기 때문에 select m이라고 써 주었다. 이는 sql로 번역된다. 쿼리문을 작성하고 이후에 필요한 파라미터 작업 등 후에 .getResultList()로 반환 처리한다.

public class JpaMemberRepository implements MemberRepository {

    private final EntityManager em;

    public JpaMemberRepository(EntityManager em) {
        this.em = em;
    }

    @Override
    public Member save(Member member) {
        em.persist(member);
        return member;
    }

    @Override
    public Optional<Member> findById(Long id) {
        Member member = em.find(Member.class, id);
        return Optional.ofNullable(member);

    }

    @Override
    public Optional<Member> findByName(String name) {
        List<Member> result = em.createQuery("select m from Member m where m.name = :name", Member.class)
                .setParameter("name", name)
                .getResultList();
        return result.stream().findAny();
    }

    @Override
    public List<Member> findAll() {
        return em.createQuery("select m from Member m", Member.class)
                .getResultList();
    }
}

 

그리고 JPA를 할 때는 Service 클래스에 @Transactional을 붙여준다. 데이터를 저장하거나 변경할 때 항상 트랜잭션 안에서 실행되어야 하기 때문이라고 한다. 지금 구현한 곳에서는 join 메서드에서만 데이터 변경이 일어나기 때문에 메서드 단위에서 붙여주어도 된다. 

@Transactional
public class MemberService {
    /**
     * 회원 가입
     */
    public Long join(Member member) {
        // 같은 이름이 있는 중복 회원X
        validateDuplicateMember(member); // 중복 회원 검증
        memberRepository.save(member);
        return member.getId();
    }
}

 

위에서 빈 등록하는 것까지 언급하지는 않았는데 여기서 em이 또 등장하여 넣는다. 스프링 컨테이너에 빈을 등록하는 Configuration 파일, SpringConfig에서 em DI 처리를 끝으로 해 준다.

@Configuration
public class SpringConfig {

    private EntityManager em;

    public SpringConfig(EntityManager em) {
        this.em = em;
    }
    
    @Bean
    public MemberService memberService() {
        return new MemberService(memberRepository());
    }
    
    @Bean
    public MemberRepository memberRepository() {
//        return new MemoryMemberRepository(); // 나중에 구현클래스만 바꿔 끼울 때 얘만 변경하면 된다는 장점이 있음 -- 자바 직접 구현
//        return new JdbcMemberRepository(dataSource);
//        return new JdbcTemplateMemberRepository(dataSource);
		return new JpaMemberRepository(em);
    }
}

5. Spring Data JPA

  • JPA를 스프링에 한번 감싸서 표현
  • 인터페이스만으로 개발할 수 있다
  • 앞의 JPA 환경 설정과 동일
  • 페이징 기능까지 자동 제공
  • extends ListCrudRepository<T, ID>, ListPagingAndSortingRepository<T, ID>, QueryByExampleExecutor<T>
public interface SpringDataJpaMemberRepository extends JpaRepository<Member, Long>, MemberRepository {

    // JPQL select m from Member m where m.name = ?
    @Override
    Optional<Member> findByName(String name);
}

 

'extends JpaRepository<T,T>'를 보고 Spring Data JPA가 구현체를 직접 만들어 자동으로 빈을 등록한다. 따라서 방금 전에 보았던 SpringConfig에서 자동으로 등록된 빈을 적어 DI만 해 주면 된다. 이렇게 간단하게 처리될 수 있는 이유는 JpaRepository<T,T>에서 기본적인 등록, 조회, 삭제, 수정 등을 할 수 있는 메서드들(CRUD)을 다 제공하고 있기 때문이다. 공통화할 수 있는 건 공통화해서 다 제공해 준다고 생각하자. 단, findByName처럼 개별적으로 처리해줘야 할 것들은 위처럼 적어주면 된다. 그러면 이름만 보고도 무슨 역할을 하는지 알아내어 JPQL로 만들고, 또 이를 sql로 바꾸어 번역한다. 

@Configuration
public class SpringConfig {

    private MemberRepository memberRepository;
    
    @Autowired
    public SpringConfig(MemberRepository memberRepository) {
        this.memberRepository = memberRepository;
    }
    
    @Bean
    public MemberService memberService() {
        return new MemberService(memberRepository);
    }
}