관리 메뉴

나구리의 개발공부기록

애플리케이션 구현 준비 - 구현 요구사항 및 애플리케이션 아키텍처, 회원 도메인 개발, 회원 리포지토리 개발, 회원 서비스 개발, 회원 기능 테스트, 상품 도메인 개발, 상품 엔터티 개발 - 비즈니스 로직 추가, 상품 리포지토리 개발, 상품 서비스 개발 본문

인프런 - 스프링부트와 JPA실무 로드맵/실전! 스프링 부트와 JPA 활용1 - 웹 애플리케이션 개발

애플리케이션 구현 준비 - 구현 요구사항 및 애플리케이션 아키텍처, 회원 도메인 개발, 회원 리포지토리 개발, 회원 서비스 개발, 회원 기능 테스트, 상품 도메인 개발, 상품 엔터티 개발 - 비즈니스 로직 추가, 상품 리포지토리 개발, 상품 서비스 개발

소소한나구리 2024. 9. 29. 12:43

출처 : 인프런 - 실전! 스프링 부트와 JPA활용1 - 웹 애플리케이션 개발(유료) / 김영한님  
유료 강의이므로 정리에 초점을 두고 코드는 일부만 인용  


1. 애플리케이션 구현 준비 - 구현 요구사항 및 애플리케이션 아키텍처

1) 구현 요구 사항

(1) 회원 기능

  • 가입, 조회

(2) 상품 기능

  • 등록, 수정, 조회

(3) 주문 기능

  • 주문, 취소
  • 주문내역 조회

2) 예제를 단순화를 위한 기능 간소화

  • 로그인과 권한관리 구현 X
  • 파라미터 검증과 예외처리는 단순화
  • 상품은 도서만 사용
  • 카테고리, 배송 정보는사용 X

3) 애플리케이션 아키텍처

(1) 계층형 구조 사용

  • controller, web: 웹 계층
  • service: 비즈니스 로직, 트랜잭션 처리
  • repository: JPA를 직접 사용하는 계층, 엔터티 매니저 사용
  • domain: 엔터티가 모여 있는 계층, 모든 계층에서 사용
  • controller에서 repository에서 간단한 정보는 가져올 수 있도록 단방향으로 실용적인 구조를 구성(서비스에서 단순히 위임하는 로직을 최대한 제거하기 위한 조치)

(2) 패키지 구조

  • jpabook.jpashop
    • domain
    • exception
    • repository
    • service
    • web

(3) 개발 순서

  1. 리포지토리, 서비스계층을 개발
  2. 테스트 케이스를 작성해서 검증
  3. 마지막에 웹 계층 적용

2. 회원 도메인 개발 - 회원 리포지토리 개발

1) 기술 설명

  • @Repository: 스프링 빈으로 등록, JPA 예외를 스프링 기반 예외로 예외 변환
  • @PersistenceContext: 엔터티 매니저(EntityManager)를 주입, 스프링이 자동 주입해주므로 생략 가능
  • @PersistenceUnit: 엔터티 매니저 팩토리를 직접 주입할 때 사용 - 쓸일이 거의 없음
  • JPQL - 객체(엔터티)를 대상으로 하는 쿼리문, SQL과 거의 비슷

3) 기능 설명

  • save(): 멤버 저장
  • findOne(): 단건 조회
  • findAll(): 전체 조회, JPQL사용
  • findByname(): 이름으로 조회: JPQL사용
package jpabook.jpashop.repository;

@Repository
public class MemberRepository {

    private EntityManager em;

    // 저장
    public Long save(Member member) {
        em.persist(member); // 저장
        return member.getId();
    }

    // 하나 조회
    public Member findOne(Long id) {
        return em.find(Member.class, id);
    }

    // 전체 조회
    public List<Member> findAll() {
        // JPQL - 엔터티를 대상으로 쿼리
        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();
    }
}

3. 회원 도메인 개발 - 회원 서비스 개발

