스프링 프레임워크에서 제공하는 JPA는 별도의 구현 클래스 없이 인터페이스만을 사용할 수 있도록 제공한다. 제공되는 인터페이스 JpaRepository는 실행시점에 자동으로 인터페이스 내용을 연결하는 엔티티에 맞게 자동으로 구현해준다. 만약 스프링 JPA 인터페이스에서 제공하지 않는 기능을 사용하고 싶을 때는 메서드명을 특정한 규칙대로 만들어서 사용하면 인터페이스가 알아서 그 이름에 맞는 JPQL을 만들어서 실행해준다.


스프링 JPA 인터페이스는 Mysql같은 RDBMS 뿐만 아니라 Mongodb, Redis와 같은 NoSQL에도 동일한 인터페이스를 사용해서 기능을 사용할 수 있도록 제공해준다. 공통으로 사용할 수 있기에 아주 편리하다.


우선 스프링 부트에 JPA를 사용하기 위해서 Gradle에 라이브러리를 넣자.

1
compile ('org.springframework.boot:spring-boot-starter-data-jpa')
cs

 

기본적인 구조

JpaRepository를 상속받아 구현하고자 하는 인터페이스를 만들고 제네릭에 구현하려는 엔티티와 엔티티의 식별자 타입을 지정하여 인터페이스를 선언한다.

1
2
3
public interface StudentRepository extends JpaRepository<Student, Long> {
}
 
cs


주요 메서드 몇개만 정리해보자.

save() : 저장하거나 업데이트한다.

delete(entity) : em.remove() 호출하여 엔티티를 제거한다.

findOne(ID) : em.find() 호출하여 엔티티를 찾는다.

findAll() : 엔티티 모두를 조회한다. 


JpaRepository 인터페이스로 부족한 기능을 구현한 PagingAndSortingRepository CrudRepository 사용해서 보완할 있다.


JPA에서 메서드 사용하는 방법

JPA에서 엔티티에 맞는 메서드를 사용하는 방법은 크게 3가지이다.

- 메서드 이름으로 쿼리 생성

- 메서드 이름으로 JPA NamedQuery 호출

- @Query 어노테이션을 사용해서 레포지토리 인터페이스에 쿼리 직접 정의


1. 메서드 이름으로 쿼리 생성

메서드 이름으로 쿼리를 생성할 있는데 정식 Document 이용하면 자세히 나와있다

https://docs.spring.io/spring-data/jpa/docs/2.1.2.RELEASE/reference/html/#jpa.query-methods


2. 메서드 이름으로 JPA NamedQuery 호출

JPA NamedQuery 쿼리에 이름을 부여해서 사용하는 방법은 다음과 같이 엔티티에 선언해주면 된다.

1
2
3
4
5
@NamedQuery(
  name="student.findByName",
  query="select s from student s where s.name = :name")
public class Student {

}
cs


위와 같이 선언하고 실제 사용할 때는 entityManager에 아래와 같이 createQuery를 사용해서 쿼리를 호출하면 된다.

1
2
3
4
5
6
7
@PersistenceContext
private EntityManager entityManager;
 
public List<Student> findByUser(String name) {
  List<Student> students = entityManager.createQuery("student.findByName", Student.class).setParameter("name""wedul").getResultList();
}
 
cs


위와 같이 EntityManager를 사용할 수 있지만 스프링 JPA를 사용하여 간단하게 메소드 이름만으로 호출이 가능하다. 이렇게 호출하면 레포지토리에서 Student.쿼리메소드 형태로 쿼리를 찾는다. 만약 실행할 쿼리가 없으면 메서드 이름으로 쿼리를 자동으로 바꿔 동작한다.

1
2
3
4
5
public interface StudentRepository extends JpaRepository<Student, Long> {
  
  List<Student> findByUserName(@Param("name") String name);
  
}
cs


3. @Query 어노테이션을 사용해서 레포지토리 인터페이스에 쿼리 직접 정의

2번에서는 @Entity 클래스에서 정의한 쿼리를 레포지토리에서 메소드 형태로 접근하여 사용하였다.  이번에는 레포지토리에서 직접적으로 쿼리를 만들어서 조회하는 방식을 확인해보자.


