[김영한의 스프링 입문] 3. 회원 관리 예제 - 백엔드 개발

회원 관리 예제 - 백엔드 개발

1. 비즈니스 요구사항 정리

회원 관리 예제를 만들 것이다. 비즈니스 요구사항 정리를 할 것이고, 회원 도메인과 회원 도메인 객체를 저장하고 불러오는 저장소라고 하는 리포지토리 객체를 만들 것이며, 리포지토리가 정상동작하는 지 테스트를 만들 것이다. 테스트는 JUnit이라는 테스트 프레임워크로 만들 것이다. 비즈니스 요구사항을 정리할 것이다. 비즈니스 요구사항은 가장 쉬운 것으로 할 것이다. 회원 ID와 이름, 그리고 기능은 회원 등록과 조회 밖에 없다.

단순한 예제를 통해 스프링 생태계에 전반적으로 어떤식으로 개발이 일어나고 동작하는지를 알아보는 것이 강의의 목표이므로 아주 단순한 비즈니스를 할 것이다. 활용 1편 강의에서는 좀더 복잡한 비즈니스를 하게 될 것이다.

또 데이터 저장소가 선정되지 않았다는 가상 시나리오가 하나더 추가될 것인데, 개발자가 개발은 해야하는데 DB가 선정이 안된 것이다. 성능이 중요한 데이터 베이스로 할지, 일반적인 환경 데이터로할지 NoSQL로 해야할지 등등 아직 정해지지 않은 상태에서 개발을 해야하는 상황인 것이다.

이러한 배경을 두고 시작을 해볼 것이다.

일반적인 웹 어플리케이션 컨트롤러 서비스 리포지토리 도메인 그리고 데이터베이스로 이뤄진다.

  • 컨트롤러는 앞에서 본것처럼 웹 mvc의 컨틀롤러의 역할을 하는 것이고
  • 서비스: 비즈니스 도메인 객체를 가지고 핵심 비즈니스 로직이 동작하도록 구현한 객체
  • 도메인: 회원, 주문, 쿠폰과 같은 데이터베이스에 주로 저장하고 관리되는 비즈니스 도메인 객체
  • 리포지토리: 데이터 베이스에 접근하고 도메인 객체를 DB에 저장하고 관리하는 객체

클래스 의존관계는 다음과 같다.

회원 비즈니스 로직에는 회원 서비스 객체가 있으며 회원 리포지토리는 인터페이스로 설계된다. 아직 데이터 저장소가 선정되지 않았으므로 구현체를 우선은 Memory구현체로 만드는 것이다. 일단 메모리 모드로 금방 몇분만에 간단히 만드는 단순한 구현체를 만드는 것이며, 향후에 이것을 RDB로 할지 JPA로 할지 선정하여 나중에는 변경할 수 있도록 인터페이스로 정의한 것이다.

개발을 진행하기 위해서 초기 개발 단계에서는 구현체로 가벼운 메모리 기반의 데이터 저장소를 사용할 것이다. MyBatis나 JPA 등 데이터베이스를 다루는 기술들이 다양하므로 이것을 무엇으로 하느냐에 따라 구현체가 달라지는 것이다.


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

회원은 여기에다가 패키지를 도메인이라고 만들 것이며, 그밑에 Member라는 도메인 자바 클래스를 만들 것이다. 필드에는 비즈니스 요구사항에서 언급했듯이 id와 name이 있다.

public class Member {
    private Long id;
    private String name;

    public Long getId() {
        return id;
    }

    public String getName() {
        return name;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public void setName(String name) {
        this.name = name;
    }
}

그리고 이어서 repository 패키지를 만들고, 필요한 메서드들을 만든다. 이때 find 메서드들의 리턴 형태가 Optional인데 이것은 Java8부터 지원하는 문법이며 메서드가 리턴할 것이 없는 경우 Null을 그대로 리턴하는 것이 아니라 Optional로 감싸서 리턴할 수 있도록 한다.

public interface MemberRepository {
    Member save(Member member);
    Optional<Member> findById(Long id);
    Optional<Member> findByName(String name);
    List<Member> findAll();
}

그리고 이 인터페이스를 메모리를 통해 데이터를 꺼내는 가벼운 구현체인 MemoryMemberRepository를 정의한다. 메모리에 데이터를 저장할 수 있도록 자료구조중 Map, 그 중에서도 HashMap 객체를 사용할 것이며 key값을 자동 생성해줄 sequence를 필드로 추가해준다.

public class MemoryMemberRepository implements MemberRepository {