1) 기술 설명

  • @Service: 컴포넌트 스캔 대상이되어 자동으로 스프링 빈으로 등록이 됨
  • @Transactional: 트랜잭션 적용(영속성 컨텍스트)
    • readOnly=true: 데이터의 변경이 없는 읽기 전용 메서드에 사용, 영속성 컨텍스트를 플러시 않으므로 약간의 성능 향상(읽기 전용에는 다 적용 됨)
    • 데이터베이스 드라이버가 지원하면 DB에서 성능 향상
  • @Autowired: 생성자 Injection, 생성자가 하나면 생략 가능

** 참고 사항

  • 실무에서는 검증 로직이 있어도 정말 동시에 동일한 이름으로 회원이 가입을 시도할 수 있으므로 멀티 쓰레드 상황을 고려해서 회원 테이블의 회원명 컬럼에 유니크 제약 조건을 추가하는 것이 안전함
package jpabook.jpashop.service;

@Service
// 기본적으로 JPA는 트랜잭션으로 수행됨
@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();              // 반환값은 id만
    }

    // 중복 회원 검증 메서드
    private void validateDuplicateMember(Member member) {
        List<Member> findMembers = memberRepository.findByName(member.getName());
        if (!findMembers.isEmpty()) {
            throw new IllegalStateException("이미 존재하는 회원입니다");
        }
        // 실무에서는 검증 로직이 있어도 멀티 쓰레드 상황을 고려해서 회원 테이블의 회원명 컬럼에 유니크 제약 조건을 추가하는 것이 안전함
    }

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

    public Member findOne(Long id) {
        return memberRepository.findOne(id);
    }
}

2) 필드 주입, 생성자 주입

(1) 생성자 주입방식을 사용 - 롬복을 사용하여 최신 트렌드를 적용

  • 스프링 필드 주입 대신 생성자 주입을 사용하며 세터 주입은 사용 금지
  • 생성자 주입을 사용하면 변경 불가능하고 안전한 객체 생성이 가능함
  • 생성자가 하나일 경우 @Autowired를 생략할 수 있음
  • final 키워드를 추가하면 컴파일 시점에 memberRepository를 설정하지 않는 오류를 체크할 수 있음(보통 기본 생성자를 추가할 때 발견함)
  • 롬복의 @RequiredArgsConstructor를 사용하면 final이 붙은 필드의 생성자를 자동으로 생성해줌
// 필드 주입
@Autowired
MemberRepository memberRepository;

// 세터 주입 - 사용 X, 테스트 사용시 Mock Repository등을 사용할 수 있으나 변경의 위험이 너무 큼
private MemberRepository memberRepoisoty;

@Autowired
public void setMemberRepository)MemberRepository memberRepository){
    this.memberRepository = memberRepository;
}

// 생성자 주입
private MemberRepository memberRepository;

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

// 실제 코드에 적용한 lombok을 적용한 가장 권장하는 방법
@RequiredArgsConstructor // final이 붙어있으는 필드의 생성자를 만들어줌
public class MemberService {
	
    private final MemberRepository memberRepository;

}

 

(2) MemberRepository 코드에도 적용

@Repository
@RequiredArgsConstructor
public class MemberRepository {

    private final EntityManager em;
    
    // 기타 코드 생략
}

3. 회원 기능 테스트

1) MemberServiceTest - junit5사용

  • junit의 Assertions를 검증으로 사용
  • @SpringBootTest: 스프링 부트 띄우고 테스트(이게 없으면 @Autowired가 다 실패함)
  • @Transactional: 반복 가능한 테스트를 지원, 각각의 테스트를 실행할 때마다 트랜잭션을 시작하고 테스트가 끝나면 트랜잭션을 강제로 롤백함(테스트에서 @Transactional을 사용했을 때)
  • 정상적인 회원가입과, 중복 회원가입으로 예외가 발생했을때의 테스트를 검증
  • Given, When, Then 테스트 케이스 작성법
package jpabook.jpashop.service;

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

@SpringBootTest
@Transactional
class MemberServiceTest {

