Caused by: org.h2.jdbc.JdbcSQLSyntaxErrorException: Syntax error in SQL statement "  
    CREATE TABLE GROUP[*] (  
       ID BIGINT GENERATED BY DEFAULT AS IDENTITY,  
        CREATE\_DATE TIMESTAMP,  
        INVITATION\_CODE VARCHAR(255) NOT NULL,  
        NAME VARCHAR(255) NOT NULL,  
        PRIMARY KEY (ID)  
    )"; expected "identifier"; SQL statement:

이런 오류로그가 찍혔는데, 그 이유는 Group 이라는 이름의 테이블 때문이었다.
Group 이라는 단어가 SQL의 키워드 중 하나이기 때문에 쓸 수 없는 모양이다.


Group 클래스에 @Table(name = "GROUP_TABLE") 를 붙여서

테이블 이름을 group 대신 group_table 로 바꿔서 해결했다.

@Entity
...
@Table(name = "GROUP_TABLE")
public class Group {
    ...
}

 

 


출처

https://www.fwantastic.com/2020/01/caused-by-orgh2jdbcjdbcsqlsyntaxerrorex.html

 

Caused by: org.h2.jdbc.JdbcSQLSyntaxErrorException: ... expected "identifier"; SQL statement:

 

www.fwantastic.com

 

프로젝트를 하다보니 select * from comment where article_id=1 order by desc; 이런 Query를 사용해야 하는 경우가 있었다. 이럴 때 @Query 어노테이션으로 JPQL을 쓰면 되겠다 싶긴 했는데, 뭔가

Spring Data JPA  Repository 에서 이정도는 메서드 이름만으로 지원해주지 않을까 싶었다.

 

그리고 정말 지원해주고 있었다!

public interface CommentRepository extends JpaRepository<Comment, Long> {

    List<Comment> findAllByArticleIdOrderByCreateDateDesc(Long articleId);
}

 


메소드 이름으로 JPQL 을 생성해주는 기능 규칙 공식문서

https://docs.spring.io/spring-data/jpa/docs/current/reference/html/#repositories.query-methods.details

한글로 잘 정리된 블로그

https://joont92.github.io/jpa/Spring-Data-JPA/

실서버에서 사용하는 크리티컬한 YML 파일들을 프로젝트 외부에서 프라이빗하게 관리하면서 버전관리까지 깔끔하게 할 수 없을까? 이런 고민 끝에 찾아낸 나만의 방법을 정리해보았다.

 

우리 프로젝트는 다음과 같은 YML 파일들을 사용한다.

  • application.yml # 디폴트로 application-local 을 사용하게 함
  • application-local.yml # 개발환경의 테스트용 H2 DB에 대한 설정정보
  • application-real.yml # 실서버의 MariaDB에 대한 설정정보
  • application-oauth.yml # 소셜로그인에 필요한 critical한 정보

또한 우리 프로젝트는 깃허브의 public 리포지토리를 사용하고 있다.


그런데 application-real.yml, application-oauth.yml 은 public 리포지토리에 올라가서는 안되는 민감한 정보를 가지고있다.

따라서 이 파일들을 별도로 관리할 방법이 필요했다.

 

real 과 local 을 나누는 것은 어렵지 않았다. 디폴트로 application-local.yml 을 사용하게 하고, 실서버에서 실행할 때는 -Dspring.profiles.active=real 옵션을 주어 application-real 을 사용하게 했다.

java -Dspring.profiles.active=real -jar rush-0.0.1-SNAPSHOT.jar

 

여기까지는 어렵지 않았다. 하지만 문제는

application-real, application-oauth가 public 리포지토리에 올라가면 안된다는 점이었다.

그렇다고 실서버에서 hard 하게 관리하자니 불편한게 이만저만이 아니었다.

 

이 문제를 해결하기 위해서 원격 리포지토리를 다음과 같이 구성하기로 했다.

그것은 바로!!! 크리티컬한 정보들만 private 리포지토리에서 따로 관리하는 것이다.

 

주변의 조언을 들어보니 깃 서브모듈이라는게 있다고 하는데.... 할일은 많고 공부할것도 많은지라 그냥 private 리포지토리를 쓰기로 했다.

 

원격 리포지토리 구성 계획은 끝났으니 이제 로컬과 실서버의 구조를 고민할 차례!

로컬과 실서버에서 프로젝트를 실행하는 과정은 각각 다음과 같게 하고싶었다.

실서버 옵션으로 실행할 경우, 프로젝트는 /app/config 의 yml 정보들을 사용해야한다.

 

application-real 과 application-local 을 분리하는것은 쉬웠다.

그러나 문제는 application-oauth 였다.

 

실서버에서는 /app/config/application-oauth.yml 을 사용하게 하고싶고,

로컬에서는 프로젝트 내의 resources 밑에 application-oauth.yml을 위치시켜서 쓰고싶었다.

로컬용 application-oauth.yml 파일과 실서버용 application-oauth.yml 파일 내용이 살짝 달랐기 때문이다.

 

그러나 아무리 해도 이렇게 구현되지가 않았다.

심지어 또다른 문제도 터졌다. 클라우드서버의 무료기간이 끝나서 집에 라즈베리파이를 설치하고 여기로 서버를 옮겼는데, 원래 잘 동작하던 -Dspring.profiles.active=real 옵션이 라즈베리파이에서 제대로 동작하지 않는 것이었다.

 

마일스톤 마지막날까지 계속 삽질을 하다가 결국 포기하고 잠자리에 누웠다.

누워서 계속 생각을 해봤는데 갑자기 이런생각이 들었다.

"main 메서드의 args 를 사용하면 되지 않을까??"

 

기존의 main 메서드는 다음과 같았다.

