728x90
반응형
SMALL

참고자료

 

스프링 MVC 2편 - 백엔드 웹 개발 활용 기술 강의 | 김영한 - 인프런

김영한 | 웹 애플리케이션 개발에 필요한 모든 웹 기술을 기초부터 이해하고, 완성할 수 있습니다. MVC 2편에서는 MVC 1편의 핵심 원리와 구조 위에 실무 웹 개발에 필요한 모든 활용 기술들을 학습

www.inflearn.com

 

사용자 로그인은 웹 애플리케이션이라면 무조건 있는 기능이다. 그래서 반드시 알고 배워야 한다.

우선, 로그인 처리를 하기 앞서 기본적인 폼이랑 컨트롤러가 필요하다. 실제 서비스에선 당연히 회원 유저에 대한 데이터베이스도 필요하지만 여기서는 데이터베이스 대신 메모리로 처리를 할 것이다. 그게 중요한 게 아니니까.

 

Member, MemberRepository

Member

package hello.login.domain.member;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

import javax.validation.constraints.NotEmpty;

@Data
@AllArgsConstructor
@NoArgsConstructor
public class Member {

    private Long id;

    @NotEmpty
    private String loginId;
    @NotEmpty
    private String password;
    @NotEmpty
    private String name;

    public Member(String loginId, String password, String name) {
        this.loginId = loginId;
        this.password = password;
        this.name = name;
    }
}

MemberRepository

package hello.login.domain.member;

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

import java.util.*;

@Slf4j
@Repository
public class MemberRepository {

    private static final Map<Long, Member> store = new HashMap<>();
    private static long sequence = 0L;

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

    public Member findById(Long id) {
        return store.get(id);
    }

    public Optional<Member> findByLoginId(String loginId) {
        return findAll().stream().filter(member -> member.getLoginId().equals(loginId)).findFirst();
    }

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

    public void clear() {
        store.clear();
    }
}
  • 회원 정보에 대한 모델인 Member, 회원 정보에 대한 CRUD에 사용될 MemberRepository를 작성했다.

MemberController

MemberController

package hello.login.web.member;

import hello.login.domain.member.Member;
import hello.login.domain.member.MemberRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Controller;
import org.springframework.validation.BindingResult;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;

@Controller
@RequiredArgsConstructor
@RequestMapping("/members")
public class MemberController {

    private final MemberRepository memberRepository;

    @GetMapping("/add")
    public String addForm(@ModelAttribute("member") Member member) {
        return "members/addMemberForm";
    }

    @PostMapping("/add")
    public String save(@Validated @ModelAttribute Member member, BindingResult bindingResult) {
        if (bindingResult.hasErrors()) {
            return "members/addMemberForm";
        }

        memberRepository.save(member);
        return "redirect:/";
    }
}
  • 로그인을 하려면 회원 가입이 필요하다. 회원 가입을 위한 컨트롤러를 만들었다. 저번 시간에 배운 검증하는 방법을 그대로 사용해서 @Validated, BindingResult를 사용했다. 그리고 Bean Validation으로 Member 클래스에 애노테이션을 달았다. 
  • 사실 회원가입도 데이터 검증 이후에 데이터베이스를 조회해서 해당 회원이 이미 있는지 확인하는 로직이 필요하지만 그 부분은생략했다. 그리고 검증에 성공하면 바로 회원을 저장하는 코드를 호출한다. 
  • 검증 시 에러가 발생하면 다시 로그인 폼을 사용자에게 보여주고 어떤 에러가 발생했는지 보여준다.

resources/templates/members/addMemberForm.html

<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="utf-8">
    <link th:href="@{/css/bootstrap.min.css}"
          href="../css/bootstrap.min.css" rel="stylesheet">
    <style>
        .container {
            max-width: 560px;
        }
        .field-error {
            border-color: #dc3545;
            color: #dc3545;
        }
    </style>
</head>
<body>

<div class="container">

    <div class="py-5 text-center">
        <h2>회원 가입</h2>
    </div>

    <h4 class="mb-3">회원 정보 입력</h4>

    <form action="" th:action th:object="${member}" method="post">

        <div th:if="${#fields.hasGlobalErrors()}">
            <p class="field-error" th:each="err : ${#fields.globalErrors()}" th:text="${err}">전체 오류 메시지</p>
        </div>

        <div>
            <label for="loginId">로그인 ID</label>
            <input type="text" id="loginId" th:field="*{loginId}" class="form-control"
                   th:errorclass="field-error">
            <div class="field-error" th:errors="*{loginId}"></div>
        </div>
        <div>
            <label for="password">비밀번호</label>
            <input type="password" id="password" th:field="*{password}" class="form-control"
                   th:errorclass="field-error">
            <div class="field-error" th:errors="*{password}"></div>
        </div>
        <div>
            <label for="name">이름</label>
            <input type="text" id="name" th:field="*{name}" class="form-control"
                   th:errorclass="field-error">
            <div class="field-error" th:errors="*{name}"></div>
        </div>


        <hr class="my-4">

        <div class="row">
            <div class="col">
                <button class="w-100 btn btn-primary btn-lg" type="submit">회원 가입</button>
            </div>
            <div class="col">
                <button class="w-100 btn btn-secondary btn-lg" onclick="location.href='items.html'"
                        th:onclick="|location.href='@{/}'|"
                        type="button">취소</button>
            </div>
        </div>

    </form>

</div> <!-- /container -->
</body>
</html>

 

LoginService, LoginController, LoginForm

LoginService

package hello.login.domain.login;

import hello.login.domain.member.Member;
import hello.login.domain.member.MemberRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
public class LoginService {

    private final MemberRepository memberRepository;

    public Member login(String loginId, String password) {
        return memberRepository.findByLoginId(loginId)
                .filter(member -> member.getPassword().equals(password))
                .orElse(null);
    }
}
  • 로그인 시도를 하는 메서드인 login(String loginId, String password)에서는 MemberRepository를 통해 loginId로 유저를 찾고 찾은 유저의 패스워드가 전달받은 password와 일치하다면 그 유저를 반환한다. 찾지 못했다면 null을 반환한다. 
  • Stream API를 사용했다. Stream은 이제 자바 코드를 작성하면서 거의 필수불가결한 요소이다.

LoginController

package hello.login.web.login;

import hello.login.domain.login.LoginService;
import hello.login.domain.member.Member;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Controller;
import org.springframework.validation.BindingResult;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;

@Slf4j
@Controller
@RequiredArgsConstructor
public class LoginController {

    private final LoginService loginService;

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

    @PostMapping("/login")
    public String loginForm(@Validated @ModelAttribute LoginForm loginForm, BindingResult bindingResult) {
        if (bindingResult.hasErrors()) {
            return "login/loginForm";
        }

        Member loginMember = loginService.login(loginForm.getLoginId(), loginForm.getPassword());
        if (loginMember == null) {
            bindingResult.reject("loginFail", "아이디 또는 비밀번호가 맞지 않습니다.");
            return "login/loginForm";
        }

        // TODO: 로그인 성공 처리

        return "redirect:/";
    }
}
  • 로그인 폼을 보여주는 GetMapping과 로그인 정보를 입력하고 요청하는 PostMapping 두 가지가 있다. 
  • 마찬가지로 저번 시간에 배운 Bean Validation을 통해 데이터 검증 처리를 한다. 검증에 실패하면 다시 로그인 화면으로 사용자를 보낸다. 
  • 검증에 성공하면, 로그인 서비스를 통해 유저를 찾는다. 만약 찾은 유저가 null이라면, BindingResult에 로그인 실패 에러를 ObjectError로 담아 다시 로그인 폼을 보여준다.
  • 찾은 유저가 있다면 로그인 성공 처리를 해줘야 한다. 그 부분은 아직 남겨두었다.
  • 그리고 홈 화면으로 리다이렉트 시킨다.

LoginForm

package hello.login.web.login;

import lombok.Data;

import javax.validation.constraints.NotEmpty;

@Data
public class LoginForm {
    @NotEmpty
    private String loginId;
    @NotEmpty
    private String password;
}
  • 로그인 컨트롤러에서 사용자의 입력값에 대한 DTO 클래스이다.

resources/templates/login/loginForm.html

<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="utf-8">
    <link th:href="@{/css/bootstrap.min.css}"
          href="../css/bootstrap.min.css" rel="stylesheet">
    <style>
        .container {
            max-width: 560px;
        }
        .field-error {
            border-color: #dc3545;
            color: #dc3545;
        }
    </style>
</head>
<body>

<div class="container">

    <div class="py-5 text-center">
        <h2>로그인</h2>
    </div>

    <form action="item.html" th:action th:object="${loginForm}" method="post">

        <div th:if="${#fields.hasGlobalErrors()}">
            <p class="field-error" th:each="err : ${#fields.globalErrors()}" th:text="${err}">전체 오류 메시지</p>
        </div>

        <div>
            <label for="loginId">로그인 ID</label>
            <input type="text" id="loginId" th:field="*{loginId}" class="form-control"
                   th:errorclass="field-error">
            <div class="field-error" th:errors="*{loginId}"></div>
        </div>
        <div>
            <label for="password">비밀번호</label>
            <input type="password" id="password" th:field="*{password}" class="form-control"
                   th:errorclass="field-error">
            <div class="field-error" th:errors="*{password}"></div>
        </div>

        <hr class="my-4">

        <div class="row">
            <div class="col">
                <button class="w-100 btn btn-primary btn-lg" type="submit">로그인</button>
            </div>
            <div class="col">
                <button class="w-100 btn btn-secondary btn-lg" onclick="location.href='items.html'"
                        th:onclick="|location.href='@{/}'|"
                        type="button">취소</button>
            </div>
        </div>

    </form>

</div> <!-- /container -->
</body>
</html>

 

여기까지가 이제 가장 기본적으로 필요한 구성들이다. 이제 하나씩 로그인 처리를 알아가보자!

 

쿠키를 사용하여 로그인 처리하기

결론부터 말하면 이 방식은 사용하면 안된다. 하지만 이 과정을 먼저 진행해보면서 왜 사용하면 안되고 어떤 방식이 이를 해결하는지 이해해보자. 쿠키는 브라우저와 서버간 상태를 유지할 수 있는 방법 중 하나이다. HTTP는 기본이 Stateless이다. 즉, 상태를 저장하지 않는다는 의미이다. 그러면 사용자가 로그인을 했을때 이 사용자가 누구인지 서버가 인증/인가를 하려면 어딘가 사용자가 제공한 정보를 보관해야 한다. 그 방법이 쿠키라는 것이다. 

 

쿠키의 방식

그래서 서버에서 로그인에 성공하면 응답을 돌려줄 때 HTTP 응답에 쿠키를 담아 전달한다. 그럼 사용자는 브라우저에 해당 URL 관련된 모든 사이트에 쿠키를 보관하여 어떤 요청을 할 때마다 서버에 이 쿠키를 같이 담아 요청한다.

 

로그인 시도

  • 사용자가 로그인한다.
  • 로그인에 성공하면 서버는 쿠키를 만들어 사용자에게 응답에 전달한다.
  • 전달받은 쿠키를 브라우저에 저장한다.

이후 클라이언트가 서버에 쿠키를 지속적으로 전달

  • 저장된 쿠키를 동일한 사이트에 요청을 할 때 지속적으로 서버에 쿠키를 전송한다. 
  • 서버에서는 받은 쿠키를 통해 사용자를 인증/인가처리 한다.

 

쿠키의 종류

  • 영속 쿠키: 만료 날짜를 입력하면 그게 영속 쿠키이고, 해당 날짜까지 유지가 된다.
  • 세션 쿠키: 만료 날짜를 생략하면 그게 세션 쿠키이고, 브라우저 종료시 까지만 유지된다.
참고로, 세션 쿠키는 HTTP 세션이 아니다. 세션 쿠키는 쿠키 종류이고 이름이다.

 

쿠키를 사용해 로그인 처리

그럼 이제 로그인 성공 시 쿠키를 생성해서 응답에 같이 내보내자.

@PostMapping("/login")
public String loginForm(@Validated @ModelAttribute LoginForm loginForm,
                        BindingResult bindingResult,
                        HttpServletResponse res) {
    if (bindingResult.hasErrors()) {
        return "login/loginForm";
    }

    Member loginMember = loginService.login(loginForm.getLoginId(), loginForm.getPassword());
    if (loginMember == null) {
        bindingResult.reject("loginFail", "아이디 또는 비밀번호가 맞지 않습니다.");
        return "login/loginForm";
    }

    Cookie idCookie = new Cookie("memberId", String.valueOf(loginMember.getId()));
    res.addCookie(idCookie); // 쿠키에 시간을 주지 않으면 브라우저 종료 시 쿠키가 삭제 = 세션 쿠키 (세션 쿠키는 HTTP 세션이랑 다른 개념)

    return "redirect:/";
}
  • 로그인에 성공한 다음, 쿠키를 new Cookie()로 생성한다.
  • 쿠키는 Key/Value 한 쌍이다. 그래서 Key`memberId`로, Value는 로그인 한 유저의 ID를 넣었다. (idloginId와 다른 것)
  • HttpServletResponse 객체에 addCookie() 메서드로 쿠키를 추가한다.

이 상태로 서버를 다시 실행해서 로그인을 시도해보자. 그리고 브라우저의 Network창을 켜두고 확인해보자.

로그인 시도 후 돌아온 응답의 Headers를 살펴보자. Set-Cookie: memberId=1 이라는 값이 들어있다.

이제 이 상태에서 브라우저를 새로고침하거나 허용된 path에 요청을 보내면 항상 쿠키가 들어가 있다.

위 사진처럼, 같은 서버에 요청을 날리면 Request HeadersCookiememberId=1 이라는 쿠키가 넘어가고 있음을 확인 가능하다.

이제 서버는 이 요청 헤더에 있는 쿠키를 꺼내서 서버와 클라이언트 간 약속에 의해 이 memberId=x라는 값이 있는지 확인을 하는 것이다.

 

 

쿠키의 로그인 정보 유지와 로그아웃 기능 

그럼 이제, 로그인에 성공한 경우에는 아래와 같은 화면을 보여주자. 로그인 한 유저의 이름을 보여주면서 로그인 유지가 되는 모습이다. (loginHome.html)

로그인이 되지 않은 경우 다음과 같은 아래 화면을 보여주자. (home.html)

resources/templates/home.html

<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="utf-8">
    <link th:href="@{/css/bootstrap.min.css}"
          href="../css/bootstrap.min.css" rel="stylesheet">
</head>
<body>

<div class="container" style="max-width: 600px">
    <div class="py-5 text-center">
        <h2>홈 화면</h2>
    </div>

    <div class="row">
        <div class="col">
            <button class="w-100 btn btn-secondary btn-lg" type="button"
                    th:onclick="|location.href='@{/members/add}'|">
                회원 가입
            </button>
        </div>
        <div class="col">
            <button class="w-100 btn btn-dark btn-lg" onclick="location.href='items.html'"
                    th:onclick="|location.href='@{/login}'|" type="button">
                로그인
            </button>
        </div>
    </div>

    <hr class="my-4">

</div> <!-- /container -->

</body>
</html>

resources/templates/loginHome.html

<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="utf-8">
    <link th:href="@{/css/bootstrap.min.css}"
          href="../css/bootstrap.min.css" rel="stylesheet">
</head>
<body>

<div class="container" style="max-width: 600px">
    <div class="py-5 text-center">
        <h2>홈 화면</h2>
    </div>

    <h4 class="mb-3" th:text="|로그인: ${member.name}|">로그인 사용자 이름</h4>

    <hr class="my-4">

    <div class="row">
        <div class="col">
            <button class="w-100 btn btn-secondary btn-lg" type="button"
                    th:onclick="|location.href='@{/items}'|">
                상품 관리
            </button>
        </div>
        <div class="col">
            <form th:action="@{/logout}" method="post">
                <button class="w-100 btn btn-dark btn-lg" onclick="location.href='items.html'" type="submit">
                    로그아웃
                </button>
            </form>
        </div>
    </div>

    <hr class="my-4">

</div> <!-- /container -->

</body>
</html>

 

HomeController

package hello.login.web;

import hello.login.domain.member.Member;
import hello.login.domain.member.MemberRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.CookieValue;
import org.springframework.web.bind.annotation.GetMapping;

@Slf4j
@Controller
@RequiredArgsConstructor
public class HomeController {

    private final MemberRepository memberRepository;

    @GetMapping("/")
    public String home(@CookieValue(name = "memberId", required = false) Long memberId, Model model) {
        if (memberId == null) {
            return "home";
        }

        Member member = memberRepository.findById(memberId);
        if (member == null) {
            return "home";
        }

        model.addAttribute("member", member);
        return "loginHome";
    }
}
  • 화면 중 하나인 "/" 경로의 홈 화면이다.
  • 우선, 로그인 여부를 확인하기 위해 쿠키로부터 `memberId` 라는 키를 가진 쿠키를 가져온다. 그 방법은 여러가지가 있지만 스프링이 지원해주는 @CookieValue를 사용하자. 
  • 우선 첫번째로, 아예 쿠키값이 없는 경우엔 그냥 home 화면으로 보낸다. 
  • 쿠키값이 있는 경우, 쿠키값을 찾았다고 끝이 아니다. 쿠키값을 통해 회원을 찾아서 해당 회원이 실제로 존재하는 회원인지 확인해야 한다. MemberRepository를 사용해 멤버를 찾는다.
  • 멤버를 찾지 못했을 경우 다시 그냥 home 화면으로 보낸다.
  • 멤버를 찾은 경우 Model에 멤버 객체를 담아 loginHome 화면으로 보낸다.

위 방식처럼 사용자가 로그인 됐는지 아닌지를 확인하면 된다. 그럼 이제 로그아웃 기능을 구현해보자.

쿠키를 통해 로그인 처리를 했을 때 로그아웃 하는 방법은 그냥 쿠키 시간을 없애버리면 된다.

@PostMapping("/logout")
public String logout(HttpServletResponse res) {
    expireCookie(res, "memberId");
    return "redirect:/";
}

private static void expireCookie(HttpServletResponse res, String cookieName) {
    Cookie cookie = new Cookie(cookieName, null);
    cookie.setMaxAge(0);
    res.addCookie(cookie);
}
  • 삭제하고자 하는 쿠키를 생성해서 setMaxAge(0)을 호출한다.
  • 해당 쿠키를 응답에 담는다.

로그아웃 버튼을 클릭하면 저 "/logout" 경로로 POST 요청을 날리게 하면 된다. 아래처럼.

<form th:action="@{/logout}" method="post">
    <button class="w-100 btn btn-dark btn-lg" onclick="location.href='items.html'" type="submit">
        로그아웃
    </button>
</form>

 

굉장히 간단하게 로그인과 로그아웃 기능을 쿠키를 사용해 구현해봤다. 그러나, 이 쿠키를 사용한 방식은 심각한 보안 문제가 있다. 그래서 쿠키를 사용해서 절대로 로그인 기능을 구현하지 않는다. 그 이유가 뭘까?

 

쿠키의 보안 문제

  • 쿠키값은 임의로 변경을 할 수 있다. 
    • 클라이언트가 쿠키를 강제로 변경이 가능하고 변경하면 다른 사용자가 된다. 예) `memberId=1``memberId=2`로 변경
  • 쿠키에 보관된 정보는 훔쳐갈 수 있다.
    • memberId도 굉장히 민감한 정보로 그대로 노출되어 있지만, 더 민감한 정보를 담았다면? (예: 주민등록번호, 신용카드 정보)그대로 훔쳐갈 수 있다.
  • 헤커가 쿠키를 한번 훔쳐가면 그 쿠키로 지속적으로 악의적인 요청을 할 수 있다.
    • 서버에서 원하는 시점에 해당 쿠키를 강제로 끊어버릴 수 없기 때문에 브라우저가 종료되기 전 또는 쿠키의 시간이 만료되기 전까지 모든짓을 다 할 수 있다.

 

대안은 이렇다.

  • 쿠키엔 중요한 값을 노출시키면 안된다. 사용자 별 예측 불가능한 임의의 토큰값을 노출하고 서버에서 토큰과 사용자 ID를 매핑해서 인식한다. 그리고 서버에서 토큰을 관리한다.
  • 토큰은 해커가 임의의 값을 넣어도 찾을 수 없도록 예상 불가능해야 한다.
  • 해커가 토큰을 털어가도 시간이 지나면 사용할 수 없도록 서버에서 해당 토큰의 만료시간을 짧게 유지하게 하고, 해킹이 의심되는 경우 서버에서 토큰을 강제로 제거해서 토큰 무효화를 할 수 있어야 한다. 

 

세션을 사용하여 로그인 처리하기

위 쿠키의 문제를 한방에 해결하는 방법이 바로 이 '세션'이다. 그리고 실제로 웹 애플리케이션 서비스들은 대부분 이 세션을 사용한다.

세션을 사용해서 어떻게 위 문제를 해결한다는 걸까?

 

세션을 사용한 흐름

  • 사용자가 로그인을 한다.
  • 로그인 정보를 서버에서 받는다.

  • 로그인 정보를 통해 서버에서는 사용자를 찾는다.
  • 사용자를 찾은 경우, 그 누구도 이해할 수 없는 랜덤문자열을 생성해서 세션 저장소에 해당 회원 정보와 같이 저장한다.

  • 이 상태에서 사용자에게 응답에 쿠키로 방금 위에서 생성한 그 누구도 이해할 수 없는 랜덤문자열을 전송한다.
  • 그럼 이제 클라이언트는 쿠키로 저장하고 있는 값이 이 랜덤문자열이 된다. 

  • 사용자는 이제 해당 사이트에 어떤 요청을 하더라도 쿠키에 저장된 랜덤문자열을 서버로 전송한다.
  • 서버에서는 해당 문자열을 받아 세션 저장소에서 해당 문자열을 찾는다.
  • 찾았다면 그 값과 매핑된 회원 정보를 통해 현재 사용자가 누구인지 인증/인가한다.

 

이렇게 로그인을 구현하면 위 쿠키를 사용했을 때 문제점들이 모두 해결된다.

  • 쿠키 값을 변조 가능하다 → 예상 불가능한 임의의 랜덤문자열이기 때문에 변조가 불가능하다. 하더라도 그 값은 유효하지 않다.
  • 쿠키에 보관하는 정보는 클라이언트 해킹 시 털릴 가능성이 있다 → 털린 값은 랜덤문자열이기 때문에 민감한 정보가 하나도 없다. 
  • 쿠키 탈취 후 원하는만큼 계속해서 재사용 할 가능성이 있다 → 해커가 이 토큰을 훔쳐가더라도 유효 시간을 짧게 설정하여 지속적으로 재사용 가능하지 못하도록 막고, 해킹 의심시에는 서버에서 언제든지 이 토큰을 삭제해버릴 수 있다.

이게 바로 세션이라는 방식을 사용한 로그인 처리 기법이다. 이제 세션을 사용해서 로그인 처리할 수 있도록 코드를 변경해보자.

 

세션 사용하여 로그인 처리 - 세션 직접 만들기

당연히 세션을 지원한다. 그러나, 한번 직접 만들어보는 것부터 해보자. 세션의 기능은 딱 3가지가 필요하다.

  • 세션 생성
    • 임의의 랜덤 값 생성
    • 세션 저장소에 값 저장
    • 해당 랜덤 값을 응답 쿠키로 생성해서 클라이언트에 전달
  • 세션 조회
    • 클라이언트가 요청 시 전달한 쿠키에 있는 랜덤값을 통해 세션 저장소에서 보관한 값 조회
  • 세션 만료
    • 클라이언트가 요청 시 전달하는 쿠키의 값을 세션 저장소에서 삭제

 

SessionManager

package hello.login.web.session;

import org.springframework.stereotype.Component;

import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.Arrays;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;

@Component
public class SessionManager {

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

    public void createSession(Object value, HttpServletResponse response) {
        // 세션 ID 생성
        String sessionId = UUID.randomUUID().toString();
        sessionStore.put(sessionId, value);

        // 쿠키 생성
        Cookie mySessionCookie = new Cookie(SESSION_COOKIE_NAME, sessionId);
        response.addCookie(mySessionCookie);
    }

    public Object getSession(HttpServletRequest request) {
        Cookie findCookie = Arrays
                .stream(request.getCookies())
                .filter(cookie -> SESSION_COOKIE_NAME.equals(cookie.getName()))
                .findFirst()
                .orElse(null);

        if (findCookie == null) {
            return null;
        }
        // findCookie.getValue() => UUID
        return sessionStore.get(findCookie.getValue());
    }

    public void expireSession(HttpServletRequest request) {
        Arrays
                .stream(request.getCookies())
                .filter(cookie -> SESSION_COOKIE_NAME.equals(cookie.getName()))
                .findFirst()
                .ifPresent(cookie -> sessionStore.remove(cookie.getValue()));
    }
}
  • 세션 생성
    • 자바에서 기본으로 제공하는 UUID를 사용해서 랜덤값을 생성한다.
    • 해당 UUID값과 매핑될 값(멤버 객체)을 세션 보관소에 저장한다.
    • 사용자에게 쿠키로 UUID값을 전달하기 위해 쿠키를 만든다.
    • 응답에 해당 쿠키를 추가한다.
  • 세션 조회
    • 요청 헤더에서 쿠키를 가져와서 쿠키의 이름을 통해 UUID값을 찾는다.
    • 찾았으면 해당값으로 세션 보관소에서 찾아 반환한다. 여기서 반환되는 값은 매핑된 값(멤버 객체)이다.
    • 못찾았으면 null을 반환한다.
  • 세션 만료
    • 요청 헤더에서 쿠키를 가져와서 쿠키의 이름을 통해 서버에서 세션으로 사용되는 쿠키 객체를 찾는다.
    • 찾았다면 해당 쿠키로부터 값(UUID)을 가져와서 세션 저장소에서 날린다.

매우 간단하게, 직접 세션을 만들어보았다. 만든 세션 저장소가 제대로 작동하는지 테스트 코드를 짜보자.

SessionManagerTest

package hello.login.web.session;

import hello.login.domain.member.Member;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;

import static org.assertj.core.api.Assertions.*;

class SessionManagerTest {

    SessionManager sessionManager = new SessionManager();

    @Test
    void sessionTest() {
        // 세션 생성 
        MockHttpServletResponse response = new MockHttpServletResponse();
        Member member = new Member();
        sessionManager.createSession(member, response);

        // 요청 헤더에 쿠키 저장
        MockHttpServletRequest req = new MockHttpServletRequest();
        req.setCookies(response.getCookies());

        // 사용자 요청으로부터 세션 조회 -> 해당 세션은 위에서 만든 멤버와 일치해야 한다.
        Object value = sessionManager.getSession(req);
        assertThat(value).isEqualTo(member);

        // 세션 만료
        sessionManager.expireSession(req);
        Object expiredValue = sessionManager.getSession(req);
        assertThat(expiredValue).isNull();
    }
}
  • 실제로 사용자가 요청을 하고 그에 대한 응답을 하고 있다고 가정해보자.
  • 먼저 로그인을 사용자가 했다고 하면 해당 사용자와 세션 ID를 한 쌍으로 세션 저장소에 저장해야 한다.
  • 동일한 사용자가 새로운 요청을 했을 때, 해당 요청에는 세션 정보가 들어있고 그 세션 정보를 통해 조회한 사용자는 동일한 사용자여야 한다.

여기서는 테스트 코드를 작성할 때 실제로 요청과 응답을 주고 받는게 아니기 때문에 HttpServletRequest, HttpServletResponse를 사용할 수 없다. 그 대신 테스트를 위해 만들어져 있는 MockHttpServletRequest, MockHttpServletResponse를 사용할 수 있다.

 

세션 사용하여 로그인 처리 - 직접 만든 세션 적용

이제 세션을 적용해보자. 기존의 쿠키로 로그인하는 방식 관련 컨트롤러는 싹 다 날려버려도 된다. 아니면 그냥 코드를 아래처럼 수정하자.

@PostMapping("/login")
public String loginForm(@Validated @ModelAttribute LoginForm loginForm,
                        BindingResult bindingResult,
                        HttpServletResponse res) {
    if (bindingResult.hasErrors()) {
        return "login/loginForm";
    }

    Member loginMember = loginService.login(loginForm.getLoginId(), loginForm.getPassword());
    if (loginMember == null) {
        bindingResult.reject("loginFail", "아이디 또는 비밀번호가 맞지 않습니다.");
        return "login/loginForm";
    }

    sessionManager.createSession(loginMember, res);
    return "redirect:/";
}
  • 다른 부분들은 전부 동일하고 로그인 성공 처리 부분에서 단지 SessionManagercreateSession()을 호출해주면 끝이다. 이 메서드 안에서 세션ID를 만들고 그 ID와 넘겨받는 멤버 객체가 한 쌍으로 세션 저장소에 보관도 되고, 응답에 쿠키로 세션ID도 추가한다.
@PostMapping("/logout")
public String logout(HttpServletRequest req) {
    sessionManager.expireSession(req);
    return "redirect:/";
}
  • 로그아웃의 경우도 그냥 expireSession()을 호출하면 된다.

 

이제 HomeController도 세션을 적용하자.

SessionHomeController

package hello.login.web;

import hello.login.domain.member.Member;
import hello.login.domain.member.MemberRepository;
import hello.login.web.session.SessionManager;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.CookieValue;
import org.springframework.web.bind.annotation.GetMapping;

import javax.servlet.http.HttpServletRequest;

@Slf4j
@Controller
@RequiredArgsConstructor
public class SessionHomeController {

    private final SessionManager sessionManager;

    @GetMapping("/")
    public String home(HttpServletRequest req, Model model) {
        Member member = (Member) sessionManager.getSession(req);

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

        model.addAttribute("member", member);
        return "loginHome";
    }
}
  • 이제 더 이상 @CookieValue는 필요없다. 그저 SessionManagergetSession()을 호출하면 된다. 이 메서드를 호출하면 요청 헤더에 담긴 세션ID를 통해 세션 저장소에서 이 세션ID와 한 쌍을 이루는 멤버 객체를 반환할 것이다.
  • 찾지 못했다면 로그인되지 않은것으로 간주한다.
  • 찾았다면 로그인 된 화면으로 이동시킨다.

이렇게 직접 만든 세션도 잘 적용해보았다. 그러나, 이번을 계기로 다시는 직접 만들지 않아도 된다. 훨씬 더 잘 만들어져 있는 제공된 기능을 사용하면 된다. 

 

서블릿 HTTP 세션사용하기

이제 직접 만드는 세션이 아니라 제공되는 좋은 기능을 사용하면 된다. 서블릿은 세션을 위해 HttpSession이라는 기능을 제공한다.

이 녀석을 통해 세션을 생성하면 다음과 같은 쿠키를 생성한다. `JSESSIONID`. 이게 이제 쿠키의 이름이고 그 값으로는 똑같이 알 수 없는 랜덤값을 넣는다. 그럼 서블릿 세션도 내부적으로 그 랜덤값을 통해 우리가 실질적으로 매핑한 값(멤버 객체)를 찾아서 로그인 여부를 확인하게 된다.

 

우선, 이 서블릿 세션을 사용할 땐 세션의 키가 필요하다. 뭐 아무렇게나 지어도 상관없다. 그래서 나는 인터페이스를 만들어서 상수를 정의하려고 한다.

SessionConst

package hello.login.web.session;

public interface SessionConst {
    String LOGIN_MEMBER = "loginMember";
}

 

그리고 이제, 로그인과 로그아웃을 이 서블릿 세션을 사용해서 처리할거니까 로그인 컨트롤러를 수정해주자.

HttpServletSessionLoginController

package hello.login.web.login;

import hello.login.domain.login.LoginService;
import hello.login.domain.member.Member;
import hello.login.web.session.SessionConst;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Controller;
import org.springframework.validation.BindingResult;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpSession;

@Slf4j
@Controller
@RequiredArgsConstructor
public class HttpServletSessionLoginController {

    private final LoginService loginService;

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

    @PostMapping("/login")
    public String loginForm(@Validated @ModelAttribute LoginForm loginForm,
                            BindingResult bindingResult,
                            HttpServletRequest req) {
        if (bindingResult.hasErrors()) {
            return "login/loginForm";
        }

        Member loginMember = loginService.login(loginForm.getLoginId(), loginForm.getPassword());
        if (loginMember == null) {
            bindingResult.reject("loginFail", "아이디 또는 비밀번호가 맞지 않습니다.");
            return "login/loginForm";
        }

        // 세션이 있으면 세션 반환, 없으면 신규 세션을 생성
        HttpSession session = req.getSession();
        session.setAttribute(SessionConst.LOGIN_MEMBER, loginMember);
        return "redirect:/";
    }

    @PostMapping("/logout")
    public String logout(HttpServletRequest req) {
        // getSession(false): 새로 만드는 게 아니라 기존에 있는 세션을 가져와야 한다.
        HttpSession session = req.getSession(false);
        if (session != null) {
            session.invalidate(); // 전부 무효화
        }
        return "redirect:/";
    }
}

이게 전체 코드이고 코드를 나눠서 보자. 먼저 로그인 할 때이다.

@PostMapping("/login")
public String loginForm(@Validated @ModelAttribute LoginForm loginForm,
                        BindingResult bindingResult,
                        HttpServletRequest req) {
    if (bindingResult.hasErrors()) {
        return "login/loginForm";
    }

    Member loginMember = loginService.login(loginForm.getLoginId(), loginForm.getPassword());
    if (loginMember == null) {
        bindingResult.reject("loginFail", "아이디 또는 비밀번호가 맞지 않습니다.");
        return "login/loginForm";
    }

    // 세션이 있으면 세션 반환, 없으면 신규 세션을 생성
    HttpSession session = req.getSession();
    session.setAttribute(SessionConst.LOGIN_MEMBER, loginMember);
    return "redirect:/";
}
  • 역시나 다 동일한데 로그인 성공 시 세션을 생성하는 부분만 변경해준다. HttpServletRequest에는 getSession()이라는 메서드가 있고 그 녀석을 통해 객체를 얻으면 HttpSession 타입의 객체가 있다.
  • 그 객체에 setAttribute()를 호출해서 원하는 키에 저장할 값(멤버 객체)를 추가하면 된다.
@PostMapping("/logout")
public String logout(HttpServletRequest req) {
    // getSession(false): 새로 만드는 게 아니라 기존에 있는 세션을 가져와야 한다.
    HttpSession session = req.getSession(false);
    if (session != null) {
        session.invalidate(); // 전부 무효화
    }
    return "redirect:/";
}
  • 이번엔 로그아웃이다. 마찬가지로 getSession()을 통해 HttpSession 객체를 가져온다.
    • getSession(true) → 세션을 가져오는데 없으면 새로 만들어서 가져오는 것이고 이게 기본값이다.
    • getSession(false) → 세션을 가져오는데 없으면 null을 반환한다. 
  • HttpSessionnull이 아니라면 invalidate()을 호출해서 세션의 데이터를 무효화한다.

 

이번엔 HomeControllerHttpSession을 사용하도록 수정해보자.

HttpServletSessionHomeController

package hello.login.web;

import hello.login.domain.member.Member;
import hello.login.web.session.SessionConst;
import hello.login.web.session.SessionManager;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpSession;

@Slf4j
@Controller
@RequiredArgsConstructor
public class HttpServletSessionHomeController {

    @GetMapping("/")
    public String home(HttpServletRequest req, Model model) {
        HttpSession session = req.getSession(false);
        if (session == null) {
            return "home";
        }

        Member loginMember = (Member) session.getAttribute(SessionConst.LOGIN_MEMBER);
        if (loginMember == null) {
            return "home";
        }

        model.addAttribute("member", loginMember);
        return "loginHome";
    }
}
  • 마찬가지로 HttpSession 객체를 가져온다. 여기서도 없으면 새로 만드는 게 아니라 기존에 있다면 가져오고 없으면 null 처리를 한다. 왜 새로 만들지 않냐면, 당연하게도 로그인을 했으면 당연히 세션이 저장된 상태일 것이다. 그럼 조회했을 때 null일 수 없다.
  • 가져온 HttpSession 객체로부터 getAttribute()를 호출해서 아까 저장한 키/값을 키를 통해 가져온다.
  • 가져온 값(멤버 객체)이 없다면 home 화면으로, 가져온 값(멤버 객체)이 있다면 loginHome 화면으로 이동한다.

이 상태로 실행을 다시 해보면 잘 동작할 것이다.

 

근데, 여기서 더 나아가서 스프링이 조금 더 도와준다. 이 HttpServletSessionHomeController를 보면 HttpServletRequest 받아서 세션 꺼내고 이런 작업이 너무 귀찮으니까 딱 이렇게 바꿔버릴 수 있다.