    private static Map<Long, Member> store = new HashMap<>();
    private static Long sequence = 0L;
 

이어서 save 메서드를 구현할 것이다.

    @Override
    public Member save(Member member) {
        member.setId(++sequence);
        store.put(member.getId(), member);
        return member;
    }

Optional의 ofNullable메서드를 통해서 리턴값이 Null이어도 감쌀 수가 있다. 이렇게 감싸주면 클라이언트측에서 데이터 처리를 편리하게 할수 있다.

@Override
public Optional<Member> findById(Long id) {
    return Optional.ofNullable(store.get(id));
}

findByName 메서드에서는 Map에서 제공하는 values 메서드를 통해 저장된 값을 모두 꺼낸 다음 그것들에 대하여 반복문을 돌려 파라미터로 받은 name과 문자열이 같은 것을 리턴한다.

@Override
public Optional<Member> findByName(String name) {
    return store.values().stream()
            .filter(member -> member.getName().equals(name))
            .findAny();
}

한편 findAll는 store.values() 메서드의 리턴값을 ArrayList 생성자의 파라미터로 넘겨서 객체를 생성하여 리턴한다.

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

이렇게 리포지토리의 구현이 끝났다. 이것이 제대로 동작하는지 검증을 하기 위한 방법이 테스트 케이스를 작성하는 것이다.


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

회원 리포지토리 테스트 케이스를 작성할 것이다. 내가 원하는 대로 정상적으로 동작할지 검증하는 방법이 있다. 코드를 코드로 테스트하는 것이다. 자바의 main 메서드를 통해서 실행하거나, 웹 애플리케이션의 컨트롤러를 통해서 해당 기능을 실행해보는 방법등이 있지만, 준비하고 실행하는데 너무 오래 오래걸리고 반복적으로 실행하기가 어려우며 여러 테스트를 한번에 실행하기 어렵다는 단점이 있다. 이제 Junit라는 테스트 코드 자체를 실행해서 이 문제를 해결한다.

일단 test/java hello.hellospring 패키지 밑에 테스트하려는 클래스의 패키지 경로를 그대로 만들고, 클래스명을 패키지밑에 테스트하려는 클래스명 + Test로 지어 자바 클래스를 만들어준다. 이 클래스에서 본격적으로 테스트할 클래스의 객체를 생성하여 필드로 저장해준다.

public class MemoryMemberRepositoryTest {

    MemoryMemberRepository repository = new MemoryMemberRepository();

그리고 @Test라는 애노테이션을 붙인 테스트를 위한 메서드를 다음과 같이 만든다. 이 메서드는 Member 객체를 하나 생성하여 넣고 해당 아이디로 다시 Member 객체를 꺼낼때 생성했던 객체와 같은 것인지 확인하는 메서드이다. 즉, 정상적으로 저장되고 조회되는 지를 간단히 확인해보는 것이다.

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

