[Spring] 여섯번째. 스프링 DB 접근 기술

SUbbb·2022년 1월 12일
0

SpringBoot

목록 보기
6/7
post-thumbnail

스프링 입문 - 코드로 배우는 스프링 부트, 웹 MVC, DB 접근 기술을 들으며 정리하는 POST입니다.

전체적인 흐름

  • Spring Project 생성
  • Spring boot로 웹 서버 실행
  • 회원 도메인 개발
  • 웹 MVC 개발
  • DB 연동 - JDBC, JPA, Spring data JPA
  • 테스트 케이스 작성

H2 데이터베이스 설치

메모리 기반의 애플리케이션은 실무에서는 사용될 수 없다.
수 많은 데이터를 기록하고 영구적으로 저장하기 위해서는 Database를 사용하여야 한다.

여기서 사용할 Database는 아주 가볍고 간단한 "H2 Database" 를 사용할 것이다.

H2 Database는 다음과 같은 특징을 가진다.

  • open source, 매우 빠른 JDBC API
  • Embedded and server modes; disk-based or in-memory databases
  • Browser 기반의 Console application
  • 트랜잭션 지원, 동시성 제어 가능
  • Encrypted databases
  • ODBC driver

설치

  1. H2 공식 홈페이지에 접속하여 'All Platforms' 을 설치한다. (Windows 환경인 경우에는 Windows를 설치하면 된다.)
  2. 압축을 해제하고 Terminal 을 열어 h2/bin 으로 이동한다.
  3. chmod 755 h2.sh 명령어로 h2.sh 에 대한 권한을 부여한다. (MAC 유저의 경우)
  4. ./h2.sh 로 실행
  5. "Browser 기반의 Console application"을 확인할 수 있다. 경우에 따라 접속이 되지 않는 경우에는 도메인 주소만 localhost 로 변경하여 접속한다. (뒤에는 세션키가 포함되어 있어 수정하면 안된다.)
  6. 최초에는 "Database File" 을 생성해야 한다. 위 화면에서 연결 을 누른다.
  7. 이후, Terminal 을 켜서, home directory에서 ls -altest.mv.db 가 존재하는지 확인한다.
  8. test.mv.db 의 존재를 확인한 이후, 다음의 접근부터는 이처럼 파일로 접근하는 것이 아니라 JDBC URL:jdbc:h2:tcp://localhost/~/test 을 입력하여 socket으로 접근하도록 한다.

    파일로 접근하게 되면 application과 web console이 동시에 접근했을 때 충돌이 발생할 수 있기 때문이다.

  9. 제대로 동작하지 않는 경우에는 rm test.mv.db 로 파일을 지우고, h2.sh 을 재시작하여 처음부터 수행한다.

Table 생성

구현했던 Member domain과 동일하게 table을 생성한다.

create table member (
    id bigint generated by default as identity,
    name varchar(255),
    pwd integer,
    phone varchar(11),
    primary key (id)
);

위와 같이 table이 생성된 것을 확인할 수 있다.
이를 이전에 생성했던 domain/Member.java 의 타입과 비교해보면,

private Long id; == (h2)bigint
private String name; == (h2)varchar
private int pwd; == (h2)integer
private String phone; == (h2)varchar
  • 여기서 가장 중요한 것은 id bigint generated by default as identity 인데, 이는 값이 설정되지 않은 채로 INSERT 되면, H2 DB가 자동으로 해당 field의 값을 할당해준다.

이제 DB에 record를 하나 insert해보자.

insert into member(name) values('spring');
insert into member(name, pwd, phone) values('spring2', 12345, '01012341234');

결과는 다음과 같다.

  • NOT NULL 특성을 부여하지 않았기에 null 값으로 채워진다.
  • 할당하지 않은 id 가 순차적으로 증가하는 것을 확인할 수 있다.

Database file에 대한 관리를 하기 위해,
project folder에 sql directory를 하나 생성한다.
그리고 sql/ddl.sql 파일을 생성해 아래와 같이 sql 문들을 저장하여 관리한다.

순수 JDBC

구현한 application에서 H2 Database에 접근하는 방법에 대해 알아보도록 한다.

환경 설정

build.gradle 파일에 jdbc, h2 db 관련 라이브러리를 추가한다.

