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' 


개발환경

 

  1. 운영체제 : Mac OS
  2. 통합개발환경(IDE) : IntelliJ Ultimate
  3. JDK version : JDK 22
  4. Spring Boot version : 3.3.2
  5. Database : MySQL
  6. Build Tool : Maven
  7. Packaging : Jar

 

Dependencies
  • Spring Web
  • Spring Data JPA
  • MySQL Driver
  • H2 Database
  • Thymeleaf
  • Lombok
  • Spring Security
  • Spring Boot DevTools
  • querydsl
  • thymeleaf-layout-dialect
  • validation
  • thymeleaf-extras-springsecurity6

 

패키지 설계

 

스프링 시큐리티 설정


  • config 패키지에 securityconfig 클래스 생성
@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
    
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    
        http.authorizeHttpRequests(configurer ->
                configurer
                        .requestMatchers("/h2-console/**").permitAll() // H2 콘솔에 대한 접근 허용
                        .anyRequest().authenticated() // 나머지 요청에 대해 인증 필요

        );
        
        // use HTTP Basic authentication
        http.httpBasic(Customizer.withDefaults());

        // disable Cross Site Request Forgery(CSRF)
        http.csrf(csrf -> csrf.disable());

        return http.build();
    
    }
    
    // H2 콘솔 관련 설정
    @Bean
    @ConditionalOnProperty(name = "spring.h2.console.enabled", havingValue = "true")
    public WebSecurityCustomizer configureH2ConsoleEnable() {
        return web -> web.ignoring()
                .requestMatchers(PathRequest.toH2Console());
    }

 

  • @Configuration : Spring의 구성 클래스를 나타내며, Bean 정의를 포함하는 클래스를 의미
  • @EnableWebSecurity : Spring Security를 활성화. Spring Security의 설정을 정의하고 적용
💡  BCryptPasswordEncoder
      비밀번호를 암호화하고 검증하는 데 사용되는 PasswordEncoder 구현체. BCrypt 알고리즘을 사용하여 비밀번호를 암호화한다. BCrypt는 비밀번호 해시를 생성하는 데 안전한 방법으로 널리 사용

 

 

SecurityFilterChain HTTP 요청에 대한 보안 필터 체인을 정의. HttpSecurity 객체를 사용하여 필터 체인을 설정.

HttpSecurity : HTTP 요청에 대한 보안 설정을 구성하는 데 사용됨

authorizeHttpRequests(configurer -> ...) : HTTP 요청에 대한 접근 권한을 설정

requestMatchers(...) : URL 패턴에 따라 요청을 필터링.

permitAll() : 해당 URL 패턴에 대해 인증 없이 접근을 허용.

anyRequest().authenticated() : 위에서 정의된 패턴을 제외한 모든 요청은 인증된 사용자만 접근할 수 있도록 설정.

httpBasic(Customizer.withDefaults()) : HTTP Basic Authentication을 활성화. 이 방법은 간단한 인증 방법으로 사용자 이름과 비밀번호를 HTTP 헤더에 포함시켜 인증을 수행.

csrf.disable() : Cross Site Request Forgery (CSRF) 보호를 비활성화. CSRF는 웹 애플리케이션에서 악의적인 요청을 방지하기 위한 보안 메커니즘. 일반적으로 비활성화하지 않는 것이 좋지만, API 또는 비상 상황에서는 비활성화할 수 있다.

 

https://wikidocs.net/162150

 

3-05 스프링 시큐리티란?

* `[완성 소스]` : [https://github.com/pahkey/sbb3/tree/v3.05](https://github.com/pahkey/sbb3/tree/v3.05…

wikidocs.net

 

📍 내가 노션에 정리한 내용!

https://www.notion.so/5-Spring-Boot-Security-b95a14467d6f4a87937a66b8d217c2d2?pvs=4

https://www.notion.so/8-Spring-MVC-Security-3fc33d56e52e4d809dac4b7fbdc0f025?pvs=4

 

 

.requestMatchers("/**").permitAll() - 일단 스프링 시큐리티 적용안하고 바로 화면 보이도록 설정...

 

 

엔티티, DTO 코드 설정


 

1. 일반 유저 / 관리자 역할 코드

  •  com.doraflower 패키지 아래 'constant' 키지 생성 - Role.java 생성
  •  enum(열거형)

 

https://velog.io/@mooh2jj/Java-Enum%EC%9D%84-%EC%82%AC%EC%9A%A9%ED%95%98%EB%8A%94-%EC%9D%B4%EC%9C%A0

 

[Java] Enum을 사용하는 이유와 활용법

Java enum은 제한된 값 목록을 갖는 타입입니다. enum은 다음과 같은 이점을 갖습니다.enum은 컴파일 타임에 타입 안정성을 보장합니다. 특정 범위의 값만 사용 가능하므로 컴파일 오류나 런타임 예

velog.io

 

public enum Role {
    USER, ADMIN
}

 

 

2. MemberFormDTO 클래스 코드 - 회원가입 화면으로부터 넘어오는 정보를 담는다.

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

    @NotBlank(message = "Name is required")
    private String name;

    @NotBlank(message = "Email is required")
    @Pattern(regexp = "^[\\w-.]+@([\\w-]+\\.)+[\\w-]{2,4}$",
            message = "Please enter a valid email address.")
    private String email;

    @NotBlank(message = "Password is required")
    @Length(min=8, max=16, message = "Enter a password between 8 and 16 characters, including numbers.")
    private String password;

    @NotBlank(message = "Address is required")
    private String address;
}

 