@GetMapping("/")
public String homeWithSpring(@SessionAttribute(name = SessionConst.LOGIN_MEMBER, required = false) Member member,
                             Model model) {
    if (member == null) {
        return "home";
    }

    model.addAttribute("member", member);
    return "loginHome";
}
  • @SessionAttribute 애노테이션을 스프링이 지원하는데 여기서 바로 키값으로 내가 원하는 해당 키와 매핑된 값(멤버 객체)을 꺼내올 수 있다. `required = false`는 당연히 있어야 한다. 로그인을 하지 않았을 수 있으니까.

 

그리고, 이렇게 서블릿 HTTP 세션을 사용할 때 최초 로그인 시에는 URL에 이런게 붙는다.

그러니까, 쿠키에 있는 JSESSIONID가 URL에 붙는 경우를 볼 수 있는데(최초 딱 한번만), 그 이유가 뭐냐면 서버 입장에서는 브라우저가 쿠키를 지원하는지 지원하지 않는지 최초에는 알 수가 없다. 이후에는 쿠키가 넘어오는지 아닌지를 통해 자연스럽게 파악이 된다고 해도 딱 최초에는 모른다. 그래서 그런 경우에 URL에 있는 이 JSESSIONID를 통해 세션을 유지할 수 있게 해주는 것인데, 쿠키를 지원하지 않는 브라우저는 거의 없다고 봐야하고 커스텀 설정을 통해 그런 작업을 했다면 그건 배제하자. URL에 이런 정보가 들어가봐야 좋을 거 하나 없기 때문에. 그래서 이 값을 없애는 방법은 다음과 같이 application.properties 파일에 이 한 줄을 추가한다.

server.servlet.session.tracking-modes=cookie

 

세션의 타임아웃

이제 이 세션의 타임아웃을 알아볼 차례다.

 

세션은 사용자가 로그아웃을 직접 호출해서 session.invalidate()이 호출되는 경우에 삭제된다. 그런데 대부분의 사용자는 로그아웃을 누르고 나가지 않고 그냥 웹 브라우저를 종료한다. 문제는 HTTP는 Stateless이기 때문에 요청 후 응답을 내보내면 더이상 뭐 서버는 알 수 있는게 없다. 그래서 사용자가 브라우저를 종료했는지, 아닌지 알 수 없다. 그러면 서버에서 세션 데이터를 언제 삭제해야 하는지 판단하기가 어렵다. 이런 경우에 남아있는 세션을 무한정 보관하면 다음과 같은 문제가 발생할 수 있다.

  • 세션과 관련된 쿠키(JSESSIONID)를 탈취 당했을 때, 오랜 시간이 지나도 해당 쿠키로 악의적인 요청을 할 수 있다.
  • 세션은 기본적으로 메모리에 생성된다.(물론, DB에 올릴수도 있다) 메모리의 크기가 무한하지 않기 때문에 꼭 필요한 경우에만 생성해서 사용해야 한다. 10만명의 사용자가 로그인하면 10만개의 세션이 생성되는 것이다. 

세션의 종료 시점

세션의 종료 시점은 생성 시점으로부터 30분 정도 잡으면 될까? 근데 그러면 30분이 지나면 세션이 삭제되기 때문에 사용자가 사이트를 열심히 돌아다니다가 중간에 갑자기 뚝 세션이 삭제되면 로그인을 해야하는 번거로움이 발생할 것이다. 그래서 대부분의 경우 사용자의 마지막 요청 시간으로부터 30분을 종료 시점으로 설정한다. 그러니까 사용하다가 사용자가 요청을 하면 요청한 시점부터 다시 30분을 늘리는 것이다. HttpSession은 이 방식을 사용한다. 그러니까 우리는 따로 작업해줄게 없다.

 

근데, 당연히 이 설정값은 변경이 가능하다. 기본이 30분이고 다음과 같이 변경할 수 있다.

application.properties

server.servlet.session.timeout=60s

이러면 60초가 타임 아웃이다. 이건 글로벌 설정이다. 

 

근데, 특정 세션별로 보안이 매우 중요해서 세션 타임 아웃을 달리 설정해야 하는 경우도 있다. 특정 세션의 타임 아웃을 바꾸려면

세션을 만들어서 다음 코드를 호출하면 된다.

session.setMaxInactiveInterval(1800); //1800초

 

정리하자면, 

서블릿의 HttpSession이 제공하는 타임아웃 기능 덕분에 세션을 안전하고 편리하게 관리하고 사용할 수 있다. 실무에서 주의할 점은 세션에는 최소한의 데이터만 보관해야 한다는 점이다. 메모리 사용량을 고려해야 하기 때문이다. 

 

참고로, 세션에는 이러한 메서드들이 있다. 참고하면 좋을 것 같다.

SessionInfoController

package hello.login.web.session;

import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpSession;
import java.util.Date;

@Slf4j
@RestController
@RequestMapping("/session")
public class SessionInfoController {

    @GetMapping("/info")
    public String sessionInfo(HttpServletRequest request) {
        HttpSession session = request.getSession(false);
        if (session == null) {
            return "session is null";
        }

        session.getAttributeNames()
                .asIterator()
                .forEachRemaining(name -> log.info("session name = {}, value = {}", name, session.getAttribute(name)));

        log.info("sessionId = {}", session.getId()); // 세션 ID
        log.info("getMaxInactiveInterval = {}", session.getMaxInactiveInterval()); // 세션의 유효시간 예)1800초=30분
        log.info("creationTime = {}", new Date(session.getCreationTime())); // 세션 생성 일시
        log.info("lastAccessTime = {}", new Date(session.getLastAccessedTime())); // 세션과 연결된 사용자가 최근에 서버에 접근한 시간
        log.info("isNew = {}", session.isNew()); // 새로 생성된 세션인지

        return "done";
    }
}

 

정리

이제 세션을 사용해 로그인 기능도 구현해보았다. 다음 포스팅에는 필터와 인터셉터를 사용해서 로그인 안 된 사용자를 걸러내는 방법을 알아보자.

728x90
반응형
LIST
728x90
반응형
SMALL

참고자료

 

스프링 MVC 2편 - 백엔드 웹 개발 활용 기술 강의 | 김영한 - 인프런

김영한 | 웹 애플리케이션 개발에 필요한 모든 웹 기술을 기초부터 이해하고, 완성할 수 있습니다. MVC 2편에서는 MVC 1편의 핵심 원리와 구조 위에 실무 웹 개발에 필요한 모든 활용 기술들을 학습

www.inflearn.com

 

이전 포스팅인 이 데이터 검증에서 BindingResult, Validator, @Validated를 사용한 데이터 검증에 대한 내용을 공부해 보았다.

 

데이터 검증 (BindingResult, Validator, @Validated)

참고자료 스프링 MVC 2편 - 백엔드 웹 개발 활용 기술 강의 | 김영한 - 인프런김영한 | 웹 애플리케이션 개발에 필요한 모든 웹 기술을 기초부터 이해하고, 완성할 수 있습니다. MVC 2편에서는 MVC 1

cwchoiit.tistory.com

 

근데 개발자들의 욕심은 끝이 없어서, 저 검증 관련 코드 작성하는것도 귀찮아 하게 된다. 그도 그럴게 사실 문자 필드에 빈 값이면 안되는 이 내용은 어떤 웹 애플리케이션에서도 만국 공통 아닐까? 그럼 좀 더 간단하게 공통화된 처리가 가능하지 않을까?라는 생각부터 출발한 것이다. 그래서 애노테이션을 가지고 그냥 검증이 가능하게 공통 처리를 해버렸다.

 

다음 코드를 보자.

 public class Item {
     private Long id;
     
     @NotBlank
     private String itemName;
     
     @NotNull
     @Range(min = 1000, max = 1000000)
     private Integer price;
     
     @NotNull
     @Max(9999)
     private Integer quantity;
     //...
}

이런식으로 범위는 어디서부터 어디까지이고, Null, Blank이면 안되고, 최대값은 얼마이고와 같은 공통 검증 로직을 애노테이션 하나로 처리해 버리는 방식이다.

 

Bean Validation

특정한 구현체가 아니라, Bean Validation 2.0(JSR-380)이라는 표준 기술이다. 쉽게 이야기해서 검증 애노테이션과 여러 인터페이스의 모음이다. 마치 JPA가 표준 기술이고 그 구현체로 하이버네이트가 있는 것처럼 말이다.

 

Bean Validation을 구현한 기술중에 일반적으로 사용하는 구현체는 하이버네이트 Validator이다. 이름에 하이버네이트가 붙었지만 ORM과는 관련이 없다. 

 

 

Bean Validation 도입

우선, 의존성을 먼저 추가해야 한다.

 implementation 'org.springframework.boot:spring-boot-starter-validation'

 

추가하면 다음과 같은 라이브러리가 추가가 된다.

  • jakarta.validation-api: Bean Validation 인터페이스
  • hibernate-validator: 구현체

그리고 이제 Item 클래스에 애노테이션을 적용해보자.

package hello.itemservice.domain.item;

import lombok.Data;
import org.hibernate.validator.constraints.Range;

import javax.validation.constraints.Max;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;

@Data
public class Item {

    private Long id;

    @NotBlank(message = "필수 필드입니다.")
    private String itemName;

    @NotNull(message = "필수 필드입니다.")
    @Range(min = 1_000, max = 1_000_000, message = "가격의 범위는 1,000 - 1,000,000 사이여야 합니다.")
    private Integer price;

    @NotNull(message = "필수 필드입니다.")
    @Max(value = 9999, message = "최대값은 9999입니다.")
    private Integer quantity;

    public Item() {
    }

    public Item(String itemName, Integer price, Integer quantity) {
        this.itemName = itemName;
        this.price = price;
        this.quantity = quantity;
    }
}
  • @NotBlank → 빈값, 공백만 있는 경우를 허용하지 않는다.
  • @NotNullnull을 허용하지 않는다.
  • @Range(min = 1_000, max = 1_000_000) → 1000 - 1000000 범위 안의 값이어야 한다.
  • @Max(9999) → 최대 9999까지 허용한다.
참고로, Max, NotBlank, NotNull은 패키지가 javax.validation.constraints이다. 이 말은 javax.validation으로 시작하면 특정 구현에 관계없이 제공되는 표준 인터페이스이란 뜻이다. 그래서 어떤 구현체든 상관없이 사용할 수가 있다. 근데, Range같은 경우는 패키지가 org.hibernate.validator.constraints이다. 이 말은 이 hibernate 구현체를 사용해야만 사용 가능한 애노테이션이라는 의미다. 결론은 거의 대부분 하이버네이트 validator를 사용하기 때문에 자유롭게 사용해도 된다. 스프링도 기본적으로 구현체를 하이버네이트 validator로 다운받아 주기 때문에. 

 

아래는 스프링을 사용하는 것과 상관없이 검증을 실행할 수가 있는데 이건 그냥 참고사항이고 우리는 스프링과 통합해서 사용할 거니까 그냥 아 이렇게 하는구나? 정도로 넘어가면 된다.

package hello.itemservice.validation;

import hello.itemservice.domain.item.Item;
import org.junit.jupiter.api.Test;

import javax.validation.ConstraintViolation;
import javax.validation.Validation;
import javax.validation.Validator;
import javax.validation.ValidatorFactory;
import java.util.Set;

public class BeanValidationTest {

    @Test
    void beanValidation() {
        ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
        Validator validator = factory.getValidator();

        Item item = new Item();
        item.setItemName(" ");
        item.setPrice(0);
        item.setQuantity(10000);

        Set<ConstraintViolation<Item>> violations = validator.validate(item);
        for (ConstraintViolation<Item> violation : violations) {
            System.out.println("violation = " + violation);
            System.out.println("violation.getMessage() = " + violation.getMessage());
        }

        factory.close();
    }
}
  • 스프링과 통합하면 이런 코드를 직접 작성할 필요가 없어진다.
    • ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
    • Validator validator = factory.getValidator();
  • 검증 실행은 이 코드를 호출하면 된다.
    • Set<ConstraintViolation<Item>> violations = validator.validate(item);
  • 호출해서 결과가 비어있으면 검증 오류가 없는 것이고 결과가 비어있지 않으면 뭔가 검증에 실패한 것이다.

실행결과

violation = ConstraintViolationImpl{interpolatedMessage='must be less than or equal to 9999', propertyPath=quantity, rootBeanClass=class hello.itemservice.domain.item.Item, messageTemplate='{javax.validation.constraints.Max.message}'}
violation.getMessage() = must be less than or equal to 9999
violation = ConstraintViolationImpl{interpolatedMessage='must not be blank', propertyPath=itemName, rootBeanClass=class hello.itemservice.domain.item.Item, messageTemplate='{javax.validation.constraints.NotBlank.message}'}
violation.getMessage() = must not be blank
violation = ConstraintViolationImpl{interpolatedMessage='must be between 1000 and 1000000', propertyPath=price, rootBeanClass=class hello.itemservice.domain.item.Item, messageTemplate='{org.hibernate.validator.constraints.Range.message}'}
violation.getMessage() = must be between 1000 and 1000000

 

스프링과 통합하기

이제 스프링과 통합해서 사용해보자. 그러기 위해 저번 포스팅에서 사용했던 컨트롤러를 복사해서 새로 만들고 코드를 좀 정리하자.

ValidationItemControllerV3

package hello.itemservice.web.validation;

import hello.itemservice.domain.item.Item;
import hello.itemservice.domain.item.ItemRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;

import java.util.List;

@Slf4j
@Controller
@RequestMapping("/validation/v3/items")
@RequiredArgsConstructor
public class ValidationItemControllerV3 {

    private final ItemRepository itemRepository;

    @GetMapping
    public String items(Model model) {
        List<Item> items = itemRepository.findAll();
        model.addAttribute("items", items);
        return "validation/v3/items";
    }

    @GetMapping("/{itemId}")
    public String item(@PathVariable long itemId, Model model) {
        Item item = itemRepository.findById(itemId);
        model.addAttribute("item", item);
        return "validation/v3/item";
    }

    @GetMapping("/add")
    public String addForm(Model model) {
        model.addAttribute("item", new Item());
        return "validation/v3/addForm";
    }

    @PostMapping("/add")
    public String addItem(@Validated @ModelAttribute Item item,
                            BindingResult bindingResult,
                            RedirectAttributes redirectAttributes) {

        if (bindingResult.hasErrors()) { // 에러가 있는 경우, 다시 폼을 보여줌
            log.debug("[addItemV2] bindingResult errors: {}", bindingResult);
            return "validation/v3/addForm";
        }

        Item savedItem = itemRepository.save(item);
        redirectAttributes.addAttribute("itemId", savedItem.getId());
        redirectAttributes.addAttribute("status", true);
        return "redirect:/validation/v3/items/{itemId}";
    }

    @GetMapping("/{itemId}/edit")
    public String editForm(@PathVariable Long itemId, Model model) {
        Item item = itemRepository.findById(itemId);
        model.addAttribute("item", item);
        return "validation/v3/editForm";
    }

    @PostMapping("/{itemId}/edit")
    public String edit(@PathVariable Long itemId, @ModelAttribute Item item) {
        itemRepository.update(itemId, item);
        return "redirect:/validation/v3/items/{itemId}";
    }
}
  • ItemValidator, @InitBinder 등의 코드를 삭제했다. 필요없다.
  • addItem() 메서드는 여전히 동일하게 남아 있다. 이 메서드에 검증할 객체인 @ModelAttribute Item item 앞에 @Validated 애노테이션을 붙여주었고, 검증한 결과를 담고 있을 BindingResult@ModelAttribute Item item 바로 뒤에 넣어주었다. (이 내용은 지난번 포스팅에서 설명했다. 이해가 안된다면 저번 포스팅을 참고)

이렇게 해두고, 테스트를 해보면? 신기하게 검증이 그대로 된다.

아무것도 한 게 없는데, 그저 Item에 애노테이션만 달았을 뿐인데 자동으로 검증이 적용됐다. 어떻게 된 일일까?

 

스프링 부트가 다음 의존성을 추가하면 자동으로 Bean Validator를 인지하고 스프링에 통합한다.

spring-boot-starter-validation

그래서 스프링 부트가 하는 일은 자동으로 글로벌 Validator를 등록한다. 저번 포스팅에서 글로벌로 Validator를 적용하면, 주입받고 @InitBinder 애노테이션으로 Validator 등록할 필요없이 그저 검증하고자 하는 컨트롤러에 @Validated 애노테이션만 검증 객체 바로 옆에 넣어주면 된다고 했다. 그게 지금 적용된 것이다.

 

스프링 부트가 LocalValidatorFactoryBean이라는 글로벌 Validator를 등록한다. 그래서 이 Validator@NotNull과 같은 애노테이션을 보고 검증을 수행한다. 검증을 해서 검증 오류가 발생하면 FieldError, ObjectError를 생성해서 BindingResult에 담아준다.

 

주의할 점은, 글로벌 Validator를 개발자가 직접 등록한 게 있다면 스프링 부트는 Bean Validator를 글로벌 Validator로 등록하지 않는다. 따라서 애노테이션 기반의 빈 검증기가 동작하지 않으니까 만약, 직접 아래와 같이 저번에 배웠던 것처럼 글로벌 Validator를 등록했다면 빈 검증기가 동작하지 않을 것이다.

@Override
public Validator getValidator() {
    return new ItemValidator();
}

 

Bean Validator의 검증 로직

이렇게 간단하게 빈 검증기로 검증을 해봤는데, 이 검증 로직(?), 순서(?)는 어떤식으로 진행될까?

  1. @ModelAttribute 각각의 필드에 타입 변환 시도
    • 사용자가 입력한 값을 해당하는 필드에 집어넣는다.
    • 성공한 경우 다음으로 넘어간다.
    • 실패한 경우 typeMismatchFieldError를 생성해서 BindingResult에 추가한다.
  2. Bean Validator를 적용한다. 
    • 위에 타입 바인딩을 했을 때 성공한 필드에만 검증을 시도한다. 
    • 생각해보면, 타입 자체가 맞지 않는데 추가적인 검증이 중요한게 아니라 사용자에게 먼저 타입부터 고치라는 말을 해줘야한다. 그래서 타입 바인딩을 실패하면 검증을 하지 않는다.
    • 타입 바인딩에 성공한 필드는 검증을 시도한다.
      • 예시) itemName에 문자 "A"를 입력 → 타입 변환 성공 → itemName 필드에 BeanValidation 적용
      • 예시) price에 문자 "A"를 입력 → 타입 변환 시도 실패 → typeMismatch FieldError 추가 → price 필드는 BeanValidation 적용하지 않음

 

Bean Validator의 에러 코드

우선, 기본 에러 메시지를 다루는 방법은 이미 처리해봤다. 바로 아래처럼 애노테이션에 message 속성에 에러 메시지를 입력하면 된다.

package hello.itemservice.domain.item;

import lombok.Data;
import org.hibernate.validator.constraints.Range;

import javax.validation.constraints.Max;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;

@Data
public class Item {

    private Long id;

    @NotBlank(message = "필수 필드입니다.")
    private String itemName;

    @NotNull(message = "필수 필드입니다.")
    @Range(min = 1_000, max = 1_000_000, message = "가격의 범위는 1,000 - 1,000,000 사이여야 합니다.")
    private Integer price;

    @NotNull(message = "필수 필드입니다.")
    @Max(value = 9999, message = "최대값은 9999입니다.")
    private Integer quantity;

    public Item() {
    }

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

 

근데, Bean Validator가 검증 오류가 있으면 어디에 뭘 담는다고 했었나?! 바로 BindingResultFieldError 또는 ObjectError를 만들어서 넣어둔다고 했다. 그 말은 codes가 사용될 것이라는 얘기가 된다. 그래서 실제로 검증 오류를 발생시켜서 어떤 에러 코드를 만드는지 보자. 검증 오류가 있는 경우 로그를 찍어보면 다음과 같이 나온다.

Field error in object 'item' on field 'quantity': rejected value [null]; 
codes [NotNull.item.quantity,NotNull.quantity,NotNull.java.lang.Integer,NotNull]; 
arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [item.quantity,quantity]; arguments []; default message [quantity]]; default message [필수 필드입니다.]
  • NotNull.item.quantity
  • NotNull.quantity
  • NotNull.java.lang.Integer
  • NotNull

계속 봤던거랑 똑같다. 대신 애노테이션 이름으로 맨 앞을 채울 뿐이다. 그래서 Bean Validatior를 사용해서 애노테이션 기반으로 검증하고 에러 메시지를 유연하게 사용하려면 이 코드들로 에러 메시지를 만들면 된다.

errors.properties

NotBlank=공백일 수 없습니다.
Range={0}의 범위는 {2} ~ {1} 범위를 허용합니다.
Max={0}의 값은 최대 {1}까지 허용합니다.

 

  • Bean Validation 사용시,
    • {0}은 필드 이름으로 들어간다.
    • Range에서 {1}은 최대값, {2} 최소값으로 들어간다.
    • Max에서 {1}은 최대값으로 들어간다.

이렇게 만들어두고 실행해보자.

위에서 설정한 에러 메시지가 출력된 것을 볼 수 있다. 이게 다 Bean Validation으로 검증 처리를 하는 것도 결국 검증 오류가 생기면BindingResult를 사용하기 때문에 사용할 수 있는 편리함이다. BindingResult는 메시지를 출력하는 코드를 만들때 MessageCodesResolver를 사용하기 때문이고. 

 

이걸 화면으로 뿌려주는 게 아니라 만약, API로 사용하는 거라고 해도 충분히 이것들을 사용해서 잘못된 데이터라는 응답을 보내줄 수 있다.

왜냐하면 BindingResult에 결국 모든게 다 담겨있기 때문이다. 아래 코드를 보자.

if (bindingResult.hasErrors()) { // 에러가 있는 경우, 다시 폼을 보여줌
    for (FieldError fieldError : bindingResult.getFieldErrors()) {
        log.debug("fieldError. field: {}", fieldError.getField());
        log.debug("fieldError. rejectedValue: {}", fieldError.getRejectedValue());
        log.debug("fieldError. message: {}", fieldError.getDefaultMessage());
        log.debug("fieldError.getCode(): {}", fieldError.getCode());
    }
}
  • getFieldErrors() 메서드를 통해 모든 필드 에러를 전부 불러올 수 있다.
  • 불러온 모든 FieldError의 정보엔 어떤 필드인지, 어떤값을 사용자가 입력했는지, 기본 에러 메시지는 무엇인지, 에러 코드는 무엇인지를 알 수 있다.
  • 에러 코드를 안다면 에러 코드를 통해 Message를 불러올 수 있다. 다음 코드를 보자.
@Autowired
private MessageSource messageSource;

...

String message = messageSource.getMessage(Objects.requireNonNull(fieldError.getCode()), null, Locale.KOREA);

그럼 이 정보들을 통해 충분히 API 응답도 깔끔하게 내보낼 수 있을 것이다.

 

Bean Validation을 사용했을 때 ObjectError 처리

그럼 이제 필드 에러는 이렇게 깔끔하게 애노테이션으로 처리가 가능했다. 그럼 필드 에러 말고 우리가 했던것처럼 globalError는 어떻게 다루지?에 대한 의문이 남았다면 아주 정상이다. 생각해보면, price * quantity10,000원이 넘어야 하는 어떤 필드 종속적 검증이 아닌 globalError 검증도 있었다. 그러나, 저런 필드가 없으니 애노테이션을 어디 달 곳이 애매하다.

 

이런 경우에 또 지원하는 방식이 있는데 @ScriptAssert 라는 애노테이션이다.

package hello.itemservice.domain.item;

import lombok.Data;
import org.hibernate.validator.constraints.Range;
import org.hibernate.validator.constraints.ScriptAssert;

import javax.validation.constraints.Max;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;

@Data
@ScriptAssert(lang = "javascript", script = "_this.price * _this.quantity >= 10000", message = "price * quantity의 결과는 10,000원 이상이어야 합니다.")
public class Item {

    private Long id;

    @NotBlank(message = "필수 필드입니다.")
    private String itemName;

    @NotNull(message = "필수 필드입니다.")
    @Range(min = 1_000, max = 1_000_000, message = "가격의 범위는 1,000 - 1,000,000 사이여야 합니다.")
    private Integer price;

    @NotNull(message = "필수 필드입니다.")
    @Max(value = 9999, message = "최대값은 9999입니다.")
    private Integer quantity;

    public Item() {
    }

    public Item(String itemName, Integer price, Integer quantity) {
        this.itemName = itemName;
        this.price = price;
        this.quantity = quantity;
    }
}
  • 이렇게 클래스 레벨에 @ScriptAssert 라는 애노테이션을 붙여서 해결할 수도 있다. 
    • script, message만 원하는대로 수정하면 된다. message는 기본 에러 메시지를 의미한다.
    • ScriptAssert 검증으로부터 만들어지는 codes는 다음과 같다.
      • ScriptAssert.item
      • ScriptAssert

globalError가 잘 보여진다. 

근데! 간단한 것 정도는 저렇게 해도 상관없을 듯 하지만 그냥 저렇게 하지말고 자바 코드로 푸는것을 추천한다. 실제로는 사용하기도 애매하고 굉장히 지저분하다. 그래서 그냥 저렇게 globalError가 필요한 경우에는 아래처럼 그냥 코드로 검증하는 방식으로 하자.

// 특정 필드가 아닌 복합 룰 검증
if (item.getPrice() != null && item.getQuantity() != null) {
    int resultPrice = item.getPrice() * item.getQuantity();
    if (resultPrice < 10_000) {
        bindingResult.reject("totalPriceMin", new Object[]{10_000, resultPrice}, null);
    }
}

 

현재까지 최종 코드

그래서 현재까지의 컨트롤러, Item, errors.properties의 최종 코드는 이렇게 되면 될 것 같다.

Item

package hello.itemservice.domain.item;

import lombok.Data;
import org.hibernate.validator.constraints.Range;

import javax.validation.constraints.Max;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;

@Data
public class Item {

    private Long id;

    @NotBlank(message = "필수 필드입니다.")
    private String itemName;

    @NotNull(message = "필수 필드입니다.")
    @Range(min = 1_000, max = 1_000_000, message = "가격의 범위는 1,000 - 1,000,000 사이여야 합니다.")
    private Integer price;

    @NotNull(message = "필수 필드입니다.")
    @Max(value = 9999, message = "최대값은 9999입니다.")
    private Integer quantity;

    public Item() {
    }

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

 

ValidationItemControllerV3

package hello.itemservice.web.validation;

import hello.itemservice.domain.item.Item;
import hello.itemservice.domain.item.ItemRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;

import java.util.List;

@Slf4j
@Controller
@RequestMapping("/validation/v3/items")
@RequiredArgsConstructor
public class ValidationItemControllerV3 {

    private final ItemRepository itemRepository;

    @GetMapping
    public String items(Model model) {
        List<Item> items = itemRepository.findAll();
        model.addAttribute("items", items);
        return "validation/v3/items";
    }

    @GetMapping("/{itemId}")
    public String item(@PathVariable long itemId, Model model) {
        Item item = itemRepository.findById(itemId);
        model.addAttribute("item", item);
        return "validation/v3/item";
    }

    @GetMapping("/add")
    public String addForm(Model model) {
        model.addAttribute("item", new Item());
        return "validation/v3/addForm";
    }

    @PostMapping("/add")
    public String addItem(@Validated @ModelAttribute Item item,
                          BindingResult bindingResult,
                          RedirectAttributes redirectAttributes) {

        if (item.getPrice() != null && item.getQuantity() != null) {
            int resultPrice = item.getPrice() * item.getQuantity();
            if (resultPrice < 10_000) {
                bindingResult.reject("totalPriceMin", new Object[]{10_000, resultPrice}, null);
            }
        }

        if (bindingResult.hasErrors()) { // 에러가 있는 경우, 다시 폼을 보여줌
            log.debug("[addItemV2] bindingResult errors: {}", bindingResult);
            return "validation/v3/addForm";
        }

        Item savedItem = itemRepository.save(item);
        redirectAttributes.addAttribute("itemId", savedItem.getId());
        redirectAttributes.addAttribute("status", true);
        return "redirect:/validation/v3/items/{itemId}";
    }

    @GetMapping("/{itemId}/edit")
    public String editForm(@PathVariable Long itemId, Model model) {
        Item item = itemRepository.findById(itemId);
        model.addAttribute("item", item);
        return "validation/v3/editForm";
    }

    @PostMapping("/{itemId}/edit")
    public String edit(@PathVariable Long itemId, @ModelAttribute Item item) {
        itemRepository.update(itemId, item);
        return "redirect:/validation/v3/items/{itemId}";
    }
}

 

errors.properties

totalPriceMin=가격 * 수량의 합은 {0}원 이상이어야 합니다. 현재 값 = {1}

typeMismatch.java.lang.Integer=숫자 필드입니다.
typeMismatch.java.lang.String=문자 필드입니다.
typeMismatch=타입 오류입니다.

NotNull=필수 필드입니다.
NotBlank=공백일 수 없습니다.
Range={0}의 범위는 {2} ~ {1} 범위를 허용합니다.
Max={0}의 값은 최대 {1}까지 허용합니다.

 

Bean Validation의 한계

이렇게 편리하고 좋은 기능도 한계점이란 게 존재한다. 어떤 문제가 있을까?

만약, 이런 요구사항이 들어왔다고 해보자.

 

생성 폼 검증 요구사항

  • 수량의 최대값은 9999

수정 폼 검증 요구사항

  • 수량의 최대값을 두지 않는다.

그러면 생성과 수정 시 검증 요구사항이 다르기 때문에 같은 Item 객체를 사용하는 순간에는 이 애노테이션을 구분하기가 굉장히 껄끄럽다. 사실 구분할 수 없다. 아래 코드가 Item 객체인데 생성폼, 수정폼 둘 다 이 Item 객체를 사용하는데 어떻게 둘을 구분해서 애노테이션을 달겠는가?

package hello.itemservice.domain.item;

import lombok.Data;
import org.hibernate.validator.constraints.Range;

import javax.validation.constraints.Max;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;

@Data
public class Item {

    private Long id;

    @NotBlank(message = "필수 필드입니다.")
    private String itemName;

    @NotNull(message = "필수 필드입니다.")
    @Range(min = 1_000, max = 1_000_000, message = "가격의 범위는 1,000 - 1,000,000 사이여야 합니다.")
    private Integer price;

    @NotNull(message = "필수 필드입니다.")
    @Max(value = 9999, message = "최대값은 9999입니다.")
    private Integer quantity;

    public Item() {
    }

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

 

Bean Validation 한계 돌파

문제를 해결할 수 있는 대표적인 두 가지 방법이 있다.

  • Bean Validationgroups 기능
  • Item 객체를 분리 (예: SaveItemDto, UpdateItemDto)

Bean Validationgroups

이런 문제를 해결하기 위해 Bean Validationgroups 라는 기능을 지원한다. 코드로 보면 바로 이해가 되니까 코드로 바로 시작하자.

우선, 그룹을 지정하기 위한 인터페이스가 필요하다. 생성 용 그룹 인터페이스와 업데이트 용 그룹 인터페이스 두개를 만들자.

 

SaveCheck

package hello.itemservice.domain.item;

public interface SaveCheck {
}

 

UpdateCheck

package hello.itemservice.domain.item;

public interface UpdateCheck {
}

 

그리고 Bean Validation을 위한 애노테이션에 생성과 업데이트 검증을 지정한다.

Item

package hello.itemservice.domain.item;

import lombok.Data;
import org.hibernate.validator.constraints.Range;

import javax.validation.constraints.Max;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;

@Data
public class Item {

    @NotNull(groups = UpdateCheck.class)
    private Long id;

    @NotBlank(message = "필수 필드입니다.", groups = {SaveCheck.class, UpdateCheck.class})
    private String itemName;

    @NotNull(message = "필수 필드입니다.", groups = {SaveCheck.class, UpdateCheck.class})
    @Range(min = 1_000, max = 1_000_000, message = "가격의 범위는 1,000 - 1,000,000 사이여야 합니다.", groups = {SaveCheck.class, UpdateCheck.class})
    private Integer price;

    @NotNull(message = "필수 필드입니다.", groups = {SaveCheck.class, UpdateCheck.class})
    @Max(value = 9999, message = "최대값은 9999입니다.", groups = SaveCheck.class)
    private Integer quantity;

    public Item() {
    }

    public Item(String itemName, Integer price, Integer quantity) {
        this.itemName = itemName;
        this.price = price;
        this.quantity = quantity;
    }
}
  • id@NotNull은 그룹으로 UpdateCheck.class를 지정했다. 이렇게 되면 업데이트 할 때만 이 검증이 요구된다.
  • itemName@NotBlank는 그룹으로 SaveCheck.class, UpdateCheck.class둘 다 지정했다. 이렇게 되면 생성과 업데이트 시 모두 검증이 요구된다. 
  • price@NotNull, @Range 모두 그룹으로 SaveCheck.class, UpdateCheck.class둘 다 지정했다. 이렇게 되면 생성과 업데이트 시 모두 검증이 요구된다. 
  • quantity@NotNull그룹으로 SaveCheck.class, UpdateCheck.class둘 다 지정했다. 그리고 @Max는 그룹으로 SaveCheck.class만 지정했다. 

이렇게 각 검증 요구사항을 그룹으로 지정해 놓은 다음 컨트롤러에서 검증을 하겠다는 의미를 가지는 @Validated에 그룹을 지정해주면 된다.

@PostMapping("/add")
public String addItem(@Validated(value = SaveCheck.class) @ModelAttribute Item item,
                      BindingResult bindingResult,
                      RedirectAttributes redirectAttributes) {

	...
}

@PostMapping("/{itemId}/edit")
public String edit(@PathVariable Long itemId,
                   @Validated(value = UpdateCheck.class) @ModelAttribute Item item,
                   BindingResult bindingResult) {
	...
}
  • 아이템을 생성하는 addItem 메서드의 @ValidatedSaveCheck.class를 지정했다. 생성 시 검증은 이 SaveCheck가 그룹으로 할당된 애노테이션만 적용된다는 의미이다.
  • 아이템을 수정하는 edit 메서드의 @ValidatedUpdateCheck.class를 지정했다. 업데이트 시 검증은 이 UpdateCheck가 그룹으로 할당된 애노테이션만 적용된다는 의미이다.

 

이렇게 적용하면 생성과 업데이트 간 같은 객체를 사용하면서 검증 처리는 분리할 수 있다.

그런데, 생각보다 불편하기도 하고 복잡하다. 우선 그룹을 지정하기 위해 인터페이스를 만드는 것부터 시작해서 Item 객체를 보면 꽤나 지저분해 진 모습이 보일것이다. 그래서 이 방식은 잘 사용하지 않는다. 위의 이유도 있지만 더 큰 이유는 실제 업무 세상에서는 생성 폼과 업데이트 폼은 매우 다르다. 일반적으로 유명한 사이트의 회원 가입 시 폼과 회원 수정 시 폼을 생각해보라. 

 

그래서 실제 업무 세상에서는 DTO 객체를 분리하여 이 문제를 처리한다. 사실 분리하면 문제를 처리하는 게 아니라 문제가 그냥 없어진다.

 

Item 객체를 ItemSaveForm, ItemUpdateForm으로 분리

우선, 이제 더 이상 Item 객체로 검증을 하지 않을거니까, 검증 관련 애노테이션을 전부 주석처리 하거나 없애자.

package hello.itemservice.domain.item;

import lombok.Data;
import org.hibernate.validator.constraints.Range;
import org.hibernate.validator.constraints.ScriptAssert;

import javax.validation.constraints.Max;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;

@Data
public class Item {

    private Long id;
    private String itemName;
    private Integer price;
    private Integer quantity;

    public Item() {
    }

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

 

그리고, 이제 생성과 업데이트 시 DTO로 사용할 클래스 두 개를 만들자.

ItemSaveForm

package hello.itemservice.web.validation.form;

import lombok.Data;
import org.hibernate.validator.constraints.Range;

import javax.validation.constraints.Max;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;

@Data
public class ItemSaveForm {

    @NotBlank
    private String itemName;

    @NotNull
    @Range(min = 1000, max = 1000000)
    private Integer price;

    @NotNull
    @Max(value = 9999)
    private Integer quantity;
}

ItemUpdateForm

package hello.itemservice.web.validation.form;

import lombok.Data;
import org.hibernate.validator.constraints.Range;

import javax.validation.constraints.Max;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;

@Data
public class ItemUpdateForm {

    @NotNull
    private Long id;

    @NotBlank
    private String itemName;

    @NotNull
    @Range(min = 1000, max = 1000000)
    private Integer price;