@Query("select s from Student s where s.name = ?1")

Student findByName(String name);


인터페이스에 정의하는 메소드에 @Query 어노테이션을 붙혀서 정의하고 사용하면 된다. 바인딩 값은 1부터 시작한다. 스프링 데이터 JPA에서는 ?1 ?2 같은 위치기반 파라미터와 :name 같은 이름 기반 방식을 모두 사용가능하다.


페이징과 정렬

스프링 데이터 JPA에서 쿼리 메서드에 페이징과 정렬 기능을 사용할 있다. 파라미터로 Pageable 인터페이스를 사용할 경우에는 Page 또는 List 반환 받을 있다.

1
2
3
4
public interface StudentRepository extends JpaRepository<Student, Long> {
 
  Page<Student> findByNameStartingWith(String name, Pageable pageable);
}
cs


실제 사용할 때는 Pageable 인터페이스이기 때문에 구현체인 PageRequest 객체를 사용해서 사용한다.

// 파라미터 순서대로 페이지 번호, 사이즈, 정렬 기준 등으로 사용한다.

1
PageRequest pageRequest = new PageRequest(0, 10, new Sort(Sort.Direction.DESC, "name"));
cs


반환되는 값인 Page에서 제공하는 다양한 메소드를 사용해서 편하게 페이징과 소트 기능을 사용할 있다. 

컨트롤러에서 사용자 요청에게 전달되는 페이징 정보를 받기 위해서는 다음과 같이 Pageable 인터페이스를 받으면 되고 받을 속성값은 page, size, sort 사용해서 받는다. (/student?page=0&size=20&sort=name,desc&sort=address.city)


1
2
3
4
5
6
7
@GetMapping("/student")
public String list(Pageable pageable, Model model) {
  Page<Student> page = studentRepository.findByNameStartingWith("dbsafer", pageable);
 
  return "dfdfdf";
}
 
cs


참고 싸이트

https://docs.spring.io/spring-data/commons/docs/current/api/org/springframework/data/domain/Page.html


출처. : 자바 ORM 표준 JPA 프로그래밍


리액티브 스트림은 총 4개의 인터페이스로 구성되어 있다. 

ㅁ 발행자(Publisher) 

- 데이터의 소스를 가지고 있으며 Subscriber의 요청이 오면 데이터를 발행한다. 구독자는 발행자에 대한 구독을 추가할 수 있다. Subscribe 메소드를 통해서 구독자를 추가할수 있다.

1
2
3
public interface Publisher<T> {
    public void subscribe(Subscriber<? Super T> s);
}
cs


ㅁ 구독자 (Subscriber) 

-  구독자는 데이터 스트림을 소비하기 위해 발행자를 구독한다. 구독자는 다양한 메서드를 제공하는데 대부분의 메서드가 콜백으로 등록되어 사용된다.

1
2
3
4
5
6
public interface Subscriber<T> {
  public void onSubscribe(Subscription s);
  public void onNext(T t);
  public void onError(Throwable t);
  public void onComplete();
}
cs


ㅁ 구독 (subscription) 

단 하나의 발행자와 단 하나의 구독자를 연결해주며 그 둘 사이에서만 데이터 교환을 중재한다. 데이터 교환은 구독자의 request 메서드 호출로 실행되고 cancel로 종료된다.

