스프링 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")})

 

예전에 입대하기 직전에 장고로 만들었던

todo 리스트?, 개발일지 작성,관리 앱?  개발 일정 관리 캘린더앱?

뭔지 모르겠는데 아무튼 그 프로젝트를 Spring으로 다시 만들어보기로 했다.

만들다 보니 재밌었던 부분, 새롭게 배운점, 고민점들이 많이 생겨서 여기에 적어보기로 했다.

 

사실 만들면서 정리해야 했던 글인데 귀찮아서 미루고 미루다가 대충 마무리 하고 적게 됐다.

이 글을 읽는 분들이 알고 있는 점이나 비슷한 경험, 좋은 아이디어가 있다면 댓글 달아주면 많은 도움이 될 것 같다.

 

1. 주요 기능 설계

  1. 유저 회원가입, 로그인 기능
  2. 유저들끼리 모여서 스터디를 구상할 수 있는 워크스페이스 기능
  3. 워크스페이스 내에서 일정을 추가하고 참여할 수 있는 기능
  4. 일정 내에서 본인의 코멘트(개발일지)를 작성할 수 있는 기능

 

2. 엔티티 설계

보다시피 유저와 워크스페이스, 유저와 스케줄은 N:M의 관계이다.

그리고 사진엔 안나와있지만 워크스페이스:스케줄은 1:N 관계이며

유저,스케줄과 개발일지 역시 1:N의 관계이다.

 

1) pk값으로 뭘 사용하는 것이 좋을까?

pk값을 뭘로 할지 고민을 많이 해봤다.

필드마다 unique 한 특성을 많이 가지는 컬럼이 많다.

User은 로그인시 사용하는 id인 accountId

Workspace는 이름인 name 등등..

 

결론은 name, accountId등은 나중에 바뀔 가능성이 존재하므로

pk는 그냥 increment PK인 id를 만든 후 사용하기로 했다.

 

나중에 DB를 변경하거나 분산할 일이 존재하면 UUID로 하는 방법도 있겠지만,

이번 프로젝트에선 그럴 일이 절대 없으므로 그냥 increment PK를 쓰기로 했다. 

 

2) 비밀번호를 DB에 그대로 저장해도 될까?

당연히 안된다.

이번 프로젝트는 토이 프로젝트라 귀찮아서 그냥 varchar을 사용하고 넘어갔는데,

좀 더 규모가 커지거나 실제로 배포할 프로젝트라면 당연히 문제가 될 것이다.

 

정확히는 DB가 유출될 경우 피해가 될 수 있는 개인정보(이름, 이메일, 비밀번호)등은 암호화해서 저장해야한다.

이 부분은 나중에 공부를 더 한 후 추가해봐야겠다.

 

 

3. api 명세 설계 - N vs M 관계에서 rest api를 설계하는 방법

1) 프론트 없이 api 설계하기?

예전에 만들었던 api 명세를 조금 수정하려 했는데, 생각해보니까 이 프로젝트 프론트랑 제대로 된 피그마가 없다.

그냥 api만 만들면 되니까, 상상속의 프론트와 피그마로 api를 만들려고 했는데 정말 잘못된 생각이었다.

사용자 화면에 맞춰서 api를 설계하는 것이 기본인데, 사용자 화면이 명확하게 정의되어 있지 않으니까

api 명세도 계속해서 바뀌고, 일관성도 없어졌다. 이번 프로젝트에서 내가 한 가장 큰 실수였던 것 같다...

 

2) N vs M (워크스페이스/유저, 스케줄/유저) 관계에서의 url 설계

특정 워크스페이스에 속한 유저를 모두 찾아야 할 때?

/workspaces/1/users

/users?workspaceId=1

 

반대로 특정 유저가 참여하는 워크스페이스를 모두 찾을 때?

/users/2/workspaces

/workspaces?userId=2

 

어떤 구조가 더 좋을까? 에 대해 많이 고민해봤다.

