Back-end/Spring & JPA

[Spring Boot] 스프링 입문 강의 정리 & 공부 [1]

Uykm 2023. 8. 5. 18:06

▼ What ?

이번엔 회원 관리 예제를 만들어보면서 백엔드 개발 과정을 대략적으로 살펴보고 공부해보려고 한다


 비즈니스 요구사항 정리

 

  • 데이터 : 회원ID, 이름

  • 기능 : 회원 등록, 조회

  • 가상의 시나리오 아직 데이터 저장소(DB)가 선정되지 않았다
    ex) 성능이 중요한 DB로 할지? 일반적인 RDB로 할지? 아니면 NoSQL?

 


 

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

 

  • 컨트롤러 : 웹 MVC의 컨트롤러 역할

  • 서비스 : 비즈니스 도메인 객체를 가지고 핵심 비즈니스 로직를 구현한 객체
    ex) 회원은 중복 가입 불가

  • 리포지토리(Repository) : 데이터베이스에 접근, 도메인 객체를 DB에 저장하고 관리

  • 도메인(Domain) : 비즈니스 도메인 객체
    ex) 회원, 주문, 쿠폰 등등 주로 데이터베이스에 저정하고 관리된

 


 

클래스 의존관계

 

  • 아직 데이터 저장소가 선정되지 않아서, 우선 인터페이스로 구현 클래스를 변경할 수 있도록 설계

  • 데이터 저장소는 RDB, NoSQL 등등 다양한 저장소를 고민중인 상황으로 가성

  • 개발을 진행하기 위해서 초기 개발 단계에서는 구현체로 가벼운 메모리 기반의 데이터 저장소 사용

 


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

 

1. 회원 객체

  • getter & setter 단축키 활용하기
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;
    }
}

 

2. 회원 리포지토리 인터페이스

  • 반환할 객체를 Optional 객체로 감싸서 반환하는 이유?

    ➜ 반환할 값이 null 인지 확인하기 위해서 !
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();
}

 

+ Optional class (참고) [JAVA] 람다 (Lamda) & 스트림 (Stream) — Uykm_Note (tistory.com)

 

[JAVA] 람다 (Lamda) & 스트림 (Stream)

🌑 람다식 (Lamda expression) ✔️ 람다식 ? 🔹 람다식(Lamda expression) : 메서드를 하나의 '식(expression)'으로 표현한 것 class, 메서드, 객체 필요 X ➜ 람다식 자체만으로 메서드의 역할을 수행 ➜ 즉, 메

ukym-tistory.tistory.com

 

3. 회원 리포지토리 메모리 구현체

  • 인터페이스 import 단축키

 

  • 사실 실무에선 동시성의 문제가 있어서 공유되는 변수일 때는 HashMap 대신 ConcurrentHashMap을, Long 대신 AtomicLong을 사용해야 한다

  • 실무에선 루프를 돌리기 편한 List를 많이 쓴다
package hello.hellospring.repository;

import hello.hellospring.domain.Member;

import java.util.*;

public class MemoryMemberRepository implements MemberRepository {

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

    @Override
    public Member save(Member member) {
        member.setId(++sequence);
        store.put(member.getId(), member); // store에 저장
        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)) // 파라미터로 넘어온 name과 같은 것만 필터링 
                .findAny(); // Map('store')에서 하나 찾아지면 바로 반환,
                            // 없으면 비어진 Optional 객체 반환
    }

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

+ 람다식 (참고) [JAVA] 람다 (Lamda) & 스트림 (Stream) — Uykm_Note (tistory.com)

 

[JAVA] 람다 (Lamda) & 스트림 (Stream)

🌑 람다식 (Lamda expression) ✔️ 람다식 ? 🔹 람다식(Lamda expression) : 메서드를 하나의 '식(expression)'으로 표현한 것 class, 메서드, 객체 필요 X ➜ 람다식 자체만으로 메서드의 역할을 수행 ➜ 즉, 메

ukym-tistory.tistory.com

 

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

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

 

회원 리포지토리 메모리 구현체 테스트

 

  • src/test/java 하위 폴더(hello.hellospring)에 테스트 패키지 생성

  • 테스트해야 할 메서드
    ➜ 'save()', 'findByName()', 'findAll()'

  • 테스트가 성공했는지를 'System.out.println("result" = " + (result == member));' 처럼 매번 문자로 확인할 수는 없기 때문에 사용하는 기능이 'Assertions.assertEquals(membe r, result);'이다
    ➜ 요즘엔 'Assertions.assertThat(member).isEqualTo(result)'을 더 많이 쓴다
    ( ' ⌥ + Enter (Mac) ' or ' Alt + Enter (Win/Linux) ' 로 Assertions를 static으로 import 해주면 'assertThat()'를 단독으로 사용 가능 )
package hello.hellospring.repository;

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

import java.util.List;

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

class MemoryMemberRepositoryTest {

    MemoryMemberRepository repository = new MemoryMemberRepository();

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

