스프링부트4

유요한·2023년 6월 11일
0

Spring Boot

목록 보기
10/25
post-thumbnail

스프링 부트 로그인 연습

회원 도메인 개발

package com.example.demo.domain;

import lombok.Data;

@Data
public class MemberDTO {
    private long id;
    private String loginId;
    private String name;
    private String password;
}
package com.example.demo.domain;

import lombok.extern.log4j.Log4j2;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Repository;

import javax.annotation.PostConstruct;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;

@Repository
@Log4j2
public class MemberRepository {
    // 회원 정보는 static ConcorrentHashMap을 사용해서 메모리에 저장하도록 할 것이다.
    private static Map<Long, MemberDTO> store = new ConcurrentHashMap<>();
    private static long sequence = 0L;

    public MemberDTO save(MemberDTO member) {
        member.setId(++sequence);
        log.info("save: member={}", member);
        store.put(member.getId(), member);

        return member;
    }

    public MemberDTO findById(long id) {
        return store.get(id);
    }

    // 여기서 findByLoginId()가 loginId를 받아 회원 저장소에서 회원 인스턴스를 찾는 메소드이다.
    // 저장소에 loginId에 해당하는 회원이 없을수도 있으므로 리턴 타입은 Optional로 감싼다.
    // Java8에서는 Optional<T> 클래스를 사용해 NPE를 방지할 수 있도록 도와준다.
    // Optional<T>는 null이 올 수 있는 값을 감싸는 Wrapper 클래스로,
    // 참조하더라도 NPE가 발생하지 않도록 도와준다.
    // Optional 클래스는 아래와 같은 value에 값을 저장하기 때문에 값이 null이더라도 바로 NPE가 발생하지 않으며,
    // 클래스이기 때문에 각종 메소드를 제공해준다.
    public Optional<MemberDTO> findByLoginId(String loginId) {
        return this.findAll().stream()
                .filter(m -> m.getLoginId().equals(loginId))
                .findFirst();
    }

    public List<MemberDTO> findAll() {
        return new ArrayList<>(store.values());
    }

    public void clearStore() {
        store.clear();
    }


    // 종속성 주입이 완료된 후 실행되어야 하는 메서드에 사용된다. 이 어노테이션은 다른 리소스에서 호출되지 않아도 수행된다.
    // 회원 가입은 화면을 별도로 만들지 않을 것이므로 @PostConstruct를 사용해 테스트용 회원을 만들도록 하였다.
    @PostConstruct
    public void init() {
        MemberDTO member = new MemberDTO();
        member.setLoginId("test");
        member.setPassword("test!");
        member.setName("테스터");

        save(member);
    }
}

로그인 기능 개발

package com.example.demo.service;


import com.example.demo.domain.MemberDTO;
import com.example.demo.domain.MemberRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

@RequiredArgsConstructor
@Service
public class LoginService {

    private  final MemberRepository memberRepository;

    public MemberDTO Login(String loginId, String password) {
        return memberRepository.findByLoginId(loginId)
                .filter(m -> m.getPassword().equals(password))
                .orElse(null);
    }
}

이 로그인 로직은 앞서 구현한 MemberRepository#findByLoginId로 회원을 조회한 다음에 파라미터로 넘어온 password와 비교해서 같으면 Member 인스턴스를 반환하고, password가 다르면 null을 반환한다.

package com.example.demo.domain;

import com.sun.istack.NotNull;
import lombok.Data;

import javax.validation.constraints.NotBlank;

@Data
public class LoginForm {
    @NotBlank
    private  String loginId;
    @NotBlank
    private String password;

}

LoginForm은 요청 form 데이터를 바인딩 받기 위한 DTO 클래스이다.

validation을 위해 @NotBlank를 추가하였다.

✅ Validation

올바르지 않은 데이터를 걸러내고 보안을 유지하기 위해 데이터 검증(validation)은 여러 계층에 걸쳐서 적용됩니다. Client의 데이터는 조작이 쉬울 뿐더러 모든 데이터가 정상적인 방식으로 들어오는 것도 아니기 때문에, Client Side뿐만 아니라 Server Side에서도 데이터 유효성을 검사해야 할 필요가 있습니다. 스프링부트 프로젝트에서는 @validated를 이용해 유효성을 검증할 수 있습니다.

spring boot 2.3 version 이상부터는 spring-boot-starter-web 의존성 내부에 있던 validation이 사라졌습니다. 때문에 사용하시는 spring boot version이 2.3 이상이라면 validation 의존성을 따로 추가해주셔야 사용할 수 있습니다.

Gradle

implementation group: 'org.springframework.boot', name: 'spring-boot-starter-validation', version: '2.5.2'

Maven

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
    <version>2.5.2</version>
</dependency>

그러면 정상적으로 사용할 수 있습니다.