    @Autowired MemberService memberService;

    @Autowired MemberRepository memberRepository;

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

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

        // Then - 검증
        assertEquals(member, memberRepository.findOne(saveId));

    }

    @Test
    public void 중복_회원_예외() throws Exception {
        // Given
        Member member1 = new Member();
        Member member2 = new Member();
        member1.setName("lee");
        member2.setName("lee");

        // When
        memberService.join(member1);

        // Then
        // member2를 join하려고 할 때 IllegalStateException이 발생해야 함
        assertThrows(IllegalStateException.class,
                () -> memberService.join(member2));
    }
}

2) 테스트 설정 - 임베디드 DB모드

(1) 테스트 격리

  • 테스트는 격리된 환경에서 실행하고 끝나면 데이터를 초기화하는 것이 좋기 때문에 메모리 DB를 사용하는 것이 가장 이상적임
  • 그리고 테스트 케이스를 위한 스프링 환경과 애플리케이션을 실행하는 환경은 보고싶은 로그레벨 등의 설정이 다를 수 있으므로 설정파일을 다르게 적용하는 것이 맞음
  • 스프링 부트는 datasource 설정이 없으면 기본적으로 메모리 DB를 사용하고 driver-class도 현재 등록된 라이브러리를 보고 찾아주며 ddl-auto 설정도 create-drop(테이블 생성후 테스트 종료되면 삭제하도록 동작하는 방식) 모드로 적용하여 동작하기 때문에 데이터소스, JPA 관련 설정등을 하지 않아도 잘 동작함

(2) 적용 방법

  • ~test/java 경로에 resources 디렉토리를 생성
  • 기존에 작성했던 application.yml을 복사하고 db관련 내용을 전부 주석처리하고 테스트를 실행하면 테스트가 메모리모드로 정상적으로 동작함(H2 데이터베이스를 실행할 필요없이 테스트가 동작)
  • 만일 해당 위치에 application 설정 파일이 없다면 src/main/resources/위치의 설정파일을 읽음
  • https://nagul2.tistory.com/314
spring: # 띄어쓰기 없음
#  datasource: # 띄어쓰기 2칸
#    url: jdbc:h2:tcp://localhost/~/jpashop # 띄어쓰기 4칸
#    username: sa
#    password:
#    driver-class-name: org.h2.Driver
#
#  jpa: # 띄어쓰기 2칸
#    hibernate: # 띄어쓰기 4칸
#      ddl-auto: create # 띄어쓰기 6칸
#    properties:
#      hibernate:
##        show_sql: true  # 띄어쓰기 8칸, System.out으로 출력 -> 실무에서는 사용안함
#        format_sql: true  # Log로 출력

logging: # 띄어쓰기 없음
  level: # 띄어쓰기 2칸
    org.hibernate.SQL: debug # 띄어쓰기 4칸 -> JPA 쿼리 출력
    org.hibernate.orm.jdbc.bind: trace # SQL에서 파라미터값이 ?로 나오는데 그것을 실제 값으로 출력

4. 상품 엔터티 개발 - 비즈니스 로직 추가

  • 도메인 주도 설계를 할때 엔터티 자체에서 해결할 수 있는 것들은 엔터티 안에 비즈니스 로직을 넣는 것이 좋으며 객체 지향적인 설계이며 응집도가 있는 설계를 할 수 있음 - information expert pattern을 지키면서 개발
  • 도메인 주도 설계를 하게되면 많은 비즈니스 로직이 엔터티로 이동하게 되어 엔터티를 객체로 사용하는 것이고 서비스에 비즈니스 로직이 모두 있다면 엔터티는 자료구조로 사용하는 방식이며 둘 방식에는 장단점이 존재하기에 상황에 맞는 적절한 방법을 택해야함
  • 값을 변경해야 할 일이 있다면 지금 예제에서는 편의를 위해 setter를 사용하고 있지만 핵심 비즈니스 메서드를 가지고 값을 변경하는 것이 좋음
  • 세터를 사용해 바깥에서 계산해서 입력하는 것이 아니라 엔터티 안에서 변경을 위한 메서드가 있는것이 가장 객체지향 적이라고 볼 수 있음
  • 클린코드 | 로버트 C. 마틴 도서 - 6. 객체와 자료구조 추천

