안녕하세요

일단 저는 Java 17이 나온 이후로는 Java 버전을 17로 설정하여 프로젝트를 만들고 있습니다. (LTS 버전이기도 하고 Java 17의 stream 메소드 toList()가 너무 편리하더라고요?ㅎㅎ collect(Collectors.toList()) 안 쓰고 저거 쓰니까 아주 신세계...👍)

그런데 매번 오류가 났습니다. 왜냐하면 제 컴퓨터에는 JDK 11이 기본으로 설정되어 있거든요.. (환경변수 설정만 바꿔주면 되는데 말이죠...... 그게 귀찮아서.........)

 

그래서 저는 당연히

'프로젝트를 생성할 때 Java 버전을 17로 만들었는데 내 컴퓨터의 default JDK 버전이 11이니까 당연히 JDK 17이 없다고 오류가 나겠지'

하면서 항상 인텔리제이의 Settings에 들어가서 JDK 17로 바꿔주고 실행을 했었습니다.

 

그런데 얼마 전 블로그를 작성하다가, 예제 코드를 깃허브에 올려두려고 프로젝트를 다시 만들었습니다.

제 블로그를 읽으시는 분들이 JDK 17이 설치가 안 되어 있을 수도 있으니 Java 11로 만들어야겠다! 하고 Spring Boot 3.0.4, Java 11로 프로젝트를 만들었습니다.

 

그런데 여전히 똑같은 오류가 나는 것입니다!!!

아니 Java 11로 프로젝트를 만들었고 내 컴퓨터에 설치된 JDK도 11 버전인데 왜 오류가 나는거지??

 

검색을 해보니 Spring Boot 3.0부터는 Java 17 이상만 지원한다고 합니다.

 

이 사실을 몰랐을 때부터 매번 무심코 해줬던 설정인데,

Spring Boot 3.0 이상부터는 Java 17부터만 된다는 사실을 알게 된 기념(?)으로

같은 문제로 어려움을 겪고 계신 분들께 조금이나마 도움이 되기를 바라며 제가 해결한 방법을 공유하고자 합니다.

✅ 실행 환경

일단 저의 환경을 공유해드리자면 시스템의 환경변수로 등록되어 있는 것은 JDK 11입니다.

 

하지만 제 컴퓨터에는 JDK 8, JDK 11, JDK 17이 설치되어 있습니다.

스프링 부트 버전은 3.0.4입니다. 

✅ 문제

스프링 부트 3.0 이상의 프로젝트를 열었더니 이런 오류가 발생합니다.

A problem occurred configuring root project '프로젝트명'.
> Could not resolve all files for configuration ':classpath'.
   > Could not resolve org.springframework.boot:spring-boot-gradle-plugin:3.0.4.
     Required by:
         project : > org.springframework.boot:org.springframework.boot.gradle.plugin:3.0.4
      > No matching variant of org.springframework.boot:spring-boot-gradle-plugin:3.0.4 was found. The consumer was configured to find a runtime of a library compatible with Java 11, packaged as a jar, and its dependencies declared externally, as well as attribute 'org.gradle.plugin.api-version' with value '7.6.1' but:
          - Variant 'apiElements' capability org.springframework.boot:spring-boot-gradle-plugin:3.0.4 declares a library, packaged as a jar, and its dependencies declared externally:
              - Incompatible because this component declares an API of a component compatible with Java 17 and the consumer needed a runtime of a component compatible with Java 11
              - Other compatible attribute:
                  - Doesn't say anything about org.gradle.plugin.api-version (required '7.6.1')
          - Variant 'javadocElements' capability org.springframework.boot:spring-boot-gradle-plugin:3.0.4 declares a runtime of a component, and its dependencies declared externally:
              - Incompatible because this component declares documentation and the consumer needed a library
              - Other compatible attributes:
                  - Doesn't say anything about its target Java version (required compatibility with Java 11)
                  - Doesn't say anything about its elements (required them packaged as a jar)
                  - Doesn't say anything about org.gradle.plugin.api-version (required '7.6.1')
          - Variant 'mavenOptionalApiElements' capability org.springframework.boot:spring-boot-gradle-plugin-maven-optional:3.0.4 declares a library, packaged as a jar, and its dependencies declared externally:
              - Incompatible because this component declares an API of a component compatible with Java 17 and the consumer needed a runtime of a component compatible with Java 11
              - Other compatible attribute:
                  - Doesn't say anything about org.gradle.plugin.api-version (required '7.6.1')
          - Variant 'mavenOptionalRuntimeElements' capability org.springframework.boot:spring-boot-gradle-plugin-maven-optional:3.0.4 declares a runtime of a library, packaged as a jar, and its dependencies declared externally:
              - Incompatible because this component declares a component compatible with Java 17 and the consumer needed a component compatible with Java 11
              - Other compatible attribute:
                  - Doesn't say anything about org.gradle.plugin.api-version (required '7.6.1')
          - Variant 'runtimeElements' capability org.springframework.boot:spring-boot-gradle-plugin:3.0.4 declares a runtime of a library, packaged as a jar, and its dependencies declared externally:
              - Incompatible because this component declares a component compatible with Java 17 and the consumer needed a component compatible with Java 11
              - Other compatible attribute:
                  - Doesn't say anything about org.gradle.plugin.api-version (required '7.6.1')
          - Variant 'sourcesElements' capability org.springframework.boot:spring-boot-gradle-plugin:3.0.4 declares a runtime of a component, and its dependencies declared externally:
              - Incompatible because this component declares documentation and the consumer needed a library
              - Other compatible attributes:
                  - Doesn't say anything about its target Java version (required compatibility with Java 11)
                  - Doesn't say anything about its elements (required them packaged as a jar)
                  - Doesn't say anything about org.gradle.plugin.api-version (required '7.6.1')

* Try:
> Run with --info or --debug option to get more log output.
> Run with --scan to get full insights.

 

✅ 해결 방법

1. Settings에 들어갑니다.(MacOS이신 분들은 Preferences)

2. Build, Execution, Deployment > Build Tools > Gradle에 들어갑니다.

JDK 11이 적용되어 있는 것을 볼 수 있습니다.

 

3. JDK 17로 변경

17 버전의 JDK를 선택한 후 OK 또는 Apply 버튼을 누릅니다.

4. Gradle Reload를 해줍니다.

코끼리 모양의 버튼 또는 오른쪽 Gradle 탭의 Reload 버튼을 눌러 Refresh 해줍니다.

5. 빌드 성공

 

참고

https://jojoldu.tistory.com/698

안녕하세요

얼마 전 프로젝트를 진행하던 중 request로 받은 파라미터들이 @ModelAttribute 객체에 바인딩이 되지 않는 이슈를 겪고 @ModelAttribute에 대해 자세히 알아보게 되었습니다.

왜 그런 것이고 어떻게 해결해야 하는지 알아보겠습니다.

 

먼저 직접 DTO와 컨트롤러를 만들어 테스트해보겠습니다.

전체 프로젝트 코드는 여기에서 보실 수 있습니다.

🥑 NoArgsAndAllArgsDto

매개변수가 없는 생성자와, 모든 필드가 다 매개변수로 있는 생성자 이렇게 2개의 생성자가 있는 DTO 클래스입니다.

@Getter
public class NoArgsAndAllArgsDto {