dependencies {
	...
	implementation 'org.springframework.boot:spring-boot-starter-jdbc'
	runtimeOnly 'com.h2database:h2'
}
  • Java는 기본적으로 DB와의 연동을 위해서는 JDBC driver가 필수적이다.
  • DB와의 연동에서, DB가 제공하는 클라이언트가 필요한데, H2가 그 역할을 한다.

추가적으로 DB에 접속하려면, 접속 정보가 필요하다.
예전에는 개발자가 일일이 정보를 입력해야 했지만, Spring 은 경로 정보만을 이용하여 이를 처리해준다.

src/main/resources/application.properties

spring.datasource.url=jdbc:h2:tcp://localhost/~/test
spring.datasource.driver-class-name=org.h2.Driver
spring.datasource.username=sa
  • h2.Driver 에 에러가 뜰텐데, 아까 수정한 build.gradle 의 변경사항이 적용되지 않아 그런 것이므로, Load Gradle Changes 를 해주면 된다.
  • 그리고 스프링 2.4부터의 변경사항으로, spring.datasource.username=sa 를 명시하지 않으면 org.h2.jdbc.JdbcSQLInvalidAuthorizationSpecException: Wrong user name or password [28000-200] 이라는 에러가 발생한다.

JDBC Repository 구현

현재, 회원을 저장, 조회하는 역할은 MemberRepository 에서 수행하지만,
구현은 Memory 에 하는 방식이기에 MemoryMemberRepository 를 사용했었다.
하지만 이제 H2 DB와 연동하여 구현하기 위해서 새로운 Class를 생성한다.

repository/JdbcMemberRepository.java

DataSource

먼저, DB와 연동하여 사용하기 위해서 DataSource 라는 것이 필요하다.
그리고, 이를 spring으로부터 주입받아야 한다.

  • springboot는 우리가 setting한 접속 정보를 가지고 DataSource 를 생성한다. 그리고 이를 주입받는 것이다.
import javax.sql.DataSource;
...

private final DataSource dataSource;

public JdbcMemberRepository(DataSource dataSource) {
    this.dataSource = dataSource;
}

Connection

private Connection getConnection() {
    return DataSourceUtils.getConnection(dataSource);
}
  • 위와 같이 Spring을 통해서 getConnection 을 수행한다.
    • 이렇게 하는 이유는 계속 새로운 Connection 이 생성되는 것을 방지하여 트랜잭션에 대한 처리를 수행하기 위해서이다.
    • release도 동일하게 수행한다.

save()

@Override
public Member save(Member member) {
    String sql = "insert into member(name, pwd, phone) 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.setInt(2, member.getPwd());
        pstmt.setString(3, member.getPhone());
        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);
    }
}
  • String sql 은 사용할 query의 format을 담고 있다.
    • PreparedStatement 를 통해, 인자를 query문에 담아 DB에 전달한다.
  • ResultSet 은 결과를 받아오는 객체이다.
  • Statement.RETURN_GENERATED_KEYS : DB에 insert할 때, id 에 key값을 할당해주기 위해 필요한 옵션
  • pstmt.setString(1, member.getName()); 로 query에 인자를 추가해주고, pstmt.executeUpdate(); 로 query를 수행한다.
  • rs = pstmt.getGeneratedKeys(); 는 위에서 설정한 옵션과 함께 사용되어야 한다. 이는 생성한 Key 를 반환한다.
  • rs.next()ResultSet 에 있는 값을 꺼낼 수 있다.
  • try - catch 구문을 사용하여 exception 에 대한 처리를 해주고, 마지막에는 사용한 자원들 (Connection, PreparedStatement, ResultSet) 에 대한 release 를 해야 한다.

findById(), findByName(), findByPhone()

가만히 생각해보니, findByPwd() 라는 method가 필요할까? 라는 생각이 들었다.
비밀번호를 가지고 회원을 조회하는 일은 본 적이 없는 것 같아 폐기처분한다.

조회 기능은 매우 비슷하기 때문에 findById() 만을 설명한다.

