갑자기 화면에 나오는 메세지를 한 번에 변경해야 하는 상황이 오면 어떻게 될까?

페이지가 100개, 1000개라면 모두 하나씩 변경해야하는데 생각만 해도 어지럽다.

 

타임리프 메세지 기능을 이용해 화면에 나오는 메세지들을 한 파일에 모아놓고 관리할 수 있다.

 

스프링 메세지 소스 설정

  1. 스프링에서 메세지 기능을 이요하려면 MessageSource를 스프링 빈으로 등록해야 한다.
  2. 하지만 스프링부트가 자동으로 등록해주므로 우리는 설정만 관리하면 된다.
  3. application.yml 에서 메세지 소스 파일 설정이 가능하다.
//aplication.yml

spring:
  messages:
    basename: message //기본 설정
    //basename: project/config/message

웹에 메세지 기능 사용하기

//messages.properties

admin=어드민
service.user=[유저 서비스]
service.schedule=[스케줄 서비스]
service.workspace=[워크스페이스 서비스]
service.devLog=[개발일지 서비스]
<div class="header">
    <h1><a href="/" th:text="#{admin}">Admin</a></h1>
    <p><a href="/admin/devLogs" th:text="#{service.devLog}">[개발일지 서비스]</a></p>  
</div>

다음과 같이 messages.properties를 만들어 설정해놓은 후

html 파일에서 th:text="#{...}"를 이용해 사용할 수 있다.

 

//messages_en.properties

admin=Admin
service.user=[User Service]
service.schedule=[Schedule Service]
service.workspace=[Workspace Service]
service.devLog=[DevLog Service]

messages_en.properties 등의 언어에 따른 파일을 분리해서 만들면

스프링이 LocaleResolver가 자동으로 국제화를 적용해준다.

 

따로 설정하지 않을 시 기본으로 Accept_Language 헤더를 읽어 적용해준다.

타임리프 기본 사용법

텍스트 출력

  1. html 콘텐츠에 내용 출력 → th:text=”${data}”
  2. 컨텐츠 안에서 직접 출력 = [[${data}]]

Espace 기본 제공

  1. html은 <, > 등등의 특수문자 기반으로 화면 생성
  2. html 엔티티 : <, >를 문자로 표현하는 방법 = &lt, &gt
  3. th:text, [[${data}]] 는 기본적으로 이스케이프 제공
  4. th:utext, [(${data})] → 이스케이프 제공 x