2024.07.25 - [Spring & Spring Boot] - @Lombok 및 Entity 관련 어노테이션

 

  • 회원가입 폼에서 받을 내용 - 이름 | 이메일 주소 (로그인 시 확인) | 비밀번호 | 주소
  • message는 유효성 검사시 에러가 발생하면 출력될 내용!

 

3. Member 클래스 코드 - 회원 정보를 저장하는 엔티티 (DB에서 관리)

@Entity
@Table(name = "member")
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Member {

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

    @Column(nullable = false)
    private String name;

    @Column(unique = true, nullable = false)
    private String email;

    @Column(nullable = false)
    private String password;

    @Column(nullable = false)
    private String address;

    @Enumerated(EnumType.STRING)
    private Role role;

    public static Member createMember(MemberFormDTO memberFormDTO,
                                      PasswordEncoder passwordEncoder) {

        String password = passwordEncoder.encode(memberFormDTO.getPassword());

        Member member = Member.builder()
                .name(memberFormDTO.getName())
                .email(memberFormDTO.getEmail())
                .password(password)
                .address(memberFormDTO.getAddress())
                .role(Role.USER)
                .build();

        return member;
    }

}

 

  • 데이터베이스 테이블 이름은 'member'로 지정
  • 이메일을 통해 회원 구분을 위해 'unique'설정
  • Role은 string으로 저장하도록 설정
  • Member 엔티티 생성하는 메소드 설정 - 파라미터로 dto와 passwordencoder를 받음
  • 패스워드는 db에 암호화 돼서 저장됨
  • role은 'USER'로 설정해줌 -> ADMIN 먼저 만들고 바꿔줘도 됨

 

 

Repository 코드 설정 - DAO 역할


  • repository 패키지 아래 MemberRepository 인터페이스 생성
public interface MemberRepository extends JpaRepository<Member, Long> {

    Member findByEmail(String email);
}
  • 회원가입 시 중복된 회원이 있는지 검사하기 위해 이메일로 회원을 검사할 수 있도록 쿼리 메소드 작성

 

 

Service 코드 설정


  • service 패키지 아래 MemberService 인터페이스, MemberServiceImpl 클래스 생성
특성 서비스 계층이 필요한 경우 서비스 계층이 필요없는 경우
비즈니스 로직의 복잡성 복잡한 비즈니스 로직이 있는 경우 비즈니스 로직이 거의 없거나 단순한 경우
유지보수 비즈니스 로직을 중앙화하여 유지 보수 용이 간단한 애플리케이션에서 유지 보수가 덜 필요
테스트 용이성 독립적인 테스트가 필요한 경우 테스트가 크게 중요하지 않은 경우
트랙잭션 관리 트랜잭션 관리를 필요로 하는 경우 트랜잭션 관리가 필요 없는 경우
계층화된 아키텍처 MVC 패턴 등 계층화된 아키텍처를 선호하는 경우 단일 클래스 또는 구조화된 아키텍처가 불필요

 

public interface MemberService {

    Member saveMember(Member theMember);
}
@Service
@Log4j2
@RequiredArgsConstructor
@Transactional
public class MemberServiceImpl implements MemberService, UserDetailsService {

    private final MemberRepository memberRepository;

    @Override
    public Member saveMember(Member theMember) {

        Member tempMember = memberRepository.findByEmail(theMember.getEmail());
        if (tempMember != null) {
            throw new IllegalStateException("Already Registered Member!");
        }

        return memberRepository.save(theMember);
    }

}

 