@EnableConfigurationProperties(AppProperties.class)
@SpringBootApplication
public class RushApplication {

    private static final String APPLICATION_LOCATIONS = "spring.config.location="
        + "classpath:application.yml,"
        + "classpath:application-local.yml,"
        + "optional:/app/config/application-real.yml";

    public static void main(String[] args) {
        new SpringApplicationBuilder(RushApplication.class)
            .properties(APPLICATION_LOCATIONS)
            .run(args);
    }
}

 

그런데 이것을 다음과 같이 바꾸는 것이다.

@EnableConfigurationProperties(AppProperties.class)
@SpringBootApplication
public class RushApplication {

    private static final String REAL_SERVER_OPTION = "REAL";
    private static final String REAL_SERVER_PROPERTY_LOCATION = "/app/config/";
    private static final String LOCAL_SERVER_PROPERTY_LOCATION = "classpath:";

    public static void main(String[] args) {
        final String PROPERTY_LOCATIONS = makeSApplicationLocations(args);

        new SpringApplicationBuilder(RushApplication.class)
            .properties(PROPERTY_LOCATIONS)
            .run(args);
    }

    private static String makeSApplicationLocations(String[] mainMethodArguments) {
        if (mainMethodArguments.length > 0
                && mainMethodArguments[0].equalsIgnoreCase(REAL_SERVER_OPTION)) {
            return "spring.config.location="
                + REAL_SERVER_PROPERTY_LOCATION + "application.yml,"
                + REAL_SERVER_PROPERTY_LOCATION + "application-oauth.yml,"
                + REAL_SERVER_PROPERTY_LOCATION + "application-real.yml";
        }
        return "spring.config.location="
            + LOCAL_SERVER_PROPERTY_LOCATION + "application.yml,"
            + LOCAL_SERVER_PROPERTY_LOCATION + "application-oauth.yml,"
            + LOCAL_SERVER_PROPERTY_LOCATION + "application-local.yml";
    }
}

 

이렇게하면 굳이 스프링의 기능을 이용해서 환경분리를 구현할 필요가 없어진다.

또한 기존의 `optional:/app/config/application-real.yml` 이 조잡한 설정도 없앨 수 있었다.

게다가 실서버에서 프로그램을 실행하는 명령도 더 간단해졌다.

 // 기존의 실서버 옵션 실행방법
 // java -Dspring.profiles.active=real -jar rush-0.0.1-SNAPSHOT.jar
 
 // 실서버 옵션 실행방법 바뀐 버전
 java -jar rush-0.0.1-SNAPSHOT.jar REAL

 

이렇게 해서 모든 실서버 yml 파일 뿐만아니라 쉘스크립트까지!! private 리포지토리에서 관리할 수 있게 되었다!

 

1. h2 database 를 설치하고 서버모드로 실행했음. 그리고 테이블 생성

create user (
   id bigint primary key auto_increment,
   name varchar(200) not null,
   age int
);

  

 

 

2. 프로젝트 생성

IntelliJ로 Gradle 프로젝트를 대충 생성한다음..

 

1. build.gradle에 의존성 추가

implementation 'org.hibernate:hibernate-entitymanager:4.3.10.Final'
runtimeOnly 'com.h2database:h2:1.4.187'

 

2. src/main/resources/META-INF/persistence.xml

<?xml version="1.0" encoding="UTF-8" ?>
<persistence xmlns="http://xmlns.jcp.org/xml/ns/persistence" version="2.1">
  <persistence-unit name="practice">

    <class>practice.domain.User</class>

    <properties>

      <property name="javax.persistence.jdbc.driver" value="org.h2.Driver"/>
      <property name="javax.persistence.jdbc.user" value="sa"/>
      <property name="javax.persistence.jdbc.password" value=""/>
      <property name="javax.persistence.jdbc.url" value="jdbc:h2:tcp://localhost/~/test"/>
      <property name="hibernate.dialect" value="org.hibernate.dialect.H2Dialect"/>

      <property name="hibernate.show_sql" value="true"/>
      <property name="hibernate.format_sql" value="true"/>
      <property name="hibernate.use_sql_comments" value="true"/>
      <property name="hibernate.id.new_generator_mappings" value="true"/>

    </properties>
  </persistence-unit>
</persistence>
  • JPA에서 필요로하는 설정 정보를 이 파일로 관리한다.
  • 설정파일 경로가 클래스패스 경로/META-INF/persistence.xml 이면 별도의 설정 없이 JPA가 인식한다.

 

 

3. 코드작성

package practice.domain;

import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;

@Entity
public class User {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String name;

    @Column(nullable = true)
    private Integer age;

    protected User() {} // 엔티티에 public or protected 기본생성자 필수

    public User(Long id, String name, Integer age) {
        this.id = id;
        this.name = name;
        this.age = age;
    }
}
  • 엔티티 클래스 : 테이블과 매핑할 클래스
    • 기본생성자 필수
    • final 필드 불가
package practice;

import javax.persistence.EntityManager;
import javax.persistence.EntityManagerFactory;
import javax.persistence.EntityTransaction;
import javax.persistence.Persistence;
import practice.domain.User;

public class Main {

    public static void main(String[] args) {
        EntityManagerFactory entityManagerFactory =
            Persistence.createEntityManagerFactory("practice");

        EntityManager entityManager = entityManagerFactory.createEntityManager();
        EntityTransaction transaction = entityManager.getTransaction();

        try {
            transaction.begin();

            entityManager.persist(new User(null, "해위", 25));

            transaction.commit();

        } catch (Exception e) {
            transaction.rollback();
            e.printStackTrace();
        } finally {
            entityManager.close();
        }
        entityManagerFactory.close();
    }
}

   

 

   

실행결과

insert 문이 잘 실행된것을 확인할 수 있다.