기본 객체 제공

  1. ${#request}
  2. ${#response}
  3. ${#session}
  4. ${#servletContext}
  5. ${#locale}

연산 기능 제공

  1. html 안에서 연산을 사용할 수 있다.
  2. th:text=”1+3” → 4가 화면에 나옴
    1. &gt, gt,, ge, ≥, ==, +, -, %, *, ≠ 다 사용 가능
    2. th:text=”(10 % 2 == 0)? ‘짝수’ : ‘홀수’ “ (조건식 사용 가능)
  3. Elvis 연산자
    1. 데이터 있는지 없는지에 따라 다른 값이 나오도록 할 수 있다.
    2. th:text=”${data}? ‘데이터가 없습니다’ ”
    3. 조건식의 축약 버전
  4. No- Operation
    1. th:text=”${data}? _ ”
    2. 데이터 없으면 타임리프가 렌더링을 안한다.

속성 값 설정

  1. th:* 사용해서 html 태그 내의 속성 값 설정
  2. 기존 속성 있으면 대체, 없으면 새로 만듦 - 기본 값 만들어놓고 디버깅 가능
    1. th:attrappend : 속성 뒤에 값 추가
    2. th:attrperpend: 속성 앞에 값 추가
    3. th:classappend: 클래스 뒤에 값 추가
  3. checked 처리
    1. html checked → true,false 상관없이 그냥 체크 표시 나옴
    2. th:checked=”false”면 checked 지워버림

반복

  1. 반복 상태 유지
    1. th:each 사용하면 됨
    2. List, Map, 배열, Iterable 등등 다 가능
    3. “user, userStat : ${userList}”
    4. userStat에 현재 반복 상태 알려줌, 생략 가능 → +Stat으로 들어감
      1. userStat.index : 0 부터 시작하는 인덱스
      2. count: 1 부터 시작함
      3. size: 전체 사이즈
      4. event, odd : 홀수, 짝수 여부
      5. first, last: 처음, 마지막 여부
      6. current 현재 객체

템플릿 조각과 레이아웃

<div th:fragment="users_header (title)" class="title">
    <div class="header">
        <h1><a href="/">Admin</a></h1>
        <p><a href="/admin/users">[유저 서비스]</a></p>
    </div>
    <p th:text="${title}"></p>
</div>
<!DOCTYPE HTML>
<html xmlns: th=”http://www.thymeleaf.org”>
<head>
    <meta charset="utf-8">
    <title>BlazingDevs Calender!</title>
    <link th:href="@{/css/menu.css}" rel="stylesheet" />
</head>
<body>
    <div th:replace="~{template/fragment/header :: users_header ('유저관리')}" class="title">
        <h1>유저 관리</h1>
    </div>
    ...
</body>
</html>

th:fragment를 사용하면 템플릿을 조각처럼 만들어놓고 사용할 수 있다.

네비게이션 바와 같은 여러 템플릿에서 공유하는 부분을 만들어 놓고 사용하면 편할 것 같다.

 

html 태그에 fragment를 사용하여 기본 레이아웃을 만들어 놓고 사용할 수도 있다.

 

스프링 통합 기능

th:object, th:field를 사용하면 템플릿에 객체를 넘겨주고 form 태그를 편리하게 사용할 수 있다.

@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
public class CreateScheduleRequest{
    private String workspace;
    private String name;
    private List<String> users;
}
    @GetMapping("/admin/schedules/create-schedules")
    public String createScheduleForm(Model model){
        
        List<Workspace> workspaces = workspaceService.findAll()
        List<User> users = userService.findAll();
        
        List<String> workspaceList = workspaces.stream()
            .map(w -> w.getName())
            .collect(toList());
        
        List<String> userList = users.stream()
            .map(u -> u.getAccountId())
            .collect(toList());
        
        model.addAttribute("workspaceList", workspaceList);
        model.addAttribute("userList", userList);
        model.addAttribute("schedule", new CreateScheduleRequest());
        
        return "admin/schedules/schedules-create-form";
    }

스케줄 생성 폼에 생성에 필요한 내용을 담고 있는 CreateScheduleRequest 객체를 넘겨줬다.

<body>
    <div th:replace="~{template/fragment/header :: schedules_header ('스케줄생성')}" class="title">
        <h1>스케줄 생성</h1>
    </div>

    <div class="container">
        <form role="form" action="/admin/schedules/create-schedules" method="post" th:object="${schedule}">
            <div class="form-group">
                <label>이름</label>
                <input type="text" th:field="*{name}" class="form-control" placeholder="이름을 입력하세요"/>
            </div>
            <div class="form-group">
                <label>워크스페이스</label>
                <select th:field="*{workspace}">
                    <option th:each="workspace: ${workspaceList}" th:value="${workspace}"
                            th:text="${workspace}"></option>
                </select>
            </div>
            <div class="form-group">
                <label>유저</label>
                <details>
                    <summary> 유저 보기 </summary>
                    <ul>
                        <li th:each="user : ${userList}">
                            <input th:field="*{users}" type="checkbox" th:text="${user}" th:value="${user}" />
                        </li>
                    </ul>
                </details>
            </div>
            <button type="submit" class="btn">생성</button>
        </form>
    </div>
</body>

th:object="${schedule}" 를 사용하면 form에서 model로 받아온 객체를 사용할 수 있다.

th:field="*{name}"를 사용하면 가져온 객체(schedule)의 name 필드를 사용할 수 있다.

th:field를 사용하면

  1. 태그에 name, id 를 자동으로 만들어준다.
  2. field 안에 값이 있을 경우 value도 만들어준다.
  3. checkbox 타입에선 value와 field의 값을 비교하여 checked 속성과 히든 태그를 만들어준다.

validation에서도 사용할 수 있다. 고 하는데 강의를 더 들어봐야 할 것 같다

 

@ModelAttribute NoArgs Setter 이슈

어드민 페이지를 만들면서 파라미터로는 값을 잘 전달하는데

ModelAttribute에서 변환하는 과정에서 객체 프로퍼티에 null값이 전달되는 일이 있었다.

 

ModelAttribute에서 파라미터로 넘어온 값을 이용해서 객체를 생성할때

  1. 파라미터 없는 기본 생성자(NoArgsConstructer 등)로 객체 생성 후 Setter로 초기화
  2. 파라미터 존재하는 생성자로 생성하면서 초기화

순으로 진행하기 때문에

 

파라미터 없는 생성자가 있을 경우 Setter를 달아줘야한다.

 

 

 

 

스프링 mvc 강의 1편의 마지막 쯤으로 가니까 타임리프를 활용한 프로젝트 구현이 진행됐다.

사실 기존에 만들던 캘린더 API는 프론트가 있다고 가정하고 만든 api 형태의 백엔드라, 타임리프를 사용할 이유가 없었다.

 

그런데 강의에서 백엔드 개발자더라도 SSR을 위해 타임리프로 화면 그리기 정도는 할 줄 알아야 한다라고 이야기했고,

또 간단한 어드민 페이지 정도는 프론트에 맡기는 거보단 직접 만드는게 편하다고 했기에

타임리프를 써서 간단한 어드민 페이지를 만들어봤다.

 

개발일지 생성 화면

개발 일지 생성 화면이다.

스케줄과 개발일지 작성자를 선택한 후에 내용을 입력하면 된다.

 

(물론 이건 어드민 화면이므로 작성자와 스케줄을 직접 선택했지만,

실제 서비스에선 로그인 한 후에 스케줄에 댓글을 다는 식으로 개발일지를 남기려 했다.)

// Admin 컨트롤러
@Controller
@RequiredArgsConstructor
public class AdminDevLogController{
    
    private final ScheduleService scheduleService;
    private final UserService userService;
    private final DevLogService devLogService;
    
    // 개발일지 생성 화면
    @GetMapping("/admin/devLogs/create-devLogs")
    public String createDevLogForm(Model model){
        
        Pageable pageable = PageRequest.of(0,20);
        List<Schedule> scheduleList = scheduleService.findSchedules(pageable).getContent();
        List<User> users = userService.findAllBySearch(pageable, null, null).getContent();
        
        List<String> userList = users.stream()
            .map(u -> u.getAccountId())
            .collect(toList());
        
        model.addAttribute("scheduleList", scheduleList);
        model.addAttribute("userList", userList);
        
        return "devLogs/devLogs-create-form";
    }
    
    // 개발일지 생성
    @PostMapping("/admin/devLogs/create-devLogs")
    public String createDevLog(CreateDevLogRequest request){
        
        devLogService.createDevLog(request);
        
        return "redirect:/admin/devLogs/read-devLogs";    
    }
}
<!-- devLogs-create-form.html -->
<!DOCTYPE HTML>
<html xmlns: th=”http://www.thymeleaf.org”>
<head>
    <title>BlazingDevs Calender!</title>
    <link th:href="@{/css/create-form.css}" rel="stylesheet" />
</head>
<body>
    <div class="title">
        <h1>개발일지 생성</h1>
    </div>

    <div class="container">
        <form role="form" action="/admin/devLogs/create-devLogs" method="post">
            <div class="form-group">
                <label>스케줄</label>
                <select name="scheduleId">
                    <option th:each="schedule: ${scheduleList}" th:text="${schedule.name}" th:value="${schedule.id}"></option>
                </select>
            </div>
            <div class="form-group">
                <label>유저</label>
                <select name="userAccountId">
                    <option th:each="user: ${userList}" th:text="${user}"></option>
                </select>
            </div>
            <div class="form-group">
                <label>내용</label>
                <input type="text" name="content" class="form-control" placeholder="내용을 입력하세요"/>
            </div>
            <button type="submit" class="btn">생성</button>
        </form>
    </div>
</body>
</html>

 

mvc 강의 1편 마지막에서 간단히 배운 내용으로 직접 만들어 봤다.

  • 타임리프 사용 선언 : <html xmls: th="http://www.thymeleaf.org">
  • html 속성 변경 : th:바꿀 속성="${내용}", 이때 내용은 컨트롤러에서 model.addAttribute로 넘겨줄 수 있다.
  • th:xxx 가 붙은 부분은 SSR로 서버가 실행될때 변경된다.
  • th:each="xxx : ${xxxList}" 사용시 리스트로 받아온 값을 이용해 반복문을 사용할 수 있다.
  • "${내용}" 과 같은 리터럴은 다른 문자열과 함께 사용할 수 없다. 무조건 따로 쓴 후 + 로 합쳐야한다.
  • |...| 을 사용하면 리터럴을 대체해 사용할 수 있다. (<span th:text = “|Hello world ${username}|”>)

 

PRG 패턴

 POST 방식으로 온 요청에 대해서 GET 방식의 웹페이지로 리다이렉트 시키는 패턴을 말한다.

Post 방식으로 온 요청을 사용한 후 비즈니스 로직(값 DB에 저장)을 진행한 다음 get 방식으로 html을 다시 반환하면

사용자가 뒤로가기,새로고침을 누를 시 방금 전의 post 요청이 계속해서 다시 발생한다.

 

따라서 redirect를 이용해 새로운 페이지로 이동을 시켜준다.

브라우저는 302 응답코드와 url을 받으며, 받은 url로 다시 한 번 get 요청을 보내고 이동시킨다.

이렇게 하면 비즈니스 로직이 중복 발생하지 않는다.

또 브라우저는 3XX 요청을 받은 페이지는 저장하지 않아서 뒤로 가기를 하더라도 요청 페이지가 다시 나오지 않는다.

 

어드민 페이지를 만들면서 느낀 점

 

1. 프론트 화면을 만들면 생각보다 신경써야할 내용들이 많다.

뒤로가기 구현, 메인 화면으로 돌아가기 구현 등의 프론트가 신경써야할 부분 뿐만 아니라

회원가입 시 ID 중복 체크, 유저 추가 시 중복 방지와 같은 백엔드 api에선 단순히 오류만 보내고 끝냈는데

화면을 구현하면 더 신경써야 할 로직들이 많았다.

 

2. Contoller 를 새로 만들면서 느낀 점

왜 service 계층이 dto에 의존하지 않는 것이 좋은지 잘 알겠다.

admin 페이지를 위한 컨트롤러를 만들면서, model로 전송할 내용들이 api와는 몇가지 달라진 점이 있는데,

모든 service, repository, domain, dto 코드들을 단 한 줄도 건드리지 않고 재활용하여 만들 수 있었다.

service 계층이 dto를 반환하고 있었다면, 컨트롤러 로직이 복잡해지거나 바꿀 코드들이 많았을 것 같다,

 

저번에 고민했던 dto의 생존 시점 고민이 도움이 됐던 것 같다.

도메인 생성을 제외한 나머지 로직은 dto가 컨트롤러까지만 살아있도록 구현했다.

사실 도메인 생성시에 필요한 값은 변할일이 많이 없으며, 변하더라도 서비스에 영향을 끼칠거라고 판단했다.

 

실제로 바꾼 컨트롤러에서도 생성 dto를 그냥 그대로 재활용했고,

나머지 경우엔 서비스가 dto가 아닌 엔티티만 반환하므로 컨트롤러에서 화면에 맞게 바꾸면 됐다.

 

로직이 Controller - Service 어디에 들어가는지 애매하다면

  1. 앱, 어드민 화면 등으로 화면 구현 바꿨을 때 다른 UI 코드랑 중복이 생기나?
  2. ui에 종속적인가, 핵심 비즈니스 로직인가?

를 생각해 보는 것이 좋을 것 같다.

화면을 변경해도 동일하게 나오는 로직이라면 서비스에 넣는 것도 나쁘지 않은 선택인 것 같다.

 

ui에 종속적인 화면이라면 Controller로, 핵심 비즈니스 로직이라면 service로 넣는 것이 좋은데

사실 핵심 비즈니스 로직이라는 것도 말이 애매하긴 한 것 같다.

 

모든 설계와 디자인 패턴에는 트레이드 오프가 존재하니까 상황에 맞춰서 잘 사용하는 것이 제일 좋아보인다.

 

3. Service의 의존관계

예재의 DevLogService는 3개의 Repository에 의존한다.

  1. 스케줄 선택을 위해 현재 존재하는 스케줄을 조회하는 ScheduleRepository
  2. 유저 선택을 위해 현재 존재하는 유저들을 조회하는 UserRepository
  3. 개발일지 저장, 조회 등의 로직을 담당하는 DevLogRepository

이렇게 하나의 서비스가 여러개의 리포지토리에 의존하면 서비스의 부담이 커지고,

코드 변경에 어려움이 있지 않을까 라는 생각을 했다.

 

하나의 서비스는 한 개의 리포지토리에만 의존하며 한 번에 하나의 비즈니스 로직만 수행하고,

컨트롤러에서 여러개의 서비스를 사용하는 것은 어떨까 싶었다.

 

  1. Controller - service 단일 상속 & service - repository 다중 상속
    1. 현재 방식, service가 모든 핵심 비즈니스 로직 처리
    2. 트랜잭션 원자성 준수
    3. 서비스가 repository에 너무 많이 의존함
  2. Controller - service 다중 상속 & service -repository 단일 상속
    1. 코드 변경에 좋음
    2. service가 단일 로직만 수행함, 변경에 용이, 버그 덜 생김
    3. controller 부담 커짐, 트랜잭션 원자성 어김

장 단점이 존재하지만 Controller 단에서 여러개의 service 코드를 호출할 경우,

트랜잭션 원자성이 깨진다는 큰 단점이 존재했다.

 

서비스 레이어 안에서 다시 계층을 나누는 Facade 패턴도 고민해봤는데,

현재 크기의 프로젝트에선 굳이 나눌 필요성을 못 느꼈고,

서비스 레이어를 쪼갤 경우 몇몇 서비스 레이어가 너무 가벼워질 것 같았다.

(리포지토리의 메소드를 그냥 불러오는 정도의 역할만 수행할 것 같았다.)

 

그래서 그냥 이대로 놔두기로 했다.

Controller를 새로 만드는데 전혀 이상이 없었으니까.

 

공부할수록 부족한 부분이 많이 보인다. 갈 길이 멀다.

근데 부족한게 잘 보이니까 가야할 길은 확실히 보이는 것 같다.

 

이제 mvc 2편을 들으면서 지금 만들어놨던 admin 페이지에 다른 기능들을 추가해봐야겠다.

 

 

 

 

 

 

 

 

 

1. 요청 매핑

특정 url로 요청이 들어오면 스프링 mvc는 그 요청에 맞는 핸들러(컨트롤러)를 찾아 핸들러 어뎁터와 매핑한다.

그럼 url에 따른 요청 매핑은 어떻게 할까?

 

1) @RequestMapping을 사용한다.

  • @RequestMapping("/example-url")
  • @RequestMapping("/example-url", method = RequestMethod.GET) 
  • @GetMapping("example-url)
  • @RequestMapping(value = {"/example-url", "/example-url2"})

HTTP 메소드를 지정해줄 수 있다. 지정하지 않을 시 모든 메소드를 전부 매핑한다.

@GetMapping, @PostMapping 등등의 어노테이션을 사용하면, 편리하게 축약해서 사용할 수 있다.

배열을 만들어 여러개의 url을 매핑하는 것도 가능하다.

 

2) 경로 변수 사용 - @PathVariable

@GetMapping("/movies/{movieId}")
public String readMovie(@PathVariable("movieId") Long data){}

//변수명 같으면 생략 가능
@GetMapping("/movies/{movieId}")
public String readMovie(@PathVariable  Long movieId){}

restApi를 사용하면 "/movies/1" 등과 같이 url에 id와 같은 변수를 사용한다.

@PathVariable 을 사용하면 경로 변수를 컨트롤러 메소드 함수 인자로 편하게 받아올 수 있다.

변수명이 같으면 생략도 가능하다.

 

3) 특정 파라미터 매핑

GetMapping(value=”/users”, params=”mode=debug”)

파라미터에 특정 값 없으면 매핑이 안되도록 하는 방법이다.

 

4) 특정 헤더 매핑

GetMapping(headers = “..”)

특정 헤더 넣어야지만 매핑 되도록 가능하도록 하는 방법이다.

 

5) 미디어 타입 조건 매핑 (contentType, produces)

GetMapping(value=”/users”, consumes=”application/json”)

GetMapping(value=”/users”, produces=”application/json”)

 

요청 헤더의 content type과, accept를 기반으로도 매핑이 가능하다.

consumes = Content-Type 기반

produces = Accept 헤더 기반

 

2. HTTP요청 기본, 헤더 조회

어노테이션 기반 컨트롤러는 ReqeustMappingHandler가 처리한다.

ReqeustMappingHandler에서 헤더, 기본값을 꺼내는 방법은 파라미터로 다음과 같은 값을 넣어주면 된다.

  • HttpservletRequest request 
  • HttpServletResponse response
  • HttpMethod method
  • Locale locale
  • @RequestHeader("host")
  • @CookieValue(value="cookieName", required=false)

이외에도 여러가지 값들을 메소드 파라미터로 넣을 수 있고, 사용할 수 있다.

 

3. Http 요청 조회

1) 쿼리 파라미터 & HTML Form - @RequestParam