1
2
3
4
public interface Subscription {
  public void request(long n);
  public void cancel();  
cs


ㅁ 프로세서 (procesor) 

프로세서는 처리 단계를 나타내며, 발행자 인터페이스와 구독자 인터페이스를 모두 상속한다. 프로세서는 발행자와 구독자 사이의 계약을 반드시 준수해야한다. 프로세서는 발행자와 구독자를 연결해서 chaining(메서드가 반환하는 객체를 다른 변수에 할당하지 않고 객체가 가지고 있는 메소드를 호출하는 것)을 할 수도 있다.

1
2
public interface Processor<T, R> extends Subscriber<T>, Publisher<R> {
}
cs



리액터에는 발행자 플로세서로 사용되는 flux와 mono가 있다. flux는 0 또는 N개의 이벤트를 발행할 수 있고, Mono는 0 또는 1 개의 이벤트만 발행할 수 있다. 그래서 다수의 데이터 요소 또는 값의 리스트를 스트림으로 전송할 때만 사용된다.

개인적으로 공부겸 만들고 있는 Wedul Pos에는 아이디와 패스워드를 사용해서 로그인하는 방식을 제공했다.

하지만 페이스북 로그인 방식을 추가해보고 싶어서 facebook 개발자 사이트에 가입하여 정보를 얻고 추가해봤다.

우선 페이스북 로그인 방식을 처리하는 방식은  Facebook Javascript plugin을 사용하여 spring security에서 인증을 하는 방식과 /sign/facebook 요청만 front에서 보내면 server에서 모든 처리를 진행하는 방식 두가지가 있다.

그 중에 첫번째 javascript plugin을 이용하는 방식을 사용해서 구현해보자.

1. facebook developer 사이트에서 javascript 내용 얻기
https://developers.facebook.com/docs/facebook-login/web#confirm 페이지에서 자바스크립트 플러그인에 대한 사용법과 소스를 받을 수 있다. App Id의 경우에는 개발자 사이트에 등록하면 받을 수 있다.

몇 가지 부분만 간단하게 설명을해보자.

※초기화
Facebook javascript 플러그인 사용을 위한 초기화.
{your-app-id}에는 발급받은 App Id를 version에는 최신 버전을 쓰면 된다. 지금은 v3.1이 최신이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
  window.fbAsyncInit = function() {
    FB.init({
      appId      : '{your-app-id}',
      cookie     : true,  // enable cookies to allow the server to access 
                          // the session
      xfbml      : true,  // parse social plugins on this page
      version    : 'v2.8' // use graph api version 2.8
    });
 
    // Now that we've initialized the JavaScript SDK, we call 
    // FB.getLoginStatus().  This function gets the state of the
    // person visiting this page and can return one of three states to
    // the callback you provide.  They can be:
    //
    // 1. Logged into your app ('connected')
    // 2. Logged into Facebook, but not your app ('not_authorized')
    // 3. Not logged into Facebook and can't tell if they are logged into
    //    your app or not.
    //
    // These three cases are handled in the callback function.
 
    FB.getLoginStatus(function(response) {
      statusChangeCallback(response);
    });
 
  };
 
  // Load the SDK asynchronously
  (function(d, s, id) {
    var js, fjs = d.getElementsByTagName(s)[0];
    if (d.getElementById(id)) return;
    js = d.createElement(s); js.id = id;
    js.src = "https://connect.facebook.net/en_US/sdk.js";
    fjs.parentNode.insertBefore(js, fjs);
  }(document'script''facebook-jssdk'));
cs


※ 로그인 상태 체크

로그인 상태 체크를 위해 사용된다. 전달되는 response에는 현재 상태와 accessToken, appId등을 담고있다.

1
2
3
FB.getLoginStatus(function(response) {
    statusChangeCallback(response);
});
cs


1
2
3
4
5
6
7
8
9
10
11
// 상태값. 자세한 설명은 개발자 페이지 참고
{
    status: 'connected',
    authResponse: {
        accessToken: '...',
        expiresIn:'...',
        reauthorize_required_in:'...'
        signedRequest:'...',
        userID:'...'
    }
}
cs


