20230706 자습노트

라영진·2023년 7월 6일
0

Java 학습일지

목록 보기
30/35

Not found(404) 오류 발생
-404는 HTTP 오류코드 중 하나이다.
<404 오류는 브라우저가 요청한 페이지를 찾을 수 없을 경우 발생>

HTTP 500 오류는 "Internal Server Error"라고 알려진 오류이다.
이 오류는 서버에서 클라이언트로부터 요청을 처리하는 동안 내부적인 문제가 발생했음을 나타낸다.
클라이언트에게 정확한 오류 원인을 알려주지 않기 때문에 디버깅하기 어려울 수 있다.
500 오류 발생 이유

1.소프트웨어 버그:서버 애플리케이션에 버그가 있을 수 있으며, 이로 인해 요청 처리 중에 오류가 발생할 수 있다.
2.잘못된 구성:서버가 잘못된 구성으로 실행될 경우, 오류가 발생할 수 있다. 예를 들어, 잘못된 데이터베이스 연결 구성 또는 잘못된 파일 권한 등.
3.리소스 부족:서버가 처리할 수 있는 요청의 양이 너무 많거나 서버의 리소스(CPU,메모리,디스크 공간 등)가 부족한 경우에도 오류 발생할 수 있음.

해결법:
1. 페이지 새로고침
2. 잠시후 다시 시도
3. 로그 확인
4. 서버 관리자에게 문의(서버 측에서 발생하는 문제이므로, 클라이언트 측에서 직접 해결할 수 있는것은 제한적이다.)

@ResponseBody 애너테이션은 URL 요청에 대한 응답으로 문자열을 리턴하라는 의미이다.
이 애너테이션을 생략한다면 "index"라는 이름의 템플릿 파일을 찾게 된다.

데이터베이스 사용하려면 SQL쿼리(query)라는 구조화된 질의를 작성하고 실행하는 등의 복잡한 과정이 필요한데 ORM(object relational mapping)을 이용하면 자바 문법만으로도 데이터베이스를 다룰 수 있다.
즉, ORM을 이용하면 개발자가 쿼리를 작성하지 않아도 데이터벱이스의 데이터를 처리할 수 있다.

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

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

위와 같이 ORM을 이용한 데이터의 삽입 예제는 코드 자체만 놓고 보면 양이 많아 보이지만 별도의 SQL 문법을 배우지 않아도 된다는 장점이 있따.

---코드에서 Question은 자바 클래스이며, 이처럼 데이터를 관리하는 데 사용하는 ORM 클래스를 엔티티(Entity)라고 한다.
ORM을 사용하면 내부에서 SQL쿼리를 자동으로 생성해 주므로 직접 작성하지 않아도 된다. 즉, 자바만 알아도 데이터베이스에 질의할 수 있다.

※ORM 장점
1.ORM을 이용하면 데이터베이스의 종류에 상관 없이 일관된 코드를 유지할 수 있어서 프로그램을 유지.보수하기가 편리하다.
2. 내부에서 안전한 SQL 쿼리를 자동으로 생성해 주므로 개발자가 달라도 통일된 쿼리를 작성할 수 있고 오류 발생률도 줄일 수 있다.

JPA란?

스프링부트는 JPA(Java Persistence API)를 사용하여 데이터베이스를 처리한다.
JPA는 자바 진영에서 ORM(Object-Relational Mapping)의 기술 표준으로 사용하는 인터페이스의 모음이다.

--JPA는 인터페이스이다.
따라서 인터페이스를 구현하는 실제 클래스가 필요하다.
JPA를 구현한 대표적인 실제 클래스에는 하이버네이트(Hibernate)가 있다.
SBB도 JPA + 하이버네이트 조합을 사용한다.

H2 데이터베이스

소규모 프로젝트에서 사용되는 파일 기반의 경량 데이터베이스 정도로만 이해하고 있어도 될 듯 싶다.
개발시에는 H2를 사용하여 빠르게 개발하고 실제 운영시스템은 좀 더규모있는 DB를 사용하는 것이 일반적인 개발 패턴이다.
굵직한 데이터베이스(Oracle,MSSQL)