    private String nickname;

    private Integer count;

    public NoArgsAndAllArgsDto() {
    }

    public NoArgsAndAllArgsDto(String nickname, Integer count) {
        this.nickname = nickname;
        this.count = count;
    }
}

🥑AllArgsDto

모든 필드가 다 매개변수로 있는 생성자만 있는 DTO 클래스입니다.

@Getter
public class AllArgsDto {

    private String nickname;

    private Integer count;

    public AllArgsDto(String nickname, Integer count) {
        this.nickname = nickname;
        this.count = count;
    }

    public void setNickname(String nickname) {
        this.nickname = nickname;
    }

    public void setCount(Integer count) {
        this.count = count;
    }
}

🥑NoArgsAndSetterDto

매개변수가 없는 생성자와 각각의 필드를 세팅할 수 있는 Setter가이 있는 DTO 클래스입니다.

@Getter
public class NoArgsAndSetterDto {

    private String nickname;

    private Integer count;

    public NoArgsAndSetterDto() {
    }

    public void setNickname(String nickname) {
        this.nickname = nickname;
    }

    public void setCount(Integer count) {
        this.count = count;
    }
}

🥑 TestController.java

테스트를 하기 위한 컨트롤러입니다.

@RestController
@RequestMapping("/test")
public class ModelAttributeTestController {

    @PostMapping("/no-args-and-all-args")
    public NoArgsAndAllArgsDto test1(@ModelAttribute NoArgsAndAllArgsDto requestDto) {
        return requestDto;
    }

    @PostMapping("/all-args-only")
    public AllArgsDto test2(@ModelAttribute AllArgsDto requestDto) {
        return requestDto;
    }

    @PostMapping("/no-args-and-setter")
    public NoArgsAndSetterDto test3(@ModelAttribute NoArgsAndSetterDto requestDto) {
        return requestDto;
    }

}

 

이제 테스트를 해봅시다.

🥑 NoArgsConstructor와 AllArgsConstructor가 있을 때

@Test
@DisplayName("NoArgsConstructor와 AllArgsConstructor가 있을 때 값이 할당이 잘 되는지 테스트")
void test1() throws Exception {
    NoArgsAndAllArgsDto requestDto = new NoArgsAndAllArgsDto("feelcoding", 100);

    mvc.perform(post("/test/no-args-and-all-args")
            .contentType(MediaType.APPLICATION_JSON)
                    .param("nickname", requestDto.getNickname())
                    .param("count", requestDto.getCount().toString()))
            .andExpect(status().isOk())
            .andExpect(content().json(objectMapper.writeValueAsString(requestDto)));

}

테스트 코드입니다.

테스트에 실패했습니다.

nickname 필드와 count 필드 모두 값이 할당되지 않고 null인 것을 볼 수 있습니다.

 

🥑 AllArgsConstructor만 있을 때

@Test
@DisplayName("AllArgsConstructor만 있을 때 값이 할당이 잘 되는지 테스트")
void test2() throws Exception {
    AllArgsDto requestDto = new AllArgsDto("feelcoding", 100);

    mvc.perform(post("/test/all-args-only")
                    .contentType(MediaType.APPLICATION_JSON)
                    .param("nickname", requestDto.getNickname())
                    .param("count", requestDto.getCount().toString()))
            .andExpect(status().isOk())
            .andExpect(content().json(objectMapper.writeValueAsString(requestDto)))
            .andDo(print());
}

테스트 코드입니다.

테스트에 성공했습니다.

 

🥑 NoArgsConstructor와 Setter가 있을 때

@Test
@DisplayName("NoArgsConstructor와 Setter가 있을 때 값이 할당이 잘 되는지 테스트")
void test3() throws Exception {
    NoArgsAndSetterDto requestDto = new NoArgsAndSetterDto();
    requestDto.setNickname("feelcoding");
    requestDto.setCount(100);

    mvc.perform(post("/test/no-args-and-setter")
                    .contentType(MediaType.APPLICATION_JSON)
                    .param("nickname", requestDto.getNickname())
                    .param("count", requestDto.getCount().toString()))
            .andExpect(status().isOk())
            .andExpect(content().json(objectMapper.writeValueAsString(requestDto)))
            .andDo(print());
}

테스트 코드입니다.

테스트에 성공했습니다.

 

✅ 테스트 결과

  • AllArgsConstructor만 있을 때는 분명히 값이 잘 할당되었지만 NoArgsConstructor와 AllArgsConstructor가 모두 있으면 값이 할당되지 않았습니다.
  • NoArgsConstructorSetter가 있을 때는 할당이 잘 되었습니다.

 

왜 그런 것일까요? 🤔

 

그것은 @ModelAttrubute가 어떻게 동작하는지 내부 구조를 살펴보면 알 수 있습니다.

 

🥑 @ModelAttribute의 동작 원리를 알아보자

전체 검색 (Windows는 ctrl + shift + f, MacOS는 cmd + shift + f)으로 ModelAttribute.class를 검색하고 Scope All Place로 해주면 ModelAttributeMethodProcessor.java 파일이 보입니다.

이 파일에 한 번 들어가보겠습니다.

ModelAttributeMethodProcessor

HandlerMethodArgumentResolver를 구현한 클래스네요. (HandlerMethodArgumentResolver에 대해서는 이전 글을 참고해주세요)

request를 어떻게 가공해서 Controller의 매개변수로 넣어줬는지 resolveArgument() 메소드를 확인해봅시다.

핵심 로직을 살펴보면 createAttribute()라는 메소드를 호출하여 attrubute에 할당한 후 attribute를 리턴하는 것을 볼 수 있습니다

그렇다면 createAttribute() 메소드에 가봅시다.

Reflection을 사용해서 해당 매개변수가 어느 클래스 타입인지 알아내고 해당 클래스를 getResolvableConstructor() 메소드의 인자로 넘겨 생성자를 받습니다.

그 생성자로 객체를 만들고 리턴하는 것을 볼 수 있습니다.

그러면 아마 범인은 저 getResolvableConstructor() 메소드일 것 같습니다. 해당 메소드로 가봅시다.

저희가 테스트했던 NoArgsAndAllArgsConstructor는 생성자가 2개이기 때문에 위의 if문과 else if문은 타지 않을 것입니다.

그렇다면 try 문을 실행하게 될텐데요, 위에 주석으로 '생성자가 여러 개면 기본 생성자로 하자' 라고 써있습니다.

(사실 엄격하게 말하면 제가 만든 생성자는 기본 생성자가 아닙니다. 단지 매개변수 없는 생성자입니다. 기본 생성자는 제가 아무런 생성자도 만들지 않았을 때 자동으로 만들어준 생성자를 말하는 것입니다.)

저 getDeclaredConstructor를 타고 들어가보면 너무 복잡하기 때문에 내부 구조를 파헤치는 것은 여기까지 하는 것으로 합시다..ㅎㅎ

(catch문에 Giving up... 이라고 써있는 거 너무 웃기네요..ㅎㅎ)

 

✅ 디버거를 통해 확인

디버거를 통해 정말 해당 로직을 타는지 확인해봅시다.

NoArgsAndAllArgsDto의 생성자가 2개라서 if와 else if 문을 안 타고 try문으로 오는 것을 확인할 수 있습니다.

