Spock Framework

 

개요 및 특징
  • Groovy 언어를 기반으로 자바와 100% 호환됨
  • mocking, stubbing 을 지원해 mock객체로 동작 정의가 쉬움
  • 라이프사이클 메소드 제공 : setup(), cleanup() 등의 메소드로 테스트 전/후 설정 수행이 가능
  • 동일한 데이터를 여러번 실행가능한 블록 제공
  • 테스트 블록을 분리하고 실행 순서 지정 가능

 

 

Spock Block
  • given / setup
    • 테스트에 필요한 객체나 환경을 준비하는 블록
  • when
    • 테스트 하고 싶은 상황을 만드는 영역 ( 코드 실행 영역 )
  • then
    • 테스트 결과를 검증하는 블록 ( assert에 해당하는 문장 )
  • expect
    • when, then이 합쳐진 형태로 단위 테스트 시 유용
  • cleanup
    • 필요시 setup 자원을 정리
  • where
    • 일부 데이터만 바꿔가며 테스트를 할 수 있게 도와주는 영역

 

 

기본 사용법

 

1. 라이브러리 추가

testImplementation "org.spockframework:spock-spring"

 

2. 플러그인 추가

apply plugin: 'groovy' // groovy 지원

 

3. Specification 클래스 상속

import spock.lang.Specification

class UpdateBizFleetOrderVinsToPurchasedHandlerTest extends Specification {
}

* 이때, 주의할 점은 클래스를 ctrl + command + T 로 만들 때, 기존에 Test Library JUnit으로 사용하시던 분들은 Groovy로 바꿔줘야 합니다.

 

 

4. 라이프 사이클에 맞는 메소드 생성

def setupSpec() {}    // 모든 테스트 케이스 실행 전 실행 (@BeforeClass)
def setup() {}        // 각 테스트 케이스 실행 전 마다 실행 (@Before)
def cleanup() {}      // 각 테스트 케이스 실행 후 마다 실행 (@After)
def cleanupSpec() {}  // 모든 테스트 케이스 실행 후 실행 (@AfterClass)

 

 

참고
  • @Shared
    • 여러 테스트 간에 걸쳐서 공유되는 객체
  • Mock 테스트 코드
    • 도메인 클래스를 Mock 클래스의 피라미터로 주면서 생성
    • >> 를 통해 값을 반환 ( ex. A.findName(1) >> "park" )

에러 :

Caused by: java.io.FileNotFoundException: class path resource [elastic/article-setting.json] cannot be opened because it does not exist

 

말 그래도 경로를 못잡아서 생기는 문제입니다.

 

저 같은 경우,

@Mapping(mappingPath = "elastic/article-mapping.json")
@Setting(settingPath = "elastic/article-setting.json")
public class ArticleDoc {
...
}

이렇게 경로를 잡고 있는데, resource 경로에 파일명도 제대로 되있는데 왜 못잡을까? 를 생각했습니다.

 

정적 파일이 resource/static/elastic/~~.json 과 같이 원래 잡던 경로랑 달라져서 생기는 문제였습니다.

 

아래와 같이 문제를 해결할 수 있습니다.

 

1. WebConfig 설정

package com.example.elasticsearch.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurationSupport;

@Configuration
public class WebConfig extends WebMvcConfigurationSupport {

    @Override
    public void addResourceHandlers(ResourceHandlerRegistry registry) {
        String[] resourceLocation = {
                "classpath:/static/", "classpath:/resources/","classpath:/public/","classpath:/static/elastic/"
        };
        registry.addResourceHandler("/**").addResourceLocations(resourceLocation);
        super.addResourceHandlers(registry);
    }
}

 

 

2. Setting 경로 수정

@Mapping(mappingPath = "/static/elastic/article-mapping.json") // 경로수정
@Setting(settingPath = "/static/elastic/article-setting.json") // 경로수정
public class ArticleDoc {
...
}

 

 

이렇게 하면 문제가 해결되는 것을 볼 수 있습니다.

 

 

!! 참고

https://tjdwns4537.tistory.com/158

 

정적 리소스 사용하기 ( Resource Handler )

상황 : Controller 에서 Templates 경로의 View 를 반환하는데 404가 발생 다음의 상황에서 localhost:8080/testMain 를 접속하면 404가 뜨는 것을 볼 수 있습니다. 이 경우, 다음과 같은 설정을 해줘야 합니다. 위

tjdwns4537.tistory.com

 

@Valid  로 검증을 할 때 발생하는 에러

 

생각보다 간단한 에러입니다.

 

[ 문제 상황 ]

1. 회원가입 페이지의 Domain에 이름과 연락처 등에 대해 @NotEmpty 을 붙쳐줬습니다.

2. 컨트럴러에서 @Valid, BindingResult 피라미터를 넣어줍니다.

3. 검증에러가 발생시 bindingResult 객체에 에러가 담기고, 에러가 발생하면 다시 재입력받을 수 있는 페이지를 구축하는 것입니다.

 

* 발생한 문제: 검증이 발생하는 것은 파악이 되지만, bindingResult 객체가 제대로 작동하지 않음

 

 

 

 

[ 문제 해결 ]

검증은 피라미터 순서에 영향을 받습니다.

따라서

@PostMapping("/join")
    public String joinExecute(
            @Valid MemberForm memberForm,
            @RequestParam String password2,
            RedirectAttributes redirectAttributes,
            Model model,BindingResult bindingResult)

위의 코드처럼 @Valid 후 BindingResult 순서가 바로 붙어있는게 아닌 저렇게 다른 피라미터를 먼저 받을 경우 원하는 동작을 안합니다.

 

@PostMapping("/join")
    public String joinExecute(
            @Valid MemberForm memberForm,BindingResult bindingResult,
            @RequestParam String password2,
            RedirectAttributes redirectAttributes,
            Model model)

이렇게 피라미터 순서를 제대로 배치하면 해결됩니다.

발생하고 있던 문제 :

Button type = "submit" 으로 버튼 이벤트를 주다보니 다른 페이지로 이동해버리는 문제가 발생

 

 

[ 해결 완료 ]

 

이전 블로그에서는 다른 팝업창을 띄워서 그 곳에서 인증하는 방식으로 해결한다고 말했었습니다.

하지만 더 간단하고 쉬운 방법을 찾았습니다.

 

<form th:action="@{/member/emailConfirm}" method="post" target="param">
            <div>
                <h3 class="join_title"><label for="email">이메일</label></h3>
                <span class="box int_email">
                        <input type="text" name="email" id="email"
                               class="form-control" maxlength="100">
                    </span>
            </div>

            <!-- 이메일 인증 번호 요청 버튼 -->
            <input type="submit"
                   value="인증번호받기"
                    id="email-btn"
            />
</form>
<iframe id="if" name="param"></iframe>

위와 같이 iframe 을 활용하여 간단하게 해결할 수 있었습니다.

 

iframe 에 대한 설명은 아래의 글이 잘 적혀있어 읽어보시고 활용하면 좋을것 같습니다.

 

https://yeoulcoding.me/143

 

[HTML] iframe 태그란?

Definition iframe이란 Inline Frame 의 약자로, 웹 브라우저 내에 또 다른 프레임, 즉 현재 브라우저에 렌더링되고 있는 문서 안에 또 다른 HTML페이지를 삽입할 수 있도록 하는 기능을 제공합니다. 검색

yeoulcoding.me

 

 

만들려는 기능은 다음과 같습니다.

 

  1. 이메일 입력
  2. 인증번호 요청 클릭
  3. tjdwns4537@naver.com 이메일에 인증번호 전송
  4. 전송된 인증번호를 입력창에 입력 후 정상적으로 인증됬는지 확인

 

[ Controller ]

@PostMapping("/emailConfirm")
@ResponseBody
public void emailVerify(@RequestParam String email) throws Exception {
        log.info("post email = {}", email);
        emailService.sendSimpleMessage(email);
}

 

[ EmailService ]

package smilegate.securitySystem.service.EmailService;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.mail.MailException;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.stereotype.Service;

import javax.mail.internet.InternetAddress;
import javax.mail.internet.MimeMessage;
import java.util.Random;

@Service
public class EmailServiceImp implements EmailServiceInterface{

    @Autowired
    JavaMailSender emailSender;

    public static final String emailPassword = createKey();

    public EmailServiceImp(JavaMailSender emailSender) {
        this.emailSender = emailSender;
    }

    public String getEmailPassword() {
        return emailPassword;
    }

    public static String createKey() {
        StringBuffer key = new StringBuffer();
        Random rnd = new Random();

        for (int i = 0; i < 8; i++) { // 인증코드 8자리
            int index = rnd.nextInt(3); // 0~2 까지 랜덤

            switch (index) {
                case 0:
                    key.append((char) ((int) (rnd.nextInt(26)) + 97));
                    //  a~z  (ex. 1+97=98 => (char)98 = 'b')
                    break;
                case 1:
                    key.append((char) ((int) (rnd.nextInt(26)) + 65));
                    //  A~Z
                    break;
                case 2:
                    key.append((rnd.nextInt(10)));
                    // 0~9
                    break;
            }
        }
        return key.toString();
    }

    @Override
    public String sendSimpleMessage(String to)throws Exception {
        // TODO Auto-generated method stub
        MimeMessage message = createMessage(to);
        try{//예외처리
            emailSender.send(message);
        }catch(MailException es){
            es.printStackTrace();
            throw new IllegalArgumentException();
        }
        return emailPassword;
    }