@Override
public Optional<Member> findById(Long id) {
    String sql = "select * from member where id = ?";
    Connection conn = null;
    PreparedStatement pstmt = null;
    ResultSet rs = null;
    try {
        conn = getConnection();
        pstmt = conn.prepareStatement(sql);
        pstmt.setLong(1, id);
        rs = pstmt.executeQuery();
        if(rs.next()) {
            Member member = new Member();
            member.setId(rs.getLong("id"));
            member.setName(rs.getString("name"));
            member.setPwd(rs.getInt("pwd"));
            member.setPhone(rs.getString("phone"));
            return Optional.of(member);
        } else {
            return Optional.empty();
        }
    } catch (Exception e) {
        throw new IllegalStateException(e);
    } finally {
        close(conn, pstmt, rs);
    }
}
  • 동일하게 Connection 을 수행하고, query를 날린다.
  • save() 와는 다르게 SELECT query이므로 executeQuery() 를 사용한다.
  • 반환되는 값이 있는 경우, 새로운 Member 객체를 생성하여 이를 반환해준다.

findAll()

findAll() 은 통째로 조회하는 것이므로 조건이 있는 findBy~() 보다 단순한 구조이다.

@Override
public List<Member> findAll() {
    String sql = "select * from member";
    Connection conn = null;
    PreparedStatement pstmt = null;
    ResultSet rs = null;
    try {
        conn = getConnection();
        pstmt = conn.prepareStatement(sql);
        rs = pstmt.executeQuery();
        List<Member> members = new ArrayList<>();
        while(rs.next()) {
            Member member = new Member();
            member.setId(rs.getLong("id"));
            member.setName(rs.getString("name"));
            member.setPwd(rs.getInt("pwd"));
            member.setPhone(rs.getString("phone"));
            members.add(member);
        }
        return members;
    } catch (Exception e) {
        throw new IllegalStateException(e);
    } finally {
        close(conn, pstmt, rs);
    }
}
  • List<Member> 형태로 반환받기 때문에, while() 을 이용하여 결과 List를 반환한다.

close()

사용한 자원에 대한 release는 역순 으로 진행한다.

private void close(Connection conn, PreparedStatement pstmt, ResultSet rs) {

    try {
        if (rs != null) {
            rs.close();
        }
    } catch (SQLException e) {
        e.printStackTrace();
    }

    try {
        if (pstmt != null) {
            pstmt.close();
        }
    } catch (SQLException e) {
        e.printStackTrace();
    }

    try {
        if (conn != null) {
            close(conn);
        }
    } catch (SQLException e) {
        e.printStackTrace();
    }
}

private void close(Connection conn) throws SQLException {
    DataSourceUtils.releaseConnection(conn, dataSource);
}

Configuration

이전에 MemoryMemberRepository 로 구현했을 때, SpringConfig file에서 스프링 컨테이너에 MemoryMemberRepository 를 등록하는 작업을 거쳤다.

이를 방금 생성한 JdbcMemberRepository 를 등록하는 것으로 변경한다.
그리고 JdbcMemberRepositoryDataSource dataSource 를 필요로 한다.
이는 Spring에서 제공해주는데, 다음과 같은 방법이 있다.

SpringConfig

Autowired DataSource dataSource;

or

private DataSource dataSource;

@Autowired
public SpringConfig(DataSource dataSource) {
    this.dataSource = dataSource;
}
  • 위 설정을 통해, Spring이 DataSource dataSource 에 대한 Bean을 생성해 의존성 주입을 수행해준다.

SpringConfig 만을 변경시킴으로써, 수정된 코드에 대한 의존성을 수정할 수 있다.

./h2.sh 로 H2 DB를 동작시키고, 스프링을 동작시켜 회원 등록 및 목록 조회를 하면, DB와 연동하여 정상적으로 등록, 조회가 이루어지는 것을 확인할 수 있다.

Spring을 사용하는 이유

  • 다형성의 활용: 인터페이스를 두고, 구현체를 바꿔서 사용할 수 있다. 이는 스프링 컨테이너가 DI를 이용해 지원한다. (memoryRepository → JdbcRepository)

아래는 구현 클래스에 대한 설명을 그림으로 표현한 것이다.

아래는 스프링 컨테이너에서 구현 클래스와의 연결을 표현한 그림이다.

  • 이와 같은 구조를 개방-폐쇄 원칙(OCP, Open-Close-Principle) 을 따른다고 한다.
    • 확장에는 열려있고, 수정(변경)에는 닫혀있다.
    • 객체지향의 다형성이라는 개념을 잘 활용하면, application의 동작 코드를 변경하지 않고도 변경할 수 있다.
  • 위와 같이 스프링의 DI를 사용하여 설정(SpringConfig)만으로 구현 클래스를 변경할 수 있다.