 로그인 요청

로그인 요청 호출 스크립트이다.
같이 입력되어있는 scope에 경우에는 로그인시에 접근가능한 내용에 대한 권한 요청이다. 아래 내용에서 public profile에 대한 권한과 email 정보에 대한 권한을 요청한다.

1
2
3
FB.login(function(response) {
  // handle the response
}, {scope: 'public_profile,email'});
cs

로그인 요청 스크립트가 호출되면 다음과 같이 페이스북에서 제공하는 로그인 화면이 보여진다.


※로그아웃 요청

로그인 되어있는 사용자에 대해 로그아웃을 요청한다.
1
2
3
FB.logout(function(response) {
   // Person is now logged out
});
cs

단 로그아웃을 할때는 먼저 세션의 status를 확인하고 진행해야한다. 그렇지 않고 무작정 FB.logout을 통해 로그아웃을 시도하면 다음과 같은 오류가 발생한다.
FB.logout() called without an access token.

그래서 이런식으로 계정의 상태를 확인하고 진행하자.

1
2
3
4
5
6
7
8
9
10
11
12
// 페이스북 계정 로그아웃
var faceBookLogOut = function(callback) {
    FB.getLoginStatus(function(response) {
      if (response.status === 'connected') {
          FB.logout(function(response) {
              callback();
          });
      } else {
          callback();
      }
    });
};
cs


※ 사용자 정보 요청

로그인이 된 후에 현재 로그인된 사용자의 로그인 상태를 요청하는 스크립트이다.
response의 값은 {"name": "정철", "email" : "rokking1@naver.com", "userID" : "dkfdkfjalkd"} 와 같이 전달된다.

나는 로그인 후에 이 스크립트를 사용하여 전달받은 사용자 정보를 server로 전송하여 DB에 값을 저장하고 로그인 처리하였다. 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
var loginFacebookLoginUserInfo = function() {
    // 로그인한 사용자의 정보 얻기
    FB.api('/me', {fields: 'name,email'},  function(response) {
        let param = {};
        param.snsId = response.id;
        param.nickname = response.name;
        param.email = response.email;
 
        // 사용자 소셜 로그인 요청
        Common.sendAjax({
            url: Common.getFullPath('user/login/facebook'),
            param,
            type: 'POST',
            success: () => {
                Common.pageMove('');
            },
            failed: () => {
                alert(Common.getMessage('user.login.message.checkAccount'));
            }
        });
    });
};
cs


2. Spring Security에서 로그인 처리하기.

1번 스크립트를 통해 페이스북 로그인을 하였다. 그리고 로그인이 성공한 뒤에 받은 사용자 정보를 wedul pos 서버에 전달하여 로그인 처리하였다.

※ 사용자 정보 전달 및 저장

로그인된 사용자 정보를 전달받고 DB에 값을 저장한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// UserController.java
 
/**
   * facebook으로 로그인
   *
   * @param reqDto the req dto
   * @return the response entity
   * @throws Exception the exception
*/
@RequestMapping("/login/facebook")
public ResponseEntity<?> loginfacebook(HttpServletRequest request, UserDto reqDto) throws Exception {
    return ResponseEntity.ok(userService.facebookLogin(request, reqDto));
}
 
// UserService.java (DB에 사용자 정보 저장)
private UserDto insertSnsUser(UserDto reqDto) throws Exception {
  UserDto userDto = selectUser(reqDto);
 
  if (null == userDto) {
    if (insertUser(reqDto)) {
        return reqDto;
    } else {
        return null;
    }
  }
 
  return userDto;
}
 
cs

그리고 Spring Security에 해당 사용자의 로그인 처리를 위해서 UsernamePasswordAuthenticationToken token을 생성하고 securityContext에 Autithentication을 적용해 주었다. 

여기서 삽질을 조금 했는데 securityContext에 Autithentication을 설정만 하면 되는줄 알았는데 그게 아니라 HttpSession에 "SPRING_SECURITY_CONTEXT" 속성에 securityContext를 넣어줘야했다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Override
public ResultDto facebookLogin(HttpServletRequest request, UserDto reqDto) throws Exception {
    UserDto userDto = insertSnsUser(reqDto);
 
    if (null == userDto) {
        return ResultDto.fail("등록된 사용자가 없습니다.");
    }
 
    // 인증 토큰 생성
    MyAuthenticaion token = new MyAuthenticaion(userDto.getSnsId(), "", Arrays.asList(new SimpleGrantedAuthority(Constant.ROLE_TYPE.ROLE_USER.toString())), userDto, EnumLoginType.FACE_BOOK);
    token.setDetails(new WebAuthenticationDetails(request));
    authProvider.authenticate(token);
 
    // Security Context에 인증 토큰 셋팅
    SecurityContext securityContext = SecurityContextHolder.getContext();
    securityContext.setAuthentication(token);
 
    // Create a new session and add the security context.
    HttpSession session = request.getSession(true);
    session.setAttribute("SPRING_SECURITY_CONTEXT", securityContext);
 
    return ResultDto.success();
}
cs


자세한 소스코드는 Git을 참조.

https://github.com/weduls/wedulpos_boot

엔티티를 만들고 데이터를 삽입하고 조작할 때 create date와 last modified date를 별도로 업데이트 해주면서 관리하였다.

하지만 이번에 JPA를 공부하면서 별도의 작업 없이 JPA의 Auditing 기능을 사용하면 데이터를 삽입하고 수정할 때 자동으로 날짜를 수정하도록 할 수 있는 기능이 있는 것을 확인했다.


1. Configuration

JPA Auditing을 사용하기 위해서는 기능을 자동으로 활성화 해주는 어노테이션을 붙혀주면 된다. 처음에는 @Configuration을 사용하는 클래스에 함께 선언해주었는데 정상적으로 적용이 되지 않아서 @SpringBootApplication을 사용하는 곳에 적용했더니 성공적으로 적용되었다.


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
package com.wedul.springboottest;
 
import com.wedul.common.exception.ValidationException;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.web.servlet.error.DefaultErrorAttributes;
import org.springframework.boot.web.servlet.error.ErrorAttributes;
import org.springframework.context.annotation.Bean;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
import org.springframework.web.context.request.WebRequest;
 
import java.util.Map;
 
@SpringBootApplication
@EnableJpaAuditing
public class SpringboottestApplication {
 
