[Spring Boot] 회원 관리 예제 - 백엔드 개발

Updated:

목표

  • 비즈니스 요구사항 정리
  • 회원 도메인과 리포지토리 만들기
  • 회원 리포지토리 테스트 케이스 작성
  • 회원 서비스 개발
  • 회원 서비스 테스트(junit)

비즈니스 요구사항 정리

  • 데이터 : 회원ID, 이름
  • 기능 : 회원 등록, 조회
  • 아직 데이터 저장소가 선정되지 않음(가상의 시나리오)

일반적인 웹 애플리케이션 계층 구조

image

  • 컨트롤러 : 웹 MVC의 컨트롤러 역할
  • 서비스 : 비즈니스 도메인 객체를 가지고 핵심 비즈니스 로직 구현한 계층(ex. 회원 가입 중복 x)
  • 리포지토리 : 데이터베이스에 접근, 도메인 객체를 DB에 저장하고 관리
  • 도메인 : 비즈니스 도메인 객체 (ex. 회원, 주문, 쿠폰 등등 주로 데이터베이스에 저장하고 관리됨)


클래스 의존관계

image

  • 아직 데이터 저장소가 선정되지 않아서, 우선 인터페이스로 구현 클래스를 변경할 수 있도록 설계
  • 데이터 저장소는 RDB, NoSQL 등등 다양한 저장소를 고민중인 상황으로 가정
  • 개발을 진행하기 위해서 초기 개발 단계에서는 구현체로 가벼운 메모리 기반의 데이터 저장소 사용

회원 도메인과 리포지토리 만들기

회원 객체

Member 패키지 생성 후, Member.java 클래스 작성

package com.example.memberproject.domain;

public class Member {
    private Long id; // 데이터 구분을 위해 system이 저장하는 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 패키지 생성 후, MemberReository.java 인터페이스 작성

package com.example.memberproject.repository;

import com.example.memberproject.domain.Member;

import java.util.List;
import java.util.Optional;

public interface MemberReository {
    Member save(Member member); // 회원정보 저장
    // null 처리를 위해 Optional로 감싸기
    Optional<Member> findById(Long id); // id로 회원 찾기
    Optional<Member> findByName(String name); // name으로 회원 찾기
    List<Member> findAll(); // 모든 회원 list를 반환
}

Optional로 id로 회원을 찾는 기능을 만들거임, java8에 들어간 기능 findByName 또는 findById로 가져오는데 null이 반환될 수 있음 , 이때 null 을 그냥 반환하는 방법 대신 Optional로 감싸서 반환하는 방법을 많이 선호

  • save : 회원 정보 저장
  • findById, findByName : id나 name으로 회원 정보 찾기
  • findAll : 저장된 모든 회원 정보 불러오기


구현체 만들기

repository 경로에 MemberRepository를 구현하는 MemoryMemberRepository.java 클래스 생성

package com.example.memberproject.repository;
import com.example.memberproject.domain.Member;
import java.util.*;

public class MemoryMemberRepository implements MemberReository {

    private static Map<Long, Member> store = new HashMap<>();
    private static long sequence = 0L; // 0,1,2 .. key 값을 생성해 주는 역할

    @Override
    public Member save(Member member) {
        member.setId(++sequence); // id 증가
        store.put(member.getId(), member); // store를 map에 저장
        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))
                .findAny();
    }

    @Override
    public List<Member> findAll() {
        return new ArrayList<>(store.values());
    }
}

회원 리포지토리 테스트 케이스 작성

개발한 기능을 실행해 테스트 할 때, 자바의 main 메소드를 통해 실행하거나, 웹 애플리케이션의 컨트롤러를 통해 해당 기능을 실행한다. 이러한 방법은 준비하고 실행하는데 오래 걸리고, 반복 실행하기 어려우며 여러 테스트를 한 번에 실행하기 어렵다는 단점이 있다. 자바는 JUnit이라는 프레임워크로 테스트를 실행해 이러한 문제를 해결한다.

