20230628 공부노트

라영진·2023년 6월 28일
0

Java 학습일지

목록 보기
28/35

스프링부트 프로젝트의 구조

다운로드 이후 첫 파일은 HelloController.java와 HelloLombok.java 파일만 생성한 상태

프로젝트의 전체 구조는
src/main/java 디렉터리
SbbApplication.java 파일
src/main/resources 디렉터리
templates 디렉터리
static 디렉터리
application.properties 파일
src/test/java 디렉터리
build.gradle 파일
위 구조로 짜여져있다.(초기설정값)

1.src/main/java 디렉터리
src/main/java 디렉터리의 com.mysite.sbb 패키지는 자바 파일을 작성하는 공간이다. 자바 파일로는 HelloController와 같은 스프링부트의 컨트롤러, 폼과 DTO, 데이터 베이스 처리를 위한 엔티티, 서비스 파일등이 있다.

2.SbbApplication.java 파일
모든 프로그램에는 시작을 담당하는 파일이 있다. 스프링부트 애플리케이션에도 시작을 담당하는 파일이 있는데 그 파일이 바로 <프로젝트명> + Application.java 파일이다. 스프링부트 프로젝트를 생성할때 "Sbb"라는 이름을 사용하면 다음과 같은 SbbApplication.java 파일이 자동으로 생성된다.

3.src/main/resources 디렉터리
src/main/resources 디렉터리는 자바 파일을 제외한 HTML, CSS, Javascript, 환경파일 등을 작성하는 공간이다.

4.templates 디렉터리
src/main/resources 디렉터리의 하위 디렉터리인 templates 디렉터리에는 템플릿 파일을 저장한다. 템플릿 파일은 HTML 파일 형태로 자바 객체와 연동되는 파일이다. templates 디렉터리에는 SBB의 질문 목록, 질문 상세 등의 HTML 파일을 저장한다.

5.static 디렉터리
static 디렉터리는 SBB 프로젝트의 스타일시트(.css), 자바스크립트(.js) 그리고 이미지 파일(.jpg, .png) 등을 저장하는 공간이다.

6.application.properties 파일
application.properties 파일은 SBB 프로젝트의 환경을 설정한다. SBB 프로젝트의 환경, 데이터베이스 등의 설정을 이 파일에 저장한다.

7.src/test/java 디렉터리
src/test/java 디렉터리는 SBB 프로젝트에서 작성한 파일을 테스트하기 위한 테스트 코드를 작성하는 공간이다. JUnit과 스프링부트의 테스팅 도구를 사용하여 서버를 실행하지 않은 상태에서 src/main/java 디렉터리에 작성한 코드를 테스트할 수 있다.

8.build.gradle 파일
그레이들(Gradle)이 사용하는 환경 파일이다. 그레이들은 그루비(Groovy)를 기반으로 한 빌드 도구로 Ant, Maven과 같은 이전 세대 빌드 도구의 단점을 보완하고 장점을 취합하여 만든 빌드 도구이다. build.gradle 파일에는 프로젝트를 위해 필요한 플러그인과 라이브러리 등을 기술한다.


(2-2) 컨트롤러

URL매핑

매핑을 추가하기 위해서는 컨트롤러가 필요하기에 maincontroller 파일을 만들어준다.

maincontroller 클래스에 @controller 애너테이션을 적용시킨다
그러면 maincontroller 클래스는 스프링부트의 컨트롤러가 된다.
그리고 메서드의 @getmapping 애너테이션은 요청된 url과 매핑을 담당한다.

@responsebody 애너테이션은 url요청에 대한 응답으로 문자열을 리턴하라는 의미이다.


(2-3) JPA

우리가 만들 SBB는 질문 답변 게시판이기 떄문에 데이터 저장,조회,수정하는 기능을 구현해야 한다.웹 서비스는 데이터를 처리할 때 대부분 데이터베이스를 사용하는데, SQL쿼리(query)라는 구조화된 질의를 작성하고 실행하는 등의 복잡한 과정이 필요하다.
이때 ORM(object relational mapping)을 이용하면 자바 문법만으로도 데이터베이스를 다룰 수 있다.
즉, ORM을 이용하면 개발자가 쿼리를 직접 작성하지 않아도 데이터베이스의 데이터를 처리할 수 있다.

sql쿼리
insert into question(subject, content) values ('안녕', '가입 인사');
insert into question(subject, content) values ('질문', 'orm 궁금');

등 sql코드로 작성해야 하지만

orm을 사용하면 쿼리 대신 자바 코드로 다음처럼 작성할 수 있다.

Question q1 = new Question();
q.1setSubject("안녕하세요");
q1.setContent("가입 인사드립니다 ^^");
this.questionRepository.sace(q1);

Question q2 = new Question();
q2.setSubject("질문 있습니다");
q2.setContent("ORM이 궁금합니다");
this.questionRepository.save(q2);

위 코드처럼 Question은 자바 클래스이며, 이처럼 데이터를 관리하는 데 사용하는 ORM클래스를 엔티티(Entity)라 부른다.
내부에서 SQL쿼리를 자동생성해주기 때문에 유지.보수 편리함

JPA
스프링부트는 JPA(java persistence API)를 사용하여 데이터베이스를 처리한다.
ORM의 기술 표준으로 사용하는 인터페이스의 모음

H2데이터베이스
개발시에는 Oracle,MSSQL 등 굵직한 데이터베이스를 설치하지만
간단하고 설치도 쉽고 사용도 편리한 H2데이터베이스(경량 데이터베이스)를 많이 사용한다.

인텔리제이에서 "Refresh Gradle Project"를 실행하라는 뜻은 라이브러리를 설치하라는 뜻이고, 화면내에 새로운 코드가 감지되면 코끼리 모양이 보인다(click)

설치한 H2 데이터베이스를 사용하기 위해
application.properties 파일을 수정해야 한다.

spring.h2.console.enabled - H2 콘솔의 접속을 허용할지의 여부이다. true로 설정한다.
spring.h2.console.path - 콘솔 접속을 위한 URL 경로이다.
spring.datasource.url - 데이터베이스 접속을 위한 경로이다.
spring.datasource.driverClassName - 데이터베이스 접속시 사용하는 드라이버이다.
spring.datasource.username - 데이터베이스의 사용자명이다. (사용자명은 기본 값인 sa로 설정한다.)
spring.datasource.password - 데이터베이스의 패스워드이다. 로컬 개발 용도로만 사용하기 때문에 패스워드를 설정하지 않았다.

