도메인 분석 설계, 엔티티 구현
요구사항 분석
구현해야 하는 기능의 목록은 이와 같다
도메인 모델, 엔티티 설계
요구 사항에 맞추어 위 그림처럼 엔티티와 테이블의 관계를 설계해준다. 자세한 내용은 그림을 봐도 알 수 있기 때문에 생략했다.
설계와 분석 과정에서 짚고 넘어갈 것들
테이블에서 주문 테이블이 ORDER가 아니라 ORDERS인 이유는 SQL의 예약어에 ORDER BY가 있기 때문에 관례상 ORDERS를 많이 사용한다.
일반적으로 실무에서는 다대다 매핑은 사용하지 않는다. 다대다는 관계테이블을 만들고 일대다, 다대일 관계로 만들어 사용하자.
→ @ManyToMany를 사용하면 JPA가 자동으로 중간 테이블을 만들어주긴 하지만, 이 테이블에는 매핑에만 필요한 최소한의 정보만 있을 뿐, 추가적인 로직에 필요한 정보(만들어진 시간 등)은 없기 때문에 실무에서 절대 사용해서는 안된다.
외래키가 있는 곳을 연관관계의 주인으로 정하자
→ 실제 DB에서 외래키를 관리하는 책임이 그 테이블에 있기 때문이다. 그래서 외래키가 있는 엔티티가 연관관계의 주인이 되어야 DB와 객체 간의 상태가 일관되게 유지된다.
이것들의 자세한 내용은 JPA 기초편으로 넘어가서 알아보자
엔티티 클래스 개발
모든 구현 코드를 올리기엔 양이 많으니, 짚고 넘어갈 것들을 부분적으로 올려보았다.
@Entity
@Table(name = "orders")
@Getter @Setter
public class Order {}
엔티티는 타입이 있지만 테이블에는 없다. 그래서 관례상 테이블은 [테이블명+id]로 많이 사용한다.
@Entity
@Getter @Setter
public class Category {
...
@ManyToMany
@JoinTable(name = "category_item",
joinColumns = @JoinColumn(name = "category_id"),
inverseJoinColumns = @JoinColumn(name = "item_id"))
private List<Item> items = new ArrayList<>();
}
실무에서는 @ManyToMany 절대 사용하지 말자 - 중간테이블을 수정할 수 없음 + 운영/유지보수의 어려움. 하지만 이 예제에서는 다양한 연관관계의 표현을 위해 사용
@Embeddable
@Getter
@AllArgsConstructor
public class Address {
private String city;
private String street;
private String zipcode;
protected Address() {}
}
값 타입 변경은 불가능하게 하자. 기본생성자를 public 또는 protected(권장)로 설정. jpa가 객체를 생성할 때 리플렉션 등을 사용하기 위해 이렇게 설정하도록 jpa 스펙에 권장되어 있다.
엔티티 설계 시 주의점
@Setter는 가급적 사용하지 않는다.
→엔티티를 조회할 일은 많고, 조회를 한다고 해서 문제가 생기는 일은 잘 없지만, @Setter로 인해 값이 바뀌어버리는 경우를 피하기 위해 취하는 조치이다.
@Entity
@Table(name = "orders")
@Getter @Setter
public class Order {
@Id @GeneratedValue
@Column(name = "order_id")
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id")
private Member member;
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL)
private List<OrderItem> orderItems = new ArrayList<>();
@OneToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL)
@JoinColumn(name = "delivery_id")
private Delivery delivery;
}
모든 @XToOne은 반드시 지연로딩으로 설정. 즉시로딩은 절대절대 쓰지 않는다.
→ (n+1 문제와 연관) @XToOne은 기본이 즉시로딩이어서 반드시 지연로딩으로 바꿔줘야 한다. @XToManny는 기본이 지연로딩.
컬렉션은 필드에서 바로 초기화한다. (orderItems가 예시)
→ null 문제에서 안전한 것도 있지만, hibernate가 엔티티를 영속화 할 때 해당 컬렉션은 hibernate의 내부 컬렉션으로 감싼다. 그래서 그 후에 컬렉션을 잘못 생성하면 hibernate가 정상적으로 작동하지 않을 수 있다.
테이블, 컬럼명 생성 전략
기본 설정은 SpringPhysicalNamingStrategy로 되어있다.
이름 생성 전략을 바꾸려면 두 가지를 수정하면 된다.
논리명 생성: 명시적으로 쓰지 않으면 논리명 사용
물리명 적용: 모든 논리명과 실제 테이블에 적용. 원하는대로 전략을 수정할 수 있다.
양방향 연관관계에서는 연관관계 편의 메서드를 만들어서 한 쪽 엔티티에 set을 할 때 반대쪽 엔티티에도 한 번에 할 수 있도록 만들면 편하다.
+ 애플리케이션 구현 준비
요구사항을 따라가며 아래에 맞추어 구현한다.
도메인 개발
강의 보면서 잘 몰랐던 개념
WAS (Web Application Server)
WAS는 db조회나 로직을 처리하는 동적 컨텐츠를 제공하기 위한 Application Server이다. 일종의 미들웨어라고 보면 편할 것 같다. 주로 분산 트랜잭션, 보안, 메시징, 쓰레드 처리 등의 기능을 처리하는 분산 환경에서 사용된다. 흔히 서버라고 생각하는 Web Server는 정적인 컨텐츠(html, css, js 등)을 제공하는 서버인 것이다.
Annotation
사전적 의미로는 주석을 뜻한다. 자바에서 어노테이션은 코드가 특별한 의미, 기능을 수행하도록 한다. 즉 프로그램에 추가적인 정보를 제공하는 메타데이터(데이터를 위한 데이터)라고 볼 수 있다.
어노테이션의 주된 기능은 아래와 같다고 한다.
1. 컴파일러에게 코드 작성 문법 에러를 체크하도록 정보를 제공
2. 소프트웨어 개발툴이 빌드나 배치시 코드를 자동으로 생성할 수 있도록 정보 제공
3. 실행시(런타임시)특정 기능을 실행하도록 정보를 제공
Transactional
트랜잭션이라는 것은 데이터 작업을 하나의 묶음으로 처리하는 단위이다. 모든 작업이 성공해야 커밋, 하나라도 실패하면 롤백을 한다. 그래서 save() 같은 작업을 할 때 특히 @Transactional 어노테이션이 붙는 것 같다. 조회는 db에 큰 영향을 끼치지 않지만, 저장을 하는 메서드는 그 과정에서 문제가 생겼는데 그대로 커밋이 되어버리면 문제가 생기기 때문이 아닐까. 그러니 한 단위로 진행해야한다는 의미에서 트랜잭셔널 어노테이션이 붙는 것 같다.
영속성 컨텍스트
영속성 컨텍스트는 JPA에서 엔티티를 관리하는 일종의 캐시를 의미한다. 엔티티가 영속 상태에 들어오면 JPA는 영속성 컨텍스트에서 관리하게 되고, 동일 트랜잭션에서 그 엔티티를 다시 조회하면 db 조회가 아닌 1차 캐시에서 가져온다. 그렇게 되면 반복해서 select 쿼리를 전송하지 않아도 되어 성능 향상을 기대할 수 있을 것이다. 그리고 이를 커밋하게 되면 객체의 변경 내용을 자동으로 감지해서 update 쿼리를 전송해준다.
현재는 이 정도의 간단한 구조로 이해하면 될 것 같다.
회원
리포지토리
@Repository
public class MemberRepository {
@PersistenceContext
private EntityManager em;
// persist를 한다고 해서 db에 바로 insert를 날리지 않음.
// db transaction이 커밋을 할 때 플러시를 날리면서 insert가 날아감
public void save(Member member) {
em.persist(member);
}
public Member findOne(Long id) { // em.find(리턴 타입, pk);
return em.find(Member.class, id);
}
public List<Member> findAll() { // JPQL을 호출한다. 테이블에서 조회하는 SQL과는 다르게 객체에서 조회를 한다.
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();
}
}
위에서부터 순서대로 회원가입, id 조회, 전체 멤버 조회, 이름으로 멤버 조회 기능을 구현한다.
@Repository: 리포지토리 객체를 스프링 빈으로 등록하고 JPA 예외를 스프링 기반 예외로 변환
@PersistenceContext: 엔티티 매니저 주입
서비스
@Service
@Transactional(readOnly = true) // pulic 함수들에 flush를 날리지 않는 것으로 트랜잭셔널을 걸어준다.
@RequiredArgsConstructor
public class MemberService {
private final MemberRepository memberRepository;
// 회원가입
@Transactional // 기본 트랜잭셔널의 readOnly가 true여도 다시 붙여주면 이 메서드만 기본값이었던 false로 바뀐다
public Long join(Member member) {
validateDuplicateMember(member);
memberRepository.save(member);
return member.getId();
}
// 중복 회원 로직
private void validateDuplicateMember(Member member) { // db에서 name 제약조건을 unique로 두는 것을 추천
List<Member> findMembers = memberRepository.findByName(member.getName());
if (!findMembers.isEmpty()) throw new IllegalStateException("이미 존재하는 회원");
}
// 회원 전체 조회
public List<Member> findMembers() {
return memberRepository.findAll();
}
// id로 조회
public Member findOne(Long id) {
return memberRepository.findOne(id);
}
}
세 가지 기능에 맞춰 서비스 구현
@Transactional: 트랜잭션을 한다. readOnly를 true로 하면 영속성 컨텍스트를 flush하지 않아 약간의 성능 향상이 생긴다.
DI는 필드주입이 아닌 생성자 주입을 사용한다.
테스트
@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 savedId = memberService.join(member);
//then
// @Rollback(false) 없이 EntityManager를 주입 받아서
// em.flush()를 하게 되면 로그에서 insert문은 확인하고 db를 롤백시킬 수 있음
assertEquals(member, memberRepository.findOne(savedId));
}
@Test
public void 중복_회원_예외() throws Exception {
//given
Member member1 = new Member();
member1.setName("kim");
Member member2 = new Member();
member2.setName("kim");
//when
memberService.join(member1);
//then
// IllegalStateException이 발생하지 않으면 실패
assertThrows(IllegalStateException.class, () -> memberService.join(member2));
}
}
회원가입이 되는지, 중복 이름으로 회원가입 할 때 에러가 잘 나는지 확인한다.
+테스트 케이스를 위한 테스트 설정용 application.properties를 resources 디렉토리를 만들어 안에 추가한다. 이때, 이 파일에 내용이 없더라도 기본적으로 메모리 모드로 테스트를 실행해서 문제가 발생하지 않는다. 오히려 원래의 데이터베이스를 건드리지 않아 단위 테스트를 실행 하기에는 좋지 않을까?라는 생각이다.
상품
엔티티 (비즈니스 로직 추가)
public abstract class Item {
...
//==비즈니스 로직==//
/**
* stock 증가
*/
public void addStock(int quantity) {
this.stockQuantity += quantity;
}
/**
* stock 감소
*/
public void removeStock(int quantity) {
int rentStock = this.stockQuantity - quantity;
if (rentStock < 0) {
// 예외를 추가하여 사용한다.
throw new NotEnoughStockException("need more stock");
}
this.stockQuantity -= quantity;
}
}
엔티티에 비즈니스 로직을 구현(도메인 모델 패턴)하는 이유는, 엔티티의 값을 객체 바깥에서 수정하고 set하는 것보다는, 값을 수정하는 로직이 객체 안에 정의되어 있고 그것을 밖에서 사용하여 값을 변경하는 것이 응집력이 있어지고 객체지향적이게 될 수 있다.
리포지토리
public class ItemRepository {
private final EntityManager em;
public void save(Item item) {
// jpa에 저장하기 전까지는 id 값이 없다. 즉, 이 if에 걸린 객체는 새로 생성한 객체이다
if (item.getId() == null) {
em.persist(item);
} else {
em.merge(item); // 지금은 업데이트로 이해하자
}
}
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();
}
}
회원의 리포지토리와 거의 유사하다. save()에서 em.merge()가 추가되었지만, 지금은 업데이트 정도로 이해한다.
서비스
MemberService에서 Validation만 없는 거 말고는 똑같아서 생략하고, 테스트도 같은 이유로 생략한다.
주문 [중요]
주문 엔티티
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Order {
...
//==생성 메서드==//
public static Order createOrder(Member member, Delivery delivery, OrderItem... orderItems) {
Order order = new Order();
order.setMember(member);
order.setDelivery(delivery);
for (OrderItem orderItem : orderItems) {
order.addOrderItem(orderItem);
}
order.setStatus(OrderStatus.ORDER);
order.setOrderDate(LocalDateTime.now());
return order;
}
//==비즈니스 로직==//
/**
* 주문 취소
*/
public void cancel() {
if (delivery.getStatus() == DeliveryStatus.COMP) {
throw new IllegalStateException("이미 배송완료 된 상품은 취소가 불가능합니다");
}
this.setStatus(OrderStatus.CANCEL);
for (OrderItem orderItem : orderItems) {
orderItem.cancel();
}
}
//==조회 로직==//
/**
* 전체 주문 가격 조회
*/
public int getTotalPrice() {
int totalPrice = 0;
for (OrderItem orderItem : orderItems) {
totalPrice += orderItem.getTotalPrice();
}
return totalPrice;
}
}
주문상품 엔티티
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class OrderItem {
...
//==생성 메서드==//
public static OrderItem createOrderItem(Item item, int orderPrice, int count) {
OrderItem orderItem = new OrderItem();
orderItem.setItem(item);
orderItem.setOrderPrice(orderPrice);
orderItem.setCount(count);
item.removeStock(count);
return orderItem;
}
//==비즈니스 로직==//
public void cancel() { // 재고 수량 원복
getItem().addStock(count);
}
//==조회 로직==//
/**
* 주문 상품 전체 가격 조회
*/
public int getTotalPrice() {
return getOrderPrice() * getCount();
}
}
두 객체 모두 생성 메서드를 따로 생성했다. 이 메서드는 실제 주문 엔티티, 주문상품 엔티티를 생성할 때 사용한다. 그리고 접근 범위를 protected로 설정해두었는데, 이는 반드시 생성 메스드를 사용해 객체를 만들 수 있게 강제하도록 설정하는 것이다. 그렇게 하면 객체를 생성 메서드를 사용하지 않거나 잘못 생성하는 경우를 방지하여 유지 보수에 도움이 된다.
리포지토리
public class OrderRepository {
private final EntityManager em;
public void save(Order order) {...}
public Order findOne(Long id) {...}
//==동적 쿼리==//
/**
* JPQL문을 직접 다 조합 [복복잡해서 권장 안함]
*/
public List<Order> findAll(OrderSearch orderSearch) {
...
}
/**
* JPA Criteria 이것도 딱히 권장은 안한다, JPA 표준 스펙으로 이런 게 잇다~
* 동적 쿼리 작성에 메리트, but 유지보수성이 0에 수렴하고 실무에서 쓰기엔 너무 복잡하다
* 그래서 Querydsl이 대안으로 등장
*/
public List<Order> findAllByCriteria(OrderSearch orderSearch) {
...
}
}
주문의 리포지토리에서는 상품 검색을 할 때 검색과 필터링을 해야해서 동적 쿼리를 작성해야하는데, 이 강의에서는 JPQL을 직접 작성하거나, Criteria라는 라이브러리를 사용하는 것을 알려주셨습니다. 하지만 둘 다 유지보수성이나 복잡성에 있어서 실무에서 쓰기에 적합하지 않다고 하셔 구체적인 내용은 생략합니다. 이것들의 대안으로 Querydsl이 있는데, 그것은 관련 강의를 보아야 한다고 하십니다.
서비스
public class OrderService {
private final OrderRepository orderRepository;
private final MemberRepository memberRepository;
private final ItemRepository itemRepository;
/**
* 주문
*/
@Transactional
public Long order(Long memberId, Long itemId, int count) {
// 엔티티 조회
Member member = memberRepository.findOne(memberId);
Item item = itemRepository.findOne(itemId);
// 배송 정보 생성
Delivery delivery = new Delivery();
delivery.setAddress(member.getAddress());
// 주문 상품 생성
OrderItem orderItem = OrderItem.createOrderItem(item, item.getPrice(), count);
// 주문 생성
Order order = Order.createOrder(member, delivery, orderItem);
// 주문 저장
orderRepository.save(order);
return order.getId();
}
// 취소
@Transactional
public void cancelOrder(Long orderId) {
// 주문 엔티티 조회
Order order = orderRepository.findOne(orderId);
// 주문 취소
order.cancel();
}
// 검색
public List<Order> findOrders(OrderSearch orderSearch) {
return orderRepository.findAll(orderSearch);
}
}
save()에서 persist를 한 번만 하는 이유는 Order의 orderItem과 delivery에 cascade가 걸려있어서 알아서 persist 되기 때문이다 cascade는 연관관계의 주인이 다른 객체를 다 관리하는 경우에 쓰면 좋다. 이런 경우에 Delivery와 OrderItem은 쓰이는 경우가 잘 없어 Order에서 cascade를 걸어 한 번에 관리하면 좋지만, 만약 다른 객체가 참조하는 경우가 있다면 cascade를 쓰지 않고 persist를 별도로 하는 것이 좋을 것이다.
jpa, 스프링 데이터 jpa, Querydsl은 같이 배워야 생산성을 극대화 하고 깔끔하게 개발할 수 있다.
테스트
class OrderServiceTest {
@Autowired EntityManager em;
@Autowired OrderService orderService;
@Autowired OrderRepository orderRepository;
@Test
public void 상품주문() throws Exception {
//given
Member member = createMember();
Book book = createBook("시골 jpa", 10000, 10);
int orderCount = 2;
//when
Long orderId = orderService.order(member.getId(), book.getId(), orderCount);
//then
Order getOrder = orderRepository.findOne(orderId);
assertEquals(OrderStatus.ORDER, getOrder.getStatus(), "상품 주문시 상태는 ORDER");
assertEquals(1, getOrder.getOrderItems().size(), "주문한 상품 종류의 수가 정확해야 한다");
assertEquals(10000*orderCount, getOrder.getTotalPrice(), "주 가격은 (가격 * 수량)이다");
assertEquals(8, book.getStockQuantity(), "주문 수량만큼 재고가 줄어야 한다");
}
@Test
public void 상품주문_재고수량초과() throws Exception {
//given
Member member = createMember();
Item item = createBook("시골 jpa", 10000, 10);
int orderCount = 11;
//when
//then
assertThrows(
NotEnoughStockException.class,
() -> orderService.order(member.getId(), item.getId(), orderCount)
);
}
@Test
public void 주문취소() throws Exception {
//given
Member member = createMember();
Item item = createBook("시골 jpa", 10000, 10);
int orderCount = 2;
Long orderId = orderService.order(member.getId(), item.getId(), orderCount);
//when
orderService.cancelOrder(orderId);
//then
Order getOrder = orderRepository.findOne(orderId);
assertEquals(OrderStatus.CANCEL, getOrder.getStatus(), "주문 취소시 상태는 CANCEL");
assertEquals(10, item.getStockQuantity(), "주문이 취소된 상품은 재고가 그만큼 증가해야 한다");
}
private Book createBook(String name, int price, int stockQuantity) {
Book book = new Book();
book.setName(name);
book.setPrice(price);
book.setStockQuantity(stockQuantity);
em.persist(book);
return book;
}
private Member createMember() {
Member member = new Member();
member.setName("member1");
member.setAddress(new Address("seoul", "riverside", "123-123"));
em.persist(member);
return member;
}
}
테스트에서는 주문 검색 기능을 테스트하지는 않아서 크게 볼 부분은 없다.
유용한 기능은 intellij에서 반복되는 부분을 command+option+m을 누르게 되면 메서드로 뽑아낼 수 있다. 그 예시가 createBook(), createMember()이다.
웹 계층 개발
웹 계층 개발 파트는 타임리프로 ssr 하고 컨트롤러로 연결해주는 간단한 단계까지여서 내용은 많아보여도 크게 할 건 없었습니다. 그 중에서 알고 넘어가면 좋을 거나 중요한 것들로 정리했습니다.
bootstrap 적용 안될 때
다운로드페이지에서 cdn 항목에 가서 태그 속성 중 integrity의 값을 bootstrap의 버전에 맞게 바꿔주면 된다.
변경 감지와 병합(merge)
변경 감지 == 더티 체킹
트랜잭션이 커밋할 때 영속성 컨텍스트가 영속성 엔티티들을 더티 체킹을 한다.
준영속 엔티티: 영속성 컨텍스트가 관리하지 않는 엔티티, 쉽게 보면 이미 db에 존재하는 엔티티이다. 이는 영속성 컨텍스트가 관리하지 않아서, 트랜잭션 커밋할 때 영속성 컨텍스트가 더티 체킹을 할 수 없다. → JPA가 이를 업데이트 할 근거가 없다. 따라서 준영속 엔티티 수정 불가능
준영속 엔티티를 수정하는 방법
준영속 엔티티를 수정, 즉 업데이트를 하는 방법은 두 가지가 있다.
변경감지
객체를 찾아서 영속성 엔티티로 만들고 값을 수정. 이후 트랜잭셔널이 메서드 실행 종료 시점에 커밋을 하면서 영속성 컨텍스트가 엔티티 내용의 변경을 감지하고 더티체킹을 해서 업데이트 쿼리를 전송한다.
public class ItemService {
@Transactional
public void updateItem(Long itemId, String name, int price, int stockQuantity) {
Item findItem = itemRepository.findOne(itemId); // find로 찾아왔으니 findItem은 영속성 상태
findItem.setName(name);
findItem.setPrice(price);
findItem.setStockQuantity(stockQuantity);
// save나 merge를 하지 않아도 스프링의 트랜잭션에 의해 영속성 객체가 커밋되어 jpa가 플러시를 날려서
// 영속성 컨텍스트에 있는 객체의 변경을 감지하고 업데이트 쿼리를 전송. -> 변경 감지
}
}
병합
변경 감지와 동일하게 작동한다. 객체를 조회하고, 내용을 전부 업데이트하고, 영속성 엔티티를 반환한다. 이후 트랜잭셔널 커밋으로 업데이트된다.
@Transactional
void update(Item itemParam) { //itemParam: 파리미터로 넘어온 준영속 상태의 엔티티
Item mergeItem = em.merge(itemParam);
}
둘의 차이점은 변경 감지로 업데이트를 하면 원하는 속성만 업데이트 하지만, 병합은 모든 값들을 업데이트 해버린다. 그래서 병합할 때 업데이트할 값이 없으면 null로 바꾸기 때문에 변경 감지를 사용하는 편이 낫다.
+) 값을 수정할 때는 세터를 메인으로 쓰지 말고 수정 메서드를 만들어서 쓰자. 코드를 추적하기 쉬워 유지보수에 좋다. 특히 인수인계 할 때.
'WINK-(Web & App) > Spring Boot 스터디' 카테고리의 다른 글
[2025 1학기 스프링부트 스터디] 이상래 #4주차 (0) | 2025.05.04 |
---|---|
[2025 1학기 스프링부트 스터디] 고윤정 #4주차 (1) | 2025.05.04 |
[2025 1학기 스프링부트 스터디] 김민서 #4주차 (0) | 2025.05.03 |
[2025 1학기 스프링 부트 스터디] 석준환 #4주차 (0) | 2025.04.29 |
[2025 1학기 스프링부트 스터디] 이종윤 #3주차 (0) | 2025.04.14 |