[스프링 입문] Spring Boot 입문(3)
7. 스프링 DB 접근 기술
1) H2 데이터베이스 설치
먼저 H2 데이터베이스를 설치한다.
h2 Console을 실행한 후 초기 화면에서 연결을 하면 test.mv.db 파일이 생성된다.
이후에는 아래와 같이 jdbc:h2:tcp://localhost/~/test로 설정을 해야 소켓을 통해서 접속이 되므로 변경해주기 바란다.
혹은 cd "C:\Program Files (x86)\H2\bin" 로 이동해서 h2.bat으로 실행하는 방법도 있다.
맨 처음에 Member domain으로 생성했던 테이블을 DB에 생성한다.
create table member
(
id bigint generated by default as identity,
name varchar(255),
primary key (id)
);
2) 순수 JDBC 설정하기
예전에 사용하던 데이터베이스 연동 기술로 어떻게 동작하는지만 구경하자.
build.gradle 파일에 jdbc, h2 데이터베이스 관련 라이브러리 추가하자.
implementation 'org.springframework.boot:spring-boot-starter-jdbc'
runtimeOnly 'com.h2database:h2'
DB 관련 처리를 spring boot가 해주므로 경로만 resources/application.properties에 추가해주자.
또한 기존에 MemberRepository 인터페이스를 implements하는 JdbcMemberRepository 클래스를 생성한다.
package hello.hello_spring.repository;
import hello.hello_spring.domain.Member;
import org.springframework.jdbc.datasource.DataSourceUtils;
import javax.sql.DataSource;
import java.sql.*;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
public class JdbcMemberRepository implements MemberRepository {
private final DataSource dataSource;
public JdbcMemberRepository(DataSource dataSource) {
this.dataSource = dataSource;
}
@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> 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"));
return Optional.of(member);
} else {
return Optional.empty();
}
} catch (Exception e) {
throw new IllegalStateException(e);
} finally {
close(conn, pstmt, rs);
}
}
@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"));
members.add(member);
}
return members;
} 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);
}
return Optional.empty();
} catch (Exception e) {
throw new IllegalStateException(e);
} finally {
close(conn, pstmt, rs);
}
}
private Connection getConnection() {
return DataSourceUtils.getConnection(dataSource);
}
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);
}
}
스프링부트가 데이터베이스를 만들고 스프링을 통해서 DataSource를 주입 받는다.
dataSource.getConnection()을 통해서 연결을 한다.
이후에 SpringConfig를 통해서 Bean에서 MemoryMemberRepository를 JdbcMemberRepository로 변경한다.
package hello.hello_spring;
import hello.hello_spring.repository.JdbcMemberRepository;
import hello.hello_spring.repository.MemberRepository;
import hello.hello_spring.service.MemberService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import javax.sql.DataSource;
@Configuration
public class SpringConfig {
private DataSource dataSource;
@Autowired
public SpringConfig(DataSource dataSource){
this.dataSource = dataSource;
}
@Bean
public MemberService memberService(){
return new MemberService(memberRepository());
}
@Bean
public MemberRepository memberRepository(){
// return new MemoryMemberRepository();
return new JdbcMemberRepository(dataSource);
}
}
이 때 datasource가 필요하기 때문에 Autowired를 사용해서 Datasource를 설정한다.
객체 지향의 장점 중 하나인 다형성을 활용하는 예제였다.
Spring은 다형성을 굉장히 편리하게 사용할 수 있게 Spring Container에서 이를 지원해주고 DI를 통해서 쉽게 사용할 수 있다.
그림과 같이 보자면 기존에는 MemoryMemberRepository로 구현체를 설정하였으나 이를 JdbcMemberRepository로 변경한 것이다.
스프링의 DI를 사용하면 기존 코드를 전혀 손대지 않고 설정만으로 구현 클래스를 변경할 수 있다.
개방-폐쇄 원칙(OCP, Open-Closed Principle) : 확장에는 열려있고, 수정과 변경에는 닫혀있다.
3) 스프링 통합 테스트
스프링 컨테이너와 DB까지 연결한 통합 테스트를 작성해보자.
Controller -> Service-> Repository
Controller | HTTP 요청 처리 | @RestController | 요청 수신, 응답 반환 |
Service | 비즈니스 로직 처리 | @Service | 로직 수행, 데이터 가공 |
Repository | 데이터베이스 접근 (CRUD) | @Repository | 데이터 저장/조회 |
간단하게 Controller는 HTTP 요청과 응답을 처리하는 계층으로 Service 계층과 상호작용해서 클라이언트의 요청을 처리하고 결과를 반환한다.
Service는 비즈니스 로직을 처리하는 계층으로 Repository에서 데이터를 가져와서 비즈니스 규칙을 적용한 후 결과를 반환한다.
Repository는 데이터베이스와 직접 상호작용하는 계층으로 CRUD 작업을 수행한다.
DB와 스프링 컨테이너를 연결한 통합 테스트 코드이다.
package hello.hello_spring.service;
import hello.hello_spring.domain.Member;
import hello.hello_spring.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.*;
import static org.junit.jupiter.api.Assertions.*;
@SpringBootTest
@Transactional
class MemberServiceIntegrationTest {
@Autowired
MemberService memberService;
@Autowired
MemberRepository memberRepository;
@Test
public void 회원가입() throws Exception {
//Given
Member member = new Member();
member.setName("test");
//When
Long saveId = memberService.join(member);
//Then
Member findMember = memberRepository.findById(saveId).get();
assertEquals(member.getName(), findMember.getName());
}
@Test
public void 중복_회원_예외() throws Exception {
//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("이미 존재하는 회원입니다.");
}
}
달라진 점은 @SpringBootTest, @Transactional이다.
@SpringBootTest : 스프링 컨테이너와 테스트를 함께 실행한다.
@Transactional : 테스트 케이스에 이 annotation이 있으면 테스트 시작 전에 트랜잭션을 시작하고 테스트 완료 후 항상 롤백한다. 이렇게 하면 DB에 데이터가 반영되지 않아서 다음 테스트에 영향을 주지 않는다.
Transactional은 Test 코드에서만 정상적으로 작동한다.
순수한 Java 코드로 작성한 테스트는 단위 테스트이고 Transactional 이용하는 테스트는 통합 테스트라고 한다.
단위 테스트부터 잘 만드는 것이 좋다.
4) 스프링 JdbcTemplate
순수 Jdbc와 동일하게 환경 설정을 한다.
Spring JdbcTemplate과 MyBatis 같은 라이브러리는 JDBC API에서의 반복 코드 대게를 제거한다.
다만 SQL은 직접 작성해야 한다.
생성자가 하나라면 @Autowired 생략이 가능하고 repository/JdbcTemplateMemoryRepository를 생성하고 코드는 아래와 같다.
package hello.hello_spring.repository;
import hello.hello_spring.domain.Member;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
import org.springframework.jdbc.core.simple.SimpleJdbcInsert;
import java.util.HashMap;
import java.util.List;
import javax.sql.DataSource;
import java.util.Map;
import java.util.Optional;
public class JdbcTemplateMemberRepository implements MemberRepository {
private final JdbcTemplate 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> findById(Long id) {
List<Member> result = jdbcTemplate.query("select * from member where id = ?", memberRowMapper(), id);
return result.stream().findAny();
}
@Override
public Optional<Member> findByName(String name) {
List<Member> result = jdbcTemplate.query("select * from member where name = ?", memberRowMapper(), name);
return result.stream().findAny();
}
@Override
public List<Member> findAll() {
return jdbcTemplate.query("select * from member", memberRowMapper());
}
private RowMapper<Member> memberRowMapper() {
return (rs, rowNum) -> {
Member member = new Member();
member.setId(rs.getLong("id"));
member.setName(rs.getString("name"));
return member;
};
}
}
Jdbc를 잘 줄여놓은 것이 JdbcTemplate이라고 생각하면 된다.
SimpleJdbcInsert는 쿼리를 작성할 필요 없이 테이블 명과 컬럼 명을 쓰면 insert문을 만들어준다.
5) JPA
JPA는 기존의 반복 코드는 물론이고, 기본적인 SQL도 JPA가 직접 만들어서 실행한다.
JPA를 사용하면 SQL과 데이터 중심의 설계에서 객체 중심의 설계로 전환할 수 있고 개발 생산성을 크게 높일 수 있다.
build.gradle에 spring-boot-starter-data-jpa를 추가한다.
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
implementation 'org.springframework.boot:spring-boot-starter-web'
// 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'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}
또한 application.properties에 아래 두 줄을 추가한다.
spring.jpa.show-sql=true
spring.jpa.hibernate.ddl-auto=none
JPA로 실행하는 쿼리를 볼 수 있고 객체를 설정하면 자동으로 DDL을 설정하여준다.
none으로 설정하면 실행이 안되고 create로 설정하면 알아서 관리해준다.
JPA는 인터페이스를 제공하고 구현체로 Hybernate, EclipseLink 등 여러 구현 기술이 있는데 hybernate를 쓰려고 한다.
JPA : JPA(Java Persistence API)는 Java 애플리케이션에서 데이터베이스와 객체 간의 ORM(Object-Relational Mapping)을 표준화한 Java EE 표준 인터페이스로 객체와 관계형 데이터베이스의 데이터를 자동으로 매핑하여 SQL을 직접 작성하지 않고도 데이터베이스 작업을 수행할 수 있다.
기존에 도메인으로 생성한 Member를 아래와 같이 수정해보자.
package hello.hello_spring.domain;
import jakarta.persistence.*;
@Entity
public class Member {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "username")
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;
}
}
먼저 @Entity를 통해서 스프링이 관리하는 Bean으로 등록한다.
또한 @Id를 통해서 PK 설정을 하고 GenerateValue에 strategy를 설정하여 PK 생성 방식을 설정한다.
@Column의 경우 실제 DB에서 사용하는 이름을 따로 설정할 수 있다.
JPA는 EntityManager를 기반으로 동작을 한다.
초반에 build.gradle에 spring-boot-starter-data-jpa를 추가하였는데 스프링 부트가 자동으로 EntityManager를 생성해준다.
우리는 JpaMemberRepository를 만들고 해당 레포지토리에 DI를 해주면 된다.
PK로 작동하는 것들은 find를 사용하면 되나 PK 기반이 아닌 것들은 JPQL을 작성해줘야한다.
JPA 기술을 스프링에 감싸서 제공하는 스프링 데이터 JPA를 사용하면 이 JPQL 작성도 불필요해진다(=findByName, findAll).
JPA를 통한 모든 데이터 변경은 Transaction 안에서 실행해야 한다.
따라서 MemberService 위에 @Transaction 어노테이션을 추가한다.
package hello.hello_spring.repository;
import hello.hello_spring.domain.Member;
import jakarta.persistence.EntityManager;
import java.util.List;
import java.util.Optional;
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) {
return em.createQuery("select m from Member m where m.name = :name",Member.class )
.setParameter("name", name)
.getResultList().stream().findAny();
}
@Override
public List<Member> findAll() {
return em.createQuery("select m from Member m", Member.class)
.getResultList();
}
}
이후에 통합 테스트를 실행하면 아래와 같이 Hibernate를 통해서 쿼리가 실행되는 것을 확인할 수 있다.
6) 스프링 데이터 JPA
스프링 데이터 JPA용 interface인 SpringDataJpaMemberRepositoroy interface를 생성한다.
interface는 다중 상속이 되므로 기존의 MemberRepository와 JpaRepository를 extends해준다.
interface이므로 implelments가 아니라 extends를 한다.
SpringDataJpaMemberRepository는 JpaRepository를 extends하고 있는데 구현체가 자동으로 생성되고 이를 스프링 빈으로 자동으로 등록한다.
직접 등록할 필요 없이 SpringDataJPA가 JpaRepository를 보고 스프링 빈에 자동으로 등록한다.
package hello.hello_spring.repository;
import hello.hello_spring.domain.Member;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface SpringDataJpaMemberRepository extends JpaRepository<Member, Long>, MemberRepository {
@Override
Optional<Member> findByName(String name);
}
매우 간단하다.
그냥 JpaRepository 설정 후 T와 PK 설정만 하고 findByName 설정만 하면 자동으로 쿼리부터 response 까지 설정된다.
기존의 Configuration 파일도 변경한다.
package hello.hello_spring;
import hello.hello_spring.repository.JdbcMemberRepository;
import hello.hello_spring.repository.JdbcTemplateMemberRepository;
import hello.hello_spring.repository.JpaMemberRepository;
import hello.hello_spring.repository.MemberRepository;
import hello.hello_spring.service.MemberService;
import jakarta.persistence.EntityManager;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import javax.sql.DataSource;
@Configuration
public class SpringConfig {
private final MemberRepository memberRepository;
@Autowired
public SpringConfig(MemberRepository memberRepository) {
this.memberRepository = memberRepository;
}
@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);
//
// }
}
MemberRepository는 생성자로 생성하면 스프링 빈에 등록된 걸 자동으로 찾아서 설정해준다.
따라서 아래의 memberRepository 코드는 불필요하다.
JpaRepository에서 Paing & Sorting Repository에서 CRUD Repository를 다 제공한다.
따라서 findById와 findAll 등을 직접 구현할 필요가 없어진다.
추가로 findByName만 작성하면 전부 작동한다.
다만 findByName 메서드를 만들 때 규칙이 있어서 interface 이름을 잘 작성해주어야 한다(단순한 것은 인터페이스로 작성 가능)
복잡한 동적 쿼리의 경우 Querydsl이라는 라이브러리를 사용해서 작성한다.
정리하자면 JPA + Spring Data JPA + Querydsl(동적), 추가적으로 해결하기 어려운 쿼리라면 JPA가 제공하는 네이티브 쿼리를 사용하거나 JdbcTemplate을 사용한다.
8. AOP( Aspect Oriented Programming, 관점 지향 프로그래밍 )
공통 관심 사항(cross-cutting concern) vs 핵심 관심 사항(core concern)을 분리한다.
만약 모든 서비스에 시간 측정 기능을 추가한다면 기존에는 직접 서비스에 시간 측정 로직을 다 추가해야했다.
AOP를 적용하자면 시간 측정 로직을 한 곳에 모은 후 원하는 서비스에 적용시키는 것이다.
스프링에선 AOP 기술을 제공하고 있다.
aop 패키지 생성 후 aop/TimeTraceAop를 생성한다.
1) @Component를 통해서 component로 등록하기
package hello.hello_spring.aop;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.stereotype.Component;
@Aspect
@Component
public class TimeTraceAop {
@Around("execution(* hello.hello_spring..*(..))")
public Object execute(ProceedingJoinPoint joinPoint) throws Throwable {
long start = System.currentTimeMillis();
System.out.print("시작: " + joinPoint.toString());
try {
return joinPoint.proceed();
} finally {
long finish = System.currentTimeMillis();
long timeMs = finish - start;
System.out.println("끝: " + joinPoint.toString() + " " + timeMs + "ms");
}
}
}
2) SpringConfig를 통해서 Bean 등록하기
SpringConfig 파일에 아래 코드를 추가한다.
@Bean
public TimeTraceAop TimeTraceAop(){
return new TimeTraceAop();
}
설정 후 서비스를 실행하면 아래와 같은 로그를 확인할 수 있다.
시작: execution(String hello.hello_spring.controller.MemberController.create(MemberForm))시작: execution(Long hello.hello_spring.service.MemberService.join(Member))시작: execution(Optional hello.hello_spring.repository.SpringDataJpaMemberRepository.findByName(String))Hibernate: select m1_0.id,m1_0.name from member m1_0 where m1_0.name=?
끝: execution(Optional hello.hello_spring.repository.SpringDataJpaMemberRepository.findByName(String)) 2ms
시작: execution(Member hello.hello_spring.repository.MemberRepository.save(Member))Hibernate: insert into member (name,id) values (?,default)
끝: execution(Member hello.hello_spring.repository.MemberRepository.save(Member)) 18ms
끝: execution(Long hello.hello_spring.service.MemberService.join(Member)) 22ms
끝: execution(String hello.hello_spring.controller.MemberController.create(MemberForm)) 25ms
시작: execution(String hello.hello_spring.controller.HomeController.home())끝: execution(String hello.hello_spring.controller.HomeController.home()) 0ms
시작: execution(String hello.hello_spring.controller.MemberController.list(Model))시작: execution(List hello.hello_spring.service.MemberService.findMembers())시작: execution(List org.springframework.data.repository.ListCrudRepository.findAll())Hibernate: select m1_0.id,m1_0.name from member m1_0
끝: execution(List org.springframework.data.repository.ListCrudRepository.findAll()) 4ms
끝: execution(List hello.hello_spring.service.MemberService.findMembers()) 5ms
끝: execution(String hello.hello_spring.controller.MemberController.list(Model)) 7ms
별도의 공통 로직을 만들어서 간단하게 사용할 수 있다.
Around를 설정해서 어떤 서비스에서 사용할 것인지 설정이 가능하고 핵심 관리 사항을 깔끔하게 유지할 수 있다.
@Aspect는 Spring AOP에서 관점을 정의할 때 사용하는 어노테이션으로 공통 기능을 비즈니스 로직과 분리하여 관리할 수 있도록 지원한다.
JoinPoint는 대상 메서드가 실행되는 특정 지점을 의미하고 Advice, Pointcut, Weaving 등이 있다.
- 스프링의 Proxy AOP 동작 방식
AOP 적용 전 동작 방식은 아래와 같다.
AOP 적용 후 동작 방식은 아래와 같다.
AOP가 있으면 프록시 서비스를 만든다.
스프링 컨테이너는 프록시 스프링 빈이 끝나면 joinPoint.proceed를 통해서 실제 서비스가 실행된다.
자세한 것은 핵심 강의를 수강하면서 추가적으로 공부해야할 거 같다.
화이팅..!