    public static void main(String[] args) {
        SpringApplication.run(SpringboottestApplication.class, args);
    }
 
    @Bean
    public ErrorAttributes errorAttributes() {
        return new DefaultErrorAttributes() {
 
            @Override
            public Map<String, Object> getErrorAttributes(WebRequest webRequest,
                                                          boolean includeStackTrace) {
                Map<String, Object> errorAttributes = super.getErrorAttributes(webRequest, includeStackTrace);
                Throwable error = getError(webRequest);
 
                // validatijon Exception에 경우 별도의 처리를 진행한 에러 데이터 추가
                if (error instanceof ValidationException) {
                    errorAttributes.put("errors", ((ValidationException)error).getErrors());
                }
                return errorAttributes;
            }
 
        };
    }
}
 
cs


2. Entity에 CreatedDate, LastModifiedDate

Auditing을 사용할 엔티티에는 몇가지 어노테이션이 사용된다.

먼저 설정을위해서 사용되는 @MappedSuperclass, @EntityListeners(AuditingEntityListener.class)이다. 첫 번재 어노테이션은 이후에 사용될 createdDate, modifiedDate와 같은 필드들을 컬럼으로 인식하게 도와주는 역할을 하고 두 번째 어노테이션은 해당 Entity에 Auditing기능을 포함한다라는 명시를 한다.

처음에는 필요한 Entity에만 CreatedDate, lastModifiedDate가 포함하도록 Entity마다 기재해주었다.하지만 많은 Entity에서 필요로 할 것 같고 필요할 때마다 새로 써주기가 비효율적인 것 같아서 추상클래스로 만들고 필요한 Entity에서 이를 상속받아서 사용하도록 하였다.



- 추상클래스

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
package com.wedul.common.dto;
 
import lombok.Getter;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
 
import javax.persistence.EntityListeners;
import javax.persistence.MappedSuperclass;
import java.time.LocalDateTime;
 
/**
 * 모든 Entity들의 상위 클래스가 되어 Entity들의 createdDate, modifiedDate를 자동으로 관리
 *
 * @author wedul
 * @since 2018. 8. 14.
 **/
@Getter
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class TimeEntity {
 
    @CreatedDate
    private LocalDateTime createdDate;
 
    @LastModifiedDate
    private LocalDateTime modifiedDate;
 
}
cs


- 상속받아서 사용하는 Product Entity

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
package com.wedul.springboottest.product.dto;
 
import com.wedul.common.dto.BaseTimeEntity;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
 
import javax.persistence.*;
import java.io.Serializable;
import java.sql.Timestamp;
 
/**
 * 상품 정보
 *
 * @author wedul
 * @since 2018. 08. 12
 **/
@Data
@AllArgsConstructor
@NoArgsConstructor
@Entity // class (hibernate)
@Table(name = "product")
public class ProductDto extends TimeEntity implements Serializable {
 
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private long productId;
 