기타 사용법

   @Getter
    @Setter
    public static class Signup {

        @NotEmpty(message = "이메일은 필수 입력값입니다.")
        @Pattern(regexp = "^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+.[A-Za-z]{2,6}$", message = "이메일 형식에 맞지 않습니다.")
        private String email;

        @NotEmpty(message = "비밀번호는 필수 입력값입니다.")
        @Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d)(?=.*[~!@#$%^&*()+|=])[A-Za-z\\d~!@#$%^&*()+|=]{8,16}$\n", message = "비밀번호는 8~16자 영문 대 소문자, 숫자, 특수문자를 사용하세요.")
        private String password;
    }

그리고 유효성 검사가 필요한 Request 객체에 Validation 어노테이션을 통해 필요한 유효성 검사를 적용합니다.

유효성 검사에 사용할 수 있는 어노테이션

@Null  // null만 혀용합니다.
// 초기화나 공백의 값이 들어와 저장은 되야하지만 
// Null 로 들어온 경우 오류가 나는 변수를 받을 때 사용하면 됨
// null을 허용하지 않습니다. "", " "는 허용합니다.
@NotNull  
@NotEmpty  // null, ""을 허용하지 않습니다. " "는 허용합니다.
@NotBlank  // null, "", " " 모두 허용하지 않습니다.

@Email  // 이메일 형식을 검사합니다. 다만 ""의 경우를 통과 시킵니다. @Email 보다 아래 나올 @Patten을 통한 정규식 검사를 더 많이 사용합니다.
@Pattern(regexp = )  // 정규식을 검사할 때 사용됩니다.
@Size(min=, max=)  // 길이를 제한할 때 사용됩니다.

@Max(value = )  // value 이하의 값을 받을 때 사용됩니다.
@Min(value = )  // value 이상의 값을 받을 때 사용됩니다.

@Positive  // 값을 양수로 제한합니다.
@PositiveOrZero  // 값을 양수와 0만 가능하도록 제한합니다.

@Negative  // 값을 음수로 제한합니다.
@NegativeOrZero  // 값을 음수와 0만 가능하도록 제한합니다.

@Future  // 현재보다 미래
@Past  // 현재보다 과거

@AssertFalse  // false 여부, null은 체크하지 않습니다.
@AssertTrue  // true 여부, null은 체크하지 않습니다.

LoginController에는 @GetMapping으로 로그인 폼을 보여주는 핸들러, @PostMapping으로 로그인 요청을 받아 처리하는 핸들러를 만든다.

package com.example.demo.controller;

import com.example.demo.domain.LoginForm;
import com.example.demo.domain.MemberDTO;
import com.example.demo.service.LoginService;
import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;
import org.springframework.stereotype.Controller;
import org.springframework.validation.BindingResult;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;

@Log4j2
@RequiredArgsConstructor
@Controller
public class LoginController {

    private final LoginService loginService;

    @GetMapping("/login")
    public String loginForm(@ModelAttribute("loginForm")LoginForm loginForm) {
        return "login/loginForm";
    }

    @PostMapping("/login")
    public String login(@ModelAttribute @Validated LoginForm loginForm,
                        // BindingResult는 검증 오류가 발생할 경우 오류 내용을 보관하는 스프링 프레임워크에서 제공하는 객체
                        // 또한, BindingResult가 있으면 @ModelAttribute에 데이터 바인딩 시 오류가 발생해도 오류 정보를
                        // FieldError 개체를 BindingResult가 담은 뒤 컨트롤러가 호출됩니다.
                        // 여기서 주의할 점은 BindingResult 객체의 파라미터 위치는
                        // 반드시 @ModelAttribute 어노테이션이 붙은 객체 다음에 위치해야 한다는 점입니다.
                        BindingResult bindingResult,
                        @RequestParam(defaultValue = "/") String redirctURL) {
        if(bindingResult.hasErrors()) {
            return "login/loginForm";
        }
        MemberDTO loginMember = loginService.Login(loginForm.getLoginId(),loginForm.getPassword());

        if(loginMember == null) {
            bindingResult.reject("loginFail", "아이디 또는 비밀번호가 맞지 않습니다. ");
            return "login/loginForm";
        }
        // 로그인 성공 처리
        return "redirect: " + redirctURL;
    }
}

로그인 핸들러는 인자로 요청 데이터를 바인딩 받을 LoginForm, 바인딩 결과를 담는 BindingResult 그리고 @RequestParam으로 요청 파라미터로 redirectURL을 받도록 하였다.

bindingResult에 TypeMissMatch가 발생했거나 요청으로 넘어온 login id, password로 회원이 조회되지 않으면 "login/loginForm"을 리턴하여 로그인 폼 입력 화면으로 가게 한다.

회원이 조회되지 않는 경우에는 BindingResult#reject로 에러를 담아 아래와 같이 화면에서 적절한 메시지를 노출할 수 있도록 하였다.

로그인이 성공하면 특정 url로 redirect 하도록 한다.

@RequestParam의 defaultValue에 의해 현재는 "/"로 redirect 될것이다.

Optional<>

Java NPE 예방

자바 프로그램 코드를 작성하다보면 null 값에 대해 고려해야 하는 경우가 많다. null 값을 제대로 처리하지 않으면 NPE(NullPointerExcepion)을 만나게 된다. 안정적인 실행을 위해 NPE가 발생하지 않도록 중간중간 null 체크를 해줘야 한다.

자바 8부터 이런 null 값에 대한 처리를 좀 더 깔끔하게 할 수 있도록 Optional 클래스가 추가되었다.

Optional<T> 클래스는 null 값일 수도 있는 어떤 변수를 감싸주는 래퍼(Wrapper)클래스다. Optional 클래스는 제너릭(Generic)으로 값의 타입을 지정해줘야 한다. Optional 클래스는 여러가지 메소드를 통해 value 값에 접근하기 때문에 바로 NPE가 발생하지 않으며, null일 수도 있는 값을 다루기 위한 다양한 메소드들을 제공한다.

Optional을 사용하면 예상치 못한 NullPointerException 예외를 제공되는 메소드로 간단히 회피할 수 있다.

즉, 복잡한 조건문 없이도 널(null) 값으로 인해 발생하는 예외를 처리할 수 있게 된다.

  1. isPresent() 메소드
  • Boolean 타입

  • Optional 객체가 값을 가지고 있다면 true, 없다면 false

  1. ifPresent() 메소드
  • void 타입

  • ifPresent()는 Optional 객체가 값을 가지고 있으면 실행, 값이 없으면 넘어감

  • findById는 jpa에서 기본으로 제공하는 Optional 타입의 메소드

  • idx로 해당 idx를 가지고 있는 user 정보 확인

  • 조회 시 값이 있으면 예외 발생

    isPresent() 메소드 = true, false 체크
    ifPresent() 메소드 = 값을 가지고 있는지 확인 후 예외처리


Spring assert

※ spring의 Assert는 jUnit의 Asset와는 다름, spring의 Assert는 테스트가 아닌 디버깅을 위한 용도

 if(user == null) {
    throw new IllegalArgumentException("사용자 정보가 없습니다.");
  }  

이 if문을 assert를 사용하면 다음과 같이 바꿀 수 있다.

  Assert.notEmpty(user, "사용자 정보가 없습니다.");

Assert.isTrue()
true가 아닌경우 사용자가 정의한 예외를 던점

1.사용자 아이디가 존재하면 true
Assert.isTrue(user.getId() > 0);

2.사용자 아이디가 존재하면 true, 아니면 사용자 정의 예외 던짐
Assert.isTrue(user.getId() > 0, "사용자 정보가 존재하지 않습니다.", UserNotFoundException.class);

stream

java8부터 지원 되는 대표적인 API인 stream에 대해 알아보려고 한다. Stream은 컬렉션, 배열에 저장되어 있는 요소들을 하나씩 참조하며 반복적인 처리를 가능하게 한다.

Stream과 람다표현식을 사용하면 for문과 if문을 사용하지 않고도 깔끔하고 직관적이게 코드를 변경 할수있다.

몰라도 당연히 처리 할수 있지만, Strame이 생겨서 컬렉션 배열 계열의 처리가 간단하게 할수 있다 이런말로 요약할수 있다.


@Jsonproperty & @JsonNaming

REST API 방식으로 서버와 클라이언트가 데이터를 통신을 할 때 JSON 형식을 주로 사용합니다. 서버단에서는 카멜 케이스(Camel Case) 방식을, 클라이언트단에서는 스네이크 케이스(Snake Case) 방식을 사용합니다.

보통 자바는 카멜 케이스를, JSON형식은 스테이크 케이스 방식으로 표편을 하는게 원칙입니다. 위 네모박스처럼 클라이언트와 서버단의 표현 방식이 다름에 따라 데이터의 Key가 달라지는 경우가 존재합니다. 이러한 문제를 해결할 때 @JsonProperty, @JsonNaming 어노테이션을 사용할 수 있습니다.

@Jsonproperty

@Jsonproperty를 사용하여 key를 매핑시킬 수 있습니다.

위처럼 어노테이션을 추가해주면 된다. @JsonProperty 어노테이션은 객체를 JSON 형식으로 변환할 때 Key의 이름을 설정할 수 있습니다. 위에서 클라이언트와 서버의 표기법이 달라서 발생하는 문제를 @JsonProperty 어노테이션을 통해 해결해보겠습니다. 하지만 필드가 많을 경우 코드가 길어질 우려가 있는데 이럴 때 사용하는 것이 @JsonNaming이다.

@JsonNaming


@Validated

Bean Validation의 유효성 검증 메커니즘을 이용할 수 있습니다. 검증 대상에 @Validated를 넣고 BindingResult를 정의한다. BindingResult에는 요청 데이터의 바인딩 에러와 검사 에러 정보가 저장된다.

    @PostMapping
    // BindingResult 타입의 매개변수를 지정하면 BindingResult 매개 변수가 입력값 검증 예외를 처리한다.
    private ResponseEntity<String> register(@Validated @RequestBody Board board,
                                            BindingResult result) throws Exception{
        log.info(String.valueOf(board));
        log.info("result.hasErrors() : " + result.hasErrors());

        // 입렵값 검증 예외가 발생하면 예외 메시지를 응답한다.
        if(result.hasErrors()) {
            return ResponseEntity.status(HttpStatus.BAD_REQUEST)
                    .body(result.getClass().getSimpleName());
        }
        try {
            boardService.register(board);
            return ResponseEntity.status(HttpStatus.OK).body("success");
        } catch (Exception e) {
            e.printStackTrace();
            return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(e.getMessage());
        }
    }

인터셉터

인터셉터(Interceptor)는 웹 애플리케이션 내에서 특정한 URI 호출을 가로채는 역할을 한다.

필터(Filter)와 인터셉터

서블릿 기술의 필터와 스프링 MVC의 인터셉터는 특정 URI에 접근할 때 제어하는 용도로 사용된다는 공통점이 있다. 하지만 실행 시점에 속하는 영역(Context)에 차이점이 있다.

인터셉터의 경우 스프링에서 관리하기 때문에 스프링 내의 모든 객체에 접근이 가능하지만 필터는 웹 애플리케이션 영역 내의 자원들은 활용할 수 있지만 스프링 내에는 접근이 불가능하다.

스프링 AOP와 인터셉터

특정 객체 동작의 사전 혹은 사후 처리는 AOP 기능을 활용할 수 있지만 컨트롤러의 처리는 인터셉터를 활용하는 경우가 많습니다. AOP의 어드바이스와 인터셉터의 차이는 파라미터의 차이라고 할 수 있습니다. 어드바이스의 경우 JoinPoint나 ProceedingJoinPoint등을 활용해서 호출 대상이 되는 메서드의 파라미터 등을 처리하는 방식이다. 인서셉터는 필터와 유사하게 HttpServletRequest, HttpServletResponse를 파라미터로 받는 구조입니다.


타임리프(Thymeleaf)란?

View Templete Engine으로 JSP, Freemarkerd와 같이 서버에서 클라이언트에게 응답할 브라우저 화면을 만들어주는 역할을 한다.

주요 목표

타임리프의 주 목표는 탬플릿을 만들 때 유지관리가 쉽도록 하는 것이다. 이를 위해 디자인 프로토타입으로 사용되는 탬플릿에 영향을 미치지 않는 방식인 Natural Templates을 기반으로 한다. Natural Templates은 기존 HTML 코드와 구조를 변경하지 않고 덧붙이는 방식이다.

Thymeleaf의 장점

👉 코드를 변경하지 않기 때문에 디자인 팀과 개발 팀간의 협업이 편해진다.

👉 JSP와 달리 Servlet Code로 변환되지 않기 때문에 비즈니스 로직과 분리되어 오로지 View에 집중할 수 있다.

👉 서버상에서 동작하지 않아도 되기 때문에 서버 동작 없이 화면을 확인할 수 있다. 때문에 더미 데이터를 넣고 화면 디자인 및 테스트에 용이하다.

위와같은 타임리프 장점 때문에 Spring에서도 Spring Boot와 타임리프를 함께 사용하는 것을 권장하고 있다. Spring Boot에서는 JSP 사용 시 호환 및 환경설정에 어려움이 많기 때문이다. 반대로 타임리프는 간편하게 Dependency 추가 작업으로 사용할 수 있다.

기본 설정

  1. 의존성 추가

Maven은 pom.xml에, Gradle은 build.gradle에 타임리프의 dependency를 추가해준다.

👉 Maven

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>

👉 Gradle

implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
  1. 타임리프를 적용할 HTML 문서에 네임스페이스 추가
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
    <h1 th:text="${name}">Name</h1>
</body>
</html>

기본 문법

  1. 설정

📎 xmlns:th=" "

<html lang="en" xmlns:th="http://www.thymeleaf.org">
  • 타임리프의 th속성을 사용하기 위해 선언된 네임스테이스이다.
  • 순수 HTML로만 이루어진 페이지인 경우 선언하지 않아도 된다.
  1. 기본 기능

📎 th:text="${}"

<div th:text="${data}"></div>
package com.example.shopping_.DTO;

import lombok.Getter;
import lombok.Setter;
import lombok.ToString;

import java.time.LocalDateTime;

@Getter
@Setter
@ToString
public class ItemDTO {
    private long id;
    private String itemNm;
    private Integer price;
    private String itemDetail;
    private String sellStatCd;
    private LocalDateTime regTime;
    private LocalDateTime updateTime;
}
    @GetMapping("ex02")
    public String thymeleafEx02(Model model) {
        ItemDTO item = new ItemDTO();
        item.setItemDetail("상품 상세 설명");
        item.setItemNm("상품 1");
        item.setPrice(10000);
        item.setRegTime(LocalDateTime.now());

        model.addAttribute("item", item);
        return "/ex02";
    }
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
<h1>상품 데티어 출력</h1>
<div>
  상품명 : <span th:text="${item.getItemNm()}"></span>
</div>
<div>
  상품상세 설명 : <span th:text="${item.getItemDetail()}"></span>
</div>
<div>
  상품 등록일 : <span th:text="${item.regTime}"></span> 
</div>
<div>
  상품가격 : <span th:text="${item.getPrice()}"></span>
</div>
</body>
</html>

JSP의 EL 표현식인 ${}와 마찬가지로 ${} 표현식을 사용해서 컨트롤러에서 전달받은 데이터에 접근할 수 있다.

📎 th:href="@{}"

<body>
  <a th:hrf="@{/boardListPage?currentPageNum={page}}"></a>
</body>
  • <a> 태그의 href 속성과 동일하다.
  • 괄호안에 클릭시 이동하고자하는 url를 입력하면 된다.
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
<h1> 타임리프 링크처리</h1>
<div>
  <a th:href="@{/ex02}">예제2 페이지 이동</a>
  <a th:href="@{https://www.naver.com}">네이버 이동</a>
</div>
</body>
</html>

해당 링크로 이동시 파라미터값을 전달해야 하는 경우도 처리

<div>
  <a th:href="@{/ex07(param1 ='파라미터 데이터1', param2 ='파라미터 데이터2')}">파라미터 전달</a>
</div>
    @GetMapping("ex07")
    public String ex07(String param1, String param2, Model model) {
        model.addAttribute("param1", param1);
        model.addAttribute("param2", param2);
        return "/ex07";
    }
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
<h1>파라미터 전달</h1>
<div th:text="${param1}"></div>
<div th:text="${param2}"></div>
</body>
</html>

📎 th:with="${}"

<div th:with=”userId=${number}” th:text=”${usesrId}”>

변수형태의 값을 재정의하는 속성이다. 즉, th:with를 이용하여 새로운 변수값을 생성할 수 있다.

📎 th:value="${}"

<input type="text" id="userId" th:value="${userId} + '의 이름은 ${userName}"/>
  • input의 value에 값을 삽입할 때 사용한다.
  • 여러개의 값을 넣을땐 + 기호를 사용한다.

Layout

📎 xmlns:layout="", layout:decorator=""
👉 예시

<html lang="ko" xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{board/layout/basic}">

👉 의존성

implementation 'nz.net.ultraq.thymeleaf:thymeleaf-layout-dialect'
  • 타임리프의 layout 기능을 사용하기 위해서는 의존성을 추가해줘야 한다.
  • xmlns:layout은 타임리프의 레이아웃 기능을 사용하기 선언이다. 레이아웃을 적용시킬 HTML 파일에 해당 선언을 한다. 그리고 해당 페이지에 th:fagment로 조각한 공통 영역을 가져와서 삽입해준다.

📎 th:block

<html lagn="ko" 
      xmlns:th="http://www.thymeleaf.org"
      xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
<body>
  	//전체 레이아웃
	<th:block th:fragment="footerFragment">
 	</th:block>
</body>  
  
</html>
  • block은 타임리프 표현을 어느 곳에서든 사용할 수 있도록 하는 구문이다.
  • 해당 기능은 동적인 처리가 필요할 때 사용된다. 주로 layout기능이나 switch에 사용이 많이 된다.

📎 th:fragment=""

<body>

<footer th:fragment="footerFragment">
  <p>안녕하세요</p>
</footer>

</body>
  • 웹페이지에 메뉴 탭이나 네비게이션바와 같이 공통으로 반복되는 영역이 존재한다. 이 공통의 역영들을 매 페이지마다 HTML코드를 반복해서 쓰면 굉장히 지저분 해지는데 fragment가 바로 공통 영역을 정의하여 코드를 정리해준다.
  • 특히, header와 footer에 삽입하여 조각화 한다. 이렇게 만들어진 조각을 삽입하고자 하는 대상 HTML 파일에서 th:replace"[파일경로 :: 조각 이름]"을 통해 삽입한다.

📎 th:replace="~{파일경로 :: 조각이름}"

<body>
  <div th:replace="~{/common/footer :: footerFragment}"></div>  
</body>
  • JSP의 태그와 유사한 속성이다.
  • fragment로 조각화한 공통 영역을 HTML에 삽입하는 역할을 한다.
  • ::을 기준으로 앞에는 조각이 있는 경로를 뒤에는 조각의 이름을 넣어준다.

📎 th:insert="~{파일경로 :: 조각이름}"

<body>
<div th:insert="~{/common/footer :: footerFragment}"></div>  
</body>
  • insert는 태그 내로 조각을 삽입하는 방법이다. replace는 완전하게 대체하기 때문에 replace 태그 가 입력된 <div>가 사라지고 fragment로 조각화한 코드가 완전히 대체된다.

  • 하지만 insert는 insert가 입력된 <div> 안에 fragment를 삽입하는 개념이기 때문에 <div>안에 조각화한 코드가 삽입된다.

  • 형식은 replace와 동일하다.

    Form

    👉 대표예시

<body>
  <form th:action="@{/join}" th:object="${joinForm}" method="post">
    <input type="text" id="userId" th:field="*{userId}" >
    <input type="password" id="userPw" th:field="*{userPw}" >
  </form>
</body> 

📎 th:action="@{}"
<form> 태그 사용시, 해당 경로로 요청을 보낼 때 사용한다.

📎 th:object="${}"

  • <form> 태그에서 데이터를 보내기 위해 Submit을 할 때 데이터가 th:object 속성을 통해 object에 지정한 객체에 값을 담아 넘긴다. 이때 값을 th:field속성과 함꼐 사용하여 넘긴다.
  • Controller와 View 사이의 DTO클래스 객체라고 생각하면 된다.

📎 th:field="*{}"

  • th:object속성과 함께 th:field를 이용해서 HTML 태그에 멤버 변수를 매핑할 수 있다.
  • th:field을 이용한 사용자 입력 필드는 id, name, value 속성 값이 자동으로 매핑된다.
  • th:objectth:field는 Controller에서 특정 클래스의 객체를 전달 받은 경우에만 사용 가능하다.

조건문과 반복문

📎 th:if="",th:unless="{}", th:unless="{}"

<span th:if="${userNum} == 1"></span> 
<span th:unless="${userNum} == 2"></span> 
  • JAVA의 조건문에 해당하는 속성이다. 각각 ifelse를 뜻한다.
  • th:unless는 일반적인 언어의 else 문과는 달리 th:if에 들어가는 조건과 동일한 조건을 지정해야 한다.
  <!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
<table border="1">
  <thead>
    <tr>
      <td>순번</td>
      <td>상품명</td>
      <td>상품설명</td>
      <td>가격</td>
      <td>상품등록일</td>
    </tr>
  </thead>
  <tbody>
    <tr th:each="item, status: ${itemList}">
      <!--  인덱스가 짝수일 경우 status.even은 true가 됩니다.
            즉, 현재 인덱스가 짝수라면 순번에 짝수가 출력됩니다.-->
      <td th:if="${status.even}" th:text="짝수"></td>
      <!-- 현재 인덱스가 짝수가 아닐 경우, 홀수면 홀수가 찍힌다.-->
      <td th:unless="${status.even}" th:text="홀수"></td>
      <td th:text="${item.itemNm}"></td>
      <td th:text="${item.itemDetail}"></td>
      <td th:text="${item.price}"></td>
      <td th:text="${item.regTime}"></td>
    </tr>
  </tbody>
</table>
</body>
</html>

📎 th:each="변수 : ${list}"

  <body>
  <li th:each="pageButton" : ${#numbers.sequece(paging.firstPage, paging.lastPage)}></li>
</body>
  • JSP의 JSTL에서 <c:foreach> 그리고 JAVA의 반복문 중 for문을 뜻한다.

  • ${list}로 값을 받아온 것을 변수로 하나씩 가져온다는 뜻으로, 변수는 이름을 마음대로 지정할 수 있다.

        @GetMapping("ex03")
      public String ex03(Model model) {
          List<ItemDTO> itemDTOList = new ArrayList<>();
    
          for (int i = 0; i < 10; i++) {
              ItemDTO item = new ItemDTO();
              item.setItemDetail("상품 상세 설명" + i);
              item.setItemNm("상품 " + i);
              item.setPrice(10000 * i);
              item.setRegTime(LocalDateTime.now());
    
              itemDTOList.add(item);
          }
          model.addAttribute("itemList", itemDTOList);
          return "/ex03";
      }
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
<h1>상품 리스트 출력 예제</h1>
<table border="1">
  <thead>
    <tr>
      <td>순번</td>
      <td>상품명</td>
      <td>상품설명</td>
      <td>가격</td>
      <td>상품등록일</td>
    </tr>
  </thead>
  <tbody>
    <!--  th:each를 사용하면 자바의 for문처럼 반복문을 사용할 수 있다.
          전달받은 itemList에 있는 데이터를 하나씩 꺼내와서 item에 담아줍니다.
          status에는 현재 반복에 대한 상태 데이터가 존재합니다. 
          변수명은 status 대신 다른 것을 사용해도 됩니다.-->
    <tr th:each="item, status: ${itemList}">
      <!-- 현재 순회하고 있는 데이터의 인덱스를 출력-->
      <td th:text="${status.index}"></td>
      <td th:text="${item.itemNm}"></td>
      <td th:text="${item.itemDetail}"></td>
      <td th:text="${item.price}"></td>
      <td th:text="${item.regTime}"></td>
    </tr>
  </tbody>
</table>
</body>
</html>                                                      

📎 th:switch, th:case

  <th:block th:switch="${userNum}"> 
  <span th:case="1">권한1</span> 
  <span th:case="2">권한2</span> 
</th:block>
  • JAVA의 switch-case문과 동일하다.
  • switch case문으로 제어할 태그를 th:block으로 설정하고 그 안에 코드를 작성한다.
  • userNum라는 변수의 값이 1이거나 2일때 동작하는 예제이다.

  <!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
<table border="1">
  <thead>
  <tr>
    <td>순번</td>
    <td>상품명</td>
    <td>상품설명</td>
    <td>가격</td>
    <td>상품등록일</td>
  </tr>
  </thead>
  <tbody>
  <tr th:each="item, status: ${itemList}">
    <!--  인덱스가 짝수일 경우 status.even은 true가 됩니다.
          즉, 현재 인덱스가 짝수라면 순번에 짝수가 출력됩니다.-->
    <td th:switch="${status.even}">
      <span th:case="true">짝수</span>
      <span th:case="false">홀수</span>
    </td>
    <td th:text="${item.itemNm}"></td>
    <td th:text="${item.itemDetail}"></td>
    <td th:text="${item.price}"></td>
    <td th:text="${item.regTime}"></td>
  </tr>
  </tbody>
</table>
</body>
</html>


Thymeleaf 페이지 레이아웃

보통 웹 사이트를 만드려면 header, footer, menu 등 공통적인 페이지 구성 요소들이 있습니다. 이런 영역들을 각각의 페이지마다 같은 소스 코드를 넣는다면 변경이 일어날 때마다 이를 포함하고 있는 모든 페이지를 수정해야 할 것입니다. Thymeleaf의 페이지 레이아웃을 기능을 사용한다면 공통 요소 관리를 쉽게 할 수 있습니다.

Thymeleaf Layout Dialect dependency 추가하기

// https://mvnrepository.com/artifact/nz.net.ultraq.thymeleaf/thymeleaf-layout-dialect
implementation group: 'nz.net.ultraq.thymeleaf', name: 'thymeleaf-layout-dialect', version: '3.2.0'

thymeleaf-layout-dialect 라이브러리가 설치되면 tmeplates 아래에 fragments 폴더 생성 후 footer.html, header.html 파일을 생성합니다. 그리고 layouts 폴더를 만들고 layout1 파일을 생성한다.

  <!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>footer</title>
</head>
<body>
<div th:fragment="footer">
  footer 영역입니다.
</div>
</body>
</html>
  <!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>header</title>
</head>
<body>
<div th:fragment="header">
    header 영역입니다.
</div>
</body>
</html>
 <!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org"
               xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
<head>
   <meta charset="UTF-8">
   <title>layout</title>
   <th:block layout:fragment="script"></th:block>
   <th:block layout:fragment="css"></th:block>
</head>
<body>
<div th:replace="fragments/header::header"></div>
<div layout:fragment="content"></div>
<div th:replace="fragments/footer::footer"></div>
</body>
</html>

xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"은 layout 기능을 사용하기 위해서 html 태그에 layout 네임스페이스를 추가합니다.

<div th:replace="fragments/header::header"></div> th:replace 속성은 해당 속성이 선언된 html 태그를 다른 html 파일로 치환하는 것으로 이해하면 됩니다. fragements 폴더 아래의 header.html 파일의 th:fragment=header 영역을 가지고 옵니다.

<div layout:fragment="content"> layout에서 변경되는 영역을 fragment로 설정합니다. 앞으로 쇼핑몰을 만들면서 만들 페이지는 이 영역에 들어갑니다.

<div th:replace="fragments/footer::footer"> header 영역과 마찬가지로 fragmenets 폴더 아래의 footer.html 파일의 th:fragment=footer 영역을 가지고 옵니다.

  <!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org"
      xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
      layout:decorate="~{layout/layout1}">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
<div layout:fragment="content">
  본문 영역입니다.
</div>
</body>
</html>

layout:decorate="~{layout/layout1}"> layout폴더 아래 있는 layout1.html을 적용하기 위해 네임스페이스를 추가합니다.

<div layout:fragment="content"> layout1.html 파일의 <div layout:fragment="content">영역에 들어가는 영역입니다.


부트스트랩으로 header, footer 영역 수정하기

폴더구조

header

  <!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">

<div th:fragment="header">
    <nav class="navbar navbar-expand-sm bg-primary navbar-dark">
        <button class="navbar-toggler" type="button" data-toggle="collapse"
                data-target="#navbarTogglerDemo03"
                aria-controls="navbarTogglerDemo03"
                aria-expanded="false" aria-label="Toggle navigation">
            <span class="navbar-toggler-icon"></span>
        </button>
        <a class="navbar-brand" href="/">Shop</a>

        <div class="collapse navbar-collapse" id="navbarTogglerDemo03">
            <ul class="navbar-nav mr-auto mt-2 mt-lg-0">
                <li class="nav-item">
                    <a class="nav-link" href="/admin/item/new">상품 등록</a>
                </li>
                <li class="nav-item">
                    <a class="nav-link" href="/admin/item">상품 관리</a>
                </li>
                <li class="nav-item">
                    <a class="nav-link" href="/cart">장바구니</a>
                </li>
                <li class="nav-item">
                    <a class="nav-link" href="/orders">구매이력</a>
                </li>
                <li class="nav-item">
                    <a class="nav-link" href="/members/login">로그인</a>
                </li>
                <li class="nav-item">
                    <a class="nav-link" href="/members/logout">로그아웃</a>
                </li>
            </ul>
            <form class="form-inline my-2 my-lg-0" th:action="@{/}" method="get">
                <input name="searchQuery" class="form-control mr-sm-2"
                    type="search" placeholder="Search" aria-label="Search">
                <button class="btn btn-outline-success my-2 my-sm-0" type="submit">Search</button>
            </form>
        </div>
    </nav>
</div>

</html>

footer

<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
  <meta charset="UTF-8">
  <title>footer</title>
</head>
<body>
<div th:fragment="footer" class="footer">
  <footer class="page-footer font-small cyan darken-3">
      <div class="footer-copyright text-center py-3">
          2023 Shopping Mall Example WebSite
      </div>
  </footer>
</div>
</body>
</html>

layout1.css

html {
  position: relative;
  min-height: 100%;
  margin: 0;
}

body {
  min-height: 100%;
}

.footer {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  width: 100%;
  padding: 15px 0;
  text-align: center;
}

.content{
  margin-bottom: 100px;
  margin-top: 50px;
  margin-left: 200px;
  margin-right: 200px;
}

layout1.html

<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org"
              xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
<head>
  <meta charset="UTF-8">
  <title>layout</title>

  <!-- CSS only-->
  <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css">
  <link th:href="@{/css//layout1.css}" rel="stylesheet">
  <!-- JS, Propper.js, and jQuery-->
  <script src="https://code.jquery.com/jquery-3.5.1.min.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/popper.js@1.16.1/dist/umd/popper.min.js"></script>
  <script src="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/js/bootstrap.min.js"></script>

  <th:block layout:fragment="script"></th:block>
  <th:block layout:fragment="css"></th:block>
</head>
<body>
<div th:replace="fragments/header::header"></div>
<div layout:fragment="content" class="content"></div>
<div th:replace="fragments/footer::footer"></div>
</body>
</html>

```html

<th:block layout:fragment="css">

</th:block>

<th:block layout:fragment="script">

</th:block>

이름

Incorrect data

이메일 주소

Incorrect data

비밀번호

Incorrect data

주소

Incorrect data

Submit
``` 이제 로그인 페이지를 만들도록 하겠습니다. 로그인 페이지에서는 회원의 아이디와 비밀번호를 입력하는 입력란과 회원가입을 하지 않았을 경우 회원 가입 페이지로 이동할 수 있는 버튼을 만들겠습니다. ```html
이메일 주소
비밀번호

로그인 회원가입
``` 현재 상태로는 로그인을 해도 메뉴바에는 로그인이라는 메뉴가 나타납니다. 로그인 상태라면 로그아웃이라는 메뉴가 나타나야 로그인된 상태임을 알 수 있고, 다른 아이디로 로그인하려면 현재 계정으로부터 로그아웃하고 다시 로그인을 해야 합니다. 상품 등록 메뉴의 경우 관리자만 상품을 등록할 수 있도록 노출돼야 합니다. 이를 도와주는 라이브러리가 `thymeleaf-extras-springsecurity5`가 있습니다. **header** ```html
Shop
    <div class="collapse navbar-collapse" id="navbarTogglerDemo03">
        <ul class="navbar-nav mr-auto mt-2 mt-lg-0">
            <li class="nav-item" sec:authorize="hasAnyAuthority('ROLE_ADMIN')">
                <a class="nav-link" href="/admin/item/new">상품 등록</a>
            </li>
            <li class="nav-item" sec:authorize="hasAnyAuthority('ROLE_ADMIN')">
                <a class="nav-link" href="/admin/items">상품 관리</a>
            </li>
            <li class="nav-item" sec:authorize="isAuthenticated()">
                <a class="nav-link" href="/cart">장바구니</a>
            </li>
            <li class="nav-item" sec:authorize="isAuthenticated()">
                <a class="nav-link" href="/orders">구매이력</a>
            </li>
            <li class="nav-item" sec:authorize="isAnonymous()">
                <a class="nav-link" href="/member/login">로그인</a>
            </li>
            <li class="nav-item" sec:authorize="isAuthenticated()">
                <a class="nav-link" href="/member/logout">로그아웃</a>
            </li>
        </ul>
        <form class="form-inline my-2 my-lg-0" th:action="@{/}" method="get">
            <input name="searchQuery" class="form-control mr-sm-2" type="search" placeholder="Search" aria-label="Search">
            <button class="btn btn-outline-success my-2 my-sm-0" type="submit">Search</button>
        </form>
    </div>
</nav>
```

xmlns:sec="http://www.thymeleaf.org/extras/spring-security" Spring Security 태그를 사용하기 위해서 네임스페이스를 추가합니다.

  <li class="nav-item" sec:authorize="hasAnyAuthority('ROLE_ADMIN')">
         <a class="nav-link" href="/admin/item/new">상품 등록</a>
   </li>
   <li class="nav-item" sec:authorize="hasAnyAuthority('ROLE_ADMIN')">
          <a class="nav-link" href="/admin/items">상품 관리</a>
   </li>

관리자 계정(ROLE_ADMIN)으로 로그인한 경우 상품 등록, 상품 관리 메뉴를 보여줍니다.

                  <li class="nav-item" sec:authorize="isAuthenticated()">
                    <a class="nav-link" href="/cart">장바구니</a>
                </li>
                <li class="nav-item" sec:authorize="isAuthenticated()">
                    <a class="nav-link" href="/orders">구매이력</a>
                </li>

장바구니와 구매이력 페이지의 경우 로그인(인증) 했을 경우에만 보여주도록 합니다.

                <li class="nav-item" sec:authorize="isAnonymous()">
                  <a class="nav-link" href="/member/login">로그인</a>
              </li>

로그인하지 않은 상태이면 로그인 메뉴를 보여줍니다.

                <li class="nav-item" sec:authorize="isAuthenticated()">
                  <a class="nav-link" href="/member/logout">로그아웃</a>
              </li>

로그인 상태이면 로그아웃 메뉴를 보여줍니다.

페이지 권한 설정하기

마지막으로 페이지 접근 권한을 설정하는 방법을 알아보겠습니다. 상품 등록 페이지의 경우 ADMIN 계정만 접근이 가능하고, 일반 USER 계정은 접근을 할 수 없도록 설정 추가

ItemForm


<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
    xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
    layout:decorate="~{layouts/layout1}">

<div layout:fragment="content">

  <h1>상품등록 페이지입니다.</h1>

</div>

</html>

@Builder란?

구글에서 검색하다보면 많은 사람들이 @Builder를 사용하는 것을 볼 수 있는데, 장점들을 보니 공부해야 겠다는 생각이 들었습니다.

Builder Pattern은 객체 생성에서 주입하는 것에 대한 방식입니다. 객체를 생성할 때는 두 가지 패턴이 존재하는데 생성자 패턴과 빌더 패턴입니다. 생성자 패턴은 우리가 흔히 사용하던 Constructor입니다.

@Getter
@Setter
public class Car {

    private String id;
    private String name;

    public Car(String id, String name) {
        this.id = id;
        this.name = name;
    }
public class CarImpl {

    private String id = "1";
    private String name = "carTest";

    Car car1 = new Car(id, name);
    Car car2 = new Car(name, id);
}

이건 Car 객체를 구현한건데 일반 생성자 패턴을 사용하면 코드에서 파라미터에 대한 정확성과 오류를 찾기 어려워지게 됩니다. 즉, 다른 사람이 코드를 볼 때 어떤 파라미터가 정확하게 전달 되었는지 확인하기 힘듭니다. 그렇기에 Builder를 사용합니다.

빌더를 사용하는 방식에는 Builder class를 스태틱으로 가져와 사용하는 방식과 Lombok으로 편하게 사용하는 두가지 방식이 존재하는데 저는 Lombok형식을 공부하고자 합니다.

import lombok.Builder;
import lombok.Getter;
import lombok.Setter;

@Getter
@Setter
public class Car {

    private String id;
    private String name;

    @Builder    // 생성자를 만든 후 그 위에 @Builder 애노테이션 적용
    public Car(String id, String name) {
        this.id = id;
        this.name = name;
    }
}

이렇게 해서 간단하게 Lombok을 통해 @Builder 애노테이션을 생성자를 만든 후 적용 해준다.

public class CarImpl {

    private String id = "1";
    private String name = "carTest";

    Car car3 = Car.builder()
            .id(id)
            .name(name)
            .build();
}

그 후 이런식으로 생성자 파라미터 주입을 해준다. 이렇게 하면 각 인자에 대한 파라미터 주입이 되게 명확해진다.

import lombok.Builder;
import lombok.Getter;

@Getter //Getter 생성 
public class LombokPerson {

	private  String name;
	private  String grade;
	private  int age;
	
	@Builder // 생성자 만든 후 위에 @Build 어노테이션 적용 
	public LombokPerson(String name, String grade, int age) {
		this.name = name;
		this.grade = grade;
		this.age = age;
	}
}
String name = "jueun";
int age = 24;
String grade = "4";
Person p1 = Person.builder()
		        .name(name)
		        .age(age)
		        .grade(grade)
		        .build();
}

빌더 패턴 장점

  • 어떤 필드에 어떤 값을 채워야 할지 명확히 지정할 수 있음
  • 필수 및 선택인자가 많아질수록 생성자 방식보다 가독성이 좋다. ( 불필요한 생성자의 제거)
  • 자바빈 패턴(setter를 이용하는 방식)보다 안전함.
  • setter 생성을 방지하기 때문에 객체를 변경할 수 없음 (불변성 보장)
  • 데이터의 순서에 상관없이 객체생성 가능
  • 명시적 선언으로 이해하기가 쉽고 각 인자가 어떤 의미인지 알기 쉽다.(가독성)
  • 한번에 객체를 생성하므로 객체 일관성이 깨지지 않는다.
  • build() 함수가 null인지 체크해주므로 검증이 가능한다. (안그러면 set하지않은 객체에대해 get을 하게되는경우 nullPointerExcetpion발생 등등의 문제가 생김)

빌더 단위 테스트

package kr.pe.playdata.domain;

import org.junit.jupiter.api.Test;

public class LombokPerTest {
    @Test
    public void 빌더_테스트_2() {
    	
        //given
		String name = "jueun";
		int age = 24;
		String grade = "4";
        
        //when
		LombokPerson p1 = LombokPerson.builder()
		        .name(name)
		        .age(age)
		        .grade(grade)
		        .build();
                
        //then
        assertThat(p1.getGrade()).isEqualTo(grade);
        assertThat(p1.getAge()).isEqualTo(age);
        assertThat(p1.getName()).isEqualTo(name);
                
    }
}

파일 업로드

plugins {
  id 'java'
  id 'org.springframework.boot' version '2.7.10'
  id 'io.spring.dependency-management' version '1.0.15.RELEASE'
}

group = 'com.example'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '11'

configurations {
  compileOnly {
      extendsFrom annotationProcessor
  }
}

repositories {
  mavenCentral()
}

dependencies {
  implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
  implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
  implementation 'org.springframework.boot:spring-boot-starter-web'
  compileOnly 'org.projectlombok:lombok'
  developmentOnly 'org.springframework.boot:spring-boot-devtools'
  runtimeOnly 'com.h2database:h2'
  annotationProcessor 'org.projectlombok:lombok'
  testImplementation 'org.springframework.boot:spring-boot-starter-test'
}

tasks.named('test') {
  useJUnitPlatform()
}
spring:
  servlet:
    multipart:
      max-file-size: 10MB
      max-request-size: 30MB
  h2:
    console:
      enabled: true
      path: /h2-console
  datasource:
    driver-class-name: org.h2.Driver
    url: jdbc:h2:tcp://localhost/~/test
    username: sa
    password:
  jpa:
    hibernate:
      ddl-auto: create
    properties:
      hibernate:
        format_sql: true

file:
  dir: c:/upload/file/

logging:
  level:
    org.hibernate.SQL: debug

<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head>
  <meta charset="utf-8">
</head>
<body>
<div class="container">
  <div class="py-5 text-center">
    <h2>상품 등록</h2>
  </div>
  <form th:action method="post" enctype="multipart/form-data">
    <ul>
      <li>상품명 <input type="text" name="itemName"></li>
      <li>첨부파일<input type="file" name="attachFile" ></li>
      <li>이미지 파일들<input type="file" multiple="multiple"
                        name="imageFiles" ></li>
    </ul>
    <input type="submit"/>
  </form>
</div> <!-- /container -->
</body>
</html>
<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head>
  <meta charset="utf-8">
</head>
<body>
<div class="container">
  <div class="py-5 text-center">
    <h2>상품 조회</h2>
  </div>
  상품명: <span th:text="${item.itemName}">상품명</span><br/>
  첨부파일: <a th:if="${item.attachFile}" th:href="|/attach/${item.id}|"
           th:text="${item.getAttachFile().getUploadFileName()}" /><br/>
  <img th:each="imageFile : ${item.imageFiles}" th:src="|/images/${imageFile.getStoreFileName()}|" width="300" height="300"/>
</div> <!-- /container -->
</body>
</html>

ItemController


package com.example.fileupload2.controller;

import com.example.fileupload2.domain.Item;
import com.example.fileupload2.domain.ItemForm;
import com.example.fileupload2.domain.UploadFile;
import com.example.fileupload2.file.FileStore;
import com.example.fileupload2.repository.ItemRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.io.Resource;
import org.springframework.core.io.UrlResource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
import org.springframework.web.util.UriUtils;

import java.io.IOException;
import java.net.MalformedURLException;
import java.nio.charset.StandardCharsets;
import java.util.List;

@Slf4j
@Controller
@RequiredArgsConstructor
public class ItemController {

    private final ItemRepository itemRepository;
    private final FileStore fileStore;

    @GetMapping("/items/new")
    public String newItem(@ModelAttribute ItemForm form) {
        return "item-form";
    }

    @PostMapping("/items/new")
    public String saveItem(@ModelAttribute ItemForm form, RedirectAttributes redirectAttributes) throws IOException {
        // ItemForm에서 MultipartFile 타입으로 들어간 attachFile을 가져오고
        // storeFile 메소드에 넣어준다. 그러면 UploadFile에
        // 사용자가 등록한 파일명과 서버에서 관리하는 파일명 두 개가 등록이 된다.
        UploadFile attachFile = fileStore.storeFile(form.getAttachFile());
        // FileStore에 ItemForm List<MultipartFile>로 이미지를 여러개 넣은 것을
        // 넣어준다. 그리고 그것을 List<UploadFile>에 넣어준다.
        List<UploadFile> storeImageFiles = fileStore.storeFiles(form.getImageFiles());

        //데이터베이스에 저장
        // 파일 이름(사용자가 등록할 때 적을 이름), 파일 등록할 때 이름, 이미지 파일들을 Item에 넣어줌
        Item item = new Item(form.getItemName(), attachFile, storeImageFiles);
        // JPA에 있는 save 메소드를 사용해서 DB에 저장
        itemRepository.save(item);

        // return이 redirect이기 때문에 redirectAttributes를 사용해서 값을 넣어줌
        redirectAttributes.addAttribute("itemId", item.getId());

        return "redirect:/items/{itemId}";
    }

    // Post형식인 saveItem메소드가 return을 /items/{itemId}로 바로 던져줘서
    // @GetMapping("/items/{id}")을 통해 가져와서 item.getId()에 해당하는
    // 페이지를 보여준다.
    @GetMapping("/items/{id}")
    public String items(@PathVariable Long id, Model model) {
        Item item = itemRepository.findById(id).orElseThrow(() -> new IllegalArgumentException("NOT FOUND ITEM :" + id));
        model.addAttribute("item", item);
        return "item-view";
    }


    // 이미지가 보이게 하려면 이 메소드 작성
    @ResponseBody
    @GetMapping("/images/{filename}")
    public Resource downloadImage(@PathVariable String filename) throws MalformedURLException {
        // "file:C:/upload/file/xxxxxxxx.png" 이런식으로 되는데
        // 여기서 x는 파일마다 다르므로 임시로 x라고 표시함
        // 그러면 UrlResource가 찾아온다.
        return new UrlResource("file:" + fileStore.getFullPath(filename));
    }

    // 첨부 파일 다운로드
    @GetMapping("/attach/{itemId}")
    public ResponseEntity<Resource> downloadAttach(@PathVariable Long itemId) throws MalformedURLException {
        Item item = itemRepository.findById(itemId)
                .orElseThrow(() -> new IllegalArgumentException("NOT FOUND ITEM :" + itemId));
        String storeFileName = item.getAttachFile().getStoreFileName();
        String uploadFileName = item.getAttachFile().getUploadFileName();

        UrlResource resource = new UrlResource("file:" + fileStore.getFullPath(storeFileName));

        log.info("uploadFileName={}", uploadFileName);
        String encodedUploadFileName = UriUtils.encode(uploadFileName, StandardCharsets.UTF_8);
        String contentDisposition = "attachment; filename=\"" + encodedUploadFileName + "\"";
        return ResponseEntity.ok().header(HttpHeaders.CONTENT_DISPOSITION, contentDisposition).body(resource);
    }
}

Item

package com.example.fileupload2.domain;

import lombok.*;

import javax.persistence.*;
import java.util.ArrayList;
import java.util.List;

@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Entity
public class Item {

    @Id
    @GeneratedValue
    private Long id;
    private String itemName;

    // 가독성을 위해서 사용합니다.
    // UploadFile라는 객체로 묶어 둔 것입니다.
    /*
    *       private String uploadFileName;
            private String storeFileName;
            이렇게 두 개가 UploadFile입니다.
    * */
    // UploadFile이란 것을 여기에 일일히 사용한다면
    // 더러워지니 @Embedded로 쓰고 묶어둔 곳은
    // @Embeddable을 써서 표시를 해둡니다.
    @Embedded
    @AttributeOverrides({
            // @Embedded를 이용해 객체로 Entity의 Column을 표현한다면,
            // Column 이름이 중복되는 문제가 발생하기도 합니다.
            // JPA에서 필드의 이름이 아닌 개발자가 지정한 이름으로 column을 생성하기 위해서
            // @Column(name = "필드이름")을 사용하면 됩니다.
            // 하지만, 이는 상식적으로 생각해보았을때 해결방법이 되지 못합니다.
            // 바로 같은 객체를 사용하기 때문입니다. 따라서 객체안의 컬럼을 재정의하는 방법이 필요한데
            // 그것이 바로 @AttributeOverride입니다.
            @AttributeOverride(name = "uploadFileName", column = @Column(name = "attach_upload_file_name")),
            @AttributeOverride(name = "storeFileName", column = @Column(name = "attach_store_file_name"))
    })
    private UploadFile attachFile;


    // 값 타입 컬렉션을 매핑할 때 사용합니다.
    @ElementCollection
    // @CollectionTable은 값 타입 컬렉션을 매핑할 테이블에 대한 정보를 지정하는 역할을 수행합니다.
    //  @JoinColumn : 외래 키를 매핑할 때 사용합니다. name 속성에는 매핑할 외래 키 컬럼명(이름)을 지정합니다.
    @CollectionTable(name = "item_image", joinColumns = @JoinColumn(name = "item_id"))
    private List<UploadFile> imageFiles = new ArrayList<>();

    public Item(String itemName, UploadFile attachFile, List<UploadFile> imageFiles) {
        this.itemName = itemName;
        this.attachFile = attachFile;
        this.imageFiles = imageFiles;
    }
}

ItemForm

package com.example.fileupload2.domain;


import lombok.Data;
import org.springframework.web.multipart.MultipartFile;

import java.util.List;

@Data
// form을 가지고 데이터가 왔다갔다 해야함
public class ItemForm {
    private Long itemId;
    private String itemName;
    // 이미지를 다중 업로드 하기 위해서 List에 MultipartFile을 사용
    private List<MultipartFile> imageFiles;
    private MultipartFile attachFile;
}

UploadFile

package com.example.fileupload2.domain;

import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;

import javax.persistence.Embeddable;

@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Embeddable
public class UploadFile {
    // 사용자가 올린 파일명
    private String uploadFileName;
    // 실제 서버에서 관리되는 파일명
    private String storeFileName;

    public UploadFile(String uploadFileName, String storeFileName) {
        this.uploadFileName = uploadFileName;
        this.storeFileName = storeFileName;
    }
}

FileStore

package com.example.fileupload2.file;

import com.example.fileupload2.domain.UploadFile;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;

import java.io.File;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;

@Component
// 멀티파트 파일을 서버에 저장하는 역할을 한다.
public class FileStore {
    @Value("${file.dir}")
    private String fileDir;

    public String getFullPath(String filename) {
        return fileDir + filename;
    }

    // 여러개의 파일을 담을 때
    public List<UploadFile> storeFiles(List<MultipartFile> multipartFiles) throws IOException {
        List<UploadFile> storeFileResult = new ArrayList<>();
        for (MultipartFile multipartFile : multipartFiles) {
            if (!multipartFile.isEmpty()) {
                storeFileResult.add(storeFile(multipartFile));
            }
        }
        return storeFileResult;
    }

    // MultipartFile을 가지고 파일을 저장한 다음 UploadFile로 반환해준다.
    public UploadFile storeFile(MultipartFile multipartFile) throws IOException {
        if (multipartFile.isEmpty()) {
            return null;
        }

        // 사용자가 업로드한 파일 name을 가지고 온다.
        String originalFilename = multipartFile.getOriginalFilename();
        // 사용자가 업로드한 파일 이름을 createStoreFileName에 넘겨준다.
        // 예를들어, "qwe-qwe-123-qqwe.png";을 storeFilename에 담아둔다.
        String storeFilename = createStoreFileName(originalFilename);
        // "qwe-qwe-123-qqwe.png";을 넘기면 getFullPath 메소드에서
        // "c:/upload/file/qwe-qwe-123-qqwe.png";이렇게 합쳐진다.
        multipartFile.transferTo(new File(getFullPath(storeFilename)));
        // 사용자가 업로드한 파일 이름과 "qwe-qwe-123-qqwe.png";을
        // UploadFile에 보내준다.
        return new UploadFile(originalFilename, storeFilename);
    }

    // 서버 내부에서 관리하는 파일명은 유일한 이름을 생성하는 "UUID"를 사용해서 충돌되지 않도록 한다.
    private String createStoreFileName(String originalFilename) {
        // extractExt메소드를 실행해준 것을 ext에 담아준다.
        // 예를들어, extractExt 메소드에서 png을 리턴해주면 그것을 ext에 담는 것이다.
        String ext = extractExt(originalFilename);
        // uuid를 뽑으면 "qwe-qwe-123-qqwe"; 이런식으로 된다.
        String uuid = UUID.randomUUID().toString();
        // 그러면 리턴은 "qwe-qwe-123-qqwe.png"; 이런식으로 된다.
        return uuid + "." + ext;
    }

    // 확장자를 별도로 추출해서 서버 내부에서 관리하는 파일명에도 붙여준다.
    private String extractExt(String originalFilename) {
        // 위치를 가져온다. (image.png 여기서 .을 의미합니다.)
        int pos = originalFilename.lastIndexOf(".");
        // image.png에서 .다음 png를 뽑을 수 있습니다.
        return originalFilename.substring(pos + 1);
    }
}

ItemRepository

package com.example.fileupload2.repository;

import com.example.fileupload2.domain.Item;
import org.springframework.data.repository.Repository;

import java.util.Optional;

public interface ItemRepository extends Repository<Item, Long> {
    void save(Item item);

    Optional<Item> findById(Long id);
}

여기서 의문이 있다. JPA에서 repository는 JpaRepository아닌가?
근데 여기서는 Repository입니다.

JPA를 사용하는 이상 Spring Data JPA를 사용하지 않는 사람은 거의 없을 것이고, Spring Data JPA를 사용하는데 JpaRepository 인터페이스를 사용하지 않는 사람도 거의 없을 것입니다. save, findById, findAll 같은 기본적인 CRUD 명세를 제공해주고, 해당 명세들에 대한 구현체를 제공해주기까지 하며(SimpleJpaRepository) 쿼리 메서드 기능이라는 강력한 기능까지 제공해주기 때문입니다.

근데 나는 단건 조회만 쓸거 같은데 삭제 기능이 필요하지 않는데 정의된 메서드가 너무 많다고 생각되는 것은 JpaRepository를 상속해서 그럽니다.

Repository 인터페이스에는 어떠한 메서드도 정의되어 있지 않습니다. 마커 인터페이스일 뿐입니다. 때문에 Repository를 상속하면 불필요한 메서드를 정의할 필요도, 구현할 필요도 없습니다. 그러면 CrudRepository에 정의된 CRUD 기능은 어케 하냐는 물음이 있을 수 있겠죠? 쿼리 메서드 규칙대로 메서드 시그니처를 정의하기만 하면 됩니다. 구현체의 구현은 Spring Data JPA가 알아서 다 해주기 때문이죠. 이렇게 Repository를 사용하면 모든 문제를 다 해결하면서도 불필요한 인터페이스를 만들지 않을 수 있습니다.

근데 이것은 선택상황입니다.

그리고 controller를 보면 view에서 action으로 url을 지정해주지 않았는데 잘 가지는 것을 볼 수 있습니다.

  @GetMapping("/items/new")
    public String newItem(@ModelAttribute ItemForm form) {
        return "item-form";
    }

    @PostMapping("/items/new")
    public String saveItem(@ModelAttribute ItemForm form, RedirectAttributes redirectAttributes) throws IOException {
        UploadFile attachFile = fileStore.storeFile(form.getAttachFile());
        List<UploadFile> storeImageFiles = fileStore.storeFiles(form.getImageFiles());

        //데이터베이스에 저장
        Item item = new Item(form.getItemName(), attachFile, storeImageFiles);
        itemRepository.save(item);

        redirectAttributes.addAttribute("itemId", item.getId());

        return "redirect:/items/{itemId}";
    }

action을 생략하면 현재 URL을 그대로 사용합니다. 대신에! 전송방식이 GET에서 POST로 변경되었지요! 이게 중요합니다. 같은 URL인데, 수정 화면을 노출할 때는 GET을 사용하고, 수정 화면의 데이터를 실제 변경할 떄는 POST를 사용했다는 것입니다. 이런 방식이 좋은 URL 설계 방식입니다.


Spring Ajax

	<dependency>
		    <groupId>com.fasterxml.jackson.core</groupId>
		    <artifactId>jackson-databind</artifactId>
		    <version>2.9.4</version>
		</dependency>
		
		
		<dependency>
		    <groupId>com.fasterxml.jackson.dataformat</groupId>
		    <artifactId>jackson-dataformat-xml</artifactId>
		    <version>2.9.4</version>
		</dependency>
		
		<!-- gson  java인스턴스를 JSN타입의 문자열로 변환해야하는 일 -->
		<dependency>
		    <groupId>com.google.code.gson</groupId>
		    <artifactId>gson</artifactId>
		    <version>2.8.2</version>
		</dependency>
	// 화장품, 상세정보 등록 아작스
let formdata = {'type':type,'brand':brand,'skinType':skinType,'price':price,'title':title
				,'content':content,'capacity':capacity,'period':period,'nation':nation,'useMethod':useMethod};
		
$.ajax({
	 // 클라이언트가 요청을 보낼 서버의 URL 주소
	url: '${pageContext.request.contextPath}/cosmetic/cosmeticRegister',
	data: formdata,        // HTTP 요청과 함께 서버로 보낼 데이터
	type: 'POST',          // HTTP 요청 방식(GET, POST)
	dataType: 'html',      // 호출 했을 때 결과타입
	success : function(data) {
	}
});
		

// 첨부파일 등록 ajax
$.ajax({
// 클라이언트가 요청을 보낼 서버의 URL 주소
	url: '${pageContext.request.contextPath}/cosmetic/AttachRegister', 
	data: JSON.stringify(attachList),        // HTTP 요청과 함께 서버로 보낼 데이터
	type: 'POST',          // HTTP 요청 방식(GET, POST)
	dataType: 'json',      // 호출 했을 때 결과타입
	contentType: "application/json",
	success : function(data) {
	}
});
	@RequestMapping(value = "cosmeticRegister", method = RequestMethod.POST)
	@ResponseBody
	public ResponseEntity<String> cosmeticRegister(CosmeticVo cosmetic,  DescriptionVo description){
		ResponseEntity<String> r = null;
		
		log.info("cosmetic : "+cosmetic.toString());
		log.info("description : "+description.toString());
		
		// cno 값을 리턴해야함
		r= new ResponseEntity<>(HttpStatus.OK);
		return r;
	}
	
	@RequestMapping(value = "AttachRegister", method = RequestMethod.POST)
	@ResponseBody
	public ResponseEntity<String> AttachRegister(@RequestBody List<AttachFileVo> attachList ){ //Map<String, Object> params
		ResponseEntity<String> r = null;
		log.info("attach : "+attachList.toString());
		
		r= new ResponseEntity<>(HttpStatus.OK);
		return r;
	}

※ 주의점

컨트롤러의 파라미터앞에 @RequestBody 어노테이션을 넣어야 한다. 프론트에서 전달 한 json 데이터를 해당 파라미터에 매핑시킨다는 뜻. 

1번째 아작스와 2번째 아작스의 차이는 contentType  이다.  contentType 이 application/json 인 경우 json형식으로 데이터를 보낸다는 말이다. 따라서 아작스에서 데이터를 보낼때도 json형식으로 보내줘야한다. 그래서 JSON.stringify(attachList) 함수를 통해서 json형식으로 바꾸어주었다.

Ajax을 담을 DTO클래스

	@Data
	public class Jamong {
    String name;
    int age;
}

1. 기본

Ajax의 최소화 형태다.
data를 전송해서 필요한 로직들을 처리하고 값을 return한다.
여기서 @ReponseBody의 역할을 알아야 하는데, @RequestMapping으로 String type을 반환해주면, ViewResolver에서 정의한 prefix와 suffix가 return값에 추가되어 view로 이동이 된다. @ResponseBody를 사용해주면 view를 생성해주는 것이 아니라, JSON 혹은 Object 형태로 데이터를 넘겨준다.

Client

	  $('#btn1').on('click', function(){
        let form = {
                name: "jamong",
                age: 23
        }
        $.ajax({
            url: "requestObject",
            type: "POST",
            data: form,
            success: function(data){
                $('#result').text(data);
            },
            error: function(){
                alert("simpleWithObject err");
            }
        });
    });

Server

	 @RequestMapping(value="/requestObject", method=RequestMethod.POST)
    @ResponseBody
    public String simpleWithObject(Jamong jamong) {
        //필요한 로직 처리
        return jamong.getName() + jamong.getAge();
    }

View

	<body>
    <button id="btn1">simpleAJAX</button>
    <div id="result"></div>
</body>

2. 폼 파라미터 넘기기

폼의 파라미터를 넘기기 위해 serialize() 함수를 사용한다.
필요한 로직 처리를 하고 마찬가지로 @ResponseBody Annotation을 사용하여 Object형태로 넘긴다.

Client

	
    $('#btn2').on('click', function(){
        $.ajax({
            url: "serialize",
            type: "POST",
            data: $("#frm").serialize(),
            success: function(data){
                $('#result').text(data);
            },
            error: function(){
                alert("serialize err");
            }
        });
    });

Server

	  @RequestMapping(value="/serialize", method=RequestMethod.POST)
    @ResponseBody
    public String serialize(Jamong jamong) {
        //필요한 로직 처리   
        return jamong.getName() + jamong.getAge();
    }

View

	<body>
<form id="frm">
    name : <input type="text" name="name" id="name"><br>
    age : <input type="text" name="age" id="age">
</form>
    <button id="btn2">serialize</button>
    <div id="result"></div>
</body>

3. JSON.stringify()

Ajax에는 dataType이라는 옵션이 있다. 설정을 안해주면 MIME타입에 의해 자동으로 설정이 된다. JSON 형태로 보내고 싶으면 JSON.stringify() 함수를 많이 사용한다. JSON.stringify() 메서드는 JavaScript 값이나 객체를 JSON 문자열로 변환합니다.

JSON.stringify(value, replacer, space)

  • value(필수): JSON 문자열로 변환할 값이다.(배열, 객체, 또는 숫자, 문자 등이 될 수 있다.)

  • replacer(선택): 함수 또는 배열이 될 수 있다. 이 값이 null 이거나 제공되지 않으면, 객체의 모든 속성들이 JSON 문자열 결과에 포함된다.


  • space(선택): 가독성을 목적으로 JSON 문자열 출력에 공백을 삽입하는 데 사용되는데, string이나 number 객체가 될 수 있다. 이 값이 null 이거나, 제공되지 않으면 공백이 사용되지 않는다.

JSON.stringify() 특성

value의 데이터 타입이 number 또는 boolean일 경우, 그 값 자체를 그대로 가져오고, 데이터타입은 string(문자열)이 된다.

여기서 jackson 라이브러리를 추가안해주면 오류가 뜬다.
그렇기 때문에 pom.xml에 추가해줘야 한다.

pom.xml

	     <dependency>
            <groupId>com.fasterxml.jackson.core</groupId>
            <artifactId>jackson-databind</artifactId>
            <version>2.9.5</version>
        </dependency>

코드를 보기 이전에 이번에는 @RequestBody에 대해서 알아야한다.

클라이언트쪽에서 데이터 형태를 JSON으로 보내주면, 서버쪽에서도 JSON으로 받아줘야한다.

JSON형태로 받게해주는 Annotation이 @RequestBody이다.

Client

	    $('#btn3').on('click', function(){
        let form = {
                name: "jamong",
                age: 23
        }
        $.ajax({
            url: " stringify",
            type: "POST",
            data: JSON.stringify(form),
            contentType: "application/json; charset=utf-8;",
            dataType: "json",
            success: function(data){
                var txt = data.name + data.age;
                $('#result').text(txt);
            },
            error: function(){
                alert("stringify err");
            }
        });
    });

Server

	  @RequestMapping(value="/stringify", method=RequestMethod.POST)
    @ResponseBody
    public Object stringify(@RequestBody Jamong jamong) {
        HashMap<String, Object> map = new HashMap<String, Object>();
        map.put("name", jamong.getName());
        map.put("age", jamong.getAge());
        return map;
    }

View

	<body>
    <button id="btn3">stringify</button>
    <div id="result"></div>
</body>

4. @RestController

다음은 @Controller가 아닌 @RestController에서 JSON타입으로 데이터를 주고받을거다.

@RestController Annotation을 사용하면, @ResponseBody Annotation을 빼고 사용하면된다.

@RestController가 알아서 JSON형태로 return해준다.

@Controller 와 @RestController차이

Spring에서 컨트롤러를 지정해주기 위한 어노테이션은 @Controller와 @RestController가 있습니다. 전통적인 Spring MVC 컨트롤러인 @Controller와 RESTful 웹 서비스의 컨트롤러인 @RestController의 주요한 차이점은 HTTP Response Body가 생성되는 방식입니다.
@Controller의 역할은 Model 객체를 만들어 데이터를 담고 View를 반환하는 것이고, @RestController는 단순히 객체만을 반환하고 객체 데이터는 JSON 또는 XML 형식으로 HTTP 응답에 담아 전송합니다.
물론 @Controller도 @ResponseBody를 사용해서 만들 수 있지만 이런 방식은 RESTful 웹 서비스의 기본 동작이기 때문에 Spring은 @Controller와 @ResponseBody의 동작을 조합한 @RestController를 도입했습니다. @RestController는 @Controller와 @ResponseBody의 동작을 하나로 결합한 컨트롤러라 보시면 됩니다.




여기서 추가시

	<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
	xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
	xmlns:context="http://www.springframework.org/schema/context"
	xmlns:mvc="http://www.springframework.org/schema/mvc"
	xsi:schemaLocation="http://www.springframework.org/schema/mvc http://www.springframework.org/schema/mvc/spring-mvc-4.3.xsd
		http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd
		http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context-4.3.xsd">

	<!-- Spring MVC annotation(주석문)을 사용하기 위한 설정 -->
	<context:annotation-config/>
	
	<!-- xml 객체 생성 -->
	<!-- ViewResolver 설정(사용자의 view의 위치, 확장자명)-->
	<bean id="viewResolver" class="org.springframework.web.servlet.view.InternalResourceViewResolver">
		<property name="prefix" value="/WEB-INF/views/"></property> <!-- 경로 -->
		<property name="suffix" value=".jsp"></property>
	</bean>
	
	<!-- 사용자지정 -->
	<!-- java의 공통 패키지 -->
	<context:component-scan base-package="bit.com.a"/>
	
	<!-- Ajax 주석문 허가 -->
	<mvc:annotation-driven/>
	<!-- 스프링에서 처리할 수 없는 요청은 tomcat에 위임 -->
	<mvc:default-servlet-handler/>

</beans>

또한 Ajax 통신시 컨트롤러 부분에서 무조건 @ResponseBody를 사용해야 한다.

hello.jsp

	<%@page import="bit.com.a.model.MyClass"%>
<%@ page language="java" contentType="text/html; charset=UTF-8"
	pageEncoding="UTF-8"%>
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Insert title here</title>
<script src="https://code.jquery.com/jquery-3.4.1.min.js"></script>
</head>
<body>

	<!-- 1 -->
	<%
		MyClass cls = (MyClass) request.getAttribute("myCls");
	%>
	number:<%=cls.getNumber()%><br> name:<%=cls.getName()%><br>

	<!--2 -->
	<br>
	<br> number:${myCls.number}
	<br> name: ${myCls.name }

	<!-- 3 -->
	<form>
		아이디:<input type="text" id="checkid"><br> <br>
		<button type="button" id="_check" onclick="idcheck()">id 체크</button>
	</form>

	<script>
		function idcheck() {
			alert("idCheck");

			$.ajax({
				url : "./idCheck.do",
				type : "get",
				data : "id=" + $("#checkid").val(),
				success : function(data) {
					alert("되요ㅋ");
					alert(data);
				},
				error : function() {
					alert("에러나요");
				}
			})
		}
	</script>

	<!-- 4 -->
	<form method="post">
		이름: <input type="text" id="_name" value="홍길동"><br> 전화: <input
			type="text" id="_phone" value="123-4567-789"><br> 이메일: <input
			type="text" id="_email" value="http200@kakao.com"><br>
		생년월일: <input type="text" id="_birth" value="2001/11/23"><br>
		<button type="button" id="account">account</button>
	</form>

	<script type="text/javascript">
		$("#account").click(function() {
			//alert("click");

			var human = {
				name : $("#_name").val(),
				tel : $("#_phone").val(),
				email : $("#_email").val(),
				birth : $("#_birth").val()
			};
			console.log(human);
			$.ajax({
				url : "./account.do",
				data : human,
				type : "post",
				dataType : "json",
				async : true,
				success : function(resp) {
					alert("success");
					alert(resp.msg);
					alert(resp.name);

				},
				error : function() {
					alert("error")
				}
			});
		});
	</script>

	<br>
	<br>
	<br>
	<!-- 5 -->

	이름:
	<input type="text" id="_name1" value="정수동">
	<br> 전화:
	<input type="text" id="_phone1" value="123-4567-589">
	<br> 이메일:
	<input type="text" id="_email1" value="http200@kakao.com">
	<br> 생년월일:
	<input type="text" id="_birth1" value="2001-11-21">
	<button type="button" id="account1">account</button>
	<br>

	<script>
		/* Json -> Map */

		$("#account1").on("click", function() {
			//alert("account1")
			var data = {};

			data["name"] = $("#_name1").val();
			data["tel"] = $("#_phone1").val();
			data["email"] = $("#_email1").val();

			var birth = $("#_birth1").val();
			data["birth"] = birth.replace(/-/g, "");
			//alert(data["birth"]);

			$.ajax({
				contentType : 'application/json',
				dataType : 'json',
				url : "updateUser.do",
				type : 'post',
				data : JSON.stringify(data), // json -> string <-> parse
				success : function(resp) {
					alert("success")
				},
				error : function() {
					alert("error");
				}
			});
		});
	</script>

</body>
</html>

HelloController.java

	package bit.com.a;

import java.util.Date;
import java.util.HashMap;
import java.util.Map;

import javax.servlet.http.HttpSession;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.ResponseBody;

import bit.com.a.model.Human;
import bit.com.a.model.MyClass;


@Controller
public class HelloController {
	
	private static Logger logger = LoggerFactory.getLogger(HelloController.class);
	
//	1
	@RequestMapping("hello.do")
	public String hello(Model model) {
		logger.info("HelloController hello" + new Date());
		
		MyClass cls = new MyClass(101, "일지매");
		model.addAttribute("myCls", cls);
		
		return "hello";
	}
	/* 2 controller -> controller */
	@RequestMapping(value="move.do", method = RequestMethod.GET)
	public String move() {
		logger.info("HelloController move " + new Date());
		
		//sendRedirect(컨트롤러에서 컨트롤러로 보낸다)
		return "redirect:/hello.do"; 
//		return "forward:/hello.do"; 데이터를 가져갈때는 forward
	}
	
//	3번
//	Ajax 용 return값은 ajax에서 가져오는 데이터 (넘겨줘야할 데이터)
	//Ajax사용시 @ResponseBody를 사용해야한다
	@ResponseBody
	@RequestMapping(value="idCheck.do", produces = "application/String; charset=utf-8")
	//적어주기만 하면 무조건 session에 넘어간다 (의존성)
	public String idCheck(String id, HttpSession session) {
		logger.info("HelloController idCheck " + new Date());
		logger.info("id:" + id);
		
		String str = "오케이";
		return str;	
	}
	
	
	//4
	@ResponseBody
	@RequestMapping(value="account.do" ,method = RequestMethod.POST)
	public  Map<String, Object> account(Human my){
		logger.info("HelloController account " + new Date());
		logger.info(my.toString());
		
		//DB 접근
		Map<String, Object> rmap = new HashMap<String, Object>();
		rmap.put("msg", "메시지입니다");
		rmap.put("name", "정수동");
		
		return rmap;
		
	}
	
    //5
	@ResponseBody
	@RequestMapping(value="updateUser.do", method = {RequestMethod.GET, RequestMethod.POST})
	//@RequestBody를 적어야 Map으로 값을 받아올 수 있다.
	public Map<String, Object> updateUser(@RequestBody Map<String, Object> map){
		logger.info("HelloController updateUser " + new Date());
		logger.info( map.get("name") + "");
		logger.info( map.get("tel") + "");
		logger.info( map.get("email") + "");
		logger.info( map.get("birth") + "");
		
		Map<String, Object> rmap = new HashMap<String, Object>();
		rmap.put("name", "일지매");
		rmap.put("age", "24");
		
		return rmap;
	}
}

@ResponseBody, @RequestBody

Java에는 JSON 형식이 존재하지 않는다. JSON은 JavaScript Object Notation 이기 때문이다. 그래서 Spring에서 JSON 형식을 사용하기 위해 Jackson 혹은 Gson을 사용한다. 그렇다면 Javascript로 Ajax 혹은 Fetch API로 API를 개발 할 때, Controller에서 데이터를 보내 주려면 JSON 형식으로 보내야 되는데 그 때 사용하는게 @ResponseBody 이다.

@ResponseBody란?

클라이언트 통신간 요청(Request)과 응답(Response)가 존재하는데, 이때 비동기통신(ex: Ajax)을 하기위해서는 Http body에 내용을 담아서 보내야 한다. 그 내용은 대표적으로 앞서 설명했던 JSON이다. 즉, @ResponseBody은 서버와 클라이언트 비동기통신에서 JSON 데이터를 보내줄 수 있게 하는 어노테이션이다.

JavaScript Ajax

$.ajax({
            type: 'post',
            enctype: 'multipart/form-data',
            url: '/board/register/imageUpload',
            data: formData,
            processData: false,
            contentType: false,
            success: function (data) {
                console.log(data);

            },
            error: function (err) {
                console.log(err);
            }
        });

해당 코드는 Ajax 비동기통신을 이용해 이미지 파일을 업로드하는 형태이다.

Controller

@ResponseBody
@PostMapping(value = "/register/imageUpload")
public void imageUpload(@RequestParam(name = "uploadFile") MultipartFile attachFile) {

        log.info("imageUpload Controller");

        String uploadFolder = "C:\\upload";
        String uploadFileName = attachFile.getOriginalFilename();

        File saveFile = new File(uploadFolder, uploadFileName);

        try {
            attachFile.transferTo(saveFile);

        } catch (IOException e) {
            e.printStackTrace();
        }
    }

위의 Controller 코드는 간단한 파일 업로드 코드이다.
만약 저기에서 @ResponseBody 어노테이션이 빠지게 된다면?

비동기통신에서 전달되어야 할 데이터가 전달이 되지 않고 Internal Server Error인 500 Error을 맛보게 될 것이다.

controller

@PostMapping("register")
@ResponseBody // return data
public String register(@RequestBody Member member)
{
...
}
	let data={
			"name": $("#name").val(),
			"nickName": $("#nickname").val(),
			"password": $("#password").val(),
			"gender": $("#gender").val(),
			"email":$("#email").val(),
			"phone":$("#phone").val(),
			"interField":$("#interfield").val(),
			"address":$("#address").val(),
			"email":$("#email").val()
		}
		
		$.ajax({
			type:"post",
			url:"/register",
			contentType:"application/json;charset=utf-8",
			data:JSON.stringify(data),
			success:function(resp){
				if(resp=="success"){
					alert("성공")
					location.href="/login"
				}
				if(resp=="fail"){
					alert("이미 존재하는 사용자명입니다.")
					$("#name").focus();
					$("#name").val("");
				}
			},error:function(e){
				alert("실패: "+e)
			}
		})

Spring Boot에서 Ajax 통신

View(댓글 작성 부분)

<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
<!-- 작성란&버튼 -->
<div class="col justify-content-center my-1 mx-0">
  <form method="POST" onsubmit="return checkComment(event)" id="commentForm">
    <!-- hidden 영역 -->
    <input type="hidden" name="EMAIL" th:value="${userInform.userEmail}">
    <input type="hidden" name="POST_NUM" th:value="${resultMap.POST_NUM}">
    <input type="hidden" name="CATEGORY_NAME" th:value="${resultMap.CATEGORY_NAME}">

    <!-- 입력 영역 -->
    <input type="text" id="content" name="CONTENT">
    <input type="button" onclick="return checkComment(event)" value="댓글작성">
  </form>
</div>
</body>
</html>

hidden의 내용과 작성된 내용을 ajax로 보내서 controller로 갑니다.

View(댓글 출력 부분)


function updateComment() {
    let commentBean = {
        email:$("#commentEmail").val(),
        content: $("#commentContent").val(),
        post_num: $("#commentPostNum").val()
    };
    $.ajax({
        url: "/view",
        type: "POST",
        data: commentBean,
    })
    .done(function (fragment) {
        $('#commentTable').replaceWith(fragment);
    });
}

GSON 사용하기

모든 것을 @RestController와 @ResponseBody로 해결할 수 있으면 정말 좋지만, JSON 객체를 직접 만들어야 할 때가 발생하는데, 그럴 때 사용하는 방법입니다.

의존성 주입

implementation 'com.google.code.gson:gson:2.9.0'
package com.example.study02.controller;

import com.google.gson.JsonObject;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody;

@Controller
public class GsonController {

    @ResponseBody
    @RequestMapping("/test")
    public String test() {
        JsonObject obj = new JsonObject();

        obj.addProperty("title", "테스트3");
        obj.addProperty("content", "테스트3 내용");

        JsonObject data = new JsonObject();
        data.addProperty("time", "12:00");
        obj.add("data", data);

        return obj.toString();
    }
}

JsonObject의 addProperty를 사용해서 쉽게 JSON을 커스텀 할 수 있고, 객체 안에 객체를 넣을 수도 있습니다. 이 포스팅에서는 소개하지 못했지만, JsonArray를 사용하면 배열도 담을 수 있습니다.

@ResponseBody
    @RequestMapping("/test2")
    public String test2() {
        JsonObject jsonObject = new JsonObject();
        jsonObject.addProperty("title", "Gson Test Tile");
        jsonObject.addProperty("content", "Gson Test Content");

        JsonArray jsonArray = new JsonArray();
        for (int i = 0; i < 5; i++) {
            JsonObject jsonObject2 = new JsonObject();
            jsonObject2.addProperty("data", i);
            jsonArray.add(jsonObject2);
        }
        jsonObject.add("testData", jsonArray);
        return jsonObject.toString();
    }

Gson 직렬화

흔히 Spring Boot를 이용하여 리스트를 Service로 부터 받아와 Controller에서 View로 리스트를 Json 형식으로 전달할 때 사용하는 방식이다.

@RequestMapping(value = "test", method=RequestMethod.GET)
public void gsonTest(Model model) {
    Gson gson = new Gson;
    String getList = testService.getList();
    String json = gson.toJson(getList);

    model.addAttributes("list", json);
}

Spring Boot 복잡한 JSON 파싱

스포티파이 API를 이용하게 되었는데 요청받은 JSON 데이터가 너무 복잡한 형태로 이루어져 있는 경우 해결 방법을 정리

먼저 JSON 파싱을 위해 json-simple이라는 라이브러리를 사용한다.

프로젝트는 Gradle를 사용하였고, build.gradle 파일 안에 dependencies 부분에 한 줄 추가해준다.

implementation group: 'com.googlecode.json-simple', name: 'json-simple', version: '1.1.1'

JSONParser jsonParser = new JSONParser();
// API를 통해 넘어온 JSON을 파싱
JSONObject jsonObject = (JSONObject) jsonParser.parse(str); 
// JSON 파싱 데이터에서 tracks부분을 배열로 가져옴
JSONArray track = (JSONArray) jsonObject.get("tracks"); 
for (int i = 0; i < track.size(); i++) {
// 가져온 배열에서 i번째 부분만 가져옴
	JSONObject trackbody = (JSONObject) track.get(i); 
    // 위에서 가져온 데이터에서 앨범 부분만 가져와서 저장 여기에 앨범제목
	JSONObject albumbody = (JSONObject) trackbody.get("album"); 
   // 출시일은 그냥 있고 이미지 아티스트는 배열형태
   // 이미지 부분을 배열로 가져옴
	JSONArray image = (JSONArray) albumbody.get("images"); 
	JSONObject imagebody = (JSONObject) image.get(0);
    // 아티스트 부분을 배열로 가져옴
	JSONArray artist = (JSONArray) albumbody.get("artists"); 
	JSONObject artistbody = (JSONObject) artist.get(0);
	MusicDto dto = new MusicDto();
	dto.setArtist(artistbody.get("name").toString());
	dto.setTitle(trackbody.get("name").toString());
	dto.setYear(albumbody.get("release_date").toString());
	dto.setImgLink(imagebody.get("url").toString());
	list.add(dto);
}
JSONArray seed = (JSONArray) jsonObject.get("seeds");
JSONObject seedbody = (JSONObject) seed.get(0);
System.out.println(seedbody.get("id"));

json-simple 라이브러리를 추가하였으면 JSONParser와 JSONObject를 이용해 먼저 JSON을 파싱 해준다. 이후 JSON의 내용에 맡도록 파싱을 진행하는데 간단히 말하면 오브젝트의 경우 JSONObject를 사용하면 되고 배열의 경우 JSONArray를 이용하면 된다. JSONObject의 경우. get("Key값")을 통해 쉽게 가져올 수 있고, JSONArray는. get(index)를 이용해 가져온 뒤 JSONObject를 이용하면 된다. 위의 이미지와 코드를 비교한다면 쉽게 이해가 갈 것이다.


Spring Boot JSON 작업

package com.example.study02.DTO;

import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.annotation.JsonIgnore;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;

import java.time.LocalDateTime;

@Setter
@Getter
@ToString
public class UserDTO {
    private String id;
    // json에서 제외
    @JsonIgnore
    private String password;
    private String name;
    @JsonFormat(shape = JsonFormat.Shape.STRING) // ISO-8601 형식으로 변환
    private LocalDateTime registerDateTime;

    @JsonFormat(pattern = "yyyyMMddHHmmss")
    private LocalDateTime updateTime;
}

@JsonIgnore를 이용한 예외 처리

현재 구현된 응답 결과 JSON에는 비밀번호 같은 민감한 정보가 표기되므로 이를 제외해야 합니다.

// json에서 제외
    @JsonIgnore
    private String password;

날짜 형식 변환 처리: @JsonFormat

숫자나 배열보다는 특정 형식으로 날짜를 표현하므로, 다음과 같이 @JasonFormat 어노테이션을 이용

“registerDateTime”: “2023-02-15T10:55:49”

ISO-8601 형식이 아닌 원하는 형식일 경우 다음과 같이 @JsonFormat의 pattern 속성을 이용

profile
발전하기 위한 공부

0개의 댓글