request parameter 형식으로 데이터가 전달되어 같은 방법으로 둘 다 조회 가능하다.

  • HttpServletRequest - request.getParameter()
  • @RequestParam
// url = "/movies?movieName=parasite&stars=200

@RequestMapping("/movies")
public String requestParam(
	@RequestParam("movieName") String movieName,
	@RequestParam("stars") int stars){}
    
//변수명 같으면 생략 가능
@RequestMapping("/movies")
public String requestParam(
	@RequestParam String movieName,
	@RequestParam int stars){}
    
//어노테이션도 사실 생략 가능 -> 헷갈리니까 잘 안씀
@RequestMapping("/movies")
public String requestParam(String movieName, int stars){}

//필수값 지정, 기본은 true
// "/movies?stars=200              오류 x , movieName = null
// "/movies?movieName=&stars=200   오류 x, movieName에 빈 문자열 들어감
// "/movies?movieName=parasite     400 오류
@RequestMapping("/movies")
public String requestParam(
	@RequestParam(required=false) String movieName,
	@RequestParam int stars){}

쿼리 파라미터 혹은 Html form으로 정보가 넘어오는 경우 @RequestParam 어노테이션을 자주 사용한다.

이것도 변수명이 같으면 생략이 가능하며 String, int 단순 타입이면 어노테이션도 사실 생략 가능하다.

 

필수 파라미터 여부를 (required = false, true)로 설정 가능하다.

기본은 true 이고 true로 설정해놓은 파라미터에 값이 없으면 400 Bad Request가 발생한다.

 

false로 설정해놓은 파라미터가 요청에 없을시 null이 지정된다.

파라미터 이름만 사용, 값 입력 x → null이 아니라 빈문자열이 들어간다..

int로 파라미터 받으면 null 설정이 안되서 required=false여도 요청에 없으면 500 오류가 발생한다.

 

<그외>