    @Column(nullable = false, unique = true)
    private String productName;
 
    @Column(nullable = false)
    private long price;
 
    public ProductDto(ProductRequestDto req) {
        this.productName = req.getProductName();
        this.price = req.getPrice();
    }
 
}
 
cs


테스트 코드를 작성하여 정상적으로 시간값이 들어가고 또 변경되는지 확인해보자.

1. 처음 데이터 삽입시 시간값 입력 테스트

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
/**
 * Product Test
 *
 * @author wedul
 * @since 2018. 8. 14.
 **/
@RunWith(SpringJUnit4ClassRunner.class)
@SpringBootTest // rolleback 설정
@Rollback(value=true)
public class ProductTest {
 
    private MockMvc mockMvc;
    private final MediaType mediaType = new MediaType(MediaType.APPLICATION_JSON.getType(), MediaType.APPLICATION_JSON.getSubtype(), Charset.forName("utf8"));
 
    @Autowired
    ProductCtrl productCtrl;
 
    @Autowired
    ProductServiceI productService;
 
    @Before // before 클래스는 한번만 실해되고 before는 각 테스트마다 실행된다.
    public void beforeClass() {
        this.mockMvc = standaloneSetup(productCtrl).build();
    }
 
    @Test
    public void insertTest_reqestBody() throws Exception {
 
        // Mock Request Builder
        MockHttpServletRequestBuilder req =
                post("/api/product/new")
                        .content(CommonUtil.getJsonStrFromObject(new ProductRequestDto("i-mac", 2220011L)))
                        .contentType(mediaType);
 
        // 테스트
        MvcResult result = mockMvc.perform(req).andExpect(status().isOk()).andReturn();
        ResultDto resultDto = CommonUtil.getObjectFromJsonStr(result.getResponse().getContentAsString(), ResultDto.class);
 
        assertTrue(resultDto.isResult());
    }
}
cs


정상적으로 날짜값이 들어가 있는 것을 확인 할 수있다. 이제 수정 테스트를 진행해보자.


2. 수정 후 last modified date 시간 변경여부 테스트

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Test
public void updateTest_requestBody() throws Exception {
    // Mock Request Builder
    MockHttpServletRequestBuilder req =
           put("/api/product/edit")
           .content(CommonUtil.getJsonStrFromObject(new ProductRequestDto(1,"macbook pro", 2312111L)))
           .contentType(mediaType);
 
    // 테스트
    MvcResult result = mockMvc.perform(req).andExpect(status().isOk()).andReturn();
    ResultDto resultDto = CommonUtil.getObjectFromJsonStr(result.getResponse().getContentAsString(), ResultDto.class);
 
    assertTrue(resultDto.isResult());
}
cs


업데이트 후에 정상적으로 modified_date만 변경된 것을 확인할 수있다.

좋은 기능인것같다. JPA를 공부하고 있는데 Mybatis보다 솔직히 불편하다 그런데 한번 잘 구축해놓으면 편하기는 하다. 그래도 무엇하나 변경된다면 결국 너무 손이 많이가고 러닝커브가 좀 심하다.

흠 더 사용해보고 실무에서 써봐야 이걸 왜 요새 사용하는지 더 이해할 수 있을 것 같다.

자세한 코드는 github 참조
https://github.com/weduls/spring_boot_test


  1. Happy 2019.05.22 17:27

    안녕하세요 글 잘보았습니다. :) 몇가지 궁금증이 생겨서 글을 쓰게 되었습니다.

    만일 특정 사용자가 서비스 로그인 이후에 로그아웃을 하고 다시 로그인을 하면 변경사항은 전혀 없습니다.

    그렇다면 임의로 제가 특정 컬럼을 변경시켜서 (더미컬럼 ?) 수정시간이 jpa auditing 에 의해서 변경되어야 하는건지. 궁금하네요.

    한편으로는 사용자에 해당하는 테이블에 현재 로그인 여부를 컬럼으로 추가해서 그렇게 수정하는 방법도 생각해볼 수 있습니다만 어떻게 하는게 효율적일지 몰라 이렇게 장문으로 글을 남깁니다.

    • Favicon of https://wedul.site BlogIcon 위들 wedul 2019.05.22 18:45 신고

      안녕하세요!

      사용자의 마지막 로그인 시간을 별도의 컬럼으로 관리하시면 어떨까 싶습니다.

      그리고 현재 로그인중인 사용자를 테이블의 컬럼으로 관리하는건 힘들지 않을까 싶습니다.

