회원 관리 예제 - 백엔드 개발
1. 비즈니스 요구사항 정리
2. 회원 도메인과 리포지토리 만들기
3. 회원 리포지토리 테스트 케이스 작성
4. 회원 서비스 개발
5. 회원 서비스 테스트
1. 비즈니스 요구사항 정리
- 데이터: 회원ID, 이름
- 기능: 회원 등록, 조회
- 아직 데이터 저장소가 선정되지 않음(가상의 시나리오)
보통 일반적인 웹 어플리케이션의 계층 구조는 아래와 같다.
- 컨트롤러 : 웹 MVC의 컨트롤러 역할
- 서비스 : 핵심 비즈니스 로직 구현
- 리포지토리 : 데이터베이스에 접근, 도메인 객체를 DB에 저장하고 관리
- 도메인 : 비즈니스 도메인 객체
(ex. 회원, 주쿤, 쿠폰 등등 주로 데이터 베이스에 저장하고 관리됨)
해당 예제의 클래스 의존 관계는 다음과 같다.
(실선: 클래스 상속, 점선: 인터페이스 상속)
- 아직 데이터 저장소가 선정되지 않아서, 우선 인터페이스로 구현 클래스를 변경할 수 있도록 설계
- 데이터 저장소는 RDB, NoSQL 등등 다양한 저장소를 고민중인 상황으로 가정
- 개발을 진행하기 위해서 초기 개발 단계에서는 구현체로 가벼운 메모리 기반의 데이터 저장소 사용
2. 회원 도메인과 리포지토리 만들기
2-1. 회원의 정보를 담을 Member 클래스 생성
package hello.hellospring.domain;
public class Member {
private Long id;
private String name;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
}
요구사항 대로 회원ID와 이름을 받는 변수인 id, name을 작성했다.
id의 데이터 타입이 Long인 이유는 기본형인 long을 사용하면 객체를 생성하는 시점에 id값에 0이라는 기본값이 들어가는 반면, Long을 사용하면 기본값이 null이기 때문에 값이 없다는 것을 표현할 수 있기 때문.
2-2. 회원 인터페이스
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<T>
java8에서는 Optional<T> 클래스를 사용해 NPE(NullPointerException)을 방지할 수 있도록 도와준다. Optional<T>는 null이 올 수 있는 값을 감싸는 Wrapper클래스로, 참조하더라도 NPE가 발생하지 않도록 도와준다.
예를들어, 다음과 같은 코드에서 findByName()을 호출하는데 Repository(DB)에 인자로 넘긴 이름(홍길동)의 회원 객체가 없을 경우(NULL) NPE가 발생한다.
Member member1 = memberRepository.findByName("홍길동");
member1.getAge(); // <<-- NPE 발생
Optional.ofNullable(member1.getAge()); // Optional 사용 예시
※ findBy
findBy뒤에 컬럼명을 붙여주면 이를 이용한 검색이 가능하다.
※ findAll
SELECT*ALL과 같다. 모든 튜플을 가져온다.
※ List <T>
리스트 컬렉션. 리스트는 배열과 동일한 역할을 하며 크기를 지정할 필요가 없고, 제공하는 메서드를 사용할 수 있다. 객체이기 때문에 new 연산자를 사용해야 한다.
2-3. 회원 리포지토리 메모리 구현체
package hello.hellospring.repository;
import hello.hellospring.domain.Member;
import java.security.AllPermission;
import java.sql.Array;
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) {
// member의 name이 파라미터로 넘어온 name과 같은지 확인
// 같은 경우에만 필터링 되고, 그 중에서 하나라도 찾으면 반환, 만약 끝까지 없다면 Optional에 null이 포함되서 반환됨
return store.values().stream()
.filter(member -> member.getName().equals(name))
.findAny();
}
@Override
public List<Member> findAll() {
return new ArrayList<>(store.values());
}
}
※ Map<K, V>
Map은 키-값 쌍으로 구성된 데이터를 저장할 수 있는 interface형식이다.
그렇기에 아래와같이 직접적으로 생성이 불가능하다.
Map<String,String> cc = new Map<String,String>(); // 불가능
Map<String,String> bb = new HashMap<String,String>(); // 가능
Map인터페이스의 구현체로 HashMap등의 클래스를 사용해야 한다. 실무에서 많이들 사용하는 HahMap은 키와 값형태로 데이터를 저장가능하게 해준다.
HashMap<String,String> aa = new HashMap<String,String>();
aa.put("aa","value1");
aa.put("bb","value2");
※ Stream().filter().findAny();
스트림은 배열이나 컬렉션(List, Set, Map)으로 원하는 값을 얻을 때 for문 도배를 방지하기 위해 나온 개념이다. 메소드를 연달아 쓰면 메소드에 맞게 값이 정리되어 나온다
.filter() // 조건에 맞는 원소 추출.
.findAny() // 해당 스트림에서 첫 번째 요소를 참조하는 Optional 객체를 반환.
// 두 메소드 모두 비어 있는 스트림에서는 비어있는 Optional 객체를 반환.
3. 회원 리포지토리 테스트 케이스 작성
개발한기능을 실행해서테스트 할때 자바의 main 메서드를통해서 실행하거나, 웹 애플리케이션의
컨트롤러를통해서 해당기능을 실행한다. 이러한방법은 준비하고실행하는데 오래걸리고, 반복 실행하기
어렵고여러 테스트를 한번에실행하기어렵다는단점이 있다. 자바는 JUnit이라는 프레임워크로테스트를
실행해서 이러한 문제를 해결한다.
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.assertThat;
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();
//System.out.println("result = " + (result == member));
//Assertions.assertEquals(member, result);
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);
}
}
MemoryMemberRepository repository = new MemoryMemberRepository();
repository를 인터페이스로 설계 할 경우, MemberRepository 인터페이스를 구현한 구현체의 변경이 용이해진다.
※ Junit
자바용 단위 테스트 도구이다. 테스트의 성공 여부를 알기 위해 Assertion메서드를 사용한다.
assertEquals(a, b); // 객체 A와 B가 일치함을 확인
※ assertThat
// juint의 assertThat
assertThat(T actual, Matcher<? super T> matcher);
assertThat(result, allOf(greaterThan(0), lessThan(10)));
// actual: 검증 대상, matcher : 로직
// assertj의 assertThat
assertThat(T actual)
assertThat(result).isGreaterThan(0).isLessThan(10);
assertj의 assertThat은 인자로 actual(검증대상)만 받는다. 각 타입에 맞는 Assertino 메소드를 제공한다.asertj의 assertThat이 juint의 assertThat보다 가지는 장점은 크게 3가지가 있다.
- 자동완성
- Assertion 분류
- 확장성
※ clear();
테스트 순서는 보장이 안된다. 모든 테스트는 메서드별로 따로 동작하게 설게되어야 한다.
기존 로직대로 실행 시 테스트가 정상적으로 실행이 되지 않는다. findAll()이 먼저 테스트 되면서 'spring1', 'spring2'가 저장되었다. 그래서 findName()에서 오류가 발생한 것이다.
이것을 해결하려면 테스트가 하나 끝나면 데이터가 깔끔하게 삭제되게 해주어야 한다.
그래서 MomoryMemberRepository에 다음과 같은 코드를 작성해준 것이다.
public void clearStore() {
store.clear();
}
이후 MemoryMemberRepository에서 @AfterEach 어노테이션을 추가해 테스트 메서드가 끝날 때마다 저장소의 데이터를 깔끔하게 삭제해주는 코드를 작성한다.
@AfterEach
public void afterEach(){
repository.clearStore();
}
테스트 순서에 관계없이 테스트가 잘 실행되는 것을 확인할 수 있다.
4. 회원 서비스 개발
핵심 비즈니스 로직을 여기에 구현한다.
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) {
// 중복회원 검증
/*
Optional<Member> result = memberRepository.findByName(member.getName());
result.ifPresent(m -> { // result가 null이 아니라 값이 있으면 예외를 발생시킴
throw new IllegalStateException("이미 존재하는 회원입니다.");
});
*/
// 위에 내용에서 Optional 리턴값을 안보여주되 동일한 로직
validateDuplicateMember(member);
memberRepository.save(member);
return member.getId();
}
private void validateDuplicateMember(Member member) {
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);
}
}
서비스 클래스는 join, findMember 등 비즈니스 서비스 관련 단어를 사용해야 한다.
중복 회원이 있는지 검증하고, 있으면 '이미 존재하는 회원입니다.'로 예외처리하는 비즈니스 로직을 구현했다.
5. 회원 서비스 테스트
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.AssertionsForClassTypes.assertThat;
import static org.junit.jupiter.api.Assertions.*;
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("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));
assertThat(e.getMessage()).isEqualTo("이미 존재하는 회원입니다.");
/*
try {
memberService.join(member2);
fail();
} catch(IllegalStateException e) {
assertThat(e.getMessage()).isEqualTo("이미 존재하는 회원입니다.");
}
*/
// then
}
@Test
void findMembers() {
}
@Test
void findOne() {
}
}
회원가입, 중복 회원 예외 과정을 테스트해보았다. 오류 없이 정상적으로 동작한다.
테스트를 실행할 때, memberService에서 사용하는 리포지토리와 테스트에서 사용하는 레포지토리가 같아야 정상적인 테스트가 가능하다.
memberService는 외부에서 new를 생성하도록 작성한다. 객체를 외부에서 생성하는 이것을 'DI(의존성 주입)'이라 한다.
// MemberService 코드
private final MemberRepository memberRepository;
public MemberService(MemberRepository memberRepository) {
this.memberRepository = memberRepository;
}
memberServiceTest에서는 다음과 같이 코드를 작성해 테스트 실행 전마다 실행되게 한다.
MemberService memberService;
MemoryMemberRepository memberRepository;
@BeforeEach
public void beforeEach(){
memberRepository = new MemoryMemberRepository();
memberService = new MemberService(memberRepository);
}
Ref.
스프링 입문 - 코드로 배우는 스프링 부트, 웹 MVC, DB 접근 기술(김영한 강사님)
'program > Java, Spring' 카테고리의 다른 글
[Spring Boot] 회원 관리 예제 - 웹 MVC 개발 (1) | 2023.10.17 |
---|---|
[Spring Boot] 스프링 빈과 의존관계 (0) | 2023.10.15 |
[Spring Boot] 스프링 웹 개발 기초③ - API (0) | 2023.07.07 |
HTTP 통신 및 @RequestBody, @ResponseBody 이해하기 (0) | 2023.07.07 |
[Spring Boot] 스프링 웹 개발 기초② - MVC와 템플릿 엔진 (0) | 2023.07.07 |