    private Integer quantity;
}
  • 생성시에는 quantity 필드에 검증을 하지만 업데이트 시에는 quantity에 아무런 검증을 하지 않는것을 볼 수 있다.
  • id도 생성시에는 아예 필드조차 없지만, 업데이트 시에는 @NotNull 애노테이션이 붙어있다.

 

이 객체를 가지고 컨트롤러에 적용해보자. 먼저 생성 시 메서드이다.

@PostMapping("/add")
public String addItem(@Validated @ModelAttribute("item") ItemSaveForm form,
                      BindingResult bindingResult,
                      RedirectAttributes redirectAttributes) {

    //특정 필드가 아닌 복합 룰 검증
    if (form.getPrice() != null && form.getQuantity() != null) {
        int resultPrice = form.getPrice() * form.getQuantity();
        if (resultPrice < 10000) {
            bindingResult.reject("totalPriceMin", new Object[]{10000, resultPrice}, null);
        }
    }

    //검증에 실패하면 다시 입력 폼으로
    if (bindingResult.hasErrors()) {
        log.info("errors={} ", bindingResult);
        return "validation/v4/addForm";
    }

    //성공 로직
    Item item = new Item();
    item.setItemName(form.getItemName());
    item.setPrice(form.getPrice());
    item.setQuantity(form.getQuantity());

    Item savedItem = itemRepository.save(item);
    redirectAttributes.addAttribute("itemId", savedItem.getId());
    redirectAttributes.addAttribute("status", true);
    return "redirect:/validation/v4/items/{itemId}";
}
  • @ModelAttribute 애노테이션의 타입을 ItemSaveForm으로 변경했다. 그래서 이제 사용자가 입력한 값은 이 객체로 변환될 것이다. 그리고 주의할 점이 있다. @ModelAttribute에 어떠한 값도 주지 않으면 Model에 담기는 키가 기본으로 타입의 첫글자를 소문자로 바꾼 `itemSaveForm`이 될 것이다. 근데 우리의 뷰 템플릿은 item으로 받고 있으니 `item`이라고 직접 명시해주자.

다음은 업데이트 시 메서드이다.

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

    //특정 필드가 아닌 복합 룰 검증
    if (form.getPrice() != null && form.getQuantity() != null) {
        int resultPrice = form.getPrice() * form.getQuantity();
        if (resultPrice < 10000) {
            bindingResult.reject("totalPriceMin", new Object[]{10000, resultPrice}, null);
        }
    }

    if (bindingResult.hasErrors()) {
        log.info("errors={}", bindingResult);
        return "validation/v4/editForm";
    }

    Item itemParam = new Item();
    itemParam.setItemName(form.getItemName());
    itemParam.setPrice(form.getPrice());
    itemParam.setQuantity(form.getQuantity());

    itemRepository.update(itemId, itemParam);
    return "redirect:/validation/v4/items/{itemId}";
}
  • @ModelAttribute 애노테이션의 타입을 ItemUpdateForm으로 변경했다. 그래서 이제 사용자가 입력한 값은 이 객체로 변환될 것이다. 마찬가지로 @ModelAttribute의 속성값을 `item`으로 지정한다.

이렇게 생성과 업데이트에 사용되는 DTO를 분리하므로써 더 간결하고 더 명확하게 검증도 나눌 수 있다. 그리고 검증을 하고 말고를 떠나서 실무에서는 항상 이런식으로 DTO가 분리된다. 생성 시 필요한 데이터와 업데이트 시 필요한 데이터가 거의 100% 다르기 때문에.

 

이렇게 Bean Validation의 한계를 두 가지 방법으로 해결해 보았다.

 

HTTP API일 때 Bean Validation 처리

개발을 하다보면 앞단 처리를 할 때도 있지만 API 통신을 할 때가 거의 대부분이다. 그리고 그 때도 물론 당연히 검증처리는 되어야 한다.

그리고 스프링은 완전 비슷하게 검증 처리를 할 수 있다. 

 

다시 이전 포스팅을 복기해보면, @Controller가 붙은 클래스 메서드들 중 @ResponseBody가 붙은 메서드는 HTTP API로 통신한다고 했다. 그리고 이 둘을 합친것이 @RestController라고 했다. 그래서 이 경우 어떻게 검증하는지 확인해보자.

 

ValidationItemApiController

package hello.itemservice.web.validation;

import hello.itemservice.web.validation.form.ItemSaveForm;
import lombok.extern.slf4j.Slf4j;
import org.springframework.validation.BindingResult;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@Slf4j
@RestController
@RequestMapping("/validation/api/items")
public class ValidationItemApiController {

    @PostMapping("/add")
    public Object addItem(@Validated @RequestBody ItemSaveForm itemSaveForm, BindingResult bindingResult) {
        log.info("API 컨트롤러 호출");

        if (bindingResult.hasErrors()) {
            log.info("검증 오류 발생 errors = {}", bindingResult);
            return bindingResult.getAllErrors();
        }

        log.info("성공 로직 실행");
        return itemSaveForm;
    }
}
  • REST API로 요청 바디에 있는 값을 객체로 바로 컨버팅 할 수 있다고 했고 그게 바로 HTTP 메시지 컨버터가 해주는 것이라고 했다. 그리고 컨버터가 인지할 수 있도록 파라미터로 @RequestBody ItemSaveForm itemSaveForm이라고 @RequestBody 애노테이션을 붙여주면 된다. 그럼 사용자가 입력한 요청 바디값이 ItemSaveForm 객체로 변환될 것이다.
  • 그리고 그 이후부터는 기존에 하던 방식과 똑같다. 검증하고자 하는 객체에 @Validated 애노테이션을 붙이고, 그 바로 뒤에는 BindingResult를 넣으면 된다.
  • HTTP API도 검증을 이런식으로 할 수 있음을 보여주기 위한 예시 코드라 리턴값은 아무런 의미를 두지 말자. 검증 오류가 나면 검증 오류 결과가 반환되고, 검증 오류가 없으면 그냥 사용자가 던진 데이터를 그대로 돌려받는다. 

 

정상 응답 결과

 

검증 오류 결과

  • BindingResult가 가지고 있는 오류 내용을 그대로 반환하고 있다. 실제로는 이대로 반환하면 절대 안된다.

메시지 컨버터가 컨버팅을 실패하는 결과

  • 여기서는 검증 오류에 대한 내용이 나오지 않고, 뭔가 스프링에서 에러를 그냥 던진것 같은 응답이 돌아왔다. 맞다. 이 경우 컨트롤러가 실행조차 되지 않았다.
  • 그 이유는, 사용자가 입력한 값이 HTTP 메시지 컨버터에 의해 객체로 컨버팅될 수가 없기 때문이다.
  • JSON 데이터는 필드 하나하나가 아니라 저 `{...}` 이 한뭉텅이가 곧 하나다. 그래서 그 데이터가 변환하려고 하는 객체에 일치하지 않는 부분이 있으면 안된다. 그래서 아예 변환 과정에서 실패했기 때문에 컨트롤러가 호출도 되지 않았고, 검증 자체도 실행되지 않았다. 
  • 즉, HttpMessageConverter 단계에서 실패하면 위처럼 예외가 발생한다.예외 발생 시 원하는 모양으로 예외를 처리하는 방법은 이후에 자세히 배워보자!

 

"어? 근데, API가 아니라 뷰 레이어로 검증할때는 타입이 맞지 않아도 컨트롤러도 잘 호출되고 검증도 잘 됐잖아요!?"

→ 맞다. @ModelAttribute@RequestBody는 처리 방식이 좀 다르다. HTTP 요청 파라미터(QueryString, Form Data)를 처리하는 @ModelAttribute는 각각의 필드 단위로 세밀하게 적용된다. 그래서 특정 필드에 타입이 맞지 않는 오류가 발생해도 나머지 필드는 정상 처리할 수 있었다.HttpMessageConverter@ModelAttribute와 다르게 각각의 필드 단위로 적용되는 것이 아니라, 전체 객체 단위로 적용된다. 따라서 메시지 컨버터의 작동이 성공해서 ItemSaveForm 객체를 만들어야 @Validated가 적용된다. 그래서 @ModelAttributeBean Validation을 적용하면 바인딩 된 필드만 검증 처리가 실행되고 바인딩부터 실패한 필드는 검증을 그냥 무시해버리고 바로 typeMismatch로 필드에러 생성 후 넘어가 버린다고 했다. @RequestBody는 메시지 컨버터에 의해 객체를 만들어 내지 못하면 아예 그냥 컨트롤러도 호출되지 않고 @Validated도 적용할 수 없다.

728x90
반응형
LIST
728x90
반응형
SMALL

참고자료

 

스프링 MVC 2편 - 백엔드 웹 개발 활용 기술 강의 | 김영한 - 인프런

김영한 | 웹 애플리케이션 개발에 필요한 모든 웹 기술을 기초부터 이해하고, 완성할 수 있습니다. MVC 2편에서는 MVC 1편의 핵심 원리와 구조 위에 실무 웹 개발에 필요한 모든 활용 기술들을 학습

www.inflearn.com

 

검증 

전달된 데이터에 대한 검증은 웹 애플리케이션에서 빠질 수 없는 요소이다. 누군가는 "앞단에서 검증 처리를 하면 되지 않나요?"라고 말할 수 있겠지만 앞단과 뒷단 모두 검증 처리를 당연히 해야 한다. 

 

왜냐하면, 앞단 검증은 조작이 가능하다. 예를 들면, 이름을 입력하는 필드에 문자가 들어갈텐데 여기에 SQL문을 작성해도 아무런 문제가 되지 않을수도 있다. 같은 문자열이고 문자열 길이에 제한이 없는 경우라면 말이다. 

뒷단만으로 검증을 한다면 즉각적인 고객 피드백이 부족해진다. 예를 들어, 숫자 입력 필드에 문자를 입력함과 동시에 바로 반응을 해주면 고객 친화적인 애플리케이션이 될텐데 서버에서만 검증하면 결국 데이터를 날리기 전까지는 모르니까 말이다.

 

따라서, 둘을 적절하게 섞어서 사용해야 한다. API 방식을 사용하면 API 스펙을 잘 정의해서 검증 오류를 API 응답 결과에 잘 남겨주어야 한다. 

 

스프링의 도움 없이 검증을 직접 해보기

우선, 검증을 직접 처리할때는 어떻게 하는지를 먼저 보고 이를 스프링이 얼마나 편하게 해주는지를 알아보자.

상품을 저장하는 로직이 있다고 하면 상품 저장에 성공 하거나 실패하는 케이스가 있을것이다. 

 

성공하는 케이스의 흐름은 다음과 같다.

1. 사용자가 상품 저장 화면 요청

2. 상품 저장 화면 응답

3. 상품 저장 화면에서 데이터 입력

4. 상품 저장 요청

5. 상품 저장 완료

 

실패하는 케이스의 흐름은 다음과 같다.

1. 사용자가 상품 저장 화면 요청

2. 상품 저장 화면 응답

3. 상품 저장 화면에서 데이터 입력

4. 상품 저장 요청

5. 검증 실패

6. 실패 원인 데이터와 함께 상품 저장 화면을 다시 출력

 

중요한 건, 사용자 경험을 위해 어떤 부분에서 어디가 잘못됐는지에 대한 내용을 포함한 원래 화면이 다시 나와야 한다는 것이다.

이제 직접 처리하는 코드를 작성해보자.

 

우선, @ModelAttributeItem 객체는 이렇게 생겼다.

package hello.itemservice.domain.item;

import lombok.Data;

@Data
public class Item {

    private Long id;
    private String itemName;
    private Integer price;
    private Integer quantity;

    public Item() {
    }

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

서버단 처리

@PostMapping("/add")
public String addItem(@ModelAttribute Item item, Model model, RedirectAttributes redirectAttributes) {

    // 검증 오류 결과를 보관
    Map<String, String> errors = new HashMap<>();

    // 검증 로직
    if (!StringUtils.hasText(item.getItemName())) {
        errors.put("itemName", "Item name is required");
    }
    if (item.getPrice() == null || item.getPrice() < 1_000 || item.getPrice() > 1_000_000) {
        errors.put("price", "Price must be between 1000 and 1,000,000");
    }
    if (item.getQuantity() == null || item.getQuantity() < 1 || item.getQuantity() > 9999) {
        errors.put("quantity", "Quantity must be between 1 and 9999");
    }

    // 특정 필드가 아닌 복합 룰 검증
    if (item.getPrice() != null && item.getQuantity() != null) {
        int resultPrice = item.getPrice() * item.getQuantity();
        if (resultPrice < 10_000) {
            errors.put("globalError", "price * quantity must be greater than 10,000. current price is " + resultPrice);
        }
    }

    // 에러가 있는 경우, 다시 폼을 보여줌
    if (hasError(errors)) {
        log.debug("[addItem] errors: {}", errors);
        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}";
}

private boolean hasError(Map<String, String> errors) {
    return !errors.isEmpty();
}

 

  • 상품 데이터를 저장 요청해서 POST 요청을 날리면 검증을 해야 한다. 그래서 컨트롤러의 POST 요청에서 검증을 수행한다.
  • 먼저 데이터에 에러가 있는 경우 어떤 데이터의 어떤 에러인지를 담기 위한 Map을 선언한다.
  • 차례대로 하나씩 검증을 시작한다. 검증 요구사항은 당연히 애플리케이션마다 천지차별이다. 여기서는 다음과 같다.
    • itemName은 없으면 안된다.
    • price는 없거나, 1,000원보다 작거나, 1,000,000원보다 크다면 잘못된 데이터이다.
    • quantity는 없거나, 1개보다 작거나, 9999개보다 크다면 잘못된 데이터이다.
    • price * quantity의 값이 10,000원을 넘지 않는다면 잘못된 데이터이다.
  • 에러가 있는 경우 modelerrors를 담아서 다시 상품 저장 폼을 보여준다.

이렇게 하면 끝이다. 그리고 만약 에러가 있어서 다시 상품 저장 화면이 출력되면, 타임리프 문법을 통해 errors에서 에러가 있는 경우 그 데이터를 출력만 해주면 된다.

 

검증 단계를 하나씩 살펴보자.

1. 에러가 있는 경우에 해당 에러를 보관할 Map을 선언

// 검증 오류 결과를 보관
Map<String, String> errors = new HashMap<>();

 

2. itemName은 없으면 안된다.

if (!StringUtils.hasText(item.getItemName())) {
    errors.put("itemName", "Item name is required");
}

 

3. price는 없거나, 1,000원보다 작거나, 1,000,000원보다 크면 안된다.

if (item.getPrice() == null || item.getPrice() < 1_000 || item.getPrice() > 1_000_000) {
    errors.put("price", "Price must be between 1000 and 1,000,000");
}

 

4. quantity는 없거나, 1보다 작거나, 9999보다 크면 안된다.

if (item.getQuantity() == null || item.getQuantity() < 1 || item.getQuantity() > 9999) {
    errors.put("quantity", "Quantity must be between 1 and 9999");
}

 

5. price * quantity의 값이 10,000원을 넘어야 한다.

if (item.getPrice() != null && item.getQuantity() != null) {
    int resultPrice = item.getPrice() * item.getQuantity();
    if (resultPrice < 10_000) {
        errors.put("globalError", "price * quantity must be greater than 10,000. current price is " + resultPrice);
    }
}

 

6. 이렇게 검증을 직접 처리를 한 후, 검증 결과를 확인해서 에러가 있는 경우 해당 에러 데이터를 포함해서 다시 등록 폼을 보여준다.

if (hasError(errors)) {
    log.debug("[addItem] errors: {}", errors);
    model.addAttribute("errors", errors);
    return "validation/v1/addForm";
}

 

여기까지가 서버에서 해 줄 작업이다. 그럼 앞단에서는 상품 등록 폼에서 에러가 있으면 해당 에러를 보여주면 된다.

 

앞단 처리

addForm.html

<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="utf-8">
    <link th:href="@{/css/bootstrap.min.css}"
          href="../css/bootstrap.min.css" rel="stylesheet">
    <style>
        .container {
            max-width: 560px;
        }
    </style>
</head>
<body>

<div class="container">

    <div class="py-5 text-center">
        <h2 th:text="#{page.addItem}">상품 등록</h2>
    </div>

    <form action="item.html" th:action th:object="${item}" method="post">
        <div>
            <label for="itemName" th:text="#{label.item.itemName}">상품명</label>
            <input type="text" id="itemName" th:field="*{itemName}" class="form-control" placeholder="이름을 입력하세요">
        </div>
        <div>
            <label for="price" th:text="#{label.item.price}">가격</label>
            <input type="text" id="price" th:field="*{price}" class="form-control" placeholder="가격을 입력하세요">
        </div>
        <div>
            <label for="quantity" th:text="#{label.item.quantity}">수량</label>
            <input type="text" id="quantity" th:field="*{quantity}" class="form-control" placeholder="수량을 입력하세요">
        </div>

        <hr class="my-4">

        <div class="row">
            <div class="col">
                <button class="w-100 btn btn-primary btn-lg" type="submit" th:text="#{button.save}">상품 등록</button>
            </div>
            <div class="col">
                <button class="w-100 btn btn-secondary btn-lg"
                        onclick="location.href='items.html'"
                        th:onclick="|location.href='@{/validation/v1/items}'|"
                        type="button" th:text="#{button.cancel}">취소</button>
            </div>
        </div>
    </form>

</div> <!-- /container -->
</body>
</html>
  • 이렇게 타임리프로 만들어진 상품 등록 폼이 있다. 이 폼에 하나씩 에러 처리 부분을 적용해보자.

globalError

우선, 어떤 특정 필드 처리가 아니라 전체적으로 적용되는 에러가 있었다. 

그 에러가 발생하는 경우는 `가격 * 수량 < 10,000`인 경우이다.

 

그래서 form 태그 안에 바로 위에 이 부분을 넣어주자.

<div th:if="${errors?.containsKey('globalError')}">
    <p class="field-error" th:text="${errors['globalError']}">글로벌 에러 라인</p>
</div>
  • 만약, 에러가 발생해서 다시 상품 등록 폼으로 돌아왔다면, 에러 내용을 담은 errors라는 Map이 있을 것이다.
  • errorsglobalError가 있다면 이 부분이 렌더링된다.

그래서 이 globalError를 추가한 전체 코드는 이렇게 된다. (위에 스타일도 들어간 것 확인. 아무래도 빨간색이 에러 내용에 적합하니)

<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="utf-8">
    <link th:href="@{/css/bootstrap.min.css}"
          href="../css/bootstrap.min.css" rel="stylesheet">
    <style>
        .container {
            max-width: 560px;
        }
        .field-error {
            border-color: #dc3545;
            color: #dc3545;
        }
    </style>
</head>
<body>

<div class="container">

    <div class="py-5 text-center">
        <h2 th:text="#{page.addItem}">상품 등록</h2>
    </div>

    <form action="item.html" th:action th:object="${item}" method="post">

        <div th:if="${errors?.containsKey('globalError')}">
            <p class="field-error" th:text="${errors['globalError']}">글로벌 에러 라인</p>
        </div>

        <div>
            <label for="itemName" th:text="#{label.item.itemName}">상품명</label>
            <input type="text" id="itemName" th:field="*{itemName}" class="form-control" placeholder="이름을 입력하세요">
        </div>
        <div>
            <label for="price" th:text="#{label.item.price}">가격</label>
            <input type="text" id="price" th:field="*{price}" class="form-control" placeholder="가격을 입력하세요">
        </div>
        <div>
            <label for="quantity" th:text="#{label.item.quantity}">수량</label>
            <input type="text" id="quantity" th:field="*{quantity}" class="form-control" placeholder="수량을 입력하세요">
        </div>

        <hr class="my-4">

        <div class="row">
            <div class="col">
                <button class="w-100 btn btn-primary btn-lg" type="submit" th:text="#{button.save}">상품 등록</button>
            </div>
            <div class="col">
                <button class="w-100 btn btn-secondary btn-lg"
                        onclick="location.href='items.html'"
                        th:onclick="|location.href='@{/validation/v1/items}'|"
                        type="button" th:text="#{button.cancel}">취소</button>
            </div>
        </div>

    </form>

</div> <!-- /container -->
</body>
</html>

 

그래서 이 상태에서 내가 globalError를 발생시키는 데이터를 전송하면 다음과 같이 나오게 된다. 

 

 

itemName

이번엔 itemName에 대한 에러 내용을 출력해보자.

<div>
    <label for="itemName" th:text="#{label.item.itemName}">상품명</label>
    <input type="text" id="itemName" th:field="*{itemName}" class="form-control" placeholder="이름을 입력하세요">
    <div class="field-error" th:if="${errors?.containsKey('itemName')}" th:text="${errors['itemName']}">상품명 오류</div>
</div>

 

  • itemName 필드를 출력하는 부분에 다음과 같이 에러 출력 div 태그 하나를 추가해주면 된다.

그럼 이렇게 보여진다. 간단하다.

근데, 지금 상태에서 조금 더 고객에 친절한 애플리케이션이 되고 싶다면 필드의 border-color를 추가해주면 좋을것 같다.

<input type="text" id="itemName" th:field="*{itemName}" th:class="${errors.containsKey('itemName')} ? 'form-control field-error' : 'form-control'" placeholder="이름을 입력하세요">
  • 타임리프의 도움을 받아 th:class로 클래스를 추가할 수 있는데, 여기서 errorsitemName을 가지고 있냐 없냐에 따라 분기할 수 있다.

그래서 최종적으로 이렇게 만들 수 있다.

<div>
    <label for="itemName" th:text="#{label.item.itemName}">상품명</label>
    <input type="text" id="itemName" th:field="*{itemName}" th:class="${errors.containsKey('itemName')} ? 'form-control field-error' : 'form-control'" placeholder="이름을 입력하세요">
    <div class="field-error" th:if="${errors?.containsKey('itemName')}" th:text="${errors['itemName']}">상품명 오류</div>
</div>

그리고 이렇게 한 결과를 보면 조금 더 이쁘다는 것을 알 수 있다.

타임리프에서 지원하는 classappend를 사용해도 된다.

<div>
    <label for="itemName" th:text="#{label.item.itemName}">상품명</label>
    <input type="text" id="itemName" th:field="*{itemName}" class="form-control" th:classappend="${errors?.containsKey('itemName')} ? 'field-error' : _" placeholder="이름을 입력하세요">
    <div class="field-error" th:if="${errors?.containsKey('itemName')}" th:text="${errors['itemName']}">상품명 오류</div>
</div>

저 값이 있으면 추가하고자 하는 클래스만 작성하고, 없으면 `_(아무것도 하지 않음)` 처리를 해주는 것도 가능하다.

 

price

이제 가격 차례다. 위 itemName이랑 똑같이 하면 된다.

<div>
    <label for="price" th:text="#{label.item.price}">가격</label>
    <input type="text" id="price" th:field="*{price}" th:class="${errors?.containsKey('price')} ? 'form-control field-error' : 'form-control'" placeholder="가격을 입력하세요">
    <div class="field-error" th:if="${errors?.containsKey('price')}" th:text="${errors['price']}">상품명 오류</div>
</div>

 

quantity

<div>
    <label for="quantity" th:text="#{label.item.quantity}">수량</label>
    <input type="text" id="quantity" th:field="*{quantity}" th:class="${errors?.containsKey('quantity')} ? 'form-control field-error' : 'form-control'" placeholder="수량을 입력하세요">
    <div class="field-error" th:if="${errors?.containsKey('quantity')}" th:text="${errors['quantity']}">상품명 오류</div>
</div>

 

 

여기까지 하면, 뭔가 검증 다 끝난거 같은데? 하면 큰 오산이다. 우선 남은 문제점이 있다.

  • 타입 안정성이 해결되지 않았다. 예를 들어, 가격에 'qqq'라고 작성해도 검증이 되는게 아니라 아예 컨트롤러 자체가 호출이 되지 않을 것이다. 왜냐하면 Item이라는 객체에 데이터를 담아서 그 값들을 하나씩 검증해야 하는데 Item안에 price 필드는 Integer 타입이라 스트링 값 자체가 들어가지 않는다. 
  • 뷰 템플릿에서 중복 처리가 많다.

이런 것들을 직접 처리한다면 얼마나 괴로울까? 스프링이 다 도와준다. 이제 스프링이 도와주는 것들을 하나씩 차근차근 알아보자.

 

스프링의 도움을 받아 검증 처리하기

이제 스프링이 도와주는 것들을 사용해서 하나씩 세련된 코드를 작성해보자. 

BindingResult

바로 이 BindingResult라는 스프링이 만들어 놓은 좋은 녀석을 사용하면 된다.

근데, 이 녀석 사용할때 주의할 점은 @ModelAttribute 바로 뒤에 와야한다는 것이다.

이유는 무엇이냐면, ResultBinding한다는 것은 사용자가 입력한 데이터를 Result 해서 어떤 객체에 바인딩시켜야 한다는 것을 의미한다. 그렇다면 바인딩 할 대상이 있어야 하고 그 대상을 스프링에서 지정하기를 바로 앞에 있는 @ModelAttribute로 선언된 객체라고 정의한 것이다.

서버단 처리

@PostMapping("/add")
public String addItemV1(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) {
    // 검증 로직
    if (!StringUtils.hasText(item.getItemName())) {
        bindingResult.addError(new FieldError("item", "itemName", "Item name is required"));
    }
    if (item.getPrice() == null || item.getPrice() < 1_000 || item.getPrice() > 1_000_000) {
        bindingResult.addError(new FieldError("item", "price", "Price must be between 1 and 1_000_000"));
    }
    if (item.getQuantity() == null || item.getQuantity() < 1 || item.getQuantity() > 9999) {
        bindingResult.addError(new FieldError("item", "quantity", "Quantity must be between 1 and 9999"));
    }

    // 특정 필드가 아닌 복합 룰 검증
    if (item.getPrice() != null && item.getQuantity() != null) {
        int resultPrice = item.getPrice() * item.getQuantity();
        if (resultPrice < 10_000) {
            bindingResult.addError(new ObjectError("item", "price * quantity must be greater than 10,000. current price is " + resultPrice));
        }
    }

    // 에러가 있는 경우, 다시 폼을 보여줌
    if (bindingResult.hasErrors()) {
        log.debug("[addItemV1] bindingResult errors: {}", bindingResult);
        return "validation/v2/addForm";
    }

    Item savedItem = itemRepository.save(item);
    redirectAttributes.addAttribute("itemId", savedItem.getId());
    redirectAttributes.addAttribute("status", true);
    return "redirect:/validation/v2/items/{itemId}";
}
  • 파라미터 부분에 @ModelAttribute Item 바로 뒤에 BindingResult가 나온것을 알 수 있다. 이게 규칙이기 때문에 반드시 이렇게 해야한다.
  • 코드를 보니 뭔가 엄청 깔끔해진 것 같고 코드 양도 줄어보인다.

검증 단계를 하나씩 살펴보자.

1. 에러가 있는 경우에 해당 에러를 보관할 Map을 선언

BindingResult를 파라미터로 선언한 순간부터 더 할 작업이 없다.

 

2. itemName은 없으면 안된다.

if (!StringUtils.hasText(item.getItemName())) {
    bindingResult.addError(new FieldError("item", "itemName", "Item name is required"));
}
  • BindingResult가 가지고 있는 addError()를 호출한다.
  • addError()는 파라미터로 ObjectError를 받는다. 그것을 상속받는 FieldErrornew로 생성한다.
  • FieldError는 말 그대로 특정 필드에 대한 에러를 저장하는 객체이다. 그리고 파라미터는 3가지를 받는다.
    • objectName → @ModelAttribute로 선언한 객체를 의미한다.
    • field → 위 objectName으로 선언한 객체의 특정 필드인 itemName을 의미한다.
    • defaultMessage → 보여줄 에러 메시지를 의미한다.

3. price는 없거나, 1,000원보다 작거나, 1,000,000원보다 크면 안된다.

if (item.getPrice() == null || item.getPrice() < 1_000 || item.getPrice() > 1_000_000) {
    bindingResult.addError(new FieldError("item", "price", "Price must be between 1 and 1_000_000"));
}

 

4. quantity는 없거나, 1보다 작거나, 9999보다 크면 안된다.

if (item.getQuantity() == null || item.getQuantity() < 1 || item.getQuantity() > 9999) {
    bindingResult.addError(new FieldError("item", "quantity", "Quantity must be between 1 and 9999"));
}

 

5. price * quantity의 값이 10,000원을 넘어야 한다.

if (item.getPrice() != null && item.getQuantity() != null) {
    int resultPrice = item.getPrice() * item.getQuantity();
    if (resultPrice < 10_000) {
        bindingResult.addError(new ObjectError("item", "price * quantity must be greater than 10,000. current price is " + resultPrice));
    }
}
  • 이번엔 특정 필드가 아니라 글로벌 에러를 저장할 때 예시다. 
  • 이번엔 ObjectErrornew로 생성해서 넣으면 된다. 이 객체는 두가지 파라미터를 받는다.
    • objectName → @ModelAttribute로 선언한 객체를 의미한다.
    • defaultMessage → 보여줄 에러 메시지를 의미한다.

6. 이렇게 검증을 직접 처리를 한 후, 검증 결과를 확인해서 에러가 있는 경우 해당 에러 데이터를 포함해서 다시 등록 폼을 보여준다.

if (bindingResult.hasErrors()) {
    log.debug("[addItemV1] bindingResult errors: {}", bindingResult);
    return "validation/v2/addForm";
}

 

여기까지가 서버에서 해 줄 작업이다. 그럼 앞단에서는 상품 등록 폼에서 에러가 있으면 해당 에러를 보여주면 된다.

 

앞단 처리

앞단 처리가 예술이다. 스프링과 타임리프를 사용해서 웹 애플리케이션을 만든다면 꽤나 검증 처리를 재밌게 할 수 있다.

<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="utf-8">
    <link th:href="@{/css/bootstrap.min.css}"
          href="../css/bootstrap.min.css" rel="stylesheet">
    <style>
        .container {
            max-width: 560px;
        }
        .field-error {
            border-color: #dc3545;
            color: #dc3545;
        }
    </style>
</head>
<body>

<div class="container">

    <div class="py-5 text-center">
        <h2 th:text="#{page.addItem}">상품 등록</h2>
    </div>

    <form action="item.html" th:action th:object="${item}" method="post">

        <div th:if="${#fields.hasGlobalErrors()}">
            <p class="field-error" th:each="err : ${#fields.globalErrors()}" th:text="${err}">글로벌 에러 라인</p>
        </div>

        <div>
            <label for="itemName" th:text="#{label.item.itemName}">상품명</label>
            <input type="text" id="itemName" th:field="*{itemName}" class="form-control" th:errorclass="field-error" placeholder="이름을 입력하세요">
            <div class="field-error" th:errors="*{itemName}">상품명 오류</div>
        </div>
        <div>
            <label for="price" th:text="#{label.item.price}">가격</label>
            <input type="text" id="price" th:field="*{price}" class="form-control" th:errorclass="field-error" placeholder="가격을 입력하세요">
            <div class="field-error" th:errors="*{price}">상품명 오류</div>
        </div>
        <div>
            <label for="quantity" th:text="#{label.item.quantity}">수량</label>
            <input type="text" id="quantity" th:field="*{quantity}" class="form-control" th:errorclass="field-error" placeholder="수량을 입력하세요">
            <div class="field-error" th:errors="*{quantity}">상품명 오류</div>
        </div>

        <hr class="my-4">

        <div class="row">
            <div class="col">
                <button class="w-100 btn btn-primary btn-lg" type="submit" th:text="#{button.save}">상품 등록</button>
            </div>
            <div class="col">
                <button class="w-100 btn btn-secondary btn-lg"
                        onclick="location.href='items.html'"
                        th:onclick="|location.href='@{/validation/v2/items}'|"
                        type="button" th:text="#{button.cancel}">취소</button>
            </div>
        </div>

    </form>

</div> <!-- /container -->
</body>
</html>

 

globalError

<div th:if="${#fields.hasGlobalErrors()}">
    <p class="field-error" th:each="err : ${#fields.globalErrors()}" th:text="${err}">글로벌 에러 라인</p>
</div>
  • 글로벌 에러는 복수로 존재할 수 있다. 그래서 th:if="${#fields.hasGlobalErrors()}" 이 부분을 보면 글로벌 에러가 있는지 먼저 체크하는데 거기에 hasGlobalErrors라고 복수형태로 확인하는 것을 볼 수 있다.
  • #fields: #fieldsBindingResult가 제공하는 검증 오류에 접근할 수 있다.

 

itemName

<div>
    <label for="itemName" th:text="#{label.item.itemName}">상품명</label>
    <input type="text" id="itemName" th:field="*{itemName}" class="form-control" th:errorclass="field-error" placeholder="이름을 입력하세요">
    <div class="field-error" th:errors="*{itemName}">상품명 오류</div>
</div>
  • th:errors: 해당 필드에 오류가 있는 경우에 태그를 출력한다. th:if의 편의 버전이다. 
  • th:errorclass: th:field에서 지정한 필드에 오류가 있으면 class 정보를 추가한다.

price

<div>
    <label for="price" th:text="#{label.item.price}">가격</label>
    <input type="text" id="price" th:field="*{price}" class="form-control" th:errorclass="field-error" placeholder="가격을 입력하세요">
    <div class="field-error" th:errors="*{price}">상품명 오류</div>
</div>

 

quantity

<div>
    <label for="quantity" th:text="#{label.item.quantity}">수량</label>
    <input type="text" id="quantity" th:field="*{quantity}" class="form-control" th:errorclass="field-error" placeholder="수량을 입력하세요">
    <div class="field-error" th:errors="*{quantity}">상품명 오류</div>
</div>

 

아까보다 훨씬 코드가 깔끔해지고 중복도 제거됐음을 확인할 수 있다. 그러면, 아까 여전히 남아있던 문제점을 이 BindingResult는 해결해줄까? 

 

  • @ModelAttribute에 바인딩 시 타입 오류가 발생하면? 그러니까 가격에 'qqq'와 같은 문자열을 넣는다면?
    • BindingResult가 없으면 400 에러가 발생하면서 컨트롤러가 호출되지 않고 오류 페이지로 이동하게 된다.
    • BindingResult가 있으면 오류 정보를 BindingResult에 담아서 컨트롤러를 정상 호출한다. 결국 바인딩 하는 것 자체도 검증에 포함을 시켜주는 게 이 BindingResult인 것이다.
    • 그럼 어떻게? → @ModelAttribute의 객체에 타입 오류 등으로 바인딩이 실패하는 경우 스프링이 FieldError를 생성해서 BindingResult에 직접 넣어준다.

 

그럼 이제, BindingResult를 사용해서 훨씬 더 편리하게 검증을 할 수가 있다.

그런데 남은 문제가 있다. 오류가 발생하면 고객이 입력한 내용이 모두 사라진다는 점이다. 아래 사진을 보자.

 

고객의 요청데이터

오류가 발생한 결과

이렇듯 가격 필드에 입력했던 '10'이 사라졌다. 이것을 해결해보자.

 

FieldError의 생성자

bindingResult.addError(new FieldError("item", "itemName", "Item name is required"));

앞서 해본것처럼 어떤 필드에 에러가 있으면 이렇게 addError()를 호출해서 FieldError를 만들어 넣었다. 이때, FieldError는 여러 생성자를 가지는데 그 중에 사용자가 입력한 값을 보존하는 생성자가 있다.

 

그래서 위에서 처리했던, itemName, price, quantity 필드에 대한 에러를 추가할 때 이렇게 변경한다.

itemName

bindingResult.addError(new FieldError("item", "itemName", item.getItemName(), false, null, null, "Item name is required"));

 

price

bindingResult.addError(new FieldError("item", "price", item.getPrice(), false, null, null, "Price must be between 1 and 1_000_000"));

 

quantity

bindingResult.addError(new FieldError("item", "quantity", item.getQuantity(), false, null, null, "Quantity must be between 1 and 9999"));

 