    private MimeMessage createMessage(String to)throws Exception{
        System.out.println("보내는 대상 : "+ to);
        System.out.println("인증 번호 : "+ emailPassword);
        MimeMessage message = emailSender.createMimeMessage();

        message.addRecipients(MimeMessage.RecipientType.TO, to);//보내는 대상
        message.setSubject("회원가입 이메일 인증");//제목

        String msgg="";
        msgg+= "<div style='margin:100px;'>";
        msgg+= "<h1> 안녕하세요 Sungjun 입니다. </h1>";
        msgg+= "<br>";
        msgg+= "<p>아래 코드를 회원가입 창으로 돌아가 입력해주세요<p>";
        msgg+= "<br>";
        msgg+= "<p>감사합니다!<p>";
        msgg+= "<br>";
        msgg+= "<div align='center' style='border:1px solid black; font-family:verdana';>";
        msgg+= "<h3 style='color:blue;'>회원가입 인증 코드입니다.</h3>";
        msgg+= "<div style='font-size:130%'>";
        msgg+= "CODE : <strong>";
        msgg+= emailPassword+"</strong><div><br/> ";
        msgg+= "</div>";
        message.setText(msgg, "utf-8", "html");//내용
        message.setFrom(new InternetAddress("kktd4537@gmail.com","SungJun"));//보내는 사람

        return message;
    }
}

 

[ Form 태그 ]

 

<form th:action="@{/member/emailConfirm}" method="post">
            <div>
                <h3 class="join_title"><label for="email">이메일</label></h3>
                <span class="box int_email">
                        <input type="text" name="email" id="email"
                               class="form-control" maxlength="100">
                    </span>
            </div>

            <button type="submit"
                    id="email-btn"> 인증번호 요청
            </button>
        </form>

        <div>
            <h3 class="join_title">인증 번호</h3>
            <input type="text" name="email-check" id="email-check" class="form-control" maxlength="100" placeholder="인증번호 입력">
        </div>

 

메일은 정상적으로 보내지는게 확인이 되지만

위의 코드에서 문제점이 있습니다.

 

코드를 보면 Form action 태그를 통해 member/emailConfirm 으로 url 을 요청하기 때문입니다.

 

그렇다고 회원가입 페이지인 member/join 에 다시 보내게 된다면 어떻게 될까요?

email 인증이 아닌 회원가입 버튼의 주소가 member/join 으로 되있기 때문에 PostMapping 메서드 두 개가

서로 같은 곳을 경로를 바라보게 됩니다.

 

그래서 생각한 해결방안은 다음과 같습니다.

 

[ 해결방안1 ]

1) 인증번호 요청 버튼 클릭

2) 자바스크립트를 이용해 팝업창 생성

3) 팝업창에서 인증번호에 대한 검증을 진행

4) 검증이 정상적으로 완료된다면 회원가입 페이지에 검증 완료 메세지 전송

 

[ 해결방안2 ]

1) 인증번호 요청 버튼 클릭

2) RestController 에 구현한 다른 PostMapping 메서드를 호출

3) 해당 메서드에서 검증기능 구현 후 검증이 완료 된다면 회원가입 페이지에 검증 완료 메세지 전송

 

사실 해결방안1에 대한 설계는 머릿속에 바로 들어오지만,

해결방안2에 대한 구조는 저도 어떻게해야 할지 잘 생각이 나지 않습니다.

 

그래서 해결방안1에 대해 먼저 진행해보도록 하겠습니다.

 

[ 구현 과정 ]

1. 컨트롤러 / 도메인/ 레포지토리 생성

2. 타임리프를 활용해 동적 HTML 생성

 

 

컨트롤러와 타임리프의 실행 과정
package springmvc.springmvc.controller;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;
import springmvc.springmvc.domain.Member;
import springmvc.springmvc.repository.MemberRepositoryInterface;

import javax.annotation.PostConstruct;
import java.util.List;

@Slf4j
@Controller
@RequestMapping("/join")
@RequiredArgsConstructor
public class joinController {

    private final MemberRepositoryInterface memberRepository;

    @GetMapping("/memberView")
    public String memberListView(Model model) { // 회원 목록 조회
        List<Member> members = memberRepository.findAll();
        model.addAttribute("members", members);
        log.info("info log={}",model.getAttribute("members"));

        return "/join/memberView";
    }

    @PostConstruct
    public void init() { // 테스트용 멤버 데이터 넣기
        memberRepository.save(new Member("성준","tjdwns", "1234", 010));
        memberRepository.save(new Member("수진","sujin", "2345", 011));
        memberRepository.save(new Member("성형","tjdgud", "0101", 533));
    }

    @GetMapping("/memberView/{id}")
    public String item(@PathVariable Long id, Model model) {
        Member findMember = memberRepository.findById(id);
        model.addAttribute("member", findMember);
        return "/join/member";
    }
}

 

@RequestMapping("/join")

회원가입과 관련된 HTTP url은 모두 join/ 로 시작하기때문에 join을 넣어놨습니다.

 

 

@PostConstruct
    public void init() { // 테스트용 멤버 데이터 넣기
        memberRepository.save(new Member("성준","tjdwns", "1234", 010));
        memberRepository.save(new Member("수진","sujin", "2345", 011));
        memberRepository.save(new Member("성형","tjdgud", "0101", 533));
}

테스트를 위해 프로젝트 생성시 멤버 리포지토리에 위의 멤버를 넣습니다.

 

 

@GetMapping("/memberView")
    public String memberListView(Model model) { // 회원 목록 조회
        List<Member> members = memberRepository.findAll();
        model.addAttribute("members", members);
        log.info("info log={}",model.getAttribute("members"));

        return "/join/memberView";
}

1) /join/memberView 라는 url 을 접속

2) 멤버 리포지토리에서 findAll을 통해 회원 리스트를 생성

3) 모델에 members 라는 이름으로 회원 리스트를 저장

 

 

<tr th:each="member : ${members}">
                <td><a href="member.html" th:href="@{/join/memberView/{id}(id=${member.id})}" th:text="${member.id}">회원 식별번호</a></td>
                <td th:text="${member.memberName}">이름</a></td>
                <td th:text="${member.memberId}">아이디</a></td>
                <td th:text="${member.phoneNumber}">휴대폰 번호</td>
                <td th:text="${member.memberPassword}">password</td>
 </tr>

1) 타임리프를 활용해 모델에 담겨져있는 members 리스트를 순환

2) 각각의 리스트 목록에 [ 클래스.멤버 ] 로 모델에 담긴 데이터를 출력합니다.

3)

th:href="@{/join/memberView/{id}(id=${member.id})}" th:text="${member.id}"

/join/memberView 라는 브라우저 Url 에서 member.id 로 받아온 회원 식별번호를 뒤에 붙쳐서

/join/memberView/회원식별번호 로 링크를 걸어줍니다.

 

 

 

[ 출력 화면 ]

위의 화면에서 식별 번호에 링크를 누르면

 

회원 정보가 출력됩니다.

위와 같은 에러가 발생한 이유는 Database 에 연결할 필요 정보가 없기 때문입니다.

 

[ 해결방법 ]

 

위의 에러는 appllication.properties 에 데이터베이스에 대한 설정을 해줘야합니다. 아래 블로깅을 보시면 보실 수 있습니다.

 

https://tjdwns4537.tistory.com/117

 

Spring Boot 와 JPA( MySQL ) 연동 간단 가이드

* JPA 와 스프링 부트로 H2Database 를 사용해봤다는 가정하에 작성하였습니다. 1. 의존성 추가 org.springframework.boot spring-boot-starter-data-jpa mysql mysql-connector-java 2. application.properties 설정 추가 server.address=

tjdwns4537.tistory.com

 

 

그런데 여기서 의문점은 저는 jpa 와 데이터베이스를 사용하지 않고 순수 자바로만 레포지토리를 구현했는데 왜 이러한 에러가 뜰까?

였습니다.

 

단순히 강의를 따라하다보니 발생한 문제라고 생각하여 그 원인을 찾기로 하였습니다.

 

[ 의문점 해결방법 ]

implementation 'org.springframework.boot:spring-boot-starter-data-jpa'

@Data  를 붙친다는게 실수로

@Entity 를 넣었기 때문입니다.

 

엔티티 어노테이션을 붙치면서 라이브러리에 위의 jpa 라이브러리를 무의식 중에 적용시켰습니다...

그래서 jpa 관련 설정을 찾는 에러가 발생했습니다.

위의 라이브러리를 지워우고 다시 적용시켜주면 에러가 해결됩니다.

 

이번에 해볼 상황은 다음과 같습니다.

 

HTML Form 을 통해 회원 데이터를 가입하면, 새로운 페이지에서 그 데이터를 보여주는 것입니다.

 

 

Form Servlet 구현

package servletTest.servlet.webServlet;

import servletTest.servlet.repository.MemberRepository;

import javax.servlet.ServletException;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.PrintWriter;

@WebServlet(name = "memberFormServlet", urlPatterns = "/servlet/members/new-form")
public class memberFormServlet extends HttpServlet {

    private MemberRepository memberRepository = MemberRepository.getInstance();