runtimeOnly

-build.gradle 파일의 runtimeOnly는 해당 라이브러리가 런타임시에만 필요한 경우에 사용한다.
컴파일(Compile)시에만 필요한 경우에는 runtimeOnly 대신 CompileOnly를 사용한다.

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

각각의 항목에 대해 알아보자면...
1. spring.h2.console.enabled=H2콘솔의 접속을 허용하지의 여부이다.true로 설정한다.
2.spring.hj2.console.path=콘솔 접속을 위한 URL 경로이다.
3.spring.datasource.url=데이터베이스 접속을 위한 경로이다.
4.spring.datasource.username=데이터베이스의 사용자명이다.
(사용자명은 기본 값인 sa로 설정한다.)
5.spring.datasource.password=데이터베이스의 패스워드이다.
로컬 개방 용도로만 사용하기 때문에 설정 X

그리고 spring.datasource.url에 설정한 경로에 해당하는 데이터베이스 파일을 만들어야 한다.
spring.datasource.url을 jdbc:h2:~/local로 설정했기 때문에 사용자의 홈디렉터리(~에 해당하는 경로) 밑에
local.mv.db라는 파일을 생성해야한다.

cf)jdbc:h2:~/test로 설정했다면 test.mv.db라고 파일 생성해야함

※implementation
build.gradle 파일의 implementation은 해당 라이브러리 설치를 위해 일반적으로 사용하는 설정이다.
implementation은 해당 라이브러리가 변경되더라도 이 라이브러리와 연관된 모든 모듈들을 컴파일하지 않고 직접 관련이 있는 모듈들만 컴파일하기 때문에 rebuild 속도가 빠르다.

application.properties에 추가된 항목을 간단히 살펴보자면

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

※spring.jpa.hibernate.ddl-auto
위 설정에서 spring.jpa.hibernate.ddl-auto를 update로 설정했다.
update와 같은 설정값에 대해서 간단히 알아보자.
1.none-엔티티가 변경되더라도 데이터베이스를 변경하지 않는다.
2.update-엔티티의 변경된 부분만 적용한다.
3.validate-변경사항이 있는지 검사만 한다.
4.create-스프링부트 서버가 시작될때 모두 drop하고 다시 생성한다.
5.create-drop -create와 동일하다. 하지만 종료시에도 모두 drop한다.

개발 환경에서는 보통 update 모드를 사용하고 운영환경에서는 none 또는 validate 모드를 사용한다.


Entity

데이터베이스 테이블과 매핑되는 자바 클래스를 말한다.

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

그리고 엔티티의 속성으로 고유번호(id),제목(subject),내용(content),작성일시(createDate)를 추가했다.
각 속성에는 Id, GeneratedValue,Column과 같은 애너테이션이 적용되어 있다.

@Id
고유번호 id속성에 적용한 @Id 애너테이션은 id속성을 기본 키로 지정
기본 키로 지정할 시 이제 id 속성의 값은 데이터베이스에 저장할 때 동일한 값으로 저장할 수 없다.
고유 번호를 기본 키로 한 이유는 고유 번호는 엔티티에서 각 데이터를 구분하는 유효한 값으로 중복되면 안 되기 때문이다.
--데이터베이스에서는 id와 같은 특징을 가진 속성을 기본 키(primary key)라고 한다.

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

--strategy 옵션을 생략할 경우에 @GeneratedValue 애너테이션이 지정된 컬럼들이 모두 동일한 시퀀스로 번호를 생성하기 때문에 일정한 순서의 고유번호를 가질수 없게 된다.
이러한 이유로 보통 GenerationType.IDENTITY 를 많이 사용한다.

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

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

테이블 컬럼명

위의 Question 엔티티에서 작성일시에 해당하는 createDate 속성의 실제 테이블의 컬럼명은 createdate가 된다.
즉, createdDate 처럼 대소문자 형태의 카멜케이스(Camel Case) 이름은 create_date 처럼 모두 소문자로 변경되고 언더바(`
`)로 단어가 구분되어 실제 테이블 컬럼명이 된다.