spring.jpa.properties.hibernate.dialect - 데이터베이스 엔진 종류를 설정한다.
spring.jpa.hibernate.ddl-auto - 엔티티를 기준으로 테이블을 생성하는 규칙을 정의한다.


(2-4)엔티티

우리가 만들고 있는 질문과 답변 엔티티에는 어떤 속성들이 필요한지 먼저 생각해 보자.

질문(Question) 엔티티에는 최소한 다음과 같은 속성이 필요하다.

속성명 | 설명
id |질문의 고유 번호
subject | 질문의 제목
content | 질문의 내용
create_date | 질문을 작성한 일시

답변(Answer) 엔티티는 다음과 같은 속성
id | 답변의 고유 번호
question | 질문 (어떤 질문의 답변인지 알아야하므로 질문 속성이 필요하다)
content | 답변의 내용
create_date | 답변을 작성한 일시

@Entity 애너테이션을 Question 클래스에 적용한 이유는
JPA가 엔티티로 인식하기 위해서이다.
그리고, Getter,Setter 메서드를 자동으로 생성하기 위해 롬복의 @Getter,@Setter 애너테이션을 적용했다.

@Id 애너테이션은 id속성을 기본 키로 지정한다.
기본 키로 지정하면 이제 id속성의 값은 데이터베이스에 저장할 때 동일한 값으로 저장할 수 없다.
고유 번호를 기본 키로 한 이유는 고유 번호는 엔티티에서 각 데이터를 구분하는 유효한 값으로 중복되면 안되기 때문이다.

@GeneratedValue 애너테이션을 적용하면 데이터를 저장할 때 해당 속성에 값을 따로 세팅하지 않아도 1씩 자동으로 증가하여 저장된다.
strategy는 고유번호를 생성하는 옵션으로 GenerationType.IDENTYTY는 해당 컬럼만의 독립적인 시퀀스를 생성하여 번호를 증가시킬 때 사용한다.

※시퀀스??
시스템이나 프로세스의 동작 흐름을 표현하는데 사용되는 개념이다.
시퀀스 다이어그램은 객체 간의 상호작용을 보여주는 다이어그램이며, 이는 소프트웨어 개발자에게 시스템 동작의 이해와 문서화에 도움을 줄 수 있다.

@Column
엔티티의 속성은 테이블의 컬럼명과 일치하는데 컬럼의 세부 설정을 위해 @Column 애너테이션을 사용한다. length는 컬럼의 길이를 설정할 때 사용하고 columnDefinition은 컬럼의 속성을 정의할 때 사용한다.
columnDefinition = "TEXT"은 "내용"처럼 글자 수를 제한할 수 없는 경우에 사용한다.

※@Column 애너테이션을 사용하지 않아도 엔티티의 속성은 테이블 컬럼으로 인식하기에, 테이블 컬럼으로 인식하고 싶지 않은 경우에만 @Transient 애너테이션을 사용한다.

question 속성은 답변 엔티티에서 질문 엔티티를 참조하기 위해 추가했다.
예를 들어 답변 객체(ex:answer)를 통해 질문 객체의 제목을 알고 싶다면
answer.getQuestion().getSubject()처럼 접근할 수 있지만, 이렇게 속성만 추가하면 안되고 질문 엔티티와 연결된 속성이라는 것을 명시적으로 표시해야 한다.
그래서 다음과 같이 question 속성에 @ManyToOne 애너테이션을 추가해야 한다.

답변은 하나의 질문에 여러개가 달릴 수 있는 구조이다.
따라서 답변은 Many(많은 것)가 되고 질문은 One(하나)이 된다.
따라서 @ManyToOne은 N:1 관계라고 할 수 있다.
이 애너테이션을 설정하면 Answer 엔티티의 question 속성과 Question 엔티티가 서로 연결된다.(실제 데이터베이스에서는 ForeignKey 관계가 생성된다.)

그렇다면, Question 엔티티에서 Answer 엔티티를 참조할수는 없을까??
가능하다.답변과 질문이 N:1의 관계라면 질문과 답변은 1:N의 관계라고 할 수 있다.
이러한 경우 @ManyToOne이 아닌 @OneToMany 애너테이션을 사용한다.
Question 하나에 Answer는 여러개이므로 Question 엔티티에 추가할 답변의 속성은 List 형태로 구성해야 한다.

CascadeType.REMOVE 사용한 이유??
질문 하나에는 여러개의 답변이 작성될 수 있다. 이 때 질문을 삭제하면 그에 달린 답변들도 모두 함께 삭제하기 위해서 @OneToMany의 속성으로 cascade = CascadeType.REMOVE를 사용했다.


(2-5)리포지터리

엔티티만으로는 데이터베이스에 데이터를 저장하거나 조회 할 수 없다.
데이터 처리를 위해서는 실제 데이터베이스와 연동하는 JPA 리포지터리가 필요하다.

※리포지터리란?
리포지터리는 엔티티에 의해 생성된 데이터베이스 테이블에 접근하는 메서드들(EX.findAll,save 등)을 사용하기 위한 인터페이스이다.
데이터 처리를 위해서는 테이블에 어떤 값을 넣거나 값을 조회하는 등의 CRUD(Create, Read, Update, Delete)가 필요하다.
이 때 이러한 CRUD를 어떻게 처리할지 정의하는 계층이 바로 리포지터리이다.

QuestionRepository는 리포지터리로 만들기 위해 JpaRepository 인터페이스를 상속했다. JpaRepository를 상속할 때는 제네릭스 타입으로 <Question, Integer> 처럼 리포지터리의 대상이 되는 엔티티의 타입(Question)과 해당 엔티티의 PK의 속성 타입(Integer)을 지정해야 한다. 이것은 JpaRepository를 생성하기 위한 규칙이다.
AnswerRepository도 마찬가지로 생성한 뒤,
QuestionRepository, AnswerRepository를 이용하여 question,answer 테이블에 데이터를 저장하거나 조회가 가능하다.

@SpringBootTest 애너테이션은 SbbApplicationTests 클래스가 스프링부트 테스트 클래스임을 의미한다. 그리고 @Autowired 애너테이션은 스프링의 DI 기능으로 questionRepository 객체를 스프링이 자동으로 생성해 준다.

※DI(Dependency Injection) - 스프링이 객체를 대신 생성하여 주입한다.