    @Override
    protected void service(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        response.setContentType("text/html");
        response.setCharacterEncoding("UTF-8");

        PrintWriter w = response.getWriter();
        w.write("<!DOCTYPE html>\n" +
                "<html>\n" +
                "<head>\n" +
                "    <meta charset=\"UTF-8\">\n" +
                "    <title>Title</title>\n" +
                "</head>\n" +
                "<body>\n" +
                "<form action=\"/servlet/members/save\" method=\"post\">\n" +
                "    username: <input type=\"text\" name=\"username\" />\n" +
                "    age:      <input type=\"text\" name=\"age\" />\n" +
                " <button type=\"submit\">전송</button>\n" + "</form>\n" +
                "</body>\n" +
                "</html>\n");

    }
}

url 주소 : /servlet/members/new-form

해당 주소에 접속하게 되면 response 에 html 형태로 응답을 보내게 됩니다.

 

form action 에 데이터를 보낼 ur 을 작성해줍니다.

 

Form 데이터 받아서 출력하기

package servletTest.servlet.webServlet;

import servletTest.servlet.domain.member.Member;
import servletTest.servlet.repository.MemberRepository;

import javax.servlet.ServletException;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.PrintWriter;

@WebServlet(name = "memberSaveServlet", urlPatterns = "/servlet/members/save")
public class memberSaveServlet extends HttpServlet {

    private MemberRepository memberRepository = MemberRepository.getInstance();

    @Override
    protected void  service(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        System.out.println("MemberSaveServlet.service");

        // 요청 데이터
        String username = request.getParameter("username");
        int age = Integer.parseInt(request.getParameter("age"));

        // 저장
        Member member = new Member(username, age);
        memberRepository.save(member);

        // 응답 메시지
        response.setContentType("text/html");
        response.setCharacterEncoding("utf-8");
        PrintWriter writer = response.getWriter();
        writer.write("<html>\n" +
                "<head>\n" +
                " <meta charset=\"UTF-8\">\n" + "</head>\n" +
                "<body>\n" +
                "성공\n" +
                "<ul>\n" +
                "    <li>id="+member.getId()+"</li>\n" +
                "    <li>username="+member.getUsername()+"</li>\n" +
                " <li>age="+member.getAge()+"</li>\n" + "</ul>\n" +
                "<a href=\"/index.html\">메인</a>\n" + "</body>\n" +
                "</html>");
    }
}

받아온 데이터를 Member 객체에 저장해주고, 싱글톤으로 구현한 Repository 를 통해 저장해줍니다.

 

그리고 받아온 데이터를 응답메시지로 다시 HTML 형태로 출력해줍니다.

데이터를 전송을 하면

성공적으로 데이터가 출력이 됨을 볼 수 있습니다.

 

 

마무리 정리하면...

Resource 아래에 Html 파일을 작성하여 Form 을 구성할 수 있습니다.

하지만 왜 굳이 서블릿을 통해 불편하게 HTML 파일을 작성할까요?

 

그 이유는 동적으로 HTML 파일을 생성할 수 있기 때문입니다.

즉, 이미 만들어진 형태가 아닌 원하는 형태로 원할 때, 원하는 구성으로 HTML 파일을 구성할 수 있다는 장점입니다.

 

그래서 계속 달라지는 저장 결과나, 회원 목록 같은 동적인 HTML 만드는 일이 가능해집니다.

 

하지만 위의 코드를 보듯이 매우 비효율적인 코드 작성을 하게 됩니다.

 

그래서 HTML 파일 안에 변경해야하는 부분만 자바 코드를 넣을 수 있도록 한 것이 템플릿 엔진입니다.

 

템플릿 엔진에는 [ JSP , Thymeleaf, Freemarker 등 ] 이 있습니다.

 

하지만 JSP 는 현재 점점 없어지는 추세이기 때문에 Spring MVC 패턴을 가지고 토이 프로젝트를 진행하도록 하겠습니다.

 

HttpServletRequest 역할

- HTTP 를 직접 파싱하는 경우

: 메시지를 개발자가 직접 파싱해서 사용해도 되지만, 매우 할 일이 많아집니다.

 

- 서블릿 필요 이유

서블릿은 개발자가 HTTP 요청 메시지를 편리하게 사용할 수 있도록 개발자를 대신해서 HTTP 요청 메시지를 파싱합니다.

 

- HttpServletRequest 의 존재 이유

그 요청 메시지에 대한 결과를 HttpServletRequest 객체에 담아서 제공합니다.

 

 

 

HttpServletRequest 예시

- HTTP 요청 메시지

POST /save HTTP/1.1
Host : localhost: 8080
Content-Type : application/x-www-form-urlencoded

username=park&age=20

이와 같은 요청 메시지를 직접 매번 파싱한다는 의미입니다. 

 

각 문장에 대해 HttpServletRequest 가 하는 역할은

- 첫 번째 줄 : START LINE [ HTTP 메소드, URL, 스키마 ]

- 두, 세 번째 줄 : 헤더 [ 헤더 조회 ]

- 바디 : 한 줄 띄우고 나머지 부분 [ form 피라미터 형식 조회, 메세지 바디 데이터 직접 조회 ]

 

 

 HttpServletRequest 객체의 여러가지 기능

- 임시 저장소 기능

HTTP 요청이 시작부터 끝날 때 까지 유지되는 임시 저장소 기능

request.setAttribute ( name, value )

request.getAttribute ( name )

 

- 세션 관리 기능

request.getSession ( create: true )

 

 

HTTP Request 해보기

PostMan 설치 후 HTTP 메서드를 테스트 해봤습니다.

위와 같이 Request 메서드에 대해 print 할 수 있는 메서드를 작성하였습니다.

 

그 후 POST 로 메세지를 보내면

위와 같이 요청 메시지가 정상적으로 들어옴을 확인할 수 있습니다.

ㅇ WAR 로 패키징하기 ( Spring Initailize )

JAR 가 아닌 WAR 로 패키징하면 톰캣에 직접 넣을 수도 있고, 스프링 부트의 내장 톰캣에서 띄울 수도 있습니다.

그래서 서블릿으로 페이지를 구현하려면 WAR 를 사용해야 합니다.

 

 

ㅇ @ServletComponentScan

서블릿을 찾아 자동으로 등록해줍니다.

 

 

 

ㅇ HttpServlet 상속받기

- 서블릿을 구현하려면 HttpServlet 을 상속받아야 합니다.

 

 

 

ㅇ WebServlet ( name = " Servlet name ", urlPatterns = " url 경로 " )

서블릿의 Url 경로를 설정

 

 

 

ㅇ 서비스 메서드 생성

서블릿이 호출되면 서비스 메서드가 호출 됩니다.

 

 

 

ㅇ 서블릿 요청 및 응답 해보기

- 서블릿 요청 :

String username = request.getParameter("username");

위 메서드를 통해 

http://localhost:8080/hello?username=kim 를 브라우저에 입력하게 되면

브라우저에 입력된 값이 요청되는 것을 알 수 있습니다.

 

 

 

- 서블릿 응답 :

response.setContentType("text/plain");
response.setCharacterEncoding("utf-8");
response.getWriter().write("hello" + username);

위 메서드를 통해

이러한 응답 메세지가 전송됨을 알 수 있습니다.

 

 

 

ㅇ 개발 단계에 로깅 찍어보기

logging.level.org.apache.coyote.http11=debug

 

 

 

 

ㅇ 인덱스 페이지 만들어보기

webapp 경로에 index.html 생성

상황 : Controller 에서 Templates 경로의 View 를 반환하는데 404가 발생

다음의 상황에서 localhost:8080/testMain 를 접속하면 404가 뜨는 것을 볼 수 있습니다.

이 경우, 다음과 같은 설정을 해줘야 합니다.

위의 설정을 하나하나 뜯어보도록 하겠습니다.

 

WebMvcConfigurer

WebMvcConfigurer 을 사용하면 @EnableWebMvc 가 자동적으로 세팅해주는 설정에 개발자가 원하는 설정을

추가할 수 있습니다.

 

 

@EnableWebMvc

Spring Framework 에서 여러 Config 값을 알아서 세팅해줍니다.

 

 

addResourceHandler

정적인 Resource 를 처리하기 위해 사용되는 Handler 입니다.

기본적으로 서블릿 컨테이너에는 정적인 자원을 처리할 수 있는 서블릿이 등록되어 있습니다.

그래서 프로젝트를 생성하고 아무 설정을 안해도 리소스 요청에 동작을 합니다. ( ex. static Directory )

 

하지만, 특정 요청에 대한 리소스를 컨트롤해야 한다면 리소스 핸들러를 정의해서 config 에 등록해줘야 합니다.

이때, addResource Handler 메서드를 통해 어느 경로로 들어왔을 때 매핑이 되어줄 것인지를 정의해줍니다.

registry.addResourceHandler("/static/**")

이후 addResourceLocations 메서드를 통해 실제 파일이 있는 경로를 설정해줍니다.

그래서 보면 Main 경로가 있는 templates 와 css/js 파일이 있는 static 을 경로 설정을 해줬습니다.

만약, static 경로를 css / js 등등을 따로 나눠서 잡아야 한다면 다음과 같이 설정해줘야 합니다.

registry.addResourceHandler("/css/**").addResourceLocations("classpath:/css").setCachePeriod(false)
registry.addResourceHandler("/js/**").addResourceLocations("classpath:/js").setCachePeriod(false)

 

