실전! 스프링 부트와 JPA 활용1 - 웹 애플리케이션 개발 : 회원, 상품 Service, Repository 설계

jkky98·2024년 9월 20일
0

Spring

목록 보기
44/77

애플리케이션 아키텍처

위와 같은 아키텍처로 요청에 대한 응답 흐름을 가져갈 것이다. Domain은 이미 JPA 엔티티로 하여금 DB 테이블까지 거의 완성시켰지만 추후 도메인에도 비즈니스 로직 메서드가 추가될 것이다.

순서는 각 도메인들에 대해서 Service, Repository를 우선 구현하고 핵심 기능들에 대한 test를 작성한 뒤 Controller를 설계할 예정이다.

예제를 단순화 하기 위해 로그인과 권한 관리, 파라미터 검증 및 예외 처리 등은 빠지는 실습이지만 추후 이들을 모두 집어넣어 프로젝트를 완료할 예정이다.

Repository 구현


@Repository
@RequiredArgsConstructor
public class MemberRepository {

    private final EntityManager em;

    public void save(Member member) {
        em.persist(member);
    }

    public Member findOne(Long id) {
        return em.find(Member.class, id);
    }

    public List<Member> findAll() {
        return em.createQuery("select m from Member m", Member.class)
                .getResultList();
    }

    public List<Member> findByName(String name) {
        return em.createQuery("select m from Member m where m.name = :name", Member.class)
                .setParameter("name", name)
                .getResultList();
    }
}

EntityManager의 경우 @PersistenceContext를 통해 필드 주입의 방식도 존재하지만 생성자 주입 방식으로 하여금 @RequiredArgsConstructor를 활용해서 코드도 간결하게 유지하면서 더 우수한 확장성을 유지한다.

EntityManager에 제공하는 persist와 find 메서드를 활용하여 저장과 단건조회 메서드를 만들고 JPQLem.createQuery를 활용해서 전체조회와 이름조회 기능을 만들었다.

em.find의 경우 Primary Key로만 조회가 가능하기 때문에 Name을 em.find에서 활용할 수 없다. 그렇기에 em.createQuery + JPQL 조합을 사용한다.

Service 구현


@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class MemberService {

    private final MemberRepository memberRepository;

    //회원 가입
    @Transactional
    public Long join(Member member) {
        validateDuplicateMember(member); // 중복 회원 검증(비즈니스 로직)
        memberRepository.save(member);
        return member.getId();
    }

    private void validateDuplicateMember(Member member) {
        //Exception
        List<Member> findMembers = memberRepository.findByName(member.getName());
        if (!findMembers.isEmpty()) {
            throw new IllegalStateException("이미 존재하는 회원입니다.");
        }
    }

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

    //특정 회원 조회
    public Member findOne(Long memberId) {
        return memberRepository.findOne(memberId);
    }
}

Service 에서 트랜잭션 전파가 일어나는 것이 일반적이다. 만약 Repository의 기능을 직접 가져다 써야하면서 트랜잭션이 필요하다면 Repository를 호출하는 메서드 수준에서 트랜잭션을 걸거나 어차피 전파로 하여금 같은 트랜잭션으로 존재할 수 있기에 Repository의 기능 메서드들이 트랜잭션을 걸어도 된다.

validateDuplicateMember()메서드로 하여금 중복 회원을 검증하는 비즈니스 로직 메서드를 구성해주었다. 이러한 비즈니스 로직은 테스트에서 우선적으로 검증하도록 한다.

굳이 따지자면 현재 개발한 중복 회원 검증은..

현재 validateDuplicateMember() 메서드는 중복 회원 검증을 100%로 수행할 수 없다. 트랜잭션은 하나의 뭉텅이 작업을 보장해주는 개념이지 동시성을 제어하지는 않는다. 만약 스레드 2개에 의해 join()이 동시에 두 개가 실행되었고 그 join()에 해당하는 Member가 중복이라면 두 스레드 모두 Member에 대한 중복 문제에 대해서 "문제없음"으로 종결될 수 있다. 이를 해결하기 위해 validateDuplicateMember에 Lock을 걸어서 동기화 문제를 해결할 수도 있을 것이다. DB차원에서도 해결이 가능하며 여러 방법이 있으니 가장 효율적인 방식을 선택하자.

Service 클래스 수준에서 Transaction을 readOnly로 걸었다. 조회 기능이 2개이고 수정 기능이 1개 이므로 전체적으론 readOnly를 걸고 수정 기능에 해당하는 join()에 대해서만 일반 트랜잭션을 걸어 해결한다.

테스트

총 3가지 테스트로 진행한다.

  • 회원가입 성공 시나리오
  • 회원가입 중복 예외 시나리오
  • 전체조회 성공 시나리오