  • 첫번째 인자로는, objectName을 그대로 받는다.
  • 두번째 인자로는, field를 그대로 받는다.
  • 세번째 인자로는, rejectedValue이다. 사용자가 입력했던 값을 집어넣는다. 즉, 사용자가 입력한 값을 @ModelAttribute에서 알아서 바인딩을 해주는데 그 값을 여기에 넣어주는 것이다.
  • 네번째 인자로는, 바인딩 자체가 실패했는지를 묻는다. 즉, Integer로 들어올 값이 String으로 들어왔다던가 이런 바인딩 자체의 실패의 여부이다. 여기서는 그건 아니니까 false를 모두 입력한다.
  • 다섯번째 인자로는, codes 이다. 이건 이후에 더 자세히 얘기한다.
  • 여섯번째 인자로는, arguments 이다. 이건 이후에 더 자세히 얘기한다.
  • 일곱번째 인자로는, 사용자에게 보여줄 메시지이다.

그래서 이렇게 FieldError의 또다른 생성자를 사용해서 넣어주면 된다. 다른 말로 FieldError가 가지고 있는 rejectedValue에 값을 넣어주면 타임리프에서 에러가 있는 경우 이 값을 보여주게 될 것이다. 

 

그래서 itemName으로 예시를 들자면,

<div>
    <label for="itemName" th:text="#{label.item.itemName}">상품명</label>
    <input type="text" id="itemName" th:field="*{itemName}" class="form-control" th:errorclass="field-error" placeholder="이름을 입력하세요">
    <div class="field-error" th:errors="*{itemName}">상품명 오류</div>
</div>
  • th:field="*{itemName}" 이 녀석이 정말 똑똑하게도 정상 상황에서는 모델 객체의 값을 사용하고, 에러가 발생했으면 FieldError에서 보관하고 있는 rejectedValue값을 사용해서 출력한다.

그래서 이제는 아래처럼 에러가 나도 사용자의 입력값을 보관한다.

 

 

남은 문제는 무엇일까?

바인딩 자체가 안되는 경우에 이러한 모습이 보여진다.

스프링이 이렇게 바인딩 자체에 실패한 경우에 BindingResult에 직접 해당 에러를 넣어주는데 그때 보이는 저 에러 메시지가 상당히 불친절하다. 사용자 입장에서는 이해할 수 없는 내용이다. 이 부분을 해결해보자.

 

FieldError생성자 2

위에서 말한 문제를 해결하기 앞서, 아까 봤던 FieldError의 생성자에 대해 좀 더 알아보는 시간을 먼저 가져보자.

잠깐 살펴봤지만 파라미터로 들어가는 값 중, codes, arguments가 있었다.

 

만약 이 웹 애플리케이션이 규모가 엄청 크고 비슷한 에러 문구가 나오는 화면이 여러개라면 그때마다 일일이 에러메시지를 작성하기는 싫을 것이다. 또한 그렇게 한 경우 변경할 필요가 생겼을 때 하나하나 다 변경해줘야 한다. 즉, 이 BindingResult로 에러를 저장하는 것도 메시지 기능을 사용할 수가 있다.

 

그래서 메시지 기능을 적용해보자. 우선 그러려면 스프링한테 메시지를 어디에 보관할건지 알려줘야 한다.

application.yaml

spring.messages.basename=messages,errors
  • 기본값이 저 messages라고 메시지, 국제화 포스팅에서 말한 바 있다.
  • 만약, 다른 파일이 더 있다면 추가적으로 저렇게 작성해줘야 한다. 나는 에러 메시지에 대해 파일을 만들거니까 이름을 errors라고 했다.

그리고 메시지 파일 하나를 만든다.

errors.properties

required.item.itemName=상품 이름은 필수입니다.
range.item.price=가격은 {0} ~ {1} 까지 허용합니다.
max.item.quantity=수량은 최대 {0} 까지 허용합니다.
totalPriceMin=가격 * 수량의 합은 {0}원 이상이어야 합니다. 현재 값 = {1}
  • 당연히 이 또한 국제화도 가능하다. errors_en.properties 파일을 만들어서 국제화 기능 충분히 사용할 수 있다.
  • 이렇게 보고 나니, codes 파라미터 말고 arguments 파라미터는 뭘 의미하는지 바로 알 것 같다. 바로 저 {0}, {1}에 들어갈 배열이고 숫자는 배열의 인덱스를 의미한다.

 

에러 메시지 파일을 만들었으면 이것을 가져다가 사용하면 된다. 아래처럼.

if (!StringUtils.hasText(item.getItemName())) {
    bindingResult.addError(new FieldError("item", "itemName", item.getItemName(), false, new String[]{"required.item.itemName"}, null, null));
}
if (item.getPrice() == null || item.getPrice() < 1_000 || item.getPrice() > 1_000_000) {
    bindingResult.addError(new FieldError("item", "price", item.getPrice(), false, new String[]{"range.item.price"}, new Object[]{1_000, 1_000_000}, null));
}
if (item.getQuantity() == null || item.getQuantity() < 1 || item.getQuantity() > 9999) {
    bindingResult.addError(new FieldError("item", "quantity", item.getQuantity(), false, new String[]{"max.item.quantity"}, new Object[]{9999}, null));
}
  • arguments는 여러개가 있을 수 있으니 배열로 받는게 이해가 된다. 근데 왜 codes도 배열일까?
  • 그 이유는 0번째 인덱스가 가장 우선순위가 높고 여기서부터 찾으면 바로 적용을 하지만 만약 그 코드를 찾지 못하면 다음 인덱스를 찾아가면서 제일 먼저 찾아진 코드를 메시지로 적용하게 설계됐기 때문이다.
  • 그리고 배열에 모든 인덱스를 다 뒤져도 값을 찾지 못한 경우 가장 마지막 파라미터인 defaultMessage값을 적용하는데 이 값마저 null이면 에러가 발생할 것이다.

 

이렇게 사용되는게 FieldErrorcodes, arguments 파라미터이다.

근데, 솔직히 이런 생각이 든다. "불편한데..?" 맞다. 솔직히 파라미터가 너무 많고 사용하기가 꽤 불편하다. 근데 스프링이 이렇게 불편하다고 느끼는 것들을 가만히 놔두지 않는다. 더 개선한다. 그래서 어떻게 개선할 수 있을까?

 

BindingResult의 위치

위에서 말했듯 이 BindingResult는 무조건 @ModelAttribute 객체 바로 다음에 와야 한다고 했다. 그 말은, 이미 BindingResult는 어떤 객체에 데이터를 바인딩 해야 하는지 알고 있다는 뜻이다. 그래서 이 BindingResult에는 이런 메서드가 있다.

 

  • getTarget()
  • getObjectName()

그래서 한번 이렇게 코드를 작성해보자.

@PostMapping("/add")
public String addItemV4(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) {
    log.debug("target: {}", bindingResult.getTarget());
    log.debug("objectName: {}", bindingResult.getObjectName());
    
    ...
}

 

2024-08-31 17:54:24.562 DEBUG 51870 --- [nio-8080-exec-1] h.i.w.v.ValidationItemControllerV2       : target: Item(id=null, itemName=, price=null, quantity=null)
2024-08-31 17:54:24.571 DEBUG 51870 --- [nio-8080-exec-1] h.i.w.v.ValidationItemControllerV2       : objectName: item

이미 targetobjectName을 알고 있다. 그 말은 FieldError()라는 객체를 만들 때, objectName 이런 값을 굳이 넣을 필요가 없을 것 같다. 그래서 BindingResult가 다음과 같은 메서드를 제공한다.

 

if (!StringUtils.hasText(item.getItemName())) {
    bindingResult.rejectValue("itemName", "required");
}
if (item.getPrice() == null || item.getPrice() < 1_000 || item.getPrice() > 1_000_000) {
    bindingResult.rejectValue("price", "range", new Object[]{1_000, 1_000_000}, null);
}
if (item.getQuantity() == null || item.getQuantity() < 1 || item.getQuantity() > 9999) {
    bindingResult.rejectValue("quantity", "max", new Object[]{9999}, null);
}
  • rejectValue() 메서드를 사용하면, 그냥 어떤 필드이고 에러 메시지에 대한 코드가 어떤 코드인지만 작성하면 끝난다. 근데 여기서 코드를 그냥 "required"만 적었다. 우리가 작성한 코드는 "required.item.itemName"이었다. 어떻게 저렇게만 작성해도 될까? 이후에 자세히 알아보자.
  • 그리고 세번째 인자와 네번째 인자가 필요한 경우는 이제 codearguments가 필요할 때이다.
if (item.getPrice() != null && item.getQuantity() != null) {
    int resultPrice = item.getPrice() * item.getQuantity();
    if (resultPrice < 10_000) {
        bindingResult.reject("totalPriceMin", new Object[]{10_000, resultPrice}, null);
    }
}
  • globalError의 경우, reject()라는 메서드를 사용하면 된다. 특정 필드에 한정 짓는게 아니기 때문에 필드 이름은 필요없다.

이렇게 작성해서 실행해도 에러 메시지를 잘 출력해준다. 흐름 자체는 이해가 쉽다. 근데 저 오류 코드가 저렇게만 작성해도 정상적으로 동작한다는 게 받아들이기 쉽지 않다. 이 내용에 대해 자세히 알아보자!

 

스프링에서는 언제나 디테일한게 우선순위가 높다

굉장히 중요한 제목이다. 위 codes의 비밀도 이것과 연관이 있다. 다음과 같이 errors.properties 파일을 수정해보자.

errors.properties

required=필수값 입니다.
required.item.itemName=상품 이름은 필수입니다.


range.item.price=가격은 {0} ~ {1} 까지 허용합니다.
max.item.quantity=수량은 최대 {0} 까지 허용합니다.
totalPriceMin=가격 * 수량의 합은 {0}원 이상이어야 합니다. 현재 값 = {1}

이렇게 되어 있을때, 아래 코드는 어떤 메시지를 출력할까?

bindingResult.rejectValue("itemName", "required");

 

`required.item.itemName`을 출력했음을 알 수 있다. 

 

"엥?! 왜 정확히 `required`라는 키가 있는데 저걸 출력하지?" 이게 스프링에서 우선순위를 결정하는 방식이다.

단순히 `required` 이렇게만 작성한 에러 메시지는 굉장히 범용적이다. 그래서 여기저기서 가져다가 사용할 수 있을 것 같다.

근데 개발을 하다 보니 어떤 화면에서는 조금 더 사용자 친화적으로 자세한 내용을 알려주고 싶은 필수 필드 내용이 있는 것이다. 

그런 경우에 이렇게 더 디테일하게 키를 작성하면 그 값이 스프링은 더 우선순위를 높게 친다. 

 

그래서, `required.item.itemName`, `required` 이렇게 두개가 나란히 있어도 더 자세한 값인 `required.item.itemName`를 출력하는 것이다. 그래서 개발하는 입장에서는 단순하게 그냥 아래처럼 코드를 작성해두면,

bindingResult.rejectValue("itemName", "required");

추후에 더 자세한 메시지가 필요하다고 느껴져서 erros.properties 파일에 자세한 키를 추가만 하면 된다. 개발 코드에 아무런 변화를 주지 않아도 된다는 뜻이다. 이게 가장 중요하다. 

 

근데 의문이 들것이다. "아니 그럼 `required.item.itemName`을 어떻게 만들어내는 것인가? 그냥 단순히 앞에 `required`가 있는 에러 메시지 키를 다 찾는것인가?" 스프링은 에러 코드를 만들기 위한 구현체도 다 있다. MessageCodesResolver라는 인터페이스를 구현한 구현체를 통해 저 에러 코드를 만들어 낸다. 

 

다음 코드를 보자.

MessageCodesResolverTest

package hello.itemservice.validation;

import org.junit.jupiter.api.Test;
import org.springframework.validation.DefaultMessageCodesResolver;
import org.springframework.validation.FieldError;
import org.springframework.validation.MessageCodesResolver;

import static org.assertj.core.api.Assertions.*;

public class MessageCodesResolverTest {

    MessageCodesResolver messageCodesResolver = new DefaultMessageCodesResolver();

    @Test
    void messageCodesResolverObject() {
        String[] messageCodes = messageCodesResolver.resolveMessageCodes("required", "item");
        for (String messageCode : messageCodes) {
            System.out.println("messageCode = " + messageCode);
        }

        assertThat(messageCodes).containsExactly("required.item", "required");
    }
}
  • MessageCodesResolver의 기본 구현체로 스프링은 DefaultMessageCodesResolver를 사용한다.
  • 그리고 그 구현체는 resolveMessageCodes(String s, String s1)라는 메서드를 가지고 있는데, 여기에서 저렇게 두 가지 문자열인 "required", "item" 이라는 값을 넣으면 어떻게 출력되냐면 다음과 같이 출력된다.
messageCode = required.item
messageCode = required

 

그러니까 넘겨 받은 파라미터를 통해 디테일 한 코드부터 디테일하지 않은 코드를 만들어내는 것이다.

 

다음 코드도 보자.

@Test
void messageCodesResolverField() {
    String[] messageCodes = messageCodesResolver.resolveMessageCodes("required", "item", "itemName", String.class);
    for (String messageCode : messageCodes) {
        System.out.println("messageCode = " + messageCode);
    }
}
  • resolveMessageCodes(String s, String s1, String s2, Class<?> aClass) 메서드도 존재하는데 이 메서드에 파라미터로 "required", "item", "itemName", String.class라고 넣으면 어떤 값이 나올까?
messageCode = required.item.itemName
messageCode = required.itemName
messageCode = required.java.lang.String
messageCode = required

 

이렇게 나오게 된다. 그리고 BindingResultreject(), rejectValue() 메서드는 내부에서 이 MessageCodesResolver를 사용한다. 

 

"하지만? rejectValue()는 파라미터로 필드 이름하고 코드만 넘기고, reject()는 코드 이름만 넘기는데요?"

그렇다, 근데 BindingResult는 뭘 알고 있냐? 바로 objectName을 알고 있다. 파라미터의 위치로 이미 알고 있는 상태이다. 그래서! 결국 resolveMessageCodes("required", "item", "itemName", String.class) 라는 메서드를 똑같이 사용할 수 있고 그 메서드를 통해 얻은 반환값을 통해 사실은 아래 코드를 만든다.

new FieldError("item", "itemName", item.getItemName(), messageCodes, null, null);
  • 그럼 저 String[] codes 파라미터 부분에 들어가는 messageCodes 위에서 만든 messageCodes 그대로 들어가게 된다.
  • 그리고 저 부분을 얘기하면서 0번 인덱스가 가장 우선순위가 높고 찾지 못하면 다음 인덱스 순서로 에러메시지를 찾아서 타임리프가 뿌려준다고 했다. 
  • 그럼 0번 인덱스는? "required.item.itemName"이다. 가장 마지막 인덱스는? "required"이다.

그래서 아래와 같은 코드와 에러 메시지가 있을 때 우선순위는 required.item.itemName이 적용되어 이 메시지가 화면에 보이는 것이다.

bindingResult.rejectValue("itemName", "required");

errors.properties

required=필수값 입니다.
required.item.itemName=상품 이름은 필수입니다.


range.item.price=가격은 {0} ~ {1} 까지 허용합니다.
max.item.quantity=수량은 최대 {0} 까지 허용합니다.
totalPriceMin=가격 * 수량의 합은 {0}원 이상이어야 합니다. 현재 값 = {1}

 

이제야 의문이 해소가 됐다. 참고로, DefaultMessageCodesResolver의 기본 메시지 생성 규칙은 다음과 같다.

 

객체 오류

객체 오류의 경우 다음 순서로 2가지 생성 
1.: code + "." + object name 
2.: code

예) 오류 코드: required, object name: item
1.: required.item
2.: required

 

필드 오류

필드 오류의 경우 다음 순서로 4가지 메시지 코드 생성 
1.: code + "." + object name + "." + field 
2.: code + "." + field
3.: code + "." + field type
4.: code

예) 오류 코드: typeMismatch, object name "user", field "age", field type: int 
1. "typeMismatch.user.age"
2. "typeMismatch.age"
3. "typeMismatch.int"
4. "typeMismatch"

 

BindingResult는 이런 숨겨진 내부 로직이 존재하는 것이다! 즉, 디테일한 코드를 먼저 생성해내기 때문에 디테일 한 코드부터 제일 디테일하지 않은 코드를 쭉 순회하면서 먼저 찾아진 코드를 그대로 사용한다. 

 

다시 강조하지만 중요한건, 메시지 코드의 변경이 필요할때, 예를 들어, "필수값 입니다." → "이름 필드는 필수값 입니다" 이런식으로 메시지를 바꾸고 싶을 때 애플리케이션 코드를 변경하는 게 아니라 메시지를 디테일하게 추가함으로써 원하는 결과를 얻어낼 수 있다는 것이다.

 

참고로, ValidationUtils 라는 유틸 클래스가 있다. 이걸 사용하면 코드 양을 좀 줄여주는 효과가 있다.

ValidationUtils.rejectIfEmptyOrWhitespace(bindingResult, "itemName", "required");
if (!StringUtils.hasText(item.getItemName())) {
    bindingResult.rejectValue("itemName", "required");
}

위 두 코드가 같은 코드이다. 근데 ValidationUtils는 저렇게 빈 문자열인 경우에 검증 처리같은 매우 간단한 기능만 있기 때문에 그냥 참고정도로만 알아두면 될 것 같다.

 

 

스프링이 직접 처리해주는 TypeMismatch 처리

이제 드디어 Integer 필드에 문자열을 입력했을 때 나오는 이상한 에러문구에 대한 원인을 이해할 수 있다.

자 일단 그 에러를 마주해보자.

