상품 조회 조건
  • 상품 등록일
  • 상품 판매 상태 [SELL/SOLD OUT]
  • 상품명 또는 상품 등록자 아이디

 

ItemSearchDTO 클래스 생성


  • QDomain 클래스 생성 후 ItemSearchDTO 클래스 생성
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ItemSearchDTO {

    // 상품등록일 
    private String searchDateType;

    // 판매상태
    private ItemSellStatus searchSellStatus;

    // 삼품 조회 유형
    private String searchBy;

    // 조회할 검색어 저장할 변수
    private String searchQuery;

}

 

Querydsl과 Spring Data Jpa를 함께 사용하기 위해서는 '사용자 정의 리파지토리'를 정의해야 함
  1. 사용자 정의 인터페이스 작성
  2. 사용자 정의 인터페이스 구현
  3. Spring Data Jpa 리포지토리에서 사용자 정의 인터페이스 상속
public interface ItemRepositoryCustom {

    Page<Item> getAdminItemPage(ItemSearchDTO itemSearchDTO, Pageable pageable);

}
  • 상품 조회 조건을 담고 있는 itemSearchDTO 객체와 페이징 정보를 담고 있는 pageable 객체를 파라미터로 받는 getAdminItemPage 메소드를 정의. 

 

public class ItemRepositoryCustomImpl implements ItemRepositoryCustom{

    private JPAQueryFactory queryFactory;

    public ItemRepositoryCustomImpl(EntityManager em) {
        this.queryFactory = new JPAQueryFactory(em);
    }
    • 동적으로 쿼리 생성하기 위해 JPAQueryFactory 클래스 사용
    • JPAQueryFactory의 생성자로 EntityManager 객체를 넣어줌
    • EntityManager를 생성자에 주입하는 것은 JPAQueryFactory의 인스턴스를 생성하고 QueryDSL을 통해 데이터베이스 쿼리를 수행하기 위한 기본적인 설정 과정
    private BooleanExpression searchSellStatusEq(ItemSellStatus searchSellStatus) {
        return searchSellStatus ==
                null ? null : QItem.item.itemSellStatus.eq(searchSellStatus);
    }

    private BooleanExpression regDtsAfter(String searchDateType) {
    // 해당 시간 이후로 등록된 상품만 조회
    
        LocalDateTime dateTime = LocalDateTime.now();

        if(StringUtils.equals("all", searchDateType) || searchDateType == null) {
            return null;
        } else if (StringUtils.equals("1d", searchDateType)) {
            dateTime = dateTime.minusDays(1);
        } else if (StringUtils.equals("1w", searchDateType)) {
            dateTime = dateTime.minusWeeks(1);
        } else if (StringUtils.equals("1m", searchDateType)) {
            dateTime = dateTime.minusMonths(1);
        } else if (StringUtils.equals("6m", searchDateType)) {
            dateTime = dateTime.minusMonths(6);
        }

        return QItem.item.regDate.after(dateTime);
    }

    private BooleanExpression searchByLike(String searchBy, String searchQuery) {
    // 상품명에 검색어를 포함하고 있는 상품 또는 상품 생성자 아이디에 검색어 포함하고 있는 상품 조회

        if(StringUtils.equals("itemNm", searchBy)) {
            return QItem.item.itemNm.like("%" + searchQuery + "%");
        } else if(StringUtils.equals("createdBy", searchBy)) {
            return QItem.item.createdBy.like("%" + searchQuery + "%");
        }

        return null;
    }
    • BooleanExpression은 QueryDSL에서 쿼리의 조건을 표현하는 데 사용되는 클래스
    • 일반적으로 SQL에서 WHERE 절에 해당하는 부분을 Java 코드에서 타입 안전하게 작성할 수 있게 해줌https://cs-ssupport.tistory.com/518
 

[QueryDSL] 동적 쿼리 (BooleanExpression)

QueryDSL은 SQL자체를 자바 코드로 작성하기 때문에 TypeSafe하고 컴파일 시점에 오류를 발견할 수 있다는 장점이 존재한다. 더불어서 QueryDSL의 가장 큰 장점 중 하나는 "동적 쿼리 생성의 편리함"이다

cs-ssupport.tistory.com

 

    @Override
    public Page<Item> getAdminItemPage(ItemSearchDTO itemSearchDTO, Pageable pageable) {

        // 조건에 따라 아이템 리스트를 조회
        List<Item> content = queryFactory
                .selectFrom(QItem.item)
                .where(regDtsAfter(itemSearchDTO.getSearchDateType()),
                        searchSellStatusEq(itemSearchDTO.getSearchSellStatus()),
                        searchByLike(itemSearchDTO.getSearchBy(),
                                itemSearchDTO.getSearchQuery()))
                .orderBy(QItem.item.id.desc())
                .offset(pageable.getOffset())
                .limit(pageable.getPageSize())
                .fetch();  // 쿼리를 실행하여 결과 리스트를 반환

        // 전체 레코드 수를 계산
        Long result = queryFactory.select(Wildcard.count).from(QItem.item)
                .where(regDtsAfter(itemSearchDTO.getSearchDateType()),
                        searchSellStatusEq(itemSearchDTO.getSearchSellStatus()),
                        searchByLike(itemSearchDTO.getSearchBy(), itemSearchDTO.getSearchQuery()))
                .fetchOne(); // 쿼리를 실행하여 단일 결과(전체 레코드 수)를 반환

        // NullPointerException을 방지
        long total = (result != null) ? result : 0L;

        return new PageImpl<>(content, pageable, total);
    }
  • queryFactory를 이용해서 쿼리를 생성
  • where 조건절에서 ',' 단위로 넣어줄 경우 and 조건으로 인식
  • PageImpl 클래스는 Page 인터페이스의 기본 구현체로, 페이징 처리된 결과를 담는 객체

      • pageable : 페이징 정보

      • total : 조건에 맞는 전체 아이템 수

      • content : 실제 조회된 아이템 리스트

 

Querydsl 조회 결과를 반환하는 메소드
메소드 기능
QueryResults<T> fetchResults() 조회 대상 리스트 및 전체 개수를 포함하는 QueryResults 반환
List<T> fetch() 조회 대상 리스트 반환
T fetchOne() 조회 대상이 1건이면 해당 타입 반환.
조회 대상이 1건 이상이면 에러 발생.
T fetchFirst() 조회 대상이 1건 또는 1건 이상이면 1건만 반환
long fetchCount() 해당 데이터 전체 개수 반환. count 쿼리 실행

 

public interface ItemRepository extends JpaRepository<Item, Long>,
        QuerydslPredicateExecutor<Item>, ItemRepositoryCustom {
        
        ...
}
    • ItemRepository 인터페이스에서 ItemRepositoryCustom 인터페이스를 상속
    • QuerydslPredicateExecutor는 Spring Data JPA에서 QueryDSL을 통합하여 제공하는 인터페이스

 

 

ItemService 클래스 코드 추가


  • 상품 조회 조건과 페이지 정보를 파라미터로 받아서 상품 데이터를 조회하는 getAdminItemPage() 메소드 추가
  • 데이터 수정이 일어나지 않으므로 @Transactional(readOnly=true) 어노테이션 설정
    @Override
    @Transactional(readOnly = true)
    public Page<Item> getAdminItemPage(ItemSearchDTO itemSearchDTO, Pageable pageable) {
        return itemRepository.getAdminItemPage(itemSearchDTO, pageable);
    }

 

 

ItemController 클래스 코드 추가


  • 상품 관리 화면 이동 및 조회한 상품 데이터를 화면에 전달하는 로직을 구현
    @GetMapping(value= {"/admin/items", "/admin/items/{page}"})
    public String itemManage(ItemSearchDTO itemSearchDTO,
                             @PathVariable("page")Optional<Integer> page, Model model) {

        Pageable pageable = PageRequest.of(page.isPresent() ? page.get() : 0, 10);
        Page<Item> items = itemService.getAdminItemPage(itemSearchDTO, pageable);
        model.addAttribute("items", items);
        model.addAttribute("itemSearchDTO", itemSearchDTO);
        model.addAttribute("maxPage", 10);

        return "item/itemMng";
    }

 

  • 페이징을 위해 PageRequest.of 메소드를 통해 Pageable 객체를 생성
  • 첫번째 파라미터로 조회할 페이지 번호, 두 번째 파라미터로 한 번에 가지고 올 데이터 수 입력
  • URL 경로에 페이지 번호가 있으면 해당 페이지를 조회하도록 세팅하고 페이지 번호가 없으면 0페이지를 조회하도록 함
  • 조회 조건과 페이징 정보를 파라미터로 넘겨 Page<Item> 객체를 반환 받음
  • 상품 관리 메뉴 하단에 보여줄 페이지 번호의 최대 개수를 10으로 설정

 

itemMng.html 코드 설정


1.  자바스크립트 코드

$(document).ready(function () {
    $("#searchBtn").on("click", function (e) {
        e.preventDefault(); // 폼 태그 전속 막아줌
        page(0); // 0번째 페이지 조회
    });
});

function page(page) {
// 이동할 페이지 값 받아서 현재 조회조건으로 설정된 키워드를 파라미터로 설정 후 상품데이터 조회

    var searchDateType = $("#searchDateType").val();
    var searchSellStatus = $("#searchSellStatus").val();
    var searchBy = $("#searchBy").val();
    var searchQuery = $("#searchQuery").val();

    location.href = "/admin/items/" + page + "?searchDateType=" + searchDateType
        + "&searchSellStatus=" + searchSellStatus
        + "&searchBy=" + searchBy
        + "&searchQuery=" + searchQuery;
}

 

 

2.  검색 기능 코드

    <!-- form 태그 -->
    <form th:action="@{'/admin/items/' + ${items.number}}"
          role="form" method="get" th:object="${items}">

        <!-- 헤더 부분-->
        <i class="bi bi-clipboard-check-fill fs-1 ms-2" style="color: #FFCFE2;"></i>
        <h2 class="card-title ms-2 mt-1 mb-4 fw-bold text-muted">Item List</h2>

        <!-- 검색 필터 폼 -->
        <div class="form-inline justify-content-center mb-4" th:object="${itemSearchDTO}">
            <select th:field="*{searchDateType}" class="form-control" style="width:auto;">
                <option value="all">Total</option>
                <option value="1d">1 day</option>
                <option value="1w">1 week</option>
                <option value="1m">1 month</option>
                <option value="6m">6 months</option>
            </select>