defaultValue 옵션 : 기본값 설정 (빈 문자도 기본값으로 해줌)

맵으로도 조회 가능 (@RequestParam Map<String,Object>)

MultiValueMap을 이용하면 파라미터 하나로 여러 값 받아오기가 가능하다.

 

2) 쿼리 파라미터 & HTML Form - @ModelAttribute

// url = "/movies?movieName=parasite&stars=200

public class MovieRequest{
	String movieName;
	int stars;
}

@RequestMapping("/movies")
public String modelAttribute(@ModelAttribute MovieRequest request){}

//어노테이션 생략 가능
@RequestMapping("/movies")
public String modelAttribute(MovieRequest request){}

 

@ModelAttribute를 사용하면 객체를 파라미터로 받아올 수 있다.

스프링 mvc는 @ModelAttribute가 있으면 MovieRequest 객체를 생성한 후

요청 파라미터 이름과 객체의 프로퍼티를 비교해 값을 바인딩한다.

 

이것도 어노테이션을 생략해도 된다.

그러나 String, int, Integer 등의 타입이 파라미터에 있는데 생략하면 @RequestParam을 적용시킨다.

후술할 ArgumentResolver 으로 세팅 혹은 예약된 경우 아닌 클래스의 경우만 ModelAttribute 적용한다.

 

3) 단순 텍스트 (text, xml, json 등등) - @RequestBody

 

Http Body에 값이 들어오는 경우는 앞의 두 파라미터로 꺼내오기가 불가능하다.

 

  • ServletInputStream 사용해서 직접 꺼내오기
  • HttpEntity,RequestEntity를 이용해서 꺼내오기
  • @RequestBody 사용하기
// url = "/movies

public class MovieRequest{
	String movieName;
	int stars;
}

@RequestMapping("/movies")
public String modelAttribute(@RequestBody String text){}

@RequestMapping("/movies")
public String modelAttribute(@RequestBody MovieRequest request){}

@RequestBody 어노테이션을 사용하면 String이나 JSON 형식으로 Http 바디에 담겨오는 정보들을 HttpMessageConverter가 알아서 변환해서 잘 매핑해준다.

 

결론은

  • 파라미터 조회 : RequestParam or ModelAttribute
  • 바디 조회 : RequestBody
  • 헤더 조회 : @RequestHeader 또는 HttpEntity 사용하자.

4. HTTP 응답

1) 정적 리소스(html)

spring boot에서 정적 리소스 반환 기능을 자동으로 제공한다.

resource/static 폴더에 html 저장하고 url에 파일 경로를 입력하면 html파일이 전달된다.

 

2) 뷰 템플릿(동적인 html)

@Controller
public TestController{
	@GetMapping("/movies")
    public String getMovie(){
    	return "movie-list"; //뷰 이름 반환
    }
    
    @GetMapping("/movies/1")
    public String getMovieDetail(Model model){
    	String data = "movie-data";
    	
    	model.addAttribute("data",data); //model 사용해 정보 전달
    	
    	return "response/movie-detail.html"; // 뷰 경로 반환
    }
}

타임리프등 뷰 템플릿을 사용할 시에는 resource/templates 폴더에 저장한 후

컨트롤러에서 String 으로 파일경로 혹은 이름을 반환하면 된다.

 

model.addAttribute()로 모델에 값을 넣어 템플릿에 전달이 가능하다.

 

Controller가 String 반환할경우 ResponseBody이 없으면 ViewResolver가 경로나 뷰 이름으로 뷰를 찾아 렌더링해준다.

 

3) 바디에 직접 입력(JSON)

@Controller
public TestController{

    @ResponseBody
    @GetMapping("/movies")
    public String getMovies(){
    	return "movies"; // 문자열 그대로 반환
    }
    
    @GetMapping("/movies/1")
    public ResponseEntity<String> getMovieDetail(){
    	return new ResponseEntity<>("ok", HttpStatus.ok);
    }
}

ResponseBody : 그냥 String 또는 객체를 반환한다. (객체의 경우 JSON 형식으로 반환된다)

ResponseEntity : String,객체 + status code 반환를 반환한다.

 

RestController 쓰면 (ResponseBody + Controller) 를 한 것과 같이 작동한다. 

5. Http 메세지 컨버터

Http Body에서 정보 읽거나 Body에 정보를 입력해서 반환할때 스프링 부트에선 메세지 컨버터를 사용한다.

인터페이스로 만들어져있으며 다양한 종류의 HttpMessageConverter 가 존재한다. (우선순위 존재)

  • ByteArrayHttpMassageConverter : 바이트 단위로 받아오기 가능
  • StringHttpMessage : 문자(String)으로 데이터 처리 (text/plain)
  • MappingJacksonConverter : 객체로 데이터 처리, 주로 JSON

Spring boot는 Accept 헤더(클라이언트의 해석 타입) , 컨트롤러 반환 타입등의 정보를 조합해서 컨버터를 선택한다.

 

요청 처리 순서

  • Http 요청 + @RequestBody or HttpEntity, RequestEntity
  • 모든 컨버터 대상 canRead() 메소드 호출 (대상 클래스 타입, medeaType, Accept type 지원하나 확인)
  • read()로 객체 생성, 정보 입력하고 반환

6. 요청 매핑 핸들러 어뎁터의 구조

HttpMethodConverter는 그러면 어디서 동작할까?

어노테이션 기반 컨트롤러에 요청이 왔을때 DispatcherServlet은 RequestMappingHandler와 어댑터를 불러온다.

RequestMappingHandler 어댑터는 ArgumentResolver를 호출한다.

 

ArgumentResolver는 인터페이스로 만들어져있으며 (30개 넘는 ArgumentResolver 구현 클래스가 존재함)

어노테이션 기반 컨트롤러의 다양한 파라미터에 맞게 객체를 생성해 핸들러에 반환한다.

 

핸들러에서 응답할때는 ReturnValueHandler가 ModelAndView, ResponseEntity, 등등의 응답값 반환, 처리

 

그리고 이 ArgumentResolver,ReturnValueHandler가 상황에 따라(@RequestBody, @ResponseBody 등의 어노테이션이 달려있다면)

HttpMessageConverter를 사용해서 파라미터 에 맞는 객체를 생성한다.

 

Spring은 ArgumentResolver, ReturnValueHandler, HttpMessageConverter 를 모두 인터페이스로 제공한다.

그래서 ArgumentResolver 인터페이스를 수정해서 사용자가 컨트롤러의 파라미터를 새로 생성하는 등의 확장도 가능하다.

 

 

https://velog.io/@somyeong0623/Spring-MVC-1%ED%8E%B8-06.-%EC%8A%A4%ED%94%84%EB%A7%81-MVC-%EA%B8%B0%EB%B3%B8-%EA%B8%B0%EB%8A%A5

 

[Spring] MVC 1편 - 06. 스프링 MVC - 기본 기능

이 글은 스프링 \[스프링 MVC 1편]을 듣고 정리한 내용입니다프로젝트 세팅 정보

velog.io

 

예전 spring data 강의에서 Auditing에 관련된 내용을 들었을 때

Auditing을 하는 이유가 로그를 추적하고 확인하기 편하게 하려고 라는 내용을 들었다.

 

그렇다면 로그를 어떤 식으로 남겨서 어떻게 활용하는 걸까??

 

1. Slf4j 와 그 외 로깅 라이브러리

스프링 부트로 프로젝트를 만들었을때 기본으로 설치되어 있는 springboot-starter 라이브러리는 slf4j 라이브러리를 포함한다.

SLF4J는 이름을 왜 이따구로 지었는지는 잘 모르겠지만 다양한 로깅 라이브러리에 대한 추상 레이어를 제공하는 인터페이스이다.

추후에 로깅 라이브러리를 변경해도 코드를 수정하지 않아도 되도록 지원해준다.

 

Log4j, Logback 등의 로깅 라이브러리를 연결해 사용할 수 있으며