결론은 상황에 맞춰서 쓰자인 것 같다.

프론트에서 구현할 화면에 어떤 정보가 중심인지, 다른 부가 정보는 뭐가 필요한지를 잘 고려해서 선택해야 할 것 같다.

 

둘 다 구현해 놓는 방법도 있고,

workspaces에서 검색 조건이 다양하고 자주 쓰인다 싶으면 후자를

그렇지 않다면 전자를 사용할 것 같다.

혹은 /woskspace/1 같은 url에서 유저 정보를 포함한 상세 정보를 모두 보내는 방법도 있다.

 

물론 그러면 유저 정보 외에 다른 불필요한 정보가 보내지는 단점도 있는데, 이는 restAPI의 어쩔 수 없는 단점인 것 같다.

이부분은 graphQL을 좀 더 공부해봐야 될 것 같다.

 

3) url에 pk값을 그대로 사용해도 될까?

보통 pk값 필드로 auto increment 를 사용할텐데

이러면 사용자가 url에 pk값 앞 뒤로 다른 값을 넣어서 다른 사용자의 데의터를 조회할 수 있다.

그렇다고 api url에 pk 값 대신 다른 값을 넣기엔 마땅한 unique 값이 없어 새로운 필드를 정의해야하고 복잡해진다.

uuid를 pk에 사용하기엔 128bit 라는 uuid 용량 문제와 url이 복잡해진다는 문제가 있다.

 

결론은 그냥 pk값을 그대로 쓰면서 인증 인가를 사용해서 정보 접근에 제한을 두려고 한다.

 

 

4. 구현과정에서 발생한 고민점들

 

1) Dto 매핑전략

https://www.inflearn.com/questions/30618/%EA%B6%81%EA%B8%88%ED%95%A9%EB%8B%88%EB%8B%A4

 

궁금합니다. - 인프런 | 질문 & 답변

안녕하세요 진짜 좋은 강의와 선생님의 답변으로 많이 배우고 있습니다. 강의를 따라 하다보니 몇가지 궁금증이 생겼습니다.1. controller에서 responseEntity 를 안쓰시던데 딱히 이유가 있을까요?2.계

www.inflearn.com

API 를 제작하다 보면 Entity를 직접 반환하면 안되므로 필연적으로 Dto를 사용하게 된다.

View 단계에서 사용하는 Dto를 Controller, Service 중 어느 곳에서 변환하는 것이 좋을까 고민을 많이 해봤다.

 

i) Controller에서 변환하기

장점 : service가 dto에 의존하지 않게 된다. (의존관계 단순화)

dto는 화면 구현에 의존해 api 설계가 변경될 시 쉽게 바뀔 수 있음

dto 세부사항 수정시 service의 코드가 바뀌지 않아도 됨

 

단점 : Entity 생성시 의존관계가 있는 Entity 생성을 controller에서 맡아서 하게 되는데

핵심 비즈니스 로직 이라고 할 수 있는 중복 조회, 연관관계 매핑 등을 Controller 단에서 하게 됨.

이는 Controller가 여러 Service 객체들에 의존하게 만듦.

OSIV 문제도 발생. dto 매핑을 트랜잭션 종려 후 진행하면 Lazy Loding을 처리할 경우 오류가 발생함.

 

ii) Service에서 변환하기

장점 : service가 핵심 비즈니스 로직 (중복 조회, 연관관계 매핑) 등을 모두 담당하고 controller의 코드가 깔끔해짐.

dto 변환을 Service 함수 트랜잭션 내에서 처리하므로 osiv 문제 해결.

 

단점 : service 객체가 화면(dto)에 의존하게 됨.

dto 변경 시 service 코드도 변경해야함.

 

결론은 섞어서 사용하기로 했다.

이게 뭔가 싶겠지만 dto 변환을 controller에서 하기 힘든 가장 힘든 상황이 Entity 생성 시 로직 때문이었다.

