스프링 MVC 2편 - 백엔드 웹 개발 활용 기술

장원령·2021년 7월 7일

Backend(Java Spring)

목록 보기
5/6

인프런 김영한님의 '스프링 MVC 2편 - 백엔드 웹 개발 활용 기술' 강의를 요약정리한 내용입니다

1. 타임리프 - 기본 기능

: 타임리프는 백엔드 서버에서 HTML을 동적으로 렌더링 하는 용도로 사용된다.
: 웹 브라우저에서 파일을 직접 열어도 내용을 확인할 수 있고, 뷰 템플릿을 거치면 동적으로 변경된 결과를 확인 할 수 있다.
: 이러한 특징을 일컬어 Natural templates 라고 한다.

1.1 기본적인 데이터 송수신 방식

// controller 
model.addAttribute("data", "Hello World"); //으로 데이터를 보낸다

//html 내에서 
<html xmlns:th="http://www.thymeleaf.org"> // 이 문구를 추가해줘야 한다. 

// 아래와 같은 두 가지 방법이 있다.

// 1번
<li> th : text 사용하는 방법 <span th:text = "${data}"></span></span></li>

// 2번
<li> 컨텐츠 안에서 직접 출력하기 = [[${data}]]</li>

1.2 특수 문자 사용 유의

: 뷰 템플릿으로 출력할 때에는 '<','>'와 같은 문자의 출력을 주의해야 한다.
: < 를 태그의 시작이 아닌 문자로 표현하고자 하기 때문에 발생했는데, 이를 HTML 엔티티라고 한다. 변경하는 것을 escape라고 한다.
: 이를 사용하지 않기 위해서는 다음과 같이 변경하면 된다.

// 1번
<li>th:utext = <span th:utext="${data}"></span></li>
// 2번
<li><span th:inline="none">[(...)] = </span>[(${data})]</li>

1.3 변수 - SpringEL

아래의 세 가지 설명은 PPT를 참고했습니다.

1.3.1 Object

user.username : user의 username을 프로퍼티 접근 user.getUsername()
user['username'] : 위와 같음 user.getUsername()
user.getUsername() : user의 getUsername() 을 직접 호출

1.3.2 List

users[0].username : List에서 첫 번째 회원을 찾고 username 프로퍼티 접근
list.get(0).getUsername()
users[0]['username'] : 위와 같음
users[0].getUsername() : List에서 첫 번째 회원을 찾고 메서드 직접 호출

1.3.3 Map

userMap['userA'].username : Map에서 userA를 찾고, username 프로퍼티 접근
map.get("userA").getUsername()
userMap['userA']['username'] : 위와 같음
userMap['userA'].getUsername() : Map에서 userA를 찾고 메서드 직접 호출

1.4 기본 객체들

<h1>식 기본 객체 (Expression Basic Objects)</h1>
<ul>
    <li>request = <span th:text="${#request}"></span></li>
    <li>response = <span th:text="${#response}"></span></li>
    <li>session = <span th:text="${#session}"></span></li>
    <li>servletContext = <span th:text="${#servletContext}"></span></li>
    <li>locale = <span th:text="${#locale}"></span></li></ul>
<h1>편의 객체</h1>
<ul>
// 1. HTTP 요청 파라미터 접근
    <li>Request Parameter = <span th:text="${param.paramData}"></span></li>
// 2. HTTP 세션 접근
    <li>session = <span th:text="${session.sessionData}"></span></li>
// 3. 스프링 빈 접근
    <li>spring bean = <span th:text="${@helloBean.hello('Spring!')}"></span></li>
</ul>

1.5 유틸리티 객체와 날짜

: 문자, 숫자, 날짜, URI등을 편리하게 다루는 다양한 유틸리티 객체들을 제공한다.

https://www.thymeleaf.org/doc/tutorials/3.0/usingthymeleaf.html#expression-utility-objects

: 날짜 관련은 아래와 같다.

ul>
    <li>default = <span th:text="${localDateTime}"></span></li>
    <li>yyyy-MM-dd HH:mm:ss = <span th:text="${#temporals.format(localDateTime, 'yyyy-MM-dd HH:mm:ss')}"></span></li>
