스프링 입문 - 03. 회원 관리 예제

2023. 4. 18. 01:54Spring/Java

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

  • Controller : 웹 MVC의 컨트롤러, 클라이언트로부터 들어온 HTTP request를 처리하고 HTTP Response를 반환한다.
  • Service : 핵심 비즈니스 로직 구현
  • Repository : 데이터베이스에 접근, 도메인 객체를 DB에 저장하고 관리
  • Domain : 비즈니스 도메인 객체

회원 관리 비즈니스 요구사항

데이터 : 회원 ID, 이름

기능 : 회원가입, 조회(ID / 이름 별로)

DB 저장소는 구현체로 메모리 기반 데이터 저장소 사용

 

회원 Domain

package hello.hellospring.domain;

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

django의 모델을 보는 것 같다. Member 라는 class 안에 pk인 id 필드와 name 필드를 추가하였다.

 

회원 Repository Interface

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(Java 8) : 없으면 None 반환
    Optional<Member> findByName(String name);
    List<Member> findAll();
}

아직 특정 저장소가 선정되지 않았기 때문에 Repository Interface를 먼저 만들어 주었다. 이를 상속받은 구현체를 사용할 것이다.

Java 8 부터 지원한다는 Optional 이라는 class에 대해 알게 되었다. NPE(NullPointerException)을 발생하지 않도록 하기 위해 도와준다. 반환할 객체가 있으면 가져오고 아니면 None을 반환한다. Django에서 request.POST.get() 의 get() 함수와 비슷한 개념인 것 같다.

 

회원 Repository 메모리 구현체

package hello.hellospring.repository;

import hello.hellospring.domain.Member;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;

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) {
        return store.values().stream()
                .filter(member -> member.getName().equals(name))
                .findAny();
    }

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

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

위에서 말한대로 저장소가 확정되지 않은 상태에서 메모리를 이용한 구현체를 사용하기로 했다. 그래서 Repository interface를 상속해주었고 Map을 이용해서 Member를 저장할 것이다. 물론 동시성 문제가 고려되어 있지 않기 때문에 사용 하는 것이고 실무에서는 ConcurrentHashMap, AtomicLong 사용을 고려한다고 한다. (나중에 알아보자!)

 

save 메소드

Domain에서 만든 Member를 저장할 때 sequence라는 pk 값을 올려준다. 이후 map의 put 메소드를 이용하여 메모리에 적재한다.

 

findById 메소드

Optional.ofNullable은 null값을 갖거나 갖지 않을수 있는 객체를 반환한다.

 

findByName 메소드

store에 저장한 value중 name에 해당하는 것을 가져오는 것이다. findAny라는 stream interface의 method을 사용하여 없으면 Optional 형태로 반환해준다.

* 내일 java stream에 대해 다시 공부하자..!

 

clearStore 메소드

Test 코드에서 사용하기 위해 만들어두었다.

 

회원 Repository Test

토비의 스프링 2장은 Test 에 관한 내용으로 가득차있다. 개발의 핵심이자, 스프링의 핵심인 Test 코드를 드디어 짜볼수 있게되어 아주 재밌었다. 

package hello.hellospring.repository;

import static org.assertj.core.api.Assertions.*;

import hello.hellospring.domain.Member;
import java.util.List;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;

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();
//        org.junit.jupiter.api.Assertions
//        Assertions.assertEquals(member, null); // 실패
//        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(member1).isEqualTo(result);
        assertThat(member2).isNotEqualTo(result);
    }

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

    }
}

우선 메소드에 해당하는 test들을 @Test annotation을 붙여 짜준다.

try-catch 를 써서 test를 직접 해볼수도 있겠지만 Spring에서는 Assertions 라는 것을 두가지나 제공한다.

첫번째는 junit의 Assertions 인데 위에 주석처리한 것처럼 코드를 쓸 수있다. 하지만 요즘에는 assertj의 Assertions을 더 많이 쓴다고 한다. 코드를 딱 봐도 더 직관적이기 때문인 것 같다. 또한 static method 이기 때문에 import 를 해두면 바로 메소드로 접근이 가능하다.

 

@AfterEach