그렇다고 조회 등의 모든 상황에서 dto가 service로 보내진다면 service 코드가 의존하는 dto가 너무 많아진다.

 

그러나 생성 시에는 결국 dto가 변경되면 controller, service 혹은 mapper를 사용하면 mapper객체든

연관관계가 있는 다른 객체를 같이 바꾸는 수 밖에 없는 것 같았다.

 

그래서 생성 로직만 service 객체까지 dto를 보내고, 나머지 다른 로직에선 controller에서 변환해서 service에 보내기로 했다.

그리고 나중에 프로젝트 규모가 커지면

그때 osiv를 false로 바꾸고 조회 생성 모두 dto에 의존하는 api 전용 service를 새로 만들고 분리시키는 것이 좋을 것 같다.

 

2) Dto 파일들 관리 방법

영한 센세의 답변이다.

i) 도메인 패키지 안에 포함하기

ii) 별도의 dto 패키지를 만들기 (여러 패키지에서 공유될 시)

iii) 사용되는 repository나 service 계층에 두기

 

나는 여러 패키지에서 공유하지 않는데도 2번을 사용했다.

이게 구조가 제일 깔끔하고 사용하기 편한 것 같다.

대신 dto에서 Entity를 제외한 다른 패키지를 절대 의존하지 않도록 했다.

 

3)Swagger를 꼭 써야할까?

 

그냥 postman 쓰면 안되는 걸까?

라는 생각을 하고 있었는데 프론트로 일하고 있는 친구가 제발 써달라고 했다.

프론트에서 요청과정에서 실수 방지, 결과 테스트가 훨씬 편하다고 했었던 것 같다.

 

springdoc을 이용해서 swagger를 구현하기로 했다.

https://colabear754.tistory.com/99

 

[Spring Boot] Springdoc 라이브러리를 통한 Swagger 적용

목차 기본 환경 IntelliJ Ultimate 2022.3 Spring Boot 2.7.7 Kotlin 1.7.21(JDK 11) Springdoc Openapi UI 1.6.11 Springdoc은 무엇인가? 이전에 Spring Boot 프로젝트에 Swagger UI를 적용하는 포스트를 작성한 적이 있다. 해당 포

colabear754.tistory.com

 

 

//MemberServiceImpl
@Component
public  class MemberServiceImpl implements MemberService{
    private final MemberRepository memberRepository;
    
    public MemberServiceImpl(MemberRepository memberRepository){
        this.memberRepository = memberRepository;
    }
    
    public void join(Member member){
        memberRepository.save(member);
    }
    
    public Member findMember(Long id){
        return memberRepository.findById(id);
    }
}

//MemoryMemberRepository
@Component
public class MemoryMemberRepository implements MemberRepository{
    private static Map<Long, Member> store = new HashMap<> ();
    
    @Override
    public void save(Member member){
        store.put(member.getId(), member);
    }
    @Override
    public Member findById(Long id){
        return store.get(id);
    }
}

다음과 같이 컴포넌트 어노테이션을 이용해 스프링 빈에 자동으로 인스턴스를 등록할 수 있다.

문제는 MemberServiceImpl 객체는 MemberRepository 인터페이스와 의존관계를 가지고 있다.

 

수동 빈 등록에선 AppConfig 파일에서 직접 의존관계를 주입해줬는데 자동 등록에선 어떻게 의존관계를 설정할까??

 

@Autowired - 자동 의존관계 주입

//MemberServiceImpl
@Component
public  class MemberServiceImpl implements MemberService{
    private final MemberRepository memberRepository;
    
    @Autowired
    public MemberServiceImpl(MemberRepository memberRepository){
        this.memberRepository = memberRepository;
    }
    
    public void join(Member member){
        memberRepository.save(member);
    }
    
    public Member findMember(Long id){
        return memberRepository.findById(id);
    }
}

@Autowired는 자동 의존관계 등록 어노테이션이다.

Autowired를 달아놓으면 스프링이 컴포넌트 스캔 후 빈을 등록할 때 자동으로 의존관계를 주입시켜준다.

 