@Autowired 애너테이션
객체를 주입하기 위해 사용하는 스프링의 애너테이션이다.
객체를 주입하는 방식에는 @Autowired 외에 Setter 또는 생성자를 사용하는 방식이 있다. 순환참조 문제와 같은 이유로 @AutoWired 보다는 생성자를 통한 객체 주입방식이 권장된다. 하지만 테스트 코드의 경우 생성자를 통한 객체의 주입이 불가능하므로 테스트 코드 작성시에만 @Autowired를 사용하고 실제 코드 작성시에는 생성자를 통한 객체 주입방식을 사용하겠다.


(2-7)질문 목록과 템플릿

우리가 원하는 질문 목록은 다음 주소에 접속할 때 동작해야 한다

템플릿 설정하기
템플릿을 어떻게 사용할수 있는지 알아보려면 스프링부트에서 사용할 수 있는 템플릿 엔진에는 Thymeleaf, Mustache, Groovy, Freemarker, Velocity 등이 있는데 현 교과과정에서는 스프링 진영에서 추천하는 타임리프 템플릿 엔진을 사용할 것이다.
그러기 위해 build.gradle에서 설치를 진행하여주자

템플릿 사용하기
question_list.html 파일을 만든 뒤
QuestionController를 수정한다
@ResponseBody 애너테이션은 지워주고 return 문자열에 question_list를 리턴값에 저장한다.
그러면 성공적으로 http://localhost:8080/question/list에 Hello Template가 입력이 된다.

데이터 조회하여 템플릿에 전달하기
화면에 전달하는 것은 성공했으니 이제 템플릿에 질문 목록을 조회하여 출력해보자
질문 목록을 조회하기 위해서는 Question 리포지터리를 사용해야 한다. Question 리포지터리로 조회한 질문 목록은 Model클래스를 사용하여 템플릿에 전달할 수 있다.

우선 @RequiredArgsConstructor 애너테이션으로 questionRepository 속성을 포함하는 생성자를 생성하였다.
@RequiredArgsConstructor는 롬복이 제공하는 애너테이션으로 final이 붙은 속성을 포함하는 생성자를 자동으로 생성하는 역할을 한다.
롬복의 @Getter, @Setter가 자동으로 Getter,Setter 메서드를 생성하는 것과 마찬가지로 @RequiredArgsConstructor는 자동으로 생성자를 생성한다.
따라서 스프링 의존성 주입 규칙에 의해 questionRepository 객체가 자동 주입된다.

※ 스프링의 의존성 주입(Dependency Injection)방식 3가지
1.@Autowired 속성 - 속성에 @Autowired 애너테이션을 적용하여 객체를 주입하는 방식
2.생성자 - 생성자를 작성하여 객체를 주입하는 방식(가장 권장하는 방식이다)
3.Setter - Setter 매서드를 작성하여 객체를 주입하는 방식(메서드에 @Autowired 애너테이션 적용이 필요하다.)

테스트코드(SbbApplicationTests.java)에서는 속성에 @Autowired 애너테이션을 사용하여 객체를 주입했다.

템플릿에서 전달받은 데이터 사용하기

질문 목록을 HTML의 테이블 구조로 표시되게 하였다.
템플릿 파일에 입력된 th:each="question : ${questionList}"와 같은 특이한 표현이 눈에띌 것이다. th: 로 시작하는 속성은 타임리프 템플릿 엔진이 사용하는 속성인데 이 부분이 자바 코드와 연결된다.

question_list.html 파일에 사용한 타임리프 속성들을 살펴보면,
QuestionController의 list 메서드에서 조회한 질문 목록 데이터를 "questionList"라는 이름으로 Model 객체에 저장했다. 타임리프는 Model 객체에 저장된 값을 읽을 수 있으므로 템플릿에서 questionList를 사용할수 있게 되는 것이다. 위의 코드는 ... 엘리먼트를 questionList의 갯수만큼 반복하여 출력하는 역할을 한다. 그리고 questionList에 저장된 데이터를 하나씩 꺼내 question 객체에 대입하여 반복구간 내에서 사용할수 있게 한다. 자바의 for each 문을 떠올리면 쉽게 이해할 수 있을 것이다.

다음 코드는 바로 앞의 for 문에서 얻은 question 객체의 제목을 <td> 엘리먼트의 텍스트로 출력한다.

이전에 테스트로 등록한 질문이 조회될 것이다.
만약 테스트시 Question 데이터를 더 추가했다면 더 많은 질문이 표시될 것이다.

자주 사용하는 타임리프의 속성

1.분기문 속성
분기문 속성은 다음과 같이 사용한다.
th:if="${question != null}"
위의 경우 question 객체가 null이 아닌 경우에 해당 엘리먼트가 표시된다.

2.반복문 속성
반복문은 반복횟수만큼 해당 엘리먼트를 반복하여 표시한다. 반복문 속성은 자바의 for each 문과 유사하다.