엔티티와 Setter

일반적으로 엔티티에는 Setter 메서드를 구현하지 않고 사용하기를 권한다.
왜냐하면 엔티티는 데이터베이스와 바로 연결되어 있으므로 데이터를 자유롭게 변경할 수 있는 Setter 메서드를 허용하는 것이 안전하지 않다고 판단하기 때문이다.
그렇다면 Setter 메서드 없이 어떻게 엔티티에 값을 저장할 수 있을까?
엔티티를 생성할 경우에는 롬복의 @Builder 어노테이션을 통한 빌드패턴을 사용하고, 데이터를 변경해야 할 경우에는 그에 해당되는 메서드를 엔티티에 추가하여 데이터를 변경하면 된다.

Answer도 똑같이 만들어준다.
Id,content,createDate 속성은 질문 엔티티와 동일하므로 설명은 생략하고, question 속성은 답변 엔티티에서 질문 엔티티를 참조하기 위해 추가했다. 예를 들어 답변 객체(예:answer)를 통해 객체의 제목을 알고 싶다면 answer.getQuestion().getSubject() 처럼 접근할 수 있다. 하지만 이렇게 속성만 추가하면 안되고 질문 엔티티와 연결된 속성이라는 것을 명시적으로 표시해야 한다.
즉, question속성에 @ManyToOne 애너테이션을 추가해야한다.
@ManyToOne 애너테이션은 답변은 하나의 질문에 여러개가 달릴 수 있는 구조를 만들어주는 애너테이션이다.
따라서 답변은 많은 것(Many)이 되고, 질문은 하나(One)가 된다.
@ManyToOne은 N:1관계라고 할 수 있다.
이렇게 애너테이션을 설정하면 Answer 엔티티의 question 속성과 Question 엔티티가 서로 연결된다.
(실제 데이터베이스에서는 ForeignKey 관계가 생성된다.)

반대방향, Question 엔티티에서 Answer 엔티티를 참조하려면
1:N의 관계를 나타내는 애너테이션인 @OneToMany 애너테이션을 사용한다.

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


리포지터리

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

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

QuestionRepository는 리포지터리로 만들기 위해 JpaRepository 인터페이스를 상속했다.
JpaRepository를 상속할 때는 제네릭스 타입으로 <Question, Integer> 처럼 리포지터리의 대상이 되는 엔티티의 타입(Question)과 해당 엔티티의 PK의 속성 타입(Integer)을 지정해야 한다.
이것은 JpaRepository를 생성하기 위한 규칙이다.

--Question 엔티티의 PK(Primary Key)속성인 id의 타입은 Integer이다.

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

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

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


템플릿 설정하기

우리는 보통 브라우저에 응답하는 문자열은 자바 코드에서 직접 만들지는 않는다.

일반적으로 많이 사용하는 방식은 템플릿 방식이다.
템플릿은 자바 코드를 삽입할 수 있는 HTML 형식의 방식이다.

스프링부트에서 사용할 수 있는 템플릿 엔진에는 Thymeleaf, Mustache, Groovy, Freemarker, Velocity 등이 있다.

타임리프를 사용하려면 설치가 필요하다

템플릿 엔진을 적용하기 위해서는 로컬서버 재시작!!


템플릿 사용하기

templates에 새로운 question_list.html 파일을 만든 뒤
header부분에 Hello Template를 적고
QuestionController에서 @ResponseBody 애너테이션을 지운뒤
return값을 question list -> question_list로 바꿔보면
localhost:8080/question/list 링크로 들어가면
Hello Template가 정상출력된다.


데이터 조회하여 템플릿에 전달하기

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

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


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

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

<tr th:each="question : ${questionList}">

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

  1. 분기문 속성

th:if="${question !=null}"
위의 경우 question 객체가 null이 아닌 경우에 해당 엘리먼트가 표시된다.

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

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

반복문은 다음과 같이 사용할 수도 있다.

th:each"question, loop : ${questionList}"

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

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