getResolvableConstructor() 메소드 호출로 리턴 받은 생성자가 매개변수 없는 생성자임을 알 수 있다

getResolvableConstructor()의 호출하여 얻은 ctor가 매개변수 없는 생성자 NoArgsAndAllArgsDto()인 것을 볼 수 있습니다.

그러니까 AllArgsConstructor가 있는데도 NoArgsConstructor로 객체를 생성하고, Setter가 없어서 값을 세팅하지 못 한 것입니다.

 

✅ 정리

  • @ModelAttribute는 생성자가 1개면 그 생성자를 통해 객체를 생성합니다.
  • 생성자가 2개 이상이면 매개변수 없는 생성자를 통해 객체를 생성하고 Setter로 값을 세팅합니다.

✅ 결론

@ModelAttribute로 바인딩하려는 클래스에 NoArgsConstructor가 있다면 반드시 Setter를 만들어주세요!

아니면 NoArgsConstructor를 없애고 AllArgsConstructor만 두세요!

 

 

참고

https://steady-coding.tistory.com/489

안녕하세요
오늘은 Spring에서 제공하는 정말 편리한 기능인 HandlerMethodArgumentResolver에 대해 알아보겠습니다.
HandlerMethodArgumentResolver는 무엇이고 어떤 경우에 사용하는 것이 좋을까요?
예제를 보며 함께 알아보는 시간을 가져봅시다.

❓ 대부분의 API에 필요한 공통적인 로직이 있다면 어떻게 처리하는 것이 좋을까?

공통적인 로직이 있다면 스프링에서는 interceptor, filter, AOP 등 다양한 방법으로 처리할 수 있습니다.
하지만 공통적인 로직을 처리하여 어떤 결과값을 컨트롤러에 넘겨줘야 한다면 어떻게 하는 것이 좋을까요?
 
예를 들어, 클라이언트 측에서 header에 토큰을 담아서 request를 보내는 경우가 있다고 가정해봅시다.
그리고 아래와 같이 거의 모든 API에서 해당 요청을 보낸 사용자가 누구인지 사용자 정보가 필요합니다.

package com.feelcoding.argumentresolverdemo.controller;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequiredArgsConstructor
@Slf4j
public class TestController {

    @GetMapping("/test1")
    public void test1(@RequestHeader String token) {
        // 이 API에서는 사용자 정보가 필요합니다!
    }

    @PostMapping("/test2")
    public void test2(@RequestHeader String token) {
        // 이 API에서도 사용자 정보가 필요합니다!
    }

    @GetMapping("/test3")
    public void test3(@RequestHeader String token) {
        // 이 API에서도 사용자 정보가 필요합니다!
    }

    @GetMapping("/test4")
    public void test4(@RequestHeader String token) {
        // 이 API에서도 사용자 정보가 필요합니다!
    }
}

 
이 경우, 사용자의 정보를 알아내는 코드가 여러 곳에서 중복될테니 토큰으로부터 사용자 정보를 알아내 User 객체를 리턴해주는 메소드를 따로 뽑는 것이 나을 것 같습니다.
 

🤨 중복되는 로직을 메소드로 만들어보자

public User getLoginUser(String token) {
    return userRepository.findByEmail(token).orElseThrow();
}

토큰으로부터 사용자 정보를 알아내서 User 객체를 리턴하는 메소드를 이렇게 만들었습니다!
(원래는 토큰을 파싱해서 사용자 정보를 알아내야 하는데 여기에서 토큰에 대한 설명까지 하면 토큰에 대한 글이 될 것 같아 토큰 부분에 이메일을 헤더에 담아 보내는 것으로 했습니다😅)
 

package com.feelcoding.argumentresolverdemo.controller;

import com.feelcoding.argumentresolverdemo.User;
import com.feelcoding.argumentresolverdemo.dto.SignUpRequestDto;
import com.feelcoding.argumentresolverdemo.dto.SignUpResponseDto;
import com.feelcoding.argumentresolverdemo.service.AuthService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequiredArgsConstructor
@Slf4j
public class TestController {

    private final AuthService authService;

    @GetMapping("/test1")
    public void test1(@RequestHeader String token) {
        User user = authService.getLoginUser(token);
        // 어쩌구 저쩌구
    }

    @PostMapping("/test2")
    public void test2(@RequestHeader String token) {
        User user = authService.getLoginUser(token);
        // 어쩌구 저쩌구
    }

    @GetMapping("/test3")
    public void test3(@RequestHeader String token) {
        User user = authService.getLoginUser(token);
        // 어쩌구 저쩌구
    }

    @GetMapping("/test4")
    public void test4(@RequestHeader String token) {
        User user = authService.getLoginUser(token);
        // 어쩌구 저쩌구
    }

}

메소드로 따로 뺐기 때문에 그냥 중복되는 코드를 여러 번 작성하는 것보다는 낫겠지만,
사용자 정보가 필요한 컨트롤러마다 이렇게 코드가 반복되어 들어갔습니다.
 

🤔 @LoginUser 어노테이션만 달면 컨트롤러 메소드에 User 객체를 넣어주면 얼마나 좋을까?

헤더에 있는 토큰을 읽어서 User 객체를 반환해주는 이런 공통적인 부분을 어딘가에서 처리해서 컨트롤러의 매개변수로 User 객체를 짠! 하고 넘겨주면 얼마나 좋을까요? 이렇게 말입니다.

@GetMapping("/test1")
public void test1(@LoginUser User user) {
    // 어쩌구 저쩌구
}

@PostMapping("/test2")
public void test2(@LoginUser User user) {
    // 어쩌구 저쩌구
}

@GetMapping("/test3")
public void test3(@LoginUser User user) {
    // 어쩌구 저쩌구
}

@GetMapping("/test4")
public void test4(@LoginUser User user) {
    // 어쩌구 저쩌구
}

 
 
HandlerMethodArgumentResolver를 사용하면 이것이 가능해집니다!
 

❗ HandlerMethodArgumentResolver를 사용하여 사용자 정보를 쉽게 받아오자

위와 같이 @LoginUser와 같은 어노테이션 하나로 컨트롤러에서 쉽게 User 객체를 얻어오려면 세 가지를 해야 합니다.

  1. @LoginUser 어노테이션을 만들어줍니다.
  2. HandlerMethodArgumentResolver를 구현한 클래스를 만들어야 하고
  3. 이렇게 구현한 HandlerMethodArgumentResolver를 등록해주어야 합니다.

하나씩 해볼까요?
 

1️⃣ LoginUser 어노테이션을 만들어줍니다.

 
LoginUser라는 어노테이션 이름은 제가 임의로 정한 것이고 원하는대로 변경하셔서 사용하시면 됩니다.

package com.feelcoding.argumentresolverdemo.util;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface LoginUser {
}

어노테이션을 만들 때는 interface 앞에 @를 붙이면 됩니다.
@Target(ElementType.PARAMETER)은 해당 어노테이션이 매개변수에 쓰일 것이기 때문에 PARAMETER로 해주었습니다.
@Retention(RetentionPolicy.RUNTIME)은 해당 어노테이션이 런타임까지도 쓰일 것이기 때문에 RUNTIME으로 해주었습니다.
 

2️⃣ HandlerMethodArgumentResolver를 구현한 클래스를 만들어줍니다.

 
HandlerMethodArumentResolver를 구현한 클래스를 만들면 메소드를 구현하라고 이렇게 에러가 뜹니다.