    repository.save(member);
    Member result = repository.findById(member.getId()).get();
    Assertions.assertEquals(member, result)
}

그런데 이때 두개의 객체가 같은 것인지를 확인하는 역할을 해주는 Assertions라는 클래스가 org.junit.jupiter.api와 org.assertj.core.api에서 제공되고 있다. 전자의 Assertions 클래스는 위와 같이 assertEquals([Exptected Object], [Actual Object])라는 메서드를 제공하며, 후자의 Assertions 클래스는 요즘 들어 많이 사용되고 있으며, assertThat() 메서드를 제공하므로 다음과 같이 작성이 가능하다. 영어처럼 자연스럽게 읽히는 문장이 완성된다.

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

그런데 Assertions를 선택하고 option+enter를 하면 static import를 하여 Assertions를 생략할 수가 있다.

assertThat(member).isEqualTo(result);

다음에 테스트할 것은 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 메서드가 정상 작동하고 있는지 확인하기 위해서 findAll로 뽑은 리스트의 개수가 저장한 개수와 같은 지를 확인하는 메서드를 추가한다.

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

그런데 이렇게 한 후, 패키지 전체를 테스트하면 findByName에서 에러가 뜬다. 에러가 뜬 이유는, 모든 테스트는 순서가 보장이 되지않기 때문에 순서에 의존하여 코딩하면 안된다. 현재는 findAll이 먼저 실행이 됐다. findByName에서 spring1 이름을 가진 객체를 조회했을때, findAll에서 추가한 객체가 조회되었기 떄문에 에러가 발생했다. 하나의 테스트가 끝나고 나면 데이터를 클리어해줘야한다.

일단 store을 비워주는 메서드를 MemoryMemberRepository 클래스에서 정의한다.

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

그리고 MemoryMemberRepositoryTest에서 각 테스트 메서드가 끝날때마다 호출되는 콜백 메서드를 따로 만들고 그 안에서 repository 객체에 대해 clearStore를 호출한다. 테스트 메서드가 끝날때마다 계속 호출되는 콜백 메서드를 정의하려면 해당 메서드에 대해 @AfterEach라는 애노테이션을 붙여주기만 하면 된다.

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

우리는 여태 MemoryMemberRepsitory 클래스를 모두 만든 후 이에 대한 테스트 클래스를 작성했으나, 어떤 경우에는 테스트 클래스를 먼저 작성하고, 각 테스트들이 정상 결과를 도출할 때까지 원하는 대상을 구현하는 방법도 가능하다. 이것을 테스트 주도 개발이라고 하여 TDD라고 한다.


4. 회원 서비스 개발

회원 리포지토리와 도메인을 이용해서 비즈니스 로직을 작성하는 서비스 객체를 만들어보자. 우선 service라는 이름의 패키지를 만들고 그밑에 MemberService 클래스를 만든다.

그리고 join이라는 회원가입 메서드를 구현한다. 한가지 규칙은 이미 존재하는 회원의 이름으로 회원가입할 수 없다는 점이다. 이에 따라서 일단 사용자가 입력한 대로 만든 Member에 대해서 이 회원의 이름을 갖고 findByName을 호출하여 회원이 조회가 될 경우 예외를 띄운다. 이때 Optional이 제공하는 메서드 ifPresent메서드는 Consumer 구현체를 파라미터로 받으며 Optional로 감싼 객체가 Null이 아닐 경우 accept라는 메서드가 실행된다. 이것을 람다를 통해 간단하게 구현하는 동시에 ifPresent를 호출하자.

public class MemberService {

    private final MemberRepository memberRepository = new MemoryMemberRepository();

    // 회원가입

    public Long join(Member member) {
        //  같은 이름이 있는 중복 회원 x
        Optional<Member> result = memberRepository.findByName(member.getName());
        result.ifPresent(m -> {
            throw new IllegalStateException("이미 존재하는 회원입니다.");
        });
        memberRepository.save(member);
        return member.getId();
    }
}

그런데 지금처럼 Optional이라는 데이터 형이 바로 보이도록 코딩하는 것이 좋은 것은 아니다. 따라서 그냥 Optional 객체에 대하여 바로 ifPresent를 호출하자.

public Long join(Member member) {
    //  같은 이름이 있는 중복 회원 x
    memberRepository.findByName(member.getName())
            .ifPresent(m -> {
        throw new IllegalStateException("이미 존재하는 회원입니다.");
    });
    memberRepository.save(member);
    return member.getId();
}

이러한 경우에는 중복회원 검증하는 메서드를 추출하는 것이 더 깔끔하다. 인텔리제이에서는 ctrl + t를 누르면 리팩토링에 대해 제공되는 기능들이 보여지는데 여기서 extract method를 누르면 된다.

public Long join(Member member) {
        //  같은 이름이 있는 중복 회원 x
        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);
    }
}

5. 회원 서비스 테스트