특성 단일 클래스 인터페이스 + 구현체
구조 단순하지만 커질 수 있음 명확하게 구조화됨
유지보수 어려울 수 있음 용이함
테스트 어려울 수 있음 용이함 (모킹 가능)
확장성 낮음 높음
의존성 주입 어려울 수 있음 쉬움

 

 

  • @Autowired는 빈에 생성자가 1개이고 생성자의 파라미터 타입이 빈으로 등록 가능하면 사용 안해도 의존성 주입 가능..
  • 이미 가입된 회원은 IllegalStateException 예외 발생

 

회원 가입 기능 테스트


  • 마우스 오른쪽 버튼 - Go To - Test 자동생성 기능 사용
@SpringBootTest
@Transactional
@Log4j2
@RequiredArgsConstructor
@TestPropertySource(locations = "classpath:application-test.properties")
class MemberServiceImplTest {

    @Autowired
    MemberService memberService;

    @Autowired
    PasswordEncoder passwordEncoder;

    @Test
    @DisplayName("회원가입 테스트")
    public void saveMemberTest() {

        MemberFormDTO theMemberDTO = MemberFormDTO.builder()
                .email("test@email.com")
                .name("Dora")
                .address("Daegu")
                .password("1234")
                .build();

        // 비밀번호 인코딩 및 회원 생성
        Member member = Member.createMember(theMemberDTO, passwordEncoder);

        // 회원 저장
        Member savedMember = memberService.saveMember(member);

        // 로그로 저장된 Member 확인
        log.info("Saved Member: {}", savedMember);
        assertNotNull(savedMember.getMemberId()); // ID가 null이 아닌지 확인
        assertEquals("test@email.com", savedMember.getEmail());

    }

    @Test
    @DisplayName("중복 회원 가입 테스트")
    public void saveDuplicateMember() {

        // 객체 생성
        Member member1 = new Member(null, "Dora", "test@email.com", "1234", "Daegu", ADMIN);
        Member member2 = new Member(null, "Dora", "test@email.com", "1234", "Daegu", USER);

        // 객체 저장
        memberService.saveMember(member1);

        // 예외 처리
        IllegalStateException thrown = assertThrows(IllegalStateException.class, () -> {
            memberService.saveMember(member2);
        });

        assertTrue(thrown.getMessage().contains("Already Registered Member!"));
    }
}

 

  • h2 db에 테스트 할 거기 때문에 기존 properties 외 새로 하나 더 만들어서 경로를 지정해줬다.
  • Junit의 Assertions 클래스의 assertEquals 메소드를 이용해서 저장하려고 요청했던 값과 실제 저장된 데이터를 비교함.
  • assertThrows 메소드 이용하면 예외 처리 테스트가 가능.

https://junit.org/junit5/docs/5.0.1/api/org/junit/jupiter/api/Assertions.html

 

Assertions (JUnit 5.0.1 API)

Asserts that all supplied executables do not throw exceptions. If any supplied Executable throws an exception (i.e., a Throwable or any subclass thereof), all remaining executables will still be executed, and all exceptions will be aggregated and reported

junit.org

 

https://velog.io/@roycewon/JUnit-Assert-Methods1

 

JUnit - Assert Methods(1)

JUnit 5 모듈인 Jupiter는 JUnit4에 있는 Assertion method를 포함하여 여러 메소드를 제공한다. Java8에서 추가된 람다와 함께 사용하면 좋다.Assert method는 org.junit.jupiter.api.Assertions 라는 클래

velog.io

 

 

 

Controller 코드 구현


  • controller 패키지 아래 MemberController 생성
@Controller
@RequestMapping("/members")
@RequiredArgsConstructor
public class MemberController {

    private final MemberService memberService;
    private final PasswordEncoder passwordEncoder;

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

        model.addAttribute("memberFormDTO", new MemberFormDTO());
        return "member/memberForm";
    }
    
    @PostMapping("/new")
    public String memberForm(@Valid MemberFormDTO memberFormDTO,
                             BindingResult bindingResult,
                             Model model) {

        // 유효성 오류시 실행할 메서드
        if (bindingResult.hasErrors()) {
            System.out.println("Binding results: " + bindingResult.toString());
            return "member/memberForm";
        }

        // 이메일 중복 시 예외처리
        try {
            Member member = Member.createMember(memberFormDTO, passwordEncoder);
            memberService.saveMember(member);
        } catch (IllegalStateException e){
            model.addAttribute("errorMessage", e.getMessage());
            return "member/memberForm";
        }

        // 성공 시 리다이렉트
        return "redirect:/";
    }
}

 

  • ModelmemberFormDTO라는 이름으로 새로운 MemberFormDTO 객체를 추가. 뷰에서 이 객체를 사용하여 폼을 렌더링할 수 있다.
  • localhost:8080/members/new 접속하면 member 패키지 아래에 있는 memberForm.html 화면이 출력됨.
  • @PostMapping은 form 을 제출했을 때 실행되는 코드~ 페이지 구현하고 작성해도 됨!

 

 