실무에선 스프링부트에서 기본 제공하는 Logback 많이 사용한다.

 

2. 활용하는 법과 주의점

@RestController
public class UserController{
    
    private final Logger logger = LoggerFactory.getLogger(getClass());
    
    @GetMapping("/users")
    public ResponseEntity<FindAllUserResponse> findAllUserName(){
	//생략
        
        logger.info("[INFO] find All User Name {}", data); // GOOD
        logger.info("[INFO] find All User Name " + data); // BAD!!
        
        return "ok";
    }
}

slf4j 라이브러리의 Logger를 LoggerFactory를 통해 주입 받은 후 사용하면 된다.

@Slf4j 어노테이션으로 연관관계 주입 과정을 간략화 할 수도 있다.

 

이때 로거 안에 + 연산자 등을 이용해 문자열 연산을 넣으면

로깅 시 불필요한 연산으로 인한 메모리 및 자원 낭비, 성능 저하를 유발할 수 있다.

실제 로그는 다음과 같은 방식으로 출력된다. (데이터는 넣지 않았다)

실행시간  로그레벨  프로세스ID  —[쓰레드명]  클래스명 :  메세지

3. 로그의 종류

로그의 종류는 심각도에 따라 5가지로 나뉜다.

trace < debug < info < warn < error 

 

trace, debug는 디버깅용 출력 메세지이며 trace가 좀 더 상세한 메세지를 제공한다.

info는 정보성 메세지이며

warn은 처리 가능한 문제 혹은 향후 에러 원인이 될 수 있는 문제 로그이며

error은 실행 중 발생한 에러를 나타낸다.

 

application.properties, yml 파일에서 콘솔에 출력할 logging 레벨 설정이 가능하다.

trace(전부 보기), debug(개발), info(운영시,기본) 으로 보통 설정한다.

 

 

4. 실무에서 어떻게 사용할까?

1) logback 설정파일 만들기

예전에 장고로 api를 개발할때 하루에 한 번씩 자동으로 수행되는 크론을 사용했었다.

이때 하루에 한 번 수행되는 것이 맞는지 테스트하기 위해, 실행될때마다 cron.log 파일에 로그를 찍어 확인했던 기억이 난다.

 

logback또한 파일에 로그를 따로 기록하는 것이 가능하며, 콘솔에 출력되는 로그의 형식을 바꾸는 것도 가능하다.

/src/resources 폴더에 logback-spring.xml 파일을 만들어 설정이 가능하다.

 

콘솔에만 로그를 띄우면 오래된 로그를 확인하기 어려우므로,

들어오는 모든 요청과 SQL 쿼리를 파일로 만들어서 저장해보려고 한다.

또 에러 로그는 따로 수집해서 다른 파일에 모으고 싶다.

 

<?xml version="1.0" encoding="UTF-8" ?>
<configuration>
    <conversionRule conversionWord="clr" converterClass="org.springframework.boot.logging.logback.ColorConverter" />
    
    <!-- 변수 저장 -->
    <property name="CONSOLE_LOG_PATTERN" value="%d{yyyy-MM-dd HH:mm:ss.SSS} %clr(%5level) [%thread] %cyan(%logger) : %msg%n" />
    <property name="FILE_LOG_PATTERN" value="%d{yyyy-MM-dd HH:mm:ss.SSS} %5level [%thread] %logger : %msg%n" />
    
    <!--콘솔 로깅 설정-->
    <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
        <encoder>
            <pattern>${CONSOLE_LOG_PATTERN}</pattern>
        </encoder>
    </appender>
    
    <!-- 디버그 파일 로깅 설정 -->
    <appender name="DEBUG_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
        <encoder>
            <pattern>${FILE_LOG_PATTERN}</pattern>
        </encoder>
        <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
            <fileNamePattern>../../log/debug/%d{yyyy-MM-dd}.%i.log</fileNamePattern>
            <maxFileSize>10KB</maxFileSize>
            <totalSizeCap>100MB</totalSizeCap>
            <maxHistory>30</maxHistory>
        </rollingPolicy>
    </appender>
    
    <!-- 에러 파일 로깅 설정 -->
    <appender name="ERROR_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
        <encoder>
            <pattern>${FILE_LOG_PATTERN}</pattern>
        </encoder>
        
        <filter class = "ch.qos.logback.classic.filter.LevelFilter">
                <level>ERROR</level>
                <onMatch>ACCEPT</onMatch>
                <onMismatch>DENY</onMismatch>
        </filter>
        
        <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
            <fileNamePattern>../../log/error/%d{yyyy-MM-dd}_ERROR.%i.log</fileNamePattern>
            <maxFileSize>10KB</maxFileSize>
            <totalSizeCap>100MB</totalSizeCap>
            <maxHistory>7</maxHistory>
        </rollingPolicy>
    </appender>
    
    <!-- 루트 로깅 레벨 -->
    <root level="INFO">
        <appender-ref ref="CONSOLE"/>
        <appender-ref ref="DEBUG_FILE"/>
        <appender-ref ref="ERROR_FILE"/>
    </root>
    
    <!-- 패키지 로깅 레벨 -->
    <logger name="project" level="DEBUG"/>
</configuration>

Appender : 로그가 출력될 대상을 결정함 (파일, 콘솔 등)

encoder : 로그 출력될 패턴 결정

root : 로그 출력 레벨 지정

property : 변수 저장

 

CONSOLE_LOG_PATTERN : "%d{yyyy-MM-dd HH:mm:ss.SSS} %clr(%5level) [%thread] %cyan(%logger) : %msg%n"

FILE_LOG_PATTERN : "%d{yyyy-MM-dd HH:mm:ss.SSS} %5level [%thread] %logger : %msg%n"

다음과 같은 형식으로 로그를 기록하기로 했다. 기존의 콘솔 로그에서 프로세스ID만 빠진 예시이다.

파일에 기록할때는 색상에 대한 설정이 필요없으므로 뺐다.

 

파일 형태로 로그를 저장하기 위해 RollingFileAppender를 사용했다.

또 파일을 일정 주기와 크기로 분리하기 위해 SizeAndTimeBasedRollingPolicy를 사용해서 설정했다.

 

에러 로그만 따로 저장하기 위해 filter를 사용했다.

기본 로그는 테스트를 위해 10KB씩 분리해 최대 100MB씩 30일간 저장하고,

에러 로그는 보다 자주 확인할 것을 예상해 7일간만 저장하도록 설정했다.

 

날짜와 순서에 따라 로그가 잘 저장되는 것을 볼 수 있다.

그러나 어떤 SQL이 어떤 요청때문에 수행된 것인지 알아보기가 힘들다.

 

2) AOP를 이용해 공통 로직 처리하기

예전에 잠깐 공부했던 AOP를 활용해서 어떤 url 어떤 http method로 요청이 들어왔고, 언제 로직이 끝났는지 로그로 기록해보자.

 

gradle 추가 - implementation 'org.springframework.boot:spring-boot-starter-aop'

@Slf4j
@Aspect
@Component
public class LogAop{
    // 컨트롤러 이하 패키지의 모든 클래스 적용
    @Pointcut("execution(* project.controller..*.*(..))")
    private void run(){}
    
    @Before("run()")
    public void beforeLog(JoinPoint joinPoint){
        HttpServletRequest request = getRequest();
        log.info("=========== start [{}] {} ============", request.getMethod() ,request.getRequestURI());
    }
    
    @After("run()")
    public void afterLog(JoinPoint joinPoint){
        HttpServletRequest request = getRequest();
        log.info("=========== end [{}] {} ============", request.getMethod() ,request.getRequestURI());
    }
    
    private HttpServletRequest getRequest() {
        HttpServletRequest request = ((ServletRequestAttributes)RequestContextHolder.getRequestAttributes()).getRequest();
        return request;
    }
}

@PointCut 을 통해 적용시킬 범위를 정한다.