스프링 MVC 에서
Json 형식의 Request Body 로 요청을 받고 Json 형식의 Response Body 로 응답하는 방법.

1. Jackson 의존 추가

Jackson : Java 객체를 JSON 형식으로 변환하는 것과
JSON 형식을 Java 객체로 변환하는 것을 해주는 Java 라이브러리

// Jackson core와 Jackson 어노테이션
implementation 'com.fasterxml.jackson.core:jackson-databind:2.9.4'

// Java8 date/time 지원을 위한 Jackson 모듈
implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.9.4'

2. JSON 응답

2.1. 기본 사용법

  • @RestController
  • return (Response Body)
@RestController
public class MemberRestController {

   ...

    @GetMapping("/api/members")
    public List<Member> members() {
        return memberDao.selectAll();
    }
}

GET /api/members 요청 시 Response Body

[
    {
        "id": 62,
        "email": "test@test.com",
        "password": "test",
        "name": "test",
        "registerDateTime": [
            2021,
            3,
            5,
            16,
            7,
            39
        ]
    }
]

2.2. @JsonIgnore

@JsonIgnore 를 사용하면
Json 응답에 특정 필드가 들어가지 않게 할 수 있다.

...
import com.fasterxml.jackson.annotation.JsonIgnore;

public class Member {

    private Long id;
    private String email;
    @JsonIgnore
    private String password;
    private String name;
    private LocalDateTime registerDateTime;

GET /api/members 요청 시 Response Body

[
    {
        "id": 62,
        "email": "test@test.com",
        "name": "test",
        "registerDateTime": [
            2021,
            3,
            5,
            16,
            7,
            39
        ]
    }
]

2.3. 날짜 객체 응답

LocalDateTime -> Json 기본 변환 형태

앞서 예제에서
Member 객체의 java.time.LocalDateTime 필드가 다음과 같은 Json 형식으로 자동 변형된 것을 확인할 수 있다.

...
"registerDateTime": [
    2021,
    3,
    5,
    16,
    7,
    39
]
...

@JsonFormat을 이용해서 날짜 응답 형식을 커스터마이징할 수 있다.

@JsonFormat(shape = Shape.STRING)

ISO-8601 형식으로 변환

public class Member {

    private Long id;
    private String email;
    @JsonIgnore
    private String password;
    private String name;
    @JsonFormat(shape = Shape.STRING)
    private LocalDateTime registerDateTime;

    ...
}

GET /api/members 요청 시 Response Body

[
     {
         "id": 62,
         "email": "test@test.com",
         "name": "test",
         "registerDateTime": "2021-03-05T16:07:39"
     }
]

@JsonFormat(pattern="yyyyMMddHHmmss")

pattern 속성은 java.time.format.DateTimeFormatter 클래스나 java.text.SimpleDateFormat 클래스의 API 문서에 정의된 패턴을 따른다.

public class Member {

     private Long id;
     private String email;
     @JsonIgnore
     private String password;
     private String name;
     @JsonFormat(pattern = "yyyyMMddHHmmss")
     private LocalDateTime registerDateTime;

     ...
}

GET /api/members 요청 시 Response Body

[
     {
         "id": 62,
         "email": "test@test.com",
         "name": "test",
         "registerDateTime": "20210305160739"
     }
]

java.time.LocalDateTime/java.util.Date -> ISO-8601 형식

RestController에 다음과 같은 api 두개를 추가해보았다.

package controller;

import java.time.LocalDateTime;
import java.util.Date;
...

@RestController
public class MemberRestController {
    ...

    @GetMapping("/api/date")
    public Date date() {
        return new Date();
    }

    @GetMapping("/api/local-date")
    public LocalDateTime localDate() {
        return LocalDateTime.now();
    }
}

여기에 대한 기본적인 실행 결과는 다음과 같다.

/api/date

1615968465377

이 숫자는 유닉스 타임 스탬프, 즉 1970년 1월 1일 이후 흘러간 시간 (단위 : 초) 입니다.

/api/local-date

[
    2021,
    3,
    17,
    17,
    5,
    1,
    198000000
]

아래 코드는 Jackson이 날짜 형식을 출력할 때 유닉스 타임스탬프로 출력하는 기능을 비활성화한다.
이 기능을 비활성화하면 ObjectMapper는 날짜 타입의 값을 ISO-8601 형식으로 출력한다.

@Configuration
@EnableWebMvc
public class MvcConfig implements WebMvcConfigurer {
    ...

    @Override
    public void extendMessageConverters(List<HttpMessageConverter<?>> converters) {
        ObjectMapper objectMapper = Jackson2ObjectMapperBuilder.json()
            .featuresToDisable(
                SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
            .build();
        converters.add(8, new MappingJackson2HttpMessageConverter(objectMapper));
    }
}

이때 주의할 점은, converters 에 커스텀 커버터를 add 할 때
커스텀 날짜 컨버터가 Default 날짜 컨버터보다 앞에 등록되어야
커스텀 날짜 컨버터가 사용된다는 점이다.

java.util.Date 타입 -> 원하는 Format

@Configuration
@EnableWebMvc
public class MvcConfig implements WebMvcConfigurer {
    ...

    @Override
    public void extendMessageConverters(List<HttpMessageConverter<?>> converters) {
        ObjectMapper objectMapper = Jackson2ObjectMapperBuilder.json()
            .simpleDateFormat("yyyy-MM-dd HH:mm:ss")
            .build();
        converters.add(0, new MappingJackson2HttpMessageConverter(objectMapper));
    }
}

이러면 java.util.Date 타입은 yyyy-MM-dd HH:mm:ss 형태의 문자열로 바뀐다.
LocalDateTime 타입에는 이 커스텀 컨버터가 적용되지 않고, ISO-8601 형식으로 변환된다.

java.time.LocalDateTime 타입 -> 원하는 Format

@Configuration
@EnableWebMvc
public class MvcConfig implements WebMvcConfigurer {
    ...

