💡 회원 관리 예제
회원 관리 예제를 진행해볼 것이다. 순서는 다음과 같다.
1. 비즈니스 요구사항 정리
2. 회원 도메인과 레파지토리 만들기
3. 회원 레파지토리 테스트 케이스 작성
4. 회원 서비스 개발
5. 회원 서비스 테스트
JUnit 프레임워크를 사용하여 기능 테스트를 진행한다.
🔍 비즈니스 요구사항 정리
첫 예제이기 때문에 요구사항은 최대한 간단하게 진행한다.
일반적인 웹 애플리케이션 구조
- 컨트롤러 : 웹 MVC의 컨트롤러 역할
- 서비스 : 서비스 클래스의 핵심 비즈니스 로직 구현 ex) 회원의 중복가입 불가
- 레파지토리 : 데이터베이스에 접근, 도메인 객체를 DB에 저장하고 관리
- 도메인 : 비즈니스 도메인 객체, ex) 회원, 주문, 쿠폰 등등 주로 데이터베이스에 저장하고 관리됨
클래스 의존관계
- 아직 데이터 저장소가 선정되지 않아 인터페이스로 구현 클래스를 변경할 수 있도록 설계
- 회원을 저장하는 것은 인터페이스로 설계할 것이다. 이러한 이유는 앞의 요구사항에서 데이터 저장소가 선택되지 않았다는 가정을 하고 있기 때문에 인터페이스로 선택한다. 향후에 구체적인 데이터 저장소가 선택되면 구현할 수 있도록 하기 위함이다.
- 개발을 진행하기 위해 초기 개발 단계는 구현체로 가벼운 메모리 기반의 데이터 저장소 사용
📄 회원 도메인과 레파지토리 만들기
src/main/java/hello.hellospring 하위에 domain 패키지를 만들고 그 안에 Member클래스를 만들자. Member클래스는 다음과 같다.
package hello.hellospring.domain;
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;
}
}
src/main/java/hello.hellospring 하위에 repository 패키지를 만들고 그 안에 MemberRepository interface를 만들자. 전체 코드는 다음과 같다.
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); // ID로 검색
Optional<Member> findByName(String name); // 이름으로 검색
List<Member> findAll(); // 모든 회원 리스트 반환
}
src/main/java/hello.hellospring/repository 하위에 MemoryMemberRepository 클래스를 만들자. 전체 코드는 다음과 같다.
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) { // 1
member.setId(++sequence);
store.put(member.getId(), member);
return member;
}
@Override
public Optional<Member> findById(Long id) { // 2
return Optional.ofNullable(store.get(id));
}
@Override
public Optional<Member> findByName(String name) { // 3
return store.values().stream()
.filter(member -> member.getName().equals(name))
.findAny();
}
@Override
public List<Member> findAll() { // 4
return new ArrayList<>(store.values());
}
public void clearStore() { // 5
store.clear();
}
}
- 회원 추가 기능 : 회원 번호를 증가시키고 member를 새로 만들어 HahsMap에 추가
- id기반 검색 기능 : id를 가진 회원이 있는지 검색
- Optional은 null이 올 수 있는 값을 감싸는 Wrapper 클래스로 참조하더라도 Null Pointer Exception이 발생하지 않도록 도와준다.
- Optional.ofNullable()은 데이터가 null이 올 수도 있고 아닐 수도 있는 경우에 사용할 수 있다.
- 이름기반 검색 기능 : 이름을 가진 회원이 있는지 검색
- Stream은 컬렉션에 저장되어 있는 요소들을 하나씩 참조하여 람다식으로 처리할 수 있도록 해주는 코드패턴(반복자)이다.
- filter() 메서드를 통해 파라미터로 받은 이름과 같은 이름을 가진 member객체를 찾는다.
- findAny()는 Stream에서 가장 먼저 탐색되는 요소를 반환한다.
회원 도메인&레파지토리 만든 후 프로젝트 구조는 다음과 같다.
📑 테스트 케이스 작성
기능을 테스트 하기 위해 main 메서드를 통해 실행하는 방법을 사용할 수 있다. 하지만 이런 방법은 실행하는데 오래 걸리고, 반복 실행이 어려우며 여러 테스트를 한번에 실행하기 어렵다. 이를 해결하기 위해 JUnit이라는 프레임워크를 사용하여 테스트 케이스를 작성해보자.
(테스트를 하기 위한 관례로 클래스명 뒤에 Test를 붙여서 파일을 만든다.)
src/test/hello.hellospring 하위에 repository 패키지를 만든 후에 안에 MemoryMemberRepositoryTest 클래스를 만든다. 전체 코드는 다음과 같다.
package hello.hellospring.repository;
import hello.hellospring.domain.Member;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.Optional;
public class MemoryMemberRepositoryTest {
MemoryMemberRepository repository = new MemoryMemberRepository();
@AfterEach // 1
public void afterEach(){
repository.clearStore();
}
@Test // 2
public void save() { // 3
Member member = new Member();
member.setName("spring");
repository.save(member);
Member result = repository.findById(member.getId()).get(); // 4
Assertions.assertThat(member).isEqualTo(result); // 5
}
@Test
public void findByName() { // 6
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();
Assertions.assertThat(member1).isEqualTo(result);
}
@Test
public void findAll() { // 7
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();
Assertions.assertThat(result.size()).isEqualTo(2);
}
}
- @AfterEach를 통해 afterEach() 메서드가 하나의 테스트 메서드가 끝나면 호출되도록 한다.
- 한번에 여러 테스트를 진행하게 되면 메모리 DB에 직전 테스트의 결과가 남을 수 있다. 이렇게 되면 다음 테스트가 이전 테스트 때문에 실패할 가능성이 있다.
- @AfterEach를 사용하면 각 테스트가 종료될 때마다 이 기능을 실행하게 할 수 있고 여기서는 메모리 DB에 저장된 데이터를 삭제하도록 했다.
- 테스트 메서드의 동작 순서는 보장되지 않는다. 만약 findAll()이 먼저 실행되면 spring1, spring2 가 저장되어 있을 것이고 이후 findByName()이 실행되었을 때 다른 객체를 반환한다. 따라서 repository를 깔끔하게 지워주는 코드를 작성하여 매 테스트가 개별적으로 동작할 수 있도록 하는 것이다.
- 테스트는 서로 의존관계 없이 설계가 되어야 한다.
- @Test를 통해 메서드를 개별로 실행하여 정상동작 하는지 확인할 수 있다.
- save기능 테스트
- MemoryMemberRepository클래스의 findByName() 메서드의 반환 타입은 Optional이다. Optional은 get() 메서드를 통해 값을 꺼낼 수 있다.
- Assertions.assertThat(A).isEqualTo(B) 메서드를 사용하여 A와 B가 같은지 확인할 수 있다. 메서드를 실행했을 때 문제가 없다면 ✔표시가 나오면서 녹색불이 뜬다. 만약 문제가 있다면 빨간불이 뜬다.
- 이름 기반 검색기능 테스트
- 전체 검색 기능 테스트
테스트가 성공하면 아래와 같이 초록불이 뜬다.
테스트가 실패하면 아래와 같이 빨간불이 뜬다. 메서드명을 보고 어떤 기능에서 문제가 생겼는지 확인할 수 있고 터미널 창에서 기대 값과 실제 값을 비교 확인할 수 있다.
📄 회원 서비스 개발
src/main/java/hello.hellospring 하위에 service 패키지를 만들고 안에 MemberService 클래스를 만든다. 전체 코드는 다음과 같다.
package hello.hellospring.service;
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) {
// 같은 이름이 있는 중복 회원 X
validateDuplicateMember(member); // 중복 회원 검증
memberRepository.save(member);
return member.getId();
}
private void validateDuplicateMember(Member member) { // 1
memberRepository.findByName(member.getName())
.ifPresent(m -> {
throw new IllegalStateException("이미 존재하는 회원입니다.");
});
}
/**
* 전체 회원 조회
*/
public List<Member> findMembers() {
return memberRepository.findAll();
}
public Optional<Member> findOne(Long memberId) {
return memberRepository.findById(memberId);
}
}
- findByName() 메서드의 반환 타입은 Optional이다. Optional은 ifPresent() 를 사용할 수 있다.
- ifPresent()는 값이 있으면 안에 로직이 동작하도록 한다. 이를 이용하여 값이 있으면 중복 회원이 있다는 뜻이기 때문에 "이미 존재하는 회원입니다"라는 예외를 뱉어내도록 한다.
📑 테스트 케이스 작성
테스트 케이스를 만들려고 하는 클래스에서 Ctrl + Shift + T를 누르면 Create New Test가 나온다. 이것을 클릭하면 다음과 같은 창이 나온다.
기능을 테스트 하고 싶은 메서드를 클릭하고 OK를 클릭하면 src/test 아래 같은 패키지와 클래스가 만들어진다.
그리고 테스트 케이스를 작성하기 전에 수정해야 하는 부분이 있다. 기존에는 다음과 같은 코드로 회원 서비스가 직접 메모리 회원 레파지토리를 생성하도록 했었다.
public class MemberService {
private final MemberRepository memberRepository = new MemoryMemberRepository();
}
이렇게 하는 경우 join() 메서드의 테스트 코드를 실행할 때 앞에서 확인한 동일한 문제가 발생할 수 있다. 앞선 테스트에서는 @AfterEach를 통해 해결했지만 이번에는 MemberService 클래스를 테스트하는 것이기 때문에 memoryMemberService에 접근할 수 없다.
이것을 해결하기 위해 회원 서비스 코드를 DI(Dependency Injection)가 가능하도록 변경한다.
public class MemberService {
private final MemberRepository memberRepository;
public MemberService(MemberRepository memberRepository) {
this.memberRepository = memberRepository;
}
...
}
그리고 나서 테스트 코드를 작성하면 다음과 같다.
package hello.hellospring.service;
import hello.hellospring.domain.Member;
import hello.hellospring.repository.MemoryMemberRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
import static org.assertj.core.api.Assertions.*;
class MemberServiceTest {
MemberService memberService;
MemoryMemberRepository memberRepository;
@BeforeEach // 1
public void beforeEach() {
memberRepository = new MemoryMemberRepository();
memberService = new MemberService(memberRepository);
}
@Test
void 회원가입() {
// given // 2
Member member = new Member();
member.setName("hello");
// when // 3
Long saveId = memberService.join(member);
// then // 4
Member findMember = memberService.findOne(member.getId()).get();
assertThat(member.getName()).isEqualTo(findMember.getName());
}
@Test
void 중복_회원_예외() {
//given
Member member1 = new Member();
member1.setName("spring");
Member member2 = new Member();
member2.setName("spring");
//when
memberService.join(member1);
assertThrows(IllegalStateException.class, () -> memberService.join(member2)); // 5
}
@Test
void findMembers() {
}
@Test
void findOne() {
}
}
- @BeforeEach를 통해 각 테스트를 실행하기 전에 beforeEach() 메서드를 호출한다. beforeEach() 메서드는 테스트 간 영향이 없도록 항상 새로운 객체를 생성하고, 의존관계로 새로 맺어주는 역할을 한다.
- 테스트 코드를 작성할 때 주어진 상황을 구분하기 위해 사용하면 좋다.
- 테스트 코드를 작성할 때 확인하려는 기능을 구분하기 위해 사용하면 좋다.
- 테스트 코드를 작성할 때 확인하려는 기능의 결과를 구분하기 위해 사용하면 좋다.
- memberService.join(member2) 메서드를 실행했을 때 IllegalStateException이 발생하는지 확인한다.
⌨ 인텔리제이 단축키
ctrl + alt + v : 반환 타입을 볼 수 있는 단축키
ctrl + p : 함수의 파라미터 타입을 확인할 수 있는 단축키
shift + F6 : 같은 변수명을 한꺼번에 변경할 수 있는 단축키
ctrl + alt + m : 메서드 추출
ctrl + alt + shift + t : 리팩토링 관련
alt + INS : 테스트 파일 생성
잡담
테스트 코드를 사용하지 않고 개발하게 되면 혼자 개발할 때는 불편한 부분을 느끼기는 어렵다고 한다. 하지만 다른 개발자들과 협력할 때, 코드의 라인이 길어졌을 때는 굉장한 불편함을 겪게 되고 이러한 경우를 방지하기 위해 테스트 코드를 작성하는 것에 익숙해져야 한다. 우테코에서도 아마 이러한 이유 때문에 기능별 테스트를 진행하라고 이야기 해줬던 것 같다. 백엔드 내용 뿐 아니라 이러한 내용도 알려주셔서 너무 좋은 것 같다.