첫번째 오류

 

 

[ 환경 ] 

- 스프링 부트

- 자바11

- JPA + MySQL

- Gradle

- Application.properties 설정

spring.datasource.url=jdbc:mysql://127.0.0.1:3306/~~?useSSL=false&characterEncoding=UTF-8&serverTimezone=UTC
spring.datasource.username=??
spring.datasource.password=??
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver

# mysql 사용
spring.jpa.database=mysql
spring.jpa.database-platform=org.hibernate.dialect.MySQL5InnoDBDialect
spring.jpa.hibernate.ddl-auto=none
spring.jpa.generate-ddl=false

# 로깅 레벨
logging.level.org.hibernate=info

# 하이버네이트가 실행한 모든 SQL문을 콘솔로 출력
spring.jpa.properties.hibernate.show_sql=true
# SQL문을 가독성 있게 표현
spring.jpa.properties.hibernate.format_sql=true
# 디버깅 정보 출력
spring.jpa.properties.hibernate.use_sql_comments=true

 

 

두번째 오류

Access to DialectResolutionInfo cannot be null when 'hibernate.dialect' not set

 

 

[ 해결방법 ]

두 오류를 종합해봤을 때, application.properties 의 MySQL 설정을 읽지 못하고 있는 것으로 보입니다.

그 이유는 제가 META-INF 에 PersistenceUnit 을 설정하기위해 persistence.xml 을 만들어 놓고

내부 태그에 아무 내용을 적지 않았기 때문입니다.

 

=> persistence.xml 에 다음과 같은 코드를 작성 후 해결완료

<?xml version="1.0" encoding="UTF-8"?>
<persistence xmlns="http://xmlns.jcp.org/xml/ns/persistence"
             version="2.1">
    <persistence-unit name="Recycler">

        <class>kbbank.recycler.domain.MEMBER</class>

        <properties>
            <property name="javax.persistence.jdbc.driver" value="com.mysql.cj.jdbc.Driver"/>
            <property name="javax.persistence.jdbc.user" value=""/>
            <property name="javax.persistence.jdbc.password" value=""/>
            <property name="javax.persistence.jdbc.url" value="jdbc:mysql://127.0.0.1:3306/Recycler"/>
            <property name="hibernate.dialect" value="org.hibernate.dialect.MySQL5Dialect"/>

            <property name="hibernate.show_sql" value="true"/>
            <property name="hibernate.format_sql" value="true"/>
            <property name="hibernate.use_sql_comments" value="true"/>
            <property name="hibernate.jdbc.batch_size" value="10"/>
            <property name="hibernate.hbm2ddl.auto" value="update" />
            <!--property name="hibernate.id.new_generator_mappings" value="true"/-->
        </properties>
    </persistence-unit>

</persistence>

* JPA 와 스프링 부트로 H2Database 를 사용해봤다는 가정하에 작성하였습니다.

 

1. 의존성 추가

<dependencies>
	<dependency>
		<groupId>org.springframework.boot</groupId>
		<artifactId>spring-boot-starter-data-jpa</artifactId>
	</dependency>

	<dependency>
		<groupId>mysql</groupId>
		<artifactId>mysql-connector-java</artifactId>
	</dependency>
</dependencies>

 

2. application.properties 설정 추가

server.address=localhost
server.port=8080

spring.datasource.url=jdbc:mysql://localhost:3306/TEST_DB?useSSL=false&characterEncoding=UTF-8&serverTimezone=UTC
spring.datasource.username=test_user
spring.datasource.password=admin
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver

# mysql 사용
spring.jpa.database=mysql
spring.jpa.database-platform=org.hibernate.dialect.MySQL5InnoDBDialect

# 로깅 레벨
logging.level.org.hibernate=info

# 하이버네이트가 실행한 모든 SQL문을 콘솔로 출력
spring.jpa.properties.hibernate.show_sql=true
# SQL문을 가독성 있게 표현
spring.jpa.properties.hibernate.format_sql=true
# 디버깅 정보 출력
spring.jpa.properties.hibernate.use_sql_comments=true

 

 

3. Entity 생성

 

4. Repository 생성

 

5. Controller 생성

 

6. 결과 확인

테스트 코드에서 위 사진과 같은 에러가 발생하고 있습니다.

 

  1. 멤버 리포지토리에 멤버 객체를 저장 ( java memory 사용 )
  2. findById 로 저장한 객체를 꺼내와서 비교

간단하게 위 두 과정을 거치는데 null 값을찾고 있다는 오류가 발생해 디버깅을 해보니

 

 

저는 분명 하나의 객체를 저장했는데 sequence 는 2를 가리키고 있습니다.

 

 

[ 해결 ]

간단하게 테스트하기 위해 save 할 때 , Id 값을 직접 지정해주도록 했습니다.

그런데 member 객체에 Id 값을 설정안해주고, member.getId() 메서드로 Id를 호출하니 발생하는 오류 였습니다.

 

해결방법 : 테스트 코드의 member 객체에 Id 값을 설정해줘야함

Spring Bean

스프링 컨테이너에 의해 관리되는 자바 객체를 의미합니다.

 

 

Spring Bean 을 왜 사용하는가?

개발자는 new 키워드, Interface 호출, 팩토리 호출 방식으로 객체를 생성하고 소멸합니다.

하지만 Spring Container를 사용하면 이러한 역할을 대신 해줍니다.

1) 제어 흐름을 외부에서 관리하게 됩니다.

2) 객체들 간의 의존 관계를 스프링 컨테이너가 런타임 과정에서 알아서 생성해줍니다.

 

 

Spring Bean 등록 방식

1. Component Scan

 

- @Component 를 클래스 위에 명시하면 스프링이 알아서 등록해줍니다.

- @Controller, @Service, @Repository, @Configuration 은 @Componenet를 상속 받고 있으므로

   모두 컴포넌트 스캔 대상입니다.

 

- Controller : Spring MVC Controller
- Repository : 스프링 데이터 접근 계층으로 해당 계층의 예외는 모두 DataAccessException 으로 변환
- Service : 핵심 비지니스로 인식 ( 가독성 역할 )
- Configuration : Spring 설정 정보로 인식하고, 스프링 빈이 싱글톤 유지를 하도록 처리해줌

 

  •   컴포넌트 스캔 방법1 ( @Component를 붙쳐주는 방법 )

1) 각 서비스/레포지토리 등에 어노테이션을 달아줌 ( @Repository, @Service, @Controller )

@Service
public class MemberServiceImp implements MemberServiceInterface{
    
    private MemberRepository memberRepository;

    @Autowired
    public MemberServiceImp(MemberRepository memberRepository) {
        this.memberRepository = memberRepository;
    }

    // 회원가입
    @Override
    public Long join(Member member) {
        // 휴대폰 번호 중복 체크
        memberRepository.save(member);
        return member.getId();
    }
}
@Repository
public class JPQLMemberRepository implements MemberRepository{
    
    public Member save(Member member) {
        return member;
    }
}

 

2) 빈 등록이 됬으면 의존성 주입

 

 

  •   컴포넌트 스캔 방법2 ( @Bean를 붙쳐주는 방법 )

1) IoC컨테이너에 등록을 해줌 ( @Configuration )

@Configuration
public class SpringConfig {
	
    //등록
    @Bean
    public EntityManager getEm() {
        EntityManagerFactory emf = Persistence.createEntityManagerFactory("5xik");
        return emf.createEntityManager();
    }
    
    @Bean
    public MemberServiceInterface memberService() {
        return new MemberServiceImp(memberRepository());
    }

    @Bean
    public MemberRepository memberRepository() {
        return new JPQLMemberRepository(getEm());
    }
}

2) Repository / Serivce 에 필요한 의존성 주입

public class JPQLMemberRepository implements MemberRepository{

    private EntityManager em;

	@Autowired
    public JPQLMemberRepository(EntityManager em) {
        this.em = em;
    }
    
    public EntityTransaction getTx() {
        return em.getTransaction();
    }

    public Member save(Member member) {
        em.persist(member);
        return member;
    }
}

 

 

  •   컴포넌트 스캔 방법3 ( AutoConfig 설정 방식 )
@Configuration
@ComponentScan(
        excludeFilters = @Filter(type = FilterType.ANNOTATION, classes =
                Configuration.class))
public class AutoSpringConfig {

}

@ComponentScan 은 @SpringBootApplication 에 기본적으로 포함되어 있습니다.

그런데 만약 기존에 @Configuration 으로 컨테이너를 만들어 놨다면 두 설정파일을 읽어서 오류가 발생하겠죠??

 

이렇게 직접 컴포넌트 스캔을 설정해주면은 기존의 IoC컨테이너에 영향을 주지 않고

컴포넌트 스캔을 구현할 수 있습니다.

 

만약, 다른 IoC컨테이너가 없다면 따로 AutoConfig는 따로 설정할 필요 없습니다.

 

@SpringBootApplication 에 기본적으로 Component Scan 이 포함되어 있다는 의미는
IoC컨테이너가 없이도 @Component 관련 어노테이션을 다 읽는다는 의미이므로

@Repository 로 빈 등록하고,
@Configuration 내부에서 @Bean으로 또 등록하는 실수를 하게되면

Unique~~~ 와 관련 에러가 발생하니 유의하시길 바랍니다.

< 관련 오류 >
https://tjdwns4537.tistory.com/67?category=949574

 

 

