[SpringBoot-스프링 입문] 코드로 배우는 스프링 부트, 웹 MVC, DB 접근 기술: 3. 회원 관리 예제-백엔드 개발
해당 강의는 Inflearn에 등록된 김영한님의 Springboot 강의입니다.
이번 장부터 우리가 할 프로젝트에 대해서 설명하겠다. 비즈니스 요구 사항을 보면 회원 객체만 존재하고, 다른 객체는 없이(즉, 일대다 다대일과 같은 복잡한 연관관계 없이) 회원을 db에 등록하고, 회원 이름을 조회 하는 상황을 가정하였다.
이렇게 DB에 값을 넣고, 사용자는 그 DB의 데이터를 조회하게 해주는 작업을 백엔드 개발이라고 한다.
위의 프로젝트의 확인을 위해선 view가 필요하므로 MVC로 해당 프로젝트를 구현 하였다.
(API 개발은 postman을 통해 확인할 수 있다. 이는 추후 JPA 활용편2에 정리 하겠다.)
1. 비즈니스 요구사항 정리
2. 리포지토리 설정 및 서비스의 의존과계 설정
서비스는 인터페이스를 통해 비즈니스 로직을 구현하고, 인터페이스는 우선 DB가 아닌 메모리에 값을 저장한다.
(추후 H2 데이터 베이스를 통해 메모리가 아닌 H2에 연결 할 예정입니다.)
컨트롤러
: 웹 MVC의 컨트롤러 역할
도메인
: 객체(이번 강의에는 회원객체만 있습니다.)
서비스
: 핵심 비즈니스 로직
- 회원가입(중복,null 체크) 메서드
- 회원 전체 조회 메서드
리포지토리
: DB에 접근 및 도메인 객체를 DB에 저장하고 관리
3. 회원 도메인과 리포지토리 만들기
회원 객체(도메인)
개발
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,name라는 2가지 속성이 존재한다. 모든 도메인에는 setter(값 설정), getter(setter에서 설정한 값을 호출)메서드를 만들어야한다. 이는 추후에 이 속성들을 이용하여 비즈니스 로직을 구현하기 위함이다.
사실롬복
이라는 라이브러리를 이용하면 클래스위에 @Getter @Setter 로 명시하여 간단하게 getter,setter를 구현할 수 있다. 그리고 setter같은 경우도 사실 생성자로 값을 setting하는게 좋다. (이유는 추후 서비스에서 값을 setting하는 과정에서 해당 내용에 대해서 설명 하겠습니다.)
회원 리포지토리 인터페이스
개발
//2. 회원 리포지토리 인터페이스
//인터페이스 생성(아직 어떤 형태의 저장소를 쓸줄 모르니)
public interface MemberRepository {
Member save(Member member); //멤버 저장
Optional<Member> findById(Long id);//회원 조회
Optional<Member> findByName(String name);//회원 조회
List<Member> findAll();//모든 회원 리스트 반환
}
코드 설명
인터페이스는 우선 메서드를 설정한다. 우리는 회원 등록, 회원 조회(이름 및 id), 회원 목록 조회 총4가지를 db와 연관지어 활용할 예정이다. 이렇게 우선 메서드만 설정하고, 추후 클래스에서 해당 인터페이스를 상속 받아 해당 메서드를 직접 구현한다.
회원 리포지토리 메모리 구현체 개발
//3. 회원 리포지토리 메모리 구현체
//@Repository
public class MemoryMemberRepository implements MemberRepository{
//회원 저장 키,벨류 매핑
private static Map<Long,Member> store =new HashMap<>();//로컬 메모리에 저장
//sequence: 0,1,2 순으로 키값 생성
private static long sequence=0L;
@Override
public Member save(Member member) {
//1. id 세팅
member.setId(++sequence);
//2. 스토어 저장
store.put(member.getId(),member); //키:value값
return member;
}
@Override
public Optional<Member> findById(Long id) {
return Optional.ofNullable(store.get(id)); //null이어도 반환 가능하게 하는 메소드: Optional.ofNullable
}
//★★★저장소에 저장되어있는 값중에 전체를 탐색해서 찾는 방법
@Override
public Optional<Member> findByName(String name) {
return store.values().stream() //1.저장소의 값들을 스트림으로 변환
.filter(member -> member.getName().equals(name)) //2. member의 이름과 완전히 일치하는지 필터
.findAny(); //3. 조건을 만족하는 첫번째 멤버 찾기. 없으면 Optional을 반환
}
@Override
public List<Member> findAll() {
//store에 있는 멤버 각각 Map의 모든 값들을 ArrayList형태로 반환.
return new ArrayList<>(store.values());
}
//store data를 모두 삭제하는 법
public void clearStore() {
store.clear();
}
}
각 메서드의 코드 설명은 주석으로 처리 하였습니다.
인터페이스를 상속 받으면 반드시 인터페이스에 설정한 메서드들을 구현하여야한다. 이때 그 메서드들은 @Override를 명시해야한다.
위 코드에는 DB에 id값을 저장할때 0L부터 설정해서 값이 저장되면 (++sequence)로 설정 했는데 나중에는 JPA를 통해 객체에 annotation하나면 자동으로 id를 증가시켜주는 방식이 있다.(추후 6장 스프링 DB접근에서 페이징과 함께 설명하겠다.)
회원 리포지토리 테스트 케이스 작성
첫 게시글에 인텔리 제이 단축키에 대해 언급 한 적이 있다. 테스트 케이스 생성은 리포지토리 아무데나 클릭하고, ctrl
+shitf
+t
키를 누르면 다음과 같은 화면이 나온다. 거기서 엔터를 치면 테스트할 메소드를 선택하는 창이 나온다. 그것을 모두 클릭하자.
ok클릭을 하면 다음과 같이 test 디렉토리에 구현한 리포지토리의 테스트의 틀을 다음과 같이 자동으로 만들어 준다.
테스트 케이스의 작성의 이유는 간단하다. Main함수에서 전체 서버를 껐다 키면 부팅하느라 시간이 엄청 오래걸리고, 콘트롤러에서 실행해도 마찬가지로 시간이 문제다. 테스트는 기능 단위별로 해야하는데 이는 테스트끼리의 순서에 의존관계가 있는 것은 좋은 테스트가 아니기 때문이다. 사실 이 밖에도 더많은 이점이 있지만 현재 포스팅 내용과 알맞지 않으므로 생략하겠다.
★★★★★★★★★★ "메소드 Test의 6가지 과정"★★★★★★★★★★
`1.회원 객체 생성`, `2. 회원 객체 세팅(생성자로도 가능)`, `3. 저장소에 저장.`
`4. 3에서 저장한 값을 불러옴`. `5. 불러온 값과 기대값 비교`, `6.테스트 후 저장소에 데이터 삭제`
테스트 결과
테스트 실행 방법
은 아래의 그림을 참고하면 된다.( 동그라미 친부분 클릭하면 된다. 메서드 별로 실행할 수도 있다.)
코드
public class MemoryMemberRepositoryTest {
//1. 인터페이스 객체 생성(아직 어떤 형태의 저장소를 쓸줄 모르니)
MemoryMemberRepository repository=new MemoryMemberRepository();
//2. @AfterEach: 각 함수가 끝난후에 실행되는 콜백 메서드를 지정.
//이를 통해 테스트 후 DB에 남은 데이터를 삭제한다.
@AfterEach
public void afterEach()
{
repository.clearStore();
}
@Test
public void save(){
Member member=new Member();
member.setName("spring");
repository.save(member);
//repository.findById(member.getId()): Opitonal형태로 반환 하므로, 여기에 "get()"을 해야 "실제 값"이 반환.
Member result=repository.findById(member.getId()).get();
//3.1 ★Assertions.assertEquals(기대,실제): Junit의 테스트 방법 중 하나. 둘이랑 같은지 확인.
//Assertions.assertEquals(member,result);
//3.2.★★assert(실제).isEqualTo(기대): 이문장이 가독성이 더 좋아 자주 사용된다.
assertThat(result).isEqualTo(member);
}
@Test
public void findByname()
{
Member member1=new Member();
member1.setName("spring1");
repository.save(member1);
//★★★shift+F6키 누르면 같은 이름 한방에 변경 가능.★★★
Member member2=new Member();
member2.setName("spring2");
repository.save(member2);
//Member result= repository.findByName(member1.getName()).get();
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("spring1");
repository.save(member2);
List<Member> result=repository.findAll();
assertThat(result.size()).isEqualTo(2);
}
}
메서드 테스트 과정을 나는 6가지로 나눴다.
우선 save() 메서드에 대한 내용이다.
객체 생성 후 setter로 값을 setting했다. 그다음의 save 메서드를 실행했다.
그 다음 과정은 db에서 저장된 실제 값을 뽑아온 값과 객체 값을 비교하였다.
나머지 조회 테스트도 사실 조회 한 내용과 객체와의 비교만 하면 된다. (assertThat( A ).isEqualTo( B )메서드사용)
추가적으로 다음과 같은 코드는 테스트 케이스 작성시 반드시 추가해야한다.
//2. @AfterEach: 각 함수가 끝난후에 실행되는 콜백 메서드를 지정.
//이를 통해 테스트 후 DB에 남은 데이터를 삭제한다.
@AfterEach
public void afterEach()
{
repository.clearStore();
}
4. 회원 서비스 개발
코드
public class MemberService {
private final MemberRepository memberRepository;
public MemberService(MemberRepository memberRepository) {
this.memberRepository = memberRepository;
}
//회원가입
//1.중복이름이 아니면 추가
public Long join(Member member)
{
//중복이름의 회원 허용x 함수 사용
validateDuplicateMember(member);//1.중복 검증
memberRepository.save(member); //2.회원 정보 저장(join)
return member.getId(); //3.해당 회원정보 반환
}
private void validateDuplicateMember(Member member) {
memberRepository.findByName(member.getName())
.ifPresent(m -> {
throw new IllegalStateException("이미 존재하는 회원입니다.");
});
}
//전체회원 조회
public List<Member> findMembers()
{
return memberRepository.findAll(); //List로 반환
}
//한 멤버 조회
public Optional<Member> findOne(Long memberId)
{
return memberRepository.findById(memberId);
}
}
Test에서 같은 레포 공유를 위해 생성자에 의존성 주입. 이는 DI의 생성자 주입이라고 한다.
(이를 디자인패턴에서 보면 의존성 주입 디자인 패턴 이라고 한다. )
생성자 주입의 이유는 콘트롤러->서비스->레포지토리의 의존관계가 잘 안바뀌므로 생성자 주입을 권장.
setter로 설정하면 다른 개발자가 임의로 setting 값을 바꿀 수 있기 때문이다.
위의 코드를 보면 회원 가입
시 이름이 중복
되면 에러가 발생
하도록 처리 했다.
5. 회원 서비스 테스트
위의 서비스 코드를 테스트하는 테스트는 회원 가입, 중복 예외 테스트를 하겠다.
@BeforEach는 각 테스트 전에 실행된다. store는 리포지토리에 정의 한 메모리 db인데, 이 annotation을 통해 테스트 실행 전에
//실행 전에 테스트끼리 같은 메모리 리포지토리를 사용하기위해 세팅한다.
//+a) store가 static으로 선언되어 있으므로 같은 "메모리(data section)"를 공유한다.
@BeforeEach
public void beforeEach()
{
memberRepository=new MemoryMemberRepository();
memberService=new MemberService(memberRepository);
}
코드
class MemberServiceTest {
//★★★의존성 주입 디자인 패턴(테스트의 용이성을 편하게함)★★★★
//설명: MemberService 클래스가 MemoryMemberRepository에 "의존"한다.
//의존성 주입은 이 의존 관계를 외부에서 설정하여, MemberService가 특정 구현에 종속되지 않도록 한다.
//밑의 코드들은 의존성 주입들 중 생성자 주입이다.
MemberService memberService;
//clear해야하는 서비스클래스 객체밖에 없다. 따라서 memorymemberRepo클래스 객체생성
MemoryMemberRepository memberRepository;
//실행 전에 테스트끼리 같은 메모리 리포지토리를 사용하기위해 세팅한다.
//+a) store가 static으로 선언되어 있으므로 같은 "메모리(data section)"를 공유한다.
@BeforeEach
public void beforeEach()
{
memberRepository=new MemoryMemberRepository();
memberService=new MemberService(memberRepository);
}
@AfterEach
public void afterEach()
{
memberRepository.clearStore();
}
//테스트 코드는 한글로 작성가능(테스트 기본 3가지 과정: given,when,then)
@Test
void 회원가입() {
//given(주어진 상황 속)
Member member = new Member();
member.setName("hello");
//when(이것을 실행했을 때,검증해야할 것)
Long savedId = memberService.join(member);
//then(결과(검증확인 구현))
//리포지토리에 저장이 됐는지 확인.
Member findmember = memberService.findOne(savedId).get();
assertThat(member.getName()).isEqualTo(findmember.getName());//실제 이름이 리포의 이름과 같은지 테스트
}
@Test
void 중복_회원_예외() {
//given
Member member1 = new Member();
member1.setName("spring");
Member member2 = new Member();
member2.setName("spring");
/* 오류 확인 방법1...try catch문
//when(중복상황이라면)
memberService.join(member1);
try {
memberService.join(member2);
}
catch (IllegalStateException e)
{
assertThat(e.getMessage()).isEqualTo("이미 존재하는 회원입니다.");
}
*/
//오류 확인 방법2...assertThrows(예외.class, 예외 발생 특정 코드 블록 로직)
//()->: JAVA의 람다 표현식(함수형 프로그래밍)
// 1. ():빈 매개변수,2. ->:매개변수와 본문 구분, 3.그뒤: 함수의 본문. 즉, { }안의 내용
memberService.join(member1);
//assertThrows의 사용은 함수의 동작에 중점을 두어 테스트하는 함수형 프로그래밍 스타일에 부합하다.
IllegalStateException e = assertThrows(IllegalStateException.class, () -> memberService.join(member2));
assertThat(e.getMessage()).isEqualTo("이미 존재하는 회원입니다.");
//then
}
@Test
void findMembers() {
}
@Test
void findOne() {
}
}
코드 설명
- 회원가입() test: 이름을 "hello"로 setting하고, db값과 객체값이 일치하느지 확인했다. 이를 통해 db에 데이터가 잘 들어갔음을 확인했다.
- 중복_회원_예외() test: 테스트는 첫번째 객체와 두번째 객체에 같은 이름을 setting하고, 중복이름이 발생하면 에러가 처리되는지 확인한다. 이를 총 두가지 방식으로 테스트를 진행했는데, 첫번째는 try{ db값=객체 값 } catch(예상 오류){} 방식으로 중복됐을때 오류가 발생하게 되면 테스트 성공이다. 두번째는 첫번째와 같은데 메서드 호출시 같은 오류 메세지인지 확인하는 과정에서 `람다 표현식`(`함수형 프로그래밍`)을 통해 구현 하였다.
오류 확인 방법1...try catch문
오류 확인 방법2...assertThrows(예외.class, 예외 발생 특정 코드 블록 로직)
테스트 결과
findOne,findMembers는 리포지토리에서 이미 조회하는 로직을 테스트 했으므로 굳이 하지는 않았다.