        repository.save(member);
        
        // get() : Optional에서 값을 바로 꺼내는 용도로 사용하지만, 테스트 코드 말고는 사용 권장 X
        Member result = repository.findById(member.getId()).get();
        
        // System.out.println("result" = " + (result == member));
        // Assertions.assertEquals(member, result);  // OK
        // Assertions.assertEquals(member, null);    // Error
        
        // Assertions.assertThat(member).isEqualTo(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(result).isEqualTo(member2); // Error
        assertThat(result).isEqualTo(member1);
    }

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

}

 

  • 테스트를 메서드 하나씩 해볼 수도 있고,
    클래스 레벨에 있는 메서드들을 동시에 할 수도 있고,
    패키지를 실행시켜서 전체 클래스들을 동시에 실행시킬 수도 있다

메서드
클래스
전체 클래스들

 

  • 모든 테스트는 아래처럼 실행 순서가 보장되지 않기 때문에, 순서에 의존적으로 테스트 케이스를 설계하면 안된다 !

 

  • 테스트가 끝날 때마다 메모리 DB에서 해당 테스트에 대한 데이터를 지워줘야 한다(clear) !
    ➜ 테스트를 해볼 클래스(리포지토리)에  'store.clear()'를 실행할 메서드('clearStore()')를 하나 생성해줘야 한다
    ' @AfterEach ' : 각 테스트가 종료될 때마다 이 기능을 실행
    : 한번에 여러 테스트를 실행하면 메모리 DB에 직전 테스트의 결과가 남을 수 있다.
    이렇게 되면 다음 이전 테스트 때문에 다음 테스트가 실패할 가능성이 있다.
    여기서는 '@AfterEach'가 붙은 메서드는 메모리 DB에 저장된 데이터를 삭제하는 기능을 한다.
public class MemoryMemberRepository implements MemberRepository {

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

        ...
        
    // 각 테스트가 종료될 때마다 이 기능을 실행
    @AfterEach
    public void afterEach() {
        repository.clearStore();
    }
    
        ...
        
}

 

  • 테스트 주도 개발 (TDD)
    ➜ 이 예제의 방식과 반대로 테스트를 먼저 만들고 구현 클래스를 만드는 방식을 'TDD'라고 한다 !

 

5. 회원 서비스 개발

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

  • 회원 서비스(MemberService)를 개발하기 위해선 회원 리포지토리(MemberRepository)가 필요 !
    private final MemberRepository memberRepository = new MemoryMemberRepository();

  • 앞서 미리 설정한 비즈니스 로직
    ➜ 같은 이름의 중복 회원은 허용 X

  • 'Member member1 = result.get();'
    ➜ 'get()'으로 바로 꺼내도 되지만, 그냥 바로 값을 꺼내고 싶은 경우 말고는 권장 X

    'Member member1 = result.orElseGet();'
    'orElseGet()'
    : 값이 없을 경우에 어떤 메서드를 실행하거나 default값을 넣어서 꺼내도록 할 수 있다 ( 자주 사용 )

  • 객체에 저장된 값을 가져올 때엔 Null이 아닌지 확인하고 가져오자 !
    ➜ 'memberRepository.findByName(member.getName());'

    변수를 추출하는 단축키

    ➜ Optional<member> result = memberRepository.findByName(member.getName());
    [ ⌥ + ⌘ + V (Mac), Ctrl + Alt + V (Win/Linux) ]
    ( Optional로 객체를 감싸면, 'ifPresent()' 같은 메서드들 사용 가능 )

  • 아래 코드를 메서드('validateDuplicateMember')로 따로 빼주자
    Extract Method  [ Ctrl + T (Max), Ctrl + Alt + Shift + T (Win/Linux) ]
Optional<member> result = memberRepository.findByName(member.getName());
result.ifPresent(m -> {
    throw new IllegalStateException("이미 존재하는 회원입니다.");
});
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 = new MemoryMemberRepository();

    /**
    * 회원 가입
    */
    public Long join(Member member) {
        // 같은 이름이 있는 중복 회원 X
        // Extract Method
        validateDuplicateMember(member);

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

    private void validateDuplicateMember(Member member) {
        memberRepository.findByName(member.getName())
                .ifPresent(m -> {
                    throw new IllegalStateException("이미 존재하는 회원입니다.");
                });
    }
    
    // Service는 비즈니스를 처리하는 역할이기 때문에 비즈니스에 의존적이고, 따라서 비즈니스적인 네이밍을 하자
    // Repository는 기계적으로 네이밍을 한다
    /**
     * 전체 회원 조회
     **/
    public List<Member> findMembers() {
        return memberRepository.findAll();
    }
    
    public Optional<Member> findOne(Long memberId) {
        return memberRepository.findById(memberId);
    }
}

 

  • Service는 비즈니스 로직을 구현하는 역할이기 때문에 비즈니스에 의존적이고, 따라서 비즈니스적인 네이밍을 하는 편

  • Repository는 'memberRepository' 같이 기계적으로 네이밍을 하는 편

  • 이렇게 구현한 Sevice도 Repository처럼 제대로 작동하는지 테스트해봐야 한다 !

 


 