            <select th:field="*{searchSellStatus}" class="form-control" style="width:auto;">
                <option value="">SELL STATUS(ALL)</option>
                <option value="SELL">SELL</option>
                <option value="SOLD_OUT">SOLD OUT</option>
            </select>

            <select th:field="*{searchBy}" class="form-control" style="width:auto;">
                <option value="itemNm">Product Name</option>
                <option value="createdBy">Writer</option>
            </select>

            <input th:field="*{searchQuery}" type="text" class="form-control w-25"
                   placeholder="Please enter a search word">

            <button id="searchBtn" type="submit" class="btn btn-dark ms-2">Search</button>
        </div>

 

전체적인 동작 방식

   1. 사용자가 검색 조건(날짜, 판매 상태, 기준, 검색어)을 설정하고, 검색 버튼을 클릭

   2. 폼이 제출되면서, URL에 설정된 경로(@{'/admin/items/' + ${items.number}})로 GET 요청이 전송

   3. 서버는 ItemSearchDTO 객체에 바인딩된 폼 데이터를 받아 검색 조건에 맞는 아이템 리스트를 조회

   4. 조회된 결과는 페이징된 형태로 사용자에게 반환되며, 해당 페이지가 렌더링됨

 

 

3.  조회 상품 목록 코드

 <!-- 테이블 구조 -->
        <table class="table">
            <!-- 테이블 헤더 -->
            <thead>
            <tr>
                <th scope="col">Id</th>
                <th scope="col">Product Name</th>
                <th scope="col">Sell Status</th>
                <th scope="col">Writer</th>
                <th scope="col">Date</th>
            </tr>
            </thead>