1) Item - 비즈니스 로직 추가

  • addStock() : 파라미터로 넘어온 수만큼 재고를 늘리는 메서드, 재고가 증가하거나 상품 주문을 취소해서 재고를 다시 늘려야할 때 사용
  • removeStock() : 파라미터로 넘어온 수만큼 재고를 줄이는 메서, 재고가 부족하면 예외를 발생시며 주로 상품을 주문할 때 사용함
  • NotEnoughStockException() : 직접 정의한 RuntimeException
// 애노테이션 생략
public abstract class Item {
    
    // 기존 코드 생략
   
    /* 비즈니스 로직 */
    // 재고 증가
    public void addStock(int quantity) {    
        this.stockQuantity += quantity;
    }
    
    // 재고 감소
    public void removeStock(int stockQuantity) {
        int restStock = this.stockQuantity - stockQuantity;
        if (restStock < 0) {
            throw new NotEnoughStockException("need more stock");
        }
        this.stockQuantity = restStock;
    }
}

2) NotEnoughStockException - 예외 추가

package jpabook.jpashop.exception;

public class NotEnoughStockException extends RuntimeException {
    public NotEnoughStockException() {
        super();
    }

    public NotEnoughStockException(String message) {
        super(message);
    }

    public NotEnoughStockException(String message, Throwable cause) {
        super(message, cause);
    }

    public NotEnoughStockException(Throwable cause) {
        super(cause);
    }
}

5. 상품 리포지토리 개발

1) ItemRepository 

  • save() id가 없으면 신규로 간주하여 persist()로 저장하고 id가 있다면 merge()로 저장
  • merge()에 대한 자세한 내용은 뒤에서 설명, 지금은 데이터베이스에 저장된 엔터티를 가져와서 수정하는 update와 비슷하게 동작한다는 정도로만 이해
  • 단건 조회하는 findOne()와 전체를 조회하는 findAll()를 생성
package jpabook.jpashop.repository;

@Repository
@RequiredArgsConstructor
public class ItemRepository {

    private final EntityManager em;

    public void save(Item item) {

        if (item.getId() == null) {
            em.persist(item);   // id값이 없으면 저장
        } else {
            em.merge(item);     // update와 비슷하며 DB에 저장된 엔터티를 수정한다고 이해하면됨 -> 자세한 내용은 이후에 설명
        }
    }
    
    public Item findOne(Long id) {
        return em.find(Item.class, id);
    }
    
    public List<Item> findAll() {
        return em.createQuery("select i from Item i", Item.class).getResultList();
    }
}

6. 상품 서비스 개발

1) ItemService

  • 지금의 상품 서비스는 상품 리포지토리에 단순히 위임하는 로직들로 구성되어 있음
  • 경우에 따라서 이렇게 정말 위임만 하는 거에 대해서 만들어야할 필요성이 있는지에 대해 고민해볼 필요성이 있으며 이러한 경우에는 컨트롤러에서 아이템 리포지토리에 바로 접근해서 써도 큰 문제가 없다고 볼 수 있음 (김영한님 생각)
  • 클래스 레벨의 트랜잭션에는 readOnly를 적용하고, 상품을 저장하는 saveItem() 에만 쓰기도 가능한 트랜잭션을 적용
package jpabook.jpashop.service;

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

    private final ItemRepository itemRepository;

    @Transactional
    public void saveItem(Item item) {
        itemRepository.save(item);
    }
    
    public List<Item> findItems() {
        return itemRepository.findAll();
    }
    
    public Item findOne(Long id) {
        return itemRepository.findOne(id);
    }
}

 

** 상품 기능 테스트는 회원 테스트와 로직이 비슷하므로 생략