회원가입 페이지 구현


  • templates 패키지 아래 member 패키지 생성 - memberForm.html 생성
  • Thymeleaf 템플릿 사용해서 작성함.
  • fragments 패키지 아래에 header/footer html 만들어서 layout01.html에 fragment로 설정하고 content영역만 따로 페이지 구현해서 연동되도록 만들었습니닷,

2024.05.09 - [Spring & Spring Boot] - [Spring Boot] Thymeleaf

https://github.com/SominY/Dora-Flower-Shop/blob/main/src/main/resources/templates/member/memberForm.html(소스코드)

 

Dora-Flower-Shop/src/main/resources/templates/member/memberForm.html at main · SominY/Dora-Flower-Shop

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

github.com

 

 

 

 

 

 

 

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


JpaRepositoy 지원 메소드

메소드 기능
<S extends T> save(S entity) 엔티티 저장 및 수정
void delete(T entity) 엔티티 삭제
count() 엔티티 총 개수 반환
Iterable<T> findAll() 모든 엔티티 조히

 

📍 연관 페이지

https://www.notion.so/4-Spring-Boot-Rest-CRUD-b666e9cbc99746048343b5ac0b464871?pvs=4

2024.05.25 - [Spring & Spring Boot] - [Spring Boot] Spring Data JPA (1)

 

[Spring Boot] Spring Data JPA (1)

1.  Spring Data JPAJDBC에서 작업하다가 MyBatis를 배울 경우에는 SQL 작성법도 거의 동일하고, 코드의 간략화가 목표여서 러닝커브가 높지 않지만JPA의 경우에는 JDBC, MyBatis와는 사용방법이 상당한

allriver.tistory.com

 


Query method & JPQL snippet

Keyword Sample JPQL snippet
And findByLastnameAndFirstname ... where x.lastname =  ?1 and x.firstname = ?2
Or findByLastnameOrFirstname ... where x.lastname =  ?1 or x.firstname = ?2
Is, Equals findByFirstname
findByFirstnameIs
findByFirstnameEquals
... where x.firstname = ?1
Between findByStartDateBetween ... where x.startDate between ?1 and ?2
LessThan findByAgeLessThan ... where x.age < ?1
LessThanEqual findByAgeLessThanEqual ... where x.age <= ?1
GreaterThan findByAgeGreaterThan ... where x.age > ?1
GreaterThanEqual findByAgeGreaterThanEqual ... where x.age >= ?1
After findByStartDateAfter ... where x.startDate > ?1
Before findByStartDateBefore ... where x.startDate < ?1
IsNull, Null
IsNotNull
findByAge(Is)Null ... where x.age is null
NotNull findByAge(Is)NotNull ... where x.age not null
Like findByAgeFirstnameLike ... where x.firstname like ?1
NotLike findByAgeFirstnameNotLike ... where x.firstname not like ?1
StartingWith findByFirstnameStartingWith ... where x.firstname like ?1 (parameter bound with appended %)
EndingWith findByFirstnameEndingWith ... where x.firstname like ?1 (parameter bound with prepended %)
Containing findByFirstnameContaining ... where x.firstname like ?1 (parameter bound wrapped in %)
OrderBy findByAgeOrderByLastnameDesc ... where x.age ?1 order by x.lastname desc
Not findByLastnameNot ... where x.lastname <> ?1
In findByAgeIn(Collection<Age> ages) ... where x.age in ?1
NotIn findByAgeNotIn(Collection<Age> ages) ... where x.age not in ?1
True findByActiveTrue() ... where x.active = true
False findByActiveFalse() ... where x.active = false
IgnoreCase findBYFirstnameIgnoreCase ... where UPPER(x.firstname) = UPPER(?1)

 

 

 


 

javax.validation 어노테이션 예시

어노테이션 설명
@NotEmpty NULL 체크 및 문자열의 경우 길이 0인지 검사
@NotBlank NULL 체크 및 문자열의 경우 길이 0 및 빈 문자열(" ") 검사
@Length(min=, max=) 최소, 최대 길이 검사
@Email 이메일 형식인지 검사
@Max(숫자) 지정한 값보다 작은지 검사
@Min(숫자) 지정한 값보다 큰지 검사
@Null 값이 NULL인지 검사
@NotNull 값이 NULL이 아닌지 검사

 

 

 

 

 

 

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

+ Recent posts