@Before, After를 통해 메소드 전 후에 실행시킬 로직을 작성한다.

 

그리고 HttpServletRequest를 통해 요청의 uri와 http method를 받아올 수 있다.

 

아마 프론트 컨트롤러(DispatcherServlet) 에서 공통 로직 처리 이후

원래 컨트롤러를 호출하기 전 후에  AOP로직이 실행되고

AOP 로직에서 DispatcherServlet이 처리해놓은 HttpServletRequest의 정보를 가져올 수 있는 것 같다.

 

결과

 

 

 

https://velog.io/@dhk22/Spring-AOP-%EA%B0%84%EB%8B%A8%ED%95%9C-AOP-%EC%A0%81%EC%9A%A9-%EC%98%88%EC%A0%9C-Logging

 

[Spring AOP] 간단한 AOP 적용 예제 (Logging)

Spring AOP .. 누구니

velog.io

https://velog.io/@backtony/Spring-%EB%A1%9C%EA%B7%B8-%EC%84%A4%EC%A0%95%ED%95%98%EA%B8%B0-Logback

 

Spring 로그 설정하기 - Logback

해당 포스팅의 코드는 Github{:target="\_blank"} 를 참고해주세요. 로깅을 사용하면 다음과 같은 이점이 있습니다.출력 형식을 지정할 수 있다.로그 레벨에 따라 남기고 싶은 로그를 별도로 지정할 수

velog.io

https://breakcoding.tistory.com/400

 

[Spring] logback 파헤치기 (로그 레벨 설정, 프로필별 로그 설정, 글자 색상 변경)

안녕하세요 오늘은 logback을 통해 로그를 관리하는 방법을 알아보겠습니다. 목차 - 로그 색상 바꾸기 - 프로필에 따라 로그 레벨 다르게 설정하기 - 로그 파일을 분할해서 저장하기 - JPA SQL을 로그

breakcoding.tistory.com

 

1. MVC 패턴의 기본 개념

MVC는 Model-View-Controller의 약자로 웹 어플리케이션에서 주로 사용되는 디자인 패턴이다.

웹 어플리케이션은 클라이언트의 요청이 들어오면 비즈니스 로직을 수행한 후 HTML 파일을 생성해 다시 돌려줘야 한다. 

MVC 패턴은 클라이언트 요청 받기, 비즈니스 로직 수행, 화면 생성이라는 각 책임을 분리한 디자인 패턴이다.

 

Controller는 클라이언트의 요청을 받아 url과 파라미터를 검증하고, 비즈니스 로직을 호출하거나 직접 실행한다.

Model은 화면을 그리는데 필요한 데이터들을 저장하고 전달하는 역할을 맡는다.

View는 Model의 데이터를 이용해 화면을 렌더링하는 일을 맡는다.

 

핵심은 Controller과 View의 역할 분리이다.

화면을 그리는 일과, 비즈니스 로직을 수행하는 일은 변경주기가 크게 다르다.

화면은 간단하게 자주 수정되는 반면, 비즈니스 로직은 변경이 적다.

 

비즈니스 로직과 화면 렌더링이 같은 파일에서 진행될 경우, 코드가 복잡해지고 가독성이 떨어진다.

코드 수정도 불편해지며, 화면 렌더링 코드 변경 도중 비즈니스 로직을 잘못 건드릴 경우 큰 문제가 생길 수 있다.

 

2. 서블릿과 JSP를 사용해 구현해본 MVC 패턴 + 문제점

강의에서 Servlet을 컨트롤러로, JSP를 뷰로, HttpServletRequest를 모델로 사용해 MVC 패턴을 구현했다.

//Servlet을 이용한 Controller
@WebServlet(name = "mvcMemberSaveServlet", urlPatterns = "/servlet-mvc/members/save")
public class MvcMemberSaveServlet extends HttpServlet {
    
    private MemberRespository memberRepository = new MemberRepository.getInstance();
    
    @Override
    protected void service(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        //파라미터에서 유저 정보 받아오기
        String username = request.getParameter("username");
        int age = Integer.parseInt(request.getParameter("age"));
        
        //비즈니스 로직 - 유저 생성
        Member member = new Member(username, age);
        System.out.println("member = " + member);
        memberRepository.save(member);
        
        // Model에 데이터 보관.
        request.setAttribute("member", member);
        
        //View(JSP) 호출
        String viewPath = "/views/save-result.jsp";
        RequestDispatcher dispatcher = request.getRequestDispatcher(viewPath);
        dispatcher.forward(request, response);
    }
}
<!-- /views/save-result.jsp -->
<%@ page import="hello.servlet.domain.member.Member" %>
<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<html>
<head>
  <meta charset="UTF-8">
</head>
<body>
  성공
  <ul>
    <li>id=${member.id}</li>
    <li>username=${member.username}</li>
    <li>age=${member.age}</li>
  </ul>
  <a href="/index.html">메인</a>
</body>
</html>

회원 생성이라는 비즈니스 로직은 Controller에서, 화면 렌더링은 JSP에서 담당하게 됐다.

그러나 이러한 방식도 컨트롤러가 늘어나면 문제가 발생한다.

 

1. 컨트롤러에서 코드 중복이 너무 많음.

View 호출 코드는 모든 컨트롤러에서 수행되어야 한다.

또한 뷰 호출 경로를 컨트롤러에서 직접 입력해야하는데, View 파일 경로가 바뀔 경우 모든 컨트롤러의 경로를 직접 수정해야한다.

 

2. 컨트롤러가 HttpServlet에 의존적

HttpServlet와 request, response 등을 직접 만들어야 하므로 테스트 코드를 만들기 힘들어진다.

또 HttpServletResponse 등 컨트롤러에서 필요 없는 코드가 계속 사용된다.

 

결론은 공통 처리가 어려워지고, 테스트가 힘들어진다는 문제가 생겼다.

 

3. 문제점 해결 → 프론트 컨트롤러의 도입

공통 처리가 어려워지는 문제를 해결하기 위해선 프론트 컨트롤러를 만들면 된다.

 

모든 요청을 프론트 컨트롤러에서 받아와 공통 로직을 모두 수행한 이후,

개별 컨트롤러를 호출에 요청에 맞는 비즈니스 로직을 수행시키면 된다,

 

개별 컨트롤러에 HttpSevlet을 전달하는 것이 아닌,

필요한 정보(파라미터)만을 추출해 전달하면 컨트롤러의 HttpServlet 의존성을 제거할 수 있다.

//프론트 컨트롤러
@WebServlet(name = "frontControllerServletV1", urlPatterns = "/front-controller/v1/*")
public class FrontControllerServletV1 extends HttpServlet {
	
	private Map<String, ControllerV1> controllerMap = new HashMap<>();
	
	public FrontControllerServletV1() {
		controllerMap.put("/front-controller/v1/members/new-form", new MemberFormControllerV1());
		controllerMap.put("/front-controller/v1/members/save", new MemberSaveControllerV1());
		controllerMap.put("/front-controller/v1/members", new MemberListControllerV1());
	}
	
	@Override
	protected void service(HttpServletRequest req, HttpServletResponse res) throws ServletException, IOException {
		//요청에 맞는 컨트롤러 찾기
		String reqURI = req.getRequestURI();
		ControllerV1 controller = controllerMap.get(reqURI);
		
		//필요한 데이터 추출 로직
		Map<String,String> paramData = getParamData(req);
        
		//컨트롤러 로직 호출
		controller.process(paramData);
	}
}

4. 유연한 컨트롤러 사용 → 어댑터 패턴 사용

위의 코드와 같이 프론트 컨트롤러를 구현하면, ControllerV1이 아닌 다른 종류의 컨트롤러를 사용할 수 없다.

다양한 인터페이스로 만들어진 컨트롤러를 모두 사용할 수 있는 프론트 컨트롤러를 만들기 위해선 어댑터 패턴을 사용하면 된다.

