본 포스팅은 인프런 - 스프링 입문을 강의를 바탕으로 공부하고 정리한 글입니다.
간단한 회원관리 예제를 만들어보면서 Spring이 어떻게 돌아가는지 알아보도록 하자.
비즈니스 요구사항
- 데이터 : 회원 ID, 이름
- 기능: 회원 등록, 조회
- 데이터베이스가 선정되지 않은 상태
웹 어플리케이션 계층 구조
- 컨트롤러 : 웹 MVC의 컨트롤러 역할
- 서비스 : 핵심 비즈니스 로직 구현
- 리포지토리 : 데이터베이스에 접근, 도메인 객체를 DB에 저장하고 관리
- 도메인 : 비즈니스 도메인 객체, 예) 회원, 주문, 쿠폰 등 주로 데이터베이스에 저장하고 관리됨
클래스 의존 관계
- 회원 비즈니스 로직에는 회원 서비스가 있다.
- 회원 리포지토리는 아직 데이터베이스가 정해지지 않았기 때문에 우선 인터페이스로 구현 클래스를 변경할 수 있도록 설계한다.
- 데이터 저장소는 RDB, NoSQL 등 다양한 저장소를 고민중인 상황으로 가정
- 개발을 진행하기 위해 초기 개발 단계에서는 구현체로 가벼운 메모리 기반의 데이터 저장소를 사용한다.
개발
회원 도메인, 리포지토리 개발
📁 domain/Member
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;
}
}
📁 repository/MemberRepository (인터페이스)
public interface MemberRepository {
Member save(Member member);
Optional<Member> findById(Long id);
Optional<Member> findByName(String name);
List<Member> findAll();
}
📁 repository/MemoryMemberRepository (구현체)
public class MemoryMemberRepository implements MemberRepository {
private static Map<Long, Member> store = new HashMap<>();
private static long sequence = 0L; // key값을 생성
@Override
public Member save(Member member) {
member.setId(++sequence); // id 셋팅하기
store.put(member.getId(), member); // store에 저장하기
return member;
}
@Override
public Optional<Member> findById(Long id) {
return Optional.ofNullable(store.get(id)); // 값이 null이더라도 optional로 감싸서 반환
}
@Override
public Optional<Member> findByName(String name) {
return store.values().stream()
.filter(member -> member.getName().equals(name)) // name이 매개변수로 전달된 name과 같은지 확인
.findAny(); // 찾으면 반환, 끝까지 찾은 결과 없으면 null을 optional에 감싸서 반환
}
@Override
public List<Member> findAll() {
return new ArrayList<>(store.values());
}
public void clearStore() {
store.clear();
}
}
- save()
- 회원의 id는 시스템상 구분을 위해 임의로 지정해주는 값이기 때문에 sequence로 생성해준다.
- findById()
- get()을 통해 값을 가져옴
- 이때 가져올 값이 없어 null이 반환될 가능성이 있기 때문에 Optional로 감싸서 반환해준다.
- findByName()
- store에서 회원목록을 꺼내와 스트림 생성하고,
- 전달받은 name과 이름이 같은 회원을 찾는다.
- 찾으면 해당 회원을 반환, 못찾으면 null을 optional에 감싸서 반환해준다.
- findAll()
- store는 Map이지만 반환 타입이 List이다.
- 따라서 store에 values를 담은 ArrayList를 생성해 반환해준다.
- clearStore()
- store 전부 삭제
- 테스트 진행시 하나의 테스트가 끝날때 마다 저장소를 비워주는데 사용한다.
회원 리포지토리 테스트 작성
개발한 기능을 테스트할 때 자바의 main 메소드를 통해 실행해보거나, 웹 애플리케이션의 컨트롤러를 통해 해당 기능을 실행해볼 수 있다.
하지만 이러한 방법은 준비하고 실행하는데 오래 걸리며 반복 실행이 어렵고 여러 테스트를 한번에 실행하기 어렵다는 단점이 있다.
따라서 자바는 JUnit이라는 프레임워크로 테스트를 실행해 이러한 문제를 해결한다.
만들어준 구현 클래스를 잘 동작하는지 테스트를 작성해 확인해보자.
👉 회원 리포지토리 구현체 테스트
package hello.hellospring.repository;
class MemoryMemberRepositoryTest {
MemoryMemberRepository repository = new MemoryMemberRepository();
@AfterEach
public void afterEach() {
repository.clearStore();
}
@Test
public void save() {
Member member = new Member();
member.setName("spring");
repository.save(member);
Member result = repository.findById(member.getId()).get(); // findById의 반환타입이 Optional이므로 get()으로 값을 꺼내옴
assertThat(member).isEqualTo(result);
}
@Test
public void findByName() {
Member member1 = new Member();
member1.setName("spring1");
repository.save(member1);
Member member2 = new Member();
member2.setName("spring2");
repository.save(member2);
Member result = repository.findByName("spring1").get();
assertThat(result).isEqualTo(member1);
}
@Test
public void findAll() {
Member member1 = new Member();
member1.setName("spring1");
repository.save(member1);
Member member2 = new Member();
member2.setName("spring2");
repository.save(member2);
List<Member> result = repository.findAll();
assertThat(result.size()).isEqualTo(2);
}
}
👉 회원 리포지토리 메모리 구현체 테스트
package hello.hellospring.repository;
class MemoryMemberRepositoryTest {
MemoryMemberRepository repository = new MemoryMemberRepository();
@AfterEach
public void afterEach() {
repository.clearStore();
}
@Test
public void save() {
Member member = new Member();
member.setName("spring");
repository.save(member);
Member result = repository.findById(member.getId()).get(); // findById의 반환타입이 Optional이므로 get()으로 값을 꺼내옴
assertThat(member).isEqualTo(result);
}
@Test
public void findByName() {
Member member1 = new Member();
member1.setName("spring1");
repository.save(member1);
Member member2 = new Member();
member2.setName("spring2");
repository.save(member2);
Member result = repository.findByName("spring1").get();
assertThat(result).isEqualTo(member1);
}
@Test
public void findAll() {
Member member1 = new Member();
member1.setName("spring1");
repository.save(member1);
Member member2 = new Member();
member2.setName("spring2");
repository.save(member2);
List<Member> result = repository.findAll();
assertThat(result.size()).isEqualTo(2);
}
}
- @Test
- junit 프레임 워크를 사용해 테스트 실행
- @AterEach
- 테스트는 순서에 상관 없이 즉, 서로 의존관계 없이 설계되어야 한다.
따라서 하나의 테스트가 끝날 때마다 저장소나 공용 데이터들을 깔끔하게 지워줘야 문제가 되지 않는다. - 이때 @AfterEach를 사용하면 각 테스트가 종료될 때 마다 이 기능을 실행한다.
여기서는 메모리 DB에 저장된 데이터를 삭제하도록 하였다.
- 테스트는 순서에 상관 없이 즉, 서로 의존관계 없이 설계되어야 한다.
- save 테스트
- assertj의 Assertions을 사용해 assertThat(), isEqualTo()메소드를 호출해 기대값과 실제값이 같은지 확인
- findByName 테스트
- 테스트를 위해 두개의 객체(member1, member2)를 생성
- spring1이라는 이름으로 객체를 찾아오도록 한 뒤 assertThat(), isEqualTo() 메소드를 호출해 확인
- findAll 테스트
- 테스트를 위해 두개의 객체(member1, member2)를 생성
- assertThat(), isEqualTo() 메소드를 호출해 사이즈가 같은지 확인
테스트주도 개발 (TDD)
• 테스트를 먼저 만들고 난 뒤 구현 클래스를 만들어 실행 해보는 개발 방법이다.
회원 서비스 개발
👉 회원 서비스
package hello.hellospring.service;
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 -> { // null이 아닌 값이 있으면 throw 구문 실행
throw new IllegalStateException("이미 존재하는 회원입니다");
});
}
/**
* 전체 회원 조회
*/
public List<Member> findMembers() {
return memberRepository.findAll();
}
public Optional<Member> findOne(Long memberId) {
return memberRepository.findById(memberId);
}
}
- 서비스 클래스는 비즈니스에 가까운 용어로 네이밍하는 경우가 많다. (예 - join, findMembers ...)
- 중복 회원 검증 (validateDuplicateMember)
- findByName()을 통해 이름을 통해 회원 조회
- 이때 findByName()의 반환타입이 Optional이기 때문에 ifPresent() 연속 호출 가능
- ifPresent() 메소드를 통해 동일 이름의 회원이 있는지 확인, 있으면 "이미 존재하는 회원입니다" 반환
회원 서비스 테스트
Tip_자동 테스트 만들기
• window : 클래스 선택 후 ctrl + shift + T
• mac : 커맨드 + shift + T
👉 회원 리포지토리의 코드가 회원 서비스 코드를 DI 가능하게 변경
package hello.hellospring.service;
// 변경 전 (회원 서비스가 메모리 회원 리포지토리를 직접 생성)
public class MemberService {
private final MemberRepository memberRepository = new MemoryMemberRepository();
}
// 변경 후
public class MemberService {
private final MemberRepository memberRepository;
public MemberService(MemberRepository memberRepository) { // memberRepository를 외부에서 넣어줌 (DI)
this.memberRepository = memberRepository;
}
...
}
- MemberService에서 new로 생성해준 memberRepository와 테스트에서 new로 생성해준 memberRepository는 다른 객체이기 때문에 문제가 생길 수 있다.
- 때문에 같은 객체를 사용할 수 있도록 변경해줘야 한다.
- memberRepository를 new로 직접 생성해주는 것이 아닌 외부에서 넣어주도록 변경
👉 회원 서비스 테스트
package hello.hellospring.service;
class MemberServiceTest {
MemberService memberService;
MemoryMemberRepository memberRepository; // DB를 clear해주기 위한 회원리포지토리
@BeforeEach // 각 테스트를 실행하기 전에
public void beforeEach() {
memberRepository = new MemoryMemberRepository(); // MemoryMemberRepository 생성
memberService = new MemberService(memberRepository); // 같은 MemoryMemberRepository가 사용될 수 있도록 함.
}
@AfterEach // 각 테스트가 끝날때마다
public void afterEach() {
memberRepository.clearStore(); // DB에 저장된 값을 모두 삭제
}
@Test
void 회원가입() {
// given
Member member = new Member();
member.setName("hello");
// 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)); // 중복회원일 경우 IllegalStateException 예외가 발생하는지 확인
assertThat(e.getMessage()).isEqualTo("이미 존재하는 회원입니다."); // 에러 메세지 확인
/*
try {
memberService.join(member2); // 이름이 동일하므로 중복회원 검증에 따라 예외가 발생해야 함
fail("예외가 발생해야 합니다.");
} catch (IllegalStateException e) {
assertThat(e.getMessage()).isEqualTo("이미 존재하는 회원입니다.");
}
*/
}
}
- 테스트 케이스 작성할 때는 한글로 네이밍을 해도 괜찮다.
- @BeforeEach
- 각 테스트 실행 전에 호출된다.
- 테스트가 서로 영향이 없도록 항상 새로운 객체를 생성하고, 의존관계도 새로 맺어준다.
- 테스트 패턴
- given : 뭔가가 주어졌을 때
- when : 이것을 실행했을 때
- then : 결과가 이것이 나와야 한다.
- 회원 가입(join) 테스트
- join은 member의 id를 반환해주기 때문에 saveId로 받는다.
- findOne()를 호출하면서 받은 saveId를 전달해주면 해당 id의 회원 객체를 찾는다.
- assertThat(), isEqualTo() 메소드 호출을 통해 member의 이름과 찾은 회원 객체의 이름이 같은지 확인
- 중복_회원_예외 테스트
- join은 예외가 발생하는 것을 확인하는 것도 중요하기 때문에 예외 테스트를 추가로 진행한다.
- 예외 발생을 위해 이름이 같은 회원 객체(member1, member2)를 생성
- 두개의 객체를 대상으로 join을 진행해 예외를 발생시킨다.
- 이때 assertThrows를 사용해 같은 이름의 중복 회원일 경우 IllegalstateException 예외가 발생하는지 확인
- assertThat(), isEqualTo() 메소드 호출을 통해 에러메세지가 같은지 확인
'🌱 Spring > Core' 카테고리의 다른 글
[기본] #2 회원, 주문, 할인 도메인 개발 및 테스트 (0) | 2022.02.21 |
---|---|
[기본] #1 객체 지향 설계와 스프링 (0) | 2022.02.18 |
[입문] #7 AOP (0) | 2022.02.17 |
[입문] #6 DB 접근기술(JDBC, JdbcTemplate, JPA, SpringJPA) (0) | 2022.02.16 |
[입문] #5 웹 MVC 개발 (0) | 2022.02.15 |
[입문] #4 스프링 빈과 의존관계 (0) | 2022.02.15 |
[입문] #2 정적웹, MVC, API (0) | 2022.02.14 |
[입문] #1 스프링 프로젝트 환경설정 (0) | 2022.02.14 |