[스프링 입문] Section04. 회원 관리 예제 - 백엔드

Euiyeon Park·2025년 6월 6일
0

갓영한 스프링🍀

목록 보기
3/12
post-thumbnail

10. 비지니스 요구사항 정리

  • 데이터: 회원ID, 이름
  • 기능: 회원 등록, 조회

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

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

클래스 의존 관계

아직 데이터 저장소를 선정하지 않은 상황으로 가정 - RDB, NoSQL, JPA 중 고민하는 상황

  • 핵심은 모냐! 우선 인터페이스로 추후에 구현 클래스를 변경할 수 있도록 설계
  • 나중에 갈아끼우기 위해서 일단 인터페이스를 정의해둠

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

📂 src/main/java/.../domain/Member

@Entity
public class Member {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    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;}
}

📂 src/main/java/.../repository/MemberRepository

  • Repository -> DB에 접근, 도메인 객체를 DB에 저장하고 관리
  • null로 반환될 수 있는 값 -> Optional로 감싸서 반환
public interface MemberRepository {
    Member save(Member member);                 // 저장
    Optional<Member> findById(Long id);         // id로 회원 조회
    Optional<Member> findByName(String name);   // name으로 회원 조회
    List<Member> findAll();                     // 모든 회원 조회
}

📂 src/main/java/.../repository/MemoryMemberRepository

public class MemoryMemberRepository implements MemberRepository {
    /* 📌 공유변수 store, sequence
    * 동시성 문제를 고려해야 함(예제에서는 동시성 문제 고려❌)
    * 실무에서는 ConcurrentHashMap, AtomicLong 사용 고려
    */
    private static Map<Long, Member> store = new HashMap<>( );
    private static long sequence = 0L;             //  0, 1, 2 키값을 자동으로 생성하는 역할

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

    @Override
    public Optional<Member> findById(Long id) {
        // Map<Key, Value> 형태로 저장, Map의 get(key)는 value를 반환
        // ofNullable(value)는 value가 null일 수도 있고, 아닐 수도 있을 때 사용
        return Optional.ofNullable(store.get(id));
    }

    @Override
    public Optional<Member> findByName(String name) {
    	// values()는 Map에 저장된 모든 value객체(Member)를 Collection으로 반환
        return store.values().stream()
                .filter(member -> member.getName().equals(name))
                // 필터링된 요소 중 조건을 만족하는 임의의 요소를 Optional로 반환
                .findAny();
    }
    
    @Override
    public List<Member> findAll() {
        return new ArrayList<>(store.values());
    }

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

🪄.steam() 연산을 할 수 있는 대표적인 자료형

  • .steam()연산을 사용할 수 있는 자료형들은
    특정한 인터페이스나 클래스를 구현하거나 상속한 컬렉션 및 데이터 구조들
    • Collection 인터페이스(List, Set, Queue)
    • Map 인터페이스(keySet(), entrySet())
    • 객체 배열(Arrays)
  • 기본형 배열, 일반 객체는 스트림 사용 불가❌

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

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

📂MemoryMemberRepositoryTest.java

class MemoryMemberRepositoryTest {
	MemberRepository repository = new MemoryMemberRepository();

    // 📍 findById() 테스트
    @Test
    public void findByIdTest(){
        // given
        Member member = new Member();
        member.setName("나는의연");

        // when
        repository.save(member);

        // then - 검증
        // findById()의 반환타입은 Optional -> Member와 타입 불일치 해소를 위해 get() 사용
        // get()은 Optional 내부에 저장된 값을 꺼냄(Optional을 까는 메서드)
        Member result = repository.findById(member.getId()).get();

        // 검증 방법1 - assertThat() : JUnit, AssertJ 라이브러리의 테스트 검증 메서드
        assertThat(result).isEqualTo(member);

        // 검증 방법2 -  assertEquals(expected(기대값), actual(실제값))
        assertEquals(member, result);

        // 검증 방법3
        System.out.println("result = " + (result == member));   // ✅ true
    }