테스트 코드를 짜서 실행해보면 알겠지만 하나의 Repository에서 작업을 하고 있기 때문에 해당 메모리 구현체에 test를 할 때마다 객체가 저장이 된다. 이에 AfterEach 라는 annotation을 이용하여 test 하나의 method가 끝날때마다 실행 시켜줄 메소드를 만들어줄수있다. 이를 사용하기 위해 이전에 repository에서 clearStore 라는 메소드를 만들어 두었던 것이다.

※ 테스트 코드는 순서가 보장되지 않는다.

 

회원 Service

Domain, Repository를 구현했고 테스트 까지 마쳤으므로 핵심 기능을 구현해보자.

회원 가입, 회원 조회 기능을 구현하는 것이 목표이다.

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);
        return member.getId();
    }

    private void validateDuplicateMember(Member member) {
        memberRepository.findByName(member.getName())
                .ifPresent(m -> {
                    throw new IllegalStateException("이미 존재하는 회원입니다.");
                });
    }

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

    /*
    * Id로 특정 회원 조회
     */
    public Optional<Member> findOne(Long memberId){
        return memberRepository.findById(memberId);
    }

}

MemberService의 생성자는 추후 MemberServiceTest 에서 진행할 Test 들을 위해 Dependency Injection을 해두었다.

회원 가입 메소드는 같은 이름인지 validate 하는 메소드로 확인 하고 저장할수 있도록 구현하였다.

회원 조회 메소드는 repository에서 만든 함수들을 이용하였다.

 

나는 회원 조회 파트를 구현하며 의문이 좀 들기 시작했다. repository에서 쓰는 함수들이 있는데 왜 굳이 Service에서 다시 함수를 구현해야 하는 것일가?

-> 처음으로 돌아가 생각해보자. repository는 DB에 접근하는 것이고 Service는 핵심 로직을 구현 하는 것이다. DB에서 직접 조회하는 것과 이를 이용하여 핵심로직으로 구현하는 것은 하는 일은 비슷하지만 service는 사용자와의 중간단계에서 로직을 수행해주는 것이다. 또한 회원가입 처럼 validate하는 함수를 이용할수도 있는데 이런 경우 repository에 있는 db접근 함수들을 "이용" 하여 핵심 로직을 짜는 것 같다.

 

회원 Service Test

Service도 구현을 했으니 Test 코드도 구현해준다.

package hello.hellospring.service;

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

import hello.hellospring.domain.Member;
import hello.hellospring.repository.MemoryMemberRepository;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

class MemberServiceTest {

    MemberService memberService;
    MemoryMemberRepository memberRepository;

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

    @AfterEach
    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
    void 중복_회원_가입(){
        //given
        Member member1 = new Member();
        member1.setName("hello");

        Member member2 = new Member();
        member2.setName("hello");

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

 

@BeforeEach

AfterEach와 마찬가지로 각 테스트를 실행하기 이전에 실행해주는 메소드를 정의 한다. 테스트에 서로 영향이 없도록 항상 새로운 객체를 생성하고 의존관계도 맺어준다.

 

 

Given / When / Then

Test code를 짜기 위해서는 Given / When / Then 을 써두고 짜면 편하다고 한다.

  • Given : 테스트 할 데이터 혹은 객체
  • When : 기능 실행 (메소드 호출)
  • Then : 예상 결과

Given, When, Then을 이용한 간단한 계산기 테스트 코드이다.

@Test
public void testAddition() {
    // Given
    Calculator calc = new Calculator();
    int x = 2;
    int y = 3;

    // When
    int result = calc.add(x, y);

    // Then
    assertEquals(5, result);
}

설명하지 않아도 이해가 아주 잘 될 것 같다. 이런 느낌으로 test 코드를 짜면 초보자들이 test를 짜기 쉽다고 한다.

 

Test 코드는 개발자들이 확인하기 때문에 영어권 사람과 같이 개발하는 것이 아니라면 한글로 메소드명을 지어도 무방하다고 한다. (물론 같이 일하는 팀마다 다르겠지만)

 

Test 코드에서는 맞을 경우 뿐만 아니라 틀린 경우에 대한 것, 예상하는 예외 까지 test 해볼 수 있는 것이 신기했다.