웹 계층 개발
- 김영한님의 실전! 스프링 부트와 JPA 활용1 - 웹 애플리케이션 개발 강의를 기반으로 홈 화면, 회원 관리, 상품 관리, 주문 관리 등 웹 계층 전반의 개발 과정과 변경 감지 및 병합 메커니즘을 정리함
홈 화면과 레이아웃
홈 컨트롤러 등록
1
2
3
4
5
6
7
8
9
@Controller
@Slf4j
public class HomeController {
@RequestMapping("/")
public String home() {
log.info("home controller");
return "home";
}
}
@Controller- 스프링 MVC 컨트롤러로 등록됨
@RequestMapping("/")- 루트 URL 요청을 처리함
return "home"resources/templates/home.html을 렌더링함
스프링 부트 Thymeleaf 설정
1
2
3
4
spring:
thymeleaf:
prefix: classpath:/templates/
suffix: .html
- ViewName 매핑 규칙
resources:templates/+{ViewName}+.html"home"반환 →resources:templates/home.html렌더링
타임리프 템플릿 구조
- templates/home.html
- 메인 화면
- 회원 기능
- 회원 가입
- 회원 목록
- 상품 기능
- 상품 등록
- 상품 목록
- 주문 기능
- 상품 주문
- 주문 내역
- 공통 템플릿 포함
fragments/header.html(Bootstrap CSS 링크, 공통 meta 태그)fragments/bodyHeader.html(상단 네비게이션, 홈 링크)fragments/footer.html(하단 저작권 정보)
개발 편의 기능
- 뷰 템플릿 변경사항 즉시 반영
spring-boot-devtools추가html파일 build → Recompile
- view 리소스 등록
- Bootstrap v4.3.1 사용
resources/static/css/jumbotron-narrow.css추가resources/static/js폴더
회원 관리
회원 등록
-
폼 객체 (MemberForm)
1 2 3 4 5 6 7 8
@Getter @Setter public class MemberForm { @NotEmpty(message = "회원 이름은 필수 입니다") private String name; private String city; private String street; private String zipcode; }
-
컨트롤러 흐름