    // 📍 findByName() 테스트
    @Test
    public void findByNameTest(){
        Member member1 = new Member();
        member1.setName("나는최강의연");
        repository.save(member1);

        Member member2 = new Member();
        member2.setName("나는지존의연");
        repository.save(member2);

        Member result = repository.findByName(member1.getName()).get();
        assertThat(result).isEqualTo(member1);
//        assertThat(result).isEqualTo(member2);        // ❌ test failed

//        assertAll(
//                () -> assertEquals(result, member1),
//                () -> assertEquals(result, member2)
//        );
    }

    // 📍 findAll() 테스트
    @Test
    public void findAllTest(){
        Member member1 = new Member();
        member1.setName("zizon의연v");
        repository.save(member1);

        Member member2 = new Member();
        member2.setName("zizon지연v");
        repository.save(member2);

        List<Member> result = repository.findAll();
        assertThat(result.size()).isEqualTo(2);
        assertEquals(result.size(), 2);
    }
}

♻️ 테스트 코드 실행 시 주의할 점

  • 테스트 코드, 즉 테스트 메서드들은 순서가 보장되지 않는다. 따라서:
    • 테스트 코드는 서로 순서와 관계없이 , 서로 의존 관계없이 설계가 되어야 함**
    • 테스트는 반복 실행이 가능해야 함**
    • 하나의 테스트가 끝날 때마다 공용 데이터를 정리하는 작업이 필요

  • 실제로 테스트 메서드들을 순차적으로 실행할 땐 문제가 없지만,
    마지막에 최종적으로 전체 메서드들을 실행하면 문제가 발생한다.

📂MemoryMemberRepository.java

  1. 리포지토리(Map) 전체를 비우는 메서드를 추가하고
    public void clearStore(){
        store.clear();
    }
  1. 테스트 코드에서 데이터를 정리하는 작업을 수행해야 한다.
    @AfterEach
    public void afterEach(){
        repository.clearStore();
    }
class MemoryMemberRepositoryTest {
    MemoryMemberRepository repository = new MemoryMemberRepository();

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

    // 📍 findById() 테스트
    @Test
    public void findByIdTest(){
        // given
        Member member = new Member();
        member.setName("나는의연");

        // when
        repository.save(member);

        // then
        Member result = repository.findById(member.getId()).get();

        assertThat(result).isEqualTo(member);
        assertEquals(member, result);
        System.out.println("result = " + (result == member));   // ✅ true
    }

    // 📍 findByName() 테스트
    @Test
    public void findByNameTest(){
        Member member1 = new Member();
        member1.setName("나는최강의연");
        repository.save(member1);

        Member member2 = new Member();
        member2.setName("나는지존의연");
        repository.save(member2);

        Member result = repository.findByName(member1.getName()).get();
        assertThat(result).isEqualTo(member1);
    }

    // 📍 findAll() 테스트
    @Test
    public void findAllTest(){
        Member member1 = new Member();
        member1.setName("zizon의연v");
        repository.save(member1);

        Member member2 = new Member();
        member2.setName("zizon지연v");
        repository.save(member2);

        List<Member> result = repository.findAll();
        assertThat(result.size()).isEqualTo(2);
        assertEquals(result.size(), 2);
    }
}

@AfterEach

  • 테스트 메서드 실행 후 공통된 정리 작업(clean up)을 수행하기 위해 사용
  • 테스트 데이터 초기화, 리소스 해제, 연결 종료와 같은 정리 작업을 수행
  • 각 테스트 메서드마다 독립적으로 실행되기 때문에 의존성 제거에 유용
  • 🪄 @BeforeEach각 테스트 메서드 실행 전에 필요한 설정 작업을 수행할 때 사용

13. 회원 서비스 개발

  • 서비스 계층은 리포지토리랑 도메인을 활용해 실제 비지니스 로직을 작성하는 게층

repository

  • save(), findById(), findByName(), findAll()
  • 저장소에 넣고, 빼고, 찾는 기계적인 느낌의 네이밍

service

  • join(), validDuplicatedMember()
  • 비지니스적인 느낌의 네이밍, 메서드명부터 리포지토리와 차이가 있음
@Service
@Transactional
public class MemberService {

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