대게 개발을 진행할 때 front에서 validate를 체크하고 민감한 정보에 대해서는 한번더 체크를 진행하고 작업을 했었다. 

하지만 클라이언트에서만 validation을 체크하게 되는 경우 브라우저에서 악의적인 행동에 대해서 대응하기 어려워질수 있기 때문에 백엔드에서도 Validation을 처리해야한다. 

여기서 사용되는 @valid 어노테이션들을 알아보자.

1. DTO validation 선언
우선적으로 DTO에 각 속성에 필요한 @valid 옵션들을 추가한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
package com.wedul.springboottest.rest.dto;
 
import lombok.*;
 
import javax.validation.constraints.Email;
import javax.validation.constraints.NotBlank;
 
/**
 * 사용자 클래스.
 * User: wedul
 * Date: 2018-08-05
 * Time: 오후 9:47
 */
@Data
@AllArgsConstructor
@EqualsAndHashCode
@NoArgsConstructor
public class UserDto {
    @NotBlank(message = "이름을 입력해주세요.")
    private String name;
 
    private int age;
 
    @NotBlank(message = "이메일을 입력해주세요.")
    @Email(message = "이메일을 양식을 지켜주세요.")
    private String email;
 
    public void updateInfo(UserDto user) {
        this.age = user.getAge();
        this.email = user.getEmail();
    }
}
cs


@Valid 데이터 종류

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@AssertFalse : false 값만 통과 가능
@AssertTrue : true 값만 통과 가능
@DecimalMax(value=) : 지정된 값 이하의 실수만 통과 가능
@DecimalMin(value=) : 지정된 값 이상의 실수만 통과 가능
@Digits(integer=,fraction=) : 대상 수가 지정된 정수와 소수 자리수보다 적을 경우 통과 가능
@Future : 대상 날짜가 현재보다 미래일 경우만 통과 가능
@Past : 대상 날짜가 현재보다 과거일 경우만 통과 가능
@Max(value) : 지정된 값보다 아래일 경우만 통과 가능
@Min(value) : 지정된 값보다 이상일 경우만 통과 가능
@NotNull : null 값이 아닐 경우만 통과 가능
@Null : null일 겨우만 통과 가능
@Pattern(regex=, flag=) : 해당 정규식을 만족할 경우만 통과 가능
@Size(min=, max=) : 문자열 또는 배열이 지정된 값 사이일 경우 통과 가능
@Valid : 대상 객체의 확인 조건을 만족할 경우 통과 가능
 
출처: http://goldenraccoon.tistory.com/entry/Valid-annotation-종류 [황금너구리 블로그]
cs


2. @Request에서 전달받는 파라미터 설정
간단하게 @Valid 설정을 통해 전달받을 객체에 대한 유효성 체크를 설정할 수 있다.

1
2
3
4
5
6
7
8
9
10
 /**
   * Edit user info response entity.
   *
   * @param user the user
   * @return the response entity
 */
 @PutMapping(value = "/user/info")
 public ResponseEntity<?> editUserInfo(@RequestBody @Valid UserDto user) {
     return ResponseEntity.ok(userService.updateUser(user));
 }
cs

3. 테스트
위의 설정한 validation 설정을 위반하여 request를 보내보자

요청

응답

validation 위반 시 400에러와 함께 오류 field와 기존에 설정한 defaultMessage를 확인할 수 있다.


4. 추가적인 action에 대한 validation 설정
단순하게 regex와 null, email 등등에 대한 validation 체크 이외에 중복 체크등을 진행하기 위해서는 별도의 작업이 필요하다.