@SpringBootTest
@Transactional
class MemberServiceTest {

    @Autowired MemberService memberService;
    @Autowired MemberRepository memberRepository;

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

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

        // then
        assertThat(member).isEqualTo(memberRepository.findOne(saveId));
        // Q -> 왜 리포지토리를 써서 검증할까?
    }

    @Test
    void 중복_회원_예외() {
    	// given
        Member member1 = new Member();
        member1.setName("kim");

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

        // when
        Long member1Id = memberService.join(member1);

        // then
        Assertions.assertThatThrownBy(() -> memberService.join(member2))
                .isInstanceOf(IllegalStateException.class);
    }

    @Test
    void 전체_회원_조회() {
    	// given
        Member m1 = new Member();
        Member m2 = new Member();
        Member m3 = new Member();

        m1.setName("geng");
        m2.setName("t1");
        m3.setName("dk");

        memberService.join(m1);
        memberService.join(m2);
        memberService.join(m3);
        // when
        List<Member> findMemberAll = memberRepository.findAll();
        // then
        assertThat(findMemberAll.size()).isEqualTo(3);
    }

}

테스트 클래스에 트랜잭션을 걸어 테스트용 트랜잭션을 명시하자. 테스트 환경에서의 트랜잭션은 로직 성공시 커밋이 아닌 롤백을 한다. 그러므로 데이터베이스를 깔끔하게 유지할 수 있다. 또한 test용 설정 파일을 새로 구성한다.

// test/resources/application.yml
spring:
logging.level:
  org.hibernate.SQL: debug

테스트에서는 기본적으로 test/resources/application.yml이 위치의 설정파일을 읽으며 만약 없다면 src/resources/application.yml 위치의 설정 파일을 읽는다.

스프링 부트는 datasource 설정이 없으면, 기본적을 메모리 DB(H2->가능, 불가능한 DB들이 존재함)를 사용하고, driver-class도 현재 등록된 라이브러리를 보고 찾아준다. 추가로 ddl-autocreate-drop 모드로 동작한다. 따라서 데이터소스나, JPA 관련된 별도의 추가 설정을 하지 않아도 된다.

회원가입 부분에서 Service를 써써 member를 저장했을 때 같은 트랜잭션에서의 같은 영속성 컨텍스트의 존재성을 인지하고 isEqualTo를 써서 검증했다.

그런데 왜

assertThat(member).isEqualTo(memberService.findOne(saveId));
가 아닌
assertThat(member).isEqualTo(memberRepository.findOne(saveId)); Repository로 검증을 시도하는 것일까?

리포지토리와 서비스의 역할을 분리하는 것이다. 서비스는 비즈니스 로직을 처리하는 데 초점을 맞춘다. 그리고 우리가 검증하고 있는 것은 서비스 영역이다. 그러므로 리포지토리라는 데이터에 바로 접근하는 기능으로 이를 검증하는 것이다. 각 컴포넌트의 책임을 분리함으로써, 데이터가 실제로 올바르게 저장되는지에 대한 검증을 리포지토리에서 수행하는 것이 더 명확한 것이다.

상품 Entity 비즈니스 로직

Item은 재고 수의 의미에 해당하는 stockQuantity필드를 가진다. addStock(량 1 증가), removeStock(량 1 감소) 기능을 만들고자 한다. 이는 간단한 엔티티 필드의 단위기능급에 해당한다. 서비스에서 구현하는 것보다 더 응집력있고 객체지향적으로 만들기 위해서는 엔티티 클래스에 직접 개발할 수 있을 것이다.

@Setter를 남발하지 않고 이렇게 필요한 단위 기능들을 엔티티에서 만들어 활용하는 편이 좋다. setter 자체는 모든 필드에 대해 수정 권한을 갖게 되지만 직접 엔티티에 단위기능을 작성하면 setter보다 더 기능 범위가 한정되면서도 메서드명을 가져 더욱 직관적으로 다가오기 때문이다.

// Item 추상 클래스에 작성
//== 비즈니스 로직 ==//
    // Setter를 사용하지 않고 이렇게!
    /**
     *
     * stock 증가
     */
    public void addStock(int quantity) {
        this.stockQuantity += quantity;
    }


    /**
     *
     * stock 감소
     */
    public void removeStock(int quantity) {
        int restStock = this.stockQuantity - quantity;
        if (restStock < 0) {
            throw new NotEnoughStockException("need more stock");
        }
        this.stockQuantity = restStock;
    }

이어 상품 저장, 단건 조회, 전체 조회 기능을 작성한다. 이는 이전 MemberRepository, MemberService와 굉장히 유사한 코드가 되니 생략.

profile
자바집사의 거북이 수련법

0개의 댓글