Caused by: org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'JPQLMemberRepository' defined in file [/Repository/JPQLMemberRepository.class]: Unsatisfied dependency expressed through constructor parameter 0; nested exception is org.springframework.beans.factory.NoUniqueBeanDefinitionException: No qualifying bean of type 'javax.persistence.EntityManager' available: expected single matching bean but found 2: getEm,org.springframework.orm.jpa.SharedEntityManagerCreator#0

해결 과정.

1) IoC컨테이너에서 EntityManagerFactory, EntityManager를 따로 빈 등록하는 것이 아닌 하나의 함수에 묶어서 빈 등록

2) 레포지토리에서는 엔티티 매니저만 주입을 받음

3) 테스트코드에서는 레포지토리만 주입 받음

 

 

 

위의 과정 중 발생한 오류

 

org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'xik.ShoppingMall.Repository.JPQLMemberRepositoryTest': Unsatisfied dependency expressed through field 'repository'; nested exception is org.springframework.beans.factory.NoUniqueBeanDefinitionException: No qualifying bean of type 'xik.ShoppingMall.Repository.MemberRepository' available: expected single matching bean but found 2: JPQLMemberRepository,memberRepository

 

 

 

오류 키워드

1) NoUniqueBeanDefinitionException

2) available: expected single matching bean but found 2: JPQLMemberRepository,memberRepository

 

해당 키워드를 생각해보면 MemberRepository 두 개가 빈 등록 되어있어서 발생하는 오류임을 알 수 있습니다.

 

 

해결방법

: 기존에는 IoC컨테이너에서도 레포지토리를 빈 등록해주고, 레포지토리 클래스에서 @Repository를 해서 두번 빈 등록 해주고 있었습니다.

  - JPQLMemberRepository 에서 @Repository 할 꺼면 컨테이너에 레포지토리에 대한 빈 등록은 필요가 없습니다.

  - IoC컨테이너를 쓰려면 @Repository는 필요가없습니다.

  - 만약 두 군대에 레포지토리 등록을 사용하고 싶다면 JPQLMemberRepository 에 @Primary 를 붙쳐주면 해결됩니다.

 

 

* 빈 등록에 대해 숙지가 제대로 안된 상태라서 발생한 문제였습니다.

  빈 등록에 대해서 아래 주소에 블로깅해놨습니다.

 

https://tjdwns4537.tistory.com/70

 

Bean 등록 방법

 

tjdwns4537.tistory.com

 

 

 

 

 

 

저 같은 경우는 Entity 위치를 못 찾고 있어서 발생한 문제였습니다.

 

gradle 환경의 스프링에서 JPA를 사용하는데에에 있어서 persistence.xml로 설정을 하니 엔티티 위치를 못잡고 있었습니다.

 

persistence.xml에 아래 코드로 경로를 설정해주면 됩니다.

<class>xik.ShoppingMall.Domain.클래스명</class>
여러 클라이언트가 하나의 객체 인스턴스를 공유하기 때문에 싱글톤 객체는 상태를 유지하게 설계하면 안된다.

 

이는 아주 중요한 개념입니다.

 

왜 중요할까요 ??

 

공유값이 바뀌는 에러가 발생

코드로 예를 들어보겠습니다.

[ SingleTon class ]

public class StateFulService {

    private int price; // 상태 유지 필드

    public void order(String name, int price) {
        System.out.println("name:" + name + " price:" + price);
        this.price = price;
    }

    public int getPrice() {
        return price;
    }
}

 

[ Test code ]

class StateFulServiceTest {
    @Test
    void FailSigleTon() {
        ApplicationContext ac = new AnnotationConfigApplicationContext(TestConfig.class);
        StateFulService stateFulService1 = ac.getBean("stateFulService", StateFulService.class);
        StateFulService stateFulService2 = ac.getBean("stateFulService", StateFulService.class);

        stateFulService1.order("parkA", 10000);
        stateFulService2.order("parkB", 20000);

        int price = stateFulService1.getPrice();

        System.out.println("price: " + price);

        Assertions.assertThat(stateFulService1.getPrice()).isEqualTo(20000);
    }

    static class TestConfig{

        @Bean
        public StateFulService stateFulService() {
            return new StateFulService();
        }
    }
}

위의 코드에서 보면 과정은 이렇습니다.

 

[ 테스트 코드에서 생성된 스프링 컨테이너는 기본적으로 싱글톤을 사용할 수 있게 설계되어 있습니다. ]

 

1)  parkA라는 주문을 후 getPrice함수로 바로 가격을 출력해줘야하는데

 

2) 그 중간에 parkB라는 주문이 들어옵니다.

 

3) 그러면 price 라는 객체를 공유하고 있기 때문에 10000 -> 20000으로 바꿔버리게 됩니다.

 

4) 그래서 stateFulService1 객체의 getPrice() 함수를 사용해도 20000이 출력되게 되는 오류입니다.

 

 

 

 

예시의 싱글톤이 이러한 에러가 발생하는 이유는 설계에 아주 큰 문제점이 있습니다.

 

*** " 테스트 코드 (클라이언트) 가 값을 변경한다 " 라는 것입니다. ***

 

그래서 스프링 빈은 항상 무상태로 설계해야 하는 것입니다.

 

그렇다면 위의 예시를 다시 무상태로 설계해보겠습니다.

 

 

[ SingleTon class ]

public class StateFulService {

    public int order(String name, int _price) {
        System.out.println("name:" + name + " price:" + _price);
        int price =  _price;
        return price;
    }
}

 

[ 테스트 코드 ]

class StateFulServiceTest {
    @Test
    void FailSigleTon() {
        ApplicationContext ac = new AnnotationConfigApplicationContext(TestConfig.class);
        StateFulService stateFulService1 = ac.getBean("stateFulService", StateFulService.class);
        StateFulService stateFulService2 = ac.getBean("stateFulService", StateFulService.class);

        int price1 = stateFulService1.order("parkA", 10000);
        int price2 = stateFulService2.order("parkB", 20000);

        Assertions.assertThat(price1).isEqualTo(10000);
    }

    static class TestConfig{

        @Bean
        public StateFulService stateFulService() {
            return new StateFulService();
        }
    }
}

 

이렇게 공유된 필드를 사용하는게 아니라 지역변수를 통해서 공유하지 못하게 해결할 수 있습니다.

 

 

이 외에 싱글톤의 문제점을 해결하는 방법으로 @Configuration 에노테이션을 사용하는 방법이 있습니다.
이 부분은 검색해보시면 많은 자료를 찾아볼 수 있습니다.

 

 

 

 

org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'springConfig': Unsatisfied dependency expressed through constructor parameter 0; nested exception is org.springframework.beans.factory.NoSuchBeanDefinitionException: No qualifying bean of type 'xik.ShoppingMall.Repository.MemberRepository' available: expected at least 1 bean which qualifies as autowire candidate. Dependency annotations: {}

위와 같은 에러가 발생하였습니다.
위의 에러를 해석해보면 빈을 등록할 때 에러가 발생한것으로 보입니다.

 

 

[ 기존 코드 ]

//    @Autowired
//    MemberServiceInterface memberService;
//
//    @Autowired
//    OrderService orderService;
//
//    @Autowired
//    DiscountPolicy discountPolicy;

 

 

[ 새로 작성한 코드 ]

ApplicationContext applicationContext = new AnnotationConfigApplicationContext(SpringConfig.class);
MemberServiceInterface memberService = applicationContext.getBean("memberService", MemberServiceInterface.class);
OrderService orderService = applicationContext.getBean("orderService", OrderService.class);

 

 

클라이언트 코드에서 스프링 컨테이너를 사용하는 버젼으로 바꾸기 위해 사용하다가 에러가 발생했습니다.

 

뭐가 문제인지 에러 코드를 다시 읽어보겠습니다.

 

 

 

UnsatisfiedDependencyException : Error creating bean with name 'springConfig'

: springConfig 라는 이름을 찾고있네요 ??

근데 제 코드를 보면 applicationContext 객체를 불러올때도 SpringConfig를 사용해주고 있고, 

애초에 클래스명도 SpringConfig로 적어둔 상태입니다.

근데 확인을 해보니 애초에 맨 앞 철자가 소문자로 변경된 형태로 등록되기 때문에 상관없는 문제라고 하네요.

 

그러면 다음 에러코드를 읽어보겠습니다.

 

: Unsatisfied dependency expressed through constructor parameter 0; nested exception is org.springframework.beans.factory.NoSuchBeanDefinitionException: No qualifying bean of type 'xik.ShoppingMall.Repository.MemberRepository' available: expected at least 1 bean which qualifies as autowire candidate. Dependency annotations: {}

위의  코드를 읽어보니 Repository에서의 bean을 등록해줄 때 생기는 문제 같습니다.

코드의 구성도를 보면

 

[ SpringConfig ]

    public SpringConfig(MemberRepository memberRepository) {
        this.memberRepository = memberRepository;
    }

 

[ SpringDataJpaRepository ]

public interface SpringDataJpaMemberRepository extends JpaRepository<Member, Long>, MemberRepository {

    @Override
    Optional<Member> findByName(String name);

}

 

의심가는 부분이 있네요

applicationContext 로 getBean으로 빈들을 가져오는데,

제 코드 같은 경우 SpringConfig 생성자에서 JpaRepository로 구현된 레포지토리를 불러오는데