test/java/com.example.memberproject/ 안에 repository 패키지 생성 후, MemoryMemberRepositoryTest.java 클래스 생성

TEST 1

package com.example.memberproject.repository;
import com.example.memberproject.domain.Member;
import org.junit.jupiter.api.Test;

// 굳이 public으로 안해도 된다.
class MemoryMemberRepositoryTest {

    MemoryMemberRepository repository = new MemoryMemberRepository();

    @Test
    public void save() {
        Member member = new Member();
        member.setName("spring");

        repository.save(member);

        // return type이 optional이라 그냥 get()으로 꺼낼 수 있음
        // get으로 그냥 꺼내는 것이 좋은 방법은 아니지만 test이기 때문에 그냥
        Member result = repository.findById(member.getId()).get();
        System.out.println("result = " + (result == member));
    }
}

Screen Shot 2022-04-05 at 8 41 56 PM

assert 기능 이용

println 문장을 아래 코드로 변경

import org.junit.jupiter.api.Assertions;

Assertions.assertEquals(member, null);

테스트가 성공하면 녹색 체크가 뜬다. 하지만 실패한다면(null로 바꿔서 실행) 아래처럼 오류가 남

Screen Shot 2022-04-05 at 8 46 52 PM

아래와 같이도 쓸 수 있다.

import org.assertj.core.api.Assertions;

Assertions.assertThat(member).isEqualTo(result);

위에 코드에서 Assertions를 static import 해서 아래와 같이 쓴다. 바로 assertThat만 사용할 수 있어 더 편하다.

단축키 : assertThat() 입력 후 option + enter

import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.*;

assertThat(member).isEqualTo(result);


TEST 2

    @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);
    }

Screen Shot 2022-04-05 at 9 11 19 PM

테스트는 성공적으로 진행 되었는데 아래에 빨간색으로 이렇게 떴다.

0 containers and 1 tests were Method or class mismatch


해결방법

Preferences -> Build, Execution, Deployment -> Build Tools -> Gradle 에서 Build and run using과 Run tests using의 속성을 Intellij IDEA로 변경

Screen Shot 2022-04-05 at 9 10 48 PM

다시 실행하니 해결 되었다.

Screen Shot 2022-04-05 at 9 12 55 PM

다른 경우를 입력해보면 다른 객체라고 오류가 뜬다.

Member result = repository.findByName("spring2").get();

Screen Shot 2022-04-05 at 9 15 37 PM

두 테스트를 동시에 실행하고 싶다면, class 옆에 초록 화살표 또는 파일 자체를 실행 하면 된다.


TEST 3

@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);
}

Screen Shot 2022-04-05 at 9 20 34 PM

.isEqualTo(3)으로 바꾸면 당연히 오류가 난다.

Screen Shot 2022-04-05 at 9 20 16 PM


Test, data clear (중요)

그런데 전체를 다 돌려보니 오류가 난다.

Screen Shot 2022-04-05 at 9 22 51 PM

왜냐?

왼쪽의 Test 순서를 보면 findAll() -> findByName() -> save() 순으로 진행이 된다. 테스트 순서는 보장이 안된다.
모든 테스트는 순서와 상관없이 메소드별로 다 따로 동작하게 설계를 해야한다. 순서에 의존되면 절대 X

findAll()이 실행될 때 sping1, sping2를 save 한다. 근데 findByName()을 할 때 findAll()에서 이미 저장되니 객체가 나와버려서 오류가 나는 것이다.

-> 그래서 테스트가 하나 끝나고 나면 data를 clear 해줘야 한다!


MemoryMemberRepository.java에 다음 코드를 추가

public void clearStore() {
    store.clear();
}

MemoryMemberRepositoryTest.java에 객체 생성 문장 다음에 아래 코드를 추가

@AfterEach // callback 메소드
public void afterEach() { // test가 끝날때마다 repository를 지워줌
    repository.clearStore();
}