이제 두 메소드를 구현해봅시다.

✅ supportsParameter

Whether the given method parameter is supported by this resolver.

supportsParameter 메소드는 해당 메소드의 매개변수가 해당 resolver가 지원하는지를 체크하는 것입니다.
true를 반환하면 지원한다는 것이고 false를 반환하면 지원하지 않는다는 것입니다.

@Override
public boolean supportsParameter(MethodParameter parameter) {
    boolean hasLoginUserAnnotation = parameter.hasParameterAnnotation(LoginUser.class);
    boolean isUserType = User.class.isAssignableFrom(parameter.getParameterType());
    return hasLoginUserAnnotation && isUserType;
}

저는 매개변수에 LoginUser라는 어노테이션이 있고 해당 매개변수의 타입이 User 타입인지를 체크해주었습니다.
 

✅ resolveArgument

Resolves a method parameter into an argument value from a given request.

resolveArgument 메소드는 매개변수로 넣어줄 값을 제공하는 메소드입니다.
저희에게는 User 객체가 되겠죠?
User 객체를 반환하는 resolveArgumentParemeter 메소드를 구현해봅시다.

@Override
public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
    HttpServletRequest request = (HttpServletRequest) webRequest.getNativeRequest();
    String token = request.getHeader("Authorization");
    if (token == null) {
        return null;
    }
    return authService.getLoginUser(token);
}

request의 헤더에서 토큰을 꺼내고 토큰을 통해 얻은 User 객체를 반환하였습니다.

returns: the resolved argument value, or null if not resolvable

공식 문서의 resolveArgument 메소드에 대한 설명을 보면 이렇게 resolve할 수 없으면 null을 반환하라고 나와있습니다.

따라서 토큰이 없다면 null을 반환했습니다.

 

아래는 LoginUserArgumentResolver 전체 코드입니다.

package com.feelcoding.argumentresolverdemo.util;

import com.feelcoding.argumentresolverdemo.User;
import com.feelcoding.argumentresolverdemo.service.AuthService;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.core.MethodParameter;
import org.springframework.web.bind.support.WebDataBinderFactory;
import org.springframework.web.context.request.NativeWebRequest;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.method.support.ModelAndViewContainer;

@Component
@RequiredArgsConstructor
public class LoginUserArgumentResolver implements HandlerMethodArgumentResolver {

    private final AuthService authService;

    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        boolean hasLoginUserAnnotation = parameter.hasParameterAnnotation(LoginUser.class);
        boolean isUserType = User.class.isAssignableFrom(parameter.getParameterType());
        return hasLoginUserAnnotation && isUserType;
    }

    @Override
    public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
        HttpServletRequest request = (HttpServletRequest) webRequest.getNativeRequest();
        String token = request.getHeader("Authorization");
        if (token == null) {
            return null;
        }
        return authService.getLoginUser(token);
    }
}

LoginUserArgumentResolver를 @Component를 이용하여 빈으로 등록해준 이유는 뒤에서 설명하겠습니다.
 

3️⃣ 구현한 HandlerMethodArgumentResolver를 등록해줍니다.

 
이제 저희가 만든 LoginUserArgumentResolver를 등록해봅시다.
HandlerMethodArgumentResolver 등록을 위해서는 WebMvcConfigurer를 구현한 클래스가 필요합니다.
(저는 WebConfig라는 이름으로 클래스를 만들었습니다.)
그리고 addArgumentResolvers()라는 메소드를 구현해야 합니다.
이 메소드에서 아까 저희가 만든 LoginUserArgumentResolver를 추가해주시면 됩니다.

package com.feelcoding.argumentresolverdemo.config;

import com.feelcoding.argumentresolverdemo.util.LoginUserArgumentResolver;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

import java.util.List;

@Configuration
@RequiredArgsConstructor
public class WebConfig implements WebMvcConfigurer {

    private final LoginUserArgumentResolver loginUserArgumentResolver;

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

이렇게 해주시면 됩니다.
아까 LoginUserArgumentResolver를 빈으로 등록한 이유가 바로 여기에서 주입받아 사용하기 위함이었습니다.
 
드디어 완성이 되었습니다.
이제 컨트롤러에서 User 객체를 받아볼까요?
 

🔥컨트롤러에서 현재 로그인한 사용자의 User 객체를 매개변수로 받아보자

 컨트롤러의 매개변수로 User 정보를 잘 받아오는지 확인해봅시다.
 
우선 매개변수에 @LoginUser 어노테이션과 User 매개변수를 추가해줍니다.
그리고 사용자 정보를 잘 가져오는지 확인하기 위해 로그를 남겨봅시다.

package com.feelcoding.argumentresolverdemo.controller;

import com.feelcoding.argumentresolverdemo.User;
import com.feelcoding.argumentresolverdemo.util.LoginUser;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequiredArgsConstructor
@Slf4j
public class TestController {

    @GetMapping("/test1")
    public void test1(@LoginUser User user) {
        log.debug("여기는 test1, 사용자의 이름은 {}, 이메일은 {}", user.getName(), user.getEmail());
    }

    @PostMapping("/test2")
    public void test2(@LoginUser User user) {
        log.debug("여기는 test2, 사용자의 이름은 {}, 이메일은 {}", user.getName(), user.getEmail());
    }

    @GetMapping("/test3")
    public void test3(@LoginUser User user) {
        log.debug("여기는 test3, 사용자의 이름은 {}, 이메일은 {}", user.getName(), user.getEmail());
    }

    @GetMapping("/test4")
    public void test4(@LoginUser User user) {
        log.debug("여기는 test4, 사용자의 이름은 {}, 이메일은 {}", user.getName(), user.getEmail());
    }

}

 
이제 Postman을 통해 API들을 호출해봅시다.

이렇게 헤더에는 Authorization이라는 이름으로 사용자의 이메일을 넣어주고
GET /test1, POST /test2, GET /test3, GET /test4 API들을 차례로 호출해보겠습니다.
 

이렇게 사용자 정보를 모두 다 잘 가져온 것을 볼 수 있습니다.
 
이렇게 HandlerMethodArgumentResolver를 사용하면 여러 곳에서 사용되는 공통적인 로직을 줄이고, 매개변수로 필요한 정보를 손쉽게 가져올 수 있습니다.
예시를 로그인으로 들었지만, 필요할 때 여러 방법으로 활용하시면 될 것 같습니다.
 
읽어주셔서 감사합니다 :)
 

출처: https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/web/method/support/HandlerMethodArgumentResolver.html

안녕하세요

 

오늘은 logback을 통해 로그를 관리하는 방법을 알아보겠습니다.

목차

- 로그 색상 바꾸기

- 프로필에 따라 로그 레벨 다르게 설정하기

- 로그 파일을 분할해서 저장하기

- JPA SQL을 로그 파일에 남기기

 

 

로그 출력 테스트를 하기 위해서 일단 테스트 컨트롤러를 하나 만들어볼까요?

 

일단 build.gradle에 아래 3개의 의존성을 추가하고

// @RestController에 필요
implementation 'org.springframework.boot:spring-boot-starter-web'
    
// @Slf4j에 필요
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'

 