예재의 패턴
실제 스프링 mvc 패턴

1. 클라이언트의 요청

2. 클라이언트의 요청을 처리할 수 있는 컨트롤러 있는지 확인

3. 컨트롤러(핸들러)를 처리할 수 있는 핸들러 어뎁터가 있는지 확인

4. 핸들러 어뎁터에 핸들러 처리를 맡김 -> 비즈니스 로직 수행 후 View 반환

5. 뷰에서 화면 만들어 응답.

 

순으로 진행된다.

핸들러 어뎁터는 컨트롤러에 맞춰서 요청 정보를 전달하고, 컨트롤러의 응답을 View로 변환하는 역할을 맞는다.

여기까지 구현한 mvc 패턴의 구조는 Spring MVC 패턴과 매우 유사한 구조를 띄게 된다.

 

실제 Spring MVC 패턴에선

프론트 컨트롤러 -> Dispatcher Servlet

viewResolver, View를 인터페이스화 -> 확장성 증가

정도의 차이만 존재한다.

 

1. 클라이언트의 요청

2. 클라이언트의 요청을 처리할 수 있는 컨트롤러가 있는지 확인 (어노테이션 기반 핸들러, 빈 이름으로 조회 순서)

3. 컨트롤러(핸들러)를 처리할 수 있는 핸들러 어뎁터가 있는지 확인 (어노테이션 기반 핸들러 어뎁터, HttpRequestHandler 순서)

4. 핸들러 어뎁터에 핸들러 처리를 맡김 -> 비즈니스 로직 수행 후 ModelAndView 반환

5. ViewResolver에서 view 이름으로 View 찾아옴(동일한 이름의 빈 있는지, application.properties 설정에서 경로 찾기)

6. View에서 화면 만들어 응답

 

 

5. 실무에서 mvc를 활용할 때

실무에선 controller를 만들때 그냥 @Controller 어노테이션을 사용한다.

@Controller
public SpringMvcController{
	private MemberRepository memberRepository = MemberRepository.getInstance();
    
	@PostMapping("/save")
	public String createMember(
		@RequestParam("username") String username,
		@RequestParam("age") int age,
		Model model) {
    
		//비즈니스 로직
		Member member = new Member(username, age);
		memberRepository.save(member);
    
		//모델에 값 추가
		model.addAttribute("member",member);
	
		//view 이름만 반환
		return "save-result";
	}
}

mvc 패턴에 대해 자세히 알고 나니 다르게 보이는 것들이 조금 있다.

 

1.  @Controller의 역할

컨트롤러 어노테이션은 단순히 빈 등록만 할 뿐 아니라 어노테이션 기반 핸들러라는 것을 등록함

 

2.  @RequestParam 의 역할

form 방식으로 넘어오는 데이터가 get방식의 파라미터 형식과 똑같이 넘어온다.

그래서 get, post 둘다 RequestParam을 사용 가능.

 

3. model 과 getAttribute의 기능

HttpServletReqeust에 setAttribute()를 구현해놓은 것.

그래서 model을 받아와서 addAttribute로 정보만 입력하고 다시 반환하지 않아도 된다.

Handler에서 HttpServletRequest에 담겨있는 정보를 다시 ModelAndView로 전달해줄거니까.

 

4. 왜 Controller가 파일 경로나 클래스가 아닌 String을 반환하도록 만들어졌을까

String만 간단하게 반환해도 ViewResolver가 view 이름으로 알아서 View를 찾아주니까 가능한거였다.

 

6. 번외 - 장고의 MTV 패턴과 비교

장고는 이러한 MVC 패턴을 MTV(Model-View-Template) 패턴으로 지원한다.

 

1. Model

장고에서 Model은 화면 정보를 저장하고 전달하는 것이 아니다.

Django ORM을 이용해 DB와 직접적으로 연결되며, 하나의 테이블 정보를 저장하고 있기도 하다.

 

2. View

장고의 View는 MVC 패턴의 View가 아닌 Controller의 역할을 한다!!!

왜 이렇게 이름을 헷갈리게 지었는지는 잘 모르겠지만, 장고의 View는 비즈니스 로직을 수행하고 템플릿을 호출한다.

 

3. Template

장고의 Template가 View역할을 맡아 화면을 렌더링하는 역할을 맡는다.

또 자체척인 Template 문법을 지원해 context(MVC패턴의 모델 역할)에서 데이터를 받아와 렌더링한다.

 

7. 리팩토링 할때 알면 좋은 점

  1. 변경 주기가 다르면 분리하자
  2. 큰 메소드 사이에 작은 구현 → 메소드로 분리하자
  3. 리팩토링시 전체적인 구조 개선이 필요할 경우, 기존 구현 코드를 재활용하며 우선 구조부터 개선하자.
    1. 구조 개선에 대한 커밋과 테스트를 진행하고 나서 세부 구현 코드를 수정하자.

 

  1. 웹 서버 vs 웹 애플리케이션 서버
    • 최근엔 거의 모든 형태의 데이터를 주고 받을때 HTTP 사용해 통신함 (클라이언트 ↔ 서버, 서버↔ 서버)
    • 웹서버: 정적 리소스 제공(html,css,js,image,영상 등)
    • 웹 애플리케이션 서버(WAS) : 웹 서버 기능 + 애플리케이션 로직(Servlet, JSP, Spring mvc등등) 실행
    • 실무에서 웹을 만들때 → was + db 만으로도 구성 가능
      • 그러나 was가 너무 많은 역할 담당
      • 애플리케이션 로직이 정적 리소스 때문에 수행에 문제,지연 가능
      • 장애 시 오류화면 로드 불가능
    • 실제론 웹서버에서 기본 처리하다가 필요할때 was 호출하는 방식 주로 사용함
      • 리소스 효율적인 분배 가능
      • 서버 증설이 편리함
      • was 장애 발생해도 오류화면 제공 가능
  2. 서블릿
    • TCP/IP 통신 연결 부터 HTTP 메소드 파싱 등 비즈니스 로직 외에 다른 일들을 제공함
    • url로 요청 → WAS에서 request,response 객체 만들어서 서블릿 컨테이너에 제공 → 컨테이너에서 요청에 맞는 서블릿 호출,  비즈니스 로직 실행 → response 응답 만들어서 브라우저에 전달
    • 서블릿 컨테이너란
      • 서블릿을 지원하는 was
      • 서블릿 객체를 싱글톤으로 관리한다
      • JSP도 서블릿으로 변환되어 사용 (java server pages)
      • 멀티 쓰레드 기능 지원
  3. 동시 요청과 멀티 쓰레드
    • 웹 요청 ↔ WAS 응답(servlet 객체를 누가 호출함? -> 쓰레드)
    • 애플리케이션 코드를 순차적으로 실행하는 것 = 쓰레드 (java main메소드 실행 -> main이라는 쓰레드 실행)
    • 쓰레드는 한 번에 하나 코드만 실행 → 동시 처리 필요 → 다중 쓰레드 필요
    • was 응답 ( 쓰레드 할당 → 서블릿 호출 → 응답 → 쓰레드 휴식)
    • 요청 여러개 → 요청마다 쓰레드 새로 생성하면?
      • 쓰레드 생성하는 시간 발생
      • 컨텍스트 스위칭 비용 발생
      • CPU, 메모리 과부화 가능
    • 보통 내부에 쓰레드 풀(개수 제한) 만들어놓고 운영함 ( 톰캣은 기본 200개 ← 개수가 나중에 주요 튜닝 포인트)
    • 쓰레드 풀 이상 요청시 → 대기 또는 거절
    • 어플리케이션 시작할때 미리 쓰레드 생성해놓음 → 생성 비용 없음, 응답 속도 굳, 요청 많아도 기존 요청 처리 가능
    • 쓰레드 적정 숫자 어떻게? → 성능 테스트 해봐야함. 아파치 ab, 제이미터, nGrinder 등등
    • 멀티 쓰레드 상황에서 싱글톤 객체 어떻게 사용됨
      • 멀티 스레드 → 메모리는 공유하는 상황임 → 객체 하나 메모리에 생성해놓고 호출하게 하는것
      • 그래서 공유 변수 없도록 항상 주의해야함(상태가 없어야한다, stateless 하도록)
  4. Http Request, Response 기본 지식
    • 요청시 데이터 보내는 방법
      • GET, 쿼리 파라미터
      • POST, Html Form (contentType = application/x-www-form-urlencoded) → 바디에 쿼리 파라미터형식으로 전달
      • Http 바디에 데이터 직접 담아서 제공 (JSON, XML, Text 등등)
    • 응답시 데이터 보내는 방법
      • 단순 텍스트 응답 (contentType = text/plain)
      • HTML 응답 (contentType = text/html)
      • Http body 응답(contentType = application/json... )