  • "가격은 1,000 ~ 1,000,000까지 허용합니다." 이 에러메시지는 내가 작성한 에러메시지다. 내가 체크하는 검증 로직에도 실패했으니 당연히 저 에러 메시지가 보여야 한다. 
  • 그 부분을 제외한 위에 부분은 어디서 나온걸까?

 

우리가 BindingResult에 에러가 있으면 에러를 출력하는 로그를 찍은적이 있다. 그 로그를 봐보자.

Field error in object 'item' on field 'price': rejected value [qq]; \
codes [typeMismatch.item.price,typeMismatch.price,typeMismatch.java.lang.Integer,typeMismatch]; \
arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [item.price,price]; \
arguments []; \
default message [price]]; \
default message [Failed to convert property value of type 'java.lang.String' to required type 'java.lang.Integer' for property 'price'; 
nested exception is java.lang.NumberFormatException: For input string: "qq"]
  • 보기 좋게 조금 단락을 나누었다. 
  • 이 에러를 보면 price라는 필드에서 에러가 발생했고 rejected value [qq]라고 되어 있다.
  • 그리고 그 때 codes를 보면 이렇게 되어 있다.
    • typeMismatch.item.price
    • typeMismatch.price
    • typeMismatch.java.lang.Integer
    • typeMismatch
  • 즉, 스프링이 이 경우에 rejectedValue("qq", typeMismatch")라고 나 대신에 메서드 호출해준 것이다.
  • 그러니까 이제 에러 메시지를 출력하기 위해 저 코드를 내가 선언한 errors라는 메시지 파일을 찾아서 보는데 코드가 없기 때문에? defaultMessage를 출력하게 된다.
  • 위 로그에서 defaultMessage는 다음과 같다.
    • Failed to convert property value of type 'java.lang.String' to required type 'java.lang.Integer' for property 'price'; nested exception is java.lang.NumberFormatException: For input string: "qq"

이제야 이 에러 메시지의 비밀이 풀리게 되는 순간이다. 그럼 우리가 저 보기 싫은 에러 메시지를 없애고 사용자 친화적인 메시지를 보여주려면? 저 codes를 추가하면 된다. 나는 딱 이렇게만 넣었다.

typeMismatch.java.lang.Integer=숫자 필드입니다.

 

그랬더니 다음과 같은 아름다운 결과가 도출된다. 

 

누군가는 그럴수도 있다. "왜 에러메시지가 두개나 보여지죠? 헷갈리니까 하나만 보이면 좋을 것 같아요!"

나는 이 경우도 문제가 되지 않는다고 생각하지만 만약 타입 미스매치로 바인딩 자체에 실패가 된 경우 바로 다시 등록 폼을 보여주는 방법을 택해도 된다. 다음 코드처럼 맨 앞에 바인딩 자체에 실패했는지 로직을 추가하면 끝난다. (이건 원하는 사람만 이렇게 하면 된다)

@PostMapping("/add")
public String addItemV4(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) {

    if (bindingResult.hasErrors()) {
        log.debug("[addItemV2] bindingResult errors: {}", bindingResult);
        return "validation/v2/addForm";
    }

    // 검증 로직
    if (!StringUtils.hasText(item.getItemName())) {
        bindingResult.rejectValue("itemName", "required");
    }
    if (item.getPrice() == null || item.getPrice() < 1_000 || item.getPrice() > 1_000_000) {
        bindingResult.rejectValue("price", "range", new Object[]{1_000, 1_000_000}, null);
    }
    if (item.getQuantity() == null || item.getQuantity() < 1 || item.getQuantity() > 9999) {
        bindingResult.rejectValue("quantity", "max", new Object[]{9999}, null);
    }

    // 특정 필드가 아닌 복합 룰 검증
    if (item.getPrice() != null && item.getQuantity() != null) {
        int resultPrice = item.getPrice() * item.getQuantity();
        if (resultPrice < 10_000) {
            bindingResult.reject("totalPriceMin", new Object[]{10_000, resultPrice}, null);
        }
    }

    // 에러가 있는 경우, 다시 폼을 보여줌
    if (bindingResult.hasErrors()) {
        log.debug("[addItemV2] bindingResult errors: {}", bindingResult);
        return "validation/v2/addForm";
    }

    Item savedItem = itemRepository.save(item);
    redirectAttributes.addAttribute("itemId", savedItem.getId());
    redirectAttributes.addAttribute("status", true);
    return "redirect:/validation/v2/items/{itemId}";
}

 

중간 정리를 하자면..

BindingResult를 통해 컨트롤러에서 들어온 데이터를 검증하고 검증 실패 시 어떤 필드에 어떤 에러가 있는지 잘 담아 다시 사용자에게 화면에 뿌려줄 수 있었다. 그리고 이 과정에서 에러 메시지를 유연하게 사용하는 방법을 배웠다. 

 

여기서 핵심은, 내가 rejectValue(item.getItemName(), "required") 라고 codes 부분에 가장 덜 디테일한 `required`를 적어도 스프링이 내부적으로 MessageCodesResolver를 사용해서 만들어내는 가장 디테일한 코드부터 가장 덜 디테일한 코드까지를 모두 가져다가 사용해서 가장 디테일한 코드부터 먼저 찾는순서로 에러 메시지를 찾아내기 때문에, 추후에 더 자세하거나 다른 에러 메시지가 필요해진 경우에 애플리케이션 코드를 변경할 필요 없이 그냥 에러 메시지만 추가해주면 된다는 것이다. 

 

근데, 한가지 불편한 점이 있다. 컨트롤러에 검증 로직이 대부분을 차지한다는 점. 즉, 역할과 책임이 너무 가중되어 있다. 이 부분을 해결하기 위해 Validator라는 인터페이스를 사용해서 검증 로직을 분리할 수 있다. 그 부분을 이제 알아보자.

 

Validator 인터페이스

우리는 이제 스프링이 제공하는 Validator라는 인터페이스를 구현한 구현체로 검증 로직을 분리하려고 한다.

무엇을 검증하느냐? Item을 검증한다. 그래서 이름을 ItemValidator라고 작성한 클래스를 만든다.

 

ItemValidator

package hello.itemservice.domain.item;

import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.validation.Errors;
import org.springframework.validation.Validator;

@Component
public class ItemValidator implements Validator {
    @Override
    public boolean supports(Class<?> aClass) {
        return Item.class.isAssignableFrom(aClass); // Item 클래스와 Item 클래스의 자식 클래스까지 검증 지원
    }

    @Override
    public void validate(Object o, Errors errors) {
        Item item = (Item) o;

        // 검증 로직
        if (!StringUtils.hasText(item.getItemName())) {
            errors.rejectValue("itemName", "required");
        }
        if (item.getPrice() == null || item.getPrice() < 1_000 || item.getPrice() > 1_000_000) {
            errors.rejectValue("price", "range", new Object[]{1_000, 1_000_000}, null);
        }
        if (item.getQuantity() == null || item.getQuantity() < 1 || item.getQuantity() > 9999) {
            errors.rejectValue("quantity", "max", new Object[]{9999}, null);
        }

        // 특정 필드가 아닌 복합 룰 검증
        if (item.getPrice() != null && item.getQuantity() != null) {
            int resultPrice = item.getPrice() * item.getQuantity();
            if (resultPrice < 10_000) {
                errors.reject("totalPriceMin", new Object[]{10_000, resultPrice}, null);
            }
        }
    }
}
  • Validator 인터페이스는 두 가지 클래스를 구현해야 한다.
    • supports(Class<?> aClass)
    • validate(Object o, Errors errors)
  • support() 메서드 안에는 어떤 클래스를 검증할 수 있는지를 작성한다. 그래서 우리는 Item이라는 객체를 검증할 것이기 때문에 Item.class.isAssignableFrom(aClass)라고 작성한다. 여기서 isAssignableFrom()은 넘겨받은 클래스와 그 클래스의 자식까지 지원한다. 
  • validate() 메서드 안에 기존에 검증 로직을 그대로 가져다가 복사-붙여넣기 하면 끝이다. 
  • 그리고 ItemValidator를 컴포넌트 스캔이 가능하도록 @Component 애노테이션을 사용한다.

그럼 이제 다시 컨트롤러로 돌아와서, 일단 이 ItemValidator를 주입받는다.

package hello.itemservice.web.validation;

import hello.itemservice.domain.item.Item;
import hello.itemservice.domain.item.ItemRepository;
import hello.itemservice.domain.item.ItemValidator;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.util.StringUtils;
import org.springframework.validation.BindingResult;
import org.springframework.validation.FieldError;
import org.springframework.validation.ObjectError;
import org.springframework.validation.ValidationUtils;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;

import java.util.HashMap;
import java.util.List;
import java.util.Map;

@Slf4j
@Controller
@RequestMapping("/validation/v2/items")
@RequiredArgsConstructor
public class ValidationItemControllerV2 {

    private final ItemRepository itemRepository;
    private final ItemValidator itemValidator;

    ...

    @PostMapping("/add")
    public String addItemV5(@ModelAttribute Item item, BindingResult bindingResult, RedirectAttributes redirectAttributes) {

        itemValidator.validate(item, bindingResult);

        if (bindingResult.hasErrors()) { // 에러가 있는 경우, 다시 폼을 보여줌
            log.debug("[addItemV2] bindingResult errors: {}", bindingResult);
            return "validation/v2/addForm";
        }

        Item savedItem = itemRepository.save(item);
        redirectAttributes.addAttribute("itemId", savedItem.getId());
        redirectAttributes.addAttribute("status", true);
        return "redirect:/validation/v2/items/{itemId}";
    }

    @GetMapping("/{itemId}/edit")
    public String editForm(@PathVariable Long itemId, Model model) {
        Item item = itemRepository.findById(itemId);
        model.addAttribute("item", item);
        return "validation/v2/editForm";
    }

    @PostMapping("/{itemId}/edit")
    public String edit(@PathVariable Long itemId, @ModelAttribute Item item) {
        itemRepository.update(itemId, item);
        return "redirect:/validation/v2/items/{itemId}";
    }
}
  • 검증하는 부분에 주입받은 ItemValidatorvalidate()을 호출하면 끝이다. 
  • BindingResult의 부모가 Errors이기 때문에 validate(Class<?> aClass, Errors errors)의 두번째 파라미터로 넘기는데 아무런 문제가 없다.

그런데, 생각해보면 별로다. 뭐가 별로냐면 이걸 굳이 주입받고, 빈으로 등록하고, Validator 인터페이스를 구현하고 이럴 필요 없이 그냥 아래와 같은 클래스를 만든 다음,

package hello.itemservice.domain.item;

import org.springframework.util.StringUtils;
import org.springframework.validation.Errors;

public class ItemValidator {
    
    public void validate(Object o, Errors errors) {
        Item item = (Item) o;

        // 검증 로직
        if (!StringUtils.hasText(item.getItemName())) {
            errors.rejectValue("itemName", "required");
        }
        if (item.getPrice() == null || item.getPrice() < 1_000 || item.getPrice() > 1_000_000) {
            errors.rejectValue("price", "range", new Object[]{1_000, 1_000_000}, null);
        }
        if (item.getQuantity() == null || item.getQuantity() < 1 || item.getQuantity() > 9999) {
            errors.rejectValue("quantity", "max", new Object[]{9999}, null);
        }

        // 특정 필드가 아닌 복합 룰 검증
        if (item.getPrice() != null && item.getQuantity() != null) {
            int resultPrice = item.getPrice() * item.getQuantity();
            if (resultPrice < 10_000) {
                errors.reject("totalPriceMin", new Object[]{10_000, resultPrice}, null);
            }
        }
    }
}

 

컨트롤러에서는 아래처럼 그냥 new로 객체 생성해서 validate() 메서드를 호출해도 될 것 같다. 맞다, 된다.

@PostMapping("/add")
public String addItemV5(@Validated @ModelAttribute Item item,
                        BindingResult bindingResult,
                        RedirectAttributes redirectAttributes) {

    new ItemValidator().validate(item, bindingResult);

    if (bindingResult.hasErrors()) { // 에러가 있는 경우, 다시 폼을 보여줌
        log.debug("[addItemV2] bindingResult errors: {}", bindingResult);
        return "validation/v2/addForm";
    }

    Item savedItem = itemRepository.save(item);
    redirectAttributes.addAttribute("itemId", savedItem.getId());
    redirectAttributes.addAttribute("status", true);
    return "redirect:/validation/v2/items/{itemId}";
}

 

 

근데 굳이 굳이 이렇게 스프링이 만든 Validator 인터페이스를 구현하고 빈으로 등록한 다음 주입 받아 사용하는데는 이유가 있다. 더 편리하게 사용할 수 있기 때문이다.

 

@Validated, @InitBinder

스프링이 제공하는 @Validated 애노테이션으로 다음 코드도 없앨 수 있다.

itemValidator.validate(item, bindingResult);

 

다음 컨트롤러를 보자.

package hello.itemservice.web.validation;

import hello.itemservice.domain.item.Item;
import hello.itemservice.domain.item.ItemRepository;
import hello.itemservice.domain.item.ItemValidator;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.util.StringUtils;
import org.springframework.validation.BindingResult;
import org.springframework.validation.FieldError;
import org.springframework.validation.ObjectError;
import org.springframework.validation.ValidationUtils;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.WebDataBinder;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;

import java.util.HashMap;
import java.util.List;
import java.util.Map;

@Slf4j
@Controller
@RequestMapping("/validation/v2/items")
@RequiredArgsConstructor
public class ValidationItemControllerV2 {

    private final ItemRepository itemRepository;
    private final ItemValidator itemValidator;

    @InitBinder
    public void init(WebDataBinder binder) {
        binder.addValidators(itemValidator);
    }

    ...

    @PostMapping("/add")
    public String addItemV5(@Validated @ModelAttribute Item item,
                            BindingResult bindingResult,
                            RedirectAttributes redirectAttributes) {

        if (bindingResult.hasErrors()) { // 에러가 있는 경우, 다시 폼을 보여줌
            log.debug("[addItemV2] bindingResult errors: {}", bindingResult);
            return "validation/v2/addForm";
        }

        Item savedItem = itemRepository.save(item);
        redirectAttributes.addAttribute("itemId", savedItem.getId());
        redirectAttributes.addAttribute("status", true);
        return "redirect:/validation/v2/items/{itemId}";
    }

    @PostMapping("/{itemId}/edit")
    public String edit(@PathVariable Long itemId, @ModelAttribute Item item) {
        itemRepository.update(itemId, item);
        return "redirect:/validation/v2/items/{itemId}";
    }
}
  • Validator 인터페이스를 구현한 구현체를 빈으로 등록하고 검증이 필요한 컨트롤러에서 주입을 받는다.
  • @InitBinder라는 애노테이션으로 이 컨트롤러가 호출될 때마다 검증할 Validator 구현체를 집어넣는다.
  • 여기까지 하면, 이 컨트롤러에 한하여 어떤 요청이 들어와도 ItemValidator를 통해 검증을 할 수 있게 된다. 그래서 "/add" 라는 pathPOST요청이 들어왔을 때 이 검증기를 실행시키고 싶으면, @Validated 애노테이션을 @ModelAttribute Item item 옆에 붙이면 끝난다. (addItemV5)

근데, 보다시피 @Validated 애노테이션만 붙이면 어떤 Validator가 사용될지 어떻게 알까? 보니까 binder.addValidators(Validator... validator)는 여러 Validator 구현체가 들어갈 수 있는데 말이다. 아래처럼 말이다.

binder.addValidators(itemValidator, categoryValidator, productValidator);

이때 바로 Validator 인터페이스의 메서드인 supports(Class<?> aClass)가 사용되는 것이다. 그래서 @Validated 애노테이션이 붙은 바로 옆 객체인 Itemsupports()에 넘어가게 된다. 그래서 이 메서드가 `true`를 반환하는 Validator를 선택하여 검증하게 된다.

 

 

글로벌 설정

참고로, 글로벌로 설정할 수도 있다. 지금은 현재 이 @InitBinder 애노테이션이 달린 컨트롤러에 한하여 검증이 가능하게 설정했지만 모든 컨트롤러에 다 적용할 수도 있다. 다만, 그럴일이 매우 드물지만 가능은 하다.

 

글로벌 설정 방법

아래와 같이 WebMvcConfigurer 인터페이스를 구현한 구현체를 만든다. 단순하게 하기 위해 여기서는 메인 클래스로 적용했다.

package hello.itemservice;

import hello.itemservice.domain.item.ItemValidator;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.validation.Validator;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@SpringBootApplication
public class ItemServiceApplication implements WebMvcConfigurer {

	public static void main(String[] args) {
		SpringApplication.run(ItemServiceApplication.class, args);
	}

	@Override
	public Validator getValidator() {
            return new ItemValidator();
	}
}
  • 그리고 Validator를 반환하는 메서드를 만들고 원하는 Validator를 반환하면 된다.
  • 글로벌로 적용했으니 특정 컨트롤러에 @InitBinder는 제거해야 한다. (아래 코드처럼 주석 처리)
//private final ItemValidator itemValidator;

/*@InitBinder
public void init(WebDataBinder binder) {
    binder.addValidators(itemValidator);
}*/
  • 글로벌로 적용해도 어떤 컨트롤러를 호출할 때 검증할지 설정하는 @Validated 애노테이션은 당연히 넣어야 한다. (아래 코드처럼)
@PostMapping("/add")
public String addItemV5(@Validated @ModelAttribute Item item,
                        BindingResult bindingResult,
                        RedirectAttributes redirectAttributes) {

    if (bindingResult.hasErrors()) { // 에러가 있는 경우, 다시 폼을 보여줌
        log.debug("[addItemV2] bindingResult errors: {}", bindingResult);
        return "validation/v2/addForm";
    }

    Item savedItem = itemRepository.save(item);
    redirectAttributes.addAttribute("itemId", savedItem.getId());
    redirectAttributes.addAttribute("status", true);
    return "redirect:/validation/v2/items/{itemId}";
}

 

여기까지가 글로벌 설정이다. 그런데, 글로벌로 설정하는 경우는 거의 없다. 그냥 필요한 컨트롤러에서 @InitBinder를 통해 사용하자.

728x90
반응형
LIST
728x90
반응형
SMALL

참고자료:

 

스프링 MVC 2편 - 백엔드 웹 개발 활용 기술 강의 | 김영한 - 인프런

김영한 | 웹 애플리케이션 개발에 필요한 모든 웹 기술을 기초부터 이해하고, 완성할 수 있습니다. MVC 2편에서는 MVC 1편의 핵심 원리와 구조 위에 실무 웹 개발에 필요한 모든 활용 기술들을 학습

www.inflearn.com

 

 

메시지

화면에 보이는 어떤 특정 단어를 변경해야 한다면 어떻게 할까? 예를 들어, 상품명이라는 단어를 모두 상품이름으로 변경하고자 하는 요구사항이 들어왔다면 말이다. 이 경우, 모든 파일을 다 뒤져서 상품명을 상품이름으로 바꿔야 할까?

 

매우 귀찮은 작업이 될 것이다. 이럴때 상품명이라는 단어를 한 곳에서 관리하도록 하고 관리하는 파일에서 상품명상품이름으로만 바꿔주면 모든 파일에 적용이 된다면 좋을것이다. 이게 메시지 기능이다. 

 

예를 들어, messages.properties 라는 파일에 메시지 관리용 파일을 만들고,

item=상품
item.id=상품 ID
item.itemName=상품명
item.price=가격
item.quantity=수량

 

각 HTML들은 다음과 같이 해당 데이터를 key값으로 불러서 사용하는 것이다.

`<label for="itemName" th:text="#{item.itemName}"></label>`

 

국제화

사용자의 주 언어에 따라 보여지는 서비스의 언어가 달라지게 하는건 웹 애플리케이션이라면 그냥 필수이다. 이 경우도 위 메시지처럼 비슷하게 언어별로 파일을 만들고 관리할 수 있다.

 

message_en.properties

item=Item
item.id=Item ID
item.itemName=Item Name
item.price=price
item.quantity=quantity

 

message_ko.properties

item=상품
item.id=상품 ID
item.itemName=상품명
item.price=가격
item.quantity=수량

 

이런식으로 파일을 분리해서 관리하고 영어를 사용하는 사용자라면 message_en.properties이 파일을 사용하고, 한국어를 사용하는 사용자라면 message_ko.properties이 파일을 사용하면 된다.

 

한국에서 접근한 것인지 영어권에서 접근한 것인지는 HTTP 헤더accept-language 값을 사용하거나, 사용자가 직접 언어를 선택한 것을 쿠키에 저장해서 서버와 통신해서 처리하면 된다. 그리고, 결론적으로 스프링은 기본적인 메시지와 국제화 기능을 모두 제공한다. 그래서 스프링을 사용해서 메시지와 국제화 기능을 알아보자.

 

스프링 메시지, 국제화 소스 설정

만약, 스프링 부트를 사용하지 않는다면 직접 빈을 등록해야 한다.

@Bean
public MessageSource messageSource() {
    ResourceBundleMessageSource messageSource = new ResourceBundleMessageSource();
    messageSource.setBasenames("messages", "errors");
    messageSource.setDefaultEncoding("utf-8");
    return messageSource;
}
  • basenames: 설정 파일의 이름을 지정하는 것이다. 위 코드처럼 복수로 여러개를 지정할 수 있다. 저렇게 여러개로 지정하면 저 이름으로된 파일들을 찾고 그 파일들로 메시지 기능을 사용할 수 있다.
  • 파일 위치는 기본이 /resources/messages.properties 이다.

그렇지만, 이 빈을 스프링 부트는 자동으로 등록해준다. 스프링 부트를 사용한다면, 다음과 같이 메시지 소스를 설정할 수 있다.

 

application.yaml

spring:
  messages:
    basename: messages

참고로 위 값이 기본값이라 저렇게 작성할거면 작성 안해도 무방하다.

 

그래서 resources 안에 이렇게 파일 두 개를 만들어보자.

 

messages.properties

hello=안녕
hello.name=안녕 {0}

 

messages_en.properties

hello=hello
hello.name=hello {0}

 

위처럼 작성하면 기본값을 한국어로 설정한 것이다. 만약, messages_en.properties, messages_es.properties, messages.properties 이렇게 파일이 있을 때, 한국어로 설정된 언어 사용자가 진입하면 최초에 _ko를 찾는데 그게 없으면 기본값인 그냥 messages.properties가 적용된다. 

 

스프링 메시지, 국제화 소스 사용

이제 이 메시지 기능을 사용해보자. 간단하게 테스트 코드로 작성해서 확인을 해보자.

 

`test` 폴더 하위에 테스트 파일 하나를 만들자.

@SpringBootTest
public class MessageSourceTest {

    @Autowired
    MessageSource messageSource;
    
}
  • 그리고 우선 스프링 부트가 자동으로 빈으로 등록해준 MessageSource를 주입받는다.
@Test
void helloMessage() {
    String hello = messageSource.getMessage("hello", null, Locale.US);
    assertThat(hello).isEqualTo("hello");
}
  • getMessage()를 호출해서 원하는 키를 가져온다. 여기서는 "hello"를 가져온다. 두번째 파라미터는 arguments이다. 이건 이후에 알아본다. 세번째 파라미터는 Locale 값이다. US로 지정해보자.
  • 그리고 가져온 값이 "hello"와 일치하는지 테스트한다.
@Test
void notFoundMessage() {
    assertThatThrownBy(() -> messageSource.getMessage("no", null, Locale.US)).isInstanceOf(NoSuchMessageException.class);
}
  • 이번엔 없는 키를 가져오는 경우 NoSuchMessageException을 받는다. 그것을 테스트한다.
@Test
void defaultMessage() {
    String defaultMessage = messageSource.getMessage("no", null, "default Message", Locale.US);
    assertThat(defaultMessage).isEqualTo("default Message");
}
  • 값이 없는 경우, 기본값을 설정해서 예외를 피할수 있다. 여기서는 "default Message" 라고 기본값을 설정했다.
  • 가져온 값이 "default Message"와 일치한지 확인한다.
@Test
void argsMessage() {
    String message = messageSource.getMessage("hello.name", new Object[]{"Spring"}, Locale.US);
    assertThat(message).isEqualTo("hello Spring");
}
  • 이번에는 arguments를 사용해본다. 위에서 `hello.name=hello {0}` 이렇게 messages 파일을 작성한 것이 있다.
  • 이 `{0}`에 넘겨진 배열의 0번 인덱스인 "Spring"을집어넣는 것이다.
  • 반환값이 "hello Spring"인지 확인한다.
@Test
void localizedMessage() {
    String enMessage = messageSource.getMessage("hello", null, Locale.US);
    assertThat(enMessage).isEqualTo("hello");

    String koMessage = messageSource.getMessage("hello", null, Locale.KOREAN);
    assertThat(koMessage).isEqualTo("안녕");
}
  • 이번엔 국제화 기능을 사용해본다. Locale.US로 넘긴 경우 messages_en.properties 파일을 찾을것이다. 
  • Locale.KOREAN을 넘긴 경우 messages_ko.properties 파일을 찾을것이다.

 

이렇게 스프링의 메시지, 국제화 기능을 코드로써 편리하게 사용할 수 있다.

 

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

만약, 웹 애플리케이션 개발을 할 때 스프링 부트 + 타임리프를 사용한다면 타임리프에서 스프링의 메시지, 국제화 기능을 바로 적용할 수 있다. 

참고로, 앞단을 리액트와 같이 백엔드와 분리하여 개발한다면 앞단에서 처리를 하면 된다.

 

우선, messages.properties 파일을 이렇게 적용한다.

label.item=상품
label.item.id=상품 ID
label.item.itemName=상품명
label.item.price=가격
label.item.quantity=수량

page.items=상품 목록
page.item=상품 상세
page.addItem=상품 등록
page.updateItem=상품 수정

button.save=저장
button.cancel=취소

 

그리고 타임리프에서 이 값을 그대로 가져다가 사용하면 된다. 아래처럼.

<h2 th:text="#{page.updateItem}">상품 수정 폼</h2>

 

 

만약, 파라미터를 줘야하는 `hello.name=hello {0}` 이런 경우는 타임리프에서 이렇게 사용할 수 있다.

`<p th:text="#{hello.name(${item.itemName})}"></p>`

 

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

우선, messages_en.properties 파일을 이렇게 수정하자.

label.item=Item
label.item.id=Item ID
label.item.itemName=Item Name
label.item.price=price
label.item.quantity=quantity

page.items=Item List
page.item=Item Detail
page.addItem=Item Add
page.updateItem=Item Update

button.save=Save
button.cancel=Cancel

 

그럼 끝이다. 왜냐? 메시지 적용하면서 타임리프 템플릿에 이미 필요한 부분에 다음과 같이 적용을 해놨다.

<h2 th:text="#{page.updateItem}">상품 수정 폼</h2>

 

그래서, 사용자의 브라우저가 어떤 언어를 쓰냐에 따라 알아서 국제화가 적용될 것이다.

그럼 테스트해보자. 일단 브라우저 언어를 바꿔보자.

 

다시 들어가서 보면 이렇게 영어로 잘 나오게 된다.

 

LocaleResolver

이걸 살짝만 깊게 들어가보면, 스프링도 결국 Locale 정보를 알아야 언어를 선택할 수 있을 것이다. 그래서 스프링은 언어 선택시 기본으로 Accept-Language의 헤더값을 사용하는데 이게 스프링 부트가 LocaleResolver 인터페이스의 구현체를 기본으로 AcceptHeaderLocaleResolver를 사용하기 때문이다.

 

LocaleResolver 인터페이스

public interface LocaleResolver {
    Locale resolveLocale(HttpServletRequest var1);

    void setLocale(HttpServletRequest var1, @Nullable HttpServletResponse var2, @Nullable Locale var3);
}

 

AcceptHeaderLocaleResolver

package org.springframework.web.servlet.i18n;

import java.util.ArrayList;
import java.util.Enumeration;
import java.util.Iterator;
import java.util.List;
import java.util.Locale;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.springframework.lang.Nullable;
import org.springframework.util.StringUtils;
import org.springframework.web.servlet.LocaleResolver;

public class AcceptHeaderLocaleResolver implements LocaleResolver {
    private final List<Locale> supportedLocales = new ArrayList(4);
    @Nullable
    private Locale defaultLocale;

    public AcceptHeaderLocaleResolver() {
    }

    public void setSupportedLocales(List<Locale> locales) {
        this.supportedLocales.clear();
        this.supportedLocales.addAll(locales);
    }

    public List<Locale> getSupportedLocales() {
        return this.supportedLocales;
    }

    public void setDefaultLocale(@Nullable Locale defaultLocale) {
        this.defaultLocale = defaultLocale;
    }

    @Nullable
    public Locale getDefaultLocale() {
        return this.defaultLocale;
    }

    public Locale resolveLocale(HttpServletRequest request) {
        Locale defaultLocale = this.getDefaultLocale();
        if (defaultLocale != null && request.getHeader("Accept-Language") == null) {
            return defaultLocale;
        } else {
            Locale requestLocale = request.getLocale();
            List<Locale> supportedLocales = this.getSupportedLocales();
            if (!supportedLocales.isEmpty() && !supportedLocales.contains(requestLocale)) {
                Locale supportedLocale = this.findSupportedLocale(request, supportedLocales);
                if (supportedLocale != null) {
                    return supportedLocale;
                } else {
                    return defaultLocale != null ? defaultLocale : requestLocale;
                }
            } else {
                return requestLocale;
            }
        }
    }

    @Nullable
    private Locale findSupportedLocale(HttpServletRequest request, List<Locale> supportedLocales) {
        Enumeration<Locale> requestLocales = request.getLocales();
        Locale languageMatch = null;

        Locale locale;
        label38:
        do {
            while(requestLocales.hasMoreElements()) {
                locale = (Locale)requestLocales.nextElement();
                if (supportedLocales.contains(locale)) {
                    continue label38;
                }

                if (languageMatch == null) {
                    Iterator var6 = supportedLocales.iterator();

                    while(var6.hasNext()) {
                        Locale candidate = (Locale)var6.next();
                        if (!StringUtils.hasLength(candidate.getCountry()) && candidate.getLanguage().equals(locale.getLanguage())) {
                            languageMatch = candidate;
                            break;
                        }
                    }
                }
            }

            return languageMatch;
        } while(languageMatch != null && !languageMatch.getLanguage().equals(locale.getLanguage()));

        return locale;
    }

    public void setLocale(HttpServletRequest request, @Nullable HttpServletResponse response, @Nullable Locale locale) {
        throw new UnsupportedOperationException("Cannot change HTTP accept header - use a different locale resolution strategy");
    }
}

 

그 말은 우리가 LocaleResolver 구현체를 다른것으로 변경한다면, 얼마든지 Locale 정보를 다른 방식으로 알아올 수 있다. 가장 대표적인 예시가 잘 만든 웹 사이트를 보면 사이트 자체적으로 언어를 선택할 수 있는 사이트가 있다. 이렇게 사용자가 브라우저 단위가 아니라 웹 애플리케이션 단위로 언어를 선택했을 때 그 값을 쿠키같은 곳에 저장하고 서버와 통신하게 하고 Locale 정보를 내가 직접 만든 LocaleResolver 구현체를 쿠키에서 가져와서 Locale값을 알아내는 구현체를 만들면 된다.

 

그리고? 그 구현체를 빈으로 등록하면 스프링은 내가 만든 구현체로 변경해줄것이다. 이게 바로 스프링의 위대함이다. 얼마든지 구현체만 갈아끼우면 된다는 것. 이게 바로 DI의 핵심이다. 하나의 인터페이스에 여러 구현체. 의존관계 설정을 나중으로 미루는 것. 확장에는 열려있고 변경에는 닫혀있는 OCP 법칙.

 

아무튼, 이렇게 스프링, 스프링 부트를 이용해서 메시지, 국제화 기능을 알아보았다!

728x90
반응형
LIST
728x90
반응형
SMALL

JIRA DC 플러그인을 개발할땐 기본으로 H2 데이터베이스를 사용한다. H2 데이터베이스는 개발 시 굉장히 가볍고 좋은 데이터베이스이나 운영단계로 넘어가면 이 데이터베이스를 사용하기엔 무리가 있다. 여러가지 이유로 말이다. 그리고 꼭 운영단계가 아니라도 개발 환경에서부터 MySQL로 변경하고 싶을수가 있다. 

 

MySQL, PostgreSQL 둘 중 하나로 변경하면 되는데 여기서는 MySQL로 변경해서 사용하는 방법을 알아본다.

 

MySQL 설치

우선, 나의 경우 로컬에서 개발할때부터 데이터베이스를 MySQL로 변경하고자 한다. 그래서 로컬에 MySQL을 설치한다.

따라서, MacOS 환경 기준으로 설명한다.

 

다음 명령어를 터미널에서 실행한다.

(꼭 8.0 버전으로 설치해주자. 그 상위 버전은 지라에서 지원하지 않고 있다 아직까진. 현재 2024-08-20)

brew install mysql@8.0

 

정상적으로 설치가 되면 다음과 같은 화면이 보인다.

 

다른거 말고 중간에 이 문구가 있으면 된다.

To connect run:
	mysql -u root

 

기본 설정

세팅을 추가적으로 해줘야한다. 그러기 위해 다음 명령어를 입력한다.

mysql.server start

입력해서 이런 문구가 나오면 된다.

이제 다음 명령어를 통해 보안 관련 설정을 해준다.

mysql_secure_installation

 

비밀번호 유효성 검사 설정

첫번째로 비밀번호 유효성 설정이 나온다. 권장은 당연히 설정해서 강력한 비밀번호를 만드는게 맞다. 근데 그냥 로컬에서 간단하게 사용할 목적이라면 굳이 이 설정을 하지 않아도 상관은 없다. 난 과감하게 No를 하겠다.

No를 입력하면, 위 사진처럼 root 계정의 패스워드를 설정하라는 메시지가 나온다. 원하는대로 설정해주자.

 

익명의 사용자 삭제 설정  

다음은 익명의 사용자를 제거할지 묻는 화면이다. 제거해주자.

 

root 계정 원격 접속 차단 설정

다음은 root 계정의 원격 접속을 차단할지 묻는 화면이다. 위 사진처럼 일반적으로 원격 접속을 차단해야 보안상 안전하다.

이 PC가 아닌 다른 곳에서 이 PC의 MySQL로 root 계정으로 접속하는것은 차단하는게 권장된다. 다른곳에서 원격으로 접속이 필요하다면 다른 계정을 만들어서 원격 접속 권한을 제한적으로 주는것이 바람직하다. 나 역시 root 계정의 원격 접속을 제한하기로 한다.

 

테스트 데이터베이스 삭제 설정

기본으로 제공되는 테스트 데이터베이스를 삭제할건지 묻는다. 삭제한다.

 

위 작업으로 인한 변경사항 적용 설정

위 작업을 토대로 변경된 내용을 적용할지 묻는다. 적용하자.

 

여기까지 하면 끝이다. 다음과 같은 화면이 나오면 된다.

 

root 계정으로 MySQL 접속

위 설정을 다 하면 이제 root 계정으로 접속을 해보자. 패스워드는 위에서 설정한 패스워드로 입력하면 된다.

mysql -u root -p

이렇게 접속이 잘 되면 된다.

 

JIRA DC 플러그인 용 데이터베이스 생성

이제 데이터베이스를 생성하자.

CREATE DATABASE jira CHARACTER SET utf8mb4 COLLATE utf8mb4_bin;

 

데이터베이스 명은 원하는대로 작성하면 된다. 위는 단지 예시일 뿐이다.

데이터베이스를 생성했으면, 유저를 생성하고 데이터베이스에 권한을 주자. 원격으로도 접속이 가능해야 하니까.

CREATE USER 'jirauser'@'localhost' IDENTIFIED BY 'password';
GRANT ALL PRIVILEGES ON jira.* TO 'jirauser'@'localhost';
FLUSH PRIVILEGES;

이 또한, 유저명과 패스워드는 원하는대로 만들어주면 된다. 이렇게 권한까지 다 주고 나면 MySQL 설정은 끝이다.

 

MySQL JDBC 드라이버 설정

다음 링크에서 적절한 버전의 드라이버를 다운받는다. MySQL 8.0 버전을 사용하니까 드라이버도 8 버전으로 설치하면 된다.

 

MySQL :: Download Connector/J

MySQL Connector/J is the official JDBC driver for MySQL. MySQL Connector/J 8.0 and higher is compatible with all MySQL versions starting with MySQL 5.7. Additionally, MySQL Connector/J 8.0 and higher supports the new X DevAPI for development with MySQL Ser

dev.mysql.com

다운 받은 jar 파일을 다음 경로에 추가해줘야 한다.

target/jira/home/webapp/WEB-INF/lib

참고로 이 경로는, JIRA DC 플러그인 프로젝트를 빌드하면 생기는 target 폴더이다. 그래서 최초 빌드 한번이 필요하다!

다음과 같이 잘 추가가 됐으면 끝이다.

 

JIRA 데이터베이스 연결 설정 변경

JIRA DC 플러그인 프로젝트를 빌드하면 target 폴더가 생긴다. 이 폴더 내부에 홈 디렉토리가 있다. 그리고 그 안에 dbconfig.xml 파일이 존재한다.

target/jira/home/dbconfig.xml

 

이 파일을 삭제한다! 이 파일 삭제하면, 설치 마법사가 다시 실행되면서 내가 원하는 데이터베이스를 연결할 수 있다. 

 

삭제한 후 다음 명령어를 실행!

atlas-run

다음과 같이 정상 실행이 된것을 확인하자.

보이는 것 그대로 링크 주소를 브라우저에 입력하면 다음 화면이 나온다!

설치 마법사가 실행된다! 여기서 `I'll set it up myself` 를 선택하고 Next.

이런 화면이 나온다. 여기서 `My Own Database`를 선택하자. 그러면 하단에 설정정보 입력 칸이 보여진다.

설정 정보를 입력하고 `Test Connection` 버튼을 클릭해서 연결이 잘 확인되어야 한다! 그리고 Next

한참 데이터베이스를 세팅한 후에 다음 화면이 보여진다.

그대로 Next.

이 화면이 나오면 `generate a Jira trial license` 버튼을 클릭해서 Trial license를 받으면 된다. 라이센스 받으면 다음과 같이 자동으로 입력된다. 

Trial license를 받는 방법은 이 포스팅의 범주를 넘어서기 때문에 따로 설명하지 않는다. 어렵지 않으니 그냥 들어가서 발급하면 된다.

그리고 Next를 클릭하면, 이제 Admin 유저 정보를 입력하는 화면이 나오고 그 화면에서 적절하게 Admin 정보를 기입 후 다음으로 넘어가면 된다! 그러면 다음과 같이 MySQL 데이터베이스와 연동된 Jira가 띄워진다!

 

728x90
반응형
LIST
728x90
반응형
SMALL

이건 뭐 굳이 이 카테고리여야 싶지만, 이 JIRA 플러그인 개발을 할 때 '개발하고 - 확인하고 - 개발하고'를 반복하다보면 띄워진 서버의 URL(`http://localhost:2990/jira`)에 대한 브라우저 캐시가 남아있어서 변경 사항이 적용이 안되는 경우를 한번은 마주하게 된다.

 

여러 방법이 있다. 아예 캐시를 다 지우거나, 시크릿 모드를 띄워서 실행하거나 등등.

 

근데 가장 간단하고 편한 방법은 해당 URL에서 Inspect - Network 탭으로 들어간다.

 

여기서 보면, Disable cache 체크박스가 보인다.

 

이거 체크하고 새로고침하자! 그럼 캐시가 싹 날라가고 전부 다 새로 받아온다 👍 

728x90
반응형
LIST
728x90
반응형
SMALL

예외 공통처리는 어떤 프레임워크를 사용하건 심지어 서블릿만 사용하더라도 잘 알아야 하는 부분이다.

개인적으로 중요한 이유 중 가장 큰 이유는, 비즈니스 로직이 깔끔해지고 관심사가 분리된다는 점인것 같다.

 

JIRA DC 플러그인 개발은 JAX-RS를 사용한다.

JAX-RSExceptionMapper를 사용해야 한다.

 

긴 말 필요없이 바로 코드를 보면 굉장히 간단하다.

 

IOExceptionMapper

package kr.osci.aijql.exception;

import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;

import javax.ws.rs.core.Response;
import javax.ws.rs.ext.ExceptionMapper;
import javax.ws.rs.ext.Provider;
import java.io.IOException;

@Slf4j
@Provider
@Component
public class IOExceptionMapper implements ExceptionMapper<IOException> {

    @Override
    public Response toResponse(IOException e) {
        log.error("[toResponse] IOException, root cause = ", e);
        return Response.status(Response.Status.INTERNAL_SERVER_ERROR).entity(e.getMessage()).build();
    }
}

 

우선, @Provider 애노테이션을 사용해야 한다. 이 애노테이션은 JAX-RS 런타임에 특정 클래스를 자동으로 등록해서 공통으로 사용할 수 있게 해준다. 대표적으로 이렇게 ExceptionMapper를 구현한 구현체를 등록해서 이 지정된 예외가 발생 시 이 클래스가 호출되도록 말이다.

 

그리고 보면, @Component 애노테이션도 달려있다. 이전 포스팅에서 설명했듯 스프링 스캐너를 사용한다. 그래서 이 클래스 자체가 빈으로 자동 주입이 되어야 한다. 그래야 이 공통 클래스를 사용할수가 있으니까.

 

그리고 어떤 예외를 처리할지를 제네릭에 넣어준다. 위 코드는 IOException을 처리하는 클래스이다.

그래서 이 IOException이 어디선가 발생하고 그걸 잡아주지 않는다면 외부로 던져질때 이 클래스를 통한다. 

 

그래서, 위 코드는 개발자가 나중에 알아볼 수 있는 에러로그가 찍히고 500 에러 상태 코드를 가진 반환을 한다. 응답 바디는 에러 메시지가 들어가게 된다. 이렇게 공통 처리할 예외 클래스를 ExceptionMapper로 등록하여 공통 처리할 수 있다.

728x90
반응형
LIST
728x90
반응형
SMALL

개발하면서 로그 남기는 건 필수인데, 로그 찍는법을 드디어 알아냈다.

우선 버전이 Jira 9.x 이상인 경우 log4j2를 사용한다. 그리고 이 로그 설정 파일의 경로는 다음 위치에 있다.

 

Step 1. 로그 파일 위치와 수정

<jira-application-dir>/atlassian-jira/WEB-INF/classes/log4j2.xml

 

예를 들어, 프로젝트를 만들고 프로젝트를 실행하면, target 폴더가 생기는데 그 위치는 이렇다.

<project-root-path>/target/jira/webapp/WEB-INF/classes/log4j2.xml

 

이 파일을 열어보면 다음과 같이 생겼다.

log4j2.xml

<?xml version="1.0" encoding="UTF-8"?>
<Configuration packages="com.atlassian.logging.log4j,com.atlassian.jira.logging">
    <Properties>
        <Property name="StackTraceFilteringPattern">%d{yyyy-MM-dd HH:mm:ss,SSSZ} %t %p %X{jira.username} %X{jira.request.id} %X{jira.request.assession.id} %X{jira.request.ipaddr} %X{jira.request.url} [%q{2}] %nlm%n%stf{stackTracePackagingExamined(false)}{filteringApplied(true)}{filteredFrames(@jira-filtered-frames.properties)}</Property>
        <Property name="NonFilteringPattern">%d{yyyy-MM-dd HH:mm:ss,SSSZ} %t %p %X{jira.username} %X{jira.request.id} %X{jira.request.assession.id} %X{jira.request.ipaddr} %X{jira.request.url} [%q{2}] %nlm%n%stf{filteringApplied(false)}</Property>
        <Property name="NewLineIndentingNotFilteringPattern">%nlm%n%stf{filteringApplied(false)}</Property>
        <Property name="NoLevelNonFilteringPattern">%d{yyyy-MM-dd HH:mm:ss,SSSZ} %t %X{jira.username} %X{jira.request.id} %X{jira.request.assession.id} %X{jira.request.url} %nlm%n%stf{filteringApplied(false)}</Property>
        <Property name="IpAddressNonFilteringPattern">%d{yyyy-MM-dd HH:mm:ss,SSSZ} %t %X{jira.username} %X{jira.request.id} %X{jira.request.assession.id} %X{jira.request.ipaddr} %X{jira.request.url} %nlm%n%stf{filteringApplied(false)}</Property>
        <Property name="JustMessagePattern">%nlm%n%stf{filteringApplied(false)}</Property>

        <Property name="ProfilerPattern">%d | %t | %X{jira.request.id} | %X{jira.username} | %X{jira.request.assession.id}%n%nlm%n%stf{filteringApplied(false)}</Property>
        <Property name="OutgoingMailPattern">%d{yyyy-MM-dd HH:mm:ss,SSSZ} %p [%X{jira.mailserver}] %t %X{jira.username} %X{jira.request.id} %X{jira.request.assession.id} %X{jira.request.ipaddr} %X{jira.request.url} [%q{2}] %nlm%n%stf{filteringApplied(false)}</Property>
        <Property name="IncomingMailPattern">%d{yyyy-MM-dd HH:mm:ss,SSSZ} %p [%X{jira.mailserver}] %t %X{jira.username} %X{jira.request.id} %X{jira.request.assession.id} %X{jira.request.ipaddr} %X{jira.request.url} %nlm%n%stf{filteringApplied(false)}</Property>
        <Property name="ApdexPattern">%d{yyyy-MM-dd HH:mm:ss,SSSZ} %nlm%n%stf{filteringApplied(false)}</Property>
        <Property name="InProductDiagnosticPattern">%d{yyyy-MM-dd HH:mm:ss,SSSZ} %nlm%n</Property>
    </Properties>

    <Appenders>
        <Console name="console" target="SYSTEM_OUT">
            <PatternLayout alwaysWriteExceptions="false">
                <Pattern>${StackTraceFilteringPattern}</Pattern>
            </PatternLayout>
            <ThresholdFilter level="DEBUG" onMatch="ACCEPT" onMismatch="DENY"/>
        </Console>
        <JiraHomeAppender name="filelog"
                          fileName="atlassian-jira.log"
                          filePattern="atlassian-jira.log.%i">
            <PatternLayout alwaysWriteExceptions="false">
                <Pattern>${StackTraceFilteringPattern}</Pattern>
            </PatternLayout>
            <Policies>
                <SizeBasedTriggeringPolicy size="20480 KB"/>
            </Policies>
            <DefaultRolloverStrategy fileIndex="min" max="10"/>
        </JiraHomeAppender>
        <JiraHomeAppender name="httpaccesslog"
                          fileName="atlassian-jira-http-access.log"
                          filePattern="atlassian-jira-http-access.log.%i">
            <PatternLayout alwaysWriteExceptions="false">
                <Pattern>${NewLineIndentingNotFilteringPattern}</Pattern>
            </PatternLayout>
            <Policies>
                <SizeBasedTriggeringPolicy size="20480 KB"/>
            </Policies>
            <DefaultRolloverStrategy fileIndex="min" max="5"/>
        </JiraHomeAppender>
        <JiraHomeAppender name="httpdumplog"
                          fileName="atlassian-jira-http-dump.log"
                          filePattern="atlassian-jira-http-dump.log.%i">
            <PatternLayout alwaysWriteExceptions="false">
                <Pattern>${NewLineIndentingNotFilteringPattern}</Pattern>
            </PatternLayout>
            <Policies>
                <SizeBasedTriggeringPolicy size="20480 KB"/>
            </Policies>
            <DefaultRolloverStrategy fileIndex="min" max="5"/>
        </JiraHomeAppender>
        <JiraHomeAppender name="profilerlog"
                          fileName="atlassian-jira-profiler.log"
                          filePattern="atlassian-jira-profiler.log.%i">
            <PatternLayout>
                <Pattern>${ProfilerPattern}</Pattern>
            </PatternLayout>
            <Policies>
                <SizeBasedTriggeringPolicy size="20480 KB"/>
            </Policies>
            <DefaultRolloverStrategy fileIndex="min" max="5"/>
        </JiraHomeAppender>
        <JiraHomeAppender name="sqllog"
                          fileName="atlassian-jira-sql.log"
                          filePattern="atlassian-jira-sql.log.%i">
            <PatternLayout alwaysWriteExceptions="false">
                <Pattern>${NoLevelNonFilteringPattern}</Pattern>
            </PatternLayout>
            <Policies>
                <SizeBasedTriggeringPolicy size="20480 KB"/>
            </Policies>
            <DefaultRolloverStrategy fileIndex="min" max="5"/>
        </JiraHomeAppender>

        <JiraHomeAppender name="slowquerylog"
                          fileName="atlassian-jira-slow-queries.log"
                          filePattern="atlassian-jira-slow-queries.log.%i">
            <PatternLayout alwaysWriteExceptions="false">
                <Pattern>${StackTraceFilteringPattern}</Pattern>
            </PatternLayout>
            <Policies>
                <SizeBasedTriggeringPolicy size="20480 KB"/>
            </Policies>
            <DefaultRolloverStrategy fileIndex="min" max="5"/>
        </JiraHomeAppender>

        <JiraHomeAppender name="querydsllog"
                          fileName="atlassian-jira-querydsl-sql.log"
                          filePattern="atlassian-jira-querydsl-sql.log.%i">
            <PatternLayout alwaysWriteExceptions="false">
                <Pattern>${NoLevelNonFilteringPattern}</Pattern>
            </PatternLayout>
            <Policies>
                <SizeBasedTriggeringPolicy size="20480 KB"/>
            </Policies>
            <DefaultRolloverStrategy fileIndex="min" max="5"/>
        </JiraHomeAppender>

        <JiraHomeAppender name="slowsqlquerylog"
                          fileName="atlassian-jira-slow-querydsl-queries.log"
                          filePattern="atlassian-jira-slow-querydsl-queries.log.%i">
            <PatternLayout alwaysWriteExceptions="false">
                <Pattern>${NonFilteringPattern}</Pattern>
            </PatternLayout>
            <Policies>
                <SizeBasedTriggeringPolicy size="20480 KB"/>
            </Policies>
            <DefaultRolloverStrategy fileIndex="min" max="5"/>
        </JiraHomeAppender>

        <JiraHomeAppender name="xsrflog"
                          fileName="atlassian-jira-xsrf.log"
                          filePattern="atlassian-jira-xsrf.log.%i">
            <PatternLayout alwaysWriteExceptions="false">
                <Pattern>${NoLevelNonFilteringPattern}</Pattern>
            </PatternLayout>
            <Policies>
                <SizeBasedTriggeringPolicy size="20480 KB"/>
            </Policies>
            <DefaultRolloverStrategy fileIndex="min" max="5"/>
        </JiraHomeAppender>

        <JiraHomeAppender name="securitylog"
                          fileName="atlassian-jira-security.log"
                          filePattern="atlassian-jira-security.log.%i">
            <PatternLayout alwaysWriteExceptions="false">
                <Pattern>${IpAddressNonFilteringPattern}</Pattern>
            </PatternLayout>
            <Policies>
                <SizeBasedTriggeringPolicy size="20480 KB"/>
            </Policies>
            <DefaultRolloverStrategy fileIndex="min" max="5"/>
        </JiraHomeAppender>

        <JiraHomeAppender name="outgoingmaillog"
                          fileName="atlassian-jira-outgoing-mail.log"
                          filePattern="atlassian-jira-outgoing-mail.log.%i">
            <PatternLayout alwaysWriteExceptions="false">
                <Pattern>${OutgoingMailPattern}</Pattern>
            </PatternLayout>
            <Policies>
                <SizeBasedTriggeringPolicy size="20480 KB"/>
            </Policies>
            <DefaultRolloverStrategy fileIndex="min" max="5"/>
        </JiraHomeAppender>

        <JiraHomeAppender name="incomingmaillog"
                          fileName="atlassian-jira-incoming-mail.log"
                          filePattern="atlassian-jira-incoming-mail.log.%i">
            <PatternLayout alwaysWriteExceptions="false">
                <Pattern>${IncomingMailPattern}</Pattern>
            </PatternLayout>
            <Policies>
                <SizeBasedTriggeringPolicy size="20480 KB"/>
            </Policies>
            <DefaultRolloverStrategy fileIndex="min" max="5"/>
        </JiraHomeAppender>

        <JiraHomeAppender name="remoteappssecurity"
                          fileName="atlassian-remoteapps-security.log"
                          filePattern="atlassian-remoteapps-security.log.%i">
            <PatternLayout alwaysWriteExceptions="false">
                <Pattern>${StackTraceFilteringPattern}</Pattern>
            </PatternLayout>
            <Policies>
                <SizeBasedTriggeringPolicy size="20480 KB"/>
            </Policies>
            <DefaultRolloverStrategy fileIndex="min" max="5"/>
        </JiraHomeAppender>

        <JiraHomeAppender name="apdexlog"
                          fileName="atlassian-jira-apdex.log"
                          filePattern="atlassian-jira-apdex.log.%i">
            <PatternLayout alwaysWriteExceptions="false">
                <Pattern>${ApdexPattern}</Pattern>
            </PatternLayout>
            <Policies>
                <SizeBasedTriggeringPolicy size="20480 KB"/>
            </Policies>
            <DefaultRolloverStrategy fileIndex="min" max="5"/>
        </JiraHomeAppender>

        <JiraHomeAppender name="startupjdbc"
                          fileName="jdbc-startup.log"
                          filePattern="jdbc-startup.log.%i">
            <PatternLayout alwaysWriteExceptions="false">
                <Pattern>${JustMessagePattern}</Pattern>
            </PatternLayout>
            <Policies>
                <SizeBasedTriggeringPolicy size="10480 KB"/>
            </Policies>
            <DefaultRolloverStrategy fileIndex="min" max="2"/>
        </JiraHomeAppender>

        <JiraHomeAppender name="diagnostics"
                          fileName="jira-diagnostics.log"
                          filePattern="jira-diagnostics.log.%i">
            <PatternLayout alwaysWriteExceptions="false">
                <Pattern>${StackTraceFilteringPattern}</Pattern>
            </PatternLayout>
            <Policies>
                <SizeBasedTriggeringPolicy size="20480 KB"/>
            </Policies>
            <DefaultRolloverStrategy fileIndex="min" max="5"/>
        </JiraHomeAppender>

        <JiraHomeAppender name="performance"
                          fileName="atlassian-jira-perf.log"
                          filePattern="atlassian-jira-perf.log.%i">
            <PatternLayout alwaysWriteExceptions="false">
                <Pattern>%d ${JustMessagePattern}</Pattern>
            </PatternLayout>
            <Policies>
                <SizeBasedTriggeringPolicy size="20480 KB"/>
            </Policies>
            <DefaultRolloverStrategy fileIndex="min" max="5"/>
        </JiraHomeAppender>

        <JiraHomeAppender name="app"
                          fileName="atlassian-jira-app-monitoring.log"
                          filePattern="atlassian-jira-app-monitoring.log.%i">
            <PatternLayout alwaysWriteExceptions="false">
                <Pattern>%d ${JustMessagePattern}</Pattern>
            </PatternLayout>
            <Policies>
                <SizeBasedTriggeringPolicy size="20480 KB"/>
            </Policies>
            <DefaultRolloverStrategy fileIndex="min" max="5"/>
        </JiraHomeAppender>

        <JiraHomeAppender name="ipd"
                          fileName="atlassian-jira-ipd-monitoring.log"
                          filePattern="atlassian-jira-ipd-monitoring.log.%i">
            <PatternLayout>
                <Pattern>${InProductDiagnosticPattern}</Pattern>
            </PatternLayout>
            <Policies>
                <SizeBasedTriggeringPolicy size="20480 KB"/>
            </Policies>
            <DefaultRolloverStrategy fileIndex="min" max="5"/>
        </JiraHomeAppender>

        <FluentdAppender name="fluentdAppender"
                         fluentdEndpoint="http://localhost:9880">
            <AtlassianJsonLayout
                    filteringApplied="true"
                    filteredFrames="@jira-filtered-frames.properties"
                    minimumLines="6"
                    showEludedSummary="false"
                    includeLocation="true"/>
            <ThresholdFilter level="INFO" onMatch="ACCEPT" onMismatch="DENY"/>
        </FluentdAppender>

    </Appenders>
    <Loggers>
        <Root level="WARN">
            <AppenderRef ref="filelog"/>
            <AppenderRef ref="fluentdAppender"/>
        </Root>
        <!--        #####################################################-->
        <!--        # Log Marking-->
        <!--        #####################################################-->
        <Logger name="com.atlassian.jira.util.log.LogMarker" level="INFO" additivity="false">
            <AppenderRef ref="console"/>
            <AppenderRef ref="filelog"/>
            <AppenderRef ref="httpaccesslog"/>
            <AppenderRef ref="httpdumplog"/>
            <AppenderRef ref="sqllog"/>
            <AppenderRef ref="querydsllog"/>
            <AppenderRef ref="slowquerylog"/>
            <AppenderRef ref="slowsqlquerylog"/>
            <AppenderRef ref="xsrflog"/>
            <AppenderRef ref="securitylog"/>
            <AppenderRef ref="outgoingmaillog"/>
            <AppenderRef ref="incomingmaillog"/>
            <AppenderRef ref="remoteappssecurity"/>
            <AppenderRef ref="apdexlog"/>
        </Logger>
        <!--        #####################################################-->
        <!--        # Access logs-->
        <!--        #####################################################-->
        <Logger name="com.atlassian.jira.web.filters.accesslog.AccessLogFilter" level="OFF" additivity="false">
            <AppenderRef ref="httpaccesslog"/>
        </Logger>
        <Logger name="com.atlassian.jira.web.filters.accesslog.AccessLogFilterIncludeImages" level="OFF"
                additivity="false">
            <AppenderRef ref="httpaccesslog"/>
        </Logger>
        <Logger name="com.atlassian.jira.web.filters.accesslog.AccessLogFilterDump" level="OFF" additivity="false">
            <AppenderRef ref="httpdumplog"/>
        </Logger>
        <!--    #####################################################-->
        <!--    # SQL logs-->
        <!--    #####################################################-->
        <!--    #-->
        <!--    # Beware of turning this log level on.  At INFO level it will log every SQL statement-->
        <!--    # and at DEBUG level it will also log the calling stack trace.  Turning this on will DEGRADE your-->
        <!--    # JIRA database throughput.-->
        <!--    #-->
        <Logger name="com.atlassian.jira.ofbiz.LoggingSQLInterceptor" level="OFF" additivity="false">
            <AppenderRef ref="sqllog"/>
        </Logger>
        <Logger name="com.atlassian.jira.security.xsrf.XsrfVulnerabilityDetectionSQLInterceptor" level="OFF"
                additivity="false">
            <AppenderRef ref="xsrflog"/>
        </Logger>
        <!--    #####################################################-->
        <!--    # Security logs-->
        <!--    #####################################################-->
        <Logger name="com.atlassian.jira.login.security" level="INFO" additivity="false">
            <AppenderRef ref="securitylog"/>
        </Logger>

        <!--        The following log levels can be useful to set when login problems occur within JIRA-->
        <Logger name="com.atlassian.jira.login" level="WARN" additivity="false">
            <AppenderRef ref="securitylog"/>
        </Logger>
        <Logger name="com.atlassian.jira.web.session.currentusers" level="WARN" additivity="false">
            <AppenderRef ref="securitylog"/>
        </Logger>

        <!--        BEWARE - Turning on Seraph debug logs will result in many logs lines per web request.-->
        <Logger name="com.atlassian.seraph" level="WARN" additivity="false">
            <AppenderRef ref="securitylog"/>
        </Logger>
        <Logger name="com.atlassian.seraph.filter.LoginFilter" level="INFO" additivity="false">
            <AppenderRef ref="securitylog"/>
        </Logger>

        <!--        #####################################################-->
        <!--        # CLASS-SPECIFIC LOGGING LEVELS-->
        <!--        #####################################################-->
        <!--        # This stuff you may wish to debug, but it produces a high volume of logs.-->
        <!--        # Uncomment only if you want to debug something particular-->
        <Logger name="com.atlassian" level="WARN" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <Logger name="com.atlassian.jira" level="INFO" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <Logger name="com.atlassian.plugin" level="INFO" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <Logger name="atlassian.plugin" level="INFO" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <Logger name="org.twdata.pkgscanner" level="WARN" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <Logger name="com.atlassian.plugin.osgi.factory" level="WARN" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <Logger name="com.atlassian.plugin.osgi.container" level="WARN" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <Logger name="org.apache.shindig" level="ERROR" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <Logger name="com.atlassian.gadgets" level="WARN" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>

        <!--        # The directory may produce errors of interest to admins when adding gadgets with features that aren't supported-->
        <!--        # (for example).-->
        <Logger name="com.atlassian.gadgets.directory" level="INFO" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>

        <!--        # Felix annoyingly dumps some pretty silly INFO level messages. So we have to set logging to WARN here.  Means-->
        <!--        # we miss out on some useful startup logging.  Should probably remove this if Felix ever fix this.-->
        <Logger name="com.atlassian.plugin.osgi.container.felix.FelixOsgiContainerManager" level="WARN"
                additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>

        <Logger name="com.atlassian.plugin.servlet" level="WARN" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <Logger name="com.atlassian.plugin.classloader" level="WARN" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <!--        # PluginEnabler spams startup log with 200+ messages about plugins getting enabled-->
        <Logger name="com.atlassian.plugin.manager.PluginEnabler" level="WARN" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <!--        # DevModeBeanInitialisationLoggerBeanPostProcessor spams with shit tonne of boring debug-level messages at WARN-->
        <Logger name="com.atlassian.plugin.spring.scanner.extension.DevModeBeanInitialisationLoggerBeanPostProcessor"
                level="ERROR" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <Logger name="com.atlassian.jira.util.system.JiraSystemRestarterImpl" level="INFO" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <Logger name="com.atlassian.upgrade" level="INFO" additivity="false">
            <AppenderRef ref="filelog"/>
            <AppenderRef ref="console"/>
        </Logger>
        <Logger name="com.atlassian.jira.upgrade" level="INFO" additivity="false">
            <AppenderRef ref="filelog"/>
            <AppenderRef ref="console"/>
        </Logger>
        <Logger name="com.atlassian.jira.upgrade.tasks.role" level="INFO" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <Logger name="com.atlassian.jira.startup" level="INFO" additivity="false">
            <AppenderRef ref="filelog"/>
            <AppenderRef ref="console"/>
        </Logger>
        <Logger name="com.atlassian.jira.config.database" level="INFO" additivity="false">
            <AppenderRef ref="filelog"/>
            <AppenderRef ref="console"/>
        </Logger>
        <Logger name="com.atlassian.jira.web.action.util.LDAPConfigurer" level="INFO" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <Logger name="com.atlassian.jira.imports" level="INFO" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <Logger name="com.atlassian.jira.plugin" level="INFO" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <Logger name="com.atlassian.jira.bc.dataimport" level="INFO" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <Logger name="com.atlassian.jira.security" level="INFO" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <Logger name="com.atlassian.jira.issue.index" level="INFO" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <Logger name="com.atlassian.jira.index.LuceneCorruptionChecker" level="INFO" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <Logger name="com.atlassian.jira.index.AccumulatingResultBuilder" level="INFO" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <!--        # DefaultIndexManager should run at INFO level, because we want to see messages when we force an optimise etc.-->
        <Logger name="com.atlassian.jira.issue.index.DefaultIndexManager" level="INFO" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <!--        # Allow the Composite IndexLifecycleManager to log info-->
        <Logger name="com.atlassian.jira.util.index" level="INFO" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <Logger name="com.atlassian.jira.project" level="INFO" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <Logger name="com.atlassian.jira.project.version" level="INFO" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <Logger name="com.atlassian.jira.issue.search.providers" level="INFO" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>

        <Logger name="com.atlassian.jira.issue.search.providers.LuceneSearchProvider_SLOW" level="INFO"
                additivity="false">
            <AppenderRef ref="slowquerylog"/>
        </Logger>
        <Logger name="com.atlassian.jira.issue.search.providers.DbSearchProvider_SLOW" level="INFO" additivity="false">
            <AppenderRef ref="slowsqlquerylog"/>
        </Logger>
        <Logger name="com.atlassian.jira.action.admin" level="INFO" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <Logger name="com.opensymphony" level="WARN" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <Logger name="com.atlassian.jira.user" level="INFO" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <Logger name="com.atlassian.jira.bc.user" level="INFO" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <Logger name="com.atlassian.jira.workflow" level="INFO" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <Logger name="com.atlassian.jira.service" level="INFO" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <Logger name="com.atlassian.jira.service.services.DebugService" level="DEBUG" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <Logger name="com.atlassian.jira.web.dispatcher.JiraWebworkActionDispatcher" level="WARN" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <Logger name="webwork" level="WARN" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <Logger name="webwork.util.ServletValueStack" level="WARN" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <Logger name="org.ofbiz.core.entity.jdbc.DatabaseUtil" level="INFO" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <Logger name="org.ofbiz" level="WARN" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <Logger name="com.atlassian.jira.plugin.ext.perforce" level="INFO" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <Logger name="logMessage.jsp" level="INFO" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <Logger name="com.atlassian.jira.issue.views" level="INFO" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <!--        # Project Imports should be logged at INFO level so we can see the steps running.-->
        <Logger name="com.atlassian.jira.imports.project" level="INFO" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <Logger name="com.atlassian.jira.plugin.userformat.DefaultUserFormats" level="INFO" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <Logger name="com.atlassian.jira.scheduler.JiraSchedulerLauncher" level="INFO" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <Logger name="com.atlassian.sal.jira.scheduling" level="INFO" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <Logger name="httpclient.wire" level="WARN" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <Logger name="com.atlassian.jira.propertyset.ComponentCachingOfBizPropertyEntryStore" level="INFO"
                additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>

        <!--        #####################################################-->
        <!--        # Crowd Embedded-->
        <!--        #####################################################-->

        <!--        # We want to get INFO level logs about Directory events-->
        <Logger name="com.atlassian.crowd.directory" level="INFO" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <!--        #####################################################-->
        <!--        # REST-->
        <!--        #####################################################-->

        <!--        # only show WARN for WADL generation doclet-->
        <Logger name="com.atlassian.plugins.rest.doclet" level="WARN" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <!--        # JRADEV-12012: suppress irrelevant warnings.-->
        <Logger name="com.sun.jersey.spi.container.servlet.WebComponent" level="ERROR" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <!--        #####################################################-->
        <!--        # JQL-->
        <!--        #####################################################-->
        <Logger name="com.atlassian.jira.jql" level="INFO" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <Logger name="com.atlassian.jira.jql.resolver" level="INFO" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>

        <!--        #####################################################-->
        <!--        # UAL-->
        <!--        #####################################################-->

        <Logger name="com.atlassian.applinks" level="WARN" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>

        <!--        #####################################################-->
        <!--        # ActiveObjects-->
        <!--        #####################################################-->

        <Logger name="net.java.ao" level="WARN" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <Logger name="net.java.ao.sql" level="WARN" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <Logger name="net.java.ao.DelegateConnectionHandler" level="WARN" additivity="false">
            <AppenderRef ref="sqllog"/>
        </Logger>
        <Logger name="net.java.ao.schema.SchemaGenerator" level="ERROR" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>

        <!--        #####################################################-->
        <!--        # Long Running Tasks-->
        <!--        #####################################################-->

        <Logger name="com.atlassian.jira.workflow.migration" level="INFO" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <Logger name="com.atlassian.jira.web.action.admin.index.IndexAdminImpl" level="INFO" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>

        <!--        #####################################################-->
        <!--        # PROFILING-->
        <!--        #####################################################-->

        <Logger name="com.atlassian.util.profiling.filters" level="INFO" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <Logger name="com.atlassian.util.profiling" level="DEBUG" additivity="false">
            <AppenderRef ref="profilerlog"/>
        </Logger>
        <Logger name="com.atlassian.jira.web.filters.ThreadLocalQueryProfiler" level="DEBUG" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <!--        # By default we ignore some usually harmless exception such as Client Abort Exceptions.  However-->
        <!--        # if this proves problematic then we can turn this to DEBUG log on.-->
        <Logger name="com.atlassian.jira.web.exception.WebExceptionChecker" level="OFF" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>

        <!--        # Errors in the logs occur at this logger if the user cancels a form upload. The actual exception-->
        <!--        # is rethrown and dealt with elsewhere so there is no need to keep these logs around.-->
        <Logger name="webwork.multipart.MultiPartRequestWrapper" level="OFF" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <Logger name="com.atlassian.jira.plugins.monitor" level="INFO" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>

        <!--        #####################################################-->
        <!--        # Mails-->
        <!--        #####################################################-->

        <!--        #-->
        <!--        # outgoing mail log includes also some logging information from classes which handle both incoming and outgoing mails-->
        <!--        # that's why the appender is configured at com.atlassian.mail level (not com.atlassian.mail.outgoing)-->
        <!--        #-->

        <Logger name="com.atlassian.mail" level="INFO" additivity="false">
            <AppenderRef ref="outgoingmaillog"/>
        </Logger>
        <Logger name="com.atlassian.mail.incoming" level="INFO" additivity="false">
            <AppenderRef ref="incomingmaillog"/>
        </Logger>
        <!--        # changes in mail settings need to be logged-->
        <Logger name="com.atlassian.jira.mail.settings.MailSetting" level="INFO" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <!--        # Need to ensure that the actual discovery of duplicates is logged-->
        <Logger name="com.atlassian.jira.upgrade.tasks.UpgradeTask_Build663" level="INFO" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <!--        # JRADEV-19240: Suppress useless warnings (will be fixed in atlassian-soy-templates-2.0.0, see SOY-18)-->
        <Logger name="com.atlassian.soy.impl.GetTextFunction" level="ERROR" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <!--        # JRADEV-19613: Remote should log security messages to a separate log file-->
        <Logger name="com.atlassian.plugin.remotable.plugin.module.oauth.OAuth2LOAuthenticator" level="INFO"
                additivity="false">
            <AppenderRef ref="remoteappssecurity"/>
        </Logger>
        <Logger name="com.atlassian.plugin.remotable.plugin.module.permission.ApiScopingFilter" level="INFO"
                additivity="false">
            <AppenderRef ref="remoteappssecurity"/>
        </Logger>
        <Logger name="com.atlassian.plugin.remotable.plugin.OAuthLinkManager" level="INFO"
                additivity="false">
            <AppenderRef ref="remoteappssecurity"/>
        </Logger>
        <Logger name="com.atlassian.plugin.remotable.plugin.util.http.CachingHttpContentRetriever" level="INFO"
                additivity="false">
            <AppenderRef ref="remoteappssecurity"/>
        </Logger>
        <Logger name="com.atlassian.plugin.remotable.plugin.service.LocalSignedRequestHandler" level="INFO"
                additivity="false">
            <AppenderRef ref="remoteappssecurity"/>
        </Logger>

        <Logger name="com.atlassian.jira.web.bean.BackingI18n" level="INFO" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <Logger name="com.atlassian.studio.jira.homepage.CloudHomepageFilter" level="INFO" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>

        <!--        # Suppress excessive config warnings from EHCACHE-->
        <Logger name="net.sf.ehcache.config.CacheConfiguration" level="ERROR" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <!--        # This one so it is in the UI and can be set-->
        <Logger name="net.sf.ehcache.distribution" level="ERROR" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <Logger name="com.atlassian.jira.cluster.cache.ehcache.BlockingParallelCacheReplicator" level="WARN"
                additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>

        <!--        # Asynchronous EHCACHE replication logging-->
        <!--        # when set to DEBUG produces similar logs to BlockingParallelCacheReplicator-->
        <Logger name="com.atlassian.jira.cluster.distribution.localq.LocalQCacheReplicator" level="INFO"
                additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <!--        # on INFO logs queue stats per node, on DEBUG logs queue stats per queue-->
        <Logger name="com.atlassian.jira.cluster.distribution.localq.LocalQCacheManager" level="INFO"
                additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>

        <!--        # Cluster Authentication stats-->
        <Logger name="com.atlassian.jira.cluster.distribution.localq.rmi.auth.ClusterAuthStatsManager" level="INFO"
                additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>

        <!--        # Decryption of the DB password-->
        <Logger name="com.atlassian.secrets" level="INFO" additivity="false">
            <AppenderRef ref="filelog"/>
            <AppenderRef ref="console"/>
        </Logger>
        <!--        # Logging cache flush events for ALL caches; stacktraces must still be enabled manually.-->
        <Logger name="com.atlassian.cache.event" level="WARN" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <Logger name="com.atlassian.cache.stacktrace" level="OFF" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <!--        # Added to give more information on AO startup-->
        <Logger name="com.atlassian.activeobjects.osgi" level="DEBUG" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <Logger name="jdbc.startup.log" level="INFO" additivity="false">
            <AppenderRef ref="startupjdbc"/>
        </Logger>

        <!--        #####################################################-->
        <!--        # Apdex logs-->
        <!--        #####################################################-->
        <!--        #-->
        <Logger name="com.atlassian.jira.apdex.impl.SendAnalyticsJobRunner" level="INFO" additivity="false">
            <AppenderRef ref="apdexlog"/>
        </Logger>

        <!--        #####################################################-->
        <!--        # App Diagnostics-->
        <!--        #####################################################-->
        <Logger name="atlassian-diagnostics" level="WARN" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <Logger name="atlassian-diagnostics-data-logger" level="INFO" additivity="false">
            <AppenderRef ref="diagnostics"/>
        </Logger>

        <!--        #####################################################-->
        <!--        # App Diagnostics - performance metrics logging-->
        <!--        #####################################################-->
        <Logger name="atlassian-performance" level="WARN" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <Logger name="atlassian-performance-data-logger" level="INFO" additivity="false">
            <AppenderRef ref="performance"/>
        </Logger>

        <!--        #####################################################-->
        <!--        # App Diagnostics - app monitoring logging-->
        <!--        #####################################################-->
        <Logger name="app-monitoring" level="INFO" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <Logger name="app-monitoring-data-logger" level="INFO" additivity="false">
            <AppenderRef ref="app"/>
        </Logger>

        <!--        #####################################################-->
        <!--        # Rate Limiting-->
        <!--        #####################################################-->
        <Logger name="com.atlassian.ratelimiting" level="INFO" additivity="false">
            <AppenderRef ref="filelog"/>
            <AppenderRef ref="console"/>
        </Logger>

        <!--        #####################################################-->
        <!--        # Index replay and DBR-->
        <!--        #####################################################-->
        <Logger name="com.atlassian.jira.index.ha.DefaultNodeReindexService" level="INFO" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <Logger name="com.atlassian.jira.cluster.dbr" level="INFO" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <Logger name="com.atlassian.jira.cluster.distribution.localq" level="INFO" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <Logger name="com.atlassian.jira.index.WriterWrapper" level="INFO" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <Logger name="com.atlassian.jira.index.DefaultIndexEngine" level="INFO" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>

        <!--        #####################################################-->
        <!--        # Startup index fetching-->
        <!--        #####################################################-->
        <Logger name="com.atlassian.jira.cluster.DefaultClusterManager" level="INFO" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <Logger name="com.atlassian.jira.cluster.DefaultIndexFetcher" level="INFO" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>

        <!--        #####################################################-->
        <!--        # In-product diagnostics monitoring logging-->
        <!--        #####################################################-->
        <Logger name="ipd-monitoring" level="INFO" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
        <Logger name="ipd-monitoring-data-logger" level="INFO" additivity="false">
            <AppenderRef ref="ipd"/>
        </Logger>

        <!--        #####################################################-->
        <!--        # Integrity Checks-->
        <!--        #####################################################-->
        <Logger name="com.atlassian.jira.appconsistency.integrity.IntegrityChecker" level="INFO" additivity="false">
            <AppenderRef ref="filelog"/>
        </Logger>
    </Loggers>
</Configuration>

 

여기서 <Loggers></Loggers> 태그 안에 가장 하단 부분에 원하는 패키지의 로그 레벨과 출력 부분을 추가해준다. 나의 경우 이렇게 추가했다.

<Logger name="kr.osci.aijql" level="DEBUG" additivity="false">
    <AppenderRef ref="filelog"/>
    <AppenderRef ref="console"/>
</Logger>

 

Step 2.서버 재실행

이렇게 설정한 후 서버 재실행을 해주면 된다! 그럼 atlassian-jira.log 파일에도 로그가 남고, 콘솔에도 로그가 잘 찍힌다!

 

이제 개발하면서 로그를 남기고 볼 수 있다 😆

728x90
반응형
LIST
728x90
반응형
SMALL

참고자료:

 

스프링 MVC 1편 - 백엔드 웹 개발 핵심 기술 강의 | 김영한 - 인프런

김영한 | 웹 애플리케이션을 개발할 때 필요한 모든 웹 기술을 기초부터 이해하고, 완성할 수 있습니다. 스프링 MVC의 핵심 원리와 구조를 이해하고, 더 깊이있는 백엔드 개발자로 성장할 수 있습

www.inflearn.com

이제 기능적으로 알아두면 좋을 것들 몇가지를 작성해보고자 한다. 

 

@ModelAttribute

Part.2에서 잠시 다뤘던 적이 있는 이 @ModelAttribute는 이런 기능들을 대신해준다.

다음 코드를 보자.

@PostMapping("/add")
public String save(@ModelAttribute Item item) {
    itemRepository.save(item);
    return "basic/item";
}

 

이 코드에 대해 조금 설명을 하자면, 어떤 상품 등록을 하는 폼이 있고 그 폼에서 상품 등록 버튼을 클릭하면 호출되는 @PostMapping이다. 그럼 상품 등록을 폼으로 한다고 하면 폼으로부터 전달되는 데이터가 있을 것인데 그 데이터가 저 @ModelAttribute로 담기게 된다.

그리고 타입은 Item 이라는 클래스인데 다음과 같이 생겼다.

Item

package hello.itemservice.domain.item;

import lombok.Data;

@Data
public class Item {
    private Long id;
    private String itemName;
    private Integer price;
    private Integer quantity;

    public Item() {
    }

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

 

그래서 폼으로부터 들어오는 데이터가 이 클래스의 필드로 다 만족을 하면 스프링 MVC는 자동으로 폼으로 들어오는 데이터를 저렇게 바인딩 할 수 있다. 그래서 정확히 저 코드는 사실 이렇게 생긴것이다.

@PostMapping("/add")
public String save(@RequestParam String itemName,
                   @RequestParam Integer price,
                   @RequestParam Integer quantity,
                   Model model) {
    // @ModelAttribute가 대신 해주는 작업                   
    Item item = new Item();
    item.setItemName(itemName);
    item.setPrice(price);
    item.setQuantity(quantity);
    model.addAttribute("item", item);
    // @ModelAttribute가 대신 해주는 작업 끝

    itemRepository.save(item);
    return "basic/item";
}

 

"어? Item 객체를 만들어서 데이터를 넣어주는 것까진 알겠는데 model.addAttribute("item", item); 까지 해준다고?!" 그렇다. 저 @ModelAttributeModel 객체에 데이터를 담아주는 것까지 해준다. 그리고 그때 key값은 클래스의 앞글자만 소문자로 바꾼 형태가 된다. (Itemitem)

 

Redirect

이번엔 리다이렉트를 하는 방법이다. 간단하다.

@PostMapping("/{itemId}/edit")
public String edit(@ModelAttribute Item item, @PathVariable Long itemId) {
    itemRepository.update(itemId, item);
    return "redirect:/basic/items/{itemId}";
}

 

저렇게 "redirect:/redirect할 경로" 를 입력하면 된다. 그리고 리다이렉트 할 경로에 PathVariable이 있는 경우 코드처럼 문자열로 {pathVariable}를 입력하면 된다. 그럼 스프링이 알아서 이 메서드의 @PathVariable에 있는 값을 매핑시켜준다.

 

당연히 이렇게도 가능하다. 오히려 @PathVariable을 받지 않는 메서드에서 itemId가 있어야 하는 경로로 리다이렉트 할 땐 아래 코드처럼만 해야한다.

@PostMapping("/add")
public String save(@ModelAttribute Item item) {
    itemRepository.save(item);
    return "redirect:/basic/items/" + item.getId();
}

 

근데 사실 좀 짜치는것도 있고, 더 큰 문제는 URL은 모든게 다 문자열로 이루어져 있다. 그래서 인코딩이 필수다. 숫자 같은 건 괜찮은데 한글이 만약 들어간다? 바로 깨진다. 그래서 인코딩이 필수이고 그 방법이 RedirectAttribute라는 게 있다.

@PostMapping("/add")
public String save(@ModelAttribute Item item, RedirectAttributes redirectAttributes) {
    Item savedItem = itemRepository.save(item);
    redirectAttributes.addAttribute("itemId", savedItem.getId());
    redirectAttributes.addAttribute("status", true);

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

이렇게 파라미터로 RedirectAttributes를 받으면 이 녀석을 사용할 수가 있는데, addAttribute()로 원하는 key/value를 넣으면 그 key를 가지고 {PathVariable}을 사용할 수가 있다. 

 

그리고 "status"라는 키도 있는데 이렇게 addAttribute()key/value를 저장하고 PathVariable로 사용하지 않는건 쿼리 파라미터로 들어간다. 그리고 이 키는 저장이 잘 됐다면 리다이렉트된 화면에서 뭔가 잘 저장됐다는 표시를 보여주고 싶어서 플래그를 사용했다고 생각하면 된다. 그리고 그 플래그를 Thymeleaf랑 같이 사용할 때 이렇게 param라는 키로 받을수가 있다.

<h2 th:if="${param.status}" th:text="'저장 완료'"></h2>

 

이런 방식이 훨씬 더 깔끔하고 인코딩도 다 해주기 때문에 더 좋은 접근방법이다. 이렇게 사용하자.

 

 

PRG - Post/Redirect/Get

이건 실무에서도 자주 사용되는 방식인데, 폼을 통해 POST 요청을 하고 보여지는 화면에서 사용자가 새로 고침을 누르면 POST 요청이 계속 들어간다. 그런 경우에 POST 요청이 계속 들어오면 만약 그게 상품 저장 기능이었다면 새로 고침한만큼 상품 저장이 되는 문제가 발생한다. 그 것을 방지하기 위해 폼을 통해 POST 요청을 처리하는 컨트롤러에서는 그 요청의 반환으로 Redirect를 해서 GET으로 최종 목적지를 변경해줘야 한다.

@PostMapping("/add")
public String save(@ModelAttribute Item item, RedirectAttributes redirectAttributes) {
    Item savedItem = itemRepository.save(item);
    redirectAttributes.addAttribute("itemId", savedItem.getId());
    redirectAttributes.addAttribute("status", true);

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

 

그래서 위 코드도 POST로 요청이 들어온 상품 저장 기능에 반환으로 리다이렉트를 통해 저장된 상품의 상세 목록으로 페이지를 이동시킨다. 그래야 사용자는 저장한 후 보여지는 화면에서 새로고침을 눌러도 POST 요청이 계속 발생하지 않는다. 그러니까 새로고침은 가장 마지막에 한 행위를 다시 하는것이다. 그래서 새로고침을 누르더라도 POST 요청이 다시 일어나지 않도록 리다이렉트로 마지막에 요청한 행위는 그저 상품 상세 화면을 보고 있는 GET 요청으로 바꿔줘야 한다. 

 

 

728x90
반응형
LIST
728x90
반응형
SMALL

참고자료:

 

김영한의 실전 자바 - 고급 1편, 멀티스레드와 동시성 강의 | 김영한 - 인프런

김영한 | 멀티스레드와 동시성을 기초부터 실무 레벨까지 깊이있게 학습합니다., 국내 개발 분야 누적 수강생 1위, 제대로 만든 김영한의 실전 자바[사진][임베딩 영상]단순히 자바 문법을 안다?

www.inflearn.com

 

이제 실무적 관점에서 좀 더 깊이있게 알아보자.

 

ExecutorService 우아한 종료 

고객의 주문을 처리하는 서버를 운영중이라고 생각해보자.

만약 서버 기능을 업데이트 해야해서 서버를 재시작해야 한다고 가정해보자.

이때 서버 애플리케이션이 고객의 주문을 처리하고 있는 도중에 갑자기 재시작 된다면, 해당 고객의 주문이 제대로 진행되지 못할 것이다. 가장 이상적인 방향은 새로운 주문 요청은 막고, 이미 진행중인 주문은 모두 완료한 다음에 서버를 재시작 하는 것이 가장 좋을 것이다. 이처럼 서비스를 안정적으로 종료하는 것도 매우 중요하다. 이렇게 문제 없이 우아하게 종료하는 방식을 graceful shutdown이라 한다.

 

이런 관점에서 ExecutorService의 종료에 대해서 알아보자.

 

ExecutorService의 종료 메서드 관련

void shutdown()
  • 새로운 작업을 받지 않고, 이미 제출된 작업을 모두 완료한 후에 종료한다.
  • 논 블로킹 메서드(이 메서드를 호출한 스레드는 대기하지 않고 즉시 다음 코드를 호출한다)

 

List<Runnable> shutdownNow()
  • 실행 중인 작업을 중단하고, 대기 중인 작업(블로킹 큐에 있는 대기 작업들을 의미)을 반환하며 즉시 종료한다.
  • 실행 중인 작업을 중단하기 위해 인터럽트를 발생시킨다.
  • 논 블로킹 메서드(이 메서드를 호출한 스레드는 대기하지 않고 즉시 다음 코드를 호출한다)

 

boolean isShutdown()
  • ExecutorService가 종료되었는지 확인한다.

 

boolean isTerminated()
  • shutdown(), shutdownNow() 호출 후, 모든 작업이 완료되었는지 확인한다.

 

boolean awaitTermination(long timeout, TimeUnit unit) throws InterruptException
  • 서비스 종료 시 모든 작업이 완료될 때까지 대기한다. 이때 지정된 시간까지만 대기한다.
  • 블로킹 메서드(이 메서드를 호출한 스레드는 이 메서드가 종료될 때까지 대기 상태가 된다.)

 

void close()
  • 자바 19부터 지원하는 서비스 종료 메서드이다. 이 메서드는 shutdown()과 같다고 생각하면 된다. 더 정확히는 shutdown()을 호출하고, 하루를 기다려도 작업이 완료되지 않으면 shutdownNow()를 호출한다.
  • 호출한 스레드에 인터럽트가 발생해도 shutdownNow()를 호출한다. 

 

  • ExecutorService에 아무런 작업이 없고, 스레드만 2개 대기하고 있다.

  • shutdown()을 호출한다.
  • ExecutorService는 새로운 요청을 거절한다.
    • 거절 시 기본적으로 java.util.concurrent.RejectedExecutionException 예외가 발생한다.
  • 스레드 풀의 자원을 정리한다.

 

  • shutdown()을 호출한다.
  • ExecutorService는 새로운 요청을 거절한다.
  • 스레드 풀의 스레드는 처리중인 작업을 완료한다.
  • 스레드 풀의 스레드는 큐에 남아있는 작업도 모두 꺼내서 완료한다.

  • 모든 작업을 완료하면 자원을 정리한다.
  • 결과적으로 처리중이던 taskA, taskB는 물론이고, 큐에 대기중이던 taskC, taskD도 완료된다.

 

  • shutdownNow()를 호출한다.
  • ExecutorService는 새로운 요청을 거절한다.
  • 큐를 비우면서, 큐에 있는 작업을 모두 꺼내서 컬렉션으로 반환한다.
    • List<Runnable> runnables = es.shutdownNow()
  • 작업 중인 스레드에 인터럽트가 발생한다.
    • 작업 중인 taskA, taskB는 인터럽트가 걸린다.
    • 큐에 대기중인 taskC, taskD는 수행되지 않는다.
  • 작업을 완료하면 자원을 정리한다.

 

ExecutorService 우아한 종료 - 구현

shutdown()을 호출해서 이미 들어온 모든 작업을 다 처리하고 서비스를 우아하게 종료(graceful shutdown)하는 것이 가장 이상적이지만, 갑자기 요청이 너무 많이 들어와서 큐에 대기중인 작업이 너무 많아 작업 완료가 어렵거나, 작업이 너무 오래 걸리거나, 또는 버그가 발생해서 특정 작업이 끝나지 않을 수 있다. 이렇게 되면 서비스가 너무 늦게 종료되거나 종료되지 않는 문제가 발생할 수 있다.

 

이럴 때는 보통 우아하게 종료하는 시간을 정한다. 예를 들어서 60초까지는 작업을 다 처리할 수 있게 기다리는 것이다. 그리고 60초가 지나면, 무언가 문제가 있다고 가정하고 shutdownNow()을 호출해서 작업들을 강제로 종료한다.

 

close()

close()의 경우 이렇게 구현되어 있다. shutdown()을 호출하고, 하루를 기다려도 작업이 완료되지 않으면 shutdownNow()를 호출한다. 그런데 대부분 하루를 기다릴 수는 없을 것이다.

 

방금 설명한대로 우선은 shutdown()을 통해 우아한 종료를 시도하고, 10초간 종료되지 않으면 shutdownNow()를 통해 강제 종료하는 방식을 구현해보자. (예제에서 60초는 너무 길다..) 참고로 구현할 shutdownAndAwaitTermination()ExecutorService 공식 API 문서에서 제안하는 방식이다. 

 

ExecutorShutdownMain

package thread.executor;

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;

import static util.MyLogger.log;

public class ExecutorShutdownMain {

    public static void main(String[] args) {
        ExecutorService es = Executors.newFixedThreadPool(2);

        es.execute(new RunnableTask("taskA"));
        es.execute(new RunnableTask("taskB"));
        es.execute(new RunnableTask("taskC"));
        es.execute(new RunnableTask("longTask", 100_000));

        ExecutorUtils.printState(es);

        log("== shutdown 시작==");

        shutdownAndAwaitTermination(es);

        log("== shutdown 완료==");

        ExecutorUtils.printState(es);

    }

    private static void shutdownAndAwaitTermination(ExecutorService es) {
        es.shutdown();

        try {
            if (!es.awaitTermination(10, TimeUnit.SECONDS)) {
                log("서비스 정상 종료 실패 -> 강제 종료 시도");
                es.shutdownNow();

                if (!es.awaitTermination(10, TimeUnit.SECONDS)) {
                    log("서비스가 종료되지 않았습니다.");
                }
            }
        } catch (InterruptedException e) {
            es.shutdownNow();
        }
    }
}

실행 결과

2024-07-30 13:01:53.014 [pool-1-thread-2] taskB 시작
2024-07-30 13:01:53.014 [     main] [pool= 2, active=2, queuedTasks=2, completedTask=0]
2024-07-30 13:01:53.014 [pool-1-thread-1] taskA 시작
2024-07-30 13:01:53.018 [     main] == shutdown 시작==
2024-07-30 13:01:54.020 [pool-1-thread-2] taskB 완료
2024-07-30 13:01:54.020 [pool-1-thread-1] taskA 완료
2024-07-30 13:01:54.022 [pool-1-thread-2] taskC 시작
2024-07-30 13:01:54.022 [pool-1-thread-1] longTask 시작
2024-07-30 13:01:55.023 [pool-1-thread-2] taskC 완료
2024-07-30 13:02:03.024 [     main] 서비스 정상 종료 실패 -> 강제 종료 시도
2024-07-30 13:02:03.026 [pool-1-thread-1] 인터럽트 발생, null
2024-07-30 13:02:03.029 [     main] == shutdown 완료==
2024-07-30 13:02:03.030 [     main] [pool= 0, active=0, queuedTasks=0, completedTask=4]
Exception in thread "pool-1-thread-1" java.lang.RuntimeException: java.lang.InterruptedException
	at util.ThreadUtils.sleep(ThreadUtils.java:12)
	at thread.executor.RunnableTask.run(RunnableTask.java:23)
	at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1144)
	at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:642)
	at java.base/java.lang.Thread.run(Thread.java:1595)
Caused by: java.lang.InterruptedException
	at java.base/java.lang.Thread.sleepImpl(Native Method)
	at java.base/java.lang.Thread.sleep(Thread.java:516)
	at util.ThreadUtils.sleep(ThreadUtils.java:9)
	... 4 more

 

작업 처리에 필요한 시간

  • taskA, taskB, taskC: 1초
  • longTask: 100초

서비스 종료

es.shutdown();
  • 새로운 작업을 받지 않는다. 처리 중이거나, 큐에 이미 대기중인 작업은 처리한다. 이후에 풀의 스레드를 종료한다.
  • shutdown()은 블로킹 메서드가 아니다. ExecutorService가 종료될 때까지 main 스레드가 대기하지 않는다. main 스레드는 바로 다음 코드를 호출한다.
if (!es.awaitTermination(10, TimeUnit.SECONDS) { ... }
  • 블로킹 메서드이다.
  • main 스레드는 대기하며 서비스 종료를 10초간 기다린다.
    • 만약 10초안에 모든 작업이 끝나면 true를 반환한다.
  • 여기서 taskA, taskB, taskC의 수행이 완료된다. 그런데 longTask는 10초가 지나도 완료되지 않았다. 따라서 false를 반환한다.

 

서비스 정상 종료 실패 → 강제 종료 시도

if (!es.awaitTermination(10, TimeUnit.SECONDS)) {
    log("서비스 정상 종료 실패 -> 강제 종료 시도");
    es.shutdownNow();

    if (!es.awaitTermination(10, TimeUnit.SECONDS)) {
        log("서비스가 종료되지 않았습니다.");
    }
}
  • 정상 종료가 10초 이상 너무 오래 걸렸다.
  • shutdownNow()를 통해 강제 종료에 들어간다. shutdown()과 마찬가지로 블로킹 메서드가 아니다.
  • 강제 종료를 하면 작업 중인 스레드에 인터럽트가 발생한다. 다음 로그를 통해 인터럽트를 확인할 수 있다.
  • 인터럽트가 발생하면서 스레드도 작업을 종료하고, shutdownNow()를 통한 강제 shutdown도 완료된다.
2024-07-30 13:02:03.024 [     main] 서비스 정상 종료 실패 -> 강제 종료 시도
2024-07-30 13:02:03.026 [pool-1-thread-1] 인터럽트 발생, null
2024-07-30 13:02:03.029 [     main] == shutdown 완료==

 

서비스 종료 실패

그런데 마지막에 강제 종료인 es.shutdownNow()를 호출한 다음에 왜 10초간 또 기다릴까?

shutdownNow()가 작업 중인 스레드에 인터럽트를 호출하는 것은 맞다. 인터럽트를 호출하더라도 여러가지 이유로 작업에 시간이 걸릴 수 있다. 인터럽트 이후에 자원을 정리하는 어떤 간단한 작업을 수행할 수도 있다. 이런 시간을 기다려주는 것이다. 극단적으로 최악의 경우 스레드가 다음과 같이 인터럽트를 받을 수 없는 코드를 수행중일 수 있다. 이 경우 인터럽트 예외가 발생하지 않고, 스레드도 계속 수행된다.

 

인터럽트를 받을 수 없는 코드

while(true) {}

이런 스레드는 자바를 강제 종료해야 제거할 수 있다.

 

이런 경우를 대비해서 강제 종료 후 10초간 대기해도 작업이 완료되지 않으면 "서비스가 종료되지 않았습니다"라고 개발자가 인지할 수 있는 로그를 남겨두어야 한다. 그래야 개발자가 나중에 문제를 찾아서 코드를 수정도 할 수 있고 이 종료되지 않는 프로그램을 자바를 강제 종료 시켜서라도 종료할 수 있다.

 

try {
    ...
} catch (InterruptedException e) {
    es.shutdownNow();
}

이 부분은 왜 있을까? InterruptedException을 왜 catch로 잡았을까? 잡은 후 왜 또 shutdownNow()를 호출할까?

그 이유는 아래 코드를 호출할 때, 다른 스레드에서 이 awaitTermination()을 호출한 스레드에 인터럽트를 걸 수도 있기 때문이다.

그 경우에도 무사히 종료할 수 있도록 catch로 잡아서 shutdownNow()를 호출한다.

es.awaitTermination(10, TimeUnit.SECONDS)

 

정리

서비스를 종료할 때 생각보다 고려해야 할 점이 많다는 것을 이해했을 것이다. 기본적으로 우아한 종료를 선택하고, 우아한 종료가 되지 않으면 무한정 기다릴 수는 없으니, 그 다음으로 강제 종료를 하는 방식으로 접근하는 것이 좋다.

 

Executor 스레드 풀 관리 - 코드

이번 시간에는 Executor 프레임워크가 어떤식으로 스레드를 관리하는지 깊이있게 알아보자. 이 부분을 알아두면 실무에서 대량의 요청을 별도의 스레드에서 어떤식으로 처리해야 하는지에 대한 기본기를 쌓을 수 있을 것이다. 

 

ExecutorService의 기본 구현체인 ThreadPollExecutor의 생성자는 다음 속성을 지원한다.

  • corePoolSize: 스레드 풀에서 관리되는 기본 스레드의 수
  • maximumPoolSize: 스레드 풀에서 관리되는 최대 스레드 수
  • keepAliveTime, TimeUnit unit: 기본 스레드 수를 초과해서 만들어진 초과 스레드가 생존할 수 있는 대기 시간, 이 시간 동안 처리할 작업이 없다면 초과 스레드는 제거된다.
  • BlockingQueue workQueue: 작업을 보관할 블로킹 큐

corePoolSizemaximumPoolSize의 차이를 알아보기 위해 간단한 예제를 만들어보자.

먼저 예제를 좀 더 쉽게 확인하기 위해 ExecutorUtils에 메서드를 하나 추가하자.

 

ExecutorUtils

package thread.executor;

import java.util.concurrent.ExecutorService;
import java.util.concurrent.ThreadPoolExecutor;

import static util.MyLogger.log;

public abstract class ExecutorUtils {

    public static void printState(ExecutorService executorService) {

        if (executorService instanceof ThreadPoolExecutor poolExecutor) {
            int pool = poolExecutor.getPoolSize();
            int active = poolExecutor.getActiveCount();
            int queuedTasks = poolExecutor.getQueue().size();
            long completedTask = poolExecutor.getCompletedTaskCount();

            log("[pool= " + pool + ", active=" + active + ", queuedTasks=" + queuedTasks + ", completedTask=" + completedTask + "]");
        } else {
            log(executorService);
        }
    }

    public static void printState(ExecutorService executorService, String taskName) {

        if (executorService instanceof ThreadPoolExecutor poolExecutor) {
            int pool = poolExecutor.getPoolSize();
            int active = poolExecutor.getActiveCount();
            int queuedTasks = poolExecutor.getQueue().size();
            long completedTask = poolExecutor.getCompletedTaskCount();

            log(taskName + " -> [pool= " + pool + ", active=" + active + ", queuedTasks=" + queuedTasks + ", completedTask=" + completedTask + "]");
        } else {
            log(executorService);
        }
    }
}
  • printState() 메서드를 하나 오버로딩했다. 단순히 taskName을 출력하는 부분이 추가되었다.
  • 중복된 부분을 제거할 수 있지만, 기본 코드를 유지하기 위해 그대로 복사해서 약간만 수정했다.

추가로 이전에 만든 RunnableTask를 사용한다. 다음 코드는 앞서 만든 코드이니 참고만 하자. RuunableTask는 기본 1초 정도 작업을 수행한다고 가정한다.

RunnableTask

package thread.executor;

import static util.MyLogger.log;
import static util.ThreadUtils.sleep;

public class RunnableTask implements Runnable {

    private final String name;
    private int sleepMs = 1000;

    public RunnableTask(String name) {
        this.name = name;
    }

    public RunnableTask(String name, int sleepMs) {
        this.name = name;
        this.sleepMs = sleepMs;
    }

    @Override
    public void run() {
        log(name + " 시작");
        sleep(sleepMs);
        log(name + " 완료");
    }
}

 

 

이제 이것들을 활용해서 한번 maximumPoolSize의 비밀을 파헤쳐보자.

PoolSizeMainV1

package thread.executor.poolsize;

import thread.executor.ExecutorUtils;
import thread.executor.RunnableTask;

import java.util.concurrent.*;

import static util.MyLogger.log;
import static util.ThreadUtils.sleep;

public class PoolSizeMainV1 {

    public static void main(String[] args) {
        ArrayBlockingQueue<Runnable> workQueue = new ArrayBlockingQueue<>(2);
        ExecutorService es = new ThreadPoolExecutor(
                2,
                4,
                3000,
                TimeUnit.MILLISECONDS,
                workQueue);

        ExecutorUtils.printState(es);

        es.execute(new RunnableTask("task1"));
        ExecutorUtils.printState(es, "task1");

        es.execute(new RunnableTask("task2"));
        ExecutorUtils.printState(es, "task2");

        es.execute(new RunnableTask("task3"));
        ExecutorUtils.printState(es, "task3");

        es.execute(new RunnableTask("task4"));
        ExecutorUtils.printState(es, "task4");

        es.execute(new RunnableTask("task5"));
        ExecutorUtils.printState(es, "task5");

        es.execute(new RunnableTask("task6"));
        ExecutorUtils.printState(es, "task6");

        try {
            es.execute(new RunnableTask("task7"));
        } catch (RejectedExecutionException e) {
            log("task7 실행 거절 예외 발생: " + e);
        }

        sleep(3000);
        log("== 작업 수행 완료 ==");
        ExecutorUtils.printState(es);

        sleep(3000);
        log("== maximumPoolSize 대기 시간 초과 ==");
        ExecutorUtils.printState(es);

        es.close();
        log(" == shutdown 완료 ==");
        ExecutorUtils.printState(es);
    }
}

 

ArrayBlockingQueue<Runnable> workQueue = new ArrayBlockingQueue<>(2);
ExecutorService es = new ThreadPoolExecutor(
        2,
        4,
        3000,
        TimeUnit.MILLISECONDS,
        workQueue);
  • 작업을 보관할 블로킹 큐의 구현체로 ArrayBlockingQueue(2)를 사용했다. 사이즈를 2로 설정했으므로 최대 2개까지 작업을 큐에 보관할 수 있다.
  • corePoolSize = 2, maximumPoolSize = 4 를 사용해서 기본 스레드 2개, 최대 스레드는 4개로 설정했다.
    • 스레드 풀에 기본 2개의 스레드를 운영한다. 요청이 너무 많거나 급한 경우 스레드 풀은 최대 4개까지 스레드를 증가시켜서 사용할 수 있다. 이렇게 기본 스레드 수를 초과해서 만들어진 스레드를 초과 스레드라고 하겠다.
  • 3000, TimeUnit.MILLISECONDS
    • 초과 스레드가 생존할 수 있는 대기 시간을 뜻한다. 이 시간 동안 초과 스레드가 처리할 작업이 없다면 초과 스레드는 제거한다.
    • 여기서는 3000 밀리초(3초)를 설정했으므로, 초과 스레드가 3초간 작업을 하지 않고 대기한다면 초과 스레드는 스레드 풀에서 제거된다.

 

근데, 이 maximumPoolSize가 그냥 단순하게 기본 스레드가 다 없으면 바로 이 최대 스레드 수까지 스레드 수를 늘려서 작업을 처리하는 게 아니다.

 

우선 아래 코드를 실행해보자.

package thread.executor.poolsize;

import thread.executor.ExecutorUtils;
import thread.executor.RunnableTask;

import java.util.concurrent.*;

import static util.MyLogger.log;
import static util.ThreadUtils.sleep;

public class PoolSizeMainV1 {

    public static void main(String[] args) {
        ArrayBlockingQueue<Runnable> workQueue = new ArrayBlockingQueue<>(2);
        ExecutorService es = new ThreadPoolExecutor(
                2,
                4,
                3000,
                TimeUnit.MILLISECONDS,
                workQueue);

        ExecutorUtils.printState(es);

        es.execute(new RunnableTask("task1"));
        ExecutorUtils.printState(es, "task1");

        es.execute(new RunnableTask("task2"));
        ExecutorUtils.printState(es, "task2");

        es.close();
        log(" == shutdown 완료 ==");
        ExecutorUtils.printState(es);
    }
}

실행 결과

2024-07-30 14:21:29.778 [     main] [pool= 0, active=0, queuedTasks=0, completedTask=0]
2024-07-30 14:21:29.783 [pool-1-thread-1] task1 시작
2024-07-30 14:21:29.793 [     main] task1 -> [pool= 1, active=1, queuedTasks=0, completedTask=0]
2024-07-30 14:21:29.793 [     main] task2 -> [pool= 2, active=2, queuedTasks=0, completedTask=0]
2024-07-30 14:21:29.793 [pool-1-thread-2] task2 시작
2024-07-30 14:21:30.785 [pool-1-thread-1] task1 완료
2024-07-30 14:21:30.795 [pool-1-thread-2] task2 완료
2024-07-30 14:21:30.799 [     main]  == shutdown 완료 ==
2024-07-30 14:21:30.799 [     main] [pool= 0, active=0, queuedTasks=0, completedTask=2]

 

task가 총 2개이므로 기본 스레드 수와 일치한다. 이 경우 아무런 문제 없이 기본 스레드 수만으로 작업을 처리하게 된다.

그럼 단순하게 생각했을 땐, 이 경우에 task가 하나 더 늘어나면? "최대 스레드 수가 4개니까 스레드 한 개가 더 추가되겠다!" 라고 생각할 수 있다. 그래서 task 하나를 더 추가해서 실행해보자.

package thread.executor.poolsize;

import thread.executor.ExecutorUtils;
import thread.executor.RunnableTask;

import java.util.concurrent.*;

import static util.MyLogger.log;
import static util.ThreadUtils.sleep;

public class PoolSizeMainV1 {

    public static void main(String[] args) {
        ArrayBlockingQueue<Runnable> workQueue = new ArrayBlockingQueue<>(2);
        ExecutorService es = new ThreadPoolExecutor(
                2,
                4,
                3000,
                TimeUnit.MILLISECONDS,
                workQueue);

        ExecutorUtils.printState(es);

        es.execute(new RunnableTask("task1"));
        ExecutorUtils.printState(es, "task1");

        es.execute(new RunnableTask("task2"));
        ExecutorUtils.printState(es, "task2");
        
        es.execute(new RunnableTask("task3"));
        ExecutorUtils.printState(es, "task3");

        es.close();
        log(" == shutdown 완료 ==");
        ExecutorUtils.printState(es);
    }
}

실행 결과

2024-07-30 14:24:09.864 [     main] [pool= 0, active=0, queuedTasks=0, completedTask=0]
2024-07-30 14:24:09.869 [pool-1-thread-1] task1 시작
2024-07-30 14:24:09.882 [     main] task1 -> [pool= 1, active=1, queuedTasks=0, completedTask=0]
2024-07-30 14:24:09.882 [     main] task2 -> [pool= 2, active=2, queuedTasks=0, completedTask=0]
2024-07-30 14:24:09.882 [pool-1-thread-2] task2 시작
2024-07-30 14:24:09.882 [     main] task3 -> [pool= 2, active=2, queuedTasks=1, completedTask=0]
2024-07-30 14:24:10.872 [pool-1-thread-1] task1 완료
2024-07-30 14:24:10.873 [pool-1-thread-1] task3 시작
2024-07-30 14:24:10.884 [pool-1-thread-2] task2 완료
2024-07-30 14:24:11.875 [pool-1-thread-1] task3 완료
2024-07-30 14:24:11.876 [     main]  == shutdown 완료 ==
2024-07-30 14:24:11.877 [     main] [pool= 0, active=0, queuedTasks=0, completedTask=3]

결과를 보니, queuedTasks에 들어갔다. 생각해보니, 2개가 작업중이고 블로킹 큐 사이즈가 2라면 이 상태에서 더 들어온 작업은 큐에 대기 상태로 남는게 더 합리적이다. 그럼 큐 사이즈가 2이니까 최대 사이즈까지 RunnableTask를 추가해보자.

package thread.executor.poolsize;

import thread.executor.ExecutorUtils;
import thread.executor.RunnableTask;

import java.util.concurrent.*;

import static util.MyLogger.log;
import static util.ThreadUtils.sleep;

public class PoolSizeMainV1 {

    public static void main(String[] args) {
        ArrayBlockingQueue<Runnable> workQueue = new ArrayBlockingQueue<>(2);
        ExecutorService es = new ThreadPoolExecutor(
                2,
                4,
                3000,
                TimeUnit.MILLISECONDS,
                workQueue);

        ExecutorUtils.printState(es);

        es.execute(new RunnableTask("task1"));
        ExecutorUtils.printState(es, "task1");

        es.execute(new RunnableTask("task2"));
        ExecutorUtils.printState(es, "task2");

        es.execute(new RunnableTask("task3"));
        ExecutorUtils.printState(es, "task3");

        es.execute(new RunnableTask("task4"));
        ExecutorUtils.printState(es, "task4");

        es.close();
        log(" == shutdown 완료 ==");
        ExecutorUtils.printState(es);
    }
}

실행 결과

2024-07-30 14:26:44.081 [     main] [pool= 0, active=0, queuedTasks=0, completedTask=0]
2024-07-30 14:26:44.084 [pool-1-thread-1] task1 시작
2024-07-30 14:26:44.095 [     main] task1 -> [pool= 1, active=1, queuedTasks=0, completedTask=0]
2024-07-30 14:26:44.095 [     main] task2 -> [pool= 2, active=2, queuedTasks=0, completedTask=0]
2024-07-30 14:26:44.095 [pool-1-thread-2] task2 시작
2024-07-30 14:26:44.096 [     main] task3 -> [pool= 2, active=2, queuedTasks=1, completedTask=0]
2024-07-30 14:26:44.096 [     main] task4 -> [pool= 2, active=2, queuedTasks=2, completedTask=0]
2024-07-30 14:26:45.086 [pool-1-thread-1] task1 완료
2024-07-30 14:26:45.087 [pool-1-thread-1] task3 시작
2024-07-30 14:26:45.097 [pool-1-thread-2] task2 완료
2024-07-30 14:26:45.097 [pool-1-thread-2] task4 시작
2024-07-30 14:26:46.088 [pool-1-thread-1] task3 완료
2024-07-30 14:26:46.099 [pool-1-thread-2] task4 완료
2024-07-30 14:26:46.103 [     main]  == shutdown 완료 ==
2024-07-30 14:26:46.103 [     main] [pool= 0, active=0, queuedTasks=0, completedTask=4]

 

 

결과를 보니, 이제 작업중인 스레드가 2개고 큐에 들어간 작업들도 꽉 찬 상태인 2개이다. 이렇듯 maximumPoolSize는 기본 스레드 수보다 작업이 더 많이 들어온다고 바로 스레드를 생성하는 게 아니다. 큐에 대기할 수 있는 공간이 있을때까지 더 들어온 작업들은 큐에 대기상태로 보관된다. 

 

그럼? 큐에 대기 상태로 보관될 공간까지도 부족하면? 이때 바로 최대 스레드 수까지 스레드가 생성된다.

package thread.executor.poolsize;

import thread.executor.ExecutorUtils;
import thread.executor.RunnableTask;

import java.util.concurrent.*;

import static util.MyLogger.log;
import static util.ThreadUtils.sleep;

public class PoolSizeMainV1 {

    public static void main(String[] args) {
        ArrayBlockingQueue<Runnable> workQueue = new ArrayBlockingQueue<>(2);
        ExecutorService es = new ThreadPoolExecutor(
                2,
                4,
                3000,
                TimeUnit.MILLISECONDS,
                workQueue);

        ExecutorUtils.printState(es);

        es.execute(new RunnableTask("task1"));
        ExecutorUtils.printState(es, "task1");

        es.execute(new RunnableTask("task2"));
        ExecutorUtils.printState(es, "task2");

        es.execute(new RunnableTask("task3"));
        ExecutorUtils.printState(es, "task3");

        es.execute(new RunnableTask("task4"));
        ExecutorUtils.printState(es, "task4");

        es.execute(new RunnableTask("task5"));
        ExecutorUtils.printState(es, "task5");

        es.execute(new RunnableTask("task6"));
        ExecutorUtils.printState(es, "task6");

        es.close();
        log(" == shutdown 완료 ==");
        ExecutorUtils.printState(es);
    }
}

실행 결과

2024-07-30 14:29:07.291 [     main] [pool= 0, active=0, queuedTasks=0, completedTask=0]
2024-07-30 14:29:07.295 [pool-1-thread-1] task1 시작
2024-07-30 14:29:07.305 [     main] task1 -> [pool= 1, active=1, queuedTasks=0, completedTask=0]
2024-07-30 14:29:07.305 [     main] task2 -> [pool= 2, active=2, queuedTasks=0, completedTask=0]
2024-07-30 14:29:07.305 [pool-1-thread-2] task2 시작
2024-07-30 14:29:07.305 [     main] task3 -> [pool= 2, active=2, queuedTasks=1, completedTask=0]
2024-07-30 14:29:07.306 [     main] task4 -> [pool= 2, active=2, queuedTasks=2, completedTask=0]
2024-07-30 14:29:07.306 [     main] task5 -> [pool= 3, active=3, queuedTasks=2, completedTask=0]
2024-07-30 14:29:07.306 [pool-1-thread-3] task5 시작
2024-07-30 14:29:07.307 [     main] task6 -> [pool= 4, active=4, queuedTasks=2, completedTask=0]
2024-07-30 14:29:07.307 [pool-1-thread-4] task6 시작
2024-07-30 14:29:08.297 [pool-1-thread-1] task1 완료
2024-07-30 14:29:08.298 [pool-1-thread-1] task3 시작
2024-07-30 14:29:08.307 [pool-1-thread-2] task2 완료
2024-07-30 14:29:08.307 [pool-1-thread-2] task4 시작
2024-07-30 14:29:08.308 [pool-1-thread-3] task5 완료
2024-07-30 14:29:08.308 [pool-1-thread-4] task6 완료
2024-07-30 14:29:09.299 [pool-1-thread-1] task3 완료
2024-07-30 14:29:09.308 [pool-1-thread-2] task4 완료
2024-07-30 14:29:09.310 [     main]  == shutdown 완료 ==
2024-07-30 14:29:09.311 [     main] [pool= 0, active=0, queuedTasks=0, completedTask=6]

 

결과를 보면 작업 6개를 돌리니 최대 스레드 수까지 스레드가 늘어나고 그 수보다 더 남은 작업들은 큐에 보관된 상태로 관리된다.

이게 corePoolSize, maximumPoolSize의 메커니즘이다.

 

그럼 만약, 최대 스레드 수도 꽉 찼고 큐에 보관 가능한 공간도 꽉 찬 상태에서 또 작업이 들어오면?

package thread.executor.poolsize;

import thread.executor.ExecutorUtils;
import thread.executor.RunnableTask;

import java.util.concurrent.*;

import static util.MyLogger.log;
import static util.ThreadUtils.sleep;

public class PoolSizeMainV1 {

    public static void main(String[] args) {
        ArrayBlockingQueue<Runnable> workQueue = new ArrayBlockingQueue<>(2);
        ExecutorService es = new ThreadPoolExecutor(
                2,
                4,
                3000,
                TimeUnit.MILLISECONDS,
                workQueue);

        ExecutorUtils.printState(es);

        es.execute(new RunnableTask("task1"));
        ExecutorUtils.printState(es, "task1");

        es.execute(new RunnableTask("task2"));
        ExecutorUtils.printState(es, "task2");

        es.execute(new RunnableTask("task3"));
        ExecutorUtils.printState(es, "task3");

        es.execute(new RunnableTask("task4"));
        ExecutorUtils.printState(es, "task4");

        es.execute(new RunnableTask("task5"));
        ExecutorUtils.printState(es, "task5");

        es.execute(new RunnableTask("task6"));
        ExecutorUtils.printState(es, "task6");

        es.execute(new RunnableTask("task7"));
        ExecutorUtils.printState(es, "task7");

        es.close();
        log(" == shutdown 완료 ==");
        ExecutorUtils.printState(es);
    }
}

실행 결과

2024-07-30 14:32:01.841 [     main] [pool= 0, active=0, queuedTasks=0, completedTask=0]
2024-07-30 14:32:01.846 [pool-1-thread-1] task1 시작
2024-07-30 14:32:01.858 [     main] task1 -> [pool= 1, active=1, queuedTasks=0, completedTask=0]
2024-07-30 14:32:01.858 [     main] task2 -> [pool= 2, active=2, queuedTasks=0, completedTask=0]
2024-07-30 14:32:01.858 [pool-1-thread-2] task2 시작
2024-07-30 14:32:01.858 [     main] task3 -> [pool= 2, active=2, queuedTasks=1, completedTask=0]
2024-07-30 14:32:01.859 [     main] task4 -> [pool= 2, active=2, queuedTasks=2, completedTask=0]
2024-07-30 14:32:01.859 [     main] task5 -> [pool= 3, active=3, queuedTasks=2, completedTask=0]
2024-07-30 14:32:01.859 [pool-1-thread-3] task5 시작
2024-07-30 14:32:01.860 [pool-1-thread-4] task6 시작
2024-07-30 14:32:01.860 [     main] task6 -> [pool= 4, active=4, queuedTasks=2, completedTask=0]
Exception in thread "main" java.util.concurrent.RejectedExecutionException: Task thread.executor.RunnableTask@5240ca61 rejected from java.util.concurrent.ThreadPoolExecutor@aa6715fa[Running, pool size = 4, active threads = 4, queued tasks = 2, completed tasks = 0]
	at java.base/java.util.concurrent.ThreadPoolExecutor$AbortPolicy.rejectedExecution(ThreadPoolExecutor.java:2081)
	at java.base/java.util.concurrent.ThreadPoolExecutor.reject(ThreadPoolExecutor.java:841)
	at java.base/java.util.concurrent.ThreadPoolExecutor.execute(ThreadPoolExecutor.java:1376)
	at thread.executor.poolsize.PoolSizeMainV1.main(PoolSizeMainV1.java:42)
2024-07-30 14:32:02.847 [pool-1-thread-1] task1 완료
2024-07-30 14:32:02.848 [pool-1-thread-1] task3 시작
2024-07-30 14:32:02.860 [pool-1-thread-2] task2 완료
2024-07-30 14:32:02.860 [pool-1-thread-2] task4 시작
2024-07-30 14:32:02.861 [pool-1-thread-4] task6 완료
2024-07-30 14:32:02.861 [pool-1-thread-3] task5 완료
2024-07-30 14:32:03.850 [pool-1-thread-1] task3 완료
2024-07-30 14:32:03.862 [pool-1-thread-2] task4 완료

이렇듯 RejectedExecutionExcpetion 예외가 터진다. 그래서 적절하게 corePoolSize, maximumPoolSize, 블로킹 큐 사이즈를 설정해야 한다. 

 

그래서 우선 이 예외를 try - catch로 잡아보자.

package thread.executor.poolsize;

import thread.executor.ExecutorUtils;
import thread.executor.RunnableTask;

import java.util.concurrent.*;

import static util.MyLogger.log;
import static util.ThreadUtils.sleep;

public class PoolSizeMainV1 {

    public static void main(String[] args) {
        ArrayBlockingQueue<Runnable> workQueue = new ArrayBlockingQueue<>(2);
        ExecutorService es = new ThreadPoolExecutor(
                2,
                4,
                3000,
                TimeUnit.MILLISECONDS,
                workQueue);

        ExecutorUtils.printState(es);

        es.execute(new RunnableTask("task1"));
        ExecutorUtils.printState(es, "task1");

        es.execute(new RunnableTask("task2"));
        ExecutorUtils.printState(es, "task2");

        es.execute(new RunnableTask("task3"));
        ExecutorUtils.printState(es, "task3");

        es.execute(new RunnableTask("task4"));
        ExecutorUtils.printState(es, "task4");

        es.execute(new RunnableTask("task5"));
        ExecutorUtils.printState(es, "task5");

        es.execute(new RunnableTask("task6"));
        ExecutorUtils.printState(es, "task6");

        try {
            es.execute(new RunnableTask("task7"));
        } catch (RejectedExecutionException e) {
            log("task7 실행 거절 예외 발생: " + e);
        }

        sleep(3000);
        log("== 작업 수행 완료 ==");
        ExecutorUtils.printState(es);

        sleep(3000);
        log("== maximumPoolSize 대기 시간 초과 ==");
        ExecutorUtils.printState(es);

        es.close();
        log(" == shutdown 완료 ==");
        ExecutorUtils.printState(es);
    }
}

 

그런 다음 3초 정도 대기한 후 현재 상태를 찍어보자. 이 시기에 딱 작업이 모든 작업이 끝날 것이다. 그리고 나서 또 3초를 대기해보자. 그러면 keepAliveTime3초로 설정했기 때문에 기본 스레드 수보다 더 많이 생성된 스레드들이 정해진 시간 이상으로 놀고 있기 때문에 스레드를 지울것이다. 그리고 바로 아래가 그 결과다.

 

실행 결과

2024-07-30 14:39:33.945 [     main] [pool= 0, active=0, queuedTasks=0, completedTask=0]
2024-07-30 14:39:33.950 [pool-1-thread-1] task1 시작
2024-07-30 14:39:33.963 [     main] task1 -> [pool= 1, active=1, queuedTasks=0, completedTask=0]
2024-07-30 14:39:33.964 [     main] task2 -> [pool= 2, active=2, queuedTasks=0, completedTask=0]
2024-07-30 14:39:33.964 [pool-1-thread-2] task2 시작
2024-07-30 14:39:33.964 [     main] task3 -> [pool= 2, active=2, queuedTasks=1, completedTask=0]
2024-07-30 14:39:33.964 [     main] task4 -> [pool= 2, active=2, queuedTasks=2, completedTask=0]
2024-07-30 14:39:33.965 [     main] task5 -> [pool= 3, active=3, queuedTasks=2, completedTask=0]
2024-07-30 14:39:33.965 [pool-1-thread-3] task5 시작
2024-07-30 14:39:33.965 [     main] task6 -> [pool= 4, active=4, queuedTasks=2, completedTask=0]
2024-07-30 14:39:33.965 [pool-1-thread-4] task6 시작
2024-07-30 14:39:33.966 [     main] task7 실행 거절 예외 발생: java.util.concurrent.RejectedExecutionException: Task thread.executor.RunnableTask@e51fe68d rejected from java.util.concurrent.ThreadPoolExecutor@4df7cb20[Running, pool size = 4, active threads = 4, queued tasks = 2, completed tasks = 0]
2024-07-30 14:39:34.953 [pool-1-thread-1] task1 완료
2024-07-30 14:39:34.954 [pool-1-thread-1] task3 시작
2024-07-30 14:39:34.965 [pool-1-thread-2] task2 완료
2024-07-30 14:39:34.966 [pool-1-thread-2] task4 시작
2024-07-30 14:39:34.966 [pool-1-thread-3] task5 완료
2024-07-30 14:39:34.967 [pool-1-thread-4] task6 완료
2024-07-30 14:39:35.955 [pool-1-thread-1] task3 완료
2024-07-30 14:39:35.967 [pool-1-thread-2] task4 완료
2024-07-30 14:39:36.971 [     main] == 작업 수행 완료 ==
2024-07-30 14:39:36.972 [     main] [pool= 4, active=0, queuedTasks=0, completedTask=6]
2024-07-30 14:39:39.975 [     main] == maximumPoolSize 대기 시간 초과 ==
2024-07-30 14:39:39.976 [     main] [pool= 2, active=0, queuedTasks=0, completedTask=6]
2024-07-30 14:39:39.978 [     main]  == shutdown 완료 ==
2024-07-30 14:39:39.978 [     main] [pool= 0, active=0, queuedTasks=0, completedTask=6]

 

그럼 이 내용을 그림과 같이 분석해보자.

Executor 스레드 풀 관리 - 분석

  • task1 작업을 요청한다.
  • Executor는 스레드 풀에 스레드가 core 사이즈 만큼 있는지 확인한다.
    • core 사이즈 만큼 없다면 스레드를 하나 생성한다.
    • 작업을 처리하기 위해 스레드를 하나 생성했기 때문에 작업을 큐에 넣을 필요 없이, 해당 스레드가 바로 작업을 처리한다.

2024-07-30 15:36:01.299 [pool-1-thread-1] task1 시작
2024-07-30 15:36:01.307 [     main] task1 -> [pool= 1, active=1, queuedTasks=0, completedTask=0]
  • 새로 만들어진 스레드1이 task1을 수행한다.

2024-07-30 15:36:01.308 [     main] task2 -> [pool= 2, active=2, queuedTasks=0, completedTask=0]
2024-07-30 15:36:01.308 [pool-1-thread-2] task2 시작
  • task2를 요청한다.
  • Executor는 스레드 풀에 스레드가 core 사이즈 만큼 있는지 확인한다.
    • 아직 core 사이즈만큼 없으므로 스레드를 하나 생성한다.
  • 새로 만들어진 스레드2가 task2를 처리한다. 마찬가지로 작업을 처리하기 위해 스레드를 만들었으니 작업을 큐에 넣을 필요 없이, 바로 작업을 수행한다.

2024-07-30 15:36:01.308 [     main] task3 -> [pool= 2, active=2, queuedTasks=1, completedTask=0]
  • task3 작업을 요청한다.
  • Executor는 스레드 풀에 스레드가 core 사이즈 만큼 있는지 확인한다.
  • core 사이즈 만큼 스레드가 이미 만들어져 있고, 스레드 풀에 사용할 수 있는 스레드가 없으므로 이 경우 큐에 작업을 보관한다. 

2024-07-30 15:36:01.308 [     main] task4 -> [pool= 2, active=2, queuedTasks=2, completedTask=0]
  • task4 작업을 요청한다.
  • Executor는 스레드 풀에 스레드가 core 사이즈 만큼 있는지 확인한다.
  • core 사이즈 만큼 스레드가 이미 만들어져 있고, 스레드 풀에 사용할 수 있는 스레드가 없으므로 이 경우 큐에 작업을 보관한다.

2024-07-30 15:36:01.309 [     main] task5 -> [pool= 3, active=3, queuedTasks=2, completedTask=0]
2024-07-30 15:36:01.309 [pool-1-thread-3] task5 시작
  • task5 작업을 요청한다.
  • Executor는 스레드 풀에 스레드가 core 사이즈 만큼 있는지 확인한다 → core 사이즈 만큼 있다.
  • Executor는 큐에 보관을 시도한다 → 큐가 가득 찼다. 
    • 큐까지 가득찬 상황이라면 긴급 상황이다. 대기하는 작업이 꽉 찰 정도로 요청이 많다는 뜻이다. 이 경우, Executormax(maximumPoolSize) 사이즈까지 초과 스레드를 만들어서 작업을 수행한다.
    • core=2 → 기본 스레드는 최대 2개
    • max=4 → 기본 스레드 2개에 초과 스레드 2개 합계 총 4개 가능 (초과 스레드 = max - core)
  • Executor는 초과 스레드인 스레드3을 만든다.
  • 작업을 처리하기 위해 스레드를 하나 생성했기 때문에 작업을 큐에 넣을 필요 없이, 해당 스레드가 바로 작업을 처리한다.
    • 참고로 이 경우는 큐가 가득찬 상태이기 때문에 큐에 넣는것도 불가능하다.
  • 스레드3이 task5를 처리한다.

2024-07-30 15:36:01.309 [     main] task6 -> [pool= 4, active=4, queuedTasks=2, completedTask=0]
2024-07-30 15:36:01.309 [pool-1-thread-4] task6 시작
  • task6 작업을 요청한다.
  • 마찬가지로 큐가 가득찼고 core 스레드도 전부 만든 상태이다.
  • Executor는 초과 스레드인 스레드4를 만들어서 task6을 처리한다. 
    • 이 경우 큐가 가득찬 상태이기 때문에 큐에 넣는것도 불가능하다.

2024-07-30 15:36:01.310 [     main] task7 실행 거절 예외 발생: java.util.concurrent.RejectedExecutionException: Task thread.executor.RunnableTask@c415607b rejected from java.util.concurrent.ThreadPoolExecutor@da251ccc[Running, pool size = 4, active threads = 4, queued tasks = 2, completed tasks = 0]
  • task7 작업을 요청한다.
  • 큐가 가득찼다.
  • 스레드 풀의 스레드도 max 사이즈만큼 가득찼다.
  • RejectedExecutionException이 발생한다.

이 경우 큐에 넣을 수도 없고, 작업을 수행할 스레드도 만들 수 없다. 따라서 작업을 거절한다.

  • 작업들이 수행된다.

  • 스레드1이 task1을 스레드3이 task5의 작업을 완료하고 스레드 풀에 대기 상태로 돌아간다.

  • 스레드 풀의 스레드는 큐의 데이터를 획득하기 위해 대기한다.
  • 스레드1: task3을 획득한다.
  • 스레드3: task4를 획득한다.

  • 계속 작업을 수행한다.

2024-07-30 15:36:04.316 [     main] == 작업 수행 완료 ==
2024-07-30 15:36:04.317 [     main] [pool= 4, active=0, queuedTasks=0, completedTask=6]
  • 모든 작업이 완료된다.

2024-07-30 15:36:07.323 [     main] == maximumPoolSize 대기 시간 초과 ==
2024-07-30 15:36:07.325 [     main] [pool= 2, active=0, queuedTasks=0, completedTask=6]
  • 스레드3, 스레드4와 같은 초과 스레드들은 지정된 시간까지 작업을 하지 않고 대기하면 제거된다. 긴급한 작업들이 끝난 것으로 이해하면 된다.
  • 여기서는 지정한 3초간 스레드3, 스레드4가 작업을 진행하지 않았기 때문에 스레드 풀에서 제거된다.
  • 참고로 초과 스레드가 작업을 처리할 때마다 시간은 계속 초기화가 된다.
    • 작업 요청이 계속 들어온다면 긴급한 상황이 끝난 것이 아니다. 따라서 긴급한 상황이 끝날 때 까지는 초과 스레드를 살려두는 것이 많은 스레드를 사용해서 작업을 더 빨리 처리할 수 있다.

  • 초과 스레드가 제거된 모습이다.
  • 이후에 shutdown()이 진행되면 풀의 스레드도 모두 제거된다.

 

정리를 하자면,

  1. 작업을 요청하면 core 사이즈 만큼 스레드를 만든다.
  2. core 사이즈를 초과하면 큐에 작업을 넣는다.
  3. 큐를 초과하면 max 사이즈 만큼 스레드를 만든다. 임시로 사용되는 초과 스레드가 생성된다.
    • 큐가 가득차서 큐에 넣을 수도 없다. 초과 스레드가 바로 수행해야 한다.
  4. max 사이즈를 초과하면 요청을 거절한다. 예외가 발생한다.
    • Executor에 큐도 가득차고, 풀 최대 생성 가능한 스레드 수도 가득 찼다. 작업을 받을 수 없다.

 

스레드 미리 생성하기 (팁)

응답 시간이 아주 중요한 서버라면, 서버가 고객의 처음 요청을 받기 전에 스레드를 스레드 풀에 미리 생성해두고 싶을 수 있다. 스레드를 미리 생성해두면, 처음 요청에서 사용되는 스레드의 생성 시간을 줄일 수 있다. ThreadPoolExecutor.prestartAllCoreThreads()를 사용하면 기본 스레드를 미리 생성할 수 있다. 참고로 ExecutorService는 이 메서드를 제공하지 않는다. 

 

PreStartPoolMain

package thread.executor;

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ThreadPoolExecutor;

public class PreStartPoolMain {

    public static void main(String[] args) {
        ExecutorService es = Executors.newFixedThreadPool(1000);
        ExecutorUtils.printState(es);

        ThreadPoolExecutor threadPoolExecutor = (ThreadPoolExecutor) es;
        threadPoolExecutor.prestartAllCoreThreads();

        ExecutorUtils.printState(es);
    }
}

실행 결과

2024-07-30 16:00:36.336 [     main] [pool= 0, active=0, queuedTasks=0, completedTask=0]
2024-07-30 16:00:36.381 [     main] [pool= 1000, active=0, queuedTasks=0, completedTask=0]

 

 

이제 이 스레드를 생성하는 전략에 대해 알아보고, 어떤 상황에 어떤 전략을 사용해서 최대한 효율적으로 스레드를 사용할 수 있는지 알아보자. 

Executor 전략 - 고정 풀 전략

Executor 스레드 풀 관리 - 다양한 전략

ThreadPoolExecutor를 사용하면 스레드 풀에 사용되는 숫자와 블로킹 큐 등 다양한 속성을 조절할 수 있다.

  • corePoolSize: 스레드 풀에서 관리되는 기본 스레드 수
  • maximumPoolSize: 스레드 풀에서 관리되는 최대 스레드 수
  • keepAliveTime, TimeUnit unit: 기본 스레드 수를 초과해서 만들어진 스레드가 생존할 수 있는 대기 시간, 이 시간동안 처리할 작업이 없다면 초과 스레드는 제거된다.
  • BlockingQueue workQueue: 작업을 보관할 블로킹 큐

이런 속성들을 잘 사용한다면 자신에게 맞는 스레드 풀 전략을 사용할 수 있다.

자바는 Executors 클래스를 통해 3가지 기본 전략을 제공한다.

  • newSingleThreadPool(): 단일 스레드 풀 전략
  • newFixedThreadPool(nThreads): 고정 스레드 풀 전략
  • newCachedThreadPool(): 캐시 스레드 풀 전략

newSingleThreadPool(): 단일 스레드 풀 전략

  • 스레드 풀에 기본 스레드 1개만 사용한다.
  • 큐 사이즈에 제한이 없다 (LinkedBlockingQueue)
  • 주로 간단히 사용하거나, 테스트 용도로 사용한다.
new ThreadPoolExecutor(1, 1, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<Runnable>());

 

newFixedThreadPool(nThreads): 고정 풀 전략

  • 스레드 풀에 nThreads 만큼의 기본 스레드를 생성한다. 초과 스레드는 생성하지 않는다.
  • 큐 사이즈에 제한이 없다 (LinkedBlockingQueue)
  • 스레드 수가 고정되어 있기 때문에 CPU, 메모리 리소스가 어느정도 예측 가능한 안정적인 방식이다.
new ThreadPoolExecutor(nThreads, nThreads, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<Runnable>());

 

PoolSizeMainV2

package thread.executor.poolsize;

import thread.executor.RunnableTask;

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

import static thread.executor.ExecutorUtils.printState;
import static util.MyLogger.log;

public class PoolSizeMainV2 {

    public static void main(String[] args) {
        ExecutorService es = Executors.newFixedThreadPool(2);

        log("pool 생성");

        printState(es);

        for (int i = 1; i <= 6; i++) {
            String taskName = "task" + i;
            es.execute(new RunnableTask(taskName));
            printState(es, taskName);
        }
        es.close();
        log("== shutdown 완료 ==");
    }
}

실행 결과

2024-07-30 16:32:32.558 [     main] pool 생성
2024-07-30 16:32:32.575 [     main] [pool= 0, active=0, queuedTasks=0, completedTask=0]
2024-07-30 16:32:32.579 [pool-1-thread-1] task1 시작
2024-07-30 16:32:32.588 [     main] task1 -> [pool= 1, active=1, queuedTasks=0, completedTask=0]
2024-07-30 16:32:32.588 [     main] task2 -> [pool= 2, active=2, queuedTasks=0, completedTask=0]
2024-07-30 16:32:32.588 [pool-1-thread-2] task2 시작
2024-07-30 16:32:32.589 [     main] task3 -> [pool= 2, active=2, queuedTasks=1, completedTask=0]
2024-07-30 16:32:32.589 [     main] task4 -> [pool= 2, active=2, queuedTasks=2, completedTask=0]
2024-07-30 16:32:32.590 [     main] task5 -> [pool= 2, active=2, queuedTasks=3, completedTask=0]
2024-07-30 16:32:32.590 [     main] task6 -> [pool= 2, active=2, queuedTasks=4, completedTask=0]
2024-07-30 16:32:33.581 [pool-1-thread-1] task1 완료
2024-07-30 16:32:33.582 [pool-1-thread-1] task3 시작
2024-07-30 16:32:33.590 [pool-1-thread-2] task2 완료
2024-07-30 16:32:33.591 [pool-1-thread-2] task4 시작
2024-07-30 16:32:34.584 [pool-1-thread-1] task3 완료
2024-07-30 16:32:34.584 [pool-1-thread-1] task5 시작
2024-07-30 16:32:34.592 [pool-1-thread-2] task4 완료
2024-07-30 16:32:34.592 [pool-1-thread-2] task6 시작
2024-07-30 16:32:35.586 [pool-1-thread-1] task5 완료
2024-07-30 16:32:35.594 [pool-1-thread-2] task6 완료
2024-07-30 16:32:35.597 [     main] == shutdown 완료 ==

 

2개의 스레드가 안정적으로 작업을 처리하는 것을 확인할 수 있다. 이 전략은 다음과 같은 특징이 있다.

 

특징

스레드 수가 고정되어 있기 때문에 CPU, 메모리 리소스가 어느정도 예측 가능한 안정적인 방식이다. 큐 사이즈도 제한이 없어서 작업을 많이 담아두어도 문제가 없다.

 

주의

이 방식의 가장 큰 장점은 스레드 수가 고정되어서 CPU, 메모리 리소스가 어느정도 예측 가능하다는 점이다. 따라서 일반적인 상황에 가장 안정적으로 서비스를 운영할 수 있다. 하지만 상황에 따라 장점이 가장 큰 단점이 되기도 한다.

 

상황1 - 점진적인 사용자 확대

  • 개발한 서비스가 잘 되어서 사용자가 점점 늘어난다.
  • 고정 스레드 전략을 사용해서 서비스를 안정적으로 잘 운영했는데, 언젠가부터 사용자들이 서비스 응답이 점점 느려진다고 항의한다.

상황2 - 갑작스런 요청 증가

  • 마케팅 팀의 이벤트가 대성공 하면서 갑자기 사용자가 폭증했다.
  • 고객은 응답을 받지 못한다고 항의한다.

확인

  • 개발자는 급하게 CPU, 메모리 사용량을 확인해보는데 아무런 문제 없이 여유있고 안정적으로 서비스가 운영되고 있다.
  • 고정 스레드 전략은 실행되는 스레드 수가 고정되어 있다. 따라서 사용자가 늘어나도 CPU, 메모리 사용량이 확 늘어나지 않는다.
  • 큐의 사이즈를 확인해보니 요청이 수 만 건 쌓여있다. 요청이 처리되는 시간보다 쌓이는 시간이 더 빠른 것이다. 참고로 고정 풀 전략의 큐 사이즈는 무한이다.
  • 예를 들어서 큐에 10000건이 쌓여있는데, 고정 스레드 수가 10이고, 각 스레드가 작업을 하나 처리하는데 1초가 걸린다면 모든 작업을 다 처리하는데는 1000초가 걸린다. 만약 처리 속도보다 작업이 쌓이는 속도가 더 빠른 경우에는 더 문제가 된다.
  • 서비스 초기에는 사용자가 적기 때문에 이런 문제가 없지만, 사용자가 늘어나면 문제가 될 수 있다.
  • 갑작스런 요청 증가도 물론 마찬가지이다.

결국 서버 자원은 여유가 있는데, 사용자만 점점 느려지는 문제가 발생한 것이다.

 

newCachedThreadPool(): 캐시 풀 전략

  • 기본 스레드를 사용하지 않고, 60초 생존 주기를 가진 초과 스레드만 사용한다.
  • 초과 스레드의 수는 제한이 없다.
  • 큐에 작업을 저장하지 않는다 (SynchronousQueue)
    • 대신에 생산자의 요청을 스레드 풀의 소비자 스레드가 직접 받아서 바로 처리한다.
  • 모든 요청이 대기하지 않고 스레드가 바로바로 처리한다. 따라서 빠른 처리가 가능하다.
new ThreadPoolExecutor(0, Integer.MAX_VALUE, 60L, TimeUnit.SECONDS, new SychronousQueue<Runnable>());

 

SynchronousQueue는 아주 특별한 블로킹 큐이다.

  • BlockingQueue 인터페이스의 구현체 중 하나이다.
  • 이 큐는 내부에 저장 공간이 없다. 대신에 생산자의 작업을 소비자 스레드에게 직접 전달한다.
  • 쉽게 이야기해서 저장 공간의 크기가 0이고, 생산자 스레드가 큐에 작업을 전달하면 소비자 스레드가 큐에서 작업을 꺼낼 때까지 대기한다.
  • 소비자가 작업을 요청하면 기다리던 생산자가 소비자에게 직접 작업을 전달하고 반환된다. 그 반대의 경우도 같다.
  • 이름 그대로 생산자와 소비자를 동기화하는 큐이다.
  • 쉽게 이야기해서 중간에 버퍼를 두지 않는 스레드 간 직거래라고 보면 된다.

PoolSizeMainV3

package thread.executor.poolsize;

import thread.executor.RunnableTask;

import java.util.concurrent.*;

import static thread.executor.ExecutorUtils.printState;
import static util.MyLogger.log;
import static util.ThreadUtils.sleep;

public class PoolSizeMainV3 {

    public static void main(String[] args) {

        ExecutorService es = Executors.newCachedThreadPool();

        log("pool 생성");
        printState(es);

        for (int i = 1; i <= 4; i++) {
            String taskName = "task " + i;
            es.execute(new RunnableTask(taskName));
            printState(es, taskName);
        }

        sleep(4000);
        log("== 작업 수행 완료 ==");
        printState(es);

        es.close();
        log("== shutdown 완료 ==");
        printState(es);
    }
}

실행 결과

2024-07-30 17:44:29.832 [     main] pool 생성
2024-07-30 17:44:29.849 [     main] [pool= 0, active=0, queuedTasks=0, completedTask=0]
2024-07-30 17:44:29.852 [pool-1-thread-1] task 1 시작
2024-07-30 17:44:29.861 [     main] task 1 -> [pool= 1, active=1, queuedTasks=0, completedTask=0]
2024-07-30 17:44:29.862 [     main] task 2 -> [pool= 2, active=2, queuedTasks=0, completedTask=0]
2024-07-30 17:44:29.862 [pool-1-thread-2] task 2 시작
2024-07-30 17:44:29.862 [     main] task 3 -> [pool= 3, active=3, queuedTasks=0, completedTask=0]
2024-07-30 17:44:29.862 [pool-1-thread-3] task 3 시작
2024-07-30 17:44:29.863 [     main] task 4 -> [pool= 4, active=4, queuedTasks=0, completedTask=0]
2024-07-30 17:44:29.863 [pool-1-thread-4] task 4 시작
2024-07-30 17:44:29.863 [     main] == 작업 수행 완료 ==
2024-07-30 17:44:30.853 [pool-1-thread-1] task 1 완료
2024-07-30 17:44:30.863 [pool-1-thread-2] task 2 완료
2024-07-30 17:44:30.863 [pool-1-thread-4] task 4 완료
2024-07-30 17:44:30.864 [pool-1-thread-3] task 3 완료
2024-07-30 17:44:33.869 [     main] == maximumPoolSize 대기 시간 초과 ==
2024-07-30 17:44:33.870 [     main] [pool= 4, active=0, queuedTasks=0, completedTask=4]
2024-07-30 17:44:33.874 [     main] == shutdown 완료 ==
2024-07-30 17:44:33.875 [     main] [pool= 0, active=0, queuedTasks=0, completedTask=4]
  • 모든 작업이 대기하지 않고 작업의 수 만큼 스레드가 생기면서 바로 실행되는 것을 확인할 수 있다.
  • "maximumPoolSize 대기 시간 초과" 로그를 통해 초과 스레드가 대기 시간이 지나서 모두 사라진 것을 확인할 수 있다.

이 전략은 다음과 같은 특징이 있다.

 

특징

캐시 스레드 풀 전략은 매우 빠르고, 유연한 전략이다.

이 전략은 기본 스레드도 없고, 대기 큐에 작업도 쌓이지 않는다. 대신에 작업 요청이 오면 초과 스레드로 작업을 바로바로 처리한다. 따라서 빠른 처리가 가능하다. 초과 스레드의 수도 제한이 없기 때문에 CPU, 메모리 자원만 허용한다면 시스템의 자원을 최대로 사용할 수 있다.

추가로 초과 스레드는 60초간 생존한다. 그래서 작업 수에 맞추어 적절한 수의 스레드가 재사용된다. 이런 특징 때문에 요청이 갑자기 증가하면 스레드도 갑자기 증가하고, 요청이 줄어들면 스레드도 점점 줄어든다. 이 전략은 작업의 요청 수에 따라서 스레드도 증가하고 감소하므로, 매우 유연한 전략이다.

 

그런데 어떻게 기본 스레드 없이 초과 스레드만 만들 수 있을까? Executor 스레드 풀 기본 관리 정책을 다시 확인해보자.

Executor 스레드 풀 관리

  1. 작업을 요청하면 core 사이즈 만큼 스레드를 만든다.
    • core 사이즈가 없다. 바로 core 사이즈를 초과한다.
  2. core 사이즈를 초과하면 큐에 작업을 넣는다.
    • 큐에 작업을 넣을 수 없다. (SynchronousQueue는 큐의 저장 공간이 0인 특별한 큐이다)
  3. 큐를 초과하면 max 사이즈 만큼 스레드를 만든다. 임시로 사용되는 초과 스레드가 생성된다.
    • 초과 스레드가 생성된다. 물론 풀에 대기하는 초과 스레드가 있으면 재사용된다.
  4. max 사이즈를 초과하면 요청을 거절한다. 예외가 발생한다.
    • 참고로 max 사이즈가 무제한이다. 따라서 초과 스레드를 무제한으로 만들 수 있다.

결과적으로 이 전략의 모든 작업은 초과 스레드가 처리한다.

 

주의

이 방식은 작업 수에 맞추어 스레드 수가 변하기 때문에, 작업의 처리 속도가 빠르고, CPU, 메모리를 매우 유연하게 사용할 수 있다는 장점이 있다. 하지만 상황에 따라 장점이 가장 큰 단점이 되기도 한다.

 

상황1 - 점진적인 사용자 확대

  • 개발한 서비스가 잘 되어서 사용자가 점점 늘어난다.
  • 캐시 스레드 전략을 사용하면 이런 경우 크게 문제가 되지 않는다.
  • 캐시 스레드 전략은 이런 경우에는 문제를 빠르게 찾을 수 있다. 사용자가 점점 증가하면서 스레드 사용량도 함께 늘어난다. 따라서 CPU 메모리의 사용량도 자연스럽게 증가한다.
  • 물론 CPU, 메모리 자원은 한계가 있기 때문에 적절한 시점에 시스템을 증설해야 한다. 그렇지 않으면 CPU, 메모리 같은 시스템 자원을 너무 많이 사용하면서 시스템이 다운될 수 있다.

상황2 - 갑작스런 요청 증가

  • 마케팅 팀의 이벤트가 대성공 하면서 갑자기 사용자가 폭증했다.
  • 고객은 응답을 받지 못한다고 항의한다.

상황2 - 확인

  • 개발자는 급하게 CPU, 메모리 사용량을 확인해보는데, CPU 사용량이 100%이고, 메모리 사용량도 지나치게 높아져 있다. 
  • 스레드 수를 확인해보니 스레드가 수 천개 실행되고 있다. 너무 많은 스레드가 작업을 처리하면서 시스템 전체가 느려지는 현상이 발생한다.
  • 캐시 스레드 풀 전략은 스레드가 무한으로 생성될 수 있다.
  • 수 천개의 스레드가 처리하는 속도보다 더 많은 작업이 들어온다.
  • 시스템은 너무 많은 스레드에 잠식 당해서 거의 다운된다. 메모리도 거의 다 사용되어 버린다.
  • 시스템이 멈추는 장애가 발생한다.

고정 스레드 풀 전략은 서버 자원은 여유가 있는데, 사용자만 점점 느려지는 문제가 발생할 수 있다. 반면에 캐시 스레드 풀 전략은 서버의 자원을 최대한 사용하지만, 서버가 감당할 수 있는 임계점을 넘는 순간 시스템이 다운될 수 있다.

 

Executor 전략 - 사용자 정의 풀 전략

상황1 - 점진적인 사용자 확대

  • 개발한 서비스가 잘 되어서 사용자가 점점 늘어난다.

상황2 - 갑작스런 요청 증가

  • 마케팅 팀의 이벤트가 대성공 하면서 갑자기 사용자가 폭증했다.

다음과 같이 세분화된 전략을 사용하면 상황1, 상황2를 모두 어느정도 대응할 수 있다.

  • 일반: 일반적인 상황에는 CPU, 메모리 자원을 예측할 수 있도록 고정 크기의 스레드로 서비스를 안정적으로 운영한다.
  • 긴급: 사용자의 요청이 갑자기 증가하면 긴급하게 스레드를 추가로 투입해서 작업을 빠르게 처리한다.
  • 거절: 사용자의 요청이 폭증해서 긴급 대응도 어렵다면 사용자의 요청을 거절한다.

이 방법은 평소에는 안정적으로 운영하다가, 사용자의 요청이 갑자기 증가하면 긴급하게 스레드를 더 투입해서 급한 불을 끄는 방법이다. 물론 긴급 상황에는 CPU, 메모리 자원을 더 사용하기 때문에 적정 수준을 찾아야 한다. 일반적으로는 여기까지 대응이 되겠지만, 시스템이 감당할 수 없을 정도로 사용자의 요청이 폭증하면, 처리 가능한 수준의 사용자 요청만 처리하고 나머지 요청은 거절해야 한다. 어떤 경우도 시스템이 다운되는 최악의 상황은 피해야 한다. 

 

사용자 정의 풀 전략은 다음과 같이 적용할 수 있다.

ExecutorService es = new ThreadPoolExecutor(100, 200, 60L, TimeUnit.SECONDS, new ArrayBlockingQueue<>(1000));
  • 100개의 기본 스레드를 사용한다.
  • 추가로 긴급 대응 가능한 긴급 스레드 100개를 사용한다. 긴급 스레드는 60초의 생존 주기를 가진다.
  • 1000개의 작업이 큐에 대기할 수 있다.

PoolSizeMainV4

package thread.executor.poolsize;

import thread.executor.RunnableTask;

import java.util.concurrent.*;

import static thread.executor.ExecutorUtils.printState;
import static util.MyLogger.log;

public class PoolSizeMainV4 {

     public static final int TASK_SIZE = 1100;
//     public static final int TASK_SIZE = 1200; // 긴급
//     public static final int TASK_SIZE = 1201; // 거절

    public static void main(String[] args) {
        ExecutorService es = new ThreadPoolExecutor(
                100,
                200,
                60L,
                TimeUnit.SECONDS,
                new ArrayBlockingQueue<>(1000));

        printState(es);

        long startMs = System.currentTimeMillis();

        for (int i = 1; i <= TASK_SIZE; i++) {
            String taskName = "task" + i;
            try {
                es.execute(new RunnableTask(taskName));
                printState(es, taskName);
            } catch (RejectedExecutionException e) {
                log(taskName + " -> " + e);
            }
        }

        es.close();
        long endMs = System.currentTimeMillis();

        log("time : " + (endMs - startMs) + "ms");
    }
}

이 전략은 다음과 같이 작동한다. 하나의 작업을 처리하는데 약 1초가 걸린다고 가정해보자.

  • 일반: 1000개 이하의 작업이 큐에 담겨있다. → 100개의 기본 스레드가 처리한다.
  • 긴급: 큐에 담긴 작업이 이미 1000개인데 그 이상으로 계속 작업이 들어온다 → 1000개의 기본 스레드 + 100개의 초과 스레드가 처리한다.
  • 거절: 초과 스레드를 투입했지만, 큐에 담긴 작업이 1000개이고 또 초과 스레드도 넘어간 상황이다. → 이 경우 예외를 발생시킨다.

코드상에 작성해 둔 TASK_SIZE를 각각 한번씩 주석을 변경해서 실행해보자. 아래와 같은 실행 결과를 얻는다.

 

일반 - TASK_SIZE = 1100

2024-07-31 10:35:56.468 [     main] task1099 -> [pool= 100, active=100, queuedTasks=999, completedTask=0]
2024-07-31 10:35:56.468 [     main] task1100 -> [pool= 100, active=100, queuedTasks=1000, completedTask=0]
...
2024-07-31 10:36:07.426 [     main] time : 11072ms
  • 1000개 이하의 작업이 큐에 담겨있다. → 100개의 기본 스레드가 처리한다.
  • 최대 1000개의 작업이 큐에 대기하고 100개의 작업이 실행중일 수 있다. 따라서 1100개 까지는 기본 스레드로 처리할 수 있다.
  • 작업을 모두 처리하는데 11초가 걸린다. 1100 / 100 → 11초

긴급 - TASK_SIZE = 1200

2024-07-31 10:38:18.589 [     main] task1200 -> [pool= 200, active=200, queuedTasks=1000, completedTask=0]
...
2024-07-31 10:38:24.605 [     main] time : 6147ms
  • 큐에 담긴 작업이 1000개를 초과한다. → 100개의 기본 스레드 + 100개의 초과 스레드가 처리한다.
  • 최대 1000개의 작업이 대기하고 200개의 작업이 실행중일 수 있다.
  • 작업을 모두 처리하는데 6초가 걸린다. 1200 / 200 → 6초
  • 긴급 투입한 스레드 덕분에 풀의 스레드 수가 2배가 된다. 따라서 작업을 2배 빠르게 처리한다.
  • 물론 CPU, 메모리 사용을 더 하기 때문에 이런 부분은 감안해서 긴급 상황에 투입할 최대 스레드를 정해야 한다.

거절 - TASK_SIZE = 1201

2024-07-31 10:41:21.000 [     main] task1200 -> [pool= 200, active=200, queuedTasks=1000, completedTask=0]
2024-07-31 10:41:21.004 [     main] task1201 -> java.util.concurrent.RejectedExecutionException: Task thread.executor.RunnableTask@d82f3ab8 rejected from java.util.concurrent.ThreadPoolExecutor@21fb7e74[Running, pool size = 200, active threads = 200, queued tasks = 1000, completed tasks = 0]
...
2024-07-31 10:41:27.008 [     main] time : 6139ms
  • 중간에 task1201 예외 로그를 잘 확인해보자.
  • 긴급 투입한 스레드로도 작업이 빠르게 소모되지 않는다는 것은, 시스템이 감당하기 어려운 많은 요청이 들어오고 있다는 의미이다.
  • 여기서는 큐에 대기하는 작업 1000개 + 스레드가 처리 중인 작업 200개 → 총 1200개의 작업을 초과하면 예외가 발생한다.
  • 따라서 1201번에서 예외가 발생한다.
  • 이런 경우 요청을 거절한다. 고객 서비스라면 시스템에 사용자가 너무 많으니 나중에 다시 시도해달라고 해야 한다.
  • 나머지 1200개의 작업들은 긴급 상황과 같이 정상 처리된다.

 

이런 실수를 하면 안된다!

new ThreadPoolExecutor(100, 200, 60L, TimeUnit.SECONDS, new LinkedBlockingQueue());
  • 기본 스레드 100개
  • 최대 스레드 200개
  • 큐 사이즈: 무한대

이렇게 설정하면 절대로 최대 스레드가 동작하지 않는다. 왜냐하면 큐가 가득차야 긴급 상황으로 인지 되는데, LinkedBlockingQueue를 기본 생성자를 통해 무한대의 사이즈로 사용하게 되면, 큐가 가득찰 수가 없다. 결국 기본 스레드 100개만으로 무한대의 작업을 처리해야 하는 문제가 발생한다. 실무에서 자주 하는 실수 중에 하나이다.

 

Executor 예외 정책

생산자 소비자 문제를 실무에서 사용할 때는, 결국 소비자가 처리할 수 없을 정도로 생산 요청이 가득 차면 어떻게 할지를 정해야 한다. 개발자가 인지할 수 있게 로그도 남겨야 하고, 사용자에게 현재 시스템에 문제가 있다고 알리는 것도 필요하다. 이런 것을 위해 예외 정책이 필요하다. 

 

ThreadPoolExecutor에 작업을 요청할 때, 큐도 가득차고, 초과 스레드도 더는 할당할 수 없다면 작업을 거절한다.

ThreadPoolExecutor는 작업을 거절하는 다양한 정책을 제공한다.

  • AbortPolicy: 새로운 작업을 제출할 때 RejectedExecutionException을 발생시킨다. 기본 정책이다.
  • DiscardPolicy: 새로운 작업을 조용히 버린다.
  • CallerRunsPolicy: 새로운 작업을 제출한 스레드가 대신해서 직접 작업을 실행한다.
  • 사용자 정의(RejectedExecutionHandler): 개발자가 직접 정의한 거절 정책을 사용할 수 있다. 
참고로, ThreadPoolExecutorshutdown()하면 이후에 요청하는 작업을 거절하는데, 이때도 같은 정책이 적용된다.

 

AbortPolicy

작업이 거절되면 RejectedExecutionException을 던진다. 기본적으로 설정되어 있는 정책이다.

RejectMainV1

package thread.executor.rejected;

import thread.executor.RunnableTask;

import java.util.concurrent.*;

import static util.MyLogger.log;

public class RejectMainV1 {

    public static void main(String[] args) {

        ExecutorService es = new ThreadPoolExecutor(
                1,
                1,
                0,
                TimeUnit.SECONDS,
                new SynchronousQueue<>(),
                new ThreadPoolExecutor.AbortPolicy());

        es.execute(new RunnableTask("task1"));
        try {
            es.execute(new RunnableTask("task2"));
        } catch (RejectedExecutionException e) {
            log("요청 초과");
            // 포기, 다시 시도 등 다양한 고민을 하면 됨
            log(e);
        }

        es.close();
    }
}
  • ThreadPoolExecutor 생성자 마지막에 new ThreadPoolExecutor.AbortPolicy()를 제공하면 된다.
  • 참고로 이것이 기본 정책이기 때문에 생략해도 된다.
  • 스레드는 1개만 사용한다. 예제를 단순하게 만들기 위해 큐에 작업을 넣지 않도록 SynchronousQueue를 사용한다.

실행 결과

2024-07-31 12:43:13.500 [     main] 요청 초과
2024-07-31 12:43:13.500 [pool-1-thread-1] task1 시작
2024-07-31 12:43:13.503 [     main] java.util.concurrent.RejectedExecutionException: Task thread.executor.RunnableTask@b7fa1e8f rejected from java.util.concurrent.ThreadPoolExecutor@6ac69be4[Running, pool size = 1, active threads = 1, queued tasks = 0, completed tasks = 0]
2024-07-31 12:43:14.506 [pool-1-thread-1] task1 완료
  • task1은 풀의 스레드가 수행한다.
  • task2를 요청하면 허용 작업을 초과한다. 따라서 RejectedExecutionException이 발생한다.

RejectedExecutionException 예외를 잡아서 작업을 포기하거나, 사용자에게 알리거나, 다시 시도하면 된다. 이렇게 예외를 잡아서 필요한 코드를 직접 구현해도 되고 아니면 다음에 설명한 다른 정책들을 사용해도 된다.

 

RejectedExecutionHandler

마지막에 전달한 AbortPolicyRejectedExecutionHandler의 구현체이다. ThreadPoolExecutor 생성자는 RejectedExecutionHandler의 구현체를 전달 받는다.

public static class AbortPolicy implements RejectedExecutionHandler {
    public AbortPolicy() { }

    public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
        throw new RejectedExecutionException("Task " + r.toString() +
                                             " rejected from " +
                                             e.toString());
    }
}

ThreadPoolExecutor는 거절해야 하는 상황이 발생하면 RejectedExecutionHandler가 가지고 있는 rejectExecution()을 호출한다. 

 

DiscardPolicy

거절된 작업을 무시하고 아무런 예외도 발생시키지 않는다.

RejectMainV2

package thread.executor.rejected;

import thread.executor.RunnableTask;

import java.util.concurrent.*;

import static util.MyLogger.log;

public class RejectMainV2 {

    public static void main(String[] args) {

        ExecutorService es = new ThreadPoolExecutor(
                1,
                1,
                0,
                TimeUnit.SECONDS,
                new SynchronousQueue<>(),
                new ThreadPoolExecutor.DiscardPolicy());

        es.execute(new RunnableTask("task1"));
        es.execute(new RunnableTask("task2"));
        es.execute(new RunnableTask("task3"));

        es.close();
    }
}
  • ThreadPoolExecutor 생성자 마지막에 new ThreadPoolExecutor.DiscardPolicy()를 제공하면 된다.

실행 결과

2024-07-31 12:47:28.663 [pool-1-thread-1] task1 시작
2024-07-31 12:47:29.668 [pool-1-thread-1] task1 완료
  • task2, task3은 거절된다. DiscardPolicy는 조용히 버리는 정책이다.
  • 다음 구현 코드를 보면 왜 조용히 버리는 정책인지 이해가 될 것이다. (아무것도 없다)
public static class DiscardPolicy implements RejectedExecutionHandler {

    public DiscardPolicy() { }

    public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
    }
}

 

CallerRunsPolicy

이제 이게 재밌는데, 호출한 스레드가 직접 작업을 수행하게 한다. 그러니까 예를 들어, 피자를 먹으러 온 손님한테 주인이 "내가 두 팔을 다 사용중이라 더 피자를 못 만드니까 당신이 알아서 해 먹으세요!" 라고 직접 시키는 것과 같다. 이로 인해 새로운 작업을 제출하는 스레드의 속도가 느려질 수 있다. 왜냐? 원래 같으면 새로운 작업을 제출하는 스레드는 execute() 또는 submit()으로 계속 작업을 요청하는데 요청했더니 나보고 이걸 하라고 시키면? 직접 작업을 처리하는 시간동안 요청을 못하게 된다. 

 

RejectMainV3

package thread.executor.rejected;

import thread.executor.RunnableTask;

import java.util.concurrent.ExecutorService;
import java.util.concurrent.SynchronousQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;

public class RejectMainV3 {

    public static void main(String[] args) {

        ExecutorService es = new ThreadPoolExecutor(
                1,
                1,
                0,
                TimeUnit.SECONDS,
                new SynchronousQueue<>(),
                new ThreadPoolExecutor.CallerRunsPolicy());

        es.execute(new RunnableTask("task1"));
        es.execute(new RunnableTask("task2"));
        es.execute(new RunnableTask("task3"));

        es.close();
    }
}

실행 결과

2024-07-31 12:52:13.122 [     main] task2 시작
2024-07-31 12:52:13.122 [pool-1-thread-1] task1 시작
2024-07-31 12:52:14.127 [pool-1-thread-1] task1 완료
2024-07-31 12:52:14.132 [     main] task2 완료
2024-07-31 12:52:14.133 [pool-1-thread-1] task3 시작
2024-07-31 12:52:15.134 [pool-1-thread-1] task3 완료
  • task1은 스레드 풀에 스레드가 있어서 스레드 풀에 있는 스레드가 수행한다.
  • task2는 스레드 풀에 보관할 큐도 없고 작업할 스레드가 없다. 거절해야 한다.
  • 이때 작업을 거절하는 대신에 작업을 요청한 스레드에게 대신 일을 시킨다.
  • task2의 작업을 main 스레드가 수행하는 것을 확인할 수 있다.

이 정책의 특징은 생산자 스레드가 소비자 대신 일을 수행하는 것도 있지만, 생산자 스레드가 대신 일을 수행하는 덕분에 작업의 생산 자체가 느려진다는 점이다. 덕분에 작업의 생산 속도가 너무 빠르다면, 생산 속도를 조절할 수 있다. 원래대로 하면 main 스레드가 task1, task2, task3, task4를 연속해서 바로 생산해야 한다. CallerRunsPolicy 정책 덕분에 main 스레드는 task2를 본인이 직접 완료하고 나서야 task3을 생산할 수 있다. 결과적으로 생산 속도가 조절되었다. 

 

public static class CallerRunsPolicy implements RejectedExecutionHandler {
    public CallerRunsPolicy() { }

    public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
        if (!e.isShutdown()) {
            r.run();
        }
    }
}
  • r.run() 코드를 보면 별도의 스레드에서 수행하는 것이 아니라 main 스레드가 직접 수행하는 것을 알 수 있다. Runnable이 가진 run()을 직접 호출하면 절대 안된다고 예전 포스팅에서 말했다. 그 이유는 직접 호출하면 그 호출한 스레드가 이 작업을 수행하는 것이지 새로운 스레드가 작업을 수행하는게 아니라고 말했다. 그 이유 그대로가 여기서 사용된다. 재밌다.
  • 참고로 ThreadPoolExecutorshutdown()을 하면 이후에 요청하는 작업을 거절하는데, 이때도 생성할 때 설정한 예외 정책이 그대로 적용된다. 그래서 CallerRunsPolicy 정책은 shutdown() 이후에도 받은 작업을 수행하게 될테니 shutdown() 조건을 체크해서 이 경우에는 작업을 수행하지 않도록 해두었다.