테스트 컨트롤러를 만들고 다음과 같이 다섯가지 레벨의 로그를 찍어봅시다.

package com.feelcoding.logbackdemo.controller;

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

@RestController
@Slf4j
public class TestController {

    @GetMapping("/test")
    public void test() {
        log.trace("TRACE!!");
        log.debug("DEBUG!!");
        log.info("INFO!!");
        log.warn("WARN!!");
        log.error("ERROR!!");
    }
}

 

이 상태에서 실행을 하면 아래와 같이 로그가 찍히는 것을 볼 수 있습니다.

 

콘솔창을 clear한 후 아까 만들고 로그를 찍어 둔 API를 호출해보면

분명 로그를 5개 찍었는데 trace, debug레벨의 로그는 보이지 않고 info, warn, error레벨의 로그만 출력되는 것을 확인할 수 있습니다.

왜 그런 걸까요?

 

그 이유는 뒤에서 알아보도록 하고, 일단 제가 개발하면서 디버깅용으로 찍은 debug 레벨의 로그를 콘솔에서 보고 싶기 때문에 로그 레벨을 debug로 바꿔보겠습니다.

 

resources 폴더에 logback-spring.xml 파일을 만들고 아래와 같이 설정해줍니다.

<?xml version="1.0" encoding="UTF-8" ?>
<configuration>
    <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
        <encoder>
            <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %5level %logger - %msg%n</pattern>
        </encoder>
    </appender>
    <root level="DEBUG">
        <appender-ref ref="CONSOLE" />
    </root>
</configuration>

"CONSOLE"이라는 이름의 ConsoleAppender를 하나 만들고 기본 세팅(root)을 DEBUG 레벨로 지정하고 해당 appender를 사용한 것입니다.

Appender에는 ConsoleAppender, FileAppender, RollingFileAppender, SMTPAppender, DBAppender 등이 있습니다.

지금 저희가 쓸 ConsoleAppender는 로그를 콘솔에 출력하겠다는 것입니다.

 

이렇게 logback 설정을 해주고 난 뒤 다시 실행을 시켜보면 아까와 달리 debug 레벨의 로그들이 찍히는 것을 볼 수 있습니다.

API를 호출해봐도

이렇게 debug 레벨의 로그가 잘 출력됩니다.

 

그런데 불편한 점이 몇 가지 있습니다.

1. 내 프로젝트의 debug 레벨 로그만 보이는 것이 아니라 다른 프레임워크나 라이브러리에서 찍은 debug 레벨의 로그까지 다 보여서 내 로그를 보기가 힘듭니다.

2. 로그에 색깔이 없어서 보기가 힘들고 안 예쁩니다.

 

이제 이 불편한 점들을 하나씩 해결해볼까요?

 

우선 1번부터 해결해보겠습니다.

다른 라이브러리의 로그는 info 레벨로 보고 싶고 내 프로젝트의 로그만 debug 레벨로 조정하고 싶다면

위와 같이 <root> 태그의 level 속성을 "DEBUG"에서 "INFO"로 바꿔서 기본 로그 레벨을 info레벨로 변경해주고

아래 코드를 추가해줍니다. ("내 프로젝트 패키지명" 부분은 본인 프로젝트의 패키지명을 적어주시면 됩니다.)

<logger name="내 프로젝트 패키지명" level="DEBUG" />

기본 로그 레벨은 info 레벨로 설정하지만 내 프로젝트 패키지에 해당하는 로그만 debug 레벨로 조정하겠다는 것입니다.

이렇게 하면 다른 라이브러리의 DEBUG 레벨 로그는 출력되지 않고 내 프로젝트의 debug 레벨 로그만 출력됩니다.

정말 그런지 확인해볼까요?

아까와 달리 스프링 프레임워크의 DEBUG 레벨의 로그는 보이지 않는 것을 확인할 수 있고

 

아까와 달리 org.springframework의 debug 레벨 로그는 출력되지 않고 제 프로젝트 패키지인 com.feelcoding.logbackdemo 패키지에 있는 로거에서만 debug 레벨의 로그가 출력되는 것을 볼 수 있습니다.

 

그럼 1번 문제점은 해결했고 2번 문제점을 해결해볼까요?

 

공식 문서에 따르면 

%black", "%red", "%green", "%yellow", "%blue", "%magenta", "%cyan", "%white", "%gray", "%boldRed", "%boldGreen", "%boldYellow", "%boldBlue", "%boldMagenta", "%boldCyan", "%boldWhite" and "%highlight"

이렇게 17개의 색상을 지원한다고 합니다.

그럼 색깔을 바꿔볼까요?

예시로 보여주기 위해 최대한 다양한 색깔을 사용해 보았습니다. 날짜는 초록색, 스레드명은 보라색, 로거는 청록색, 로그 메시지에는 노란색, 로그 레벨은 로그 레벨에 따라 다른 색상을 출력해주는 highlight를 적용했습니다.

%green(%d{yyyy-MM-dd HH:mm:ss.SSS}) %magenta([%thread]) %highlight(%5level) %cyan(%logger) - %yellow(%msg%n)

%색상명() 이렇게 감싸주면 됩니다.

아까와 달리 로그가 알록달록해졌습니다.

 

그런데 INFO,  WARN, ERROR 등 로그 레벨을 나타내는 글자 색은 아까 아무런 설정을 해주지 않았을 때의 색이 더 예쁜 것 같은데... 바꿀 수 없을까요?

아무 설정을 해주지 않았을 때의 로그 색상

그럼 기본 설정이 어떻게 되어 있는지, 기본 설정이 되어 있는 곳을 찾아가봅시다.

Windows 사용자는 ctrl + n, Mac OS 사용자는 cmd + o를 누르고 우측 상단에 범위를 Files 탭에서 base.xml을 검색해봅시다. (base.xml이라고 입력하면 자동으로 범위가 All Places로 바뀔 것인데, 만약 나오지 않는다면 우측 상단의 범위를 All Places로 수정해보세요.)

해당 파일에 들어가보면

defaults.xml 파일을 include 했다는 것을 알 수 있습니다.

그러면 다시 검색을 해서 defaults.xml 파일에 가봅시다.

defaults.xml을 보면 

여기에 clr이라는 이름으로 conversionRule이 선언되어 있고

defaults.xml

로그 레벨을 지정하는 부분을 clr이라는 색으로 지정한 것을 볼 수 있습니다.

 

저는 로그 레벨을 해당 색으로 변경하고 logger만 cyan 색으로 하고 나머지는 그냥 기본 색으로 바꾸겠습니다.

 

<?xml version="1.0" encoding="UTF-8" ?>
<configuration>
    <conversionRule conversionWord="clr" converterClass="org.springframework.boot.logging.logback.ColorConverter" />
    <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
        <encoder>
            <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %clr(%5level) %cyan(%logger) - %msg%n</pattern>
        </encoder>
    </appender>
    <logger name="com.feelcoding.logbackdemo" level="DEBUG" />
    <root level="INFO">
        <appender-ref ref="CONSOLE" />
    </root>
</configuration>

 

로그 파일에 로그 기록하기

로그를 콘솔에만 출력하면 로그가 많이 찍혔을 때는 위쪽 로그가 보이지 않게 되기도 하고, 기록이 남지 않아 나중에 확인하기가 어려운 등 불편한 점이 많습니다.