스프링 통합 테스트

이전에 작성한 MemoryMemberRepositoryTest 의 코드들은 스프링과는 관련없이 순수하게 자바 코드만으로 테스트를 수행한 것이다.
test/.../MemberServiceTest.java
순수한 자바 코드였기에, JVM 안에서 실행되어 빠르게 실행된다.

이제는 DB까지 연결한 스프링 통합 테스트를 수행해보도록 한다.

우선 이전의 MemberServiceTest.java 를 복사하여MemberServiceIntegrationTest.java 를 생성한다.

package hello.hellospring.service;

import hello.hellospring.domain.Member;
import hello.hellospring.repository.MemberRepository;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.transaction.annotation.Transactional;

import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertThrows;

@SpringBootTest
@Transactional
class MemberServiceIntegrationTest {

    @Autowired MemberService memberService;
    @Autowired MemberRepository memberRepository;

    @Test
    void 회원가입() {
        // given
        Member member = new Member();
        member.setName("spring");

        // when
        Long saveId = memberService.join(member);

        // then
        Member findMember = memberService.findOne(saveId).get();
        assertThat(member.getName()).isEqualTo(findMember.getName());
    }

    @Test
    public void 중복_회원_예외() {
        // given
        Member member1 = new Member();
        member1.setName("spring");

        Member member2 = new Member();
        member2.setName("spring");

        // when
        memberService.join(member1);
        IllegalStateException e = assertThrows(IllegalStateException.class, () -> memberService.join(member2));

        assertThat(e.getMessage()).isEqualTo("이미 존재하는 회원입니다.");

        // then
    }
}
  • @BeforeEach annotation을 사용하는 method를 삭제한다.
    • 이제는 객체를 직접 생성하는 것이 아닌 스프링 컨테이너로부터 받아와야 하기 때문에 생성자를 이용하여 의존성 주입을 수행해야 한다.
    • 하지만, Test 는 개발의 제일 끝단에 있다고 할 수 있으므로, 가장 간단한 방법을 사용한다. (field기반의 @Autowired 를 사용)
  • MemoryMemberRepository 가 아니라 MemberRepository 를 불러온다.
    • SpringConfig 를 통해 구현체가 올라왔기 때문이다?
  • Test를 수행하는 동안, 이전 Test method의 영향을 없애기 위해 사용했던 @AfterEach method를 삭제한다.
    • 이는 @Transactional 의 존재로 필요가 없어졌다.
    • @Transactional 을 주석 처리하고 회원가입 Test를 수행하면, Test가 끝난 이후에도, DB에 테스트 가입한 회원의 정보가 그대로 남아있다.
      • Test를 반복 수행하게 되면, 에러가 뜨게 된다.
    • 해결하기 위해서는 DB에 연결하여 select, insert 등의 query를 날린 후, rollback 하여 DB 수정사항을 되돌리는 로직이 필요하다.
    • @Transactional annotationd을 Test case에 달면, Test 실행 전, Transaction 을 실행한 후, Test가 끝날 때 rollback 을 수행한다. 따라서, DB는 반복 수행에도 문제가 없는 상태가 된다.

Test를 실행하면, console을 확인하여 Spring 이 함께 동작하는 것을 확인할 수 있다. (@SpringBootTest)

  • SpringConfig 에 작성한 내용도 함께 올라오는 것을 확인할 수 있다.

    Test를 실행할 때, 이미 DB에 저장되어 있는 정보와 겹쳐 에러가 발생하지 않도록 유의한다.

그럼 이전에 구현한 MemberServiceTest 는 필요가 없는가?

MemberServiceTest"단위 테스트",
MemberServiceIntegrationTest"통합 테스트" 라고 한다.

  • 가급적으로, 단위 테스트가 훨씬 좋은 테스트일 확률이 높다. 컨테이너까지 올려야 하는 테스트인 경우 테스트 설계가 잘못되었을 확률이 높다.

스프링 JdbcTemplate

JDBC API에서의 반복적인 코드를 제거해준다. 하지만 SQL query는 직접 작성해야 한다.

repository/JdbcTemplateMemberRepository.java

package hello.hellospring.repository;

import hello.hellospring.domain.Member;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;

import javax.sql.DataSource;
import java.util.List;
import java.util.Optional;