4-1)  기존의 Validation과 동일한 방식으로 표현하기 위해서 별도의 RuntimeException 클래스를 만든다.

이 예외 클래스는 Response Stauts는 400으로 전달될 수 있게 @ResponseStatus(HttpStatus.BAD_REQUEST)로 설정하고 예외가 발생한 field와 defaultMessage를 출력해주기 위해 별도의 속성을 만들어준다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
package com.wedul.springboottest.rest.exception;
 
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;
 
import java.util.List;
 
/**
 * Created by Leo.
 * User: wedul
 * Date: 2018-08-09
 * Time: 오후 9:06
 */
// 기존에 validation의 에러와 동일하게 400 에러가 발생하도록 추가
@ResponseStatus(HttpStatus.BAD_REQUEST)
public class ValidationException extends RuntimeException {
 
    private Error[] errors;
 
    public ValidationException(String defaultMessage, String field){
        this.errors = new Error[]{new Error(defaultMessage, field)};
    }
 
    public ValidationException(Error[] errors) {
        this.errors = errors;
    }
 
    public Error[] getErrors() {
        return errors;
    }
 
    public static class Error {
 
        private String defaultMessage;
        private String field;
 
        public Error(String defaultMessage, String field) {
            this.defaultMessage = defaultMessage;
            this.field = field;
        }
 
        public String getDefaultMessage() {
            return defaultMessage;
        }
 
        public String getField() {
            return field;
        }
    }
 
 
}
cs


이 생성된 ValidationException 클래스를 ErrorAttributes 인터페이스를 재 정의하여 Validation 오류가 발생하였을 때 별도의 작업을 할 수 있도록 처리해준다. (이 Bean 객체는 Application에 정의해준다.)


※ ErrorAttributes는 기본적으로 스프링 부트에서 에러 처리하는 BasicErrorController인데 이 인터페이스는 DefaultErrorAttributes 클래스를 참조하여 커스터마이징이 가능하다.
참고
https://brunch.co.kr/@sbcoba/9


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import org.springframework.boot.web.servlet.error.DefaultErrorAttributes;
import org.springframework.boot.web.servlet.error.ErrorAttributes;
import org.springframework.web.context.request.RequestAttributes;
import org.springframework.web.context.request.WebRequest;
 
 
 
@SpringBootApplication
public class SpringboottestApplication {
 
    public static void main(String[] args) {
        SpringApplication.run(SpringboottestApplication.class, args);
    }
 
    @Bean
    public ErrorAttributes errorAttributes() {
        return new DefaultErrorAttributes() {
 
        @Override
        public Map<String, Object> getErrorAttributes(WebRequest webRequest,
                                                          boolean includeStackTrace) {
            Map<String, Object> errorAttributes = super.getErrorAttributes(webRequest, includeStackTrace);
            Throwable error = getError(webRequest);
 
            // validatijon Exception에 경우 별도의 처리를 진행한 에러 데이터 추가
            if (error instanceof ValidationException) {
                errorAttributes.put("errors", ((ValidationException)error).getErrors());
            }
            return errorAttributes;
        }
    };
}
cs


그리고 서비스 코드 영역에서 중복, 금지 등등을 확인하고  ValidationException을 throw하면 front에 동일한 형태에 에러코드를 전송해 줄 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 서비스 코드
 
@Override
public ResultDto insertUser(UserDto user) {
    if (null == users) {
       return ResultDto.fail("UserList Empty");
    }
 
    if (null == user) {
        return ResultDto.fail("Request User Is Null");
    } else {
        if (users.contains(user)) {
            // 중복된 부분에 대해 ValidationException
            throw new ValidationException("name""이름이 중복됩니다.");
        }
        users.add(user);
        return ResultDto.success();
    }
}
cs

테스트 해보면 다음과 같이 중복코드에 대해서 체크가 정상적으로 되는 것을 알 수 있다.

물론 이렇게 RuntimeException을 만들지 않고 팩토리 객체를 만들어서 동일하게 실패 메시지를 만들어서 줘도 상관없다.

하지만 예측이 어려운 Runtime Exception을 잘 정의해놓으면 편하게 처리할 수 있다. 




+ Recent posts