그러면 이제 로그 파일에 저장을 해봅시다.

logback-spring.xml 파일에 FileAppender를 하나 추가해봅시다.

<appender name="FILE" class="ch.qos.logback.core.FileAppender">
    <file>./log/testFile.log</file>
    <encoder>
        <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %5level %logger - %msg%n</pattern>
    </encoder>
</appender>

"FILE"이라는 이름으로 FileAppender를 하나 만들고

<root level="INFO">
    <appender-ref ref="CONSOLE" />
    <appender-ref ref="FILE" />
</root>

해당 appender를 root 레벨에 추가해줍니다.

프로젝트 디렉토리 아래 log라는 디렉토리 하위에 testFile.log라는 파일에 로그를 출력하겠다는 것입니다.

그리고 로그 출력 패턴은 아래와 같이 콘솔 appender와 동일하게 해주겠습니다.

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

그런데 이렇게 동일한 문자열이 두 곳에서나 쓰였습니다. 해당 패턴을 복사해서 여러 곳에서 쓰기 보다는 해당 문자열을 변수로 뽑아볼까요?

<property name="LOG_PATTERN" value="%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %clr(%5level) %cyan(%logger) - %msg%n" />

이렇게 LOG_PATTERN이라는 이름으로 변수를 뽑았습니다.

사용할 때는

${LOG_PATTERN}

이렇게 사용하면 됩니다.

그리고 실행을 해보면

아까 지정해준 log 폴더의 testFile.log 파일에 가보면 이렇게 로그가 파일로 저장되어 있는 것을 볼 수 있습니다.

애플리케이션을 종료했다가 다시 실행하면

이렇게 기존 파일 내용의 마지막 부분에 이어서 써지는 것을 볼 수 있습니다.

여기서 잠깐!! 이렇게 로그를 파일에 저장할 때는 gitignore에 log 파일을 꼭 추가해주세요. 그렇지 않으면 불필요한 로그 파일들이 깃허브에 올라가게 됩니다.

 

