실전! 스프링 부트와 JPA 활용1 - 웹 애플리케이션 개발 : 컨트롤러 계층, 템플릿

jkky98·2024년 9월 25일
0

Spring

목록 보기
46/77

회원 등록

템플릿으로 엔티티의 데이터를 전달하기 위해 엔티티 객체를 직접 사용해서 View계층으로 전달하는 것은 옳지 않다. View와 Controller 계층 사이에서 Validation과 같은 여러 추가 작업이 발생할 수 있으며, 필요없는 필드가 왔다갔다하는 것은 굉장히 비효율 적이기 때문이다. 또한 엔티티가 View계층에 종속적으로 변할 경우 화면에서의 수정이 엔티티의 수정으로 이어질 수 있다. 엔티티는 여러 계층에서 사용할 수 있기에 이전에도 이러한 것을 줄이기 위해(객체 지향적으로!) 엔티티에서만 해결할 수 있는 단위핵심기능들은 엔티티 클래스에서 메서드를 작성하기도 했다.

엔티티는 핵심 비즈니스 로직만 가지고 있고, 화면을 위한 로직은 없어야 한다. 화면이나 API에 맞는 폼 객체나 DTO 객체를 따로 만들어 사용하도록 해야한다. 엔티티는 순수하게 유지하자.

그렇기에 따로 Form 객체를 사용하도록 한다. Member 엔티티에 대응하는 MemberForm을 만들었으며 도메인 엔티티에서 엔티티 관련 어노테이션이 빠지고 연관관계 엔티티들을 담은 orders 컬렉션 필드도 빠졌다. 또한@NotEmpty와 같은 검증 툴이 적용될 수 있다.

MemberForm

@Getter @Setter
public class MemberForm {

    @NotEmpty(message = "회원 이름은 필수 입니다.")
    private String name;

    private String city;
    private String street;
    private String zipcode;
}

MemberController

@Controller
@RequiredArgsConstructor
public class MemberController {

    private final MemberService memberService;

    @GetMapping(value = "/members/new")
    private String createForm(Model model) {
        model.addAttribute("memberForm", new MemberForm());
        return "members/createMemberForm";
    }

    @PostMapping(value = "/members/new")
    private String create(@Validated MemberForm form, BindingResult bindingResult) {

        if (bindingResult.hasErrors()) {
            return "members/createMemberForm";
        }

        Address address = new Address(form.getCity(), form.getStreet(), form.getZipcode());

        Member member = new Member();
        member.setName(form.getName());
        member.setAddress(address);

        memberService.join(member);
        return "redirect:/";
    }

    @GetMapping(value = "/members")
    private String list(Model model) {
        List<Member> members = memberService.findMembers();
        model.addAttribute("members", members);
        return "members/memberList";
    }
}

MemberController 에서는 회원 등록 페이지로의 GET요청을 처리하기 위해 createForm으로 하여금 model에는 빈 memberForm객체를 생성해서 템플릿으로의 리턴을 했다. 아무 데이터도 없는 폼을 그냥 보내는 이유는 template에 있다.

<form role="form" action="/members/new" th:object="${memberForm}" method="post">
    <div class="form-group my-2">
      <label th:for="name">이름</label>
      <input type="text" th:field="*{name}" class="form-control"
             placeholder="이름을 입력하세요"
             th:class="${#fields.hasErrors('name')}? 'form-control fieldError' : 'form-control'">
      <p th:if="${#fields.hasErrors('name')}" th:errors="*{name}">Incorrect date</p>
    </div>

폼 태그를 보면 th:object로 하여금 memberForm과 바인딩 되어있고 아래의 input 태그에는 이 태그가 th:field="*{name}"로 memberForm의 name 필드와 바인딩 되어있음을 알 수 있다. #fields.hasError('name')으로 하여금 th:object의 name 필드에 대한 에러 처리도 나타나있다.

현재 같은 post요청에서 @Validated로 하여금 폼의 검증 애노테이션을 확인하여 에러를 필터링한다. BindingResult로 하여금 에러를 담는다. 에러가 들어있다면 다시 이 폼 태그가 포함된 템플릿을 반환하게 된다. 이때 name에서 예외가 발생했던 것이라면, #fields.hasErrors('name')에서 이 에러 정보를 잡게 된다. 자세한 과정은 이전 스프링 MVC편에서 작성해두었다.

그렇기에 단순히 page를 보여주는 GET 요청에 대해서도 빈 객체를 모델에 담아 보내어 일관성을 유지하는 것이다.