    @Override
    public void extendMessageConverters(List<HttpMessageConverter<?>> converters) {
        DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");

        ObjectMapper objectMapper = Jackson2ObjectMapperBuilder.json()
            .serializerByType(LocalDateTime.class, new LocalDateTimeSerializer(formatter))
            .build();
        converters.add(0, new MappingJackson2HttpMessageConverter(objectMapper));
    }
}

이렇게 하면 LocalDateTime 은 yyyy-MM-dd HH:mm:ss 형태로 바뀌고,
Date 타입에는 이 커스텀 컨버터가 적용되지 않는다. 확인해보니 유닉스 타임 스탬프로 나왔다.

+a

  • converter 목록에 등록된 converter 보다 @JsonFormat 어노테이션 설정이 우선된다.
  • Json 형식의 response body 로 응답을 보내면 HttpResponse 의 Content-Type 헤더 값이 "application/json" 이 된다.

2.4. ResponseEntity

앞서 만들었던 @GetMapping("/api/date") 를 다음과 같이 바꿔보았다.

@GetMapping("/api/date")
public Date date(@RequestParam String name,  HttpServletResponse response)
        throws IOException {
    if (Objects.isNull(name) || name.isEmpty()) {
        response.sendError(HttpServletResponse.SC_BAD_REQUEST, "name 빼먹었다");
    }
    return new Date();
}

이러면 요청 시 query string 으로 name 파라미터를 전달해야한다.

## 요청
GET http://localhost:8080/Gradle___springmvc_war/api/date?name=yelim

## 응답
HTTP/1.1 200 
Content-Type: application/json;charset=UTF-8
Transfer-Encoding: chunked
Date: Wed, 17 Mar 2021 13:03:33 GMT
Keep-Alive: timeout=20
Connection: keep-alive

1615986213155

컨트롤러 메서드의 if 문 때문에,
name 에 빈 값을 전달하면 다음과 같은 응답이 온다.

## 요청
GET http://localhost:8080/Gradle___springmvc_war/api/date?name=

## 응답
HTTP/1.1 400 
--응답 헤더 생략--

<!doctype html>
<html lang="ko">
<head><title>HTTP 상태 400 – 잘못된 요청</title>
  <style type="text/css">body {
    font-family: Tahoma, Arial, sans-serif;
  }

  h1, h2, h3, b {
    color: white;
    background-color: #525D76;
  }
  -- 생략 --
  </style>
</head>
<body><h1>HTTP 상태 400 – 잘못된 요청</h1>
<hr class="line"/>
<p><b>타입</b> 상태 보고</p>
<p><b>메시지</b> name 빼먹었다</p>
...

그런데 정상 응답은 Json 형식이고 오류 응답은 HTML 형식이다.

오류 시 HTML 대신 Json 형식의 에러메세지를 내려주도록 바꿔보자.

@RestController
public class MemberRestController {
    ...
    @GetMapping("/api/date")
    public ResponseEntity<Object> date(@RequestParam String name) {
        if (Objects.isNull(name) || name.isEmpty()) {
            return ResponseEntity.badRequest().body("name 빼먹었다");
        }
        return ResponseEntity.ok(new Date());
    }
    ...
}
## 요청
GET http://localhost:8080/Gradle___springmvc_war/api/date?name=

## 응답
HTTP/1.1 400 
Content-Type: application/json;charset=UTF-8
Transfer-Encoding: chunked
Date: Wed, 17 Mar 2021 13:19:07 GMT
Connection: close

"name 빼먹었다"

ResponseEntity.ok(new Date());,
ResponseEntity.badRequest().body("name 빼먹었다");
이렇게 ResponseEntity 로 return 하면 응답 헤더부분까지 컨트롤할 수 있어서 좋다.

2.5. @ExceptionHandler

GET /api/date, /api/local-date 컨트롤러 메서드에서 더 많은 예외처리를 추가해볼 것이다.

public class MemberService {
    ...
    public String hello(String name) {
        if (Objects.isNull(name) || name.isEmpty()) throw new EmptyNameException();
        if (name.length() > 10) throw new IllegalNameFormatException();
        return name + "님 반갑습니다!";
    }
}
@RestController
public class MemberRestController {
    ...
    @GetMapping("/api/date")
    public ResponseEntity<Object> date(@RequestParam String name) {
        try {
            String hello = memberService.hello(name);
            return ResponseEntity.ok(hello + new Date());
        } catch (EmptyNameException e) {
            return ResponseEntity.badRequest()
                .body("name 빼먹었다");
        } catch(IllegalNameFormatException e) {
            return ResponseEntity.badRequest()
                .body("이름 형식이 잘못되었음");
        }
    }

    @GetMapping("/api/local-date")
    public ResponseEntity<Object> localDate(String name) {
        try {
            return ResponseEntity.ok(memberService.hello(name) + LocalDateTime.now());
        } catch (EmptyNameException e) {
            return ResponseEntity.badRequest()
                .body("name 빼먹었다");
        } catch (IllegalNameFormatException e) {
            return ResponseEntity.badRequest()
                .body("이름 형식이 잘못되었음");
        }
    }
}
  • date() 메서드 안에서 예외처리를 구구절절 해주는게 지저분하다. 이 메서드에서 핵심 로직은 사실상 try 안에 있는 두줄이 전부다.
  • 에러처리 코드가 중복되고 있다.

에러 처리 코드 분리하기

에러처리 코드를 다음과 같이 분리할 수 있다.

@RestController
public class MemberRestController {