회원 리포지토리에 대한 테스트를 만들때와 마찬가지로 여태 작성한 회원 서비스 클래스에 대한 테스트 코드를 작성하고자 한다. 일단 회원 서비스 클래스와 동일한 패키지 경로를 만들어주고 그밑에 MemberServiceTest 클래스를 만든다. 인텔리제이에서는 이 과정을 단축해주는 단축키가 있다. cmd + shift + t를 누르면 해당 클래스에 대한 테스트를 만들 수가 있다. 이렇게 하면 test/java hello.hellospring 밑에 자동으로 service패키지를 만들고 그 밑에 테스트 클래스를 만든다.

class MemberServiceTest {
    
    MemberService memberService = new MemberService();

테스트 클래스에서 메서드를 만들때에는 메서드명을 한글로 적기도 한다. 직관적이고 이 메서드가 실제 코드에 포함되지도 않기 때문이다.

하나의 테스트 메서드는 세가지 요소로 구조화될 수 있다. 테스트를 처음 작성할 때에는 이 틀에 맞춰서 했다가 익숙해지면 다양하게 변형해서 작성하는 것이 좋다.

  • given : a라는 환경에서
  • when : b일때
  • then : c여야한다.
@Test
void 회원가입() {
    // given

    // when

    // then

}
@Test
void 회원가입() {
    // 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());
}

사실은 이것은 반쪽자리 테스트이다. 예외가 나와야하는 부분에서는 확실히 예외가 나오는지를 확인해야한다. 중복회원에 대해서 예외를 정상적으로 띄우는지에 대한 테스트 메서드는 다음과 같이 정의한다.

try catch문을 통해 두번째 join 메서드 호출시 예외가 발생하지않을 경우에는 fail이고 예외가 발생했을떄에는 예외 메시지가 우리가 원하는 메시지가 맞는지 확인해주는 것이다.

@Test
public void 중복_회원_예외() {
    // 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) {
        assertThat(e.getMessage()).isEqualTo("이미 존재하는 회원입니다.");
    }

    // then

}

그런데 이 대신 더욱 편하게 예외를 확인할 수 있는 문법이 존재한다. assertThrows(class expectedType, Executable executable)을 이용하며 executable을 구현할때에는 람다를 통해 메서드 구현부에 예외를 띄워져야하는 코드를 작성하면 된다. assertThrows는 예외를 리턴하기 때문에 이것을 받아 예외 메시지도 확인해볼 수 있다.

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

이것도 마찬가지로 매 테스트가 끝날때마다 클리어를 해줘야한다. 클리어를 해주고싶으나 필드에 서비스 객체밖에 없다. 따라서 리포지토리를 가져와야한다. store 객체는 MemoryMemberRepository의 스태틱 필드이기 때문에 그냥 새 리포지토리 객체를 생성해도 같은 store 객체를 공유하게 되므로 새로운 리포지토리 객체에 대해 clearStore를 호출하여 store를 비워줄 수가 있다.

class MemberServiceTest {

    MemberService memberService = new MemberService();
    MemoryMemberRepository memberRepository =  new MemoryMemberRepository();

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

그러나 여전히 일단 서비스객체가 사용하는 리포지토리와 테스트에서 만든 리포지토리가 서로 다른 인스턴스이기 때문에 스태틱 필드가 아닌 다른 것들은 아예 별도의 데이터를 사용한다는 문제가 생긴다. 같은 인스턴스를 사용하게끔 바꾸려면 이렇게 할것이 아니라 DI(Dependency Injection)을 통해서 의존객체를 주입해주는 것이 좋다.

즉 서비스클래스에서 리포지토리 필드에 들어갈 객체를 스스로 생성하지 않고 생성자에서 파라미터로 받게 한다.

public class MemberService {

    private final MemberRepository memberRepository;

    public MemberService(MemberRepository memberRepository) {
        this.memberRepository = memberRepository;
    }

그리고 테스트 케이스에서는 매번 메서드가 실행되기전에 리포지토리 객체를 생성후 서비스에 대하여 생성자를 호출할때 해당 리포지토리 객체를 주입해줌으로써 매 테스트마다 독립적으로 두개의 객체를 만든 후, 테스트가 끝나면 해당 서비스객체가 사용하는 리포지토리 객체의 store를 비워주는 것이다.

class MemberServiceTest {

    MemberService memberService;
    MemoryMemberRepository memberRepository;

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

Comments