스프링 빈이 컴포넌트 스캔을 할때 생성자에 Autowired 어노테이션이 붙은 인스턴스는

생성자의 파라미터를 보고 같은 타입의 빈을 자동으로 주입해준다.

 

중복 등록과 충돌

@Component
public class MemberA{}

@Component
public class MemberB {}


@Component
public class MemberService{
    private Member member;
    
    @Autowired
    public MemberService(Member member){
    	this.member = member;
    }
}

만약 다음과 같이 같은 클래스 타입의 빈이 두 개 이상이라면 어떻게 될까??

 

NoUniqueBeanDefinitionException발생한다.

 

다음과 같은 오류를 해결하는 방법은

 

1. 이름으로 매칭

public class MemberService{
    private Member memberA;
    
    @Autowired
    public MemberService(Member memberA){

다음과 같이 필드명을 memberA라고 명시해주거나

@Autowired("memberA")
public MemberService(Member member){

어노테이션안에 이름을 입력해준다면 스프링이 이름이 동일한 memberA를 자동으로 주입해준다.

 

2. Qualifier 사용하기

@Component
@Qualifier("A")
public class MemberA{}

@Component
public class MemberB {}


@Component
public class MemberService{
    private Member member;
    
    @Autowired
    public MemberService(Member @Qualifier("A")member){
    	this.member = member;
    }
}

빈 이름이 아닌 추가 구분자를 제공하는 기능이다.

 

3. Primary 사용하기

@Component
@Primary
public class MemberA{}

@Component
public class MemberB {}


@Component
public class MemberService{
    private Member member;
    
    @Autowired
    public MemberService(Member member){
    	this.member = member;
    }
}

빈 등록시 우선순위를 지정해주는 방법이다.

여러개의 빈이 존재할 때 @Primary가 붙어있는 빈을 먼저 주입해준다.

 

 여러개의 빈을 조회해야 할때 - List, Map

예를 들면 방금과 같은 코드에서 서비스가 실행되기 전까지는

MemberA,MemberB 둘 중 어떤 인스턴스 빈을 사용할 지 모르는 상황이 생길 수 있다.

 

@Component
public class MemberA{}

@Component
public class MemberB {}


@Component
public class MemberService{
    private Map<String,Member> memberMap;
    private List<Member> memberList
    
    @Autowired
    public MemberService(Map<String,Member> memberMap, List<Member> memberList ){
    	this.memberMap = memberMap;
        this.memberList = memberList;
    }
}

그럴땐 Map,List를 이용해 여러개의 빈을 받아온 후

사용시점에 동적으로 선택해 사용할 수 있다.

 

자동, 수동의 올바른 실무 운영 기준

  1. 편리한 자동 등록을 주로 사용하자
    1. 최근 스프링 부트는 컴포넌트 스캔을 기본으로 지원함
    2. 관리할 빈이 많아지면 수동 등록은 번거로움
    3. 자동 등록으로도 DIP,OCP 지킬 수 있음
  2. 수동은 언제 쓸까?
    1. 업무 로직 빈
      1. 웹을 지원하는 컨트롤러, 핵심 비즈니스 로직이 있는 서비스, 데이터를 처리하는 리포지토리등이 업무 로직
      2. 비즈니스 요구사항을 개발할 때 추가하거나 수정됨
      3. 숫자가 매우 많음, 보통 문제가 발생하면 찾기 쉬움 → 자동 등록
    2. 기술 지원 빈
      1. 기술적인 문제나 AOP(공통 관심사)를 처리할때 주로 사용됨
      2. 데이터 베이스 연결, 공통 로직 처리
      3. 수가 적음, 그러나 광범위한 영향, 어디서 문제인지 찾기 힘듦 → 수동 등록
      4. 수동 등록 후 설정 정보에 바로 나오도록 하는게 유지보수 쉬움

 

 

 

+ Recent posts