test가 실행되고 끝날때마다 한번식 repository를 지움 -> 순서가 상관이 없어진다.

@AfterEach : 한번에 여러 테스트를 실행하면 DB에 직전 테스트의 결과가 남을 수 있다. 이렇게되면 이전 테스트 때문에 다음 테스트가 실패할 가능성이 있다. @AfterEach를 사용하면 각 테스트가 종료될 때마다 이 기능을 실행하게 한다. 여기서는 메모리 DB에 저장된 데이터를 삭제


수정 후 결과

Screen Shot 2022-04-05 at 9 42 43 PM

Test를 먼저 만들고, 구현 클래스를 만들어서 돌린다. -> 테스트 주도 개발(TDD,Test Driven Development) ex. 별을 만든다고 할 때, 별 모양 틀을 먼저 만들어 놓고, 별을 만든 후 틀에 별이 맞는지 확인

회원 서비스 개발

회원 서비스는 회원 리포지토리와 도메인을 활용해 비즈니스 로직을 작성하는 것

  • 서비스 클래스 : 비즈니스에 가까운 용어로, 비즈니으세 의존적으로 설계
  • 리포지토리 : 단순히 기계적으로, 개발스럽게 용어 선택

src/main/java/com.example.memberproject 경로 안에 service 패키지 생성 후, MemberService.java 생성

회원가입

package com.example.memberproject.service;

import com.example.memberproject.domain.Member;
import com.example.memberproject.repository.MemberRepository;
import com.example.memberproject.repository.MemoryMemberRepository;

import java.util.Optional;

public class MemberService {

    private final MemberRepository memberRepository = new MemoryMemberRepository();

    /**
     * 회원가입
     */
    public Long join(Member member) {
        // 같은 이름이 있는 중복 회원 X
        memberRepository.findByName(member.getName())
                .ifPresent(m -> { // null이 아닌 어떤 값이 있으면 동작
                    throw new IllegalStateException("이미 존재하는 회원입니다.");
                });

        memberRepository.save(member);
        return member.getId();
    }

}

단축키 command + option + v : 자동으로 return 받을 변수 생성


예전에는 if null == 이런식으로 처리 했지만 지금은 null일 가능성이 있으면 Optional로 한번 감싸준다.

Optional을 통해 여러 메소드가 사용 가능하다. 그래서 ifPresent 사용 가능하다.

ifPresent : null이 아닌 어떤 값이 있으면 동작 orElseGet : 값이 있으면 꺼내고, 값이 없으면 default 값을 넣어서 꺼낸다.


findByName의 결과가 Optional로 반환됐기 때문에 아래와 같이 변경

memberRepository.findByName(member.getName())
        .ifPresent(m -> { // null이 아닌 어떤 값이 있으면 동작
            throw new IllegalStateException("이미 존재하는 회원입니다.");
        });

memberRepository.save(member);

해당 부분을 메소드로 변경해 준다.

단축키 control + t -> extract method

Screen Shot 2022-04-06 at 8 35 20 PM


전체 회원 조회

/**
    * 전체 회원 조회
    */
public List<Member> findMembers() {
    return memberRepository.findAll();
}

public Optional<Member> findOne(Long memberId) {
    return memberRepository.findById(memberId);
}

회원 서비스 테스트

단축키 : 클래스에서 command + shift + t : 테스트 클래스를 만들어줌

Screen Shot 2022-04-06 at 8 47 04 PM

Screen Shot 2022-04-06 at 8 48 30 PM

테스트는 한글로 바꿔도 된다…!

! given - when - then 문법

// given 뭔가가 주어졌는데(데이터)

// when 이거를 실행 했을 때(검증하는 것)

// then 결과가 이게 나와야 한다.(검증부)

먼저 test에 사용할 객체를 생성한다.

MemberService memberService = new MemberService();

회원가입 TEST

@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());
}

결과는 당연히 정상 작동한다.