public class JdbcTemplateMemberRepository implements MemberRepository {

    private final JdbcTemplate jdbcTemplate;

    // @Autowired 생략 가능
    public JdbcTemplateMemberRepository(DataSource dataSource) {
        this.jdbcTemplate = new JdbcTemplate(dataSource);
    }

    ...
}
  • JdbcTemplate 이 존재하므로, 이를 사용한다. 이는 Injection을 받을 수 있는 것이 아니라 DataSource 가 필요하다.

    class의 생성자가 딱 1개인 경우, Spring bean으로 등록 시, @Autowired 를 생략할 수 있다.

RowMapper method

private RowMapper<Member> memberRowMapper() {
    return (rs, rowNum) -> {
        Member member = new Member();
        member.setId(rs.getLong("id"));
        member.setName(rs.getString("name"));
        member.setPwd(rs.getInt("pwd"));
        member.setPhone(rs.getString("phone"));
        return member;
    };
}
  • 회원 정보 조회의 결과를 받아오기 위해서 RowMapper 라는 것이 필요하다.
    • RowMapper 는 query의 결과를 객체로 받아온다. 이전에는 ResultSet 에 결과를 받고, 이를 객체로 생성해서 반환하는 과정을 거쳤지만 RowMapper 는 내부적으로 ResultSet 을 사용하여 위 과정을 수행한다.

회원 정보 조회

@Override
public Optional<Member> findById(Long id) {
    List<Member> result = jdbcTemplate.query("select * from member where id = ?", memberRowMapper(), id);
    return result.stream().findAny();
}
  • 입력받은 id 값을 ? 자리에 치환하고, 결과를 memberRowMapper() 를 통해 받아온다.
  • 결과의 반환형이 List<Member> 이므로 result 에 받고, 결과를 Optional<> 로 변환하여 반환한다.

회원 정보 조회 (findAll)

@Override
public List<Member> findAll() {
    return jdbcTemplate.query("select * from member", memberRowMapper());
}

회원가입

@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());
    parameters.put("pwd", member.getPwd());
    parameters.put("phone", member.getPhone());
    Number key = jdbcInsert.executeAndReturnKey(new MapSqlParameterSource(parameters));
    member.setId(key.longValue());
    return member;
}
  • SimpleJdbcInsert 를 사용하여 query를 짤 필요 없이 구현이 가능하다.
    • TableName, PK, 입력값만 있으면 insert 는 구현 가능하기에 사용한다.
  • executeAndReturnKey 를 이용해 생성한 회원의 key 를 받고 이를 저장한 Member 객체를 반환한다.

SpringConfig

이제 구현한 DB 접근 class를 연결하기 위한 설정을 한다.

@Bean
public MemberRepository memberRepository() {
    //return new JdbcMemberRepository(dataSource);
    return new JdbcTemplateMemberRepository(dataSource);
}

이제는 스프링 통합 테스트를 구현했기 때문에 Web Application을 실행할 필요 없이 DB만 접속하고 통합 테스트만 수행하면 된다.

JPA

JdbcTemplate 을 사용하여 반복적인 코드를 많이 줄인 것은 맞지만, 여전히 SQL query는 개발자가 직접 작성해야 한다.

이번 장에서는 Interface의 일종인 JPA 를 사용하여 query 또한 자동으로 처리하도록 한다. 이를 통해

  • SQL과 Data 중심의 설계 → 객체 중심의 설계 (패러다임의 전환)
  • 개발 생산성의 증대

build.gradle

dependencies {
    ...
    //	implementation 'org.springframework.boot:spring-boot-starter-jdbc'
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa' 
    runtimeOnly 'com.h2database:h2'
    testImplementation('org.springframework.boot:spring-boot-starter-test') {
        exclude group: 'org.junit.vintage', module: 'junit-vintage-engine'
    }
}

application.properties

JPA와 관련된 설정을 추가한다.

spring.jpa.show-sql=true
spring.jpa.hibernate.ddl-auto=none
  • show-sql=true : JPA가 생성하는 sql을 볼 수 있다.
  • ddl-auto=none : JPA는 객체를 확인하고 이에 대한 Table을 생성한다. 하지만 이미 Table을 생성했고, 생성한 Table을 사용할 것이므로, 해당 기능을 none 설정한다.