            <!-- 테이블 바디 -->
            <tbody>
            <tr th:each="item, status: ${items.getContent()}">
                <th scope="col">[[${item.id}]]</th>
                <td>
                    <a th:href="'/admin/item/'+${item.id}" th:text="${item.itemNm}"></a>
                </td>
                <td th:text="${item.itemSellStatus.toString()}"></td>
                <td th:text="${item.createdBy}"></td>
                <td>[[${#temporals.format(item.regDate, 'yyyy-MM-dd')}]]</td>
            </tr>
            </tbody>
        </table>

 

전체적인 동작 방식

  1. 데이터 바인딩 : items.getContent()에서 가져온 데이터 리스트를 바탕으로 각 아이템의 정보를 테이블 행(tr)으로 출력

  2. 동적 데이터 렌더링 : 각 열(th, td)에 동적으로 데이터를 렌더링하여 아이템의 세부 정보를 표시

  3. 클릭 가능한 링크 : Product Name 열에 아이템 상세 페이지로 이동할 수 있는 링크를 제공

  4. 날짜 포맷팅 : 등록 날짜는 yyyy-MM-dd 형식으로 포맷팅되어 출력

 

 

4.  하단 페이지 번호 코드

 <!-- 페이지 번호의 시작점(start)과 끝점(end)을 계산하여 페이지 번호를 동적으로 생성 -->
        <div th:with="start=${(items.number/maxPage) * maxPage + 1},
            end=(${(items.totalPages == 0) ? 1 : (start + (maxPage - 1) < items.totalPages ?
                start + (maxPage - 1) : items.totalPages)})">

            <ul class="pagination justify-content-center">

                <!-- “Previous” 버튼을 생성 -->
                <li class="page-item" th:classappend="${items.first}?'disabled'">
                    <a th:onclick="'javascript:page(' + ${items.number - 1} + ')'"
                       aria-label='Previous' class="page-link">
                        <span aria-hidden='true'>Prev</span>
                    </a>
                </li>

                <!--동적 페이지 번호 생성-->
                <th:block th:each="i: ${#numbers.sequence(start, end)}">
                    <li th:class="${items.number == i} ? 'page-item active' : 'page-item'">
                        <a class="page-link" th:data-num="${i}">[[${i}]]</a>
                    </li>
                </th:block>

                <!-- “Next” 버튼 생성 -->
                <li class="page-item" th:classappend="${items.last}?'disabled'">
                    <a th:onclick="'javascript:page(' + ${items.number + 1} + ')'"
                       aria-label='Next' class="page-link">
                        <span aria-hidden='true'>Next</span>
                    </a>
                </li>

            </ul>
        </div>

 

start 현재 페이지 번호(items.number)를 기준으로 시작 페이지 번호를 계산. maxPage는 페이지네이션에서 보여줄 최대 페이지 수
end 표시할 마지막 페이지 번호를 계산. items.totalPages는 전체 페이지 수를 나타내며, 시작 번호에서 최대 maxPage만큼 더한 값이 전체 페이지 수를 넘지 않도록 조정.
th:classappend="${items.first}?'
disabled'
현재 페이지가 첫 번째 페이지인지(items.first) 확인하고, 첫 번째 페이지라면 "disabled" 클래스를 추가하여 버튼을 비활성화
th:onclick="'javascript:page(' + ${items.number - 1} + ')'" “Previous” 버튼을 클릭하면 이전 페이지 번호를 가진 page() 함수가 호출
th:each="i: ${#numbers.sequence(start, end)}" start부터 end까지의 숫자 시퀀스를 생성하여, 각 숫자에 대해 반복
th:class="${items.number == i} ? 'page-item active' : 'page-item'  현재 페이지 번호(items.number)와 반복 중인 페이지 번호(i)가 일치하면 active 클래스를 추가하여 현재 페이지를 강조
 th:classappend="${items.last}?'
disabled'
현재 페이지가 마지막 페이지인지(items.last) 확인하고, 마지막 페이지라면 "disabled" 클래스를 추가하여 버튼을 비활성화
 th:onclick="'javascript:page(' + ${items.number + 1} + ')'" “Next” 버튼을 클릭하면 다음 페이지 번호를 가진 page() 함수가 호출

 

 


 

ItemService 코드 추가


  • 등록된 상품을 불러오는 메소드 추가
    @Override
    @Transactional(readOnly = true)
    public ItemFormDTO getItemDtl(Long itemId) {

        // 해당 상품 이미지 조회
        List<ItemImg> itemImgList =
                itemImgRepository.findByItemIdOrderByIdAsc(itemId);

        // 조회한 ItemImg 엔티티를 ItemImgDTO 객체로 만들어 리스트 추가
        List<ItemImgDTO> itemImgDTOList = new ArrayList<>();
        for (ItemImg itemImg : itemImgList) {
            ItemImgDTO itemImgDTO = ItemImgDTO.of(itemImg);
            itemImgDTOList.add(itemImgDTO);
        }

        // 상품 엔티티 조회
        Item item = itemRepository.findById(itemId)
                .orElseThrow(EntityNotFoundException::new);
        ItemFormDTO itemFormDTO = ItemFormDTO.of(item);
        itemFormDTO.setItemImgDTOList(itemImgDTOList);

        return itemFormDTO;
    }

 

  • @Transactional(readOnly = true) : 읽기 전용으로 설정하면 JPA가 더티체킹(변경감지)을 수행하지 않아서 성능을 향상시킬 수 있다.

 

 

ItemController 코드 추가


  • 상품 수정 페이지 진입 위한 코드 추가 - 등록용 페이지와 수정용 페이지 나눠서 개발함.
    @GetMapping( "/admin/item/{itemId}")
    public String itemDtl(@PathVariable("itemId") Long itemId, Model model) {

        try {
            ItemFormDTO itemFormDTO = itemService.getItemDtl(itemId);
            model.addAttribute("itemFormDTO", itemFormDTO);
        } catch (EntityNotFoundException e) {
            model.addAttribute("errorMessage", "The product does not exist.");
            model.addAttribute("itemFormDTO", new ItemFormDTO());
            return "item/itemForm";
        }

        return "item/itemModify";
    }

 

 

ItemImgService 클래스 수정


    @Override
    public void updateItemImg(Long itemImgId, MultipartFile itemImgFile)
            throws Exception {

        if (!itemImgFile.isEmpty()) {
            ItemImg savedItemImg = itemImgRepository.findById(itemImgId)
                    .orElseThrow(EntityNotFoundException::new);

            // 기존 이미지 파일 삭제
            if(!StringUtils.isEmpty(savedItemImg.getImgName())) {
                fileService.deleteFile(itemImgLocation+"/"+savedItemImg.getImgName());
            }

            // 업데이트 파일 업로드
            String oriImgName = itemImgFile.getOriginalFilename();
            String imgName = fileService.uploadFile(itemImgLocation, oriImgName, itemImgFile.getBytes());
            String imgUrl = "/images/item/" + imgName;
            savedItemImg.updateItemImg(oriImgName, imgName, imgUrl);
        }

    }

 

  • savedItemImg 엔티티는 현재 영속 상태이므로 데이터를 변경하는 것만으로 변경 감지 기능이 동작하여 트랜잭션이 끝날 때 update 쿼리가 실행된다.

 

 

Item 클래스 코드 추가


  • 상품 데이터를 업데이트 하는 로직 추가
    public void updateItem(ItemFormDTO itemFormDTO) {

        this.itemNm = itemFormDTO.getItemNm();
        this.price = itemFormDTO.getPrice();
        this.stockNum = itemFormDTO.getStockNum();
        this.itemDetail = itemFormDTO.getItemDetail();
        this.itemSellStatus = itemFormDTO.getItemSellStatus();

    }

 

 

ItemService 업데이트 코드 추가


    @Override
    public Long updateItem(ItemFormDTO itemFormDTO, List<MultipartFile> itemImgFileList)
            throws Exception {

        // 상품 수정
        Item item = itemRepository.findById(itemFormDTO.getId())
                .orElseThrow(EntityNotFoundException::new);
        item.updateItem(itemFormDTO);

        List<Long> itemImgIds = itemFormDTO.getItemImgIds();

        // 이미지 등록
        for (int i=0; i<itemImgIds.size(); i++) {
            itemImgService.updateItemImg(itemImgIds.get(i), itemImgFileList.get(i));
        }

        return item.getId();
    }

 

  • 상품 등록 화면으로부터 전달 받은 상품 아이디를 이용하여 상품 엔티티 조회
  • 상품 등록 화면으로부터 전달 받은 ItemFormDTO를 통해 상품 엔티티를 업데이트
  • 상품 이미지 아이디 리스트 조회
  • 상품 이미지 업데이트 위해 updateItemImg() 메소드에 상품 이미지 아이디와 상품 이미지 파일 정보를 파라미터로 전달

 

ItemController 코드 추가


  • 상품 수정하는 URL을 ItemController 클래스에 추가
    @PostMapping("/admin/item/{itemId}")
    public String itemUpdate(@Valid ItemFormDTO itemFormDTO,
                             BindingResult bindingResult,
                             @RequestParam("itemImgFiles") List<MultipartFile> itemImgFileList,
                             Model model) {

        if (bindingResult.hasErrors()) {
            return "item/itemModify";
        }

        if (itemImgFileList.get(0).isEmpty() && itemFormDTO.getId() == null) {
            model.addAttribute("errorMessage", "The first product image is a required field.");
            return "item/itemModify";
        }

        try {
            itemService.updateItem(itemFormDTO, itemImgFileList);
        } catch (Exception e) {
            model.addAttribute("errorMessage", "An error occured during product registration.");
            return "item/itemForm";
        }

        return "redirect:/";

    }

 

 

 

 

* 내용 참고 - 책 '스프링 부트 쇼핑몰 프로젝트 with JPA'


ERD

 

 

상품이미지 엔티티 구현


  • ItemImg 클래스 생성
@Entity
@Table(name = "item_img")
@Data
public class ItemImg extends BaseEntity{

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "item_img_id")
    private Long id;

    @Column(name = "image_name", nullable = false)
    private String imgName;  // 이미지 파일명

    @Column(name = "original_name")
    private String oriImgName;  // 원본 이미지 파일명

    @Column(name = "image_url")
    private String imgUrl;  // 이미지 조회 경로

    @Column(name = "represent_img")
    private String repImgYn;  // 대표 이미지 여부

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "item_id")
    private Item item;

    public void updateItemImg(String oriImgName, String imgName, String imgUrl) {

        this.oriImgName = oriImgName;
        this.imgName = imgName;
        this.imgUrl = imgUrl;
    }
}

 

  ✔️  updateItemImg: 이미지의 원본 파일명, 이미지 파일명, 이미지 URL을 업데이트. ItemImg 객체의 해당 필드들을 수정하는 데 사용됨.

 

 

 

DTO 구현


  • 상품 등록시에는 화면에서 전달받은 DTO 객체를 엔티티 객체로 변환하는 작업을 해야 함
  • modelmapper 라이브러리 사용하여 DTO(Data Transfer Object)와 엔티티(Entity) 간의 변환을 효율적으로 처리

 

1.  상품 저장 후 상품 이미지에 대한 데이터를 전달할 DTO 클래스

@Data
public class ItemImgDTO {

    private Long id;

    private String imgName;  // 이미지 파일명

    private String oriImgName;  // 원본 이미지 파일명

    private String imgUrl;  // 이미지 조회 경로

    private String repImgYn;  // 대표 이미지 여부

    private static ModelMapper modelMapper = new ModelMapper();

    public static ItemImgDTO of(ItemImg itemImg) {
        return modelMapper.map(itemImg, ItemImgDTO.class);
    }

}

 

  ✔️  멤버 변수로 ModelMapper 객체 추가  ➡️  ItemImg 객체를 파라미터로 받아 ItemImgDTO 로 값을 복사해서 반환

  ✔️  of 메소드는 주로 컨트롤러에서 데이터 전송을 위해 ItemImgDTO 객체를 생성할 때 사용됨.

  ✔️  static으로 선언된 of 메소드는 ItemImgDTO 클래스의 인스턴스를 생성하지 않고도 호출할 수 있다

        ➡️  static으로 선언된 이유DTO 변환 로직이 인스턴스의 상태와 관계없이 동작하므로, static 메소드로 정의하여 클래스 수준에서 직접 호출할 수 있도록함. 이로 인해 매핑 로직을 재사용할 수 있고, 코드가 간결해짐.

 

 

 

2.  상품 데이터 정보를 전달하는 DTO

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ItemFormDTO {

    private Long id;

    @NotBlank(message = "☑️ Item Name is required. ")
    private String itemNm;

    @NotNull(message = "☑️ Price is required. ")
    @Positive(message = "Price must be a positive number.")
    private Integer price;

    @NotBlank(message = "☑️ Item Detail is required. ")
    private String itemDetail;

    @NotNull(message = "☑️ Stock Number is required. ")
    @Min(value = 0, message = "☑️ Stock Number must be zero or positive.")
    private Integer stockNum;

    private ItemSellStatus itemSellStatus;

    // 상품 저장 후 수정할 때 상품 이미지 정보를 저장하는 리스트
    private List<ItemImgDTO> itemImgDTOList = new ArrayList<>();

    // 수정 시에 아이디를 담아둘 용도
    private List<Long> itemImgIds = new ArrayList<>();

    private static ModelMapper modelMapper = new ModelMapper();

    // ItemFormDTO의 필드를 Item 엔티티로 매핑
    public Item createItem() {
        return modelMapper.map(this, Item.class);
    }

    // Item 엔티티를 ItemFormDTO로 매핑하는 정적 메서드
    public static ItemFormDTO of(Item item) {
        return modelMapper.map(item, ItemFormDTO.class);
    }
}

 

  ✔️  문자열이아닌 Integer에서는 @Length가 아닌 @Min, @Max를 사용

  ✔️  createItem(): ItemFormDTO 객체의 데이터를 Item 엔티티 객체로 변환. 주로 상품을 데이터베이스에 저장하기 위해 사용

  ✔️  of(Item item): Item 엔티티 객체를 ItemFormDTO로 변환하는 정적 메소드. 주로 엔티티를 DTO로 변환하여 사용자에게 반환할 때 사용

 

 

기존 ItemController 페이지 수정


@Controller
@RequiredArgsConstructor
@Data
public class ItemController {

    @GetMapping("/admin/item/new")
    public String itemForm(Model model){
        model.addAttribute("itemFormDTO", new ItemFormDTO());
        return "item/itemForm";
    }
}

 

  • 상품 등록과 같은 관리자 페이지에서 중요한 것은 데이터의 무결성을 보장해야 한다는 것!
  • 데이터가 의도와 다르게 저장되거나 잘못된 값이 저장되지 않도록 validation을 해야 함.
  • 특히 데이터끼리 서로 연관이 있으면 어떤 데이터가 변함에 따라 다른 데이터도 함께 체크를 해야하는 경우 빈번.

 

 

상품 등록 페이지 구현


  • ItemForm.html 생성 (코드가 길기 때문에 깃허브 소스 첨부)

https://github.com/SominY/Dora-Flower-Shop/blob/main/src/main/resources/templates/item/itemForm.html

 

Dora-Flower-Shop/src/main/resources/templates/item/itemForm.html at main · SominY/Dora-Flower-Shop

쇼핑몰 프로젝트 (개인 프로젝트). Contribute to SominY/Dora-Flower-Shop development by creating an account on GitHub.

github.com

 

 

* 앞에 memberForm 페이지 참고해서 부트스트랩 코드 작성함..! 아쉬운 점은 부트스트랩 파일 업로드 형식이 너무 제한적.. ㅠ 저런 디자인 말고 다른거 하고 싶은데 버전별로 다른듯..😣

 

        • 폼 전송할 때 enctype(인코딩 타입) 값으로 'multipart/form-data' 입력 - 웹 폼에서 파일을 업로드할 때 사용되는 인코딩 방식. 이 방식은 데이터와 파일을 서버로 전송할 때 각각의 데이터를 구분하고, 파일을 포함한 복합적인 데이터 전송을 가능하게 함

 

상품 등록 시

   

    - itemImgDTOList가 비어 있는 경우(새 상품 등록 시) 1~5개의 이미지 업로드 필드를 동적으로 생성.

    - itemFormDTO.id가 비어 있을 경우, Register 버튼을 표시

 

상품 수정 시

   

    - itemImgDTOList가 비어 있지 않은 경우(기존 상품 수정 시) 기존 이미지들을 수정할 수 있도록 이미지 업로드 필드를 생성.

    - itemImgIds는 숨겨진 필드로 기존 이미지 ID를 전달.

    - itemFormDTO.id가 존재할 경우, Update 버튼을 표시

 

#lists.isEmpty(...)

 

  • #lists는 Thymeleaf의 내장 객체로, 리스트와 관련된 유틸리티 메소드를 제공

  • isEmpty(...)#lists 객체의 메소드로, 리스트가 비어 있는지를 확인하는 기능

 

#strings.isEmpty(...)

 

  • 특정 문자열이 빈 문자열("")인지, 또는 null인지 여부를 검사

 

#numbers.sequence(start, end)

 

  • 지정된 시작 값과 끝 값 사이의 정수 시퀀스를 생성하여 반복할 수 있게 함

  • Thymeleaf 템플릿에서 주로 반복문을 생성할 때 유용

 

th:formaction

 

  • 폼의 제출 URL을 동적으로 설정

  • 등록과 수정 작업에 따라 서로 다른 URL로 폼을 제출할 수 있도록 함

 

 

자바스크립트 코드

⚡️ 외부로 파일 빼고 싶으면 부트스트랩 jquery 코드 html에 넣어줘야함!!!!!

더보기

1. $(".custom-file-input").on("change", function () { ... });

  • custom-file-input 클래스를 가진 HTML 요소에 change 이벤트 리스너를 추가. 사용자가 파일 선택 필드에서 파일을 선택할 때마다 이 함수가 실행.

 

2. var fileName = $(this).val().split("\\").pop();

  • 사용자가 선택한 파일의 이름을 가져옴

  • 작동 방식: $(this).val() - 현재 변경된 input 요소의 값을 가져옴. 이 값은 선택된 파일의 경로.

                     split("\\") - 경로를 역슬래시(\)를 기준으로 분리하여 파일 이름을 포함한 배열을 생성.

                     pop() - 배열의 마지막 요소, 즉 파일 이름을 가져옴.

 

3. var fileExt = fileName.substring(fileName.lastIndexOf(".") + 1);

  • 파일의 확장자를 추출

  • 작동 방식 : fileName.lastIndexOf(".") + 1 - 파일 이름에서 마지막 점(.)의 위치를 찾고, 이 위치 다음 문자부터 문자열의 끝까지를 가져옴.

                     substring(...) - 파일 확장자를 추출

 

4. fileExt = fileExt.toLowerCase();

  • 파일 확장자를 소문자로 변환하여 대소문자 구분 없이 파일 형식을 검사할 수 있도록 함

 

5. if (fileExt != "jpg" && fileExt != "jpeg" && fileExt != "gif" && fileExt != "png" && fileExt != "bmp") { ... }

  • 파일 확장자가 허용된 이미지 파일 형식인지 확인

  • 허용되지 않는 파일 형식인 경우 사용자에게 알림을 띄우고 함수 실행을 종료 (return)

 

6. $(this).siblings(".custom-file-label").html(fileName);

  • 선택한 파일의 이름을 .custom-file-label 클래스를 가진 형제 요소에 표시

  • 작동 방식 : $(this).siblings(".custom-file-label") - 현재 파일 입력 요소의 형제 요소 중 .custom-file-label 클래스를 가진 요소를 선택.

                      .html(fileName) - 선택된 파일 이름을 해당 요소의 HTML로 설정

 

 

application.properties 설정 추가


# 파일 한 개당 최대 사이즈
spring.servlet.multipart.max-file-size=20MB
# 요청당 최대 파일 크기
spring.servlet.multipart.max-request-size=100MB
# 상품 이미지 업로드 경로
itemImgLocation=/Users/Desktop/e-commerce project/item
# 리소스 업로드 경로
uploadPath=file:/Users/Desktop/e-commerce project

 

 

 

WebMvcConfigurer 인터페이스 구현


@Configuration
public class WebMvcConfig implements WebMvcConfigurer {

    @Value("${uploadPath}")
    String uploadPath;

    @Override
    public void addResourceHandlers(ResourceHandlerRegistry registry) {
        registry.addResourceHandler("/images/**")
                .addResourceLocations(uploadPath);
    }

}
  • addResourceHandlers(ResourceHandlerRegistry registry) : Spring MVC의 리소스 핸들러를 추가하는 메소드. 이 메소드를 통해 리소스(예: 이미지, CSS, JS 파일)의 요청을 처리하는 방법을 설정.
  • registry.addResourceHandler("/images/**") : /images/** 패턴의 URL 요청을 리소스 핸들러로 등록. 즉, 클라이언트가 /images/로 시작하는 URL을 요청할 때, 이 핸들러가 요청을 처리함
  • addResourceLocations(uploadPath) : uploadPath에 설정된 경로에서 리소스를 제공하도록 지정. 이 경로는 파일 시스템의 위치를 나타내며, 이 경로에서 요청된 파일을 제공하게 됨. 예를 들어, /images/example.jpg 요청이 들어오면 uploadPath로 지정된 경로에서 example.jpg 파일을 찾아서 반환.

 

https://jake-seo-dev.tistory.com/605

 

스프링 WebMvcConfigurer 인터페이스란?

WebMvcConfigurer 란? 스프링 프레임워크에서 제공하는 인터페이스이다. 보일러플레이트 코드 없이 요구사항에 맞게 프레임워크를 조정할 수 있게 해준다. 특정한 스프링 클래스를 구현하거나 상속

jake-seo-dev.tistory.com

 

 

 

파일을 처리하는 Service 구현


public String uploadFile(String uploadPath, String originalFileName, 
    byte[] fileData) throws Exception {
    
    // 서로 다른 개체들을 구별하기 위해서 이름을 부여할 때 사용
    UUID uuid = UUID.randomUUID();
    
    String extension = originalFileName.substring(originalFileName.lastIndexOf("."));
    String savedFileName = uuid + extension;
    String fileUploadFullUrl = uploadPath + "/" + savedFileName;

    FileOutputStream fos = new FileOutputStream(fileUploadFullUrl);
    fos.write(fileData);
    fos.close();

    return savedFileName;
}

 

 

UUID 생성 : UUID.randomUUID()를 사용하여 파일 이름 충돌을 방지. UUID는 파일 이름의 일부분으로 사용되며, 파일의 유일성을 보장.

파일 확장자 추출 : originalFileName에서 파일 확장자를 추출. 예를 들어, .jpg, .png와 같은 확장자를 추출.

파일 이름 생성 : UUID와 파일 확장자를 결합하여 저장할 파일 이름을 생성.

파일 경로 구성 : uploadPathsavedFileName을 결합하여 파일의 전체 경로를 생성.

파일 저장

    -  FileOutputStream 객체를 생성하여 지정된 파일 경로 (fileUploadFullUrl)로 데이터를 쓸 수 있는 출력 스트림을 준비

    -  바이트 배열 fileData를 파일에 기록. fileData는 파일의 실제 데이터이며, 이 데이터가 FileOutputStream을 통해 파일에 기록

    -  스트림을 닫아 파일 출력을 완료하고 자원을 해제

예외 처리 : throws Exception을 선언하여 예외 발생 시 호출자에게 전달. 예를 들어, 파일 저장 중 IO 오류가 발생할 수 있음.

 

UUID는 'Universally Unique Identifier'의 약자로 128-bit의 고유 식별자. 다른 고유 ID 생성 방법과 다르게 UUID는 중앙 시스템에 등록하고 발급하는 과정이 없어서 상대적으로 더 빠르고 간단하게 만들 수 있다는 장점이 있다.

 

https://docs.tosspayments.com/resources/glossary/uuid

 

UUID(Universally Unique Identifier) | 토스페이먼츠 개발자센터

UUID는 128-bit의 고유 식별자에요. UUID는 중앙 시스템에 등록하고 발급하는 과정이 없어서 상대적으로 빠르고 간단하게 만들 수 있어요.

docs.tosspayments.com

 

 

    public void deleteFile(String filePath) throws Exception{

        File deleteFile = new File(filePath);

        if(deleteFile.exists()) {
            deleteFile.delete();
            log.info("파일을 삭제하였습니다.");
        } else {
            log.info("파일이 존재하지 않습니다.");
        }

    }

 

File 클래스 : java.io.File 클래스는 파일 및 디렉토리에 대한 경로를 나타내며, 파일 시스템에서 파일이나 디렉토리에 접근하고 조작하는 기능을 제공

 

 

ItemImgRepository 구현


public interface ItemImgRepository extends JpaRepository<ItemImg, Long> {

}

 

 

 

ItemImgService, ItemImgServiceImpl 구현


public interface ItemImgService {

    void saveItemImg(ItemImg itemImg, MultipartFile multipartFile)
            throws Exception;
}
@Service
@Transactional
@RequiredArgsConstructor
public class ItemImgServiceImpl implements ItemImgService {

    @Value("${itemImgLocation}")
    private String itemImgLocation;

    private final ItemImgRepository itemImgRepository;

    private final FileService fileService;

    @Override
    public void saveItemImg(ItemImg itemImg, MultipartFile itemImgFile) throws Exception {

        String oriImgName = itemImgFile.getOriginalFilename();
        String imgName = "";
        String imgUrl = "";

        // 파일 업로드
        if(!StringUtils.isEmpty(oriImgName)) {
            imgName = fileService.uploadFile(itemImgLocation, oriImgName,
                    itemImgFile.getBytes());
            imgUrl = "/images/item/" + imgName;
        }

        // 상품 이미지 정보 저장
        itemImg.updateItemImg(oriImgName, imgName, imgUrl);
        itemImgRepository.save(itemImg);

    }
}

 

 

더보기

MultipartFile 

 

Spring Framework에서 파일 업로드를 처리하기 위해 사용되는 인터페이스. 주로 웹 애플리케이션에서 클라이언트가 서버로 파일을 전송할 때 사용. 이 인터페이스는 파일 업로드와 관련된 다양한 기능을 제공하며, 파일의 메타데이터와 내용을 쉽게 처리할 수 있게 도와줌.

 

주요 기능

 

  1. 파일 정보 접근

    • getName() : 파일 필드의 이름을 반환

    • getOriginalFilename() : 클라이언트가 업로드한 원본 파일 이름을 반환

    • getContentType() : 파일의 MIME 타입을 반환 (예: image/png, text/plain 등)

 

  2. 파일 내용 처리

    • getBytes() : 파일의 내용을 바이트 배열로 반환

    • getInputStream() : 파일 내용을 InputStream 형태로 반환. 이를 통해 파일 내용을 직접 스트리밍하거나 처리할 수 있다.

 

  3. 파일 저장

    • transferTo(File dest): 파일을 지정된 경로에 저장. 이 메소드는 파일을 실제 파일 시스템에 저장하는 데 사용.

 

 

조건 확인 : oriImgName이 비어있지 않은 경우에만 파일 업로드를 수행

파일 업로드 : fileService.uploadFile() 메소드를 호출하여 파일을 서버에 업로드. 이 메소드의 결과로 저장된 파일 이름(imgName)을 반환받는다.

이미지 URL 생성 : 파일의 접근 경로를 기반으로 URL을 생성. 이 URL은 클라이언트가 이미지에 접근하는 데 사용.

상품 이미지 정보 업데이트 : ItemImg 객체의 updateItemImg 메소드를 호출하여 이미지의 원본 이름, 저장된 파일 이름, URL을 업데이트.

데이터베이스 저장 : itemImgRepository.save(itemImg)를 호출하여 ItemImg 객체를 데이터베이스에 저장.

 

 

 

ItemService, ItemServiceImpl 구현


public interface ItemService {

    Long saveItem(ItemFormDTO itemFormDTO, List<MultipartFile> itemImgFileList)
        throws Exception;
}
@Service
@Transactional
@RequiredArgsConstructor
public class ItemServiceImpl implements ItemService {

    private final ItemRepository itemRepository;

    private final ItemImgService itemImgService;

    private final ItemImgRepository itemImgRepository;


    @Override
    public Long saveItem(ItemFormDTO itemFormDTO, List<MultipartFile> itemImgFileList) throws Exception {

        //상품 등록
        Item item = itemFormDTO.createItem();
        itemRepository.save(item);

        //이미지 등록
        for(int i=0; i<itemImgFileList.size(); i++){
            ItemImg itemImg = new ItemImg();
            itemImg.setItem(item);

            if(i == 0)
                itemImg.setRepImgYn("Y");
            else
                itemImg.setRepImgYn("N");

            itemImgService.saveItemImg(itemImg, itemImgFileList.get(i));
        }

        return item.getId();

    }
}

 

          • itemRepository.save(item)를 호출하여 Item 객체를 데이터베이스에 저장
          • itemImgFileList에 있는 모든 이미지 파일에 대해 반복.
          • ItemImg객체 생성 후itemImg.setItem(item)을 호출하여 현재 Item 객체와 ItemImg 객체를 연결
          • 첫 번째 이미지 파일 (i == 0)은 대표 이미지로 설정 (itemImg.setRepImgYn("Y"))
          • itemImgService.saveItemImg(itemImg, itemImgFileList.get(i))를 호출하여 ItemImg 객체와 이미지 파일을 저장
          • 저장된 Item 객체의 ID를 반환
List<MultipartFile> itemImgFileList클라이언트에서 전송된 파일들로 구성된 리스트로, MultipartFile 객체는 Spring MVC가 파일 업로드 요청을 처리할 때 자동으로 생성. 이 리스트는 컨트롤러 메소드에서 @RequestParam을 사용하여 수신되며, 이후 서비스 계층에서 파일을 저장하거나 처리하는 데 사용.

 

 

 

ItemController 추가 코드


    private final ItemService itemService;
    
    @PostMapping("/admin/item/new")
    public String itemNew(@Valid ItemFormDTO itemFormDTO, BindingResult bindingResult,
                          Model model, @RequestParam("itemImgFile") List<MultipartFile> itemImgFileList){

        if(bindingResult.hasErrors()){
            return "item/itemForm";
        }

        if(itemImgFileList.get(0).isEmpty() && itemFormDTO.getId() == null){
            model.addAttribute("errorMessage", "첫번째 상품 이미지는 필수 입력 값 입니다.");
            return "item/itemForm";
        }

        try {
            itemService.saveItem(itemFormDTO, itemImgFileList);
        } catch (Exception e){
            model.addAttribute("errorMessage", "상품 등록 중 에러가 발생하였습니다.");
            return "item/itemForm";
        }

        return "redirect:/";
    }

 

 

 

테스트코드 작성


  • ItemImgRepository 인터페이스에 findByItemOrderByIdAsc 메소드 추가
public interface ItemImgRepository extends JpaRepository<ItemImg, Long> {

    List<ItemImg> findByItemIdOrderByIdAsc(Long itemId);
}
@SpringBootTest
@Transactional
@TestPropertySource(locations = "classpath:application-test.properties")
class ItemServiceImplTest {

    @Autowired
    ItemService itemService;

    @Autowired
    ItemRepository itemRepository;

    @Autowired
    ItemImgRepository itemImgRepository;

    List<MultipartFile> createMultipartFiles() throws Exception {

        List<MultipartFile> multipartFileList = new ArrayList<>();

        for (int i=0; i<5; i++) {

            String path = "/Users/Desktop/e-commerce project/item";
            String imageName = "image" + i + ".jpg";

            MockMultipartFile multipartFile =
                    new MockMultipartFile(path, imageName, "image/jpg", new byte[]{1,2,3,4});
            multipartFileList.add(multipartFile);
        }

        return multipartFileList;
    }

    @Test
    @DisplayName("상품 등록 테스트")
    @WithMockUser(username = "admin", roles = "ADMIN")
    void saveItem() throws Exception {

        ItemFormDTO itemFormDTO = ItemFormDTO.builder()
                .itemNm("테스트상품")
                .itemSellStatus(ItemSellStatus.SELL)
                .itemDetail("테스트 상품입니다.")
                .price(1000)
                .stockNum(100)
                .build();

        List<MultipartFile> multipartFileList = createMultipartFiles();
        Long itemId = itemService.saveItem(itemFormDTO, multipartFileList);

        List<ItemImg> itemImgList =
                itemImgRepository.findByItemIdOrderByIdAsc(itemId);
        Item item = itemRepository.findById(itemId)
                .orElseThrow(EntityNotFoundException::new);

        assertEquals(itemFormDTO.getItemNm(), item.getItemNm());
        assertEquals(itemFormDTO.getItemSellStatus(), item.getItemSellStatus());
        assertEquals(itemFormDTO.getItemDetail(), item.getItemDetail());
        assertEquals(itemFormDTO.getPrice(), item.getPrice());
        assertEquals(itemFormDTO.getStockNum(), item.getStockNum());
        assertEquals(multipartFileList.get(0).getOriginalFilename(),
                itemImgList.get(0).getOriImgName());

    }

}

 

  • MockMultipartFile : Spring의 MockMultipartFile 클래스는 테스트 중 파일 업로드를 시뮬레이션하는 데 사용된다. 실제 파일이 아닌 가상의 파일을 생성할 수 있음. path, imageName, contentType, content를 매개변수로 받아 파일의 메타데이터와 내용을 설정함.

https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/mock/web/MockMultipartFile.html

 

MockMultipartFile (Spring Framework 6.1.11 API)

getContentType Return the content type of the file. Specified by: getContentType in interface MultipartFile Returns: the content type, or null if not defined (or no file has been chosen in the multipart form)

docs.spring.io

 

 

 

 

 

 

내용 참고 : 책 "스프링 부트 쇼핑몰 프로젝트 with JPA"

 


 

 

ERD

⚡️  노션에 정리한 내용

https://www.notion.so/9-JPA-Hibernate-Mappings-286fc7eaad36490ba96cfe3fab99aba5?pvs=4 

 

일대일 단방향 매핑  @OneToOne


  • 회원(Member) 엔티티와 장바구니(Cart) 엔티티 매핑 설정
@Entity
@Table(name = "cart")
@Data
public class Cart {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "cart_id")
    private Long id;

    @OneToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "member_id")
    private Member member;

}

 

  • 회원 엔티티와 일대일로 매핑
  • @JoinColumn 어노테이션을 사용해 매핑할 외래키 지정
  • 회원 엔티티에는 장바구니 엔티티와 관련된 소스 x
  • 장바구니 엔티티가 일방적으로 회원 엔티티 참조 (= 일대일 단방향 매핑)

 

테스트 코드
  • CartRepository 만든 후 테스트 코드 작성
@SpringBootTest
@Transactional
@TestPropertySource(locations = "classpath:application-test.properties")
class CartTest {

    @Autowired
    CartRepository cartRepository;

    @Autowired
    MemberRepository memberRepository;

    @Autowired
    PasswordEncoder passwordEncoder;

    @PersistenceContext
    EntityManager em;

    // 회원 엔티티 생성
    public Member createMember() {

        MemberFormDTO memberFormDTO = MemberFormDTO.builder()
                .name("test")
                .email("test@test.com")
                .password("1234")
                .address("123 st")
                .build();

        return Member.createMember(memberFormDTO, passwordEncoder);
    }

    @Test
    @DisplayName("장바구니 회원 엔티티 매핑 조회 테스트")
    public void findCartAndMemberTest() {

        // 1. Member 객체 생성 및 저장
        Member member = createMember();
        memberRepository.save(member);

        // 2. Cart 객체 생성 및 설정
        Cart cart = new Cart();
        cart.setMember(member);
        cartRepository.save(cart);

        // 3. 영속성 컨텍스트 플러시 및 초기화
        em.flush();
        em.clear();

        // 4. Cart 조회 및 검증
        Cart savedCart = cartRepository.findById(cart.getId())
                .orElseThrow(EntityNotFoundException::new);
        assertEquals(savedCart.getMember().getMemberId(), member.getMemberId());
    }
}
  • JPA는 영속성 컨텍스트에 데이터를 저장 후 트랜잭션이 끝날 때 flush() 호출하여 DB에 반영
  • em.flush()는 현재 영속성 컨텍스트의 변경 사항을 데이터베이스에 반영
  • em.clear()는 영속성 컨텍스트를 초기화하여, 현재 영속성 컨텍스트에 저장된 모든 엔티티를 비움. 이를 통해 새롭게 조회할 때 데이터베이스에서 직접 정보를 가져올 수 있다.
  • 조회된 Cart가 없으면 EntityNotFoundException을 발생

 

💡 여기서 드는 의문! 꼭 Entity Manager를 사용해야 할까?  ➡️  repository만 사용해서 테스트 할 수 있음!

     단, EntityManager를 사용하는 것은 엔티티의 영속성 컨텍스트를 직접 제어하고, 데이터베이스와의 동기화를 정확히 맞추는 데 유리함. 따라서 기본적인 테스트에는 레포지토리만 사용해도 되지만, 더 정밀한 테스트나 상태 관리를 위해 EntityManager를 사용할 수도 있다.

 

    ⚡️   테스트에서 EntityManager의 사용하는 이유

        1.  영속성 컨텍스트 초기화 : 테스트가 완료된 후, EntityManager의 clear() 메서드를 호출하여 영속성 컨텍스트를 초기화. 이는 테스트가 독립적으로 실행될 수 있도록 보장함.

        2.  최신 상태 조회 : EntityManager를 사용하여 영속성 컨텍스트의 캐시를 지우고, 데이터베이스에서 최신 상태를 직접 조회할 수 있다. 이는 테스트가 데이터베이스의 실제 상태를 반영하도록 함.

        3.  데이터 일관성 보장 : EntityManager를 통해 데이터베이스와 엔티티의 상태를 동기화함으로써 테스트의 정확성을 높임.

 

 

다대일 단방향 매핑  @ManyToOne


  • 장바구니에는 여러 개의 상품을 담을 수 있고, 같은 상품을 여러 개 주문 할 수도 있음!
@Entity
@Table(name = "cart_item")
@Data
public class CartItem {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "cart_item_id")
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "cart_id")
    private Cart cart;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "item_id")
    private Item item;

    private int count;
}

 

 

 

다대일/일대다 양방향 매핑


  • 주문과 주문 상품을 양방향 매핑으로 설정
public enum OrderStatus {
    ORDER, CANCEL
}
@Entity
@Table(name = "orders")
@Data
public class Order extends BaseEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "order_id")
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "member_id")
    private Member member;

    private LocalDateTime orderDate; // 주문일

    @Enumerated(EnumType.STRING)
    private OrderStatus orderStatus; // 주문상태

    @OneToMany(mappedBy = "order",
            cascade = CascadeType.ALL,
            orphanRemoval = true,
            fetch = FetchType.LAZY)
    private List<OrderItem> orderItems = new ArrayList<>();

}
@Entity
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class OrderItem extends BaseEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "order_item_id")
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "item_id")
    private Item item;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "order_id")
    private Order order;

    private int orderPrice; // 주문가격

    private int count; // 수량
}

 

  • 양방향 매핑에서는 연관 관계의 주인을 설정해야 함.
  • ORDER_ID를 외래키로 조인하면 주문에 속한 상품이 어떤 상품들이 있는지 알 수 있고, 주문 상품은 어떤 주문에 속하는지 알 수 있다. 즉, 테이블은 외래키 하나로 양방향 조회가 가능!
  • mappedBy 값으로 order를 적어준 이유는 OrderItem 에 있는 Order에 의해 관리된다는 의미
✓  연관 관계의 주인은 외래키가 있는 곳으로 설정
✓  연관 관계의 주인이 외래키를 관리(등록,수정,삭제)
✓  주인이 아닌 쪽은 연관 관계 매핑시 mappedBy 속성 값으로 연관 관계의 주인을 설정
✓  주인이 아닌 쪽은 읽기만 가능

 

 

 

영속성 전이 테스트


  • OrderRepository 생성 후 주문 엔티티에서 @OneToMany 어노테이션에 cascade 옵션 설정
  • 테스트 코드 설정
@SpringBootTest
@Transactional
@TestPropertySource(locations = "classpath:application-test.properties")
class OrderRepositoryTest {

    @Autowired
    OrderRepository orderRepository;

    @Autowired
    ItemRepository itemRepository;

    @PersistenceContext
    EntityManager em;

    // 주문할 상품 생성
    public Item createItem() {

        Item item = Item.builder()
                .itemNm("test")
                .price(10000)
                .stockNum(100)
                .itemDetail("detail")
                .itemSellStatus(ItemSellStatus.SELL)
                .build();

        return item;
    }

    @Test
    @DisplayName("영속성 전이 테스트")
    public void cascadeTest() {

        // Order 객체를 생성
        Order order = new Order();

        for (int i=0; i < 3; i++) {
            // 아이템 생성 및 저장
            Item item = this.createItem();
            itemRepository.save(item);

            // 주문 아이템 생성
            OrderItem orderItem = OrderItem.builder()
                    .item(item)
                    .order(order)
                    .orderPrice(1000)
                    .count(10)
                    .build();
            order.getOrderItems().add(orderItem);
        }

        // 주문 저장 및 플러시
        orderRepository.saveAndFlush(order);
        // 엔티티 매니저 초기화
        em.clear();

        // 저장된 주문 조회
        Order savedOrder = orderRepository.findById(order.getId())
                .orElseThrow(EntityNotFoundException::new);

        // savedOrder의 OrderItem 수가 3개인지 검증
        assertEquals(3, savedOrder.getOrderItems().size());
    }
}

 

 

 

고아 객체 제거


  • 부모 엔티티와 연관 관계가 끊어진 자식 엔티티를 고아 객체라고 함.
  • 영속성 전이 기능과 같이 사용하면 부모 엔티티를 통해 자식의 생명 주기를 관리할 수 있다.
  • 참조하는 곳이 하나일 때만 사용가능
  • @OneToOne, @OneToMany 어노테이션에서 옵션으로 사용 "orphanRemoval = true"
  • 주문 엔티티에서 주문 상품을 삭제했을 때 orderItem 엔티티가 삭제되는지 테스트

 

orphanRemoval의 동작 원리

 

자동 삭제: orphanRemoval = true를 설정하면, 부모 엔티티에서 자식 엔티티를 제거하면 자식 엔티티가 데이터베이스에서도 자동으로 삭제.

영속성 컨텍스트: 이 설정은 영속성 컨텍스트의 상태와 데이터베이스의 상태를 동기화하는 데 유용. 자식 엔티티가 부모 엔티티와의 관계에서 제거될 때, 해당 자식 엔티티를 삭제하여 데이터베이스에 불필요한 데이터가 남지 않도록 함.

 

orphanRemoval 사용 시 주의 사항

  1. 무결성: 자식 엔티티가 다른 엔티티와 관계가 있을 때, orphanRemoval이 무결성을 위협할 수 있다. 예를 들어, 자식 엔티티가 다른 부모와 연결되어 있거나 다른 엔티티에 의해 참조되는 경우에는 주의가 필요.

  2. 성능: orphanRemoval을 사용할 때, 자식 엔티티가 많을 경우 성능에 영향을 미칠 수 있다. 특히 대규모 데이터를 다룰 때 성능을 고려해야 함.

  3. 양방향 관계: 양방향 관계에서 orphanRemoval을 사용할 때는 자식 엔티티가 두 부모 중 하나에서만 관리되도록 주의해야 함. 이 설정을 잘못 사용할 경우, 데이터베이스 상태와 애플리케이션 상태 간의 불일치가 발생할 수 있다.

 

    @Autowired
    MemberRepository memberRepository;
  
    // 주문 데이터를 생성해서 저장
    public Order createOrder() {
        Order order = new Order();

        for(int i=0; i<3; i++) {
            Item item = this.createItem();
            itemRepository.save(item);
            OrderItem orderItem = OrderItem.builder()
                    .item(item)
                    .order(order)
                    .orderPrice(1000)
                    .count(10)
                    .build();

            order.getOrderItems().add(orderItem);
        }

        Member member = new Member();
        memberRepository.save(member);

        order.setMember(member);
        orderRepository.save(order);
        return order;
    }

    @Test
    @DisplayName("고아객체 제거 테스트")
    public void orphanRemovalTest() {

        Order order = this.createOrder();
        order.getOrderItems().remove(0);
        em.flush();
    }

 

https://velog.io/@yuseogi0218/JPA-CascadeType.REMOVE-vs-orphanRemoval-true

 

JPA - CascadeType.REMOVE vs orphanRemoval = true

CascadeType.REMOVE 와 orphanRemoval = true 옵션이 각각 고아객체를 어떻게 처리하는지 알아보았습니다.

velog.io

 

 

 

엔티티 공통 속성 공동화


  • 보통 등록시간(regDate), 수정시간(modDate) 멤버변수가 공통으로 들어가는 경우가 빈번.
  • Spring Data Jpa 에서는 Auditing 기능을 제공하여 엔티티가 저장 또는 수정될 때 자동으로 등록일, 수정일, 등록자, 수정자를 입력해줌.
  • Audit 의 사전적 정의는 '감시하다' 이다. 즉, 엔티티의 생성과 수정을 감시하고 있는 것.

 

1.  AuditorAware 인터페이스 구현

public class AuditorAwareImpl implements AuditorAware<String> {

    @Override
    public Optional<String> getCurrentAuditor() {
    
        // 현재 SecurityContext에서 인증 객체를 가져옴
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        String userId = "";
        if(authentication != null){
            userId = authentication.getName();
        }
        return Optional.of(userId);
    }

}

 

    • getCurrentAuditor() 메서드는 현재 인증된 사용자의 정보를 반환. JPA의 감시 어노테이션(@CreatedBy, @LastModifiedBy)이 이 메서드를 호출하여 엔티티의 생성자나 수정자를 자동으로 설정하는 데 사용 
    • SecurityContextHolder는 현재 스레드에서 인증 정보를 관리
    • Optionalnull 값을 처리하는 안전한 방법을 제공

 

2.  Auditing 기능을 사용하기 위해 Config 파일 생성

@Configuration
@EnableJpaAuditing // JPA의 Auditing 기능을 활성화함
public class AuditConfig {

    @Bean
    public AuditorAware<String> auditorProvider() {

        return new AuditorAwareImpl();
    }
}
  • AuditConfig 클래스는 JPA 감시 기능을 활성화하고, 현재 감시자의 정보를 제공하는 AuditorAware 빈을 설정하는 역할

 

3.  BaseTimeEntity / BaseEntity

  • 등록자, 수정자를 넣지 않아도 되는 테이블은 BaseTimeEntity만 상속받고 모두 필요한 경우는 BaseEntity를 상속받도록 설정함.
  • Member 엔티티에 Auditing 기능을 적용하기 위해 BaseEntity 클래스를 상속받도록 함.
@EntityListeners(value = {AuditingEntityListener.class})
@MappedSuperclass
@Getter
@Setter
public abstract class BaseTimeEntity {

    @CreatedDate
    @Column(name = "register_date", updatable = false)
    private LocalDateTime regDate;  // 등록시간

    @LastModifiedDate
    @Column(name = "modify_date")
    private LocalDateTime modDate;  // 수정시간

}
@MappedSuperclass
@EntityListeners(value = {AuditingEntityListener.class})
@Getter
public class BaseEntity extends BaseTimeEntity {

    @CreatedBy
    @Column(updatable = false)
    private String createdBy; // 등록자

    @LastModifiedBy
    private String modifiedBy; // 수정자

}

 

 

📍  테스트 코드

@SpringBootTest
@Transactional
@TestPropertySource(locations = "classpath:application-test.properties")
class MemberTest {

    @Autowired
    MemberRepository memberRepository;

    @PersistenceContext
    EntityManager em;

    @Test
    @DisplayName("Auditing 테스트")
    @WithMockUser(username = "dora", roles = "USER")
    public void auditingTest() {

        Member tempMember = new Member();
        memberRepository.save(tempMember);

        em.flush();
        em.clear();

        Member member = memberRepository.findById(tempMember.getMemberId())
                .orElseThrow(EntityNotFoundException::new);

        System.out.println("register time : " + member.getRegDate());
        System.out.println("update time : " + member.getModDate());
        System.out.println("create member : " + member.getCreatedBy());
        System.out.println("modify member : " + member.getModifiedBy());
    }
}
  •  @WithMockUser 어노테이션 : Spring Security의 테스트 지원 기능으로, 테스트 중에 인증된 사용자를 모의(mock)하여 보안 관련 기능을 테스트할 수 있다. 여기서는 username"dora"로 설정하고 roles"USER"로 설정.

https://velog.io/@onetuks/WithMockUser-WithUserDetails-WithSecurityContext

 

SpringSecurity 테스트 어노테이션

Spring Security 프레임워크가 적용된 상태일때 쓰는 테스트용 어노테이션이다.Spring Security 에서 인증된 사용자를 Mock으로 만들어서 테스트를 수행할 수 있도록 한다.상황을 가정해보자.Spring Security

velog.io

 

// 실행 결과
Hibernate: 
    insert 
    into
        member
        (address, created_by, email, modify_date, modified_by, name, password, register_date, role, member_id) 
    values
        (?, ?, ?, ?, ?, ?, ?, ?, ?, default)
Hibernate: 
    select
        m1_0.member_id,
        m1_0.address,
        m1_0.created_by,
        m1_0.email,
        m1_0.modify_date,
        m1_0.modified_by,
        m1_0.name,
        m1_0.password,
        m1_0.register_date,
        m1_0.role 
    from
        member m1_0 
    where
        m1_0.member_id=?
register time : 2024-08-02T21:54:44.335837
update time : 2024-08-02T21:54:44.335837
create member : dora
modify member : dora

 

 

 

 

내용 참고 : 책 '스프링 부트 쇼핑몰 프로젝트 with JPA'


로그인/로그아웃 화면 연동


로그인시 화면 VS 로그아웃시 화면

 

  📍 로그인 시 화면에는 우측 상단 메뉴에 '로그아웃 | 장바구니 | 주문목록' 이 나옴

       + 관리자로 로그인 시에는 '상품등록 | 상품관리' 메뉴가 추가로 나오도록 함!

       + 로그인 후에는 회원가입 창이 접속 안되도록 설정

 

  📍 로그아웃 시 화면에는 우측 상단 메뉴에 '로그인 | 회원가입' 메뉴만 나오도록 함.

 

💡  thymeleaf-extras-security6는 Thymeleaf와 Spring Security를 통합하여, Thymeleaf 템플릿에서 Spring Security의 기능을 손쉽게 사용할 수 있게 해주는 라이브러리. 이 라이브러리는 Spring Security 6 버전과 함께 사용할 수 있도록 설계되었으며, Thymeleaf 템플릿에서 보안 관련 기능을 보다 쉽게 구현할 수 있게 도와줌.

 

 

⚡️ header.html 에 네비게이션 바가 있고 메뉴 목록이 들어 있으므로 이 코드 안에 설정함!

<div id="nav_item">
    <div>
        <ul class="navbar-nav justify-content-end me-3">
            <li class="nav-item"
                sec:authorize="hasAnyAuthority('ROLE_ADMIN')">
                <a class="nav-link text-muted" href="/admin/item/new"
                    style="font-size: small;">✓ 상품등록</a>
            </li>
            <li class="nav-item"
                sec:authorize="hasAnyAuthority('ROLE_ADMIN')">
                <a class="nav-link text-muted" href="/admin/items"
                    style="font-size: small;">✓ 상품관리</a>
            </li>
        </ul>
    </div>

    <div>
        <ul class="navbar-nav justify-content-end me-3">
            <li class="nav-item" sec:authorize="isAnonymous()">
                <a class="nav-link text-dark" href="/members/new">
                <i class="bi bi-person-fill text-dark me-1"></i>Sign Up</a>
            </li>
            <li class="nav-item" sec:authorize="isAnonymous()">
                <a class="nav-link text-dark" href="/members/login">
                <i class="bi bi-box-arrow-in-right text-dark me-2"></i>Login</a>
            </li>
            <li class="nav-item" sec:authorize="isAuthenticated()">
                <a class="nav-link text-dark" href="/members/logout">
                <i class="bi bi-box-arrow-in-right text-dark me-2"></i>Logout</a>
            </li>

            <li class="nav-item" sec:authorize="isAuthenticated()">
                <a class="nav-link text-dark" href="/cart">
                <i class="bi bi-bag-heart-fill text-dark me-1"></i>Cart</a>
            </li>
            <li class="nav-item" sec:authorize="isAuthenticated()">
                <a class="nav-link text-dark" href="/orders">
                <i class="bi bi-clipboard2-heart-fill me-1"></i>Order</a>
            </li>
        </ul>
    </div>
</div>

 

sec:authentication 속성

Thymeleaf의 Spring Security 통합을 위해 제공되며, sec 네임스페이스를 사용하여 인증된 사용자에 대한 정보를 가져오는 데 도움을 준다

 

sec:authentication="principal.username": 현재 인증된 사용자의 사용자 이름을 템플릿에서 가져옴

sec:authentication="principal.authorities": 현재 인증된 사용자의 권한 목록을 템플릿에서 가져옴

 

sec:authorize 속성

Thymeleaf 템플릿에서 Spring Security와 통합하여 콘텐츠의 표시 여부를 조건부로 제어하기 위해 사용되는 속성. 이 속성을 사용하면 특정 조건에 따라 HTML 요소를 표시하거나 숨길 수 있다.

 

  1. isAuthenticated(): 사용자가 인증된 상태인 경우에만 콘텐츠를 표시
  2. isAnonymous(): 사용자가 익명(비인증) 상태인 경우에만 콘텐츠를 표시
  3. hasRole('ROLE_NAME'): 사용자가 특정 역할을 가지고 있는 경우에만 콘텐츠를 표시
  4. hasAnyRole('ROLE1', 'ROLE2'): 사용자가 특정 역할 중 하나라도 가지고 있는 경우에만 콘텐츠를 표시
  5. hasAuthority('AUTHORITY'): 사용자가 특정 권한을 가지고 있는 경우에만 콘텐츠를 표시
  6. hasAnyAuthority('AUTHORITY1', 'AUTHORITY2'): 사용자가 특정 권한 중 하나라도 가지고 있는 경우에만 콘텐츠를 표시

 

+ 로그인 시 회원가입 페이지 접속을 막아두고 싶어서 controller에 추가 설정 함

    @GetMapping("/new")
    public String memberForm(Model model, Principal principal) {

        if (principal != null) {
            // 로그인된 사용자가 접근 시 다른 페이지로 리디렉션
            return "redirect:/";
        }

        model.addAttribute("memberFormDTO", new MemberFormDTO());
        return "member/memberForm";
    }

 

Principal 객체 ?

스프링 시큐리티에서 현재 인증된 사용자를 나타내는 객체. 로그인을 해야만 생성되는 객체이기 때문에 로그인하지 않은 상태에서 사용하면 null값이 들어가서 오류가 발생.


 

페이지 권한 설정


1.  ADMIN (관리자) 계정만 접근할 수 있는 상품 등록 페이지를 생성

  • item 패키지 아래 itemForm.html 생성
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org"
      xmlns:layout="http:www.ultraq.net.nz/thymeleaf/layout"
      layout:decorate="~{layouts/layout1}">
<head>
    <meta charset="UTF-8">

</head>
<body>

<div layout:fragment="content">
    <h1> 상품 등록 페이지 입니다.</h1>
</div>

</body>
</html>

 

 

2.  ItemController 생성

@Controller
public class ItemController {

    @GetMapping("/admin/item/new")
    public String itemForm(){
        return "/item/itemForm";
    }
}

 

 

3.  CustomAuthenticationEntryPoint 클래스 생성

  • ajax 경우 http request header에 XMLHttpRequest 라는 값이 세팅되어 요청이 오는데, 인증되지 않은 사용자가 ajax로 리소스를 요청할 경우 "Unauthorized" 에러를 발생시키고 나머지 경우 로그인 페이지로 리다이렉트 시켜준다.

 

⚡️  AuthenticationEntryPoint 란?

 

인증이 필요한 리소스에 접근할 때 인증되지 않은 사용자를 처리하는 방법을 정의하는 인터페이스. 일반적으로 AuthenticationEntryPoint는 사용자에게 로그인 페이지로 리다이렉트하거나, JSON 응답을 반환하는 등의 작업을 수행

@Log4j2
public class CustomAuthenticationEntryPoint
        implements AuthenticationEntryPoint {

    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response,
                         AuthenticationException authException)
            throws IOException, ServletException {

        if ("XMLHttpRequest".equals(request.getHeader("x-requested-with"))) {
            response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Unauthorized");
        } else {
            response.sendRedirect("/members/login");
        }
    }
}

 

    http.authorizeHttpRequests(configurer ->
        configurer
            .requestMatchers("/h2-console/**").permitAll() // H2 콘솔에 대한 접근 허용
            .requestMatchers("/", "/members/**", "/item/**", "/images/**").permitAll()
            .requestMatchers("/admin/**").hasRole("ADMIN")
            .requestMatchers("/css/**", "/js/**", "/img/**").permitAll()
            .anyRequest().authenticated() // 나머지 요청에 대해 인증 필요

    );
    
    // Custom AuthenticationEntryPoint 설정
    http.exceptionHandling(exceptionHandling ->
        exceptionHandling
            .authenticationEntryPoint(new CustomAuthenticationEntryPoint())
    );

 

  • 모든 사용자가 인증 없이 접근 가능한 곳과 특정 계정만 접근할 수 있는 곳 설정함.
  • 인증되지 않은 사용자가 리소스에 접근했을 때 수행되는 핸들러 설정함.

 

4.  테스트 코드 작성

@SpringBootTest
@AutoConfigureMockMvc
@TestPropertySource(locations = "classpath:application-test.properties")
class ItemControllerTest {

    @Autowired
    MockMvc mockMvc;

    @Test
    @DisplayName("상품 등록 페이지 권한 테스트")
    @WithMockUser(username = "admin", roles = "ADMIN")
    public void itemFormTest() throws Exception {
        mockMvc.perform(MockMvcRequestBuilders.get("/admin/item/new"))
                .andDo(print())
                .andExpect(status().isOk());
    }

    @Test
    @DisplayName("상품 등록 페이지 일반회원 접근 테스트")
    @WithMockUser(username = "user", roles = "USER")
    public void itemFormNotAdminTest() throws Exception {
        mockMvc.perform(MockMvcRequestBuilders.get("/admin/item/new"))
                .andDo(print())
                .andExpect(status().isForbidden());
    }
}

 

 

 


 

🤓  UserDetailsService 구현


로그인 기능 구현 전에 먼저 UserDetailsService 인터페이스를 설정해야 된다!

 -> DB에서 회원 정보를 가져오기 위함!

 

유데미 클래스에서는 UserDetailsManager만 배웠는데,, 책에서는 UserDetailsService를 구현해서 무슨 차이인지 찾아봄..

UserDetailsService  VS.  UserDetailsManager

 

UserDetailsService는 Spring Security에서 사용자 정보를 가져오는 데 사용되는 인터페이스.

주요 메소드: loadUserByUsername(String username)

용도: 사용자 정보를 로드하기 위해 주로 로그인 과정에서 사용됨. 즉, 사용자가 로그인 시 입력한 사용자 이름을 기반으로 해당 사용자 정보를 데이터베이스에서 조회하는 역할.

 

UserDetailsManager는 UserDetailsService의 확장된 버전으로, 사용자 정보를 조회하는 것 외에도 사용자 추가, 수정, 삭제 등의 작업을 지원하는 인터페이스!

주요 메소드:

  • createUser(UserDetails user) 

  • updateUser(UserDetails user)

  • deleteUser(String username)

  • userExists(String username)

용도: 사용자 정보를 CRUD(생성, 읽기, 업데이트, 삭제) 작업을 지원하며, 더 복잡한 사용자 관리 작업이 필요한 경우 사용됨

가장 많이 사용되는 구현체는 JdbcUserDetailsManager와 InMemoryUserDetailsManager

 -> JdbcUserDetailsManager를 구현하려 하다 user, authority 테이블 필요하다 해서.. 일단 나중에 refactoring 하면서 고쳐보기로함.. 🧐

 

  • Service 패키지 아래 UserDetailsService를 구현한 CustomUserDetailsService 클래스 생성
@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {

    public final MemberRepository memberRepository;

    @Override
    @Transactional
    public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
        Member theMember = memberRepository.findByEmail(email);

        if(theMember == null) {
            throw new UsernameNotFoundException(email);
        }

        return User.builder()
                .username(theMember.getEmail())
                .password(theMember.getPassword())
                .roles(theMember.getRole().toString())
                .build();

    }
}

 

  - email로 로그인 하기 때문에 파라미터를 email로 넘겨줌.!

  - 존재하지 않는 계정이면 예외처리.

  - User 객체로 변환하여 반환

  - 원래 책에는 MemberService 안에 넣어 줬는데, 나는 아예 따로 빼줌,, remember me 기능 구현에도 쓰이기 때문...!

 

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    private final CustomUserDetailsService customUserDetailsService;

    // 생성자 주입을 사용하여 의존성 주입
    public SecurityConfig(CustomUserDetailsService customUserDetailsService) {
        this.customUserDetailsService = customUserDetailsService;
    }
    
     @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {

        http.formLogin(form ->
                form.loginPage("/members/login")
                        .defaultSuccessUrl("/")
                        .usernameParameter("email")
                        .failureUrl("/members/login/error")
        );

        http.logout(logout ->
                logout.logoutRequestMatcher(new AntPathRequestMatcher("/members/logout"))
                        .logoutSuccessUrl("/")
        );
    
    ...

 

  💡 Spring Framework에서는 @Autowired 보다 생성자 주입을 권장하고 있다. 이는 코드의 가독성을 높이고, 의존성 관리의 명확성을 보장.

 

  - Spring Security의 인증 과정에서 사용자의 로그인 요청이 들어오면, Spring Security는 UserDetailsServiceloadUserByUsername 메서드를 호출하여 사용자 정보를 조회.

 

 

 

로그인 페이지 생성


  • templates/member/memberLoginForm.html 생성
    <form role="form" method="post" action="/members/login" novalidate>

        <i class="bi bi-chat-square-heart-fill fs-1" style="color: #FFCFE2;"></i>
        <h1 class="h3 mt-1 mb-3 fw-bold text-muted">Please sign in</h1>

        <div class="form-floating">
            <input type="email" name="email" class="form-control"
                   id="floatingInput" placeholder="name@example.com">
            <label th:for="email">Email address</label>
        </div>

        <div class="form-floating">
            <input type="password" class="form-control" name="password"
                   id="floatingPassword" placeholder="Password">
            <label th:for="password">Password</label>
        </div>

        <div th:if="${loginErrorMsg}" class="error" th:text="${loginErrorMsg}"
             style="color: red; font-size: 15px"></div>

        <div class="form-check text-start my-3">
            <input class="form-check-input" type="checkbox" id="rememberMe" name="rememberMe">
            <label class="form-check-label" for="rememberMe">
                Remember me
            </label>
        </div>

        <button class="btn btn-light w-100 py-2 fw-bold text-muted" type="submit"
                style="background-color: #FFCFE2;">Sign in
        </button>
        <p class="mt-3 text-body-secondary">&copy; 2024</p>

    </form>

 

  - remember me 를 추가해서 브라우저를 껐다 켜도 로그인 상태 유지하도록 설정할 예정!

  

 

부트스트랩 모달창을 띄워서 로그인하고 싶었는데, ajax 작동이 잘 안됨.. 콘솔에 에러도 안뜨고 내용도 안 불러와져서 일단 패스..

부트스트랩 문제인지를 잘 모르겠다,, 다른 블로그 글 다 뒤져봤는데두 안나옴 ㅠ,ㅠ..data-bs-toggle과 data-bs-target 속성지정도 제대로 했고...onclick 속성 사용하면 ReferenceError: openLoginModal is not defined at HTMLAnchorElement.onclick 에러뜸 대환장 파티,,시간 너무 오래걸려 일단 나중에 다시 해보기로..ㅎ

 

 

 

MemberController 추가 코드 구현


  • 회원가입 코드 구현한 곳 아래에 추가
    @GetMapping("/login")
    public String loginMember() {
        return "/member/memberLoginForm";
    }

    @GetMapping("/login/error")
    public String loginError(Model model) {
        model.addAttribute("loginErrorMsg", "Please enter a valid email or password");
        return "/member/memberLoginForm";
    }

 

- SecurityConfig에 로그인 성공시 리다이렉트 될 Url을 설정해줘서 PostMapping은 따로 작성 x

   -> 찾아보니 로그인 관련 설정을 SecurityConfig에서 한 곳에서 관리할 수 있어 보안 설정과 관련된 모든 구성 요소를 쉽게 찾을 수 있어서 이렇게 쓰이는듯!

 

- login/error 경로에 대한 GET 요청이 들어오면 loginError 메서드가 호출 -> 이 메서드는 Model에 오류 메시지를 추가하고 /member/memberLoginForm 템플릿을 반환 -> 렌더링 후 loginErrorMsg가 존재하면 <div> 요소가 표시되고, 오류 메시지가 사용자에게 빨간색으로 나타남

 

 

 

Test Code 작성


@SpringBootTest
@AutoConfigureMockMvc
@Transactional
@TestPropertySource(locations = "classpath:application-test.properties")
class MemberControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @Autowired
    MemberService memberService;

    @Autowired
    PasswordEncoder passwordEncoder;

    public Member createMember(String email, String password) {
        MemberFormDTO memberFormDTO = MemberFormDTO.builder()
                .name("홍길동")
                .email(email)
                .password(password)
                .address("서울시 마포구 합정동")
                .build();

        Member member = Member.createMember(memberFormDTO, passwordEncoder);
        return memberService.saveMember(member);
    }

    @Test
    @DisplayName("로그인 성공 테스트")
    public void loginSuccessTest() throws Exception{
        String email = "test@email.com";
        String password = "1234";
        this.createMember(email, password);
        mockMvc.perform(formLogin().userParameter("email")
                        .loginProcessingUrl("/members/login")
                        .user(email).password(password))
                .andExpect(SecurityMockMvcResultMatchers.authenticated());
    }

    @Test
    @DisplayName("로그인 실패 테스트")
    public void loginFailTest() throws Exception {
        String email = "test@email.com";
        String password = "1234";

        this.createMember(email, password);
        mockMvc.perform(formLogin().userParameter(email)
                .loginProcessingUrl("/members/login/error")
                .user(email).password("12345"))
                .andExpect(SecurityMockMvcResultMatchers.unauthenticated());

    }

}

 

https://adjh54.tistory.com/347

 

[Java] Spring Boot MockMvc 이해하기 : 테스트 흐름 및 사용예제

해당 글에서는 MockMvc에 대해 이해하고 활용하는 방법에 대해 확인해 봅니다.   💡 [참고] 이전에 작성한 Test 관련 글들을 읽으시면 도움이 됩니다.분류링크JUnit 5 이론 및 구성 요소https://adjh54.

adjh54.tistory.com

https://docs.spring.io/spring-security/reference/servlet/test/mockmvc/result-matchers.html

 

SecurityMockMvcResultMatchers :: Spring Security

At times it is desirable to make various security related assertions about a request. To accommodate this need, Spring Security Test support implements Spring MVC Test’s ResultMatcher interface. In order to use Spring Security’s ResultMatcher implement

docs.spring.io

 

MockMvc 라이브러리 라는 것도 있었다는 것을 처음 깨달은,,🐰

테스트용으로 많이 쓰인다고 한다!

 

perform() : MockMvc를 사용하여 HTTP 요청을 실행

andExpect() : 컨트롤러의 응답을 검증

 

 

 

Remember Me 기능 구현


이왕 부트스트랩 디자인 긁어온거 책에는 안 나와있는 remember 기능 구현 해보자 싶어서 ... 🤓

  http.rememberMe(rememberMe -> rememberMe
                .rememberMeParameter("rememberMe") // default: remember-me, checkbox 등의 이름과 맞춰야함
                .tokenValiditySeconds(3600) // 쿠키의 만료시간 설정(초), default: 14일
                .alwaysRemember(false)); // 사용자가 체크박스를 활성화하지 않아도 항상 실행, default: false
//                .userDetailsService(customUserDetailsService)); // 기능을 사용할 때 사용자 정보가 필요함. 반드시 이 설정 필요함.

 

일단 SecurityFilterChain 안에 이 코드를 넣어줬다!

근데 작동이 안돼서 왜지.. 했는데 html에 input 코드에 id, name은 'rememberMe'로 설정 유지하고 기본적으로 설정되어 있던 value값을 삭제하니깐 작동이 되더라는... ;

 

 

버전 업그레이드 하면서 SecurityFilterChain 사용시 userDetailService 설정안해줘도 된다고 한다!!!

 

 

https://velog.io/@dailylifecoding/spring-security-remember-me

 

[Spring Security] Remember Me 인증

스프링 시큐리티가 제공하는 Remember Me 기능을 알아보자.

velog.io

 

 

remember-me 쿠키가 생성되어야 성공!

 

 

 

 

 

내용 참고 : 책 '스프링 부트 쇼핑몰 프로젝트 with JPA' 

+ Recent posts