사용자 정의

사용자가 직접 RejectedExecutionHandler 인터페이스를 구현해서 자신만의 예외 처리 전략을 정의할 수 있다. 이를 통해 특정 요구사항에 맞는 작업 거절 방식을 설정할 수 있다.

 

RejectMainV4

package thread.executor.rejected;

import thread.executor.RunnableTask;

import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;

import static util.MyLogger.log;

public class RejectMainV4 {

    public static void main(String[] args) {

        ExecutorService es = new ThreadPoolExecutor(
                1,
                1,
                0,
                TimeUnit.SECONDS,
                new SynchronousQueue<>(),
                new MyRejectedExecutionHandler());

        es.execute(new RunnableTask("task1"));
        es.execute(new RunnableTask("task2"));
        es.execute(new RunnableTask("task3"));

        es.close();
    }

    static class MyRejectedExecutionHandler implements RejectedExecutionHandler {

        AtomicInteger count = new AtomicInteger();

        @Override
        public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) {
            int counted = count.incrementAndGet();
            log("[경고]: 누적된 거절 작업 수 : " + counted);
        }
    }
}

실행 결과

2024-07-31 13:01:29.707 [     main] [경고]: 누적된 거절 작업 수 : 1
2024-07-31 13:01:29.707 [pool-1-thread-1] task1 시작
2024-07-31 13:01:29.710 [     main] [경고]: 누적된 거절 작업 수 : 2
2024-07-31 13:01:30.712 [pool-1-thread-1] task1 완료

 

 

정리

길고 긴 멀티스레드 세상이 끝났다! 이 포스팅에서는 Executor 프레임워크를 조금 더 실무 관점에서 다루는 방식에 대해 알아보았다. 그래서 어떻게 우아한 종료를 할 수 있을지에 대한 고민과 스레드 풀의 corePoolSize, maximumPoolSize가 어떤 방식으로 동작하는지 이해해보았다. 그래서 풀 전략도 세가지로 나뉘어졌었다.

  • 고정 스레드 풀 전략: 트래픽이 일정하고, 시스템 안정성이 가장 중요
  • 캐시 스레드 풀 전략: 일반적인 성장하는 서비스
  • 사용자 정의 풀 전략: 다양한 상황에 대응

그리고 마지막으로, 예외 정책에 대해서도 알아봤다. 개인적으로 가장 재밌는건 CallerRunsPolicy였다. 사용하진 않을 것 같다ㅋㅋ

728x90
반응형
LIST

+ Recent posts