본 글은 해당 강의를 보고 작성한 글입니다.
[지금 무료] 스프링 입문 - 코드로 배우는 스프링 부트, 웹 MVC, DB 접근 기술 강의 | 김영한 - 인프
김영한 | 스프링 입문자가 예제를 만들어가면서 스프링 웹 애플리케이션 개발 전반을 빠르게 학습할 수 있습니다., 스프링 학습 첫 길잡이! 개발 공부의 길을 잃지 않도록 도와드립니다. 📣 확
www.inflearn.com
#3 회원 관리 예제 - 백엔드 개발
1. 비즈니스 요구사항 정리
- 데이터 : 회원 ID,이름
- 기능 : 회원 등록, 조회
- 데이터 저장소(DB)가 선정 안 된 상태에서 개발하는 상황이라고 가정한다.
- 컨트롤러: 웹 MVC의 컨트롤러 역할, API 만들기 등
- 서비스 : 핵심 비즈니스 로직 구현(ex. 같은 이름 회원 중복 안 됨), 비즈니스 도메인 객체를 가지고 핵심 비즈니스 로직이 동작하도록 구현한 계층
- 리포지토리 : 데이터베이스에 접근, 도메인 객체를 DB에 저장하고 관리
- 도메인 : 비즈니스 도메인 객체 (ex. 회원, 주문, 쿠폰 등 주로 데이터베이스에 저장하고 관리됨)
<클래스 의존관계>
-회원리포지토리(회원 저장)는 인터페이스로 설계
-> 왜냐하면 아직 데이터 저장소가 선정되지 않았기에 구현 클래스를 변경할 수 있도록 하기 위함
-초기 개발 단계에서는 구현체로 가벼운 메모리 기반의 데이터 자장소 사용
-> 추후에 구체적인 기술(RDB, NoSQL 등)이 선정되면 바꿔끼우는 과정을 거침
2. 회원 도메인과 회원 도메인 객체를 저장하고 불러올 수 있는 저장소인 리포지토리 만들기
src/main/java/hello.hellospring/domain 패키지 생성
domain/Member 클래스 생성
package hello.hellospring.domain;
public class Member {
private Long id; //시스템이 지정하는 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 패키지 생성 (회원 객체를 저장할 저장소를 만드는 것!)
repository/MemberRepository 인터페이스 생성
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로 회원 조회, null 반환시 optional로 감싸서
Optional<Member> findByName(String name); // 이름으로 회원 조회
List<Member> findAll(); // 저장된 전체 회원 조회
}
repository/MemoryMemberRepository 클래스 생성
-implements MemberRepository
-ctrl + space 또는 option + enter : import(implements)
package hello.hellospring.repository;
import hello.hellospring.domain.Member;
import java.util.*;
/**
* 동시성 문제가 고려되어 있지 않음, 실무에서는 ConcurrentHashMap, AtomicLong 사용 고려 */
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 List<Member> findAll() {
return new ArrayList<>(store.values());
}
}
- private static Map<Long, Member> store = new HashMap<>() : (key: Long(id), value: Member)
- sequence : 0, 1, 2 ... key 값 생성
- save() : store에 넣기 전에 Member의 id값을 세팅(name은 넘어온 상태)하고 store에 저장
- findById() : store에서 id를 통해 get하면 됨. 그런데 결과가 없으면 null이 반환될텐데, Optional.ofNullable로 감싸서 반환해주면 클라이언트에서 무언가 할 수 있음
- findByName() : member의 name이 파라미터로 넘어온 name과 같은지 확인하여 같으면 반환, 끝까지 찾았는데 없으면 Optional에 null이 포함돼서 반환됨
- findAll() : 실무에선 루프돌리기 편함 등의 이유로 List 많이 사용
동작을 검증하는 방법은? 테스트 케이스 작성으로!
3. 회원 리포지토리가 정상적으로 동작하는지 테스트 케이스 작성
- 개발한 기능이 제대로 작동하는지 검증하기 위해 테스트 케이스를 작성함
- 자바의 main 메서드를 통해 실행하거나 웹 애플리케이션의 컨트롤러로 검증하는 것은 반복 실행이 어렵고 여러 테스트를 한번에 실행하기 어렵다.
- 따라서 자바에서는 JUnit이라는 프레임워크로 테스트 코드를 만들어서 테스트 코드 자체를 실행하여 이런 문제를 해결함
src/test/java/repository 패키지 생성
src/test/java/repository/MemoryMemberRepositoryTest 클래스 생성
class MemoryMemberRepositoryTest {
MemoryMemberRepository repository = new MemoryMemberRepository();
}
- public일 필요는 없다
save 테스트
@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 = Assertions.assertEquals(member,result); // 1번
//Assertions.assertEquals(result, member);// 2번, jupiter - member와 result가 다른 값이면 오류 발생
//assertThat(member).isEqualTo(result); // 3번, assertj - 다르면 오류
}
- command + shift + enter 잘 사용하자! 개꿀인듯 언제 다 외워 근데
- findById(member.getId()).get() : findById의 반환 타입이 Optional인데, get()으로 꺼낼 수 있음 (좋은 방법은 아님)
- 검증 1번 : 같으면 true 출력
- 검증 2번 : junit의 Assertions의 assertEquals - 같으면 정상 실행, 다르면 오류
- 검증 3번 : assertj의 Assertions의 assertThat - 같으면 정상 실행, 다르면 오류
findByName 테스트
@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); //다른 객체라면 오류발생
}
테스트 케이스의 장점 : 동시에 여러 클래스를 테스트할 수 있음!
findAll 테스트
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);
}
- 사이즈가 같으면 정상 실행이고 다르면 오류임
- 그런데 이후에 전체를 테스트해보면 에러 발생함 -> 순서 의존적이기 때문에 테스트 순서가 보장이 안된다.
- findAll()이 먼저 실행되어서 findByName()할 때 이전에 저장된 것 때문에 에러가 발생
-> 따라서 테스트 한번 끝나면 데이터 clear 해주는 과정 필요!
src/test/java/repository/MemoryMemberRepositoryTest 클래스에 아래 코드 추가
@AfterEach
public void afterEach() {
repository.clearStore();
}
- @AfterEach : 메서드 실행 끝날때마다 동작
repository/MemoryMemberRepository 클래스에 아래 코드 추가
public void clearStore(){
store.clear();
}
- 저장소 지움
테스트는 서로 순서 의존관계없이 설계되어야 함! -> 테스트 하나 끝날때마다 저장소, 공용 데이터 깔끔하게 지워줘야 함
정리하자면
- 개발 먼저하고 테스트 케이스 작성함.
- 테스트 케이스 작성후 개발 : TDD(테스트 주도 개발)
- 테스트 코드는 필수
- 테스트 코드가 여러개면 클래스나 패키지 단위로 실행해도 됨(gradlew test 등)
4. 실제 비즈니스 로직이 있는 회원 서비스 개발
회원 서비스 : 회원 리포지토리와 도메인을 활용하여 실제 비즈니스 로직을 작성
hello.hellospring/service 패키지 생성
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) {
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);
}
}
※ command + option + v
※ ctrl + t + Extract Method : 메서드로 추출
※ 서비스 클래스는 비즈니스에서 가져온 이름 써야 함(쉬운 매칭을 위해) -> 서비스는 비즈니스에 의존적으로 설계
※ 리포지토리는 서비스보단 단순히 개발스럽게 네이밍
5. 회원 서비스가 정상 동작하는지 제이유닛이라는 테스트 프레임워크를 만들어서 테스트
command + shift + t : 테스트 파일 생성
테스트의 경우 과감하게 한글로 해도 됨 + 빌드될때 테스트 코드는 포함되지 않음
given-when-then 문법
: test는 이런 상황(given)에서 이걸 실행햇을때(when) 이런 결과(then)가 나와야 한다.
: given - 이 데이터를 기반으로 when - 검증하는 것 then - 검증
void join() {
//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 duplicate(){
//given
Member member1 = new Member();
member1.setName("spring");
Member member2 = new Member();
member2.setName("spring");
//when
memberService.join(member1);
try{
memberService.join(member2);
fail(); //
}
catch(IllegalStateException e){
Assertions.assertThat(e.getMessage()).isEqualTo("이미 존재하는 회원입니다.");
}
//then
}
- try-catch 사용하는 방법 : 애매한 방법... 별로 안쓰이는 것 같다!
@Test
public void duplicate(){
//given
Member member1 = new Member();
member1.setName("spring");
Member member2 = new Member();
member2.setName("spring");
//when
memberService.join(member1);
/*1번*/
//assertThrows(IllegalStateException.class, () -> memberService.join(member2));
/*2번*/
/*command + option + v*/
IllegalStateException e = assertThrows(IllegalStateException.class, () -> memberService.join(member2));
assertThat(e.getMessage()).isEqualTo("이미 존재하는 회원입니다.");
//then
}
- 1번 : memberService.join(member2) <- 이 로직 실행 시, IllegalStateException <- 이 예외 발생해야 한다!
- 2번 : e로 받아서 확인!
- join()에서 spring으로 setName을 변경하고 전체 테스트를 진행하면, 위와 같이 실패한다.
이유는 위에서 봤듯이 메모리 저장소에 값이 누적되기 때문이다
따라서 clear를 해줘야 함
MemoryMemberRepository memberRepository = new MemoryMemberRepository();
@AfterEach
public void afterEach(){
memberRepository.clearStore();
}
- 실행될 때마다 메모리가 clear 해주기
- MemberServiceTest와 MemberService에서 각각 MemoryMemberRepository 객체를 생성하고 있다. 즉, 이 두 객체는 서로 다른 객체이다.
- 현재는 MemoryMemberRepository.java내에 store가 static으로 선언되어 서로 다른 객체더라도 같은 db이지만, static이 아니라면 다른 db가 되면서 문제가 발생한다.
- 따라서 같은 인스턴스를 쓰게 하기 위해 아래와 같이 해줘야 한다.
- MemberService에서 memberRepository를 직접 내부에서 생성하는 것이 아니라, 외부에서 넣어주도록 하는 것이다.
- @BeforeEach : 각 테스트 실행 전에 호출됨
- MemoryMemberRepository 객체를 생성하여 memberRepository에 넣어주고, 그걸 memberService에도 넣어줌으로써 같은 MemoryMemberRepository를 사용하게 됨
- MemberService 입장에서 memberRepository를 내부에서 직접 만드는 것이 아니라 외부에서 넣어주고 있다 --> 이런 걸 DI라 한다!!
- DI : Dependency Injection
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;
public MemberService(MemberRepository memberRepository) {
this.memberRepository = memberRepository;
}
/**
* 회원가입 */
public Long join(Member member) {
validateDuplicateMember(member); //중복 회원 검증 memberRepository.save(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);
}
}
package hello.hellospring.service;
import hello.hellospring.domain.Member;
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 static org.assertj.core.api.Assertions.*;
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 join() {
//given
Member member = new Member();
member.setName("spring");
//when
Long saveId = memberService.join(member);
//then
Member findMember = memberService.findOne(saveId).get();
assertThat(member.getName()).isEqualTo(findMember.getName());
}
@Test
public void duplicate(){
//given
Member member1 = new Member();
member1.setName("spring");
Member member2 = new Member();
member2.setName("spring");
//when
memberService.join(member1);
/*command + option + v*/
IllegalStateException e = assertThrows(IllegalStateException.class, () -> memberService.join(member2));
assertThat(e.getMessage()).isEqualTo("이미 존재하는 회원입니다.");
//then
}
@Test
void findMembers() {
}
@Test
void findOne() {
}
}
'Spring' 카테고리의 다른 글
[Spring] 스프링 입문 - 코드로 배우는 스프링 부트, 웹 MVC, DB 접근 기술 #5 (0) | 2024.09.06 |
---|---|
[Spring] 스프링 입문 - 코드로 배우는 스프링 부트, 웹 MVC, DB 접근 기술 #4 (0) | 2024.09.06 |
[Spring] 스프링 입문 - 코드로 배우는 스프링 부트, 웹 MVC, DB 접근 기술 #2 (1) | 2024.07.13 |
[Spring] 스프링 입문 - 코드로 배우는 스프링 부트, 웹 MVC, DB 접근 기술 #1 (0) | 2024.07.13 |