</ul>
<h1>LocalDateTime - Utils</h1>
<ul>
    <li>${#temporals.day(localDateTime)} = <span th:text="${#temporals.day(localDateTime)}"></span></li>
    <li>${#temporals.month(localDateTime)} = <span th:text="${#temporals.month(localDateTime)}"></span></li>
    <li>${#temporals.monthName(localDateTime)} = <span th:text="${#temporals.monthName(localDateTime)}"></span></li>
    <li>${#temporals.monthNameShort(localDateTime)} = <span th:text="${#temporals.monthNameShort(localDateTime)}"></span></li>
    <li>${#temporals.year(localDateTime)} = <span th:text="${#temporals.year(localDateTime)}"></span></li>
    <li>${#temporals.dayOfWeek(localDateTime)} = <span th:text="${#temporals.dayOfWeek(localDateTime)}"></span></li>
    <li>${#temporals.dayOfWeekName(localDateTime)} = <span th:text="${#temporals.dayOfWeekName(localDateTime)}"></span></li>
    <li>${#temporals.dayOfWeekNameShort(localDateTime)} = <span th:text="${#temporals.dayOfWeekNameShort(localDateTime)}"></span></li>
    <li>${#temporals.hour(localDateTime)} = <span th:text="${#temporals.hour(localDateTime)}"></span></li>
    <li>${#temporals.minute(localDateTime)} = <span th:text="${#temporals.minute(localDateTime)}"></span></li>
    <li>${#temporals.second(localDateTime)} = <span th:text="${#temporals.second(localDateTime)}"></span></li>
    <li>${#temporals.nanosecond(localDateTime)} = <span th:text="${#temporals.nanosecond(localDateTime)}"></span></li>
</ul>

1.6 URL 링크

<h1>URL 링크</h1>
<ul>
    <li><a th:href="@{/hello}">basic url</a></li>
    <li><a th:href="@{/hello(param1=${param1}, param2=${param2})}">hello query param</a></li>
    <li><a th:href="@{/hello/{param1}/{param2}(param1=${param1}, param2=${param2})}">path variable</a></li>
    <li><a th:href="@{/hello/{param1}(param1=${param1}, param2=${param2})}">path variable + query parameter</a></li>
</ul>

1.7 리터럴

: 리터럴은 소스코드 상에 고정된 값을 말하는 용어이다.

// ex) int a = 5 일 때 의 5
<body>
<h1>리터럴</h1>
<ul>
    <!--주의! 다음 주석을 풀면 예외가 발생함-->
    <!-- <li>"hello world!" = <span th:text="hello world!"></span></li>-->
    <li>'hello' + ' world!' = <span th:text="'hello' + ' world!'"></span></li>
    <li>'hello world!' = <span th:text="'hello world!'"></span></li>
    // 중간에 띄어쓰기 있어서 처리를 해줘야 한다
    <li>'hello ' + ${data} = <span th:text="'hello ' + ${data}"></span></li>
    // 1. 리터럴을 '' 작은 따옴표로 감싼다.
    <li>리터럴 대체 |hello ${data}| = <span th:text="|hello ${data}|"></span></li>
    // 2. 리터럴 대체 문법 더해서 간편하게
</ul>
</body>

1.8 연산

<li>산술 연산
        <ul>
            <li>10 + 2 = <span th:text="10 + 2"></span></li>
            <li>10 % 2 == 0 = <span th:text="10 % 2 == 0"></span></li>
        </ul>
    </li>
    <li>비교 연산
        <ul>
            <li>1 > 10 = <span th:text="1 &gt; 10"></span></li>
            <li>1 gt 10 = <span th:text="1 gt 10"></span></li> // >, lt 는 <
            <li>1 >= 10 = <span th:text="1 >= 10"></span></li>
            <li>1 ge 10 = <span th:text="1 ge 10"></span></li> // >=, le 는 <=
            <li>1 == 10 = <span th:text="1 == 10"></span></li>
            <li>1 != 10 = <span th:text="1 != 10"></span></li>
        </ul> </li>
    <li>조건식
        <ul>
            <li>(10 % 2 == 0)? '짝수':'홀수' = <span th:text="(10 % 2 == 0)? '짝수':'홀수'"></span></li>
        </ul>
    </li>
    <li>Elvis 연산자 // 조건식을 편리하게 출력하는 연산자
        <ul>
            <li>${data}?: '데이터가 없습니다.' = <span th:text="${data}?: '데이터가없습니다.'"></span></li> // 데이터 넣거나 안넣거나 나눔
            <li>${nullData}?: '데이터가 없습니다.' = <span th:text="${nullData}?:'데이터가 없습니다.'"></span></li>
        </ul>
    </li>
    <li>No-Operation // 타임리프 오퍼레이션을 수행하지 않음 HTML처럼 그대로 사용 
        <ul>
            <li>${data}?: _ = <span th:text="${data}?: _">데이터가 없습니다.</span></li>
            <li>${nullData}?: _ = <span th:text="${nullData}?: _">데이터가 없습니다.</span></li>
        </ul>
    </li>

1.9 속성 값 설정

<body>
<h1>속성 설정</h1>
<input type="text" name="mock" th:name="userA" />
<h1>속성 추가</h1>
- th:attrappend = <input type="text" class="text" th:attrappend="class='large'" /><br/> //뒤에다 붙임 (띄어쓰기 요망)
- th:attrprepend = <input type="text" class="text" th:attrprepend="class='large'" /><br/> // 앞에다 붙임 (띄어쓰기 요망)
- th:classappend = <input type="text" class="text" th:classappend="large" /><br/> // 알아서 적정하게 붙여주고, 띄어쓰기 X

<h1>checked 처리</h1>
// 값이 false인 경우 checked 속성 자체를 제거한다. 
- checked o <input type="checkbox" name="active" th:checked="true" /><br/>
- checked x <input type="checkbox" name="active" th:checked="false" /><br/>
- checked=false <input type="checkbox" name="active" checked="false" /><br/>
</body>

1.10 반복

<body>
<h1>기본 테이블</h1>
<table border="1">
    <tr>
        <th>username</th>
        <th>age</th>
    </tr>
    <tr th:each="user : ${users}"> // 간단한 반복문 
        <td th:text="${user.username}">username</td>
        <td th:text="${user.age}">0</td>
    </tr>
</table>
<h1>반복 상태 유지</h1><table border="1"> 
    <tr>
        <th>count</th>
        <th>username</th>
        <th>age</th>
        <th>etc</th>
    </tr>
    <tr th:each="user, userStat : ${users}"> // 리스트의 상태, 루프의 상태 
        <td th:text="${userStat.count}">username</td>
        <td th:text="${user.username}">username</td>
        <td th:text="${user.age}">0</td>
        <td>
            index = <span th:text="${userStat.index}"></span>
            count = <span th:text="${userStat.count}"></span>
            size = <span th:text="${userStat.size}"></span>
            even? = <span th:text="${userStat.even}"></span>
            odd? = <span th:text="${userStat.odd}"></span>
            first? = <span th:text="${userStat.first}"></span>
            last? = <span th:text="${userStat.last}"></span>
            current = <span th:text="${userStat.current}"></span>
        </td>
    </tr>
</table>
</body>

1.11 조건부 평가

<body>
<h1>if, unless</h1><table border="1">
    <tr>
        <th>count</th>
        <th>username</th>
        <th>age</th>
    </tr>
    <tr th:each="user, userStat : ${users}">
        <td th:text="${userStat.count}">1</td>
        <td th:text="${user.username}">username</td>
        <td>
            <span th:text="${user.age}">0</span>
            <span th:text="'미성년자'" th:if="${user.age lt 20}"></span>
            <span th:text="'미성년자'" th:unless="${user.age ge 20}"></span>
        </td>
    </tr>
</table>
<h1>switch</h1>
<table border="1">
    <tr>
        <th>count</th>
        <th>username</th>
        <th>age</th>
    </tr>
    <tr th:each="user, userStat : ${users}">
        <td th:text="${userStat.count}">1</td>
        <td th:text="${user.username}">username</td>
        <td th:switch="${user.age}">
            <span th:case="10">10살</span>
            <span th:case="20">20살</span>
            <span th:case="*">기타</span>
        </td>
    </tr>
</table>
</body>

1.12 주석

<h1>1. 표준 HTML 주석</h1>
<!--
<span th:text="${data}">html data</span>
--><h1>2. 타임리프 파서 주석</h1>
<!--/* [[${data}]] */-->
<!--/*-->
<span th:text="${data}">html data</span>
<!--*/-->
<h1>3. 타임리프 프로토타입 주석</h1>
<!--/*/
<span th:text="${data}">html data</span>
/*/-->

1.13 블록

<body> // 사용하기 애매한 겨웅에 사용한다. 
<th:block th:each="user : ${users}">
    <div>
        사용자 이름1 <span th:text="${user.username}"></span>
        사용자 나이1 <span th:text="${user.age}"></span>
    </div>
    <div>
        요약 <span th:text="${user.username} + ' / ' + ${user.age}"></span> </div>
</th:block>
</body>

1.14 자바스크립트 인라인

: 자바스크립트를 편리하게 사용하는 기능

<script th:inline="javascript">
  1. 텍스트는 문자 타입인 경우 "" 를 포함해서
  2. 자바스크립트 내추럴 템플릿은 HTML 파일을 직접 열어도 동작하게
  3. 객체는 자동으로 JSON으로 변환
  4. each는 다음과 같이 사용한다.
[# th:each="user, stat : ${users}"]

1.15 템플릿 조각

: 템플릿을 조각화 내놓고 렌더링 하는 것이다.

<body>
<h1>부분 포함</h1>
<h2>부분 포함 insert</h2>
<div th:insert="~{template/fragment/footer :: copy}"></div>
// 경로 이름
<h2>부분 포함 replace</h2>
<div th:replace="~{template/fragment/footer :: copy}"></div>
<h2>부분 포함 단순 표현식</h2>
<div th:replace="template/fragment/footer :: copy"></div>
<h1>파라미터 사용</h1>
<div th:replace="~{template/fragment/footer :: copyParam ('데이터1', '데이터2')}"></div>
</body>

1.16 템플릿 레이아웃

: 코드 조각을 레이아웃에 넘겨서 사용하는 방법이다. (페이지 만들 때 중복시 유용)
: 1은 템플릿 조각과 유사했고
: 2는 HTML전체에 레이아웃을 적용하는 크기이다.
ex) 페이지는 똑같고 타이틀만 다른 경우

// 페이지의 원형 
<!DOCTYPE html>
<html th:fragment="layout (title, content)" xmlns:th="http://www.thymeleaf.org">
<head>
    <title th:replace="${title}">레이아웃 타이틀</title>
</head>
<body>
<h1>레이아웃 H1</h1>
<div th:replace="${content}">
    <p>레이아웃 컨텐츠</p>
</div>
<footer>
    레이아웃 푸터
</footer>
</body>
</html>

// 덮어쓸 파일 1
<!DOCTYPE html>
<html th:replace="~{template/layoutExtend/layoutFile :: layout(~{::title}, ~{::section})}" xmlns:th="http://www.thymeleaf.org">
// 타이틀 섹션 제외하고 다 넘겨라
<head>
    <title>메인 페이지 타이틀</title>
</head>
<body>
<section>
    <p>메인 페이지 컨텐츠</p>
    <div>메인 페이지 포함 내용</div>
</section>
</body>
</html>

2. 타임리프 - 스프링 통합과 폼

2.1 타임리프 폼을 편리하게 사용하기

: th:field를 사용하면 id name value 등을 자동으로 처리해준다.
: 이의 예시는 아래와 같다.

<form action="item.html" th:action th:object="${item}" method="post">
        <div>
            <label for="id">상품 ID</label>
<!--            <input type="text" id="id" name="id" class="form-control" value="1" th:value="${item.id}" readonly>-->
            <input type="text" id="id" class="form-control" th:field="*{id}" readonly>
        </div>
        <div>
            <label for="itemName">상품명</label>
<!--            <input type="text" id="itemName" name="itemName" class="form-control" value="상품A" th:value="${item.itemName}">-->
            <input type="text" id="itemName" th:field="*{itemName}" class="form-control">
        </div>
        <div>
            <label for="price">가격</label>
<!--            <input type="text" id="price" name="price" class="form-control" value="10000" th:value="${item.price}">-->
            <input type="text" id="price" th:field="*{price}" class="form-control">
        </div>
        <div>
            <label for="quantity">수량</label>
            <input type="text" id="quantity" th:field="*{quantity}" class="form-control">
<!--            <input type="text" id="quantity" name="quantity" class="form-control" value="10" th:value="${item.quantity}">-->
        </div>

2.2 체크박스 - 단일

: 체크박스는 체크시 HTML에서 open = on이라는 값이 넘어가고 스프링 타입 컨버터가 on은 true 타입으로 변환해준다.
: 다만 체크박스 선택하지 않을 시 open field 자체가 서버로 전송되지 않는다. 수정의 경우에는 이게 문제가 될 수 있다.
: 이를 해결 하기 위해 MVC는 히든 필드를 하나 만드는데, 체크 박스 이름 앞에 _를 붙여서 전송하면 체크를 헤제했다고 인식할 수 있다. 체크를 해제한 경우 '_open'만 전송돼서 이를 통해 체크 해제를 판단한다.

<input type="checkbox" id="open" name="open" class="form-check-input">
<input type="hidden" name="_open" value="on" > // 히든필드

2.3 체크박스 - 단일 (타임리프로 간단하게)

: 타임필드가 자동으로 위의 코드를 생성해줌

<input type="checkbox" id="open" th:field="*{open}" class="form-check-input">

2.4 체크박스 - 멀티

: 위의 체크박스를 확장해서 다중 선택이 가능케 한다.
: 여기서 ModelAttribute를 기존과는 조금 다르게 활용한다.
: 체크 박스를 반복해서 보여주어야 하는데, 이를 위해선 각각의 컨트롤러에서 model.addAttribute(...) 을 사용해서 체크 박스를 구성하는 데이터를 반복해서 넣어주어야 한다. 이를 위해 컨트롤러에 아래와 같이 ModelAttribute를 이용하면,

@ModelAttribute("regions")// 일반적인 ModelAttribute와 다른 기능
    public Map<String, String> regions(){
        Map<String, String> regions = new LinkedHashMap<>();
        regions.put("SEOUL", "서울");
        regions.put("BERLIN", "베를린");
        regions.put("LIVERPOOL", "리버풀");
        return regions;
    }
    // 컨트롤러 호출시 항상 ModelAttribute를 통해 regions의 반환 값이 Model에 자동으로 담긴다. 밑의 코드들에 주석 처리된 부분 지우기 가능
    // 그러면 아래처럼 매번 써야하는 수고를 덜 수 있다. 
    @GetMapping("/{itemId}")
    public String item(@PathVariable long itemId, Model model) {
        Item item = itemRepository.findById(itemId);
        model.addAttribute("item", item);

//        Map<String, String> regions = new LinkedHashMap<>();
//        regions.put("SEOUL", "서울");
//        regions.put("BERLIN", "베를린");
//        regions.put("LIVERPOOL", "리버풀");
//        model.addAttribute("regions", regions);

        return "form/item";
    }

2.5 라디오 버튼

: 여러 선택지 중에 하나만 고르는 것이 라디오 버튼이다. ENUM을 통해서 개발해본다.
: 먼저 앞서 설명한 ModelAttribute의 기능을 활용한다.

@ModelAttribute("itemTypes")
    public ItemType[] itemTypes(){
        ItemType[] values = ItemType.values();
        return values; // ENUM의 정보를 배열로 반환 
    }
<div>
            <div>상품 종류</div>
            <div th:each="type : ${itemTypes}" class="form-check form-check-inline">
                <input type="radio" th:field="*{itemType}" th:value="${type.name()}"
                       class="form-check-input">
                <label th:for="${#ids.prev('itemType')}" th:text="${type.description}"
                       class="form-check-label">
                    BOOK
                </label>
            </div>
        </div>

: 한번 선택하면 다시 NULL로 그 값을 바꿀수 없다는 특징이 있다. 그래서 별도의 히든 필드 사용의 필요성이 떨어진다.

2.6 셀렉트 박스

: 여러 선택지 중에 하나를 선택할 때에 사용한다.
: 먼저 자바 객체를 만들고 반환해서 ModelAttribute를 활용한다.

<!-- SELECT -->
        <div>
            <div>배송 방식</div>
            <select th:field="*{deliveryCode}" class="form-select">
                <option value="">==배송 방식 선택==</option>
                <option th:each="deliveryCode : ${deliveryCodes}" th:value="${deliveryCode.code}"
                        th:text="${deliveryCode.displayName}">FAST</option>
            </select>
        </div>

: th:object를 사용하는지 등의 여부를 주목한다.

<select th:field="${item.deliveryCode}" class="form-select" disabled>

: disabled를 사용하면 셀렉트 박스를 선택되지 않게 할 수 있다.

3. 메시지 국제화

3.1 메시지, 국제화 소개

3.1.1 메시지

: HTML 파일에 메시지가 하드코딩 되어있어서 바꾸기 곤란한 경우에, 다양한 메시지를 한 곳에서 관리하여 바꾸기 쉽게 할 수 있는 기능이 메시지 기능이다.

3.1.2 국제화

: 메시지에서 설명한 메시지 파일( messages.properteis )을 각 나라별로 별도로 관리하면 서비스를 국제화 할 수 있다(접속 지역에 따라 언어를 다르게 설정)

3.2 스프링 메시지 소스 설정

: 직접 등록하는 방식도 있다.
: 스프링 부트를 사용 하면 부트에서 자동으로 MessageSouce를 스프링 빈으로 등록한다.
: application properties에 다음과 같은 코드를 넣어야 한다.

spring.messages.basename=messages

: 이와 같이 하면 스프링 빈에 메시지 소스가 등록이 된다.

3.3 스프링 메시지 소스 사용

: main이 아닌 Test창에서 메시지 소스가 제대로 적용되는지 확인해 본다.

@Test
void helloMessage() {
        String result = ms.getMessage("hello", null, null);
        // 지역 설정이 default로 되어있어서 "안녕"이 실행이 됨
        assertThat(result).isEqualTo("안녕");
}
    
@Test
void argumentMessage() {
        String result = ms.getMessage("hello.name", new Object[]{"Spring"}, null);
        // 값을 넘겨서 치환하는데 Object[] 배열을 사용
        assertThat(result).isEqualTo("안녕 Spring");
}

: 이 처럼 메시지 소스가 잘 적용됨을 확인 할 수 있다.

3.3.1 에러

: 진행중에 한글이 제대로 인식되지 않는 에러가 발생해서 시간이 걸렸다.
: 해결하기 위해서는 Files > settings> Editor > File Encoding 메뉴에서 사진에 하이라이트 된 부분을 UTF-8로 바꾸고, 프로그램을 종료하고 다시 켠 뒤에 깨진 문자들을 지우면 된다.

3.4 웹 어플리케이션에 메시지 적용하기

: 메시지를 활용하여 $대신 #을 쓰면 된다.

<div th:text="#{label.item}"></h2>

: 이처럼 관련 파일들을 수정하면 효과적으로 관리하고, 내용이 적용이 되는 것을 눈치챌 수 있다.

3.5 웹 어플리케이션에 국제화 적용하기

: 영어버전 메시지 파일을 수정한 후 크롬의 언어 설정에서 영어를 제일 위로 적용하면 적용되는 것을 확인 할 수 있다.

4. 검증 1 - Validation

: 에러가 발생 하였을때, 어떤 오류가 발생했는지 친절하게 가르쳐주어야 한다.

4.1 검증 직접 처리

: 실제로 저장되는 @Post annotation이 있는 곳에 적용한다.
: StringUtils는 springframework의 것을 사용한다.
: 다음과 같이 조건문을 통하여 예외사항을 검증하고,

    @PostMapping("/add") //실제 저장
    public String addItem(@ModelAttribute Item item, RedirectAttributes redirectAttributes, Model model) {
        //검증 오류 결과를 보관
        Map<String, String> errors = new HashMap<>();

        //검증 로직 - itemName에 글자가 없을 경우
        if(!StringUtils.hasText(item.getItemName())){
            errors.put("itemName", "상품 이름은 필수입니다.");
        }
        //검증 로직 - itemPrice가 범위를 넘어설 경우
        if (item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() > 1000000) {
            errors.put("price", "가격은 1,000 ~ 1,000,000 까지 허용합니다.");
        }
        //검증 로직 - itemQuantity의 수량 검즘
        if (item.getQuantity() == null || item.getQuantity() >= 9999) {
            errors.put("quantity", "수량은 최대 9,999 까지 허용합니다.");
        }
        // 특정 필드가 아닌 복합 룰 검증
        if (item.getPrice() != null && item.getQuantity() != null){
            int resultPrice = item.getPrice() * item.getQuantity();
            if(resultPrice < 10000){
                errors.put("globalError", "가격 * 수량의 값이 10000원 이상이여야 합니다. 현재 는 " + resultPrice + "입니다");
            }
        }

        // 검증을 모두 실행한 이후에는, 다시 입력폼으로 돌아가야함
        if(!errors.isEmpty()){
            model.addAttribute("errors",errors); // 다시 보내려면 모델에 담아야 함.
            return "validation/v1/addForm"; //입력폼 템플릿으로 보내버리기
        }

        // 예외사항 안타면 성공 로직
        Item savedItem = itemRepository.save(item);
        redirectAttributes.addAttribute("itemId", savedItem.getId());
        redirectAttributes.addAttribute("status", true);
        return "redirect:/validation/v1/items/{itemId}";
    }

: HTML 파일에 th:if를 사용하여 조건을 만족할 경우에 에러가 출력되도록 만들 수 있다.

  • 보완점
    : 타입 오류 처리 미비
    : 타입 오류 시 입력 내용이 사라짐
    : 이 때문에 스프링이 제공하는 입력방법을 사용함.

4.2 Binding Result

: BindingResult라는 도구를 사용한다.

4.2.1 additemV1

: 먼저, BindingResult를 우선적으로 적용하는 방식에 대해 알아보겠다.
: 여기선 매개변수에서의 BindingResult의 위치, 그리고 간단한 활용법에 대해 알아본다.
: 필드에 오류는 FieldError 글로벌 오류는 ObjectError로 처리했다.

@PostMapping("/add") //실제 저장
    public String addItemV1(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes, Model model) {
        //검증 오류 결과를 보관
        Map<String, String> errors = new HashMap<>();

        //검증 로직 - itemName에 글자가 없을 경우
        if(!StringUtils.hasText(item.getItemName())){
            bindingResult.addError(new FieldError("item", "itemName", "상뭄 이름은 필수입니다."));
        }
        //검증 로직 - itemPrice가 범위를 넘어설 경우
        if (item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() > 1000000) {
            bindingResult.addError(new FieldError("item", "price", "가격은 1,000 ~ 1,000,000 까지 허용합니다."));
        }
        //검증 로직 - itemQuantity의 수량 검즘
        if (item.getQuantity() == null || item.getQuantity() >= 9999) {
            bindingResult.addError(new FieldError("item", "quantity", "수량은 최대 9,999 까지 허용합니다."));
        }
        // 특정 필드가 아닌 복합 룰 검증
        if (item.getPrice() != null && item.getQuantity() != null){
            int resultPrice = item.getPrice() * item.getQuantity();
            if(resultPrice < 10000){
                // 글로벌에러는 ObjectError를 사용한다.
                bindingResult.addError(new ObjectError("item", "가격 * 수량의 값이 10000원 이상이여야 합니다. 현재 는 " + resultPrice + "입니다"));
            }
        }

        // 검증을 모두 실행한 이후에는, 다시 입력폼으로 돌아가야함
        if(bindingResult.hasErrors()){ // 만약 에러가 있었다면의 표현 방식이 바뀜
            // 자동으로 뷰에 넣기 때문에 Model에 담을 필요가 없음
            return "validation/v2/addForm"; //입력폼 템플릿으로 보내버리기
        }

        // 예외사항 안타면 성공 로직
        Item savedItem = itemRepository.save(item);
        redirectAttributes.addAttribute("itemId", savedItem.getId());
        redirectAttributes.addAttribute("status", true);
        return "redirect:/validation/v2/items/{itemId}";
    }

: 이와 같이 타임리프도 BindingResults를 활용하여 다음과 같이 변경해 주어야 한다.

// #fields를 통하여 검증 오류에 접근
<div th:if="${#fields.hasGlobalErrors()}">
// 해당 필드에 오류가 있는 경우에 태그를 출력한다 th:if와 같다.
<div class="field-error" th:errors="*{quantity}">
// th:field에서 지정한 필드에 오류가 있으면 class 정보를 추가한다.
th:errorclass="field-error" class="form-control"

: BindingResults가 있을 경우에는, 상품 가격에 문자열을 입력했을 때와 같은 경우, 어떤 것이 문제인지 컨트롤러를 정상 호출한다.

  • 문제점
    : 다만 오류가 발생했을때, 데이터가 유지되지 않는 단점이 있다.

4.3 FieldError, ObjectError

: 입력한 값을 화면에 남겨보자
: Field와 ObjectError는 크게 두 가지의 생성자를 가진다.

bindingResult.addError(new FieldError("item", "itemName",item.getItemName(),false,null,null, "상뭄 이름은 필수입니다."));

bindingResult.addError(new ObjectError("item",null,null, "가격 * 수량의 값이 10000원 이상이여야 합니다. 현재 는 " + resultPrice + "입니다"));

: 타임리프 또한 타입 오류로 바인딩에 실패하면 담아서 컨트롤러를 호출하기 때문에 정상 출력이 가능하다.

4.4 오류코드와 메시지 처리

1단계

: 먼저 errors.properties를 만든다.
: 스프링 부트가 파일을 인식할 수 있게 다음 문장을 추가한다.
spring.messages.basename=messages,errors
: errors에 등록된 메시지를 사용해본다.

이전 파일을 돌아보고 싶을땐 ctrl + E를 활용한다. alt tab과 유사

// 코드는 String 배열로 넘긴다.
bindingResult.addError(new FieldError("item", "price",item.getPrice(),false,new String[]{"range.item.price"},new Object[]{1000,1000000}, null));

: 메시지는 다음과 같이 배열을 사용한다.

2단계

: 위와 같은 과정이 조금 번거로워서, 보다 자동화를 거친다.
: rejectValue() , reject()를 사용하면 FieldError,ObjectError를 사용하지 않고 깔끔하게 검증 오류를 다룰 수 있다.

bindingResult.rejectValue("quantity", "max", new Object[]{9999}, null);

3단계

: 어떤식으로 오류코드를 설계할 것인가 에 대한 내용. * 중요
: 범용성과 세밀성을 염두에 두면서 만들어야 한다.
: 세밀한 메시지를 높은 우선순위로 사용하는 것이다.
: 스프링에서는 MessageCodesResolver를 활용하여 오류 메시지를 관리한다.

4단계

: MessageCodesResolver를 직접 활용해보았다.

5단계

: MessageCodesResolver는 구체적인 것을 먼저 만들고 덜 구체적인 것을 가장 나중에 만든다.
: required로 크게 중요하지 않은 메시지를 처리하고, 특정 경우만 구체적으로 잘라서 사용하는게 효과적이다.
: 먼저 errors.properties에 메시지를 추가한다.
: 이렇게 분리하면 애플리케이션 코드를 수정하지 않고, properties파일만 수정하면 관리가 가능하다.

6단계

: 검증 오류 코드는 직접 설정, 스프링 설정 두 가지가 있다.

typeMismatch.java.lang.Integer=숫자를 입력해주세요.
typeMismatch=타입 오류입니다.

: 소스코드를 하나도 건드리지 않고 원하는 메시지를 단계별로 설정할 수 있다.
: 이처럼 에러에 대해 따로 처리하면, 해당 에러에 대해 메시지적용이 잘 되었음을 알 수 있다.

4.5 validiator 분리

1단계

: validator로직을 분리하는 방법에 대하여 알아보겠다.
: additemV4 컨트롤러가 너무 많은 일을 담당하고 있기 때문에 검증 로직은 다른 클래스에 맡긴다.
: 위쪽의 검증 부분은 itemvalidatior에 맡긴다.

2단계

: Validator인터페이스를 사용해서 검증기를 만들면 스프링의 추가적인 도움도 받을 수 있다.
: 아래와 같이 선언한다.

private final ItemValidator itemValidator;

    @InitBinder // 컨트롤러가 호출 될때마다 validator에 항상 넣어지게 된다. 항상 검증 적용 가능 컨트롤러에서만 적용 가능  
    public void init(WebDataBinder dataBinder) {
        dataBinder.addValidators(itemValidator);
    }

: itemvalidator를 직접 언급하지 않을 수 있다.

@PostMapping("/add")
    public String addItemV6(@Validated @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) {
        // @Validated라는 것을 넣어줘야 아이템에 대해서 자동으로 검증해줌
        // 검증이 여러개 올 경우 서포트로 관리한다. 
        if (bindingResult.hasErrors()) {
            return "validation/v2/addForm";
        }
        //성공 로직
        Item savedItem = itemRepository.save(item);
        redirectAttributes.addAttribute("itemId", savedItem.getId());
        redirectAttributes.addAttribute("status", true);
        return "redirect:/validation/v2/items/{itemId}";
    }

5. 검증 2 - Bean Validation

5.1 Bean Validation 소개 및 시작

: 애노테이션 하나로 검증 로직을 쉽게 구현할 수 있다.

implementation 'org.springframework.boot:spring-boot-starter-validation'
// 를 gradle에 추가해주어야 한다. 

: 아래와 같이 제한 조건들을 간단하게 나타낼 수 있다.


import lombok.Data;
// hibernate에서만 동작함. 
import org.hibernate.validator.constraints.Range;
// Bean validation이 표준적으로 제공 어느 구현체에서나 동작 
import javax.validation.constraints.Max;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;

@Data
public class Item {

    private Long id;
    
    @NotBlank
    private String itemName;
    
    @NotNull
    @Range(min = 100, max = 1000000)
    private Integer price;
    
    @NotNull
    @Max(9999)
    private Integer quantity;

    public Item() {
    }

    public Item(String itemName, Integer price, Integer quantity) {
        this.itemName = itemName;
        this.price = price;
        this.quantity = quantity;
    }
}

5.2 Bean Validation 스프링 적용

: @Validated 덕분에 적용된다. 다만 글로벌 등록은 하지 말아야한다.
: 타입 변환에 성공해서 바인딩해 성공해야만 적용된다.

5.3 Bean Validation 에러

: 앞서 배운 메세지(properties)에 넣어서 적용할 수 있다.

Object Error

  1. @ScriptAssert를 활용한다. (자주 쓰이지는 않음)
  2. 오브젝트 관련 부분만 자바 코드로 작성한다.

5.4 Bean Validation 수정에 적용

: 상품수정에 적용하는 과정이다. 앞에서 수정한 내용과 맞게 적절하게 수정하면 된다.

5.5 Bean Validation 한계

: 등록할 때와 수정할 때의 요구사항이 다른 경우 문제가 발생함. 등록에서는 ID를 입력받는 칸이 없지만, 수정할 때에는 ID가 필수인 경우가 그 예시이다.

5.6 Bean Validation groups

: 위의 한계를 해결하기 위한 방법이다.
: 각각 등록과 수정을 위해서 따로 인터페이스를 만든다.
: 이후에 Validated에 groups를 적용하면, 되는 모습을 알 수 있다.

public String addItem2(@Validated(SaveCheck.class) @ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) {
        if (item.getPrice() != null && item.getQuantity() != null) {
            int resultPrice = item.getPrice() * item.getQuantity();
            if (resultPrice < 10000) {
                bindingResult.reject("totalPriceMin", new Object[]{10000, resultPrice}, null);
            }
        }
        if (bindingResult.hasErrors()) {
            return "validation/v3/addForm";
        }
        //성공 로직
        Item savedItem = itemRepository.save(item);
        redirectAttributes.addAttribute("itemId", savedItem.getId());
        redirectAttributes.addAttribute("status", true);
        return "redirect:/validation/v3/items/{itemId}";
    }
    
@PostMapping("/{itemId}/edit")
    public String edit2(@PathVariable Long itemId, @Validated(UpdateCheck.class) @ModelAttribute Item item, BindingResult bindingResult) {
        if (item.getPrice() != null && item.getQuantity() != null) {
            int resultPrice = item.getPrice() * item.getQuantity();
            if (resultPrice < 10000) {
                bindingResult.reject("totalPriceMin", new Object[]{10000, resultPrice}, null);
            }
        }

        if(bindingResult.hasErrors()){
            return "validation/v3/editForm";
        }
        itemRepository.update(itemId, item);
        return "redirect:/validation/v3/items/{itemId}";
    }

: 등록시 폼에서 전달하는 데이터가 도메인 객체와 맞지 않아서 잘 쓰이지 않는다.

5.7 Form 전송 객체 분리

: 위의 문제 때문에 폼 데이터 전달에 별도의 객체를 사용한다.

// 아래와 같이 폼을 두개 만들어서 따로 적용한다. ModelAttriute에 이름도 제대로 적용하여야 한다. (폼 객체를 item 객체로 변환하는 과정이다.)
public String addItem(@Validated @ModelAttribute("item") ItemSaveForm form, BindingResult bindingResult, RedirectAttributes redirectAttributes) {

public String edit(@PathVariable Long itemId, @Validated @ModelAttribute("item") ItemUpdateForm form, BindingResult bindingResult) {
 //...
}

: Item 대신에 ItemSaveform을 전달받고 Validated로 검증 수행 후 BindgingResult로 결과도 받는다.
: item으로 넣지 않을 경우 MVC model에 itemSaveForm으로 담기게 된다.

5.8 BeanValidation - HTTP 메시지 컨버터

: @ Validated는 HTTP 메시지 컨버터에도 적용이 가능하다.

@ModelAttribute 는 HTTP 요청 파라미터(URL 쿼리 스트링, POST Form)를 다룰 때 사용한다.
@RequestBody 는 HTTP Body의 데이터를 객체로 변환할 때 사용한다. 주로 API JSON 요청을 다룰 때
사용한다.

API의 경우에는 3가지로 나누어 생각해야한다.
1. 성공 요청
2. 실패 요청
: 컨트롤러 자체도 호출되지 않고 그 전에 에러가 발생한다.
3. 검증 오류 요청

: HttpMessageConverter 단계에서 실패하면 예외가 발생한다. 예외 발생시 원하는 모양으로 예외를 처리하는 방법은 8과에서 다룬다.

6. 로그인 처리 1 - 쿠키, 세션

: 도메인은 화면 UI기술인프라 등등의 영역을 제외한 시스템이 구현해야하는 핵심 비즈니스 업무 영역을 말한다. Web을 다른 기술로 바꿔도 도메인은 유지해야한다.

6.1 회원 가입

: member, memberRepository, memberController를 추가한후, 실험 데이터도 추가하였다.

6.2 로그인 기능

: LoginService에서 아래의 코드는 주의깊게 봐야할 필요가 있다.

public Member login(String loginId, String password) {
 return memberRepository.findByLoginId(loginId)
 .filter(m -> m.getPassword().equals(password))
 .orElse(null);
 }
 // 회원을 조회하고 파라미터로 넘어온 암호가 같으면 회원을, 아니면 null을 반환하는 식이다. 

: LoginController에서는 앞서 배운 검증을 이용하였다.

@PostMapping("/login")
    public String login(@Valid @ModelAttribute LoginForm form, BindingResult bindingResult, HttpServletResponse response) {
        if (bindingResult.hasErrors()) {
            return "login/loginForm";
        }
        Member loginMember = loginService.login(form.getLoginId(), form.getPassword()); log.info("login? {}", loginMember);
        if (loginMember == null) {
            bindingResult.reject("loginFail", "아이디 또는 비밀번호가 맞지 않습니다.");
            // 글로벌 오류로 objectError를 사용하였다. 
            return "login/loginForm";
        }

6.3 로그인 처리하기 - 쿠키사용

쿠키에 대한 자세한 설명을 아래의 링크에서 쿠키에 관한 부분을 참조하자

https://velog.io/@wrjang96/%EB%AA%A8%EB%93%A0-%EA%B0%9C%EB%B0%9C%EC%9E%90%EB%A5%BC-%EC%9C%84%ED%95%9C-HTTP-%EC%9B%B9-%EA%B8%B0%EB%B3%B8-%EC%A7%80%EC%8B%9D%EC%9E%91%EC%84%B1%EC%A4%91

: 로그인의 상태를 유지하기 위하여 쿠키를 사용한다.
: 서버에서 로그인에 성공하면 HTTP 응답에 쿠키를 담아서 브라우저에 전달하면, 브라우저는 해당 쿠키를 지속해서 보내준다.

로그인

영속 쿠키 : 만료 날짜를 입력하면 해당 날짜까지 유지
세션 쿠키 : 브라우저 종료까지만 유지된다.

: 쿠키 생성 로직은 LoginController내에서 다음과 같은 코드로 적용한다.

//로그인 성공 처리
        
// 쿠키에 시간 정보를 주지 않으면 세션 쿠키가 된다.
Cookie idCookie = new Cookie("memberId", String.valueOf(loginMember.getId())); 
// long을 스트링으로 바꾸기 위해 String,valueOf를 사용한다.
response.addCookie(idCookie);
// 생성한 쿠키를 HTTPServletResponse에 담는다.
// 쿠키 이름은 memberId이고 값은 id를 담아둔다.

: 실행 하였을 때 아래 사진과 같이 로그인이 잘 되어있음을 알 수 있다.

로그아웃

@PostMapping("/logout")
    public String logout(HttpServletResponse response) {
        expireCookie(response, "memberId"); // 응답 넣고 쿠키명 넣으면 expire 해주는 것
        return "redirect:/";
    }
    private void expireCookie(HttpServletResponse response, String cookieName) {
        Cookie cookie = new Cookie(cookieName, null);
        cookie.setMaxAge(0);
        response.addCookie(cookie);
    }

: 실행해보면 쿠키가 잘 제거되는 것을 볼 수 있다.

6.4 쿠키와 보안 문제

한계

: 다만 이렇게 개발하면 보안상의 큰 문제가 있다.

  1. 쿠키 값은 임의로 변경할 수 있다.
  2. 쿠키에 보관된 정보는 훔쳐갈 수 있다.
  3. 해커가 쿠키를 한번 훔쳐가면 평생 사용할 수 있다.

대안

: 쿠키에 중요한 값을 노출하지 않는다.
: 서버에서 해당 토큰의 만료시간을 짧게 유지한다.
: 토큰에 임의의 값을 넣어도 찾을 수 없게 예상 불가능 해야한다.

  • 이를 위해 서버 세션을 사용한다.

6.5 로그인 처리하기

세션의 동작 방식

: 위의 문제 해결을 위해 중요한 정보는 모두 서버에 저장해야하고, 클라이언트와 서버는 추정 불가능한 임의의 식별자 값으로 연결해야 함을 알 수 있다.
: 서버에서 회원 아이디 비밀번호가 맞을 경우, 세션 저장소에 세션 ID를 생성하는데, UUID를 통하여 추정이 불가능하게 만든다.
: 이 세션 아이디와 세션에 보관할 값을 서버의 세션 저장소에 보관한다.
: 서버는 클라이언트에 세션 ID만 쿠키에 담아서 저장하고, 클라이언트는 쿠키 저장소에 쿠키를 보관한다.
: 이 세션 아이디를 이용해서 서버에서 중요한 정보를 관리할 수 있다.

세션을 직접 개발해보자

  1. 세션 생성
  2. 세션 조회
  3. 세션 만료

: 크게 이 세가지 기능이 있어야 한다.

상수로 만들기 단축키 : ctrl + alt + c


@Component
public class SessionManager {
    public static final String SESSION_COOKIE_NAME= "mySessionId";
    private Map<String, Object> sessionStore = new ConcurrentHashMap<>();

    // 1. 세션 생성
    public void createSession(Object value, HttpServletResponse response){
        //세션 아이디 생성하고 값을 세션에 저장
        String sessionId = UUID.randomUUID().toString();
        sessionStore.put(sessionId, value);
        
        // 쿠키를 생성
        Cookie mySessionCookie = new Cookie(SESSION_COOKIE_NAME, sessionId);
        response.addCookie(mySessionCookie);

    }
    // 2. 세션 조회
    public Object getSession(HttpServletRequest request) {
        Cookie sessionCookie = findCookie(request, SESSION_COOKIE_NAME);
        if (sessionCookie == null) {
            return null;
        }
        return sessionStore.get(sessionCookie.getValue());
    }
    private Cookie findCookie(HttpServletRequest request, String cookieName) {
        if (request.getCookies() == null) {
            return null;
        }
        return Arrays.stream(request.getCookies())// 배열의 값을 하나씩 넘기며 돌리는게 stream
                .filter(cookie -> cookie.getName().equals(cookieName))
                .findAny()
                .orElse(null);
    }

    // 위의 코드는 아래의 코드를 리팩토링 한 것이다.
    //    public Object getSession(HttpServletRequest request){
//        Cookie[] cookies = request.getCookies();// 배열로 반환됨
//        if (cookies == null){
//            return null;
//        }
//        for(Cookie cookie : cookies){
//            if(cookie.getName().equals(SESSION_COOKIE_NAME)){
//                return sessionStore.get(cookie.getValue());
//            }
//        }
//        return null;
//    }

    // 3. 세션 만료
    public void expire(HttpServletRequest request) {
        Cookie sessionCookie = findCookie(request, SESSION_COOKIE_NAME);
        if (sessionCookie != null) {
            sessionStore.remove(sessionCookie.getValue());
        }
    }
}
  • @Component : 스프링 빈으로 자동 등록한다.
  • ConcurrentHashMap : HashMap 은 동시 요청에 안전하지 않다. 동시 요청에 안전한
    ConcurrentHashMap 를 사용했다.

만들었던 세션 적용

: 로그인 함수 내에서 아래의 코드를 추가한다.

// 세션 관리자를 통해 세션을 생성하고, 회원데이터 보관
sessionManager.createSession(loginMember, response);

: 로그아웃은 HttpServletRequest를 사용한다.

@PostMapping("/logout")
    public String logoutV2(HttpServletRequest request) {
       sessionManager.expire(request);
        return "redirect:/";
    }

: 아래와 같이 Home에도 적용한다.

@GetMapping("/")
    public String homeLoginV2(HttpServletRequest request, Model model){
        // 세션 관리자에 저장된 회원 정보 조회
        Member member = (Member)sessionManager.getSession(request);

        if(member == null){
            return "home";
        }
        model.addAttribute("member", member);
        return "loginHome";
    }

서블릿 HTTP 세션

: 로그인

@PostMapping("/login")
    public String loginV3(@Valid @ModelAttribute LoginForm form, BindingResult bindingResult, HttpServletRequest request) {
        if (bindingResult.hasErrors()) {
            return "login/loginForm";
        }
        Member loginMember = loginService.login(form.getLoginId(),
                form.getPassword());
        log.info("login? {}", loginMember);
        if (loginMember == null) {
            bindingResult.reject("loginFail", "아이디 또는 비밀번호가 맞지 않습니다.");
            return "login/loginForm";
        }
        //로그인 성공 처리
        //세션이 있으면 있는 세션 반환, 없으면 신규 세션 생성
        HttpSession session = request.getSession();
        //세션에 로그인 회원 정보 보관
        session.setAttribute(SessionConst.LOGIN_MEMBER, loginMember); return "redirect:/";
    }

: 로그아웃

@PostMapping("/logout")
    public String logoutV3(HttpServletRequest request) {
        HttpSession session = request.getSession(false);
        if(session != null){
            session.invalidate();
        }
        return "redirect:/";
    }

: 홈컨트롤러에선 다음과 같이 사용한다

 //@GetMapping("/")
    public String homeLoginV3(HttpServletRequest request, Model model){
        HttpSession session = request.getSession(false); // 세션은 꼭 필요할 때만 생성해야함

        if(session == null){
            return "home";
        }

        Member loginMember = (Member)session.getAttribute(SessionConst.LOGIN_MEMBER);

        // 세션에 회원 데이터가 없으면 home
        if(loginMember == null){
            return "home";
        }
        model.addAttribute("member", loginMember);
        return "loginHome";
    }

@SessionAttribute

: 홈 컨트롤러에서 위 어노테이션을 사용하면 세션을 찾고, 세션의 데이터를 찾는 긴 과정을 편리하게 처리해주는 것을 확인할 수 있다.

 @GetMapping("/")
    public String homeLoginV3Spring(@SessionAttribute(name = SessionConst.LOGIN_MEMBER, required = false) Member loginMember,  Model model){
    // 세션 + 애트리뷰트 
        if(loginMember == null){
            return "home";
        }
        model.addAttribute("member", loginMember);
        return "loginHome";
    }

@TrackingModes

: 웹브라우저가 쿠키를 지원하지 않을 때 쿠키 대신 URL을 통해서 세션을 유지하는 방법
: 사용하지 않으려면 다음 코드를 apllication properties에 추가하면 된다

server.servlet.session.tracking-modes=cookie

6.6 세션 정보와 타임아웃 설정

세션 정보 확인

: 세션 정보를 확인하기 위해 아래와 같은 코드를 만들었다.

public class SessionInfoController {
    @GetMapping("/session-info")
    public String sessionInfo(HttpServletRequest request){
        HttpSession session = request.getSession(false);
        if(session == null){
            return "세션이 존재하지 않습니다";
        }
        session.getAttributeNames().asIterator()
                .forEachRemaining(name -> log.info("session name={}, value={}", name, session.getAttribute(name)));
        log.info("sessionId={}", session.getId());
        log.info("maxInactiveInterval={}", session.getMaxInactiveInterval());
        log.info("creationTime={}", new Date(session.getCreationTime()));
        log.info("lastAccessedTime={}", new Date(session.getLastAccessedTime()));
        log.info("isNew={}", session.isNew());
        return "세션 출력";
    }
}

: 로그인 한후 session-info로 들어가 내용을 확인해본다.
: maxInactiveInterval은 세션의 유효 시간을 의미한다.

세션 타임아웃 설정

: 세션은 로그아웃을 눌렀을때 삭제가 되는데, 대부분의 사용자는 창을 그냥 꺼버리고, 웹 브라우저는 비 연결성이기 때문에 사용자가 웹을 종료했는지 아닌지를 인식할 수 없다.

세션의 종료 시점 설정

: 사용자가 서버에 최근에 요청한 시간을 기준으로, 30분 정도를 유지하면 좋다
: 예제에서는 60초를 사용하였다.
: application.properties에 다음 코드를 추가하면 된다.

server.servlet.session.timeout=60

7. 로그인 처리 2 - 필터, 인터셉터

: 필터는 서블릿, 인터셉터는 스프링에서 제공하는 기능이다.
: 로그인하지 않은 사용자도 URL을 직접 호출하면 상품 관리 화면에 들어갈 수 있다.
: 웹과 관련된 공톰 관심사(애플리케이션 여러 로직에서 공통적으로 관심이 있는 것)에는 필터 또는 인터셉터를 사용하는 것이 좋다.

7.1 서블릿 필터

: 필터는 서블릿이 지원하는 수문장이다.

HTTP요청 -> WAS(서버) -> 필터 -> 서블릿 -> 컨트롤러

: 필터 호출한 다음에 서블릿이 호출된다. 특정 URL 패턴에 적용할 수 있다.
: 제한 하면 필터에서 자체적으로 서블릿을 호출하지 않는다.
: 필터 인터페이스는 싱글톤이다.

요청 로그

: 모든 요청을 로그로 남기는 필터를 개발한다.
: 필터는 아래와 같다.


@Slf4j
public class LogFilter implements Filter {
    @Override
    public void init(FilterConfig filterConfig) throws ServletException {
        log.info("log filter init");
    }

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        log.info("log filter doFilter");
        HttpServletRequest httpRequest = (HttpServletRequest) request;
        String requestURI = httpRequest.getRequestURI(); 
        // 모든 사용자의 요청 URI 남기기
        String uuid = UUID.randomUUID().toString();
        // 요청 온것을 구분하기 위해 UUID 사용 
        try{
            log.info("REQUEST [{}][{}]", uuid, requestURI);
            chain.doFilter(request, response); 
            // 다음 필터 호출해야함
        } catch (Exception e){
            throw e;
        }finally{
            log.info("RESPONSE [{}][{}]", uuid, requestURI);
        }
    }

    @Override
    public void destroy() {
        log.info("log filter destroy");
    }
}

: 필터를 쓸 수 있게 등록을 해야한다.

@Configuration
public class WebConfig {

    @Bean
    public FilterRegistrationBean logFilter(){
        FilterRegistrationBean<Filter> filterRegistrationBean = new FilterRegistrationBean<>();
        filterRegistrationBean.setFilter(new LogFilter());
        filterRegistrationBean.setOrder(1);
        // 필터의 순서 정해주기
        filterRegistrationBean.addUrlPatterns("/*");
        // 어떤 URL패턴에 적용하는가 (모든 URL에 적용)
        return filterRegistrationBean;
    }
}

: 스프링 부트를 사용한다면 FilterRegistrationBean 을 사용해서 등록하면 된다.

인증 체크

: 인증 받지 않으면 해당 페이지에 들어가지 못하게 한다.
: 아래와 같이 필터를 만든다

@Slf4j
public class LoginCheckFilter implements Filter {
    private static final String[] whitelist = {"/", "/members/add", "/login", "/logout","/css/*"};
    // 위의 리스트는 로그인 안돼도 허용되게 풀어줌
    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        HttpServletRequest httpRequest = (HttpServletRequest) request;
        String requestURI = httpRequest.getRequestURI();
        HttpServletResponse httpResponse = (HttpServletResponse) response;
        try {
            log.info("인증 체크 필터 시작 {}", requestURI);
            if (isLoginCheckPath(requestURI)) {
                // 화이트 리스트가 아닌 경우
                log.info("인증 체크 로직 실행 {}", requestURI);
                HttpSession session = httpRequest.getSession(false);
                if (session == null || session.getAttribute(SessionConst.LOGIN_MEMBER) == null) {
                    log.info("미인증 사용자 요청 {}", requestURI);
                    //로그인으로 redirect
                    httpResponse.sendRedirect("/login?redirectURL=" +
                            requestURI);
                    return; //여기가 중요, 미인증 사용자는 다음으로 진행하지 않고 끝!
                }
            }
            chain.doFilter(request, response);
        } catch (Exception e) {
            throw e; //예외 로깅 가능 하지만, 톰캣까지 예외를 보내주어야 함
        } finally {
            log.info("인증 체크 필터 종료 {}", requestURI);
        }
    }
    /**
     * 화이트 리스트의 경우 인증 체크X
     */
    private boolean isLoginCheckPath(String requestURI) {
        return !PatternMatchUtils.simpleMatch(whitelist, requestURI);
    }
}

: 앞서 필터를 쓰기위하여 등록했듯이, WebConfig에 등록해준다.

@Bean
    public FilterRegistrationBean loginCheckFilter() {
        FilterRegistrationBean<Filter> filterRegistrationBean = new FilterRegistrationBean<>();
        filterRegistrationBean.setFilter(new LoginCheckFilter());
        filterRegistrationBean.setOrder(2);
        filterRegistrationBean.addUrlPatterns("/*");
        return filterRegistrationBean;
    }

: 위의 코드를 적용 후에 실행하면 리다이렉트가 제대로 되는 것을 확인할 수 있다.
: 밑의 코드는 로그인 성공 시에 처음 요청한 URL로 이동하는 기능이다.

@PostMapping("/login")
    public String loginV4(@Valid @ModelAttribute LoginForm form, BindingResult bindingResult, @RequestParam(defaultValue = "/") String redirectURL, HttpServletRequest request) {
        // @RequestParam(defaultValue = "/") String redirectURL,를 수정함 
        // 없으면 /로 갈꺼고 아니면 redirectURL로 가게 설정한다.
        .....
        return "redirect:" + redirectURL;
    }

7.2 스프링 인터셉터

: 위의 필터와는 순서와 범위 사용방법이 다르다.
: 인터셉터의 흐름은 아래와 같다.

HTTP 요청 -> WAS -> 필터 -> 서블릿 -> 스프링 인터셉터 -> 컨트롤러

: 인터셉터를 사용하려면 HandlerInterceptor 인터페이스를 구현하면 된다.

요청 로그

: 모든 요청을 로그로 남기는 인터셉터를 개발한다.
: LogInterceptor의 코드는 다음과 같다


@Slf4j
public class LoginInterceptor implements HandlerInterceptor {
    public static final String LOG_ID = "logId";
    // 싱글톤이라 여기서 prehandle코드 작성 불가
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        String requestURI = request.getRequestURI();
        String uuid = UUID.randomUUID().toString();

        request.setAttribute(LOG_ID, uuid);
        // @Controller가 아니라 정적 리소스가 호출되는 경우에는 : ResourceHttpRequestHandler
        if (handler instanceof HandlerMethod){ // @RequestMapping의 경우 사용 되는 handler가 handlerMethod이다.
            HandlerMethod hm = (HandlerMethod) handler; // 호출할 컨트롤러 메소드의 모든 정보가 포함되어 있다
        }
        log.info("REQUEST [{}][{}][{}]", uuid, requestURI, handler);
        return true;
        // true 다음 컨트롤러 호출
        // false 여기서 끝남
    }

    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
        log.info("postHandle [{}]", modelAndView);
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        String requestURI = request.getRequestURI();
        String logId = (String)request.getAttribute(LOG_ID);
        log.info("RESPONSE [{}][{}]", logId, requestURI);
        if (ex != null) {
            // 예외가 NUll이 아니면(예외처리를 여기서 하는 이유는 PostHandle이 호출되지 않는다)
            log.error("afterCompletion error!!", ex); // 에러를 찍어볼 수 있음
        }
    }
}

: 등록하기 위해 만든 WebConfig의 코드는 아래와같았다.

@Configuration
public class WebConfig implements WebMvcConfigurer { // implement함

    @Override
    public void addInterceptors(InterceptorRegistry registry){
        registry.addInterceptor(new LogInterceptor())
                .order(1)
                .addPathPatterns("/**") // 리소스 폴더 포함 하위의 모든 패턴
                .excludePathPatterns("/css/**", "/*.ico", "/error");// 이 경로는 인터셉터 먹이지마
    }
}

인증 체크

: 인증이란 것은 컨트롤러 호출 전에만 호출하면 되기 때문에, preHandle만 구현하면 된다.

@Slf4j
public class LoginCheckInterceptor implements HandlerInterceptor {
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        String requestURI = request.getRequestURI();
        log.info("인증 체크 인터셉터 실행 {}", requestURI);
        HttpSession session = request.getSession(false);
        if (session == null || session.getAttribute(SessionConst.LOGIN_MEMBER) == null) {
            log.info("미인증 사용자 요청");
            //로그인으로 redirect
            response.sendRedirect("/login?redirectURL=" + requestURI);
            return false;
        } return true;
    }
}

: 다음과 같이 인터셉터를 등록한다.

registry.addInterceptor(new LoginCheckInterceptor())
                .order(2)
                .addPathPatterns("/**")
                .excludePathPatterns(
                        "/", "/members/add", "/login", "/logout",
                        "/css/**", "/*.ico", "/error"
                );
        // 인터셉터의 장점: 패턴을 세밀하게 가져갈 수 있음 

7.3 AgumentResolver 활용

: ArgumentResolver에 대한 내용은 다음과 같다.

https://velog.io/@wrjang96/%EC%8A%A4%ED%94%84%EB%A7%81-%EC%9B%B9-MVC-1#6-%EC%8A%A4%ED%94%84%EB%A7%81-mvc---%EA%B8%B0%EB%B3%B8-%EA%B8%B0%EB%8A%A5

: ArgumentResolver 를 활용하면 공통 작업이 필요할 때 컨트롤러를 더욱 편리하게 사용할 수 있다
: 먼저 HomeController에서 세션 대신에 @Login을 추가한다.
: ArgumentResolver 가 동작해서 자동으로 세션의 로그인 회원을 찾아주고, 세션에 없다면 NULL을 반환하게 개발한다.

public String homeLoginV3Argumentresolver(@Login Member loginMember,  Model model){ 
// 어노테이션 하나로 간단하게 해결한다.

: 아래와 같이 남긴다.

@Target(ElementType.PARAMETER)
// 파라미터에만 사용
@Retention(RetentionPolicy.RUNTIME)
// 런타임까지 어노테이션 정보를 남김
public @interface Login {

}

: ArgumentResolver를 개발한다.
: supportsParameter() : @Login 애노테이션이 있으면서 Member 타입이면 해당 ArgumentResolver
가 사용된다.
: resolveArgument() : 컨트롤러 호출 직전에 호출 되어서 필요한 파라미터 정보를 생성해준다. 여기서는
세션에 있는 로그인 회원 정보인 member 객체를 찾아서 반환해준다. 이후 스프링MVC는 컨트롤러의
메서드를 호출하면서 여기에서 반환된 member 객체를 파라미터에 전달해준다

@Slf4j
public class LoginMemberArgumentResolver implements HandlerMethodArgumentResolver {
    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        log.info("supportsParameter 실행");
        boolean hasLoginAnnotation =
                parameter.hasParameterAnnotation(Login.class);
        boolean hasMemberType =
                Member.class.isAssignableFrom(parameter.getParameterType());
        return hasLoginAnnotation && hasMemberType;
    }
    @Override
    public Object resolveArgument(MethodParameter parameter,
                                  ModelAndViewContainer mavContainer, NativeWebRequest webRequest,
                                  WebDataBinderFactory binderFactory) throws Exception {
        log.info("resolveArgument 실행");
        HttpServletRequest request = (HttpServletRequest)
                webRequest.getNativeRequest();
        HttpSession session = request.getSession(false);
        if (session == null) {
            return null;
        }
        return session.getAttribute(SessionConst.LOGIN_MEMBER);
    }
}

: 마지막으로, Config에 등록한다.

@Override
    public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
        resolvers.add(new LoginMemberArgumentResolver());
    }

8. 예외 처리와 오류 페이지

: 자바는 메인 메소드 실행시 main의 쓰레드가 실행된다. 예외를 잡지 못하고 넘어서 예외가 던져지면, 예외 정보를 남기고 해당 쓰레드는 종료된다.

  • 웹 어플리케이션
    : 사용자 요청별로 별도의 쓰레드가 할당되고, 서블릿 컨테이너 안에서 실행된다.
    : 애플리케이션에서 예외가 발생했는데, 잡지 못하고 서블릿 밖까지 예외가 전달 되면 HTTP status 404같은 예외 페이지가 나타난다.

8.1 서블릿 예외 처리

8.1.1 response.sendError(Http 상태코드, 오류 메시지)

: 서블릿 컨테이너에게 오류가 발생했다는 점을 전달할 수 있다.
: Excpetion이 터지면 서블릿 컨테이너는 500으로 처리한다.
: 직접 오류메시지 등을 담아서 처리하고 싶은 경우 response.sendError


@Slf4j
@Controller
public class ServletExController {
    @GetMapping("/error-ex")
    public void errorEx(){
        throw new RuntimeException("Exception occured");
    }

    @GetMapping("/error-404")
    public void error404(HttpServletResponse response) throws IOException{
        response.sendError(404, "404 Error");
    }

    @GetMapping("/error-500")
    public void error500(HttpServletResponse response) throws IOException {
        response.sendError(500);
    }


}

: 다만 사용자가 보기에 불편하다.

8.1.2 서블릿이 제공하는 오류화면 기능

: 먼저 스프링 부트가 제공하는 기능을 사용해 서블릿 오류 페이지를 등록한다.

@Component // 스프링에 등록해주는 어노테이션 
public class WebServerCustomizer implements WebServerFactoryCustomizer<ConfigurableWebServerFactory> {
    // 서블릿 컨테이너가 이렇게 사용하도록 지정함
    @Override
    public void customize(ConfigurableWebServerFactory factory) {
        ErrorPage errorPage404 = new ErrorPage(HttpStatus.NOT_FOUND, "/error-page/404");
        // 낫파운드 에러가 뜨면 404로 가라
        ErrorPage errorPage500 = new ErrorPage(HttpStatus.INTERNAL_SERVER_ERROR, "/error-page/500");
        ErrorPage errorPageEx = new ErrorPage(RuntimeException.class, "/error-page/500");
        factory.addErrorPages(errorPage404, errorPage500, errorPageEx);
        // 등록을 함
    }
}

: 오류를 처리할 컨트롤러를 만든다.

@Slf4j
@Controller
public class ErrorPageController {
    @RequestMapping("/error-page/404")
    public String errorPage404(HttpServletRequest request, HttpServletResponse response) {
        log.info("errorPage 404");
        return "error-page/404";
    }
    @RequestMapping("/error-page/500")
    public String errorPage500(HttpServletRequest request, HttpServletResponse response) {
        log.info("errorPage 500");
        return "error-page/500";
    }
}

작동원리

  1. 예외가 발생해서 서버까지 전파된다.
  2. 서버는 오류 페이지 경로를 찾아서 내부에서 오류 페이지를 호출한다. 이때 오류 페이지 경로로 서블릿 인터셉터 컨트롤러가 모두 다시 호출된다.

오류 정보 추가

public class ErrorPageController {
    public static final String ERROR_EXCEPTION = "javax.servlet.error.exception";
    public static final String ERROR_EXCEPTION_TYPE = "javax.servlet.error.exception_type";
    public static final String ERROR_MESSAGE = "javax.servlet.error.message";
    public static final String ERROR_REQUEST_URI = "javax.servlet.error.request_uri";
    public static final String ERROR_SERVLET_NAME = "javax.servlet.error.servlet_name";
    public static final String ERROR_STATUS_CODE = "javax.servlet.error.status_code";

    @RequestMapping("/error-page/404")
    public String errorPage404(HttpServletRequest request, HttpServletResponse response) {
        log.info("errorPage 404");
        return "error-page/404";
    }
    @RequestMapping("/error-page/500")
    public String errorPage500(HttpServletRequest request, HttpServletResponse response) {
        log.info("errorPage 500");
        return "error-page/500";
    }
    private void printErrorInfo(HttpServletRequest request) {
        log.info("ERROR_EXCEPTION: ex=", request.getAttribute(ERROR_EXCEPTION));
        log.info("ERROR_EXCEPTION_TYPE: {}", request.getAttribute(ERROR_EXCEPTION_TYPE));
        log.info("ERROR_MESSAGE: {}", request.getAttribute(ERROR_MESSAGE));
        //ex의 경우 NestedServletException 스프링이 한번 감싸서 반환
        log.info("ERROR_REQUEST_URI: {}", request.getAttribute(ERROR_REQUEST_URI));
        log.info("ERROR_SERVLET_NAME: {}", request.getAttribute(ERROR_SERVLET_NAME));
        log.info("ERROR_STATUS_CODE: {}", request.getAttribute(ERROR_STATUS_CODE));
        log.info("dispatchType={}", request.getDispatcherType());
    }

: 오류 정보를 위와 같이 사용할 수 있다.

예외처리 - 필터

: 이렇게 두번씩 호출되는게 비효율적이기 때문에 클라이언트로부터 발생된 정상 요청인지 오류 페이지 출력을 위한 내부 요청인지 구분해야하고, 이를 위해 DispatcherType을 사용한다.

고객이 한 요청 : dispatcherType=REQUEST
오류 요청인 경우 : dispatchType=ERROR
MVC에서 배웠던 서블릿에서 다른 서블릿이나 JSP를 호출할 때 : dispatchType=FORWARD
서블릿에서 다른 서블릿이나 JSP의 결과를 포함할 때 : dispatchType= INCLUDE
서블릿 비동기 호출 : dispatchType=ASYNC

: 필터는 기존코드와 똑같지만, 로그 출력부에 request.getDispatcherType() 을추가한다.

: 등록하는 WebConfig내에서 다음과 같이 구성한다.

filterRegistrationBean.setDispatcherTypes(DispatcherType.REQUEST, DispatcherType.ERROR);
        // 클라이언트 요청, 오류 페이지 요청에서도 필터가 호출된다.
        // 오류 페이지 경로도 필터 적용할 거 아니면 기본 값을 그대로 적용하면 된다.  

예외처리 - 인터셉터

: 위와 마찬가지로, 기존 코드는 같지만 request.getDispatcherType()를 추가한다.
: WebConfig또한 다음과 같이 구성한다.

@Override
    public void addInterceptors(InterceptorRegistry registry){
        registry.addInterceptor(new LogInterceptor())
                .order(1)
                .addPathPatterns("/**")
                .excludePathPatterns("/css/**", "/*.ico", "/error", "/error-page/**");
    }

결론

: 필터는 DispatchType 으로 중복 호출 제거 ( dispatchType=REQUEST )
: 인터셉터는 경로 정보로 중복 호출 제거( excludePathPatterns("/error-page/**") )

8.2 스프링 부트 - 오류 페이지

: 예외처리 페이지를 보다 간편하게 만들기 위해 스프링 부트에서 지원하는 기능에 대해 알아본다.
: 개발자는 오류 페이지 화면만 BasicErrorController 가 제공하는 룰과 우선순위에 따라서 등록하면
된다.
: 정적 HTML이면 정적 리소스, 뷰 템플릿을 사용해서 동적으로 오류 화면을 만들고 싶으면 뷰 템플릿
경로에 오류 페이지 파일을 만들어서 넣어두기만 하면 된다.

resources/templates/error/4xx.html

: 다음의 경로에 다음과 같이 파일을 넣으면 400대의 html이 자동으로 나오게 된다.
: BasicErrorController가 model에 담아서 뷰에 전달하는 정보들이 있다. 뷰 템플릿은 이 값을 활용해서 출력할 수 있다.
: 보안상 문제가 될 수 있으니, 오류 컨트롤러에서 정보를 모델에 담을지 말지의 여부를 선택할 수 있다.

9. API 예외 처리

: HTML과는 달리 API는 각 오류 상황에 맞는 스펙을 정하고 JSON으로 데이터를 내려주어야 한다.
: 먼저 API 예외 컨트롤러를 만들어보자

@Slf4j
@RestController
public class ApiExceptionController {
    @GetMapping("/api/members/{id}")
    public MemberDto getMember(@PathVariable("id") String id) {
        if (id.equals("ex")) {
            throw new RuntimeException("잘못된 사용자");
        }
        if (id.equals("bad")) {
            throw new IllegalArgumentException("잘못된 입력 값");
        }
        return new MemberDto(id, "hello " + id);
    }
    @Data
    @AllArgsConstructor
    static class MemberDto{
        private String memberId;
        private String name;

    }
}

: 여기서 에러를 반환하게 할 경우, 기존에 만들었던 HTML페이지가 반환된다.
: 오류페이지 컨트롤러도 JSON응답을 하게 아래와 같이 코드를 추가한다.

@RequestMapping(value = "/error-page/500", produces = MediaType.APPLICATION_JSON_VALUE)
//    MediaType은 스프링 프레임워크
//    produces = MediaType.APPLICATION_JSON_VALUE 의 뜻은 클라이언트가 요청하는 HTTP Header의
//    Accept 의 값이 application/json 일 때 해당 메서드가 호출된다는 것이다. 결국 클라어인트가 받고
//    싶은 미디어타입이 json이면 이 컨트롤러의 메서드가 호출된다.
    public ResponseEntity<Map<String, Object>> errorPage500Api(HttpServletRequest request, HttpServletResponse response) {
        log.info("API errorPage 500");
        Map<String, Object> result = new HashMap<>();
        Exception ex = (Exception) request.getAttribute(ERROR_EXCEPTION);
        result.put("status", request.getAttribute(ERROR_STATUS_CODE));
        result.put("message", ex.getMessage());
        Integer statusCode = (Integer)
                request.getAttribute(RequestDispatcher.ERROR_STATUS_CODE);
        return new ResponseEntity(result, HttpStatus.valueOf(statusCode));
    }

스프링부트 기본 오류 처리

: 스프링 부트는 BasicErrorController 가 제공하는 기본 정보들을 활용해서 오류 API를 생성해준다.

9.1 API 예외 처리 - Handler Exception Resolver

HandlerExceptionResolver 시작

: 컨트롤러 밖으로 던져진 예외를 해결하고, 동작 방식을 변경하고 싶으면 HandlerExceptionResolver 를 사용하면 된다.
: 컨트롤러에서 예외를 받으면, afterCompletion을 호출하지 않고, ExceptionResolver를 호출해 예외를 해결하려고 한다.
: ExceptionResolver에서 정상적으로 ModelandView 반환을 하면, 흐름이 정상적으로 바뀐다.
: 코드는 아래와 같다.

@Slf4j
public class MyHandlerExceptionResolver implements HandlerExceptionResolver {

    @Override
    public ModelAndView resolveException(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
        try {
            if (ex instanceof IllegalArgumentException) {
                // 만약 예외가 IllegalArgumentException일 경우
                log.info("IllegalArgumentException resolver to 400");
                response.sendError(HttpServletResponse.SC_BAD_REQUEST, ex.getMessage());
                // BAD_REQUEST가 400이다, 400으로 변경 Excpetion을 sendError로 바꿔치기 하는 것
                return new ModelAndView();
            }
        }catch(IOException e){
            log.error("resolver ex", e);
        }
        return null;
    }
}

: 예외를 해결해도 PostHandle은 호출되지 않는다.
: 포스트맨에서 http://localhost:8080/api/members/bad, http://localhost:8080/api/members/ex를 입력하면, 각각 맞게 에러가 터지는걸 확인할 수 있다.

HandlerExceptionResolver 활용
@Slf4j
public class UserHandlerExceptionResolver implements HandlerExceptionResolver {
    private final ObjectMapper objectMapper = new ObjectMapper();
    @Override
    public ModelAndView resolveException(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
        try {
            if (ex instanceof UserException) {
                log.info("UserException resolver to 400");
                String acceptHeader = request.getHeader("accept");
                response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
                if ("application/json".equals(acceptHeader)) {
                    Map<String, Object> errorResult = new HashMap<>();
                    errorResult.put("ex", ex.getClass());
                    errorResult.put("message", ex.getMessage());
                    String result =
                            objectMapper.writeValueAsString(errorResult);
                    response.setContentType("application/json");
                    response.setCharacterEncoding("utf-8");
                    response.getWriter().write(result);
                    return new ModelAndView();
                } else {
                    //TEXT/HTML
                    return new ModelAndView("error/500");
                }
            }
        } catch (IOException e) {
            log.error("resolver ex", e);
        }
        return null;
    }
}

: 예외가 발생해도 서블 까지 전송되지 않고, MVC에서 예외처리가 끝이 난다.
: 다만 직접 구현하기가 힘들어, 스프링이 제공하는 ExceptionResolver를 사용한다.

9.2 스프링에서 제공하는 Exception Resolver

: 스프링 부트가 기본으로 제공하는 ExceptionResolver 는 다음과 같다.

HandlerExceptionResolverComposite에 다음 순서로 등록

1. ExceptionHandlerExceptionResolver

: @ExceptionHandler 을 처리한다. API 예외 처리는 대부분 이 기능으로 해결한다. 제일 중요

2. ResponseStatusExceptionResolver

: HTTP 상태 코드를 지정해준다.

3. DefaultHandlerExceptionResolver

: 스프링 내부 기본 예외를 처리한다.
: 우선 순위가 가장 낮다

9.2.1. ExceptionHandlerExceptionResolver

: 오류가 발생했을 때 응답의 모양이 다를 수 있다.
: 이렇게 API예외처리 문제를 해결하기 위해 @ExceptionHandler라는 애노테이션을 사용해 편리한 예외 처리 기능을 제공하는데 이게 ExceptionHandlerExceptionResolver이다.

9.2.2.1 ResponseStatusExceptionResolver

@ResponseStatus(code = HttpStatus.BAD_REQUEST, reason = "잘못된 요청 오류")
public class BadRequestException extends RuntimeException {
}
// reason을 메세지 소스에서 찾는 기능도 제공한다. 
@ResponseStatus(code = HttpStatus.BAD_REQUEST, reason =  "error.bad")

: 개발자가 직접 변경할 수 없는 예외에는 적용할 수 없다.
: 애노테이션을 사용하기 때문에 조건에 따라 동적으로 변경하는 것도 어렵다.
: 이를 극복하기 위해 ResponseStatusException 사용
: @ExceptionHandler 애노테이션을 선언하고, 해당 컨트롤러에서 처리하고 싶은 예외를 지정해주면 된다.
해당 컨트롤러에서 예외가 발생하면 이 메서드가 호출된다. 참고로 지정한 예외 또는 그 예외의 자식
클래스는 모두 잡을 수 있다.
: 스프링의 우선순위는 항상 더 자세한 것이 우선권을 가진다.

9.2.2.2 ResponseStatusException

@GetMapping("/api/response-status-ex2")
    public String responseStatusEx2() {
        throw new ResponseStatusException(HttpStatus.NOT_FOUND, "error.bad", new
                IllegalArgumentException());
    }

: response.sendError(statusCode, resolvedReason)를 호출한다.

9.2.3. DefaultHandlerExceptionResolver

: 스프링 내부에서 발생하는 스프링 에외를 해결해준다. 대표적으로 파라미터 바인딩 시점에 타입이 맞지 않으면, 내부에서 TypeMismatchException이 발생하여서 500오류가 발생한다. 근데 파라미터 바인딩은 대부분 클라이언트가 HTTP요청을 잘못 호출해서 생긴 것이라 HTTP서는 이 오류에 HTTP상태 코드 400을 사용하게 한다. Default를 사용하면 HTTP상태코드 400으로 변경해준다.
: 따라서 아래 코드를 호출 했을때,

@GetMapping("/api/default-handler-ex")
    public String defaultException(@RequestParam Integer data) {
        return "ok";
    }

: HTTP상태코드 400로 변경된걸 확인 할 수 있다.

9.3 @ControllerAdvice

: 정상 코드와 예외 처리 코드가 하나의 컨트롤러에 섞여 있다. @ControllerAdvice를 사용하면 이를 분리할 수 있다.
: @ControllerAdvice 는 대상으로 지정한 여러 컨트롤러에 @ExceptionHandler , @InitBinder 기능을
부여해주는 역할을 한다.
: @ControllerAdvice 에 대상을 지정하지 않으면 모든 컨트롤러에 적용된다. (글로벌 적용)
: @RestControllerAdvice 는 @ControllerAdvice 와 같고, @ResponseBody 가 추가되어 있다.
: @Controller , @RestController 의 차이와 같다

결론적으로, @ExceptionHandler 와 @ControllerAdvice 를 조합하면 예외를 깔끔하게 해결할 수 있다

10. 스프링 타입 컨버터

: 예전에 자바에서는 변환하는 과정을 항상 거쳐야 했다.

@GetMapping("/hello-v1")
    public String helloV1(HttpServletRequest request) {
        // Http 요청 파라미터는 모두 문자로 처리된다.
        // 다른 타입으로 변환하고 싶으면 숫자 타입으로 변환하는 과정을 거쳐야 한다.
        String data = request.getParameter("data"); //문자 타입 조회
        Integer intValue = Integer.valueOf(data); //숫자 타입으로 변경
        System.out.println("intValue = " + intValue);
        return "OK";
    }

: 스프링에서는 다음과 같이 @RequestParam을 통해서 중간에서 형변환을 해준다.

@GetMapping("/hello-v2")
    public String helloV2(@RequestParam Integer data){
        System.out.println("data = " + data);
        return "OK";
    }

: 이러한 것과 같은 에시는 @ModelAttribute @PathVariable에서도 볼 수 있다.

10.1 타입 컨버터

: 스프링에 추가적인 타입 변환이 필요할 경우 컨버터 인터페이스를 사용하여 활용한다.

import org.springframework.core.convert.converter.Converter;
// 컨버터는 종류가 많음으로 주의

@Slf4j
public class StringToIntegerConverter implements Converter<String, Integer> {
    @Override
    public Integer convert(String source) {
        log.info("convert source={}", source);
        return Integer.valueOf(source);
    }
}

: 위와 같이 컨버터를 만들고, 테스트하면 정상 동작함을 알 수 있다.
: 여기서 나아가 IP, Port를 입력하면 IpPort객체로 변환하는 컨버터를 만들어보자
: 하나하나 직접 찾아서 쓰는 것이 불편하다.

10.1.1 컨버전 서비스

: 개별 컨버터를 모아두고 묶어서 편리하게 사용할 수 있는 기능을 제공한다.
: 컨버팅 할수 있는가와 컨버팅을 해주는 두 가지 기능을 제공한다.
: 아래와 같이 만든 컨버터를 등록하고 사용만 하면 된다.

        //등록
        DefaultConversionService conversionService = new DefaultConversionService();
        conversionService.addConverter(new StringToIntegerConverter());
        conversionService.addConverter(new IntegerToStringConverter());
        conversionService.addConverter(new StringToIpPortConverter());
        conversionService.addConverter(new IpPortToStringConverter());
        //사용
        assertThat(conversionService.convert("10", Integer.class)).isEqualTo(10);
        assertThat(conversionService.convert(10, String.class)).isEqualTo("10");
        IpPort ipPort = conversionService.convert("127.0.0.1:8080", IpPort.class);
        assertThat(ipPort).isEqualTo(new IpPort("127.0.0.1", 8080));
        String ipPortString = conversionService.convert(new IpPort("127.0.0.1", 8080), String.class);
        assertThat(ipPortString).isEqualTo("127.0.0.1:8080");

: 이렇게 등록과 사용이 잘 분리된 것을 인터페이스 분리 원칙(ISP)를 잘 지켰다고 한다.

10.1.2 스프링에 컨버터를 적용하기

: 스프링은 내부에서 ConversionService를 제공한다.
: WebMvcConfigurer 가 제공하는 addFormatters() 를 사용해서 추가하고 싶은 컨버터를 등록하면 된다.
: 코드는 아래와 같다.

@Configuration
public class WebConfig implements WebMvcConfigurer {
    @Override
    public void addFormatters(FormatterRegistry registry) {
        registry.addConverter(new StringToIntegerConverter());
        registry.addConverter(new IntegerToStringConverter());
        registry.addConverter(new StringToIpPortConverter());
        registry.addConverter(new IpPortToStringConverter());
    }
}
@GetMapping("/ip-port")
public String ipPort(@RequestParam IpPort ipPort) {
 System.out.println("ipPort IP = " + ipPort.getIp());
 System.out.println("ipPort PORT = " + ipPort.getPort());
 return "ok";
}

10.1.3 뷰 템플릿에 컨버터 적용하기

: 타임리프에서는 {}가 두 개 있면 자동으로 적용한다.

    <li>${number}: <span th:text="${number}" ></span></li>
<!--    하나는 컨버터를 적용하지 않음-->
    <li>${{number}}: <span th:text="${{number}}" ></span></li>
<!--    타임리프에서 자동으로 두개는 적용함-->
    <li>${ipPort}: <span th:text="${ipPort}" ></span></li>
    <li>${{ipPort}}: <span th:text="${{ipPort}}" ></span></li>
  • GET /converter/edit
    th:field 가 자동으로 컨버전 서비스를 적용해주어서 ${{ipPort}} 처럼 적용이 되었다. 따라서 IpPort String 으로 변환된다.

  • POST /converter/edit
    @ModelAttribute 를 사용해서 String IpPort 로 변환된다

10.2 포맷터

: 객체를 특정한 포멧에 맞추어 문자로 출력하거나 또는 그 반대의 역할을 하는 것에 특화된 기능이
바로 포맷터( Formatter )이며 컨버터의 특화된 기능이다.
: 객체를 문자로 변경하고 문자를 객체로 변경하는 두 가지 기능을 모두 수행한다

String print(T object, Locale locale) : 객체를 문자로 변경한다.
T parse(String text, Locale locale) : 문자를 객체로 변경한다.

FormattingConversionService

: 포맷터를 지원하는 컨버전 서비스이다.

@Test
    void formattingConversionService() {
        DefaultFormattingConversionService conversionService = new DefaultFormattingConversionService();
        //컨버터 등록
        conversionService.addConverter(new StringToIpPortConverter());
        conversionService.addConverter(new IpPortToStringConverter());
        //포맷터 등록
        conversionService.addFormatter(new MyNumberFormatter());
        //컨버터 사용
        IpPort ipPort = conversionService.convert("127.0.0.1:8080", IpPort.class);
        assertThat(ipPort).isEqualTo(new IpPort("127.0.0.1", 8080));
        //포맷터 사용
        assertThat(conversionService.convert(1000, String.class)).isEqualTo("1,000");
        assertThat(conversionService.convert("1,000", Long.class)).isEqualTo(1000L);
    }

스프링이 지원하는 기본 포맷터

: 객체의 각 필드마다 다른 형식으로 포맷을 지정하기는 어렵다 이래서 아래의 두 가지를 제공한다.

@NumberFormat : 숫자 관련 형식 지정 포맷터 사용, NumberFormatAnnotationFormatterFactory
@DateTimeFormat : 날짜 관련 형식 지정 포맷터 사용, Jsr310DateTimeFormatAnnotationFormatterFactory


@Controller
public class FormatterController {
    @GetMapping("/formatter/edit")
    public String formatterForm(Model model) {
        Form form = new Form();
        form.setNumber(10000);
        form.setLocalDateTime(LocalDateTime.now());
        model.addAttribute("form", form);
        return "formatter-form";
    }
    @PostMapping("/formatter/edit")
    public String formatterEdit(@ModelAttribute Form form) {
        return "formatter-view";
    }
    @Data
    static class Form {
        @NumberFormat(pattern = "###,###")
        private Integer number;
        @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
        private LocalDateTime localDateTime;
    }
}

: 위와 같이 컨트롤러를 만들고 폼을 만들어 호출하면, 변환이 잘 되는 것을 확인할수 있다.
: 다만, HttpMessageConverter에는 컨버젼 서비스가 적용되지 않는다.

11. 파일 업로드

: HTML 폼을 통한 파일 업로드를 이해하려면 먼저 폼을 전송하는 두 가지 방식의 차이를 이해해야한다.

  1. application/x-www-form-urlencoded
  2. multipart/form-data

A. application/x-www-form-urlencoded

: 가장 기본적인 방법이다.
: 폼에 전송할 항목을 HTTP Body에 문자로 &로 구분해서 전송한다.
: 다만 이 방식을 사용하면 문자와 바이너리 두 개를 동시에 저장해야 하기 때문에 아래의 방식을 사용한다.

B. multipart/form-data

: 다른 종류의 여러 파일과 폼의 내용을 함께 전송할 수 있다.
: 각각의 항목을 구분해서 한번에 전송하는 것이다.
: A와 구분했을때 더욱 복잡하다.

11.1 서블릿과 파일 업로드

: 다음과 같이 서블릿을 통해 파일 업로드를 한다.
: doDispatch 로직이 중요

@Slf4j
@Controller
@RequestMapping("/servlet/v1")
public class ServletUploadControllerV1 {
    @GetMapping("/upload")
    public String newFile() {
        return "upload-form";
    }
    @PostMapping("/upload")
    public String saveFileV1(HttpServletRequest request) throws
            ServletException, IOException {
        log.info("request={}", request);
        String itemName = request.getParameter("itemName");
        log.info("itemName={}", itemName);
        Collection<Part> parts = request.getParts();
        // multipart/form-data방식에서 각각 나누어진 부분을 받아 확인할 수 있다. 
        log.info("parts={}", parts);
        return "upload-form";
    }
}

logging.level.org.apache.coyote.http11=debug
: 옵션을 통해 HTTP 요청 메시지를 확인할 수 있다.

spring.servlet.multipart.max-file-size=1MB
spring.servlet.multipart.max-request-size=10MB
: 옵션을 통해 파일 하나의 최대 사이즈, 파일들의 총합 사이즈를 정의할 수 있다.

spring.servlet.multipart.enabled=false
: 이 옵션을 통해 스프링 부트는 서블릿 컨테이너에게 멀티파트 데이터를 처리하라고 설정한다.
: 복잡한 멀티파트 요청을 처리해서 사용할 수 있게 제공한다.

: 파일을 업로드를 하려면 실제 파일이 저장되는 경로가 필요하다.

iter + enter 하면 가장 가까이 있는거 loop 돌릴 수 있다.


@Slf4j
@Controller
@RequestMapping("/servlet/v2")
public class ServletUploadControllerV2 {
    @Value("${file.dir}")
    private String fileDir;

    //spring의 value 사용해야 함
    @GetMapping("/upload")
    public String newFile() {
        return "upload-form";
    }

    @PostMapping("/upload")
    public String saveFileV1(HttpServletRequest request) throws ServletException, IOException {
        log.info("request={}", request);
        String itemName = request.getParameter("itemName");
        log.info("itemName={}", itemName);
        Collection<Part> parts = request.getParts();
        log.info("parts={}", parts);

        for (Part part : parts) {
            log.info("==== PART ====");
            log.info("name={}", part.getName());
            // Parts의 헤더와 바디 구분
            Collection<String> headerNames = part.getHeaderNames();
            for (String headerName : headerNames) {
                log.info(headerName, part.getHeader(headerName));
            }
            // 편의 메서드
            // content-dispositon, file-name
            log.info(part.getSubmittedFileName());
            log.info("size={}", part.getSize());
            // part body size

            // 데이터 읽기
            InputStream inputStream = part.getInputStream();
            String body = StreamUtils.copyToString(inputStream, StandardCharsets.UTF_8);
            // 바디 읽은걸 String으로
            // 바이너리와 문자간의 변경에는 char 제공해줘야함
            log.info(body);
            if (StringUtils.hasText(part.getSubmittedFileName())) {
                String fullPath = fileDir + part.getSubmittedFileName();
                log.info("파일 저장 fullPath={}", fullPath);
                part.write(fullPath);
                // 편리하게 저장 가능
            }
        }

        return "upload-form";
    }
}

경로 지정 시 주의사항

: 경로는 아래와 같이 '/'으로 시작하고 끝내야 한다.

file.dir=/C:/Users/wrjan/Desktop/Programming/inflearn backend study/9. SpringMvc 2/saving/

11.2 스프링의 파일 업로드

: MultipartFile 이라는 인터페이스로 멀티파트 파일을 매우 편리하게 지원한다.
: 주요 메소드는 다음과 같다.

file.getOriginalFilename() : 업로드 파일 명
file.transferTo(...) : 파일 저장


@RequestMapping("/spring")
public class SpringUploadController {
    @Value("${file.dir}")
    private String fileDir;
    @GetMapping("/upload")
    public String newFile() {
        return "upload-form";
    }
    @PostMapping("/upload")
    public String saveFile(@RequestParam String itemName, @RequestParam MultipartFile file, HttpServletRequest request) throws IOException {
        log.info("request={}", request);
        log.info("itemName={}", itemName);
        log.info("multipartFile={}", file);
        if (!file.isEmpty()) {
            String fullPath = fileDir + file.getOriginalFilename();
            log.info("파일 저장 fullPath={}", fullPath);
            file.transferTo(new File(fullPath));
        }
        return "upload-form";
    }
}

: 업로드하는 HTML Form의 name에 맞추어 @RequestParam 을 적용하면 된다.
: 서블릿보다 훨씬더 간편해진 것을 알 수 있다.

파일 업로드 실제 예제

: Item 도메인 객체, 리포지토리, 업로드 파일정보 보관을 만든다.
: 이 때, 파일명이 겹치지 않도록 관리가 필요하다.
: 파일 저장과 관련된 업무 처리를 위해 FileStore파일을 만든다.

@Data
public class Item {
    private Long id;
    private String itemName;
    private UploadFile attachFile;
    private List<UploadFile> imageFiles;
    // 이미지 같은 경우는 여러개의 파일을 업로드 할 수 있어야 함
}

: 이름을 구분해서 파일을 업로드하였다.


@Data
public class UploadFile {
    private String uploadFileName;
    private String storeFileName;
    // 내부에서의 이미지는 안겹치게 만들어야 함
    public UploadFile(String uploadFileName, String storeFileName) {
        this.uploadFileName = uploadFileName;
        this.storeFileName = storeFileName;
    }

}

: 저장과 관련된 코드는 아래와 같다.

@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));
                // storeFile을 loop를 돌며서 시행한다.
            }
        }
        return storeFileResult;
    }
    // 하나를 업로드
    public UploadFile storeFile(MultipartFile multipartFile) throws IOException {
        // 멀티파트 파일을 받아서 uploadfile로 변환해줌
        if(multipartFile.isEmpty()){
            return null;
        }
        String originalFileName = multipartFile.getOriginalFilename();
        String storeFileName = createStoreFileName(originalFileName);
        // image.png가 들어오면 서버에 저장하는 파일명을 UUID로 만들어준다. 다만 확장자는 가져오고 싶다
        // 서버에 저장하는 파일명
        multipartFile.transferTo(new File(getFullPath(storeFileName)));
        return new UploadFile(originalFileName, storeFileName);

    }
    private String createStoreFileName(String originalFilename) {
        String ext = extractExt(originalFilename);
        String uuid = UUID.randomUUID().toString();
        return uuid + "." + ext;
    }

    private String extractExt(String originalFileName) {
        // 확장자 추출을 위한 메소드
        int pos = originalFileName.lastIndexOf(".");
        return originalFileName.substring(pos + 1);
    }

}

: 컨트롤러의 코드는 아래와 같다.

@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 {
        UploadFile attachFile = fileStore.storeFile(form.getAttachFile());
        List<UploadFile> storeImageFiles = fileStore.storeFiles(form.getImageFiles());
        //데이터베이스에 저장
        Item item = new Item();
        item.setItemName(form.getItemName());
        item.setAttachFile(attachFile);
        item.setImageFiles(storeImageFiles);
        itemRepository.save(item);
        redirectAttributes.addAttribute("itemId", item.getId());
        return "redirect:/items/{itemId}";
    }
    // 상품을 보여준다.
    @GetMapping("/items/{id}")
    public String items(@PathVariable Long id, Model model) {
        Item item = itemRepository.findById(id);
        model.addAttribute("item", item);
        return "item-view";
    }
    @ResponseBody
    @GetMapping("/images/{filename}")
    // <img> 태그로 이미지를 조회할 때 사용된다. UrlResurce로 읽고, @ResponseBody로 이미지 바이너리를 반환한다.
    public Resource downloadImage(@PathVariable String filename) throws MalformedURLException {
        return new UrlResource("file:" + fileStore.getFullPath(filename));
        // 파일에 직접 접근해서 리소스 가져옴 
    }

    @GetMapping("/attach/{itemId}")
    // 파일 다운로드시 권한체크를 한다. 고객이 업로드한 파일 이름으로 다운로드 한다.
    public ResponseEntity<Resource> downloadAttach(@PathVariable Long itemId) throws MalformedURLException {
        Item item = itemRepository.findById(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);
    }


}

: 하나의 첨부파일을 업로드 다운로드 하고 여러개의 이미지를 업로드 할 수 있다.
: 이미지 업로드와 다운로드시 파일명을 다르게 해서 관리하는 점, 이미지 보여주는 경로도 별도로 관리해야한다는 점, 이미지 파일명을 관리해야하는 점 등을 주의해야한다.

0개의 댓글