텍스트는 th:text 속성 대신에 다음처럼 대괄호를 사용하여 값을 직접 출력할 수 있다.

<`tr th:each="question : ${questionList}">

[[${question.subject]] [[${question.createDate}]] `>

ROOT

root 메서드를 추가하고 / URL을 매핑하면 리턴 문자열
redirect:/question/list 는 /question/list URL 페이지를 리다이렉트 하라는 명령어이다. 스프링부트는 리다이렉트 또는 포워딩을 다음과 같이 할 수 있다.

1.redirect:<URL>- URL로 리다이렉트 (리다이렉트는 완전히 새로운 URL로 요청이 된다.)
2.forward<URL> - URL로 포워드(포워드는 기존 요청 값들이 유지된 상태로 URL이 전환된다.)

이제 http://localhost:8080 페이지 접속을 하면 root 메서드가 실행되어 질문 목록이 표시되는 것을 확인할 수 있을 것이다.


서비스

서비스가 필요한 이유 3가지(모듈화,보안,엔티티 객체와 DTO객체의 변환)

1.모듈화

예를 들어 어떤 컨트롤러가 여러개의 리포지터리를 사용하여 데이터를 조회한후 가공하여 리턴한다고 가정해 보자. 이러한 기능을 서비스로 만들어 두면 컨트롤러에서는 해당 서비스를 호출하여 사용하면 된다.
하지만 서비스로 만들지 않고 컨트롤러에서 구현하려 한다면 해당 기능을 필요로 하는 모든 컨트롤러가 동일한 기능을 중복으로 구현해야 한다.
이러한 이유로 서비스는 모듈화를 위해서 필요하다.

2.보안

컨트롤러는 리포지터리 없이 서비스를 통해서만 데이터베이스에 접근하도록 구현하는 것이 보안상 안전하다.
이렇게 하면 어떤 해커가 해킹을 통해 컨트롤러를 제어할 수 있게 되더라도 리포지터리에 직접 접근할 수는 없게 된다.

3.엔티티 객체와 DTO 객체의 변환

우리가 작성한 Question,Answer 클래스는 엔티티 클래스이다.
엔티티 클래스는 데이터베이스와 직접 맞닿아 있는 클래스이기 때문에 컨트롤러나 타임리프 같은 템플릿 엔진에 전달하여 사용하는 것은 좋지 않다.
컨트롤러나 타임리프에서 사용하는 데이터 객체는 속성을 변경하여 비즈니스적인 요구를 처리해야하는 경우가 많은데 엔티티를 직접 사용하여 속성을 변경한다면 테이블 컬럼이 변경되어 엉망이 될수도 있기 때문이다.

이러한 이유로 Question, Answer 같은 엔티티 클래스는 컨트롤러에서 사용할 수 없게끔 설계하는 것이 좋다.
그러기 위해서는 Question,Answer 대신 사용할
DTO(Data Transfer Object) 클래스가 필요하다.
그리고 Question,Answer 등의 엔티티 객체를 DTO 객체로 변환하는 작업도 필요하다. 그러면 엔티티 객체를 DTO 객체로 변환하는 일은 어디서 처리해야 할까?
바로 서비스이다.
서비스는 컨트롤러와 리포지터리의 중간자적인 입장에서 엔티티 객체와 DTO 객체를 서로 변환하여 양방향에 전달하는 역할을 한다.

※지금 공부과정에서는 DTO 객체를 컨트롤러와 타임리프에서 그대로 사용하지만, 실제 업무 프로그램을 작성할 때는 엔티티 클래스를 대신할 DTO 클래스를 만들어 사용하기를 권한다.


QuestionService

스프링의 서비스로 만들기 위해서는 @Service 애너테이션을 붙이면 된다.

QuestionController

리포지터리 대신 서비스를 사용하도록 QuestionRepository에서 QuestionService로 바꿔줘야한다.

앞으로 작성할 컨트롤러들도 리포지터리를 직접 사용하지 않고
Controller -> Service -> Repository 구조로 데이터를 처리할 것이다.


질문 상세 링크 추가하기

