처음부터 차근차근

[Spring] Spring 입문 - 회원 관리 Backend 개발 본문

FrameWork/Spring

[Spring] Spring 입문 - 회원 관리 Backend 개발

HangJu_95 2023. 12. 18. 17:44
728x90

이번 포스팅에서는 회원 관리 Backend Service를 개발해보겠습니다.

로그인, 유저 확인 등의 경우에는 Spring Security가 필요한데, 이 부분은 아직 어려우니 제외하도록 하겠습니다.

비즈니스 요구사항 정리

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

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

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

클래스 의존관계

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

회원 도메인과 Repository interface 제작

package hello.hellospring.domain;

/**
 * 회원 Domain Class
 */
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;
    }
}

 

회원 Domain에는 간단하게 id와 name만 들어갑니다.

여기서 간단하게 Getter/Setter를 만드는 방법은 Command + N을 누르면

Generate Tool

 

Generate Tool이 나오는데, 이를 통해 간단하게 Getter/Setter, Test 파일, 생성자를 만들 수 있습니다.

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<Member> findByName(String name);
    List<Member> findAll();

}

간단한 interface를 구현하였습니다.

이때 Optional<Member>는 Member에 해당하는 value가 있을 경우 해당 value를 반환하고, 없다면은 null을 반환하는 타입입니다.

Typescrpit로 생각하니 Member | null 으로 보이네요. (추후 Java를 공부하면서 정리할 예정입니다.)

List는 Javascript의 Array로 보면 될 것 같습니다.

package hello.hellospring.repository;

import hello.hellospring.domain.Member;
import org.springframework.stereotype.Repository;

import java.util.*;

/**
 * Memory를 사용한 Repository 구현
 * 동시성 문제가 고려되어 있지 않음, 실무에서는 ConcurrentHashMap, AtomicLong 사용 고려해야 합니다.
 * MemberRepository를 implements합니다.
 */
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());
    }

    /**
     * store를 지우는 목적으로 진행
     * */
    public void clearStore() {
        store.clear();
    }
}

interface에 존재하는 메서드를 모두 Override하였습니다.

Java의 Map 자료구조를 통해 구현하였습니다.

구현한 Repository Test 진행

//test 파일
package hello.hellospring.repository;

import hello.hellospring.domain.Member;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;

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

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

class MemoryMemberRepositoryTest {
    MemoryMemberRepository repository = new MemoryMemberRepository();

    /**
     * 각각의 Test 진행 시 데이터를 지우는 용도로 사용
     */
    @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();
        // Assertions.assertEquals(null, member); // Junit을 통한 Test
        assertThat(member).isEqualTo(result); // assert.core를 통한 Assertion. Jest와 마찬가지로 직관적
    }

    @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("spring1").get();

        assertThat(result).isEqualTo(member1);
    }

    @Test
    public void findAll() {
        Member member1 = new Member();
        member1.setName("spring1");
        repository.save(member1);

        // shift + F6 동일한 내용 바꾸기
        Member member2 = new Member();
        member2.setName("spring2");
        repository.save(member2);

        List<Member> result = repository.findAll();

        assertThat(result.size()).isEqualTo(2);
    }
}

Java에서는 주로 Junit을 통해 Test를 진행합니다.

테스트 케이스를 만든 이유는 우리가 만든 코드를 테스트 해야할 때, PostMan으로 여러번 찍기 번거로우니, Junit이라는 라이브러리를 통해 빠르게 테스트하기 위함입니다.

구현한 모습을 보니, Jest와 굉장히 유사해보입니다. 

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

해당하는 코드를 통해 더욱 직관적으로 테스트 할 수 있습니다.

 

여기서

@AfterEach
public void afterEach() {
    repository.clearStore();
}
  • 한번에 여러 테스트를 실행하면 메모리 DB에 직전 테스트의 결과가 남을 수 있다. 이렇게 되면 다음 이전 테스트 때문에 다음 테스트가 실패할 가능성이 있다. `@AfterEach` 를 사용하면 각 테스트가 종료될 때마다 이 기능을 실행한다. 여기서는 메모리 DB에 저장된 데이터를 삭제한다.
  • 테스트는 각각 독립적으로 실행되어야 한다. 테스트 순서에 의존관계가 있는 것은 좋은 테스트가 아니다.

회원 서비스 개발 및 테스트 진행

public class MemberService {
    private final MemberRepository memberRepository = new MemoryMemberRepository();

    /**
     * 회원 가입
     * @param member
     * @return
     */
    public Long join(Member member) {
        validateDuplicateMember(member);
        memberRepository.save(member);
        return member.getId();
    }

    /**
     * 같은 이름이 있는지 찾는 메서드
     * @param member
     */
    private void validateDuplicateMember(Member member) {
        // 같은 이름이 있는 중복 회원 X
        // ifPresent = null이 아니면 예외 처리 진행
        memberRepository.findByName(member.getName())
            .ifPresent(m -> {
                throw new IllegalStateException("이미 존재하는 회원입니다.");
            });
    }

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

    /**
     * 단일 맴버 조회 진행
     * @param memberId
     * @return
     */
    public Optional<Member>  findOne(Long memberId) {
        return memberRepository.findById(memberId);
    }
}

현재 작성된 코드는 회원 서비스가 Memory 회원 Repository 객체를 직접 생성하는 방식으로 진행되었습니다.

코드를 조금 수정하여 변경해보겠습니다.

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

이를 통해 회원 Repository 코드가 서비스 코드에 의존성 주입 가능하도록 변경하였습니다.

 

이제 회원 서비스 테스트코드를 작성해보겠습니다.

package hello.hellospring.service;

class MemberServiceTest {
    MemberService memberService;
    MemoryMemberRepository memberRepository;

    // DI 진행
    // 각 테스트 전 호출이 진행되며, 테스트가 서로 영향이 없도록 항상 새로운 객체를 생성하고, 의존 관계를 새로 맺어줍니다.
    @BeforeEach
    public void beforeEach() {
        memberRepository = new MemoryMemberRepository();
        memberService = new MemberService(memberRepository);
    }
    @AfterEach
    public 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
    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);
//            fail();
//        } catch (IllegalStateException e) {
//            assertThat(e.getMessage()).isEqualTo("이미 존재하는 회원입니다.");
//        }
    }


    @Test
    void findMembers() {
    }

    @Test
    void findOne() {
    }
}

중복_회원_예제 Test에서 예외처리를 try / catch문으로도 진행할 수 있지만,

assertThrows 메서드를 통해서도 간단하게 진행할 수 있습니다.

참고

김영한의 스프링_입문