```
th:each="question : ${questionList}"

추가한 loop 객체를 이용하여 루프 내에서 다음과 같은 속성을 사용할 수 있다.
loop.index - 반복 순서, 0부터 1씩 증가
loop.count - 반복 순서, 1부터 1씩 증가
loop.size - 반복 객체의 요소 갯수 (예: questionList의 요소 갯수)
loop.first - 루프의 첫번째 순서인 경우 true
loop.last - 루프의 마지막 순서인 경우 true
loop.odd - 루프의 홀수번째 순서인 경우 true
loop.even - 루프의 짝수번째 순서인 경우 true
loop.current - 현재 대입된 객체 (예: 위의 경우 question과 동일)


3.텍스트 속성

th:text=값 속성은 해당 엘리먼트의 텍스트로 "값"을 출력한다.

th:text="${question.subject}"

텍스트는 th:text 속성 대신에 다음처럼 대괄호를 사용하여 값을 직접 출력할 수 있다.
<tr th:each="question : ${questionList}">
<td>[[${question.subject}]]</td>
<td>[[${question.createDate}]]</td>
</tr>
--------------------------------------
    
    (2-8) ROOT URL

    루트 URL은 http://localhost:8080 처럼 도메인명과 포트 뒤에 아무것도 붙이지 않은 URL을 말한다.
    아직 루트 URL에 대한 매핑을 만들지 않았기 때문에 브라우저에서 루트 URL에 접속하면 404오류페이지가 나타나는데 MainController에
    @GetMapping("/") 애너테이션 을 추가한 뒤
    public String root() {
    	return "redirect:/question/list";
    }
    위와 같이 root 메서드를 추가하고 / URL을 매핑한 뒤 리턴 문자열
    redirect:/question/list는 /question/list URL로 페이지를 리다이렉트하라는 명령어이다.
    스프링부트는 리다이렉트 또는 포워딩을 다음과 같이 할 수 있다.
    
    ※redirect:<URL> - URL로 리다이렉트 (리다이렉트는 완전히 새로운 URL로 요청이 된다.)
	※forward:<URL> - URL로 포워드 (포워드는 기존 요청 값들이 유지된 상태로 URL이 전환된다.)
    
    
    ---------------------------------------
    (2-9)서비스
    이전 장에서 질문 목록을 만들어봤다면, 이젠 제목 링크를 누르면 질문 상세화면이 보이게 할 것이다.
    하지만 기능을 추가하기 전에 잠시 생각할 부분이
    QuestionController에서 QuestionRepository를 직접 사용하여 질문 목록 데이터를 조회했다.
    하지만 대부분의 규모있는 스프링부트 프로젝트는 컨트롤러에서 리포지터리를 직접 호출하지 않고 중간에 서비스(Service)를 두어 데이터를 처리한다.
    
    서비스(Service)를 두는 이유
    1 모듈화
    
    예를들어 어떤 컨트롤러가 여러개의 리포지터리를 사용하여 데이터를 조회한 후 가공하여 리턴한다고 가정해 보자. 이러한 기능을 서비스로 만들어 두면 컨트롤러에서는 해당 서비스를 호출하여 사용하면 된다. 하지만 서비스로 만들지 않고 컨트롤러에서 구현하려 한다면 해당 기능을 필요로 하는 모든 컨트롤러가 동일한 기능을 중복으로 구현해야 한다. 이러한 이유로 서비스는 모듈화를 위해 필요하다.
    
   
    2.보안상의 이유
    
    컨트롤러는 리포지터리 없이 서비스를 통해서만 데이터베이스에 접근하도록 구현하는 것이 보안상 안전하다.
    이렇게 하면 어떤 해커가 해킹을 통해 컨트롤러가 제어할 수 있게 되더라도 리포지터리에 직접 접근할 수는 없게 된다.
    <컨트롤러가 리포지터리에 직접 접근하기 때문에 service라는 중간방벽이 있다고 생각하면 편할  같다>
    3.엔티티 객체와 DTO 객체의 변환
      현재 작성한 Question, Answer 클래스는 앤티티(Entity) 클래스이다.
      엔티티 클래스는 데이터베이스와 직접 맞닿아 있는 클래스이기 때문에 컨트롤러나 타임리프 같은 템플릿 엔진에 전달하여 사용하는 것은 좋지 않다. 컨트롤러나 타임리프에서 사용하는 데이터 객체는 속성을 변경하여 비즈니스적인 요구를 처리해야 하는 경우가 많은데 엔티티를 직접 사용하여 속성을 변경한다면 테이블 컬럼이 변경되어 엉말이 될수도 있기 때문이다.
      그래서 엔티티 클래스는 컨트롤러에서 사용할수 없게끔 설계하는 것이 좋다.
     그러기 위해선 Question,Answer 대신 사용할 DTO(Data Transfer Object) 클래스가 필요하다. 또한 Question, Answer 등의 엔티티 객체를 DTO객체로 변환하는 작업도 필요하다.그 일을 처리하는 것이 서비스이다.
      서비스는 컨트롤러와 리포지터리의 중간자적인 입장에서 엔티티 객체와 DTO객체를 서로 변환하여 양방향에 전달하는 역할을 한다.
      
    
    
      
      --------------------------------
      
      (2-10) 질문 상세 링크 추가하기

      제목을 <td> 엘리먼트의 텍스트로 출력하던 것에서 링크로 변경한 뒤 타임리프에서 링크의 주소를 th:href 속성을 사용한다.
      타임리프에서 th:href처럼 URL주소를 나타낼때는 반듯기 @{문자와} 문자 사이에 입력해야 한다. 그리고 URL주소는 문자열 /question/detail/과 ${question.id} 값이 조합되어 /question/detail/${question.id}로 만들어졌다. 이 때 좌우에 | 문자 없이 다음과 같이 사용하면 오류가 발생한다.
      /question/detail/과 같은 문자열과 ${question.id}와 같은 자바 객체의 값을 더할 때는 반드시 다음처럼 |과 | 기호로 좌우를 감싸 주어야 한다.
      
      ※타임리프는 문자열을 연결(concatenation)할 때 | 문자를 사용한다.
      
      질문 상세 컨트롤러 만들기
      
      요청  URL http://localhost:8080/question/detail/2의 숫자 2처럼 변하는 id값을 얻을 때에는 위와 같이 @PathVariable 애너테이션을 사용해야 한다. 이 때 @GetMapping(value = "/question/detail/{id}") 에서 사용한 id와 @PathVariable("id")의 매개변수 이름이 동일해야 한다.
      
      위와 같이 수정하고 다시 호출하면 404 에러 대신 500 오류가 발생한다.
      왜냐하면 응답으로 리턴한 question_detail 템플릿이 없기 때문이다.
      다음과 같이 question_detail.html 파일을 신규로 작성하자.
      
    
     	서비스
      "제목" "내용" 문자열 대신 데이터의 실제 제목과 내용을 출력해보자.
      Question 데이터를 조회하기 위해서 QuestionService를 수정해야한다.
      
      
      
      ----------------------------------------
      (2-11)
      <답변 등록 버튼 만들기>
    <h1 th:text="${question.subject}"></h1>
``` 위 코드를 question_detail.html 파일에 적용시키면 localhost:8080/question/detail/2번에 답변등록 버튼이 생성된다. 하지만 이 상태에서 답변등록 버튼을 누르게 되면 오류가 발생한다. 이 오류를 해결하기 위해 답변 컨트롤러를 만들고 해당 URL에 대한 매핑을 처리해야한다. <답변 컨트롤러 만들기> 답변 컨트롤러의 URL 프리픽스도 /answer로 고정했다. 그리고 /answer/create/{id} 와 같은 URL 요청시 createAnswer 메서드가 호출되도록 @PostMapping으로 매핑했다. @PostMapping은 @GetMapping과 동일하게 매핑을 담당하는 역할을 하지만 POST요청만 받아들일 경우에 사용한다. 만약 작성한 URL을 GET방식으로 요청할 경우에는 오류가 발생한다. @PostMapping(value="/create/{id}") 대신 @PostMapping("/create/{id}") 처럼 value는 생략해도 된다. 그리고 createAnswer 메서드의 매개변수에는 @RequestParam String content 항목이 추가되었다. 이 항목은 템플릿에서 답변으로 입력한 내용(content)을 얻기 위해 추가되었다. 템플릿의 답변 내용에 해당하는 textarea의 name속성명이 content이기 때문에 여기서도 변수명을 content로 사용해야 한다. 만약 content 대신 다른 이름으로 사용하면 오류가 발생할것이다. createAnswer 메서드의 URL매핑 /create/{id} 에서 {id} 는 질문의 id 이므로 이 id값으로 질문을 조회하고 없을 경우에는 404 오류가 발생할 것이다. 하지만 아직 답변을 저장하는 코드를 작성하지 않고 일단 다음과 같은 주석으로 답변을 저장해야 함을 나타내었다. // TODO: 답변을 저장한다. <답변 저장하기> AnswerService에는 답변 생성을 위해 create 메서드를 추가했다. create 메서드는 입력으로 받은 question과 content를 사용하여 Answer 객체를 생성하여 저장했다. 이제 주석문을 삭제하고 AnswerService의 create 메서드를 호출하여 답변을 저장할 수 있게 했따. 다시 <답변등록>을 눌러 보면 답변은 잘 저장되었지만 여전히 화면에는 아무런 변화가 없을 것이다. 그 이유는 등록한 답변을 템플릿에 표시하는 기능을 추가하지 않았기 때문이다. <질문 상세 페이지에 답변 표시하기> 질문에 등록된 답변을 상세 화면에 표시해 보자. 답변은 등록된 질문 밑에 보여야 하므로 질문 상세 템플릿에 책에 나와있는 코드를 추가하자.
위 코드는 기존 코드에서 답변을 확인할 수 있는 영역을 추가한 코드이다. #lists.size(question.answerList)}는 답변 개수를 의미한다. #lists.size(이터러블객체)는 타임리프가 제공하는 유틸리티로 객체의 길이를 반환한다. 답변은 question 객체의 answerList를 순회하여
  • 엘리먼트로 표시했다. 이제 질문 상세 페이지를 새로 고침하면 방금 등록한 답변들이 보일것이다. ---------------------------------- (2-12) 스태틱 디렉터리와 스타일시트 <스태틱(static) 디렉터리> 스태틱 디렉터리에 style.css 파일을 만든 뒤 textarea { width:100%; }

    input[type=submit] {
    margin-top:10px;
    }
    위 코드를 작성하게 되면 답변등록시 사용하는 텍스트 창의 넓이를 100%로 하고 "답변등록" 버튼 상단에 10픽셀의 마진을 설정한것이다.

            템플릿에 스타일 적용
            
            question_detail.html 파일로 돌아와서
            <link rel="stylesheet" type="text/css" th:href="@{/style.css}">
            위 코드를 넣게 되면 템플릿 상단에 style.css를 사용할 수 있는 링크를 추가한것이다.
            상세화면 창을 확인해보면 스타일이 변경된 걸 확인할 수 있을것이다.
            텍스트 창의 넓이가 넓어지고 "답변등록" 버튼 위에 여유공간이 생겼을 것이다.
            
            -------------------------------------
            
            (2-13)
            부트스트랩
            
            
            웹 디자이너 없이 혼자서 웹 프로그램을 작성해 봤다면 화면 디자인 작업에 얼마나 많은 시간과 고민이 필요한지 알고 있을 것이다. 이번에 소개하는 부트스트랩(Bootstrap)은 디자이너의 도움 없이도 개발자 혼자서 상당히 괜찮은 수준의 웹 페이지를 만들수 있게 도와주는 프레임워크이다.
            부트스트랩은 트위터를 개발하면서 만들어졌고 현재 지속적으로 관리되고 있는 오픈소스 프로젝트이다.
             

    <부트스트랩 설치> https://getbootstrap.com/docs/5.2/gettingstarted/download/

            해당 링크로 들어가서 부트스트랩 설치 후
            bootstrap.min.css 파일을 카피하여 스태틱 디렉터리에 저장
            <경로>
            /sbb/src/main/resources/static/bootstrap.min.css
              
              
              <부트스트랩 적용>
    
    ``<link rel="stylesheet" type="text/css" th:href="@{/bootstrap.min.css}">
    <div class="container my-3">
        <table class="table">
            <thead class="table-dark">
                <tr>
                    <th>번호</th>
                    <th>제목</th>
                    <th>작성일시</th>
                </tr>
            </thead>
            <tbody>
                <tr th:each="question, loop : ${questionList}">
                    <td th:text="${loop.count}"></td>
                    <td>
                        <a th:href="@{|/question/detail/${question.id}|}" th:text="${question.subject}"></a>
                    </td>
                    <td th:text="${#temporals.format(question.createDate, 'yyyy-MM-dd HH:mm')}"></td>
                </tr>
            </tbody>
        </table>
    </div>
    ``
    
    위 코드와 같이 테이블 항목으로 "번호"를 추가했다.
    번호는 loop.count를 사용하여 표시했다.
    그리고 날짜를 보기 좋게 출력하기 위해 타임리프의 #temporals.format 유틸리티를 사용했다. #temporlas.format은 다음과 같이 사용한다.
    
    - #temporals.format(날짜객채, 날짜포맷)  - 날짜객체를 날짜포맷에 맞게 변환한다.
    
    그리고 가장 윗줄에 bootstrap.min.css 스타일시트를 사용할 수 있도록 링크를 추가했다. 그리고 위에서 사용된 class="container my-3", class="table", class="table-dark 등은 부트스트랩 스타일시트에 정의되어 있는 클래스들이다.
    부트스트랩에 대한 자세한 내용은 다음 URL을 참조하자.
    
    https://getbootstrap.com/docs/5.2/getting-started/introduction/
    
    앞으로 템플릿 작성시에 계속 부트스트랩 스타일들을 사용할 것이다. 물론 사용하는 부트스트랩 클래스들에 대해서 간단한 설명은 하겠지만 위 문서를 간단하게라도 한번 먼저 읽어보기를 당부한다.
    
    이 시점에서 localhost:8080/question/list를 들어가보면
    스타일이 바뀌어있을것이다.
    
    상세 템플릿의 코드를 수정함에 있어 코드가 굉장히 많이 나올텐데 부트스트랩 화면을 구성하다 보면 가끔은 이렇게 많은 양의 HTML코드를 작성해야 한다.
    어렵진 않으니 찬찬히 살펴보자. 질문이나 답변은 하나의 뭉치에 해당하므로 부트스트랩의 card 컴포넌트를 사용했다
    
    ※컴포넌트란??
    여러 개의 프로그램 함수들을 모아 하나의 특정한 기능을 수행할 수 있도록 구성한 작은 기능적 단위를 말한다.
    컴포넌트를 이용하면 소프트웨어 개발을 마치 레고(Lego) 블록을 쌓듯이 조립식으로 쉽게 할 수 있다. 모듈(module)이라고도 한다.	
    
    (2-14)
    # 템플릿 상속
    
    ## 표준 HTML 구조
    -표준 HTML 문서의 구조는 위의 예처럼 html,head,body 엘리먼트가 있어야하며, CSS파일은 head 엘리먼트 안에 링크 되어야 한다. 또한 head 엘리먼트 안에는 meta,title 엘리먼트 등이 포함되어야 한다.
    <table> ~ </table> 처럼 table 태그로 시작해서 table 태그로 닫힌 구간(Block)은 table 엘리먼트이다.
    
    템플릿 상속
    
    앞에서 작성한 질문 목록, 질문 상세 템플릿을 표준 HTML 구조가 되도록 수정해보자.
    그런데 템플릿 파일들을 모두 표준 HTML 구조로 변경하면 body 엘리먼트 바깥 부분(head 엘리먼트 등)은 모두 같은 내용으로 중복된다.
    그러면 CSS파일 이름이 변경되거나 새로운 CSS파일이 추가될 때마다 모든 템플릿 파일을 일일이 수정해야 한다.
    
    타임리프는 이런 중복의 불편함을 해소하기 위해 템플릿 상속 기능을 제공한다.
    템플릿 상속은 기본 틀이 되는 템플릿을 먼저 작성하고 다른 템플릿에서 그 템플릿을 상속해 사용하는 방법이다.
    
    
    ###layout.html
    
    
    ```<!doctype html>
    <html lang="ko">
    <head>
        <!-- Required meta tags -->
        <meta charset="utf-8">
        <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
        <!-- Bootstrap CSS -->
        <link rel="stylesheet" type="text/css" th:href="@{/bootstrap.min.css}">
        <!-- sbb CSS -->
        <link rel="stylesheet" type="text/css" th:href="@{/style.css}">
        <title>Hello, sbb!</title>
    </head>
    <body>
    <!-- 기본 템플릿 안에 삽입될 내용 Start -->
    <th:block layout:fragment="content"></th:block>
    <!-- 기본 템플릿 안에 삽입될 내용 End -->
    </body>
    </html>

    먼저 표준 HTML 구조의 기본 틀이 되는 layout.html 템플릿을 다음처럼 작성해보면
    layout.html 템플릿은 모든 템플릿이 상속해야 하는 템플릿으로 표준 HTML문서의 기본 틀이된다. body 엘리먼트 안의 <th:block layout:fragment="content"></th:block> 부분이 바로 layout.html을 상속한 템플릿에서 개별적으로 구현해야 하는 영역이 된다. 즉, layout.html 템플릿을 상속하면 <th:block layout:fragment="content"></th:block> 영역에 해당되는 부분만 작성해도 표준 HTML 문서가 되는 것이다.

    (2-15)

    질문 등록

    질문 등록을 하려면 먼저 "질문 등록하기" 버튼을 만들어야 한다.
    다음처럼 질문 목록 하단에 버튼을 생성하자.

    ``

    (... 생략 ...)
    질문 등록하기
    `` 링크 엘리먼트( ... )이지만 부트스트랩의 btn btn-primary 클래스를 적용하면 버튼으로 보인다.

    이제 "질문 등록하기"버튼을 누르면 /question/create URL이 호출될 것이다.

    ##URL 매핑

    ``(... 생략 ...)

    public class QuestionController {

    (... 생략 ...)
    
    @GetMapping("/create")
    public String questionCreate() {
        return "question_form";
    }

    }
    ``
    "질문 등록하기" 버튼을 통한 /question/create 요청은 GET 요청에 해당하므로 @GetMapping 애너테이션을 사용하였다. questionCreate 메서드는 question_form 템플릿을 렌더링하여 출력한다.

    ##템플릿

    질문 등록을 위한 question_form.html 템플릿을 만들자.

    ``

    질문등록
    제목
    내용
    ``

    위와 같은 코드로 템플릿을 만들어 실행하면 제목과 내용을 입력하여 질문을 등록할 수 있는 템플릿을 작성한것이다. 이제 질문 목록 화면에서 "질문 등록하기" 버튼을 누르고 질문등록을 할 수 있는 브라우저가 나타난다.
    하지만 공백으로 질문등록을 하게 될 경우 405 에러(Method Not Allowed)가 발생한다. /question/create URL을 POST 방식으로는 처리할 수 없음을 의미한다.

    question_form.html 에서 "저장하기" 버튼으로 폼을 전송하면 <form method="post">에 의해 POST 방식으로 데이터가 요청된다.

    POST 방식으로 요청한 /question/create URL을 처리하기 위해 @PostMapping 애너테이션을 지정한 questionCreate 메서드를 추가했다. 메서드명은 @GetMapping시 사용했던 questionCreate 메서드명과 동일하게 사용할 수 있다.
    (단, 매개변수의 형태가 다른 경우에 가능하다.- 메서드 오버로딩)

    questionCreate메서드는 화면엣허 입력한 제목(subject)과 내용(content)을 매개변수로 받는다. 이 때 질문 등록 템플릿에서 필드 항목으로 사용했던 subject,content의 이름과 동일하게 해야 함에 주의하자.

    이제 입력으로 받은 subject,content를 사용하여 질문을 저장해야 한다.
    일단 질문 저장은 잠시 뒤로 미루고(TODO 주석만 작성됨)
    질문이 저장되면 질문 목록 페이지로 이동하도록 했다.

    ##서비스

    질문을 저장하려면 서비스에 세팅값을 수정해야한다.

    ``(... 생략 ...)
    import java.time.LocalDateTime;
    (... 생략 ...)
    public class QuestionService {

    (... 생략 ...)
    
    public void create(String subject, String content) {
        Question q = new Question();
        q.setSubject(subject);
        q.setContent(content);
        q.setCreateDate(LocalDateTime.now());
        this.questionRepository.save(q);
    }

    }
    ``

    제목과 내용을 입력으로 하여 질문 데이터를 저장하는 create 메서드를 만들었다. 이제 Question 컨트롤러에서 이 서비스를 사용할 수 있도록 다음과 같이 수정하자.

    ``(... 생략 ...)
    public class QuestionController {

    (... 생략 ...)
    
    @PostMapping("/create")
    public String questionCreate(@RequestParam String subject, @RequestParam String content) {
        this.questionService.create(subject, content);
        return "redirect:/question/list";
    }

    }
    ``

    TODO 주석문 대신 QuestionService로 질문 데이터를 저장하는 코드를 작성하였다.
    이렇게 수정하고 질문을 작성하고 저장하면 잘 동작하는 것을 확인할 수 있을 것이다.

    ##폼(form)
    위에서는 질문을 등록하는 기능을 구현하였다.
    하지만 질문 등록시 간과한 것이 있다.
    그것은 바로 질문이나 내용을 등록할 때 비어있는 값으로 등록이 가능하다는 점이다.
    빈 값으로 등록이 불가능하게 하려면 여러 방법이 있지만 여기서는 폼을 사용하여 입력값을 체크하는 방법을 사용해 보자.

    Spring Boot Validation

    화면에서 전달받은 입력 값을 검증하려면 Spring Boot Validation 라이브러리가 필요하다.

    build.gradle에서 "Spring Boot Validation"을 설치하면 다음과 같은 애너테이션을 사용하여 입력 값을 검증할 수 있다.

    @Size 문자 길이를 제한한다.
    @NotNull Null을 허용하지 않는다.
    @NotEmpty Null 또는 빈 문자열("")을 허용하지 않는다.
    @Past 과거 날짜만 가능
    @Future 미래 날짜만 가능
    @FutureOrPresent 미래 또는 오늘날짜만 가능
    @Max 최대값
    @Min 최소값
    @Pattern 정규식으로 검증

    폼 클래스

    화면에서 전달되는 입력 값을 검증하기 위해서는 폼 클래스가 필요하다.
    화면의 입력항목 subject, content 에 대응하는 QuestionForm 클래스를 다음과 같이 작성하자.

    ※폼 클래스는 입력 값의 검증에도 사용하지만 화면에서 전달한 입력 값을 바인딩할 때에도 사용한다.

    ``package com.mysite.sbb.question;

    import jakarta.validation.constraints.NotEmpty;
    import jakarta.validation.constraints.Size;

    import lombok.Getter;
    import lombok.Setter;

    @Getter
    @Setter
    public class QuestionForm {
    @NotEmpty(message="제목은 필수항목입니다.")
    @Size(max=200)
    private String subject;

    @NotEmpty(message="내용은 필수항목입니다.")
    private String content;

    }
    ``

    subject 속성에는 @NotEmpty와 @Size 애너테이션이 적용되었다.
    @NotEmpty는 해당 값이 Null 또는 빈 문자열("")을 허용하지 않음을 의미한다.
    그리고 여기에 사용된 message 속성은 검증이 실패할 경우 화면에 표시할 오류 메시지이다. @Size(max=200) 은 최대 길이가 200바이트를 넘으면 안된다는 의미이다.
    이와 같이 설정하면 길이가 200byte 보다 큰 제목이 입력되면 오류가 발생할 것이다.
    content 속성 역시 @NotEmpty로 빈 값을 허용하지 않도록 했다.

    ##컨트롤러

    그리고 위에서 작성한 QuestionForm을 컨트롤러에서 사용할 수 있도록 다음과 같이 컨트롤러를 수정하자.

    ``(... 생략 ...)
    import jakarta.validation.Valid;
    import org.springframework.validation.BindingResult;
    (... 생략 ...)
    public class QuestionController {

    (... 생략 ...)
    
    @PostMapping("/create")
    public String questionCreate(@Valid QuestionForm questionForm, BindingResult bindingResult) {
        if (bindingResult.hasErrors()) {
            return "question_form";
        }
        this.questionService.create(questionForm.getSubject(), questionForm.getContent());
        return "redirect:/question/list";
    }

    }
    ``

    questionCreate 메서드의 매개변수를 subject, content 대신 QuestionForm 객체로 변경했다. subject,content 항목을 지닌 폼이 전송되면 QuestionForm의 subject, content 속성이 자동으로 바인딩 된다.
    이것은 스프링 프레임워크의 바인딩 기능이다.

    그리고 QuestionForm 매개변수 앞에 @Valid 애너테이션을 적용했다.
    @Valid 애너테이션을 적용하면 QuestionForm의 @NotEmptyt,@Size 등으로 설정한 검증 기능이 동작한다.
    그리고 이어지는 BindingResult 매개변수는 @Valid 애너테이션으로 인해 검증이 수행된 결과를 의미하는 객체이다.

    ※BindingResult 매개변수는 항상 @Valid 매개변수 바로 뒤에 위치해야 한다. 만약 2개의 매개변수의 위치가 정확하지 않다면 @Valid만 적용이 되어 입력값 검증 실패 시 400 오류가 발생한다.

    따라서 questionCreate 메서드는 bindResult.hasErrors() 를 호출하여 오류가 있는 경우에는 다시 폼을 작성하는 화면을 렌더링하게 했고 오류가 없을 경우에만 질문 등록이 진행되도록 했다.

    여기까지 수정하고 질문 등록 화면에서 아무런 값도 입력하지 말고 "저장하기"버튼을 눌러보자.
    아무런 입력값도 입력하지 않았기 때문에 QuestionForm의 @NotEmpty에 의해 Validation이 실패하여 다시 질문 등록화면에 머물러 있을 것이다.
    하지만 QuestionForm에 설정한 "제목은 필수항목입니다."와 같은 오류 메시지는 보이지 않는다.

    오류메시지가 보이지 않는다면 어떤 항목에서 검증이 실패했는지 알 수가 없다

    템플릿

    검증에 실패한 오류메시지를 보여주기 위해 템플릿을 수정해야한다.
    ``

    질문등록
    제목
    내용
    ``

    위 코드대로 수정하면 검증에 실패할 경우 오류메시지를 출력할 수 있게 한다.

    Wait! 여기까지 수정하고 테스트를 진행하면 질문 등록 화면 진입시에 오류가 발생할 것이다. 이어지는 수정 과정을 완료한 후에 테스트를 진행하여야 한다.

    그리고 템플릿을 위와 같이 수정할 경우 QuestionController의 GetMapping으로 매핑한 메서드도 다음과 같이 변경해야 한다. 왜냐하면 question_form.html 템플릿은 "질문 등록하기" 버튼을 통해 GET 방식으로 요청되더라도 th:object에 의해 QuestionForm 객체가 필요하기 때문이다.

    ``(... 생략 ...)
    public class QuestionController {

    (... 생략 ...)
    
    @GetMapping("/create")
    public String questionCreate(QuestionForm questionForm) {
        return "question_form";
    }
    
    (... 생략 ...)

    }
    ``
    GetMapping으로 매핑한 questionCreate 메서드에 매개변수로 QuestionForm 객체를 추가했다. 이렇게 하면 이제 GET 방식에서도 question_form 템플릿에 QuestionForm 객체가 전달될 것이다.

    ※QuestionForm과 같이 매개변수로 바인딩한 객체는 Model 객체로 전달하지 않아도 템플릿에서 사용이 가능하다.

    이렇게 수정하고 제목 또는 내용에 값을 채우지 않은 상태로 질문 등록을 진행하면 다음과 같은 오류가 화면에 표시될 것이다.

    질문등록 화면에서 공백으로 질문등록을 하면 제목과 내용이 필수항목이라 알림이 뜬다

    오류 발생시 입력한 내용 유지하기

    하지만 테스트를 진행하다보니 또 다른 문제를 발견했다. 그것은 이미 입력한 "제목"이나 "내용"이 사라진다는 점이다. 즉, 제목에 값을 채우고 내용을 비워둔 채로 "저장하기" 버튼을 누르면 오류 메시지가 나타남과 동시에 이미 입력한 제목의 내용도 사라진다는 점이다. 입력한 제목은 남아 있어야 하지 않겠는가?

    이러한 문제를 해결하려면 이미 입력한 값이 유지되도록 다음과 같이 템플릿을 수정해야한다.

    ``

    질문등록
    제목
    내용
    ``

    name="subject", name="content"와 같이 사용하던 부분을 위와 같이 th:field 속성을 사용하도록 변경하였다. 이렇게 하면 해당 태그의 id, name, value 속성이 모두 자동으로 생성되고 타임리프가 value 속성에 기존 값을 채워 넣어 오류가 발생하더라도 기존에 입력한 값이 유지된다.

    이제 다시 질문 등록을 해보면 이전에 입력했던 값이 유지되는 것을 확인할 수 있을 것이다. 제목에 아무 값을 채우고 내용에는 값을 비워둔채로 "저장하기" 버튼을 누르면 위와 같이 제목에 입력한 내용이 사라지지 않고 남아있다.

    답변 등록

    질문등록에 폼을 적용한 것처럼 답변 등록을 할 때에도 폼을 적용해 보자. 질문 등록과 동일한 방법이므로 조금 빠르게 적용해 보자. 먼저 답변을 등록할 때 사용할 AnswerForm을 작성하자.

    ``(... 생략 ...)
    import jakarta.validation.Valid;
    import org.springframework.validation.BindingResult;
    (... 생략 ...)
    public class AnswerController {

    (... 생략 ...)
    
    @PostMapping("/create/{id}")
    public String createAnswer(Model model, @PathVariable("id") Integer id, 
            @Valid AnswerForm answerForm, BindingResult bindingResult) {
        Question question = this.questionService.getQuestion(id);
        if (bindingResult.hasErrors()) {
            model.addAttribute("question", question);
            return "question_detail";
        }
        this.answerService.create(question, answerForm.getContent());
        return String.format("redirect:/question/detail/%s", id);
    }

    }
    ``

    AnswerForm을 사용하도록 컨트롤러를 변경했다. QuestionForm을 사용했던 방법과 마찬가지로 @Valid와 BindingResult를 사용하여 검증을 진행한다.
    검증에 실패할 경우에는 다시 답변을 등록할 수 있는 question_detail 템플릿을 렌더링하게 했다. 이 때 question_detail 템플릿은 Question 객체가 필요하므로 model 객체에 Question 객체를 저장한 후에 question_detail 템플릿을 렌더링해야 한다.

    그리고 question_detail.html 템플릿 파일을 다음과 같이 수정하자.

    ``

    (... 생략 ...)
    <!-- 답변 작성 -->
    <form th:action="@{|/answer/create/${question.id}|}" th:object="${answerForm}" method="post" class="my-3">
        <div class="alert alert-danger" role="alert" th:if="${#fields.hasAnyErrors()}">
            <div th:each="err : ${#fields.allErrors()}" th:text="${err}" />
        </div>
        <textarea th:field="*{content}" rows="10" class="form-control"></textarea>
        <input type="submit" value="답변등록" class="btn btn-primary my-2">
    </form>
    ``

    답변 등록 폼의 속성이 AnswerForm을 사용하기 때문에 th:object 속성을 추가했다.
    그리고 검증이 실패할 경우 오류메시지를 출력하기 위해 #fields.hasAnyErrors()와 #fields.allErrors()를 사용하여 오류를 표시했다. 그리고 content 항목도 th:field 속성을 사용하도록 변경했다.
    그리고 question_detail 템플릿이 AnswerForm을 사용하기 때문에 QuestionController의 detail 메서드도 다음과 같이 수정해야 한다.

    ``(... 생략 ...)
    import com.mysite.sbb.answer.AnswerForm;
    (... 생략 ...)
    public class QuestionController {

    (... 생략 ...)
    
    @GetMapping(value = "/detail/{id}")
    public String detail(Model model, @PathVariable("id") Integer id, AnswerForm answerForm) {
        (... 생략 ...)
    }
    
    (... 생략 ...)

    }
    ``
    이와 같이 수정하고 답변 등록을 진행해보자. 답변 내용없이 등록하려고 시도하면 다음과 같은 검증 오류가 발생할 것이다.

    ex.답변등록 페이지에서 내용없이 답변이 불가능하다


    (2-16)
    공통 템플릿

    이전 챕터에서 질문 등록과 답변 등록시 오류가 발생하면 다음과 같이 오류를 표시했다.

    ``

    <div th:each="err : ${#fields.allErrors()}" th:text="${err}" />
    ``

    앞으로 추가적으로 만들 템플릿들에도 위와 같이 오류를 표시하는 부분이 필요하다.
    이렇게 반복적으로 사용하는 문장은 공통 템플릿으로 만들고 필요한 부분에 삽입하여 쓸 수 있다면 편리하지 않을까?

    이번 장에서는 위의 오류 메시지를 출력하는 부분을 공통 템플릿으로 작성하고 필요한 곳에 삽입하여 사용할 수 있도록 해보자.

    오류 메시지 공통 템플릿

    오류 메시지를 표시하는 공통 템플릿을 다음과 같이 작성하자.

    ``

    <div th:each="err : ${#fields.allErrors()}" th:text="${err}" />
    ``

    출력할 오류메시지 부분에 th:fragment="formErrorsFragment" 속성을 추가했다.

    질문 등록 템플릿에 적용하기

    질문 상세 템플릿에 적용하기

  • profile
    보더콜리 2마리 키우는 개발자

    0개의 댓글