이 부분도  "@Autowired 로 주입을 해줄게 아니라 applicationContext로 주입을 해줘야하는게 아닌가? " 라는 생각입니다.

 

그런데 이 의문에도 확신이 없습니다.

의존성 주입과 빈 등록에 대해 확실히 알고 나서 진행해야할 것 같네요

https://tjdwns4537.tistory.com/52

 

의존성 주입과 빈 등록

IoC 컨테이너에 빈으로 등록이 되어야 의존성 주입을 할 수 있습니다. 먼저, 여러가지 의존성 주입 방법이 있지만 @Autowired를 통해서 주입하는 것을 알아보겠습니다. @Autowired 필요한 의존 객체의

tjdwns4537.tistory.com

 

정리를 해봤으니 에러에 대해 다시 분석해보겠습니다.

applicationContext는 스프링 컨테이너이고,

@Autowired는 의존관계 주입을 해주는 것이네요.

 

그러면 스프링 컨테이너에서 빈이 등록될 때 문제가 발생되는 것으로 보입니다.

 

[ IoC컨테이너 ]

[ 테스트 코드 ]

 

구성도를 보면

1) ApplicationContext 로 SpringConfig 컨테이너에 대한 빈 팩토리 생성

2) 팩토리의 getBean메서드를 통해 memberService,orderService라는 빈을 가져옴

* 그런데 memberRepository에 대한 빈이 없다는 에러내용

 => 결국, JpaRepository에서 자동으로 빈을 만들어주는 것으로 믿고 있었지만 실제로는 안만들어지고 있는것으로 보임

 => 순수 자바 코드인 MemoryMeberRepository로 @Bean등록해주고 테스트하니 정상 작동

 

 

 

* 결론

: @Autowired할 스프링 빈 대상이 없어 발생하는 문제입니다.

원인은 JpaRepository에서 빈을 등록해주는 과정에서 문제가 있는 것으로 보입니다.

그런데 JpaRepository를 상속받으면 자동으로 빈을 등록해주는 것으로 배웠는데 정확한 이유는 아직 파악하지 못했습니다.

이유를 알게되는대로 다시 블로그에 업데이트하도록 하겠습니다.

 

 

 

 

 

[ 2022.07.12 확인된 해결방법1 ]
Jpa이 버젼 문제였던 것으로 확인되었습니다.
spring-data-jpa 2버전은 spring-core 5부터 호환이 된다고 합니다.
버젼을 낮춰서 실행해보시면 해결됩니다.


[ 2022.07.12 확인된 해결방법2 ]
멤버 이름에 대소문자 구분 처리에 대한 에러가 있을 수 있다.

* 참고 : 아직 저는 해결되지 않았습니다.. ( 2022.07.19 해결됬습니다.. )

 

 

 

 

[ 해결완료 ]

해결방법 :

1) @Repository 어노테이션을 붙치면 레포지토리에 대한 에러가 @Component랑은 다르게 나온다는걸 알 게 되었습니다.

2) JpaRepository 상속받는 부분에 @Repository를 붙쳐주니 아래와 같은 에러로그를 볼 수 있습니다.

Repository인터페이스의 findall 메서드를 가리키고 있는거 같습니다. 그러면 서비스에서 findall을 호출해주는 부분을 찾아보겠습니다.

레포지토리의 findall을 실제 호출해주는 부분이 있습니다. 

 

그런데 기본적으로 jpaRepository를 상속받게 되면 기본적으로 제공되는 다양한 메서드들이 있습니다.

그 중에 findAll() 이라는 메서드가 있습니다.

그런데 저의 소스코드에서는 findAll과 같은 역할을 하는 findall 메서드가 존재합니다.

즉, 이유는 간단했습니다.

 

1) 컨트롤러에서 서비스의 findMeber() 메서드를 호출

2) findMember메서드에서는 findall() 메서드를 호출

3) 하지만 findall() 메서드는 jpaRepository 메서드와는 엄밀히 다른 메서드이므로 jpaRepository 상속 메서드 안에서 사용하고 싶다면

  따로 선언했어야함

   * JpaRepository 명명 규칙 : https://www.devkuma.com/docs/spring/jparepository%EC%9D%98-%EB%A9%94%EC%86%8C%EB%93%9C-%EB%AA%85%EB%AA%85-%EA%B7%9C%EC%B9%99/

 

4) MemberRepository에 선언되어 있는 findall() 메서드의 이름을 findAll() 로 바꿔준 후 서비스에서 사용

5) 테스트 결과 이상 없음

 

 

몇일 동안 이 문제로 삽질했는데 드디어 해결됬습니다 !!

IoC 컨테이너에 빈으로 등록이 되어야 의존성 주입을 할 수 있습니다.

먼저, 여러가지 의존성 주입 방법이 있지만 @Autowired를 통해서 주입하는 것을 알아보겠습니다.

 

@Autowired

필요한 의존 객체의 타입에 해당하는 빈을 찾아 주입합니다. 즉, IoC컨테이너 안에 등록되어 있는 빈에서 객체를 찾아 의존성 주입을 해줍니다.

 

 

Bean

스프링 IoC컨테이너가 관리하는 객체이다.

 

  •  장점

1) 의존성 관리가 용이

2) 빈으로 등록된 객체는 기본적으로 스코프가 "싱글톤"으로 정해짐

3) 라이프사이클 인터페이스를 지원해준다.

 

 

  •  BeanFactory

스프링 IoC컨테이너의 가장 최상위 인터페이스이다.

 

 

  • ApplicationContext

BeanFactory를 포함한 다른 컨테이너를 상속받는다.

 

 

  •   Bean 설정 방법 1

1) spring-boot-starter-web 의존성 설정

2) @Component 어노테이션을 클래스에 붙쳐준다. ( 자동으로 빈이 등록됨 )

3) @Autowired 어노테이션을 통해 의존성 주입을 해준다.

 

  • Bean 설정 방법 2 ( Java Config )

1) Config 파일에 @Configuration 어노테이션을 붙쳐준다.

2) @Bean을 통해 직접 빈을 등록해준다.

3) @Autowired를 통해 등록되어 있는 

 

 

 

 

정리

예제 코드를 보면

- @Bean은 Spring에 BookingService를 제공 ( 등록 )

- @Autowired는 이를 사용 ( 의존성 주입 )

 

@SpringBootApplication
public class Application {

  @Autowired
  BookingService bookingService;

  @Bean
  BookingService bookingService() {
    return new BookingService();
  }

  public static void main(String[] args) {
    bookingService.book("Alice", "Bob", "Carol");
  }
}
에러 코드 :
org.junit.jupiter.api.extension.ParameterResolutionException: No ParameterResolver registered for parameter [java.lang.Long arg0] in method [void xik.ShoppingMall.Service.OrderServiceImpTest.createOrder(java.lang.Long,java.lang.String,int)].

테스트 코드에서 의존성 주입을 하니 다음과 같은 에러가 발생하였습니다.

 

혹시나 싶어 순수 자바로 테스트를 해보려고 작성하다보니 이상한게 보이네요...

 

 

쓰지도 않는 인자들이 왜 들어있는지.. 지우니까 정상작동하네요..

 

허무한 해결완료입니다..

 

 

ㅇ GET/POST 이해하기

 

1) GET 란?

 - URL에 데이터를 포함시켜 요청

 - 데이터를 헤더에 포함하여 전송

 - URL에 데이터가 노출되어 보안에 취약

 - 캐싱할 수 있음

 => 주로 조회할때만 사용

 

2) POST 란?

 - URL에 데이터를 노출하지 않고 요청

 - 데이터를 바디에 포함

 - URL에 데이터가 노출되지 않아 GET방식보다 보안이 높음

 - 캐싱할 수 없음

=> 주로 노출되면 안되는 데이터를 저장할 때 사용

 

 

HttpMethod

: 다음의 각 메소드들은 HttpMethod에 매칭됩니다.

1) PostMapping

2) GetMapping

3) PutMapping

4) DeleteMapping

5) PatchMapping

 

 

 

ㅇ RequestMapping

1) 스프링부트 애플리케이션이 실행되면 애플리케이션에서 사용할 bean들을 담을 ApplicationContext를 생성하고 초기화합니다.

2) @RequestMapping 이 붙은 메서드들이 핸들러에 등록되는 것은 Application refresh되는 과정에서 일어납니다.

   refresh과정에서 Spring Application 구동을 위해 많은 Bean들이 생성되고,

  그 중 하나가 RequestMappingHandlerMapping 입니다. 이 Bean은 우리가 @RequestMapping 으로 등록한 메서드들을 가지고      있다가 요청이 들어오면 Mapping해주는 역할을 합니다.

3) Bean으로 등록된 HandlerMapping이 변수들을 찾아서 Adapter를 거쳐 실행합니다.

 

 

 

ㅇ Handler

: Spring MVC에서는 핸들러가 @Controller클래스를 의미합니다.

@GetMapping / @PostMapping 을 핸들러 메서드라고 부릅니다.

결국 Handler Mapping 이란 사용자의 요청과 이 요청을 처리하는 Handler를 매핑해주는 역할을 하는 것입니다.

 

 

 

 

ㅇ HandlerAdapter

:  스프링 부트가 아닌 다른 프레임워크의 핸들러를 Spring MVC에 통합하기 위해서는

 HandlerAdapter를 사용할 수 있습니다.

 

 

 

 

 ㅇ @RequestMapping

 