이전에 service - repository까지를 잘 구현해두었기 때문에 controller에서 할 일은 많지 않다. 그리고 이것이 모범적인 아키텍처이다. 목록조회 기능에 해당하는 list 메서드도 단순히 service의 전체조회 기능을 호출해줄 뿐이다(딱 한줄이다.)

상품 등록

@Controller
@RequiredArgsConstructor
public class ItemController {

    private final ItemService itemService;

    @GetMapping("/items/new")
    public String createForm(Model model) {
        model.addAttribute("form", new BookForm());
        return "items/createItemForm";
    }

    @PostMapping("/items/new")
    public String create(BookForm form) {
        Book book = new Book();
        book.setName(form.getName());
        book.setPrice(form.getPrice());
        book.setStockQuantity(form.getStockQuantity());
        book.setAuthor(form.getAuthor());
        book.setIsbn(form.getIsbn());

        itemService.saveItem(book);
        return "redirect:/items";
    }

    @GetMapping(value = "/items")
    private String list(Model model) {
        List<Item> items = itemService.findItems();
        model.addAttribute("items", items);
        return "items/itemList";
    }

    @GetMapping("/items/{itemId}/edit")
    public String updateItemForm(@PathVariable("itemId") Long itemId, Model model) {
        Book item = (Book) itemService.findOne(itemId);

        BookForm form = new BookForm();
        form.setId(item.getId());
        form.setAuthor(item.getAuthor());
        form.setIsbn(item.getIsbn());
        form.setPrice(item.getPrice());
        form.setStockQuantity(item.getStockQuantity());
        form.setName(item.getName());

        model.addAttribute("form", form);
        return "items/updateItemForm";

    }

    @PostMapping("/items/{itemId}/edit")
    public String updateItem(@ModelAttribute("form") BookForm form) {

//        Book book = new Book();
//
//        book.setName(form.getName());
//        book.setId(form.getId());
//        book.setPrice(form.getPrice());
//        book.setIsbn(form.getIsbn());
//        book.setAuthor(form.getAuthor());
//        book.setStockQuantity(form.getStockQuantity());
//
//        itemService.saveItem(book);
        // 변경감지를 이용하는 법
        Book book = (Book) itemService.findOne(form.getId());

        book.setName(form.getName());
        book.setId(form.getId());
        book.setPrice(form.getPrice());
        book.setIsbn(form.getIsbn());
        book.setAuthor(form.getAuthor());
        book.setStockQuantity(form.getStockQuantity());

        // 이 방법도 옳진 않다. 컨트롤러에서 이렇게 풀어해쳐서 셋터로 가져오는 것보단 DTO를 활용해서 service에서 처리하거나 해야한다.

        return "redirect:/items";
    }
}

상품등록 컨트롤러에서는 약간(?) 잘못된 방식의 코드가 포함된다. 등록폼, 등록 제출, 전체조회는 이전과 다를 것이 없는 코드이다.

다만 멤버 컨트롤러와 달리 수정기능에 관한 부분이 들어가는데, 수정 폼과 수정 제출 기능이 추가된 모습이다.

변경 감지

영속성 컨텍스트

updateItem(수정제출) 메서드에서 주석 처리된 부분도 (일단은) 잘 작동하는 방식이다. JPA는 영속성 컨텍스트로 하여금 DB와 스프링 사이에서 캐시와 같은 논리적 부분을 둔다. JPA를 사용할 때, 우리는 컬렉션처럼 참조의 특성을 이용할 수 있었다. JPA로 객체를 불러오고 이를 수정할 경우(객체를 수정하고 이를 반영한 sql쿼리문이 나가지 않더라도) DB에 이를 저장할 수 있었다.

훌륭한 이해를 위해 예를 들어보자면, JPA의 영속성 컨텍스트는 JPA로 불러온 객체를 저장하고 있는다. 그리고 코드에 의해 이에 수정이 여러 차례 가해진다. 영속성 컨텍스트 내에서 객체는 계속 변화한 것이다. 이 변화한 과정은 DB에 저장되지 않는다. 그리고 최종 커밋시에 JPA는 변화된 부분만 DB의 이전 데이터의 부분부분과 바꿔치기한다.

쿼리문을 자주 날리지 않기에 성능적으로도 좋고(캐시의 개념을 생각하면 편하다.) 개발자들도 자바 프로그래밍처럼 DB의 데이터를 편하게 이용할 수 있게 된다.

꼭 변경감지를 활용하도록

이 개념을 알고 두 코드를 보면 어떤 코드를 활용해야할 지 감각이 온다.

