회원 관리 예제
웹 애플리케이션 계층 구조를 보고 백엔드 관점에서 회원 관리 예제를 적용해보자.
- 컨트롤러 : 웹 MVC의 컨트롤러 역할
- 서비스 : 핵심 비즈니스 로직 구현
- 리포지토리 : 데이터베이스에 접근, 도메인 객체를 DB에 저장하고 관리
- 도메인 : 비즈니스 도메인 객체 (회원, 주문, 쿠폰 등 데이터베이스에서 저장하고 관리되는 모델)
비즈니스 요구사항 정리
- 데이터 : 회원 ID, 이름
- 기능 : 회원 등록, 조회
- 데이터 저장소가 아직 선정되지 않음
- 클래스 의존 관계는 데이터베이스가 선정되지 않았다는 가정이므로, 우선 인터페이스로 구현 클래스를 변경할 수 있도록 설계하자. 개발을 진행하기 위해서 초기 개발 단계에서는 구현체로 가벼운 메모리 기반의 데이터 저장소 사용
회원 도메인과 리포지토리 만들기
public class Member {
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;
}
}
- 요구사항에 회원 ID, 이름이 있으므로 이에 맞게
Member
도메인을 생성하자.ID
는 서버에서 사용자 구별을 위해 사용하고, 이름은 사용자가 회원가입할 때 사용한다고 생각하자.
package hello.hellospring.repository;
import hello.hellospring.domain.Member;
import java.util.List;
import java.util.Optional;
public interface MemberRepository {
Member save(Member member);
Optional<Member> findById(Long id);
Optional<Member> findByName(String name);
List<Member> findAll();
}
- 리포지토리 인터페이스를 생성하자.
Optional
은 자바8부터 사용 가능한 클래스로null
의 값을 가질 수도 있을 경우 사용한다. 스프링에서는 보통Optional
을 자주 사용하니 익혀두자.
package hello.hellospring.repository;
import hello.hellospring.domain.Member;
import java.util.*;
public class MemoryMemberRepository implements MemberRepository{
private static Map<Long, Member> store = new HashMap<>();
private static long sequence = 0L;
@Override
public Member save(Member member) {
member.setId(++sequence);
store.put(member.getId(), member);
return member;
}
@Override
public Optional<Member> findById(Long id) {
return Optional.ofNullable(store.get(id));
}
@Override
public Optional<Member> findByName(String name) {
return store.values().stream()
.filter(member -> member.getName().equals(name))
.findAny();
}
@Override
public List<Member> findAll() {
return new ArrayList<>(store.values());
}
public void clearStore() {
store.clear();
}
}
- 리포지토리의 구현체로
MemoryMemberRepository
를 생성하자. 회원 정보를 담을HashMap
과 회원 ID를 관리할long
을 만든다. 실무에서는 동시성을 고려해야하지만, 현재 프로젝트는 고려하지 않고 진행한다. Optional
을 통해null
일 경우에도 감싸서 보낼 수 있게 리턴한다.
자바는 JUnit이라는 프레임워크로 테스트 케이스를 지원한다. 리포지토리를 개발하였으니 해당 기능이 정상 동작하는 지에 대해 테스트를 작성해보자. 스프링 부트에서
test
디렉토리를 자동 생성해준다.
회원 리포지토리 테스트 케이스 작성
package hello.hellospring.repository;
import hello.hellospring.domain.Member;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.assertj.core.api.Assertions.*;
class MemoryMemberRepositoryTest {
MemoryMemberRepository repository = new MemoryMemberRepository();
@AfterEach
public void afterEach() {
repository.clearStore();
}
@Test
public void save() {
// given : 멤버 생성
Member member = new Member();
member.setName("spring");
// when : 멤버 저장했을 때
repository.save(member);
// then : 저장한 멤버를 꺼내왔을 때와 생성한 멤버가 일치한지 비교
Member result = repository.findById(member.getId()).get();
assertThat(result).isEqualTo(member);
}
@Test
public void findByName() {
// given : 여러 회원이 등록된 상황
Member member1 = new Member();
member1.setName("sasca37");
repository.save(member1);
Member member2 = new Member();
member1.setName("myeongju00");
repository.save(member2);
// when : 회원 이름을 통해 회원 정보 갖고 왔을 때
Member result = repository.findByName(member1.getName()).get();
// then : 회원 정보와 생성한 회원이 일치하는 지 비교
assertThat(result).isEqualTo(member1);
}
@Test
public void findAll() {
// given : 여러 회원이 등록된 상황
Member member1 = new Member();
member1.setName("sasca37");
repository.save(member1);
Member member2 = new Member();
member1.setName("myeongju00");
repository.save(member2);
// when : 회원 리스트를 가져왔을 때
List<Member> result = repository.findAll();
// then : 회원 리스트 수와 현재 생성한 회원의 수가 일치한지 비교
assertThat(result.size()).isEqualTo(2);
}
}
given
,when
,then
구조로 테스트 케이스를 작성하자.@AfterEach
: 한 번에 여러 테스트를 하면 직전 테스트의 결과가 남을 수 있다. 각 테스트가 종료될 때마다 해당 애노테이션을 통해 메모리에 저장된 데이터를 삭제하여 독립적으로 실행할 수 있도록 만들자.
회원 서비스 개발
import hello.hellospring.domain.Member;
import hello.hellospring.repository.MemberRepository;
import hello.hellospring.repository.MemoryMemberRepository;
import java.util.List;
import java.util.Optional;
public class MemberService {
private final MemberRepository memberRepository = new MemoryMemberRepository();
// 중복 회원 여부 검사 후 통과 시 회원 정보 저장
public Long join(Member member) {
validateDuplicateMember(member);
memberRepository.save(member);
return member.getId();
}
private void validateDuplicateMember(Member member) {
memberRepository.findByName(member.getName())
.ifPresent(m -> { // 값이 있으면 동작
throw new IllegalArgumentException("이미 존재하는 회원입니다.");
});
}
// 전체 회원 조회
public List<Member> findMembers() {
return memberRepository.findAll();
}
public Optional<Member> findOne(Long memberId) {
return memberRepository.findById(memberId);
}
}
- 핵심 비즈니스 로직을 담을 서비스를 개발해보자.
Optional
을 사용하면stream
기능을 활용하여 여러 기능들을 사용할 수 있다.
회원 서비스 테스트
package hello.hellospring.service;
import hello.hellospring.domain.Member;
import hello.hellospring.repository.MemoryMemberRepository;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertThrows;
class MemberServiceTest {
MemberService memberService = new MemberService();
MemoryMemberRepository memberRepository = new MemoryMemberRepository();
@AfterEach
public void afterEach() {
memberRepository.clearStore();
}
@Test
void 회원가입() {
// given
Member member = new Member();
member.setName("sasca37");
// when
Long saveId = memberService.join(member);
// then
Member findMember = memberService.findOne(saveId).get();
assertThat(findMember.getName()).isEqualTo(member.getName());
}
@Test
void 중복_회원_예외() {
// given
Member member1 = new Member();
member1.setName("sasca37");
Member member2 = new Member();
member2.setName("sasca37");
// when
memberService.join(member1);
// then
IllegalStateException e = assertThrows(IllegalStateException.class,
() -> memberService.join(member2));
assertThat(e.getMessage()).isEqualTo("이미 존재하는 회원입니다.");
}
}
Service
단의 비즈니스 로직도 테스트 검증을 해보자. 이전에MemberService
에서 중복 회원의 경우IllegalStateException
예외를 던졌다. 던진 예외를 처리하기 위해junit
의assertThrows
를 통해 중복 아이디가 확인 될 경우 해당 예외 메시지가 일치하는 지를 테스트한다.
여기서 문제점이 하나 존재한다. 그것은
service
에서 실제로 사용 될 객체와 테스트의 객체가new
로 생성되었기 때문에 다르다는 점이다. 이 부분을 해결하기 위해선 DI(의존성 주입)를 통해 외부에서 주입 받아야 한다.
DI 적용
- 다음과 같이
Service
단에서 의존하게 되는Repository
를 생성자를 통해 파라미터로 주입해주게 되면Service
를 사용하는 코드에서Repository
를 생성하고 그 인스턴스를 넘겨주기 때문에 동일한 환경에서 처리가 가능하게 된다. 하지만, 현재 상황을 보면Repository
를 생성하는 객체는 다를 수 있다. 이 부분은 스프링 컨테이너에서Component
에 등록된 대상을빈
객체로 관리하여@Autowired
를 사용하면 해당빈
을 주입해주기 때문에 동일한 객체임을 보장해준다.
package hello.hellospring.service;
import hello.hellospring.domain.Member;
import hello.hellospring.repository.MemoryMemberRepository;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertThrows;
class MemberServiceTest {
MemberService memberService;
MemoryMemberRepository memberRepository;
@BeforeEach
public void beforeEach() {
memberRepository = new MemoryMemberRepository();
memberService = new MemberService(memberRepository);
}
@AfterEach
public void afterEach() {
memberRepository.clearStore();
}
@Test
void 회원가입() {
// given
Member member = new Member();
member.setName("sasca37");
// when
Long saveId = memberService.join(member);
// then
Member findMember = memberService.findOne(saveId).get();
assertThat(findMember.getName()).isEqualTo(member.getName());
}
@Test
void 중복_회원_예외() {
// given
Member member1 = new Member();
member1.setName("sasca37");
Member member2 = new Member();
member2.setName("sasca37");
// when
memberService.join(member1);
// then
IllegalStateException e = assertThrows(IllegalStateException.class,
() -> memberService.join(member2));
assertThat(e.getMessage()).isEqualTo("이미 존재하는 회원입니다.");
}
}
- 다음과 같이
Repository
를 생성해서Service
에 파라미터로 넣어주자.@BeforeEach
에 초기화를 구분한 이유는 다른 테스트에서Repository
에 영향을 줄 수 있는 가능성이 있기 때문에 초기 설정으로 테스트가 진행되도록 하기 위함이다.
@BeforeEach
와@AfterEach
를 통해 할당과 해제 작업을 처리하는 점을 기억하자.
스프링 빈과 의존 관계
스프링 빈이란?
Spring IoC 컨테이너가 관리하는 자바 객체를 빈(Bean)이라는 용어로 부른다. 우리가 new 연산자로 어떤 객체를 생성했을 때 그 객체는 빈이 아니다. ApplicationContext.getBean()으로 얻어질 수 있는 객체는 빈이다.
즉, Spring에서의 빈은 ApplicationContext가 만들어서 그 안에 담고있는 객체를 의미한다.
스프링은 스프링 컨테이넌에 빈을 등록할 때 기본으로 싱글톤으로 등록한다. (하나의 객체만 등록하여 관리) 설정으로 싱글톤이 아니게 설정할 수 있지만, 대부분 싱글톤 사용
- 스프링이 처음 실행될 때 스프링 컨테이너가 생성되고 스프링 컨테이너에서
@Controller
,@Service
,@Repository
등 어노테이션이 붙은 객체를 생성하여 관리한다.
스프링 빈을 등록하는 방법
- 컴포넌트 스캔과 자동 의존관계 설정
- 자바 코드로 직접 스프링 빈 등록
컴포넌트 스캔 원리
@Component
어노테이션이 있으면 스프링 빈으로 자동 등록 된다.@Controller
,@Service
,@Repository
등은@Component
를 포함하는 어노테이션으로 자동 등록이 된다.
@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Component
public @interface Controller {
/**
* The value may indicate a suggestion for a logical component name,
* to be turned into a Spring bean in case of an autodetected component.
* @return the suggested component name, if any (or empty String otherwise)
*/
@AliasFor(annotation = Component.class)
String value() default "";
}
의존관계 설정
Controller
는Service
를 ,Service
는Repository
를 의존한다. 이를 의존 관계라고 말한다.Controller
,Service
의존 관계 설정 예시
private final MemberService memberService = new MemberService();
- new 연산자 사용시 해당 컨트롤러가 아닌 다른 컨트롤러에서도 가져다 쓸 수 있는 문제점 발생 (사용하지 않는다)
@Controller
public class MemberController {
private final MemberService memberService; // 해당 클래스에 @Service 등록 필요
@Autowired // 의존관계 주입 DI 중 생성자 주입
public MemberController(MemberService memberService) {
this.memberService = memberService;
}
/*
// 의존관계 DI 중 세터주입
@Autowired
public void setMemberService(MemberService memberService) {
this.memberService = memberService;
}
//의존관계 주입 DI 중 필드 주입
@Autowired private MemberService memberService;
*/
- final 키워드는 무조건 초기화가 이루어져야한다. 생성자를 통해 초기화를 해주거나 바로 초기화를 해줘야한다. (실수 예방)
@Autowired
를 통해 의존 관계 주입 (Dependency Injection), 생성자, 세터, 필드 DI 존재@Autowired
는 스프링 빈에 등록이 된 객체에서만 동작하며, 생성자가 한 개인 경우 생략이 가능하다.- 세터 주입방식은
public
으로 열려있어야 해서 누군가가 바꿀 수 있다는 문제가 있다.
- DI에서 의존관계가 실행중에 동적으로 변하는 경우가 거의 없으므로 생성자 주입을 권장한다.
자바 코드로 직접 스프링 빈 등록
- 주로 정형화 되지 않거나, 상황에 따라 구현 클래스를 변경해야 하면 설정을 통해 스프링 빈으로 등록한다.
package hello.hellospring;
import hello.hellospring.repository.MemberRepository;
import hello.hellospring.repository.MemoryMemberRepository;
import hello.hellospring.service.MemberService;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class SpringConfig {
@Bean
public MemberService memberService() {
return new MemberService(memberRepository());
}
@Bean
public MemberRepository memberRepository() {
return new MemoryMemberRepository();
}
}
@Service
,@Repository
,@Autowired
등 어노테이션 사용하지 않고 @Configuration 과 @Bean을
사용하여 직접 스프링 빈에 등록한다.
회원 가입 개발
Controller
package hello.hellospring.controller;
import hello.hellospring.domain.Member;
import hello.hellospring.domain.MemberForm;
import hello.hellospring.service.MemberService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import java.util.List;
@Controller
public class MemberController {
private final MemberService memberService;
@Autowired
public MemberController(MemberService memberService) {
this.memberService = memberService;
}
@GetMapping("/members/new")
public String createForm() {
return "members/createMemberForm";
}
@PostMapping("/members/new")
public String create(MemberForm form) {
Member member = new Member();
member.setName(form.getName());
System.out.println("member : "+member.getName());
memberService.join(member);
return "redirect:/";
}
@GetMapping("/members")
public String list(Model model) {
List<Member> members = memberService.findMember();
model.addAttribute("members", members);
return "members/memberList";
}
}
- 회원에 대한 요청을 관리할 컨트롤러 생성,
MemberForm
에서 설정한name
을@PostMapping
에 매개변수로 담으면form
태그에서 전달한 키값 :name
이MemberForm
의name
으로 매핑(Setter를 통해)된다.
Model
public class MemberForm {
private String name;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
}
name
을 통해 사용자에게 입력을 받아 가입하기 위한 DTO 모델이라고 생각하면 된다.
View
<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<body>
<div class="container">
<form action="/members/new" method="post">
<div class="form-group">
<label for="name">이름</label>
<input type="text" id="name" name="name" placeholder="이름을 입력하세요"></div>
<button type="submit">등록</button>
</form>
</div> <!-- /container -->
</body>
</html>
- 회원 가입을 위한
form
형식으로 이름을 입력 받아서MemberForm
타입에 맞추어 객체를 생성하여Memeber
에 데이터를 저장한 후Repository
에 접근한다.
<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<body>
<div class="container">
<div>
<table>
<thead>
<tr>
<th>#</th>
<th>이름</th>
</tr>
</thead>
<tbody>
<tr th:each="member : ${members}">
<td th:text="${member.id}"></td>
<td th:text="${member.name}"></td>
</tr>
</tbody>
</table>
</div>
</div> <!-- /container -->
</body>
</html>
- 회원 목록을 보여주는 페이지로
Controller
에서model
로 전달받은 값을${members}
로 받아와서th:each
로 반복문을 실행한다. member
: 사용할 변수명,${members}
: Controller에서 전달 받은 값- ${member.id} : Getter 를 이용하여 값을 가져온다.
DB 적용
h2-database
설치는 https://sasca37.tistory.com/13?category=1218302 를 참고하자.
이전까지는 메모리 상에서만 데이터를 꺼냈다. h2-database
를 사용해서 DB를 적용해보자.
drop table if exists member CASCADE;
create table member
(
id bigint generated by default as identity,
name varchar(255),
primary key (id)
);
- member 테이블을 생성
순수 JDBC
implementation 'org.springframework.boot:spring-boot-starter-jdbc'
runtimeOnly 'com.h2database:h2'
build.gradle
에 jdbc, h2 데이터베이스 관련 라이브러리를 추가하자.
spring.datasource.url=jdbc:h2:tcp://localhost/~/test
spring.datasource.driver-class-name=org.h2.Driver
spring.datasource.username=sa
application.properties
에 데이터베이스 접속 정보를 추가하자.
package hello.hellospring.repository;
import hello.hellospring.domain.Member;
import org.springframework.jdbc.datasource.DataSourceUtils;
import org.springframework.stereotype.Repository;
import javax.sql.DataSource;
import java.sql.*;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
@Repository
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 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);
}
}
@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);
}
}
private Connection getConnection() {
return DataSourceUtils.getConnection(dataSource);
}
private void close(Connection conn) throws SQLException {
DataSourceUtils.releaseConnection(conn, 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();
}
}
}
- 기존의
MemberRepository
인터페이스를 구현체로 만들어서jdbc
를 사용해서 적용한다.jdbc
는 정말 고전 방식으로 별도의 정리는 생략하겠다. 단, 이전에MemoryMemberRepository
를 빈에 등록했었다면 제거해주고JdbcMemberRepository
를 빈에 등록해주자.
통합 테스트
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("sasca37");
// when
Long saveId = memberService.join(member);
// then
Member findMember = memberService.findOne(saveId).get();
assertThat(findMember.getName()).isEqualTo(member.getName());
}
@Test
void 중복_회원_예외() {
// given
Member member1 = new Member();
member1.setName("sasca37");
Member member2 = new Member();
member2.setName("sasca37");
// when
memberService.join(member1);
// then
IllegalStateException e = assertThrows(IllegalStateException.class,
() -> memberService.join(member2));
assertThat(e.getMessage()).isEqualTo("이미 존재하는 회원입니다.");
}
}
@SpringBootTest
: 스프링 컨테이너와 테스트를 함께 실행할 수 있는 애노테이션@Transactional
: 테스트케이스에 이 애노테이션이 있으면, 테스트 전에 트랜잭션을 시작하고, 테스트 완료 후에 항상 롤백한다. 이렇게 하면 DB에 데이터가 남지 않으므로 다음 테스트에 영향을 주지 않는다.
스프링 JdbcTemplate
순수 Jdbc와 동일한 환경설정을 하면 된다. 스프링 JdbcTemplate과 MyBatis 같은 라이브러리는 JDBC API에서 본 반복 코드를 대부분 제거해준다. 하지만 SQL은 직접 작성해야 한다.
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 org.springframework.stereotype.Repository;
import javax.sql.DataSource;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
@Repository
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 List<Member> findAll() {
return jdbcTemplate.query("select * from member", memberRowMapper());
}
@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;
};
}
}
JdbcTemplate
은 DataSource를 파라미터로 넣어서 주입해줘야한다. 추가적으로RowMapper
를 통해ResultSet
을 설정을 하고jdbcTemplate.query()
에 쿼리문을 넣어 사용한다.
JPA
JPA는 기존의 반복 코드는 물론이고, 기본적인 SQL도 JPA가 직접 만들어서 실행해준다.
JPA를 사용하면, SQL과 데이터 중심의 설계에서 객체 중심의 설계로 패러다임을 전환할 수 있다. 즉, 개발 생산성을 크게 높일 수 있다.
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
build.gradle
에 JPA 관련 라이브러리를 추가하자.
spring.datasource.url=jdbc:h2:tcp://localhost/~/test
spring.datasource.driver-class-name=org.h2.Driver
spring.datasource.username=sa
spring.jpa.show-sql=true
spring.jpa.hibernate.ddl-auto=none
application.properties
에 JPA 설정을 추가하자.show-sql
: JPA가 생성하는 SQL을 출력한다.ddl-auto
: JPA는 테이블을 자동으로 생성하는 기능을 제공하는데,none
을 사용하면 해당 기능을 끈다.
package hello.hellospring.domain;
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;
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;
}
}
Member
엔티티를 JPA 엔티티로 매핑하자.@Id
: 기본 키 설정@GeneratedValue
: 키 생성전략이다.IDENTITY
는 JPA에서 기본 키를 1씩 증가시켜가며 관리해준다.
package hello.hellospring.repository;
import hello.hellospring.domain.Member;
import org.springframework.stereotype.Repository;
import javax.persistence.EntityManager;
import java.util.List;
import java.util.Optional;
@Repository
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는
EntityManager
를 사용하여 영속성관리를 한다. - JPA에서는
JPQL
이라는 쿼리문을 사용한다. 즉 객체지향의 패러다임 방식으로 테이블이 아닌 객체를 넣는다. 객체를 꺼내와서 필요한 내용들을 파라미터로 넣고 결과로 반환받아 사용한다. Spring Data JPA를 사용하면 이 부분도 더 간단하게 사용할 수 있다. - JPA를 사용할 때는
@Transactional
이 필요하다. 정상이면 커밋, 오류가 있으면 롤백을 해주는 기능이다. (테스트 단계에서는 끝나고 롤백을 시켜준다.)Service
단에서@Transactional
을 적용하자. - JPA를 통한 모든 데이터 변경은 트랜잭션 안에서 실행해야 한다는 점을 주의하자.
Spring Data JPA
Spring Data JPA를 사용하면, 기존의 한계를 넘어 리포지토리에서 구현 클래스 없이 인터페이스 만으로 개발을 완료할 수 있다. 반복 개발해온 기본 CRUD 기능도 스프링 데이터 JPA가 모두 제공한다.
package hello.hellospring.repository;
import hello.hellospring.domain.Member;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.Optional;
@Repository
public interface SpringDataJpaMemberRepository extends JpaRepository<Member, Long>, MemberRepository {
@Override
Optional<Member> findByName(String name);
}
JpaRepository
와MemberRepository
인터페이스를 상속받은 인터페이스를 만들면 끝이다.
어떻게 이게 가능할까? JpaRepository를 상속받으면 Spring Data Jpa에서 직접 구현체를 생성해서 관리해주기 때문이다.
- JpaRepository에서 기본적으로 제공하는 기능들이 있고 상속받는 상위 인터페이스에 여러 메서드들이 있다. 하지만 실무에서는 별도로 동적 쿼리가 필요하다. 이런 부분은
Querydsl
이라는 라이브러리를 사용하거나 네이티브 쿼리를 사용하여 해결해나간다.
AOP란?
Spring
은Spring Triangle
이라고 부르는 세 가지 개념을 제공해준다. 각각IoC
,AOP
,PSA
를 일컫는다.AOP
는 Aspect Oriented Programming의 약자로관점 지향적인 프로그래밍
이라는 의미이다.AOP
의 핵심 개념은 관심사의 분리이다. (Tracing, Exception, Transction 등)AOP
사용 시 코드 분산, 코드 꼬임 등을 처리하여 간결한 구조를 만들 수 있다.- PSA (Portable Service Abstraction) : 추상화 계층을 사용하여 어떤 기술을 내부에 숨기고 개발자에게 편의성을 제공해주는 것
AOP가 필요한 상황
그럼 AOP는 언제 사용할까?
예를 들어 현재 프로젝트에서 모든 기능에 대해서 소요되는 시간을 출력하여 분석한다고 생각해보자.
시간을 측정하는 로직은 핵심 비즈니스로직이 아닌 공통 기능이다. 이 기능을 AOP를 적용하여 해결할 수 있다.
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 +"ms");
}
}
Service
단의 로그인 기능에서 다음과 같이 설정했다. 이 부분을 모든 비즈니스 로직에 적용해야 한다.
AOP 기능
@Aspect
: 흩어진 관심사를 모듈화, Aspect 클래스임을 정의- Target : Aspect를 적용하는 곳 (클래스, 메서드 등 )
- Advice : 실질적으로 어떤 일을 해야할 지에 대한 것, 실질적인 부가기능을 담은 구현체
- Advice 동작시점
- @Before : 메서드 실행 전에 동작
- @After : 메서드 실행 후에 동작
- @After-returning : 메서드 정상 실행 후에 동작
- @After-throwing : 예외가 발생한 후에 동작
- @Around : 메서드 호출 이전, 이후, 예외 발생 등 모든 시점에 동작
- JointPoint : Advice가 적용될 위치, 끼어들 수 있는 지점. 메서드 진입 지점, 생성자 호출 시점, 필드에서 값을 꺼내올 때 등 다양한 시점에 적용가능
- PointCut : JointPoint의 상세한 스펙을 정의한 것. 'A란 메서드의 진입 시점에 호출할 것'과 같이 더욱 구체적으로 Advice가 실행될 지점을 정할 수 있음
AOP 적용
AOP 적용 코드
package hello.hellospring.aop;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.stereotype.Component;
@Component //컴포넌트 스캔을 사용하거나 (빈등록), Configuration 에 직접 빈등록 (이 방법을 주로 선호)
@Aspect //AOP 적용 어노테이션
public class TimeTraceAop {
//hellospring 패키지의 하위 모두에 적용 시키겠다.
@Around("execution(* hello.hellospring..*(..))")
// 가짜 Bean 통해 프록시를 생성 후 JoinPoint로 진짜 Bean으로 연결
public Object execute(ProceedingJoinPoint joinPoint) throws Throwable {
long start = System.currentTimeMillis();
System.out.println("START: " + joinPoint.toString()); //joinPoint.toString(): 실행위치
try {
return joinPoint.proceed(); // 다음 메서드로 넘어
} finally {
long finish = System.currentTimeMillis();
long timeMs = finish - start;
System.out.println("END: " + joinPoint.toString()+ " " + timeMs + "ms");
}
}
}
- AOP를 적용하기 위해선
@Aspect
와 빈 등록하고@Around
를 통한 경로 설정,ProceedingJoinPoint
를 사용하여 가짜객체 설정을 해줘야한다.
@Around("execution(* hello.hellospring.service..*(..))") : service 하위만 적용
@Around
에서 적용할 곳도 별도로 지정이 가능하다.
AOP 동작 방식 (의존 관계)
- 프록시 방식의 AOP (DI가 가능하니까 프록시 방식이 가능 (전달할 대상이 누군지 상관 없음)), 실제 자바코드를 넣는 AOP 방식도 있다.
- 프록시 : 프록시 객체는 원래 객체를 감싸고 있는 가짜 객체이다.
- 프록시패턴 : 가짜 객체가 원래 객체를 감싸서 client의 요청을 처리하게 하는 패턴이다.
프록시 패턴을 쓰는 이유는 접근을 제어하고 싶거나, 부가 기능을 추가하고 싶을 때 사용한다.
주입 받는 위치에서 .getClass()를 이용해 로그 출력해보면 $$EnhancerBySpringCGLIB$$~ 통해 프록시 적용을 확인할 수 있다.
- AOP 적용 후 의존 관계
- AOP 적용 후 전체 의존 관계
본 포스팅은 인프런 김영한님 강의(스프링 입문)를 토대로 정리한 내용입니다.