1) value 

: URL값으로 매핑 조건을 부여합니다. 보통 호스트주소와 포트번호를 제외한 url주소를 넣어줍니다.

  이는 다중 요청이 가능하여 @RequestMapping ( {"/hello". "/hello-rule", "/hello/**" } ) 형식으로 사용할 수 있습니다.

 

2) method

: HTTP request 메소드 값을 매핑조건으로 부여하는데 GET,POST,HEAD,OPTIONS,PUT,DELETE,TRACE 메소드가 존재합니다.

 

 

 

 

ㅇ PostMapping / GetMapping

: Post/Get method 로 RequestMapping을 합니다.

 

1) @PostMapping : HTTP Post Method에 해당하는 단축 표현으로 서버에 리소스를 등록(저장)할 때 사용합니다.

2) @GetMapping : HTTP Get Method에 해당하는 단축 표현으로 서버의 리소스를 조회할 때 사용합니다.

3) @DeleteMapping : 서버의 리소스를 삭제

4) @PutMapping : 서버의 리소스를 모두 수정

5) @PatchMapping : 서버의 리소스를 일부 수정

 

 

 

 

ㅇ 현재 저의 소스코드로 설명

@GetMapping("/new")
public String New() {
        return "/Login/회원가입";
}

@PostMapping("/new")
public String create(MemberForm form) {
        Member member = new Member();
        member.setName(form.getName());
        member.setPhoneNumber(form.getPhoneNumber());

        memberService.join(member);

        return "redirect:/";
}
<form action="/new" method="post">

 - 위의 소스 코드에 대한 설명 및 순서도

 

 

 

 

 

 

 

 

* 출처

https://itvillage.tistory.com/33

https://velog.io/@dyunge_100/Spring-%EC%9A%94%EC%B2%AD-%EB%B0%A9%EC%8B%9DRequestMapping-GetMapping-PostMapping

 

 

김영한님의 수업에서 JPA 실습을 해보던 중 아래와 같은 에러가 발생하였습니다.

javax.persistence.PersistenceException: org.hibernate.exception.SQLGrammarException: could not prepare statement

 

위의 에러 내용을 보면 Column "MEMBER0_.USER_NAME" not found; SQL statement: 라는 문구를 볼 수 있습니다.

 

그래서 몇가지 의심되는 부분에 대해 해결해봤습니다.

 

1. Member class 에 Column을 잘못 설정했는지 확인

: 테이블에 컬럼명을 이미 만들어 둔 상태기때문에 @Column으로 이름을 정해줘서 사용하는데, 이때 오타가 있나 확인해봤습니다.

 

 => 정상인 것으로 확인

 

2. application.properties 에 jpa 설정 제대로 추가해줬는지 확인 ( 해결 )    --> 아래 내용 읽어보기

spring.jpa.hibernate.ddl-auto = create

원래 create가 아닌 none으로 설정해줬었는데 이 부분에서 오류가 나고 있었습니다.

원래 제가 이해한 바로는 create는 객체에 대한 테이블과 테이블의 컬럼명을 자동으로 생성해주기때문에 저는 none을 사용했습니다.

그런데 이 부분에 대해 오류가 나고 있었던걸 보아 다시 개념을 확실히 잡고 넘어가야 할것 같습니다.

https://tjdwns4537.tistory.com/manage/newpost/?type=post&returnURL=%2Fmanage%2Fposts%2F 

 

TISTORY

나를 표현하는 블로그를 만들어보세요.

www.tistory.com

 

여기서 또 다시 드는 의문점은

create를 해주고 다시 none을 해준 후 테스트를 해보면 되는 것입니다.

그래서 테이블을 직접 확인해보니 phonenumber이였던 컬럼명이 phone_number이 되어있었습니다.

그래서 테이블을 삭제 후 다시 테스트를 해보던 중 아래와 같은 사실을 알게 되었습니다.

 

Member class에 각 객체에 @Column 으로 이름을 설정해줬는데

이때 @Column(name="phoneNumber") 로 설정을 하니까 PHONE_NUMBER로 읽고 PHONE_NUMER 컬럼을 찾으려고하니

에러가 발생하고 있던것입니다.

그래서 @Column(name="phonenumber")라고 설정하니 ddl-auto = none을 하고도 에러발생하지 않았습니다.

spring.jpa.hibernate.ddl-auto = none

이 외에도 column명을 설정할때 전부 대문자로 해도 에러는 발생하지 않습니다.

단지 소문자뒤에 대문자가 오면 구분자로 (_) 가 들어간다는 것입니다.

 

그래서 다시 확인을 해보니 다음과 같은 규칙이 있었습니다.

 

 

ㅇ JPA의 기본 테이블 DDL Naming 전략

 기본적으로 JPA는 DDL을 진행할 때 Entity 이름과 해당 필드들을 lower_under_score 방식을 이용합니다.

 예를 들어, @Column(name="PARK") 를 하면 Table에는 park로 lowerCarmelCase 를 lower_snake_case로

 변환하여 테이블을 생성합니다.

 

 이는 중요한 문제입니다.

 그 이유는 MySQL 에서 쿼리를 날릴 때에 [ 문자의 대소문자를 확실히 구분하는 설정 ] 과 [ 그렇지 않은 설정 ] 이

 있기 때문에 만약 확실히 구분하는 설정일 경우에 에러가 발생합니다.

 

 

* 해결 방법

SpringPhysicalNamingStrategy를 상속받아 Custom하여 설정값으로 물려주는 방법입니다.

 

1) 상속받는다.

2) 오버라이딩하여 함수를 작성해준다.

public class UpperCaseNamingStrategy extends SpringPhysicalNamingStrategy {

    @Override
    protected Identifier getIdentifier(String name, boolean quoted, JdbcEnvironment jdbcEnvironment) {
        return new Identifier(name.toUpperCase(), quoted); // 파라미터로 넘어온 name값을 대문자화 시킵니다.
    }
}

3) UpperCaseNamingStrategy 클래스를 application.yml에 설정해줍니다.

spring:
  jpa:
    hibernate:
      naming:
        physical-strategy: web.common.strategy.UpperCaseNamingStrategy # 커스텀클래스 패키지

 ==> 확인해보면 UPPER_SNAKE_CASE가 적용된 모습을 볼 수 있습니다.

 

 

 

 

- 결론

1) 애초에 컬럼명을 설정할 때, 대문자나 소문자 하나로 설정한다.

2) UpperCaseNamingStrategy 클래스를 이용한다.

 

 

 

 

* 링크

https://velog.io/@devduhan/Spring%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-JPA-Naming-%EC%A0%84%EB%9E%B5

 

# JPA설정 이해하기

spring.jpa.hibernate.ddl-auto = create

 

- 역할

 1) create : 해당하는 테이블이 있으면 DROP하고 새로 만들어버린다.

 2) create-drop : create와 같은데 종료시점에 테이블 DROP한다.

 3) update : 변경분만 반영

 4) validate : 엔티티와 테이블이 정상매핑되었는지만 확인

 5) none : 사용하지 않음 ( 사실상 없는 값이지만 관례상으로 적는다. )

 

- 주의사항

: create, create-drop, update는 로컬 또는 개발 초기단계 환경에서만 사용해야한다.

   위의 역할에서 처럼 테이블을 자동으로 DROP해버린다는건.. 상상도 하기 싫은 일이다.

 

인프런 김영한 강사님의 수업을 들으면서 실습하던 중 아래와 같은 에러가 발생하였습니다.

org.h2.jdbc.JdbcSQLSyntaxErrorException: Syntax error in SQL statement "INSERT INTO MEMBER(NAME) VALUE[*]()"; expected "DIRECT, SORTED, DEFAULT, VALUES, SET, (, WITH, SELECT, TABLE, VALUES"; SQL statement:

insert into member(name) value() [42001-200]



java.lang.IllegalStateException: org.h2.jdbc.JdbcSQLSyntaxErrorException: Syntax error in SQL statement "INSERT INTO MEMBER(NAME) VALUE[*]()"; expected "DIRECT, SORTED, DEFAULT, VALUES, SET, (, WITH, SELECT, TABLE, VALUES"; SQL statement:
insert into member(name) value() [42001-200]



Caused by: org.h2.jdbc.JdbcSQLSyntaxErrorException: Syntax error in SQL statement "INSERT INTO MEMBER(NAME) VALUE[*]()"; expected "DIRECT, SORTED, DEFAULT, VALUES, SET, (, WITH, SELECT, TABLE, VALUES"; SQL statement:
insert into member(name) value() [42001-200]



2022-07-05 17:38:53.021  INFO 96188 --- [ionShutdownHook] com.zaxxer.hikari.HikariDataSource       : HikariPool-1 - Shutdown initiated...
MemberServiceIntegrationTest > join() FAILED

위와 같은 에러가 발생하는 이유는 강사님은 Member.name 에 대해서만 실습을 진행하였는데

저는 PhoneNumber 라는 변수 하나를 추가해서 실습했기 때문이라고 추정됩니다.

 

그런데 DB를 모르니 뭐가 문젠지 통 알 수가 없네요..

천천히 하나씩 뜯어보도록 하겠습니다.

 

가장 먼저

1) Syntax error in SQL statement "INSERT INTO MEMBER(NAME) VALUE[*]()"