    @GetMapping("/api/date")
    public ResponseEntity<Object> date(@RequestParam String name) {
        String hello = memberService.hello(name);
        return ResponseEntity.ok(hello + new Date());
    }

    @GetMapping("/api/local-date")
    public ResponseEntity<Object> localDate(String name) {
        return ResponseEntity.ok(memberService.hello(name) + LocalDateTime.now());
    }

    @ExceptionHandler(EmptyNameException.class)
    public ResponseEntity<String> handleEmptyNameException() {
        return ResponseEntity.badRequest()
            .body("name 빼먹었다");
    }

    @ExceptionHandler(IllegalNameFormatException.class)
    public ResponseEntity<String> handleIllegalNameFormatException() {
        return ResponseEntity.badRequest()
            .body("이름 형식이 잘못되었음");
    }
}
  • 컨트롤러 내부의 exception handling 메서드는 해당 컨트롤러에 대해서만 동작한다.

Exception Handling을 별도의 클래스로 분리하기

익셉션 핸들링을 별도의 클래스로 분리하여
하나의 Exception Handler를 여러 컨트롤러에 대해 적용할 수 있다.

@RestController
public class MemberRestController {
    ...

    @GetMapping("/api/date")
    public ResponseEntity<Object> date(@RequestParam String name) {
        String hello = memberService.hello(name);
        return ResponseEntity.ok(hello + new Date());
    }

    @GetMapping("/api/local-date")
    public ResponseEntity<Object> localDate(String name) {
        return ResponseEntity.ok(memberService.hello(name) + LocalDateTime.now());
    }
}
@RestControllerAdvice("controller")  // @RestControllerAdvice(basePackages = "controller") 이거랑 같음
public class MemberRestExceptionHandler {

    @ExceptionHandler(EmptyNameException.class)
    public ResponseEntity<String> handleEmptyNameException() {
        return ResponseEntity.badRequest()
            .body("name 빼먹었다");
    }

    @ExceptionHandler(IllegalNameFormatException.class)
    public ResponseEntity<String> handleIllegalNameFormatException() {
        return ResponseEntity.badRequest()
            .body("이름 형식이 잘못되었음");
    }
}
@Configuration
public class ControllerConfig {
    ...
    @Bean
    public MemberRestExceptionHandler MemberRestExceptionHandler() {
        return new MemberRestExceptionHandler();
    }
}

3. Json 형식의 Request Body 를 커맨드 객체로 받기

3.1. 기본 사용법

커맨드 객체에 @ResponseBody 어노테이션을 붙이면 된다.

@RestController
public class MemberRestController {
    ...
    @PostMapping("/api/members")
    public ResponseEntity<Object> newMember(@RequestBody @Valid RegisterRequest request) throws IOException {
        try {
            Long newMemberId = memberService.register(request);
            return ResponseEntity.created(URI.create("/api/members/" + newMemberId))
                .build();
        } catch (DuplicateMemberException e) {
            return ResponseEntity.badRequest()
                .body(e.getMessage());
        }
    }
}

@Valid 어노테이션도 똑같이 사용할 수 있다.

실행 결과는 다음과 같다.

## 요청
POST http://localhost:8080/Gradle___springmvc_war/api/members
Content-Type: application/json

{
  "email": "test@email.com",
  "password": "1234",
  "confirmPassword": "1234",
  "name": "성이름"
}

## 응답
HTTP/1.1 201 
Location: /api/members/1
Content-Length: 0
Date: Fri, 19 Mar 2021 14:33:59 GMT
Keep-Alive: timeout=20
Connection: keep-alive

<Response body is empty>

status code가 201 Created 인 것과, Location 헤더값이 잘 들어있는 것을 확인할 수 있다.

같은 요청을 한번 더 보내면, 이미 test@email.com 으로 가입한 회원이 있어서 오류가 난다.

## 요청
POST http://localhost:8080/Gradle___springmvc_war/api/members
Content-Type: application/json

{
  "email": "test@email.com",
  "password": "1234",
  "confirmPassword": "1234",
  "name": "성이름"
}

## 응답
HTTP/1.1 400 
Content-Type: application/json;charset=UTF-8
Transfer-Encoding: chunked
Date: Fri, 19 Mar 2021 14:37:44 GMT
Connection: close

"test@email.com은 이미 존재하는 회원입니다.\n"

Exception Handler 를 적용해서 컨트롤러 코드를 정리해주자.

@RestController
public class MemberRestController {
    ...
    @PostMapping("/api/members")
    public ResponseEntity<Void> newMember(@RequestBody @Valid RegisterRequest request) throws IOException {
        Long newMemberId = memberService.register(request);
        return ResponseEntity.created(URI.create("/api/members/" + newMemberId))
            .build();
    }
}

@RestControllerAdvice(basePackages = "controller")
public class MemberRestExceptionHandler {

    ...

    @ExceptionHandler(DuplicateMemberException.class)
    public ResponseEntity<String> handleDuplicateMemberException(Exception e) {
        return ResponseEntity.badRequest()
            .body(e.getMessage());
    }
}

매개변수 e 에는 throw 된 예외 객체가 들어온다. 실행 결과는 전과 같다.

날짜 값 변환

다음과 같이, 문자열= input을 받아서 RequestDTO의 LocalDateTime 필드에 자동으로 넣도록 하고싶다.

<input type="text" name="from" />
<input type="text" name="to" />
package spring;

import java.time.LocalDateTime;

public class MyRequestDto {

    private LocalDateTime time;

    public LocalDateTime getTime() {
        return time;
    }

    public void setTime(LocalDateTime time) {
        this.time = time;
    }
}

이럴 때는 DTO 필드에 @DateTimeFormat(pattern = "yyyyMMddHH") 어노테이션을 붙이면 된다.

package spring;

import java.time.LocalDateTime;

public class MyRequestDto {