JPA는 자바 표준의 interface이다. 그래서 구현은 여러 vendor들이 수행한다. 여기서는 hibernate library를 중점적으로 사용하여 구현한다.

JPA는 객체와 ORM이라는 기술을 사용한다. Object Relational Mapping, 여기서 Mapping은 annotation을 사용하여 수행한다.

JPA를 사용하기 위해서는 Entity Mapping 이 필요하다.

domain/Member.java

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

@Entity
public class Member {
    
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    ...
  • @Entity : JPA가 관리할 Entity임을 명시한다.
  • @Id : primary key로 지정한다.
  • @GeneratedValue(strategy = GenerationType.IDENTITY) : DB에서 고유하게 값을 자동으로 생성한다. 사용자가 insert하지 않는다.
  • 만약 DB에 저장될 column명을 수정하고 싶다면, 아래와 같은 annotation을 생성한다.
@Column(name = "원하는 column명")
private String name;

이제 repository를 생성한다.

repository/JpaMemberRepository.java

public class JpaMemberRepository implements MemberRepository {

    private final EntityManager em;

    public JpaMemberRepository(EntityManager em) {
        this.em = em;
    }
    
    ...
  • EntityManager : JPA는 EntityManager 를 통해 모든 것이 동작한다. 위에서 추가한 data-jpa dependency를 통해 JPA가 자동으로 현재 DB와 연결된 EntityManager 를 생성한다. 따라서 이를 Injection 받아 사용하면 된다.

save()

@Override
public Member save(Member member) {
    em.persist(member);
    return member;
}
  • em.persist(member) : 객체를 저장하기 위한 query를 내부적으로 작성한다.

findById

@Override
public Optional<Member> findById(Long id) {
    Member member = em.find(Member.class, id);
    return Optional.ofNullable(member);
}
  • em.find(Member.class, id) : 조회할 Type과 식별자를 전달하여 조회한다.
  • 반환형이 Optional 이므로 이에 맞게 반환한다.

findAll

JPQL이라는 객체 지향 쿼리를 사용한다.

@Override
public List<Member> findAll() {
    return em.createQuery("select m from Member m", Member.class)
            .getResultList();
}
  • Table을 대상으로 query를 보내는 것이 아닌, 객체(Entity, ex. Member)를 대상으로 query를 보내 사용한다. 이러한 query가 SQL로 번역된다.
  • select 의 대상이 특정 attribute가 아닌 Entity 자체이다.

findByName

JPQL이라는 객체 지향 쿼리를 사용한다.

@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();
}

PK 기반의 query가 아닌 경우에는 JPQL을 작성해야 한다.

JPA는 모든 데이터의 변경이 트랜잭션 안에서 이뤄져야 한다. 따라서 JPA를 사용하기 위해서는 항상 Transactional이 존재해야 한다.

service/MemberService.java@Transactional 을 추가하는데,
class 자체에 해줘도 되고, 회원 가입 시에만 동시성 제어가 필요하므로 Long join() method에만 추가해줘도 된다.

이제 SpringConfig 에 설정을 추가해줘야 한다.

private EntityManager em;
    
@Autowired
public SpringConfig(EntityManager em) {this.em = em;}

@Bean
public MemberRepository memberRepository() {
    //return new JdbcMemberRepository(dataSource);
    //return new JdbcTemplateMemberRepository(dataSource);
    return new JpaMemberRepository(em);
}
  • JpaMemberRepositoryEntityManager 만을 필요로 하기에, 기존의 DataSource 와 생성자는 삭제한다.

이제 이전에 구현했던 스프링 통합 Test 로 정상 동작하는지 확인한다.

자꾸 에러가 난다...
어딘가 수정이 필요하다.

Hibernate: select member0_.id as id1_0_, member0_.name as name2_0_, member0_.phone as phone3_0_, member0_.pwd as pwd4_0_ from member member0_ where member0_.name=?
Hibernate: select member0_.id as id1_0_, member0_.name as name2_0_, member0_.phone as phone3_0_, member0_.pwd as pwd4_0_ from member member0_ where member0_.name=?
Hibernate: insert into member (id, name, phone, pwd) values (null, ?, ?, ?)
2022-01-11 15:51:29.818  WARN 2434 --- [           main] o.h.engine.jdbc.spi.SqlExceptionHelper   : SQL Error: 23502, SQLState: 23502
2022-01-11 15:51:29.818 ERROR 2434 --- [           main] o.h.engine.jdbc.spi.SqlExceptionHelper   : NULL not allowed for column "ID"; SQL statement:

Test method에 @Commit annotation을 사용하면, @Transactional 이 있어도 rollback되지 않고 DB에 반영된다.

ERROR 수정
https://www.inflearn.com/questions/389513 해당 링크를 보면서,