사지방에서 goorm ide로 개발을 하다보니 생기는 문제점이 몇가지 있다.

 

1) 빌드 속도가 매우 느림

2) 대부분 라이브러리 설치, 설정 방법이 대부분 인텔리제이 기준으로 올라와있음

3) 개별 테스트 불가능, 테스트 실행 속도가 매우 느림

 

이 과정에서 개별 테스트 및 테스트 속도 상승을 위해서 어떻게 해야할까 고민하던 중

통합 테스트는 포기하고, 단위 테스트를 진행하기로 했다.

 

1) Controller 테스트 

@RunWith(SpringRunner.class)
@WebMvcTest(UserController.class)
public class UserControllerTest{
	@Autowired
	MockMvc mvc;
    
	@MockBean
	private UserService userService;
    
	@MockBean
	private UserRepository userRepository;
    
	@Test
	public void 유저_리스트_출력() throws Exception {
		//given
		List<User> userList = new ArrayList<>();
		userList.add(new User("test1", "test1234", "kim"));
		userList.add(new User("test2", "test1234", "park"));
		userList.add(new User("test3", "test1234", "kim"));
        
		Pageable pageable = PageRequest.of(0,20);
        
		Page<User> pageResult = new PageImpl<>(userList, pageable, 3);
        
		given(userService.findAllBySearch(any(Pageable.class),any(),any())).willReturn(pageResult);
        
		//when
		mvc.perform(get("/users"))
		//then
		.andExpect(status().isOk())
		.andExpect(jsonPath("$.data[0].accountId").value("test1"))
		.andExpect(jsonPath("$.data[1].accountId").value("test2"))
		.andExpect(jsonPath("$.data[2].accountId").value("test3"));
	}
}

WebmvcTest를 사용하기로 했다.

Junit4에선 @RunWith이 필요하다고 해서 추가했다.

 

service 단에서의 응답을 @MockBean과 given을 이용해서 대체했다.

 

2)Service 테스트

@RunWith(MockitoJUnitRunner.class)
public class UserServiceTest{
    
    @Mock
    private UserRepository userRepository;
    
    @InjectMocks
    private UserServiceImpl userService;
    
    @Test
    public void 유저_단건_조회(){
        //given
        User user = new User("test1", "test1234", "kim");
        given(userRepository.findById(any(Long.class))).willReturn(Optional.of(user));
        Long userId = 1L;
        
        //when
        User result = userService.findOne(userId);
        
        //then
        assertThat(result.getAccountId()).isEqualTo("test1");
    }
}

service에서는 Mockito를 사용했다.

@Mock을 이용해 가짜 repository를 만들고

@InjectMocks를 이용해 userService 객체에 만들어놓은 Mock 객체를 주입했다.

 

이때 스프링을 이용해서 하는 테스트가 아니기 때문에 service가 인터페이스라면 작동하지 않는다.

실제 구현되는 serviceImpl 객체를 이용해서 테스트해야한다.

 

마찬가지로 given을 이용해 repository의 응답을 대체했다.

 

3) Repository 테스트

@RunWith(SpringRunner.class)
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Import(AppConfig.class)
public class UserRepositoryTest{
    
    
    @Autowired
    private UserRepository userRepository;
    
    @Test //유저 생성
    public void test_save(){
        //given
        User user = new User("test1", "test1234!", "kim");
        //when
        User saveUser = userRepository.save(user);
        
        //then
        assertThat(saveUser.getAccountId()).isEqualTo("test1");
    }
 }

DataJpaTest를 사용했다.

이때 Auditing, queryDSL등을 활용하기 위해 AppConfig 클래스를 따로 만들어줬으므로

테스트 시에도 활용하기 위해 @Import 를 통해 불러와줘야한다.

또 테스트용 DB설정을 따로 하지 않았으므로

@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)를 사용해준다.

 

근데 테스트용 Replace.NONE는 미리 설정해놓은 local DB를 그대로 사용하는데

테스트용 데이터와 기존 데이터가 겹치는 문제가 생길 수 있다.

 

그래서 mysql에서 테스트용 db를 새로 만들고 test 폴더 안에 application.yml 파일을 새로 만들어서 설정해줬다.

 

그 외 겪었던 오류와 해결법

 

queryDSL 설정법

build.gradle 에 queryDSL관련 설정 추가 (src/main/generated 폴더 안으로 Q객체 생성되도록 설정)

// querydsl 추가
ext.querydslVersion = "4.4.0"

def querydslSrcDir = 'src/main/generated'
clean {
    delete file(querydslSrcDir)
}
tasks.withType(JavaCompile) {
    options.generatedSourceOutputDirectory = file(querydslSrcDir)
}
sourceSets {
    main.java.srcDir querydslSrcDir
}


dependencies {
    //querydsl 추가
    implementation "com.querydsl:querydsl-jpa:${querydslVersion}"
    annotationProcessor(
        "jakarta.persistence:jakarta.persistence-api",
        "jakarta.annotation:jakarta.annotation-api",
        "com.querydsl:querydsl-apt:${querydslVersion}:jpa"
    )
}

AppConfig 파일 추가 (메인클래스 위치에)

package project;
import com.querydsl.jpa.impl.JPAQueryFactory;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import javax.persistence.PersistenceContext;

import javax.persistence.EntityManager;

@Configuration
@RequiredArgsConstructor
public class AppConfig {
    
    @PersistenceContext
    private final EntityManager em;

    @Bean
    public JPAQueryFactory qf() {
        return new JPAQueryFactory(em);
    }

}

EntityManger은 왜 설정해줬는지 기억이 안나는데

아마 respository 테스트 할때 영속성 컨텍스트 주입을 위해서였던 것 같다.

 

 

Swagger 설정

application.yml에 설정 추가 (packages-to-scan에 스캔할 컨트롤러가 있는 패키지 입력)

# Swagger springdoc-ui Configuration
springdoc:
  packages-to-scan: project.controller
  default-consumes-media-type: application/json;charset=UTF-8
  default-produces-media-type: application/json;charset=UTF-8
  swagger-ui:
    path: swagger-ui.html
    disable-swagger-default-url: true
    display-request-duration: true
    operations-sorter: alpha

 

OpenApiConfig 생성 혹은 AppConfig에 설정 추가

import io.swagger.v3.oas.models.Components;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Info;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;

/**
 * Swagger springdoc-ui 구성 파일
 */
@Configuration
@EnableJpaAuditing
public class OpenApiConfig {
    @Bean
    public OpenAPI openAPI() {
        Info info = new Info()
                .title("도마잎 API Document")
                .version("v0.0.1")
                .description("API 명세서입니다.");
        return new OpenAPI()
                .components(new Components())
                .info(info);
    }
}

 

goorm ide 링크가 https라서 문제가 발생하는 경우

Application.java 에 url 설정 추가

@OpenAPIDefinition(servers = {@Server(url = "https로 시작하는 url", description = "Default url")})

 

+ Recent posts