    @DateTimeFormat(pattern = "yyyyMMddHH")
    private LocalDateTime time;

    public LocalDateTime getTime() {
        return time;
    }

    public void setTime(LocalDateTime time) {
        this.time = time;
    }
}

PathVariable

@Controller
public class MemberController {
    ...

    @GetMapping("/members/{id}")
    public String detail(@PathVariable("id") Long memberId, Model model) {
        ...
    }
}

Exception Handling

1 - 특정 컨트롤러 내에서

@Controller
public class MemberController {

    ...

    @ExceptionHandler(TypeMismatchException.class)
    public String handleTypeMismatchException() {
        return "member/invalid";
    }

    @ExceptionHandler(MemberNotFoundException.class)
    public String handleNotFoundException() {
        return "member/noMember";
    }
}
  • 같은 컨트롤러에 @ExceptionHandler 어노테이션을 적용한 메서드가 존재하면 그 메서드가 Exception을 처리한다.
  • 위와 같이 작성하면 MemberController 의 메서드 실행시
    MemberNotFoundException 이 발생하면 handleNotFoundException() 이 실행되고, TypeMismatchException 이 발생하면 handleTypeMismatchException() 이 실행된다.
  • org.springframework.beans.TypeMismatchException : 경로 변수값의 타입이 올바르지 않을 때 발생

2 - 다수의 컨트롤러에 대하여

1. @ControllerAdvice & @ExceptionHandler 으로 다음과 같이 구현

package controller;

import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;

@ControllerAdvice("controller")    // controller 패키지에 대해서
public class CommonExceptionHandler {

    @ExceptionHandler(Exception.class)
    public String handleRuntimeException() {
        return "error/commonException";
    }
}

2. 구현한 ExceptionHandler 객체를 Bean 으로 등록

package config;

import ...

@Configuration
public class ExceptionConfig {

    @Bean
    public CommonExceptionHandler commonExceptionHandler() {
        return new CommonExceptionHandler();
    }
}

@ExceptionHandler 적용 메서드 우선순위

@ControllerAdvice 클래스에 있는 @ExceptionHandler 메서드와 컨트롤러 클래스에 있는 @ExceptionHandler 메서드 중 컨트롤러에 적용된 @ExceptionHandler가 우선됨

컨트롤러에서 HttpSession 사용하기

방법1. 컨트롤러 메서드에 HttpSession 파라미터 추가

-   HttpSession 을 생성하기 전이면 새로운 HttpSession을 생성해서 파라미터로 전달하고, 그렇지않으면 기존에 존재하는 HttpSession을 전달한다.

방법2. 컨트롤러 메서드에 HttpServletRequest 파라미터를 추가하고 HttpServletRequest#getSession()

-   HttpServletRequest#getSession() 호출 시에, HttpSession 생성 전이면 HttpSession 을 생성한다.

세션 이해하기

로그인 예를 들어보자.

1 브라우저가 로그인 요청을 보냄
2 서버에서 아이디, 비밀번호를 확인한 다음,
1 세션 키를 생성한다.
2 해당 세션 키에 대응하는 저장소를 생성한다.
3 세션 키를 담은 쿠키를 응답으로 내려준다. (응답 헤더 Set-Cookie)
4 이후 브라우저가 요청을 보낼 때 요청의 쿠키 헤더에 해당 Session 키를 담아서 보냄
5 요청 헤더 쿠키의 Session key 사용

(session key = J SESSION ID 인듯)

인터셉터 사용하기

1 HandlerInterceptor 인터페이스 구현하기

다음은 springframework.web.servlet.HandlerInterceptor 인터페이스이다.


public interface HandlerInterceptor {

    default boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
            throws Exception {

        return true;
    }

    default void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler,
            @Nullable ModelAndView modelAndView) throws Exception {
    }

    default void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler,
            @Nullable Exception ex) throws Exception {
    }
}

preHandle

  • HandlerMapping 이 요청 url을 이용해 컨트롤러 찾아준 이후, DispatcherServlet 이 HandlerAdaptor 에게 처리요청을 하기 전

postHandle

  • HandlerMapping 이 처리를 수행하고 ModelAndView 를 return 한 이후, DispatcherServlet이 View 에게 응답 생성 요청을 하기 전

afterCompletion

  • 클라이언트에게 응답이 전송된 이후

2 설정파일에서 인터셉터 객체를 Bean 등록하기

package config;

import ...

@Configuration
@EnableWebMvc
public class MvcConfig implements WebMvcConfigurer {

    ...

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(authCheckInterceptor())
            .addPathPatterns("/edit/**");
    }

    @Bean
    public AuthCheckInterceptor authCheckInterceptor() {
        return new AuthCheckInterceptor();
    }
}

출처

세션 이해하기

https://m.blog.naver.com/PostView.nhn?blogId=good_ray&logNo=221360993022&proxyReferer=https:%2F%2Fwww.google.com%2Fblog.naver.com/good_ray/221360883685

1. 커맨드 객체 validation 하기

1. springframework.validation.Validator 를 implement 하는 클래스를 구현한다.

아래는 springframework.validation.Validator 인터페이스이다.

package org.springframework.validation;

public interface Validator {

    boolean supports(Class<?> clazz);

    void validate(Object target, Errors errors);

}

<구현>

package controller;

import ...

public class RegisterRequestValidator implements Validator {

    ...

    // 파라미터로 전달받은 clazz 객체가 RegisterRequest 타입으로 변환이 가능한지 확인한다.
    @Override
    public boolean supports(Class<?> clazz) {
        return RegisterRequest.class.isAssignableFrom(clazz);
    }