그런데 ESC[32m, ESC[0;39m, ESC[36m과 같은 이상한 문자가 보입니다. 이것은 뭘까요?

https://stackoverflow.com/questions/53298918/strange-symbols-in-log-output

 

Strange symbols in log-output

I started to see strange output in my log files: 2018-11-14 14:04:21,180 [main] [34mINFO[0;39m com.zaxxer.hikari.HikariDataSource - HikariPool-1 - Shutdown initiated... 2018-11-14 14:04:21,186...

stackoverflow.com

색깔을 나타내기 위한 이스케이프 문자라고 합니다.

 

글자 색이 있는 로그를 파일에 저장을 하면 이렇게 이스케이프 문자가 보여 로그를 읽기가 어려우니, 파일에 저장할 땐 글자 색깔을 없애볼까요?

기존 LOG_PATTERN을 CONSOLE_LOG_PATTERN으로 이름을 바꾸고 FILE_LOG_PATTERN이라는 이름으로 글자 색 없는 로그 패턴 변수를 추가했습니다.

<?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} [%thread] %clr(%5level) %cyan(%logger) - %msg%n" />
    <property name="FILE_LOG_PATTERN" value="%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %5level %logger - %msg%n" />

    <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
        <encoder>
            <pattern>${CONSOLE_LOG_PATTERN}</pattern>
        </encoder>
    </appender>
    <appender name="FILE" class="ch.qos.logback.core.FileAppender">
        <file>./log/testFile.log</file>
        <encoder>
            <pattern>${FILE_LOG_PATTERN}</pattern>
        </encoder>
    </appender>
    <logger name="com.feelcoding.logbackdemo" level="DEBUG" />
    <root level="INFO">
        <appender-ref ref="CONSOLE" />
        <appender-ref ref="FILE" />
    </root>
</configuration>

그러고 나서 다시 실행을 해보면

이렇게 이스케이프 문자 없이 로그가 잘 찍혀있는 것을 볼 수 있습니다.

 

여러 파일에 나눠서 로그 기록하기

그런데 한 파일에만 계속 쓰다 보면 파일의 크기가 점점 커질 것이고 나중에 검색하기가 힘들어질 것입니다.

이럴 때 쓰는 것이 RollingFileAppender입니다.

RollingFileAppender는 FileAppender와 마찬가지로 파일에 로그를 쓰는 appender인데요,

FileAppender와 다른 점은 로그를 여러 파일에 나눠서 쓴다는 것입니다.

한 번 직접 사용해보면서 알아볼까요?

 

"FILE" 이라는 이름으로 지정했던 appender의 class를 FileAppender에서 RollingFileAppender로 변경하고

<file> 태그를 지우고 아래와 같이 <rollingPolicy> 태그를 추가해봅시다.

<appender name="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/%d{yyyy-MM-dd}.%i.log</fileNamePattern>
        <maxFileSize>100MB</maxFileSize>
        <maxHistory>30</maxHistory>
    </rollingPolicy>
</appender>

로그를 /log 디렉토리 아래에 날짜.순번.log 라는 이름으로 로그 파일을 저장하고,

파일이 100MB가 넘으면 다음 파일에 저장하고,

로그 파일이 만들어진지 30일이 지나면 해당 파일을 삭제하겠다는 것입니다.

예를 들어 2023-02-13.0.log 파일에 로그가 많이 써져서 100MB가 넘으면 2023-02-13.1.log 파일에 기록을 하고, 그 다음 2023-02-13.2.log, 2023-02-13.3.log, ... 이런 식으로 파일 이름을 짓겠다는 것입니다. 그리고 30일 후인 2023년 3월 15일에 이 파일은 삭제될 것입니다.

한 번 실행해볼까요?

(저는 테스트를 위해 100MB를 10KB로 바꿔서 실행했습니다.)

이렇게 여러 파일에 나눠서 로그가 저장된 것을 볼 수 있습니다.

그럼 정말 10KB씩 나눠졌는지 확인해볼까요?

이렇게 10KB가 넘으면 파일이 나눠진 것을 확인할 수 있습니다.

 

프로필별 로그 관리하기

로컬 환경에서는 로그 레벨을 debug 레벨로 콘솔에만 출력하고 싶고,

dev와 staging 환경에서는 로그 레벨을 info 레벨로 설정하고 파일에 출력하고 싶고,

운영 환경에서는 로그 레벨을 error 레벨로 설정하고 싶다면 어떻게 해야 할까요?

프로필에 따라 다르게 로그를 관리하는 방법을 알아봅시다. 

 

일단 logback-spring.xml 파일을 이렇게 바꿔줍니다.

<?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} [%thread] %clr(%5level) %cyan(%logger) - %msg%n" />
    <property name="FILE_LOG_PATTERN" value="%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %5level %logger - %msg%n" />

    <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
        <encoder>
            <pattern>${CONSOLE_LOG_PATTERN}</pattern>
        </encoder>
    </appender>
    <appender name="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/%d{yyyy-MM-dd}.%i.log</fileNamePattern>
            <maxFileSize>100MB</maxFileSize>
            <maxHistory>30</maxHistory>
        </rollingPolicy>
    </appender>

    <springProfile name="local">
        <logger name="com.feelcoding.logbackdemo" level="DEBUG" />
        <root level="INFO">
            <appender-ref ref="CONSOLE" />
        </root>
    </springProfile>
    <springProfile name="dev|stg">
        <root level="INFO">
            <appender-ref ref="CONSOLE" />
            <appender-ref ref="FILE" />
        </root>
    </springProfile>
    <springProfile name="prod">
        <root level="ERROR">
            <appender-ref ref="CONSOLE" />
            <appender-ref ref="FILE" />
        </root>
    </springProfile>
</configuration>

딱 보면 감이 오시죠?

프로필이 local일 땐 debug 레벨로 콘솔에 출력하고

프로필이 dev나 stg일 땐 info 레벨로 콘솔과 파일에 출력하고

프로필이 prod일 땐 error 레벨로 콘솔과 파일에 출력하겠다는 것입니다.

 

로그 패턴 설정하기

맨 처음에 아무런 logback 설정을 하지 않고 실행했을 때 아래와 같이 출력되었는데요, 뭔가 이상한 점 없으신가요?

LogbackDemoApplication의 패키지는 com.feelcoding.logbackdemo인데, c.f.logbackdemo라고 출력된 것을 볼 수 있습니다.

뿐만 아니라, logback 설정을 해주고 난 뒤의 로그와 비교를 해보니

org.springframework.boot.web.embedded.tomcat도 o.s.b.w.embedded.tomcat으로,

org.apache.catalina.core.ContainerBase도 o.a.c.c.C로 나오는 등 패키지명과 클래스명이 생략된 것을 볼 수 있습니다.

왜 이렇게 출력되는 것일까요?

아까 봤던 defaults.xml 파일에 가봅시다.

%-40.40logger{39}

라고 되어 있는 것을 볼 수 있습니다.

최대 39자까지 출력하고 39자를 넘으면 축약하겠다는 것입니다.

 

 

출처

https://logback.qos.ch/manual/index.html

https://docs.spring.io/spring-boot/docs/current/reference/html/features.html#features.logging

https://oingdaddy.tistory.com/257

스프링 프레임워크를 처음부터 다시 깊이 있게 제대로 공부를 하려고 https://www.inflearn.com/course/스프링-입문-스프링부트 강의를 듣기 시작했는데 시작부터 난관에 부딪혔다.

빌드가 안 된다.

 

분명히 이렇게 저 인텔리제이의 버튼을 눌러서 실행 하는 것은 잘만 됐었다. 

그리고 이 build 버튼을 눌러서 빌드를 하는 것도 잘 성공했었다.

빌드 성공

 

그런데 이렇게 터미널에서만 하면

invalid source release: 11 이라면서 빌드에 실패했다.

검색을 해보니까 Settings와 Project Structure에 있는 자바나 JDK 버전을 다 11로 맞추라고 해서

굳이 11로 맞추지 않아도 되는 것까지도 무조건 다 11로 고쳤다. 이렇게 말이다.

그런데도 결과는 똑같다.

 

인텔리제이 IDE를 통해서 빌드를 하면 잘 되고 직접 터미널로 하니까 안 되니까 문제는 build.gradle에 있지 않을까? 라는 생각을 했다.

왜냐하면 내가 Build and run using을 Gradle이 아니라 IntelliJ IDEA로 해놨기 때문이다.

그래서 문제는 build.gradle에 있을 거라고 생각하고 build.gradle을 봤다.

sourceCompatibility가 '11'로 되어 있는데 원인이 이것인지 알아보기 위해 sourceCompatibility를 '17'로 고쳐보았다.

그랬더니 이번에는 invalid source release: 17 이라는 오류가 났다.

그렇다면 원인은 sourceCompatibility라는 것이 확실해졌다.

그래서 아예 sourceCompatibility='11' 이 부분을 지워봤다.

그랬더니 이렇게 빌드에 성공했다.

왜 그런지는 모르겠다.

일단 검색을 해서 나오는 어떠한 방법으로도 안 됐는데 해결을 해서 너무 다행이다.

build.gradle 파일에서 sourceCompatibility가 어떤 역할을 하는 건지 좀 더 알아봐야겠다.

스프링부트 Thymeleaf에서는 변수를 쓸 때 ${변수} 이런 식으로 쓸 수 있다.
내가 오늘 개발을 하다가

<td th:text="${product.price}"></td>

이런 코드를 작성했다.
그런데 "800"이 아니라 "800원"으로 보이면 좋을 것 같아서

<td th:text="${product.price}원"></td>

코드를 이렇게 수정했다.

그랬더니 이런 에러가 났다.

ERROR o.a.c.c.C.[.[.[.[dispatcherServlet] - Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed; nested exception is org.thymeleaf.exceptions.TemplateInputException: An error happened during template parsing (template: "class path resource [templates/파일이름.html]")] with root cause
org.thymeleaf.exceptions.TemplateProcessingException: Could not parse as expression: "${product.price}원"


변수를 쓸 때 ${}를 쓰는거라고 해서 그냥 문자열은 ${} 바깥쪽에 적어줬는데 이렇게 하면 안 되는 것이었다.
아래의 코드처럼 작성해야 한다.

<td th:text="|${product.price}원|"></td>

이렇게 큰 따옴표 안쪽에 |을 넣어주었더니 에러 없이 잘 돌아갔다.

출처: https://velog.io/@susu1991/Thymeleaf#%EB%A6%AC%ED%84%B0%EB%9F%B4

스프링 부트 개발을 하다가 아래와 같은 에러가 났다.

2021-07-13 21:32:15.194 ERROR 20836 --- [nio-8080-exec-1] org.thymeleaf.TemplateEngine             : [THYMELEAF][http-nio-8080-exec-1] Exception processing template "index.html": Error resolving template [index.html], template might not exist or might not be accessible by any of the configured Template Resolvers

 

org.thymeleaf.exceptions.TemplateInputException: Error resolving template [index.html], template might not exist or might not be accessible by any of the configured Template Resolvers

at org.thymeleaf.engine.TemplateManager.resolveTemplate(TemplateManager.java:869) ~[thymeleaf-3.0.12.RELEASE.jar:3.0.12.RELEASE]

at org.thymeleaf.engine.TemplateManager.parseAndProcess(TemplateManager.java:607) ~[thymeleaf-3.0.12.RELEASE.jar:3.0.12.RELEASE]

at org.thymeleaf.TemplateEngine.process(TemplateEngine.java:1098) ~[thymeleaf-3.0.12.RELEASE.jar:3.0.12.RELEASE]

at org.thymeleaf.TemplateEngine.process(TemplateEngine.java:1072) ~[thymeleaf-3.0.12.RELEASE.jar:3.0.12.RELEASE]

at org.thymeleaf.spring5.view.ThymeleafView.renderFragment(ThymeleafView.java:366) ~[thymeleaf-spring5-3.0.12.RELEASE.jar:3.0.12.RELEASE]

at org.thymeleaf.spring5.view.ThymeleafView.render(ThymeleafView.java:190) ~[thymeleaf-spring5-3.0.12.RELEASE.jar:3.0.12.RELEASE]

at org.springframework.web.servlet.DispatcherServlet.render(DispatcherServlet.java:1396) ~[spring-webmvc-5.3.8.jar:5.3.8]

at org.springframework.web.servlet.DispatcherServlet.processDispatchResult(DispatcherServlet.java:1141) ~[spring-webmvc-5.3.8.jar:5.3.8]

at org.springframework.web.servlet.DispatcherServlet.doDispatch(DispatcherServlet.java:1080) ~[spring-webmvc-5.3.8.jar:5.3.8]

at org.springframework.web.servlet.DispatcherServlet.doService(DispatcherServlet.java:963) ~[spring-webmvc-5.3.8.jar:5.3.8]

at org.springframework.web.servlet.FrameworkServlet.processRequest(FrameworkServlet.java:1006) ~[spring-webmvc-5.3.8.jar:5.3.8]

at org.springframework.web.servlet.FrameworkServlet.doGet(FrameworkServlet.java:898) ~[spring-webmvc-5.3.8.jar:5.3.8]

at javax.servlet.http.HttpServlet.service(HttpServlet.java:655) ~[tomcat-embed-core-9.0.48.jar:4.0.FR]

at org.springframework.web.servlet.FrameworkServlet.service(FrameworkServlet.java:883) ~[spring-webmvc-5.3.8.jar:5.3.8]

at javax.servlet.http.HttpServlet.service(HttpServlet.java:764) ~[tomcat-embed-core-9.0.48.jar:4.0.FR]

at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:228) ~[tomcat-embed-core-9.0.48.jar:9.0.48]

at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:163) ~[tomcat-embed-core-9.0.48.jar:9.0.48]

at org.apache.tomcat.websocket.server.WsFilter.doFilter(WsFilter.java:53) ~[tomcat-embed-websocket-9.0.48.jar:9.0.48]

at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:190) ~[tomcat-embed-core-9.0.48.jar:9.0.48]

at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:163) ~[tomcat-embed-core-9.0.48.jar:9.0.48]

at org.springframework.web.filter.RequestContextFilter.doFilterInternal(RequestContextFilter.java:100) ~[spring-web-5.3.8.jar:5.3.8]

at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:119) ~[spring-web-5.3.8.jar:5.3.8]