Screen Shot 2022-04-06 at 9 00 45 PM


!! 중요

테스트는 정상 플로우도 중요하지만 예외 플로우가 훨씬 더 중요하다. 중복 검증 로직을 잘 파서 예외가 터지는 것도 봐야한다.

메세지 검증 방법

@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("이미 존재하는 회원입니다.");

memberService.join(memeber2)를 했을 때 예외를 받은 것이 e

IllegalStateException e = assertThrows(IllegalStateException.class, () -> memberService.join(member2));

결과 Screen Shot 2022-04-06 at 9 20 26 PM

다른 예외처리 방법1

// when
memberService.join(member1);
try {
    memberService.join(member2); // -> validate에서 걸려서 예외가 나와야함
    fail();
} catch (IllegalStateException e) {
    assertThat(e.getMessage()).isEqualTo("이미 존재하는 회원입니다. 123");
}

결과 메세지가 다르기 때문에 에러

Screen Shot 2022-04-06 at 9 09 06 PM

다른 예외처리 방법2

assertThrows(IllegalStateException.class, () -> memberService.join(member2));


clear 해주기 !!

코드 추가

MemoryMemberRepository memoryRepository = new MemoryMemberRepository();

@AfterEach
public void afterEach() {
    memoryRepository.clearStore();
}

단축키 : control + r : 이전에 실행했던거 다시 실행


문제

test 케이스에 있는 MemoryMemberRepository memberRepository = new MemoryMemberRepository();main의 MemberService에 있는 private final MemberRepository memberRepository = new MemoryMemberRepository(); 는 new를 했기 때문에 다른 객체이다.

MemoryMemberRepository에서 store를 static으로 되어있다. static은 instance와 상관없이 class 레벨에 붙는 것이기 때문에 지금은 크게 상관이 없다. 하지만 static이 아니라면? 바로 다른 DB가 되면서 문제가 생긴다.

그래도 new로 다른 repository 객체가 생성이 되면 다른 instance이기 때문에 내용물이 달라질 수 있다.

같은 것으로 테스트 하는게 맞는데, 다른 repository 객체로 테스트 되고 있는 상황이다.

같은 인스턴스를 쓰게 바꿔야한다.

MemberService.java 수정

private final MemberRepository memberRepository;

// 직접 생성하는 것이 아니라 외부에서 넣어주도록
public MemberService(MemberRepository memberRepository) {
    this.memberRepository = memberRepository;
}

단축키 : command + n : generate(생성자, getter, setter, …)


MemberServiceTest.java 수정

MemberService memberService;
MemoryMemberRepository memoryRepository;

@BeforeEach
public void beforeEach() {
    memoryRepository = new MemoryMemberRepository();
    memberService = new MemberService(memoryRepository);
}

같은 memory reposiotory를 사용하게 함

이런걸 dependency injection, di 라고 한다… 다음 시간에 자세히

TEST 전체 코드

package com.example.memberproject.service;

import com.example.memberproject.domain.Member;
import com.example.memberproject.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 java.util.Optional;

import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.*;

class MemberServiceTest {

    MemberService memberService;
    MemoryMemberRepository memoryRepository;

    @BeforeEach
    public void beforeEach() {
        memoryRepository = new MemoryMemberRepository();
        memberService = new MemberService(memoryRepository);
    }

    @AfterEach
    public void afterEach() {
        memoryRepository.clearStore();
    }

    @Test
    void 회원가입() {
        // 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 중복_회원_예외() {
        // 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); // -> validate에서 걸려서 예외가 나와야함
//            fail();
//        } catch (IllegalStateException e) {
//            assertThat(e.getMessage()).isEqualTo("이미 존재하는 회원입니다. 123");
//        }

        // then
    }

    @Test
    void findMembers() {
    }

    @Test
    void findOne() {
    }
}

참고

스프링 입문 - 코드로 배우는 스프링 부트, 웹 MVC, DB 접근 기술

YoonkyungH.log

Leave a comment