    @Override
    public void validate(Object target, Errors errors) {
        RegisterRequest registerRequest = (RegisterRequest) target;

        if (isEmailEmpty(registerRequest)) {
            // "email" 프로퍼티(필드)의 에러코드로 "required"를 추가
            errors.rejectValue("email", "required");
        }
        else if (isEmailFormatWrong(registerRequest)) {
            errors.rejectValue("email", "bad");
        }
        // "name" 필드가 비어있거나 공백으로만 이루어져있으면 "name" 필드에 "required" 에러코드 추가
        ValidationUtils.rejectIfEmptyOrWhitespace(errors, "name", "required");
        ValidationUtils.rejectIfEmpty(errors, "password", "required");
        ValidationUtils.rejectIfEmpty(errors, "confirmPassword", "required");

        if (!registerRequest.isPasswordEqualToConfirmPassword()) {
            errors.rejectValue("confirmPassword", "nomatch");
        }
    }

    private boolean isEmailEmpty(RegisterRequest registerRequest) {
        ...
    }

    private boolean isEmailFormatWrong(RegisterRequest registerRequest) {
        ...
    }
}
  • Errors 객체는 커맨드 객체의 프로퍼티 값을 구할 수 있는 getFieldValue() 메서드를 제공한다.
    따라서 ValidationUtils.rejectIfEmptyOrWhitespace() 메서드가 커맨드객체 없이 커맨드객체의 값을 검사할 수 있다.

2. 구현한 Validator 를 컨트롤러 메서드에서 사용한다.

package controller;

import ...

@Controller
public class RegisterController {

    ...

    @PostMapping("/register/step3")
    public String handleStep3(RegisterRequest registerRequest, Errors errors) {
        new RegisterRequestValidator().validate(registerRequest, errors);

        if (errors.hasErrors()) {
            return "register/step2";
        }
        try {
            registerService.register(registerRequest);
            return "register/step3";
        } catch (Exception e) {
            errors.rejectValue("email", "duplicate");
            return "register/step2";
        }
    }
}
  • request mapping 어노테이션을 적용한 메서드의 커맨드객체 파라미터 뒤에 Errors 타입 파라미터가 위치하면, 스프링MVC가 이 메서드를 호출할 때 이 커맨드객체와 연결된 Errors 객체를 생성해서 파라미터로 전달한다.

2. 커맨드 객체의 에러 메세지 출력하기

여기까지 구현했으면 step2 회원가입 실패시 다시 step2 화면을 보여준다.

이번에는 뭐가 잘못됐는지를 View에서 친절하게 출력해주도록 구현해보자.

에러 메세지는 Message Source 로 관리한다.

 

1. properties 파일 작성

  • 에러코드

    앞서 구현한 Validator 를 보면 errors.rejectValue("email", "required") 이런 부분을 볼 수 있다.

    첫번째 파라미터는 커맨드객체의 필드 이름, 두번째 파라미터는 errorCode 이다.

이 에러코드들 각각에 대응시킬 메세지를 properties 파일로 정의하자.

패키지나 파일이름은 마음대로 해도 된다.

# src/resources/my-message/my-test/my-label.properties
required=필수항목입니다.
bad.email=이메일이 올바르지 않습니다.
duplicate.email=중복된 이메일입니다.
nomatch.confirmPassword=비밀번호와 확인이 일치하지 않습니다.
  • 에러 코드에 해당하는 메시지 코드를 찾을 때에는 다음 규칙을 따른다.
    1. 에러코드.커맨드객체이름.필드명
    2. 에러코드.필드명
    3. 에러코드.필드타입
    4. 에러코드

2. MessageSource 빈 등록

setBasenames 파라미터는 properties 파일의 패키지.파일이름 이렇게 써주면 된다.

package config;

import org.springframework.context.MessageSource;
import ...

@Configuration
@EnableWebMvc
public class MvcConfig implements WebMvcConfigurer {

    @Bean
    public MessageSource messageSource() {
        ResourceBundleMessageSource ms = new ResourceBundleMessageSource();
        ms.setBasenames("my-message.my-test.my-label");
        ms.setDefaultEncoding("UTF-8");
        return ms;
    }

    ...
}

 

3. <form:errors> 로 에러메세지 출력

<%@ page contentType="text/html; charset=utf-8" %>
<%@ taglib prefix="form" uri="http://www.springframework.org/tags/form" %>
<%@ taglib prefix="spring" uri="http://www.springframework.org/tags"%>
<!DOCTYPE html>
<html>
<head>
    <title>회원가입</title>
</head>
<body>
    <h2>회원 정보 입력</h2>
    <form:form action="step3" method="post" modelAttribute="registerRequest">
    <p>
        <label>이메일:<br>
        <form:input path="email"/>
        <form:errors path="email"/>
        </label>
    </p>
    <p>
        <label>이름:<br>
        <form:input path="name"/>
        <form:errors path="name"/>
        </label>
    </p>
    <p>
        <label>비밀번호:<br>
        <form:password path="password"/>
        <form:errors path="password"/>
        </label>
    </p>
    <p>
        <label>비밀번호 확인:<br>
        <form:password path="confirmPassword"/>
        <form:errors path="confirmPassword"/>
        </label>
    </p>
    <input type="submit" value="가입 완료">
    </form:form>
</body>
</html>
  • <form:errors> 태그의 path 속성 : 에러메세지를 출력할 프로퍼티 이름

4. 글로벌 범위 Validator