  • h2 DB를 낮은 버전으로 새로 받고, 새 db를 만들어서 table을 생성
  • 이후 실행했을 때 방금 생성한 Member table을 못 찾는다는 에러가 떠서 application.properties 에서 spring.jpa.hibernate.ddl-auto = create 로 수정
  • 에러 fix 완료 ...

스프링 데이터 JPA

스프링 데이터 JPA를 이용하면, 이전에 구현한 Repository에 구현체 없이도 Interface만으로 개발이 가능하다. (CRUD 기능도 제공한다!)

JPA를 편리하게 사용하도록 해주는 기술이므로 JPA에 대한 이해와 학습이 수반되어야 한다!

repository/SpringDataJpaRepository.java (interface!)

package hello.hellospring.repository;

import hello.hellospring.domain.Member;
import org.springframework.data.jpa.repository.JpaRepository;

public interface SpringDataJpaMemberRepository extends JpaRepository<Member, Long>, MemberRepository {
    @Override
    Optional<Member> findByName(String name);
}
  • Interface가 Interface를 상속받을 때는 extends 를 사용하고 다중 상속이 가능하다.
  • JpaRepository<Member, Long>
    • Member : 사용하는 Entity
    • Long : Entity의 식별자 (PK, id)
  • 스프링 데이터 JPA는 JpaRepository 를 상속받아 사용하는 interface를 발견하면, SpringDataJpaRepository 가 알아서 구현체를 만들어 스프링 빈에 등록하도록 한다. 그래서 이를 SpringConfig 를 통해 가져다 사용하면 된다.

SpringConfig

private final MemberRepository memberRepository;

public SpringConfig(MemberRepository memberRepository) {
    this.memberRepository = memberRepository;
}

@Bean
public MemberService memberService() {
    return new MemberService(memberRepository);
}

//    @Bean
//    public MemberRepository memberRepository() {
//        //return new JdbcMemberRepository(dataSource);
//        //return new JdbcTemplateMemberRepository(dataSource);
//        //return new JpaMemberRepository(em);
//    }
  • 이로서 스프링 데이터 JPA가 만든 구현체가 등록된다.
  • MemberService 에 의존 관계를 설정한다.

SpringConfig 생성자로 인해 MemberRepository 를 스프링 컨테이너에서 탐색하지만, 사용자가 등록한 것은 없다.

  • 그러나 위에서 생성한 JpaRepository 를 상속받는 interface를 통해 스프링 데이터 JPA가 구현체를 생성해 스프링 빈으로 등록한다.
  • 이렇게 등록된 MemberRepositoryMemberService 에도 그대로 전달해 사용한다.

테스트해보면 전과 같은 에러가 뜬다..............

원리

JpaRepository 를 까보면,

  • 기본적인 method들이 제공되는 것을 확인할 수 있다.
  • 그리고 JpaRepository 가 상속받는 상위 Repository들을 확인해보면, PagingAndSortingRepository (Paging 기능), CrudRepository 가 있다.
    • 즉, 기본적인 단순 조회, CRUD가 이렇게 제공된다는 것을 확인할 수 있다.
    • 그래서 이들을 가져다 쓰면 되는 것이다!
    • 하지만, 이와 같은 공통적으로 제공하는 기능이 아닌 자체적인 기능을 구현하기 위해서는 Optional<Member> findByName(String name); 과 같이 interface에 기술해야 한다.
      • 규칙: findBy{ }And{ }(String { }, Long { }) 과 같은 형태여야 한다.

실무에서는 JPA스프링 데이터 JPA 를 기본으로 사용하고, 복잡한 동적 query는 Querydsl 이라는 라이브러리를 사용한다. 이는 자바 코드로 안전하게 query를 작성할 수 있고, 동적 query 또한 편리하게 작성하도록 돕는다.

📌 중요한 개념

개방-폐쇄 원칙, 다형성, @Transactional, JPA

자료 참고

profile
배우고 정리하고 공유하기

0개의 댓글