/**
* 회원 가입
*/
public Long join(Member member) {
long start = System.currentTimeMillis();
try {
validateDuplicateMember(member); // 중복 회원 검증.
memberRepository.save(member);
return member.getId();
} finally {
long finish = System.currentTimeMillis();
long timeMs = finish - start;
System.out.println("join 시간 측정 : " + timeMs);
}
}
currentTimeMillis() 를 적용하여 회원가입 시간 측정 시 아래와 같이 측정되는것을 확인 할 수 있음.
문제점
시간을 측정하는 기능은 핵심 관심 사항이 아님.
시간을 측정하는 기능은 핵심 로직이 아닌, 공통 로직. (공통 관심 사항 - cross-cutting concern)
위와 같이 시간 측정 로직, 핵심 비즈니스 로직이 섞여있는 경우 유지보수가 어렵다.
위와 같이 시작 측정 로직이 있는 경우 변경이 필요하다면, 모든 로직을 찾아서 변경해야 하는 어려움이 존재.
## AOP 적용
공통 관심 사항(cross-cutting concern), 핵심 관심 사항(core concern) 분리.
시간 측정 AOP 등록
AOP의 경우 @Aspect 에노테이션 추가 필요.
AOP의 경우 아래와 같이 @Component 에노테이션 추가 또는 SpringConfig에 직접 등록하여 사용.
@Aspect
@Component
public class TimeTraceAop {
@Bean
public TimeTraceAop timeTraceAop() {
return new TimeTraceAop();
}
package hello.hellospring.domain;
import javax.persistence.*;
@Entity
public class Member {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// DB에 있는 컬럼명이 다른 경우 @Column(name = "username") 로 설정. (예시는 컬럼명이 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;
}
}
JpaMemberRepository
@Transactional 추가 이유 : JPA를 통한 모든 데이터 변경은 트랜잭션 안에서 실행해야 한다.
package hello.hellospring.repository;
import hello.hellospring.domain.Member;
import org.springframework.transaction.annotation.Transactional;
import javax.persistence.EntityManager;
import java.util.List;
import java.util.Optional;
@Transactional
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() {
List<Member> result = em.createQuery("select m from Member m", Member.class)
.getResultList();
return result;
}
}
SpringConfig
EntityManager 추가 및 리포지토리 JpaMemberRepository 로 변경
package hello.hellospring;
import hello.hellospring.repository.JdbcMemberRepository;
import hello.hellospring.repository.JdbcTemplateMemberRepository;
import hello.hellospring.repository.JpaMemberRepository;
import hello.hellospring.repository.MemberRepository;
import hello.hellospring.service.MemberService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import javax.persistence.EntityManager;
import javax.sql.DataSource;
@Configuration
public class SpringConfig {
/*private final DataSource dataSource;
@Autowired
public SpringConfig(DataSource dataSource) {
this.dataSource = dataSource;
}*/
private final 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);
}
}
## 스프링 데이터 JPA
스프링 부트와 JPA만 사용해도 개발 생산성이 많이 증가, 코드도 확 줄어든다.
스프링 데이터 JPA 까지 사용하면 리포지토리에 구현 클래스 없이 인터페이스 만으로 개발을 완료할 수 있다.
스프링 데이터 JPA가 CRUD 기능도 제공한다.
관계형 데이터베이스 사용 시 스프링 데이터 JPA, JPA는 필수임.
스프링 데이터 JPA 는 JPA 를 편리하게 사용해 주는 도구일 뿐이므로 JPA부터 단계적으로 학습하는 걸 권장.
기존에 생성했던 MemoryMemberRepository (인터페이스) 를 구현체로 생성.
package hello.hellospring.repository;
import hello.hellospring.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 {
// DB 연결을 위해 필요.
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 connection = null;
PreparedStatement pstmt = null;
ResultSet resultSet = null;
try {
connection = getConnection();
pstmt = connection.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS);
pstmt.setString(1, member.getName()); // sql의 ?에 매칭됨.
pstmt.executeUpdate(); // DB에 쿼리 전송.
resultSet = pstmt.getGeneratedKeys();
if (resultSet.next()) {
member.setId(resultSet.getLong(1));
} else {
throw new SQLException("회원 ID 조회 실패.");
}
return member;
} catch (Exception e) {
throw new IllegalStateException(e);
} finally {
close(connection, pstmt, resultSet);
}
}
@Override
public Optional<Member> findById(Long id) {
String sql = "select * from member where id = ?";
Connection connection = null;
PreparedStatement pstmt = null;
ResultSet resultSet = null;
try {
connection = getConnection();
pstmt = connection.prepareStatement(sql);
pstmt.setLong(1, id); // sql의 ?에 매칭됨.
resultSet = pstmt.executeQuery(); // DB에 쿼리 전송.
if (resultSet.next()) {
Member member = new Member();
member.setId(resultSet.getLong("id"));
member.setName(resultSet.getString("name"));
return Optional.of(member);
} else {
return Optional.empty();
}
} catch (Exception e) {
throw new IllegalStateException(e);
} finally {
close(connection, pstmt, resultSet);
}
}
@Override
public List<Member> findAll() {
String sql = "select * from member";
Connection connection = null;
PreparedStatement pstmt = null;
ResultSet resultSet = null;
try {
connection = getConnection();
pstmt = connection.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS);
resultSet = pstmt.executeQuery(); // DB에 쿼리 전송.
List<Member> members = new ArrayList<>();
while (resultSet.next()) {
Member member = new Member();
member.setId(resultSet.getLong("id"));
member.setName(resultSet.getString("name"));
members.add(member);
}
return members;
} catch (Exception e) {
throw new IllegalStateException(e);
} finally {
close(connection, pstmt, resultSet);
}
}
@Override
public Optional<Member> findByName(String name) {
String sql = "select * from member where name = ?";
Connection connection = null;
PreparedStatement pstmt = null;
ResultSet resultSet = null;
try {
connection = getConnection();
pstmt = connection.prepareStatement(sql);
pstmt.setString(1, name);
resultSet = pstmt.executeQuery();
if(resultSet.next()) {
Member member = new Member();
member.setId(resultSet.getLong("id"));
member.setName(resultSet.getString("name"));
return Optional.of(member);
}
return Optional.empty();
} catch (Exception e) {
throw new IllegalStateException(e);
} finally {
close(connection, pstmt, resultSet);
}
}
private Connection getConnection() {
return DataSourceUtils.getConnection(dataSource);
}
private void close(Connection connection, PreparedStatement pstmt, ResultSet resultSet) {
try {
if (resultSet != null) {
resultSet.close();
}
} catch (SQLException e) {
e.printStackTrace();
}
try {
if (pstmt != null) {
pstmt.close();
}
} catch (SQLException e) {
e.printStackTrace();
}
try {
if (connection != null) {
close(connection);
}
} catch (SQLException e) {
e.printStackTrace();
}
}
private void close(Connection conn) throws SQLException {
DataSourceUtils.releaseConnection(conn, dataSource);
}
}
SpringConfig 설정 변경
기존에 작업 수행 시 메모리에 저장되도록 Repository 를 MemoryMemberRepository 로 설정 함.
아래와 같이 입력하여 위에서 만든 JdbcMemberRepository 로 변경.
package hello.hellospring;
import hello.hellospring.repository.JdbcMemberRepository;
import hello.hellospring.repository.MemberRepository;
import hello.hellospring.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 final 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);
}
}
개방 폐쇄의 원칙(OCP:Open-Closed Principle) : 확장에는 열려있고, 수정, 변경에는 닫혀있다. (위 코드는 개발 폐쇄의 원칙이 지켜진 것.)
스프링의 DI (Dependencied Injection) 활용 시, 위와 같이 기존 소스 코드를 전혀 손대지 않고 설정만으로 구현 클래스 변경이 가능하다.
실행 결과
h2 데이터베이스 실행 된 상태에서 서버 재 시작 후 http://localhost:포트번호 입력하여 메인화면 진입.
회원 등록 테스트 진행 후 서버 재 시작하여 다시 확인 시 데이터가 유지되는것을 확인할 수 있다.
# 스프링 통합 테스트
스프링 컨테이너와 DB까지 연결한 통합 테스트 진행.
MemberServiceIntegrationTest
package hello.hellospring.service;
import hello.hellospring.domain.Member;
import hello.hellospring.repository.MemberRepository;
import hello.hellospring.repository.MemoryMemberRepository;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
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.junit.jupiter.api.Assertions.assertThrows;
@SpringBootTest
@Transactional
class MemberServiceIntegrationTest {
@Autowired MemberService memberService;
@Autowired MemberRepository memberRepository;
@Test
void 회원가입() {
// given
Member member = new Member();
member.setName("hello");
// when
Long saveId = memberService.join(member);
// then
Member findMember = memberService.findOne(saveId).get();
Assertions.assertThat(member.getName()).isEqualTo(findMember.getName());
}
@Test
public void 중복_회원_예외() {
// given
Member member1 = new Member();
member1.setName("spring1");
Member member2 = new Member();
member2.setName("spring1");
// when
memberService.join(member1);
IllegalStateException e = assertThrows(IllegalStateException.class, () -> memberService.join(member2));
Assertions.assertThat(e.getMessage()).isEqualTo("이미 존재하는 회원 입니다.");
// then
}
}
@SpringBootTest
테스트 케이스에 해당 에노테이션 추가 시, 스프링 컨테이너와 테스트를 함께 실행한다.
@Transactional
테스트 케이스에 해당 에노테이션 추가 시, 테스트 실행 전 트랜잭션을 걸고 시작하고, 테스트 완료 후 항상 ROLLBACK 한다.
롤백으로 인해 DB에 테스트 데이터가 남지 않아서, 테스트로 입력한 데이터에 대한 별도의 delete 로직을 추가하지 않아도 테스트를 반복 실행 할 수 있다.
테스트 케이스에 붙었을 때만 항상 롤백하도록 동작 함.
## 스프링 JdbcTemplate (실무에서 많이 사용됨)
순수 Jdbc 와 동일한 환경설정을 하면 된다.
스프링 JdbcTemplate과 MyBatis 같은 라이브러리는 JDBC API에서 본 반복적인 코드(중복)를 대부분 제거해준다. 하지만, SQL은 직접 작성해야 한다.
JdbcTemplateMemberRepository
생성자가 1개만 존재하는 경우 @Autowired 생략 가능.
package hello.hellospring.repository;
import hello.hellospring.domain.Member;
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 javax.sql.DataSource;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.HashMap;
import java.util.List;
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 사용 시 쿼리 짤 필요없이 테이블명, key명 입력하여 자동으로 진행.
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;
};
}
}
SpringConfig 설정
package hello.hellospring;
import hello.hellospring.repository.JdbcMemberRepository;
import hello.hellospring.repository.JdbcTemplateMemberRepository;
import hello.hellospring.repository.MemberRepository;
import hello.hellospring.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 final 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);
return new JdbcTemplateMemberRepository(dataSource);
}
}