2) insert into member(name) value() [42001-200]

 

이 두 문장을 봤을때 INSERT 쿼리에서 문제가 발생했다는 것 같습니다.

그래서 코드를 뜯어 보겠습니다.

"insert into member(name) value()";

- 해석 : member 테이블에 name 컬럼에 value() 값을 넣어준다.

이 부분에서 에러가 발생하는것 같습니다.. 그렇다면 h2 database 쪽 문제로 보이는데

 

 

1. dependency 버전 문제인가?

 

아래 사진을 보면 인텔리제이의 h2 버전이 제가 실행중인 h2 버전과 다른걸 알 수 있습니다.

 

 

제가 사용중인 버전은 아래 사진처럼 1.4.200입니다.

 

아래 사진과 같이 버전을 직접 적어서 dependency를 추가해줍니다.

 

똑같은 오류가 발생중입니다..

 

2. 예외의 원인을 찾아보자

IllegalStateException

위와 같은 예외가 제일 먼저 발생하는데 그 이유는 무엇일까?

 - 위와 같은 오류가 발생한 이유는 이전에 value 값이 들어가지 않았기 때문에 없는 value 에 접근하려고하니 에러가 발생하는 것이였다.

 

3. 오타 해결...

String sql = "insert into member(name) values(?)";

 1) value 가 아니라 values로 값 목록을 찾아야한다.

 2) values ( ? ) 은 레코드를 다수 넣기위한 하나의 문법이다.

 - 둘 이상의 값을 포함하는 배열을 생성 후

 - 쿼리문에 물음표를 작성하면 해당 자리가 값 배열로 대체됩니다.

String sql = "insert into member(name) values(?)";
PreparedStatement pstmt = null;
Connection conn = null;
pstmt = conn.prepareStatement(sql,Statement.RETURN_GENERATED_KEYS);

 * Statement.RETURN_GENERATED_KEYS

 : DB상에 AUTO_INCREMENT로 인해 자동으로 생성되어진 key(=id) 를 가져오는 쿼리

 * Statement class

 : SQL 구문 실행하는 역할

 * PreparedStatement class

 : Statement를 상속받는 인터페이스로 동일한 SQL구문을 반복 실행한다면 향상된 기능으로 실행시키는 객체

 - 예시)

 // Statement class
String name = "홍길동";
String memo = "메모 테스트 입니다. 홍길동's 메모장";
String priority = "1";
String sql = String.format("insert into tblMemo values(memoSeq.nextval,'%s','%s',default,%s)", name, memo, priority); 


// PreparedStatement class
sql = "insert into tblMemo values(memoSeq.nextval,?,?,default,?)";
pstat = conn.prepareStatement(sql);

//매개변수 값 대입 + 매개변수 유효화 처리.
pstat.setString(1, name);
pstat.setString(2, memo);
pstat.setString(3, priority);

INFO: 0 containers and 3 tests were Method or class mismatch 아 같은 에러가 발생하였습니다.

 

이 에러는 테스트 메서드를 3개를 작성해놓고 1개만 실행시킨 경우에 발생하는 에러로 무시하고 진행해도 됩니다.

 

 

 

 

해결하고 싶은경우 맥OS 의 인텔리제이 기준으로

 

preference -> Build, Execution, Deployment -> Build Tools -> Gradle 에서

 

Build and run using Run tests using의 속성을 Intellij IDEA로 해주시면 해결됩니다.

 

스프링 부트 2.4에서 패치가 생겼습니다.

그래서 스프링 부트 2.4부터는 application.properties밑에 `spring.datasource.username=sa`를 꼭 붙쳐줘야합니다.

스프링 강의를 듣던 중 테스트 코드부분에서 아래의 1번으로 2번 테스트 코드의 차이점이 무엇인가에 대한 의문점이 생겼습니다.

 

// 1.
assertThat(member.getName()).isEqualTo(result.getName());

// 2. 
Assertions.assertThat(member.getName()).isEqualTo(result);

 

그래서 위 두 코드의 차이점을 알아보고 싶었습니다.

 

 

ㅇ 테스트 코드란?

 

: 오류가 났을 때 기대값과 실제 값을 둘 다 보여줘서 어떤 부분이 틀렸는지 명확히 보여줘 오류 찾는 시간을 단축시킨다.

 

 - @Test : 테스트 대상 메서드를 지정

 - @Test ( timeout = 밀리초 ) : 테스트 메소드 수행시간을 제한

 - @Ignore : 해당 메서드의 테스트가 진행되지 않도록 지정

 - @BeforeEach : 모든 @Test메서드 실행 전에 실행되는 메서드를 지정 어노테이션

 - @AfterEach : 모든 @Test메서드의 실행이 끝난 후 실행되는 메서드 지정 어노트에션

 - @BeforeAll / @AfterAll : 해당 테스트 클래스가 실행될 때 / 실행 끝났을 때 딱 한번 수행되는 테스트 코드 지정 어노테이션

 

 

 

ㅇ 두 테스트 코드의 설명

 

1. assertThat( actual ).isEqualTo( expected )

: org.junit.Assert.assertThat 클래스

: actual - 실제 값, expected - 내가 기대한 값

 

 

 

2. Assertions.assertThat()

: org.assertj.core.api.Assertions 클래스

: assertThat으로 비교할 대상을 설정하고 isEqualTo()로 사용자가 생각하는 값을 비교한 뒤 그게 맞는지 검사하는 테스트

 

 

ㅇ 차이점

 

1. assertThat( actual , Matcher )

 -  actual 인자 : 검증 대상을 넣음

 - Matcher 인자 : 로직을 주입받아 검증 단계 수행

 

 이 부분에서 Matcher 를 개발자가 직접 구현하는 것은 비효율적이고, 이 부분에서 오류가 발생할 수 있습니다.

오류가 발생하면 테스트는 실패하는데 외부 요인에 영향을 받는 것은 좋은 테스트가 아닙니다.

 

문제점 1) 자동완성

: allOf, graterThan, lessThan 등 메서드를 미리 import 해놓지 않으면 자동 완성해주지 못하기 때문에

공식 문서를 찾거나 이름을 외워서 작성해야 합니다.

 

문제점 2) Assetions 분류 ( Matcher )

: hamcrest에 구현된 matcher를 사용해야하는데 여러 타입의 matcher가 함께 있으니 내가 원하는 타입의

matcher 찾기가 불편합니다.

 

문제점 3) 확장성

: 추가된 조건도 같이 검증하기 위해선 allOf 라는 메소드로 기존 조건을 묶어줘야하는데 이는 가독성이 좋지 않습니다.

 

 

 

* hamcrest matcher 란?

https://tjdwns4537.tistory.com/38

 

Hamcrest, Matcher란?

ㅇ Matcher 클래스 : Matcher 클래스는 대상 문자읠 패턴을 해석하고 주어진 패턴과 일치하는지 판별할 때 주로 사용됩니다. 입력 값으로는 CharSequence라는 새로운 인터페이스가 주로 사용되는데, 다

tjdwns4537.tistory.com

 

 

2. Assertions

: 이러한 문제점 세가지를 해결한게 org.assertj.core.api.Assertions 에서 제공하는 assertThat입니다.

필요한 메서드를 검색해서 import 해야하는 번거로움도 없고, 가독성도 좋습니다.

 

 

 * 결론

: org.assertj.core.api.Assertions 에서 제공하는 Assetions 로 테스트를 진행하도록 하자.

 

 

 

 

 * 그 외 테스트 코드



1) Assertions.assertThat().isInstanceOf()

: assertThat - 테스트할 대상, isInstanceOf - 앞의 테스트대상의 인스턴스인가? 라고 묻는 것으로

  앞에 자식이 될 요소, 뒤를 부모가 될 요소를 넣는다. 즉, 참조나 상속을 받고있는지 묻는 것이다.




 * org.junit.jupiter.api.Assertions.assertThrows 클래스를 사용

2) Assertions.assertThrows(에러클래스,에러발생)

: 일부러 오류를 발생시키는 테스트 코드이다. 이는 예외로 들어와서 오류가 제대로 나오는지 테스트하기 위함이다.

: assertThrows의 첫번째 인자 - 에러, 두번째 인자 - 에러 발생 요소

: 제대로 에러가 뜨면 True를 반환
    <link rel="stylesheet" th:href="@{/css/MyStyle.css}"/>

부트스트랩을 이용해 게시판을 만들던 중 CSS 파일들이 적용안되는 문제가 발생했습니다.

 

CDN 경로로 설정하면 되는데 파일을 다운받아서 적용시키면 안되는 오류 였습니다.

 

기존 문제. 

Resources/css

Resources/js

이 두 폴더를 따로 만들어서 적용시킨 것이 문제였습니다.

 

인텔리제이가 Run 될 때, static 폴더를 접근해서 구동시키니 static 폴더 아래에 css/ , js/ 폴더를 만들어서 경로를 설정해줘야하는데

 

이를 생각하지 못해서 삽질을 하고 있던 것입니다.

 

만약 저와 같이 부트스트랩 활용 또는 CSS 파일이 적용이 안된다면 아래 사진처럼 따라 해보시길 권장드립니다.

 

 

 

 

 

 (1) HTML 에 링크걸어준 부분

    <link rel="stylesheet" th:href="@{/css/MyStyle.css}"/>

(2) CSS , JS 폴더 경로들

+ Recent posts