글로벌 범위 Validator는 모든 컨트롤러에 적용할 수 있는 Validator 이다.

  1. 스프링MVC 설정파일에서 글로벌 범위 Validator를 Bean 으로 등록한다.

    package config;
    
    import ...
    
    @Configuration
    @EnableWebMvc
    public class MvcConfig implements WebMvcConfigurer {
    
      ...
    
      @Override
      public Validator getValidator() {
          return new RegisterRequestValidator();
      }
    }
  2. 글로벌 범위 Validator 를 사용하고 싶은 곳에 @Valid 를 붙인다.

    package controller;
    
    import ...
    
    @Controller
    public class RegisterController {
    
      ...
    
      @GetMapping(value = "/register/step1")
      public String handleStep1() {
          return "register/step1";
      }
    
      ...
    
      @PostMapping("/register/step3")
      public String handleStep3(@Valid RegisterRequest registerRequest, Errors errors) {
          if (errors.hasErrors()) {
              return "register/step2";
          }
          try {
              registerService.register(registerRequest);
              return "register/step3";
          } catch (Exception e) {
              errors.rejectValue("email", "myduplicate");
              return "register/step2";
          }
      }
    }
    
    • 만약 Erros 타입 파라미터가 없으면, 검증 실패시 400 에러를 응답한다.

5. 스프링 MVC가 제공하는 글로벌 Validator 사용하기

Bean Validation 2.0 (JSR 380) 이 제공하는 어노테이션을 이용해서 validation 을 해보자.

  1. dependency 추가

    compile 'javax.validation:validation-api:1.1.0.Final'
    compile 'org.hibernate:hibernate-validator:5.4.2.Final'
  2. 커맨드객체에 어노테이션 사용하기

    package spring;
    
    import javax.validation.constraints.Email;
    import javax.validation.constraints.NotBlank;
    import javax.validation.constraints.NotEmpty;
    import javax.validation.constraints.Size;
    
    public class RegisterRequest {
    
        @Email
        @NotBlank    // null 이거나 공백으로만 이루어져있으면 에러
        private String email;
        @Size(min = 6, max = 20)    // size가 지정한 값 범위에 있는지 검사
        private String name;
        @NotEmpty
        private String password;
        @NotEmpty
        private String confirmPassword;
    
        public String getEmail() {
            return email;
        }
    
        public void setEmail(String email) {
            this.email = email;
        }
    
        public String getPassword() {
            return password;
        }
    
        public void setPassword(String password) {
            this.password = password;
        }
    
        public String getConfirmPassword() {
            return confirmPassword;
        }
    
        public void setConfirmPassword(String confirmPassword) {
            this.confirmPassword = confirmPassword;
        }
    
        public String getName() {
            return name;
        }
    
        public void setName(String name) {
            this.name = name;
        }
    
        public boolean isPasswordEqualToConfirmPassword() {
            return password.equals(confirmPassword);
        }
    }
    
  • 이제 OptionalValidatorFactoryBean 객체를 빈으로 등록해야 한다.
    그런데 @EnableWebMvc 어노테이션을 사용하면 자동으로 등록되므로 신경 안써도 된다.
    단, 커스텀 글로벌 Validator를 만들어서 등록해놓으면 OptionalValidatorFactoryBean가 글로벌 범위 Validator로 사용 되지 않는다. 커스텀 글로벌 Validator가 있다면 삭제하자.

  • @Size 어노테이션의 경우 값이 null 이면 유효하다고 판단한다. 이렇게, null일 때 유효하다고 판단하는 어노테이션들을 쓸 때는 @NotNull 을 같이 사용해주자.

6. @InitBinder

@InitBinder 어노테이션은 컨트롤러에 들어오는 요청에 대해 preprocessing을 할 때 쓰인다.
WebDataBinder를 초기화하는 메서드에 붙인다.

 

Request 를 preprocessing 하는 예를 살펴보자.

 

다음과 같이 이름 앞, 뒤에 공백을 붙여서 가입해보았다. 그랬더니 DB에도 이 공백이 저장되었다.

 

preprocess 를 통해서 이름의 앞뒤 공백을 제거해보자.

 

package controller;

import org.springframework.beans.propertyeditors.StringTrimmerEditor;
import org.springframework.web.bind.WebDataBinder;
import org.springframework.web.bind.annotation.InitBinder;
...

@Controller
public class RegisterController {

    ...

    @PostMapping("/register/step3")
    public String handleStep3(@Valid RegisterRequest registerRequest, Errors errors) {
        ...
    }

    @InitBinder
    public void initBinder(WebDataBinder binder) {
        StringTrimmerEditor stringTrimmerEditor = new StringTrimmerEditor(true);
        binder.registerCustomEditor(String.class, stringTrimmerEditor);
    }
}

 

 

앞뒤 공백이 제거되고 'j' 만 저장되었음을 볼 수 있다.

RegisterController에 있는 모든 커맨드객체에 대하여

String 필드는 이제 앞뒤 여백이 사라진 채로 들어올 것이다.

 

이러한 커맨드객체 preprocessing은 validation보다도 먼저 수행된다.

 

7. 컨트롤러 범위 Validator

package controller;

import ...

@Controller
public class RegisterController {

    ...

    @PostMapping("/register/step3")
    public String handleStep3(RegisterRequest registerRequest, Errors errors) {
        ...
    }

    @InitBinder
    public void initBinder(WebDataBinder binder) {
        binder.setValidator(new RegisterRequestValidator());
    }
}

 

 - WebDataBinder 는 validator 목록을 가지고있다. 이 목록에는 글로벌 범위 Validator가 기본으로 포함된다.

 - setValidator()를 사용하면 기존의 validator 목록을 삭제하고 전달받은 Validator 를 목록에 추가한다.

   즉 setValidator() 메서드를 사용하면 글로벌 범위 Validator 대신에 컨트롤러 범위 Validator를 사용하게 된다.

 


출처

초보 웹 개발자를 위한 스프링 5 프로그래밍 입문 (최범균) - ch 12

 

@InitBinder
https://medium.com/stackavenue/how-to-use-initbinder-in-spring-mvc-ecb974a6884

+ Recent posts