회원 서비스 테스트

  • 테스트 클래스 생성 단축키
    [ Shift + ⌘ + T (Mac), Ctrl + Shift + T (Win/Linux) ]

package hello.hellospring.service;

import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.*;

class MemberServiceTest {

    @Test
    void join() {
    }

    @Test
    void findMembers() {
    }

    @Test
    void findOne() {
    }
}

 

  • given(어떤 상황이 주어졌는데) - when(이것을 실행했을 때) - then(이러한 결과가 나와야 한다) 구조로 테스트 메서드를 작성해보자 !
package hello.hellospring.service;

import hello.hellospring.domain.Member;
import hello.hellospring.repository.MemberRepository;
import hello.hellospring.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 static org.assertj.core.api.Assertions.*;
import static org.junit.jupiter.api.Assertions.*;

class MemberServiceTest {

    MemberService memberService = new MemberService();

    @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

        //when

        //then
    }

    @Test
    void findMembers() {
    }

    @Test
    void findOne() {
    }
}

 

  • 중복 회원이 가입되었을 때('memberService.join(member2);'), 'validateDuplicateMember()'에 걸려서 예외('IllegalStateException')가 발생했는지 테스트('중복_회원_예외()')해야 한다
    ➜ 'try - catch' 문으로 예외를 잡아도 되지만, 이것 때문에 'try -catch' 문을 추가하는 것이 애매하다..
    'assertThrows()'로 예외를 잡을 수 있다

    예외 메시지는 어떻게 검증해야 할까?
    ➜ 'assertThat()', 'getMessage()', 'isEqualTo()'을 활용하자
public class MemberService {

        ...
        
    private void validateDuplicateMember(Member member) {
        memberRepository.findByName(member.getName())
                .ifPresent(m -> {
                    throw new IllegalStateException("이미 존재하는 회원입니다.");
                });
    }
    
        ...
}
@Test
public void 중복_회원_예외() {
    //given
    Member member1 = new Member();
    member1.setName("spring");

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

    //when
    memberService.join(member1);
    // memberService.join(member2); // 중복 회원 가입
    
    // '() -> memberService.join(member2)'가 수행되면 IllegalStateException가 발생해야 한다
    IllegalStateException e = assertThrows(IllegalStateException.class, () -> memberService.join(member2));

    assertThat(e.getMessage()).isEqualTo("이미 존재하는 회원입니다.");

    /*
    try {
        memberService.join(member2);
        fail();
    } catch (IllegalStateException e) {
        assertThat(e.getMessage()).isEqualTo("이미 존재하는 회원입니다.");
    }
    */

    //then
    
}

 

  • 이번 테스트도 테스트가 끝날 때마다 메모리 DB에서 해당 테스트에 대한 데이터를 지워주자
    ➜ @AfterEach
class MemberServiceTest {

    // 또다른 인스턴스가 생성된다
    MemberService memberService = new MemberService();
    MemoryMemberRepository memberRepository = new MemoryMemberRepository();

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

 

  • 기존에는 회원 서비스가 메모리 회원 리포지토리를 직접 생성하게 했다.
public class MemberService {

    private final MemberRepository memberRepository = new MemoryMemberRepository();
    
}

 

  • 하지만, 위의 코드처럼 작성한다면 테스트할 때 또다른 인스턴스가 더 생성되는 문제가 있다 !
    ➜ 같은 리포지토리가 아니라 다른 리포지토리를 이용해서 테스트하게 된다

  • 회원 리포지토리의 코드가 회원 서비스 코드 "의존관계 주입(DI)" 가능하게 변경해보자
    테스트할 때도 같은 인스턴스를 사용하게 하려면, 객체를 생성하는 것이 아니라 아래처럼 외부에서 인스턴스를 받아오도록 바꿔준다
public class MemberService {

    // private final MemberRepository memberRepository = new MemoryMemberRepository();
    private final MemberRepository memberRepository;

    // 외부에서 인스턴스를 받아온다
    public MemberService(MemberRepository memberRepository) {
        this.memberRepository = memberRepository;
    }
    
        ...
}
class MemberServiceTest {
/* 같은 인스턴스, 즉 같은 리포지토리를 이용하여 테스트하는 것이 아닌 다른 리포지토리를 이용하여 테스트하게 된다
    MemberService memberService = new MemberService();
    MemoryMemberRepository memberRepository = new MemoryMemberRepository();
*/

    MemberService memberService;
    MemoryMemberRepository memberRepository;

    @BeforeEach
    public void beforEach() {
        memberRepository = new MemoryMemberRepository();
        memberService = new MemberService(memberRepository); // 의존관계 주입 (DI)
    }
    
    ...
    
}