-
주요 로직
1 2 3 4 5 6 7 8 9 10 11 12 13 14
@PostMapping(value = "/members/new") public String create(@Valid MemberForm form, BindingResult result) { if (result.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:/"; }
@Valid- 유효성 검증을 수행함
BindingResult- 검증 오류 정보를 담음
hasErrors()메서드로 오류 여부를 확인함
회원 목록 조회
1
2
3
4
5
6
@GetMapping(value = "/members")
public String list(Model model) {
List<Member> members = memberService.findMembers();
model.addAttribute("members", members);
return "members/memberList";
}
- 화면 표시
- 회원 ID
- 이름
- 도시
- 주소
- 우편번호
- 타임리프
?.연산자- null 안전 접근
폼 객체와 엔티티 직접 사용 비교
- 폼 객체 사용 권장 이유
- 화면 요구사항과 엔티티 분리
- 엔티티는 비즈니스 로직만 포함
- 화면 종속적인 기능 제거
- 유지보수성 향상
- 원칙
- 엔티티
- 비즈니스 로직만
- 폼 객체/DTO
- 화면/API 요구사항 처리
- 엔티티
상품 관리
상품 등록
-
폼 객체 (BookForm)
1 2 3 4 5 6 7 8 9
@Getter @Setter public class BookForm { private Long id; private String name; private int price; private int stockQuantity; private String author; private String isbn; }
-
컨트롤러 흐름

상품 목록 조회
1
2
3
4
5
6
@GetMapping(value = "/items")
public String list(Model model) {
List<Item> items = itemService.findItems();
model.addAttribute("items", items);
return "items/itemList";
}
- 화면 표시
- 상품 ID
- 상품명
- 가격
- 재고수량
- 수정 버튼
/items/{id}/edit
상품 수정
-
수정 폼 이동

-
수정 실행

변경 감지와 병합(merge)
준영속 엔티티
- 정의
- 영속성 컨텍스트가 더 이상 관리하지 않는 엔티티
- 이미 DB에 저장되어 식별자가 존재하는 엔티티
- 임의로 만들어낸 엔티티도 기존 식별자를 가지면 준영속 엔티티
-
ex)
1 2 3
Book book = new Book(); book.setId(form.getId()); // 기존 식별자 존재 → 준영속 엔티티 book.setName(form.getName());
준영속 엔티티 수정 방법
-
변경 감지 (권장)
1 2 3 4 5 6
@Transactional void update(Item itemParam) { Item findItem = em.find(Item.class, itemParam.getId()); // 영속 상태 조회 findItem.setPrice(itemParam.getPrice()); // 데이터 수정 // 트랜잭션 커밋 시 자동으로 변경 감지 → UPDATE SQL 실행 }
-
동작 과정

-
장점
- 원하는 속성만 선택적으로 변경 가능
- null 업데이트 위험 없음
- 명확한 변경 지점
-
-
병합 (비권장)
1 2 3 4
@Transactional void update(Item itemParam) { Item mergeItem = em.merge(itemParam); }
-
병합 동작 방식

-
단점
- 모든 필드를 교체 (선택적 수정 불가)
- 값이 없으면 null로 업데이트될 위험
- 변경 폼에서 모든 데이터를 유지해야 함
-
ItemRepository의 save 메서드
1
2
3
4
5
6
7
public void save(Item item) {
if (item.getId() == null) {
em.persist(item); // 신규 등록
} else {
em.merge(item); // 수정 (병합)
}
}
-
판단 기준
- 식별자 null → 새로운 엔티티 →
persist - 식별자 존재 → 준영속 엔티티 →
merge
- 식별자 null → 새로운 엔티티 →
권장 해결 방법
- 원칙
- 엔티티 변경 시 항상 변경 감지 사용
- 컨트롤러에서 엔티티 생성 지양
- 서비스 계층에 식별자와 변경 데이터 전달
- 서비스 계층에서 영속 엔티티 조회 후 데이터 변경
-
권장 코드
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
// Controller @PostMapping(value = "/items/{itemId}/edit") public String updateItem(@PathVariable Long itemId, @ModelAttribute("form") BookForm form) { itemService.updateItem(itemId, form.getName(), form.getPrice(), form.getStockQuantity()); return "redirect:/items"; } // Service @Transactional public void updateItem(Long id, String name, int price, int stockQuantity) { Item item = itemRepository.findOne(id); // 영속 엔티티 조회 item.setName(name); item.setPrice(price); item.setStockQuantity(stockQuantity); // 트랜잭션 커밋 시 변경 감지 자동 동작 }
주문 관리
상품 주문
-
컨트롤러 구조
1 2 3 4 5 6 7 8 9 10 11 12
@Controller @RequiredArgsConstructor public class OrderController { private final OrderService orderService; private final MemberService memberService; private final ItemService itemService; // 주문 폼 // 주문 실행 // 주문 목록 // 주문 취소 }
-
주문 폼 흐름

-
주문 실행 흐름

-
주요 로직
1 2 3 4 5 6 7
@PostMapping(value = "/order") public String order(@RequestParam("memberId") Long memberId, @RequestParam("itemId") Long itemId, @RequestParam("count") int count) { orderService.order(memberId, itemId, count); return "redirect:/orders"; }
주문 목록 검색
1
2
3
4
5
6
@GetMapping(value = "/orders")
public String orderList(@ModelAttribute("orderSearch") OrderSearch orderSearch, Model model) {
List<Order> orders = orderService.findOrders(orderSearch);
model.addAttribute("orders", orders);
return "order/orderList";
}
- 검색 기능
- 회원명으로 검색
- 주문 상태로 필터링 (ORDER, CANCEL)
- 화면 표시
- 주문 ID
- 회원명
- 대표상품 정보
- 주문 상태
- 주문 일시
- 주문 상태가 ORDER인 경우
- 취소 버튼 표시
주문 취소
-
흐름

-
주요 로직
1 2 3 4 5
@PostMapping(value = "/orders/{orderId}/cancel") public String cancelOrder(@PathVariable("orderId") Long orderId) { orderService.cancelOrder(orderId); return "redirect:/orders"; }
전체 아키텍처

계층별 역할
- Controller Layer
- HTTP 요청/응답 처리
- 폼 객체/DTO 사용
- 서비스 계층 호출
- 뷰 이름 반환
- Service Layer
- 비즈니스 로직 처리
- 트랜잭션 관리
- 엔티티 조작
- 변경 감지 활용
- Repository Layer
- 데이터 접근
- 엔티티 영속화/조회
- JPQL/QueryDSL 사용
- View Layer
- Thymeleaf 템플릿
- 프래그먼트 재사용
- 데이터 바인딩
연습 문제
-
회원 가입 시 화면 입력 데이터를 엔티티 객체 대신 별도의 Form 객체로 받는 주된 이유는 무엇일까요?
a. 화면 종속적인 데이터나 유효성 검증 로직을 분리하기 위해서
- 화면에서 넘어오는 데이터 형식이나 유효성 검증 규칙은 비즈니스 로직을 담은 엔티티와 다를 수 있음
- Form 객체는 UI 계층의 요구사항에 맞춰 데이터를 받고 처리함으로써 엔티티의 순수성을 유지하고 코드 구조를 깔끔하게 만드는 데 도움을 줌
-
Spring MVC에서
@Valid어노테이션으로 폼 데이터 검증 시 오류 발생 시 컨트롤러에서 이를 처리하고 폼 화면으로 되돌아가기 위해 주로 어떤 객체를 사용할까요?a. BindingResult 객체
@Valid대상 객체 바로 뒤에BindingResult를 파라미터로 선언하면, 유효성 검증 중 발생한 오류가 이 객체에 담겨 컨트롤러 메서드로 넘어옴- 이를 확인하여 오류가 있으면 폼 화면으로 다시 이동시키면서 오류 메시지를 함께 보여줄 수 있음
-
JPA에서 영속성 컨텍스트에 의해 관리되는(Managed) 엔티티의 데이터가 변경되면, 개발자가 별도의
update메서드를 명시적으로 호출하지 않아도 트랜잭션 커밋 시 자동으로 DB에 변경 내용이 반영됩니다. 이 메커니즘을 무엇이라고 할까요?a. Dirty Checking (변경 감지)
- JPA는 영속성 컨텍스트가 관리하는 엔티티의 상태 변화를 추적함
- 트랜잭션이 끝나는 시점에 엔티티의 변경된 내용을 감지하여 자동으로 DB에 UPDATE 쿼리를 실행하는데, 이를 변경 감지 또는 더티 체킹이라고 부름
-
JPA의
merge기능을 사용하여 준영속(Detached) 상태의 엔티티를 영속 상태로 만들고 데이터를 업데이트할 때, 파라미터로 넘어온 준영속 객체의 특정 필드가null일 경우 발생할 수 있는 주된 위험은 무엇일까요?a. DB에 저장된 엔티티의 해당 필드 값이
null로 덮어쓰여질 수 있다.merge는 파라미터로 넘어온 준영속 엔티티의 모든 필드 값을 영속 상태 엔티티에 복사함- 만약 파라미터 객체의 특정 필드가
null이라면, DB의 해당 필드 값도null로 변경될 수 있어 데이터 손실 위험이 있음
-
최신 웹/모바일 앱 개발에서 API를 설계할 때, 백엔드의 JPA 엔티티 객체를 API 응답 값으로 외부에 직접 노출하는 것을 지양해야 하는 주된 이유는 무엇일까요?
a. 엔티티 변경 시 API 스펙이 함께 변경되어 API의 안정성을 해치기 때문에
- JPA 엔티티는 DB 구조와 비즈니스 로직이 결합된 내부 모델임
- 이를 직접 노출하면 엔티티에 필드가 추가/삭제/변경될 때마다 API 사용자가 영향을 받게 되어 API 스펙이 불안정해지며, 민감한 정보가 노출될 위험도 있음
- 따라서 API 응답은 DTO 등으로 변환하여 전달하는 것이 좋음
요약 정리
- 홈 화면과 레이아웃은 Thymeleaf 템플릿을 활용하여 header, bodyHeader, footer 등 프래그먼트로 구조화함
- 회원 관리는
MemberForm을 사용하여 화면 계층과 엔티티를 분리하고,@Valid와BindingResult로 유효성 검증을 처리함 - 상품 관리는
BookForm을 통해 등록/수정을 처리하며, 준영속 엔티티 수정 시 변경 감지를 사용하는 것을 권장함 - 변경 감지(Dirty Checking)는 영속 엔티티를 조회 후 수정하는 방식으로 null 업데이트 위험이 없고, 병합(merge)은 모든 필드를 교체하므로 비권장함
- 주문 관리는 주문 생성, 목록 조회, 검색, 취소 기능을 컨트롤러에서 처리하며, 비즈니스 로직은 서비스 계층에 위임함
- 계층 분리는 Controller, Service, Repository로 나누어 관심사를 명확히 하고, 엔티티는 비즈니스 로직만 포함하도록 함