제목을 엘리먼트의 텍스트로 출력하던 것에서 링크로 변경.
타임리프에서 링크 주소 th:href 속성 사용
타임리프에서 th:href처럼 URL 주소를 나타낼때는 반드시 @{문자와} 문자 사이에 입력해야 한다.
그리고 URL 주소는 문자열 /question/detail/과 question.id값이조합되어/question/detail/{question.id} 값이 조합되어 /question/detail/{question.id}로 만들어졌다.
이 때 좌우에 | 문자없이 다음과 같이 사용하면 오류가 발생한다.

EX)<a th:href="@{/question/detail/{question.id}}" th:text="{question.subject}"></a> [오류발생]

EX)<a th:href="@{|/question/detail/{question.id}|}" th:text="{question.subject}"></a> [정상출력]

질문 상세 컨트롤러 만들기

http://localhost:8080/question/deatil/2번과 같이 상세보기 페이지로 들어가면 404 오류 발생한다.
오류 해결하기 위해 질문 상세 페이지에 대한 URL 매핑을 QuestionController에 추가해야한다.

요청 URL이 숫자 2처럼 변하는 id값을 얻을 때는 위와 같이 @PathVariable 애너테이션을 사용해야 한다.
이때 @GetMapping(value = "question/detail/{id}")에서 사용한 id와 @PathVariable("id")의 매개변수 이름이 동일해야 한다.

하지만 위와 같이 하게 되면 404오류 대신 500 오류가 발생하는데 응답으로 리턴한 question_detail 템플릿이 없기 때문이다.

서비스

이젠 "제목", "내용" 문자열 대신 실제 제목과 내용을 출력하려면
Question 데이터를 조회하기 위해 QuestionService를 수정해야한다.

id값으로 Question 데이터를 조회하는 getQuestion 메서드를 추가했다. 리포지터리로 얻은 Question 객체는 Optional 객체이기 때문에 isPresent 메서드로 해당 데이터가 존재하는지 검사하는 로직이 필요하다. 만약 id 값에 해당하는 Question 데이터가 없을 경우에는 DataNotFoundException을 발생시키도록 했다.
DataNotFoundException 클래스는 아직 존재하지 않기 때문에 컴파일 오류가 발생할 것이다.

DataNotFoundException 클래스 신규작성

DataNotFoundException은 RuntimeException을 상속하여 만들었다.
만약 DataNotFoundException이 발생하면 @ResponseStatus 애너테이션에 의해 404오류(HttpStatus.NOT_FOUND)가 나타날 것이다.

그리고 QuestionControllerdptj QuestionService의 getQuestion 메서드를 호출하여 Question 객체를 템플릿에 전달할 수 있도록 수정해야한다.

QuestionController의 detail 메서드에서 Model 객체에 "question" 이라는 이름으로 Question 객체를 저장했으므로
question_detail의 템플릿을 수정하면 질문 상세 페이지가 달라질 것이다.
하지만 상세보기도 존재하지 않는 id 값으로 페이지를 조회하면 404 오류가 발생한다.

URL 프리픽스(prefix)

QuestionController에는 다음 2개의 매핑이 있다.
1.@GetMapping("/question/list")
2.@GetMapping(value = "/question/detail/{id}")

URL 매핑시 value 매개변수는 생략할 수 있다.

URL의 프리픽스가 모두 /question으로 시작한다는 것을 알수 있다.
이런 경우 클래스명 위에 @RequestMapping("/question") 애너테이션을 추가하고 메서드 단위에서는 /question 을 생략한 그 뒷 부분만을 적으면 된다.

list 메서드의 URL 매핑은 /list 이지만 클래스에 /question 이라는 URL 매핑이 있기 때문에 /question + /list 가 되어 최종적인 URL 매핑은 /question/list가 된다.
단, 앞으로 QuestionController에서 사용하는 URL 매핑은 항상 /question 으로 시작해야 하는 규칙이 생긴 것이다.

※ 컨트롤러의 클래스 단위의 URL 매핑은 필수사항이 아니다.
컨트롤러의 성격에 맞게 사용하면 된다.

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

0개의 댓글