at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:190) ~[tomcat-embed-core-9.0.48.jar:9.0.48]

at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:163) ~[tomcat-embed-core-9.0.48.jar:9.0.48]

at org.springframework.web.filter.FormContentFilter.doFilterInternal(FormContentFilter.java:93) ~[spring-web-5.3.8.jar:5.3.8]

at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:119) ~[spring-web-5.3.8.jar:5.3.8]

at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:190) ~[tomcat-embed-core-9.0.48.jar:9.0.48]

at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:163) ~[tomcat-embed-core-9.0.48.jar:9.0.48]

at org.springframework.web.filter.CharacterEncodingFilter.doFilterInternal(CharacterEncodingFilter.java:201) ~[spring-web-5.3.8.jar:5.3.8]

at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:119) ~[spring-web-5.3.8.jar:5.3.8]

at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:190) ~[tomcat-embed-core-9.0.48.jar:9.0.48]

at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:163) ~[tomcat-embed-core-9.0.48.jar:9.0.48]

at org.apache.catalina.core.StandardWrapperValve.invoke(StandardWrapperValve.java:202) ~[tomcat-embed-core-9.0.48.jar:9.0.48]

at org.apache.catalina.core.StandardContextValve.invoke(StandardContextValve.java:97) ~[tomcat-embed-core-9.0.48.jar:9.0.48]

at org.apache.catalina.authenticator.AuthenticatorBase.invoke(AuthenticatorBase.java:542) ~[tomcat-embed-core-9.0.48.jar:9.0.48]

at org.apache.catalina.core.StandardHostValve.invoke(StandardHostValve.java:143) ~[tomcat-embed-core-9.0.48.jar:9.0.48]

at org.apache.catalina.valves.ErrorReportValve.invoke(ErrorReportValve.java:92) ~[tomcat-embed-core-9.0.48.jar:9.0.48]

at org.apache.catalina.core.StandardEngineValve.invoke(StandardEngineValve.java:78) ~[tomcat-embed-core-9.0.48.jar:9.0.48]

at org.apache.catalina.connector.CoyoteAdapter.service(CoyoteAdapter.java:357) ~[tomcat-embed-core-9.0.48.jar:9.0.48]

at org.apache.coyote.http11.Http11Processor.service(Http11Processor.java:382) ~[tomcat-embed-core-9.0.48.jar:9.0.48]

at org.apache.coyote.AbstractProcessorLight.process(AbstractProcessorLight.java:65) ~[tomcat-embed-core-9.0.48.jar:9.0.48]

at org.apache.coyote.AbstractProtocol$ConnectionHandler.process(AbstractProtocol.java:893) ~[tomcat-embed-core-9.0.48.jar:9.0.48]

at org.apache.tomcat.util.net.NioEndpoint$SocketProcessor.doRun(NioEndpoint.java:1723) ~[tomcat-embed-core-9.0.48.jar:9.0.48]

at org.apache.tomcat.util.net.SocketProcessorBase.run(SocketProcessorBase.java:49) ~[tomcat-embed-core-9.0.48.jar:9.0.48]

at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1128) ~[na:na]

at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:628) ~[na:na]

at org.apache.tomcat.util.threads.TaskThread$WrappingRunnable.run(TaskThread.java:61) ~[tomcat-embed-core-9.0.48.jar:9.0.48]

at java.base/java.lang.Thread.run(Thread.java:829) ~[na:na]

 

웹 브라우저로 들어가봐도 이렇게 떴다.

해결방법은 간단했다.

html 파일을 templates 폴더로 옮기면 된다. 나는 index.html을 static에 넣어놨었는데 index.html 파일을 templates 폴더로 옮기니 해결되었다.

출처: https://stackoverflow.com/questions/31944355/error-resolving-template-index-template-might-not-exist-or-might-not-be-acces

 

Error resolving template "index", template might not exist or might not be accessible by any of the configured Template Resolver

This question has been asked before but I did not solve my problem and I getting some weird functionality. If I put my index.html file in the static directory like so: I get the following error in...

stackoverflow.com

 

스프링 부트로 API를 만들고 있었다.

@GetMapping, @PutMapping, @PostMapping 어노테이션을 붙여서 조회, 삽입, 수정하는 API는 잘 만들었고 Postman을 이용하여 API 테스트까지 완료했다.

이렇게 필요한 거의 모든 API 구현이 거의 끝나가고 이제 삭제하는 API도 넣어야겠다 하고

이렇게 Get, Put, Post 하던 방식과 같은 방식으로 @DeleteMapping을 붙이고 삭제하는 API를 구현했다.

 

그 다음에 하던대로 Postman을 이용해서 API 테스트를 하는데

 

이런 에러가 났다.

Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed; nested exception is org.springframework.dao.InvalidDataAccessApiUsageException: No EntityManager with actual transaction available for current thread - cannot reliably process 'remove' call; nested exception is javax.persistence.TransactionRequiredException: No EntityManager with actual transaction available for current thread - cannot reliably process 'remove' call] with root cause


javax.persistence.TransactionRequiredException: No EntityManager with actual transaction available for current thread - cannot reliably process 'remove' call

찾아봤더니 delete 하는 메소드에 @Transactional 어노테이션을 붙여줘야 한다고 한다.

 

붙여줬더니

이렇게 삭제가 멀쩡히 잘 되는 것을 볼 수 있다.

 

출처

https://yoonho-devlog.tistory.com/61

 

+ Recent posts