//        Book book = new Book();
//
//        book.setName(form.getName());
//        book.setId(form.getId());
//        book.setPrice(form.getPrice());
//        book.setIsbn(form.getIsbn());
//        book.setAuthor(form.getAuthor());
//        book.setStockQuantity(form.getStockQuantity());
//
//        itemService.saveItem(book);

주석 처리한 이 부분은 Item에 해당하는 Book 엔티티를 컨트롤러에서 만들어, 수정임에도 save기능으로 저장하는 것이다. save 로직을 다시 보자.

public void save(Item item) {
        if (item.getId() == null) {
            em.persist(item);
        } else {
            em.merge(item);
        }
    }

save시에 Id가 이미 있는 객체이므로 merge가 작동한다.

em.merge()

merge()를 사용하는 이유는 위 주석된 코드로 하여금 생성된 Book 엔티티는 영속성 컨텍스트에 존재하지 않기 때문이다. em.merge(entity)를 통해 entity를 영속성 컨텍스트에 위치시켜주면서 해당 엔티티를 DB에 적용해준다.(리턴된 것을 사용해야 영속성 컨텍스트에 해당하는 entity를 사용 가능 -> merge로 리턴된 엔티티를 준영속 엔티티라고 한다.)

다만 em.merge()를 사용하지 않는 이유는 em.merge가 너무 강력한 변경을 가지기 때문이다. 만약 변경 이전의 엔티티가 10개의 필드를 가졌는데 새로운 엔티티가 5개의 필드에만 값이 존재한다면, 나머지 5개의 필드는 null일 것이다.

일반적으로는 5개의 교체된 필드에 대해서만 바꿔치기 해주면 될 것 같은데, em.merge()의 경우는 null도 반영해버린다.

변경된 5개의 필드 + 무변경 필드 5개에 대해서 변경된 5개의 필드 + 5개의 null을 적용한 것을 DB에 업데이트한다.(이전 레코드에 null이 존재하지 않았다고 가정한다.)

"이를 방지할 코드를 붙여서 merge()를 사용하면 되지 않냐"라는 의문이 올 수 있지만, 로직 자체가 매우 복잡해지게 되며 수정에 대해서도 머리가 아파질 것이다. 그렇기에 관성적으로 em.merge()의 사용을 금지시키고 변경감지를 이용하게끔 근본적인 코드 자체를 바꿀 필요가 있다는 것이다.

무조건 변경감지

		Book book = (Book) itemService.findOne(form.getId());

        book.setName(form.getName());
        book.setId(form.getId());
        book.setPrice(form.getPrice());
        book.setIsbn(form.getIsbn());
        book.setAuthor(form.getAuthor());
        book.setStockQuantity(form.getStockQuantity());

수정에 관한 코드임에도 findOne을 통해 단건조회를 진행해서 조회된 객체에 수정을 가해주는 것이다. 트랜잭션 안에서의 객체를 JPA 조회하였으니 당연히 이 엔티티 객체는 영속성 컨텍스트 내에서 존재하며, 객체 수정만으로도 트랜잭션 커밋 후 Update가 적용되는 것이다.

결론은 JPA의 영속성 컨텍스트의 특징을 잘 이해해서 merge를 사용하는 일이 없도록 해야한다.

DTO 사용

또 하나의 문제는 서비스 계층을 사용하지 않고 Controller에서 엔티티를 직접 생성하고 있다는 것이다.

	@PostMapping("/items/{itemId}/edit")
    public String updateItem(@ModelAttribute("form") BookForm form) {
        Book book = (Book) itemService.findOne(form.getId());

        book.setName(form.getName());
        book.setId(form.getId());
        book.setPrice(form.getPrice());
        book.setIsbn(form.getIsbn());
        book.setAuthor(form.getAuthor());
        book.setStockQuantity(form.getStockQuantity());

        return "redirect:/items";
    }
        
	@PostMapping("/items/{itemId}/edit")
    public String updateItem(@ModelAttribute("form") BookForm form) {
        itemService.updateItem(form)
        
       return "redirect:/items";
    }

서비스 계층에 form 데이터에 대해 업데이트를 처리하는 부분을 만들어 컨트롤러에서 두번째 코드와 같이 간결하게 처리할 수 있어야 하며 book의 setter를 저렇게 여러개 열어서 사용하는 것이 아니라 따로 데이터 전송 계층을 사용하여 setter의 사용이 아닌 단일 기능으로 처리할 수 있어야 한다.

주문 컨트롤러는 위에서 설명한 내용이 중복되어 생략한다.

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

0개의 댓글