    // 회원가입 - 동명이인 회원 중복 가입❌
    public Long join(Member member){
        validateDuplicatedMember(member);
        memberRepository.save(member);
        return member.getId();
    }

    // 중복 회원 확인 -> 중복 회원가입시 exception이 터지는지 검증이 필요(테스트 케이스 활용)
    private void validateDuplicatedMember(Member member) {
        memberRepository.findByName(member.getName())
                .ifPresent(m -> {
                    throw new IllegalStateException("이미 존재하는 회원입니다.");
                });
    }

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

    // 단일 회원 조회
    public Optional<Member> findMember(Long memberId){
        return memberRepository.findById(memberId);
    }
}

Optional의 안전한 값 접근 방법

  • ifPresent(Cosumer<? super T> action) :
    값이 존재하면 람다식에서 제공된 동작 수행(내부 코드 수행),
    값이 존재하지 않으면 아무 작업도 수행하지 않음

14. 회원 서비스 테스트

🍀 테스트 케이스는 given - when - then구조로 작성할 것

  • given : 주어진 상황, 조건
  • when : 실행되었을 때
  • then : 기대되는 결과
class MemberServiceTest {

    MemberService memberService;
    MemoryMemberRepository memberRepository;

    // Test에서 사용하는 레포지토리와 서비스의 레포지토리를 일치시키기 위함
    // Test와 서비스에서 각각 new로 레포지토리를 생성할 경우 애매~함(굳이 두 개의 객체를 사용할 이유가 없음)
    @BeforeEach
    void beforeEach(){
        memberRepository = new MemoryMemberRepository();
        memberService = new MemberService(memberRepository);
    }

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

    @Test
    void 회원가입() {
        // given
        Member member = new Member();
        member.setName("zizon의연v");

        // when
        Long savedId = memberService.join(member);

        // then
        // assertEquals(2, savedId);

        // Service의 findMember()로 테스트
        Member findMember1 = memberService.findMember(savedId).get();
        assertThat(member.getName()).isEqualTo(findMember1.getName());

        // Repository의 findById()로 테스트
        Member findMember2 =  memberRepository.findById(savedId).get();

    }

    // 테스트는 정상 플로우보다 예외 플로우 검증이 훨씬 중요
    @Test
    void 중복_회원_예외(){
        // given
        Member member1 = new Member();
        member1.setName("zizon의연v");

        Member member2 = new Member();
        member2.setName("zizon의연v");

        // when
        memberService.join(member1);

        // then
//        try {
//            memberService.join(member2);
//            fail("예외가 발생해야합니다.");
//        } catch (IllegalStateException e){
//            assertThat(ex.getMessage()).isEqualTo("이미 존재하는 회원입니다.");
//        }

        // ♻️ try-catch문 리팩토링
        IllegalStateException e = assertThrows(IllegalStateException.class, () -> memberService.join(member2));
        assertThat(e.getMessage()).isEqualTo("이미 존재하는 회원입니다.");
    }
}

DI, Dependency Injection

  • MemberService의 레포지토리와 MemberServiceTest의 레포지토리가
    일치하도록 하기 위해 DI하도록 코드 작성

📂MemberService.java

@Service
@Transactional
public class MemberService {

    private final MemberRepository memberRepository;

    // DI, Dependency Injection
    public MemberService(MemberRepository memberRepository){
        this.memberRepository = memberRepository;
    }
    
    ...
}

try-catch{}에서 사용된 fail()메서드

  • 테스트 중 예상대로 예외가 발생하지 않았을 때,
    테스트를 강제로 실패 처리하는 테스트용 메서드
  • "여기까지 코드가 도달하면 잘못된 흐름이다"를 명확히 함
  • org.junit.jupiter.api.Assertions.fail() (JUnit 5 기준)

@BeforeEach

  • 각 테스트 실행 전에 호출
  • 테스트가 서로 영향이 없도록 항상 새로운 객체 생성 및 의존 관계를 새로 맺어줌
profile
"개발자는 해결사이자 발견자이다✨" - Michael C. Feathers

0개의 댓글