Skip to content

Latest commit

 

History

History
2977 lines (2434 loc) · 119 KB

스프링5레시피.md

File metadata and controls

2977 lines (2434 loc) · 119 KB

목차

[레시 핸들러 인터셉터로 요청 가로채기

과제

  • 서블릿 명세에 정의된 서블릿 필터를 쓰면 웹 요청을 서블릿이 처리하기 전후에 각각 전처리, 후처리를 할 수 있습니다.
  • 스프링 웹 애플리케이션 컨테스트와 필터와 유사한 함수를 구성해서 컨테이너 기능을 십분 활용해보세요

해결책

  • 스프링 MVC에서 웹 요청은 핸들러 인터셉터로 가로채 전처리/후처리를 할 수 있습니다.
  • 핸들러 인터셉터는 특정 요청 URL에만 적용되도록 매핑할 수 있습니다.
  • 핸들러 인터셉터는 예외 없이 HandlerIntereceptor 인터페이스를 구현해야 하며. preHandle(), postHandle(), afterComplection() 세 콜백 메서드를 구현합니다.
  • preHandle(), postHandle() 메서드는 헨들러가 요청을 처리하기 직전과 직후에 가각 호출 됩니다.

풀이

핸들러 메서드에서 웹 요청을 처리하는 데 걸린 시간을 측정해서 뷰로 보여줍시다.

public class MeasurementInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        long startTime = System.currentTimeMillis();
        request.setAttribute("startTime", startTime);
        return true;
    }

    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
        long startTime = (long) request.getAttribute("startTime");
        long endTime = System.currentTimeMillis();
        request.setAttribute("endTime", endTime);
        System.out.println(endTime - startTime);
    }
    ...
}
  • preHandle() 메서드는 요청 처리를 시작한 시작을 재서 요청 속성에 보관합니다.
  • DispacthersServletpreHandle() 메서드가 반드시 true를 반환해야 요청 처리를 계속 진행 하여 그 외에는 이 메서드 선에서 요청 처리가 끝났다고 보고 유저에게 곧장 응답 객체를 반환합니다.
  • postHandle() 메서드는 요청 속성에 보관된 시작 시각을 읽어들여 현재 시각과 비교해서 계산된 요소 시간을 모델에 추가한 뒤 뷰에게 넙깁니다.
  • 자바에서는 인터페이스를 구현할 때 에는 원하지 앟은 메서드까지 모조리 규현해야 하는 규칙이 있습니다. 그래서 인터페이스를 군현하는 대신에 인터셉터 어뎁터 클래스를 상속 받아 사용 할수도 있습니다. ... extends HandlerInterceptorAdapter 를 사용
 @Configuration
public class InterceptorConfig  implements WebMvcConfigurer {

    @Bean
    public MeasurementInterceptor measurementInterceptor(){
        return new MeasurementInterceptor();
    }

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(measurementInterceptor());
    }
}
  • MeasurementInterceptor 인터셉터는 WebMvcConfigurer 인터페이스의 구현 클래스 InterceptorConfig에 등록할 수 있습니다.
  • Bean으로 등록된 measurementInterceptor를 addInterceptors 메서드를 활용해서 InterceptorRegistry에 등록합니다.
@GetMapping("interceptor")
public void interceptor(HttpServletRequest request, @RequestHeader HttpHeaders headers) {
    Long startTime = (Long) request.getAttribute("startTime"); //1538663792873
}
  • 컨트롤러에서 preHandle() 메서드에서 설정한 startTime 시간 값을 확인할 수 있다.

[레시피 3-4] 유저 로케일 해석하기

과제

다국어를 지원하는 웹 애플리케이션에서 각 유저마다 선호하는 로케일을 식별하고 그에 알맞은 콘텐트를 표시하세요

해결책

  • 스프링 MVC 애플리케이션에서 유저 로케일은 LocaleResolver 인터페이스를 구현한 로케일 리졸버가 식별합니다.
  • 로케일을 해석하는 기준에 따라 여러 LocaleResolver 구현체가 스프링 MVC에 준비되어 있습니다.
  • LocaleResolver 는 웹 애플리케이션 컨텍스트에 LocaleResolver형 빈으로 등록합니다.
  • DispatcherServlet이 자동 감지하면 로케일 리졸버 빈을 localeResolver라고 명령합니다.
  • 참고로 LocaleResolver는 하나만 등록 가능합니다.

풀이

[레시피 4-3] 스프링으로 REST 서비스 액세스하기

과제

스프링 애플리케이션 서드파티 REST 서비스의 페이로드를 받아 사용하세요

해결책

  • 스프링 애플리케이션 에서 서드파티 REST 서비스는 RestTemplate 클래스를 이용해 액세스합니다.
  • 스프링 애플리케이션에서 REST 서비스를 호출하고 반환받는 페이로드를 사용하기 아주 간편해 졌습니다.

풀이

    @GetMapping("/members")
    public Member member(){
        return Member
                .builder()
                .address("address")
                .age(10)
                .email("asdasd@asd.com")
                .build();
    }
    
    ...
    @Override
    public void run(ApplicationArguments args) throws Exception {
        final RestTemplate restTemplate = restTemplateBuilder.build();
        final String URL = "http://localhost:8080/members";
        final String result = restTemplate.getForObject(URL, String.class);
        System.out.println(result); //{"name":"name","email":"asdasd@asd.com","age":10,"address":"address"}
    }
  • RestTemplate 클래스를 메서드를 이용해서 REST 서비스를 엑세스해서 그 결과를 print로 출력한 코드입니다.
  • getContentAsString() 메서드 호출 결과로 받은 응답은 stirng contentAsString 변수에 할당됩니다.
  • JSON을 문자열을 데이터 추출하는 조작은 비효율적이고 실수를 하기 좋은 구조입니다.

매개변수화한 URL에서 데이터 가져오기

    @Override
    public void run(ApplicationArguments args) throws Exception {
        final RestTemplate restTemplate = restTemplateBuilder.build();
        Map<String, String> params = new HashMap<>();
        params.put("memberId", "1");

        final String URL = "http://localhost:8080/members/{memberId}";
        final String result = restTemplate.getForObject(URL, String.class, params);
        System.out.println(result); // {"name":"name","email":"asdasd@asd.com","age":10,"address":"address"}
    }
  • http://localhost:8080/members/{memberId} 처럼 URL 자체를 매개변수화 해서 바인딩 시킬 수 있습니다.
  • http://localhost:8080/members/1 가 실제 호출되는 URL 주소

데이터를 매핑된 객체로 가져오기

    @Override
    public void run(ApplicationArguments args) {
        final RestTemplate restTemplate = restTemplateBuilder.build();
        final String URL = "http://localhost:8080/members";
        final Member member = restTemplate.getForObject(URL, Member.class);
        System.out.println(member.toString()); // Member(name=name, email=asdasd@asd.com, age=10, address=address)
    }
  • 리턴 타입을 인자로 넘긴 클래스타입으로 받을 수 있음 Member.class
  • 캡슐화 안전성 등등 해당 방법이 가장 좋다고 생각

RestTemplateBuilder 사용법

@Component
public class RestTemplateFactory {
    @Autowired
    private RestTemplateBuilder restTemplateBuilder;

    @Bean
    public RestTemplate restTemplate() {
        return restTemplateBuilder
                .additionalInterceptors(new CustomClientHttpRequestInterceptor())
                .errorHandler(new CustomResponseErrorHandler())
                .setConnectTimeout(3000)
                .build();
    }
}
  • additionalInterceptors: Http Request 에대한 로깅 처리
  • errorHandler : Http Request 에러 핸들링 처리
  • setConnectTimeout : Http connection Timeout 설정
@Slf4j
public class CustomClientHttpRequestInterceptor implements ClientHttpRequestInterceptor {
    
    @Override
    public ClientHttpResponse intercept(HttpRequest request, byte[] body, ClientHttpRequestExecution execution) throws IOException {
        logRequestDetails(request, body);
        return execution.execute(request, body);
    }

    private void logRequestDetails(HttpRequest request, byte[] body) {
        log.info("================");
        log.info("Headers: {}", request.getHeaders()); 
        log.info("Request Method: {}", request.getMethod());
        log.info("Request URI: {}", request.getURI());
        log.info("Request body: {}", new String(body, StandardCharsets.UTF_8));
        log.info("================");
    }
}
 Headers: {Accept=[application/json, application/json, application/*+json, application/*+json], Content-Type=[application/json;charset=UTF-8],
 Request Method: POST
 Request URI: http://localhost:8080/members
 Request body: {"name":null,"email":null,"age":10,"address":null}
@Slf4j
public class CustomResponseErrorHandler implements ResponseErrorHandler {

    @Override
    public boolean hasError(ClientHttpResponse response) throws IOException {
        final HttpStatus statusCode = response.getStatusCode();
        return !statusCode.is2xxSuccessful();
    }

    @Override
    public void handleError(ClientHttpResponse response) throws IOException {
        final String error = getErrorAsString(response);

        log.error("================");
        log.error("Headers: {}", response.getHeaders());
        log.error("Response Status : {}", response.getRawStatusCode());
        log.error("Request body: {}", error);
        log.error("================");

        throw new RestClientException(error);
    }

    private String getErrorAsString(ClientHttpResponse response) throws IOException {
        final InputStream is = response.getBody();
        final Reader reader = new InputStreamReader(is);
        final BufferedReader bufferedReader = new BufferedReader(reader);
        final String error = bufferedReader.readLine();

        is.close();
        reader.close();
        bufferedReader.close();
        return error;
    }
}
Headers: {Content-Type=[application/json;charset=UTF-8], Transfer-Encoding=[chunked], Date=[Fri, 05 Oct 2018 16:44:21 GMT], Connection=[close]}
Response Status : 400
Request body: {
  "timestamp": "2018-10-05T16:44:21.354+0000",
  "status": 400,
  "error": "Bad Request",
  "errors": [
    ..
      "defaultMessage": "반드시 값이 존재하고 길이 혹은 크기가 0보다 커야 합니다.",
      "objectName": "member",
      "field": "name",
    },
    ...
  ],
  "message": "Validation failed for object='member'. Error count: 2",
  "path": "/members"
}

[레시피 4-4] RSS/아톰 피드 발행하기

과제

스프링 애플리케이션에서 RSS/아톰 피드를 발행하세요

해결책

  • 자바 오픈소스 프레임워크 ROME 사용

풀이

  • RSS/아톰 피드로 발행할 정보를 결정
@Controller
public class FeedController {
    @RequestMapping("/atomfeed")
    public String getAtomFeed(Model model) {
        List<TournamentContent> tournamentList = new ArrayList<>();
        tournamentList.add(TournamentContent.of("ATP", new Date(), "Australian Open", "www.australianopen.com"));
        tournamentList.add(TournamentContent.of("ATP", new Date(), "Roland Garros", "www.rolandgarros.com"));
        tournamentList.add(TournamentContent.of("ATP", new Date(), "Wimbledon", "www.wimbledon.org"));
        tournamentList.add(TournamentContent.of("ATP", new Date(), "US Open", "www.usopen.org"));
        model.addAttribute("feedContent", tournamentList);

        return "atomfeedtemplate";
    }

    @RequestMapping("/rssfeed")
    public String getRSSFeed(Model model) {
        List<TournamentContent> tournamentList;
        tournamentList = new ArrayList<>();
        tournamentList.add(TournamentContent.of("FIFA", new Date(), "World Cup", "www.fifa.com/worldcup/"));
        tournamentList.add(TournamentContent.of("FIFA", new Date(), "U-20 World Cup", "www.fifa.com/u20worldcup/"));
        tournamentList.add(TournamentContent.of("FIFA", new Date(), "U-17 World Cup", "www.fifa.com/u17worldcup/"));
        tournamentList.add(TournamentContent.of("FIFA", new Date(), "Confederations Cup", "www.fifa.com/confederationscup/"));
        model.addAttribute("feedContent", tournamentList);

        return "rssfeedtemplate";
    }
}
  • http//[호스트명]/[어플리케이션명]/atomfeed 형식의 URI를 getAtomFeed 매핑
  • http//[호스트명]/[어플리케이션명]/rssfeed 형식의 URI를 getRSSFeed 매핑
  • TournamentContent는 POJO 객체, 해당 뷰의 값
@Configuration
@EnableWebMvc
public class CourtRestConfiguration {

    @Bean
    public AtomFeedView atomfeedtemplate() {
        return new AtomFeedView();
    }

    @Bean
    public RSSFeedView rssfeedtemplate() {
        return new RSSFeedView();
    }
}
  • 해당 Bean 등록
public class AtomFeedView extends AbstractAtomFeedView {

    @Override
    protected void buildFeedMetadata(Map<String, Object> model, Feed feed, HttpServletRequest request) {
        feed.setId("tag:tennis.org");
        feed.setTitle("Grand Slam Tournaments");

        @SuppressWarnings({"unchecked"})
        List<TournamentContent> tournamentList = (List<TournamentContent>) model.get("feedContent");

        feed.setUpdated(tournamentList.stream().map(TournamentContent::getPublicationDate).sorted().findFirst().orElse(null));

    }

    @Override
    protected List<Entry> buildFeedEntries(Map<String, Object> model, HttpServletRequest request, HttpServletResponse response)
        throws Exception {
        @SuppressWarnings({"unchecked"})
        List<TournamentContent> tournamentList = (List<TournamentContent>) model.get("feedContent");
        return tournamentList.stream().map(this::toEntry).collect(Collectors.toList());
    }
    ...
}
  • buildFeedMetadata() 메서드는 피드 데이터가 담긴 Map 객체, 피드를 처리하는데 필요한 ROME의 Feed 객체, HTTP 요청을 다루어야할 때 필요한 HttpServletRequest을 받습니다.
  • buildFeedEntries() 메서드는 Map 객체에 접근해서 호출부가 할당한 feddcontent 객체를 꺼내옴
  • Entity 객체 List 기반으로 루프를 돌려 설정을 완료

[레시피 5-4] 웹소켓

과제

서버/클라이언트가 웹에서 양방향 통신을 하는 방안을 강구하세요

해결책

HTTP와 달리 전이중 통신이 가능한 웹소켓을 이용하면 서버/클라이언트가 서로 앙뱡향 통신을 할 수있습니다.

풀이

  • 웹소켓에서 HTTP 는 처음 핸드세이크를 할 때만 쓰이고 이후에는 접속 프로토콜이 일반 HTTP -> TCP 소켓으로 변경 됩니다.

웹소켓

@Configuration
@EnableWebSocket
public class WebSocketConfiguration implements WebSocketConfigurer {}
  • 구성 클래스에 @EnableWebSocket만 붙이면 웹소켓 기능을 사용할 수있습니다.
@Bean
public ServletServerContainerFactoryBean configureWebSocketContainer() {
    ServletServerContainerFactoryBean factory = ServletServerContainerFactoryBean();
    
    factory.setMaxBinaryMessageFufferSize(1384);
    factory.setMaxTextMessageBufferSize(1384);
    factory.setMaxSessionIdleTimeout(TimeUinit.MINUTES.convert(30, TimeUnit.MILLESECOUNDS));
    
    return factory;
}
  • 텍스트 크기 버퍼 및 바이너리 크기 비동기 전송 타입 아웃 시간, 비동기 세션 타임아웃 시간을 설정합니다.

WebSocketHandler 작성하기

public class EchoHandler extends TextWebSocketHandler {

    @Override
    public void afterConnectionEstablished(WebSocketSession session) throws Exception {
        session.sendMessage(new TextMessage("CONNECTION ESTABLISHED"));
    }

    @Override
    public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
        session.sendMessage(new TextMessage("CONNECTION CLOSED"));
    }

    @Override
    protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
        String msg = message.getPayload();
        session.sendMessage(new TextMessage("RECEIVED: " + msg));
    }
}
  • 접속이 체결되면 TextMessage를 클라이언트에 돌려 돌려보내 알립니다.
  • TextMeesage가 수신되면 페이로드를 꺼내 그 앞에 RECEIVED:를 붙여 클라이언트에게 회송합니다.
@Configuration
@EnableWebSocket
public class WebSocketConfiguration implements WebSocketConfigurer {

    @Bean
    public EchoHandler echoHandler() {
        return new EchoHandler();
    }

    @Override
    public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
        registry.addHandler(echoHandler(), "/echo")
            .addInterceptors();
    }
}
  • 위에서 만든 핸들러를 WebSocketConfiguration 인터페이스에서 구현한 registerWebSocketHandlers()메서드를 오버라이드 해서 등록합니다.
  • addHandler() 메서드에 /echo URL을 넣어 핸들러를 등록합니다.
  • 클라이언트는 ws://localhost:8080/echo-ws/echo URL로 웹소켓에 접속 할 수 있습니다.
var ws = null;
var url = "ws://localhost:8080/echo-ws/echo";

function setConnected(connected) {
    document.getElementById('connect').disabled = connected;
    document.getElementById('disconnect').disabled = !connected;
    document.getElementById('echo').disabled = !connected;
}

function connect() {
    ws = new WebSocket(url);

    ws.onopen = function () {
        setConnected(true);
    };

    ws.onmessage = function (event) {
        log(event.data);
    };

    ws.onclose = function (event) {
        setConnected(false);
        log('Info: Closing Connection.');
    };
}

function disconnect() {
    if (ws != null) {
        ws.close();
        ws = null;
    }
    setConnected(false);
}
function echo() {
    if (ws != null) {
        var message = document.getElementById('message').value;
        log('Sent: ' + message);
        ws.send(message);
    } else {
        alert('connection not established, please connect.');
    }
}

function log(message) {
    var console = document.getElementById('logging');
    var p = document.createElement('p');
    p.appendChild(document.createTextNode(message));
    console.appendChild(p);
    while (console.childNodes.length > 12) {
        console.removeChild(console.firstChild);
    }
    console.scrollTop = console.scrollHeight;
}
  • Connect 이벤트를 통해 ws://localhost:8080/echo-ws/echo URL로 접속해 처음으로 웹소켓을 엽니다.
  • WebSocket를 사용해 메시지, 이벤트를 리스능할 수있습니다.

[레시피 5-5] 스프링 웹플럭스로 리액티브 애플리케이션 개발하기

과제

스프링 웹플러스의 기본 개념과 구성 방법을 이해하고 간단한 리액티브 웹 애플리케이샨을 개발하세요

해결책

public class Reservation {

    private String courtName;
    private LocalDate date;
    private int hour;
    private Player player;
    private SportType sportType;

    //getter, setter...
@Service
public class InMemoryReservationService implements ReservationService {
    ...
    @Override
    public Flux<Reservation> query(String courtName) {
        if (courtName != null) {
            return findAll()
                    .filter(r -> r.getCourtName().startsWith(courtName));
        }
        return Flux.empty();
    }
}
  • 리턴 자료형을 Flux으로 변경
@Configuration
@EnableWebFlux
@ComponentScan
public class WebFluxConfiguration implements WebFluxConfigurer {...}
  • @EnableWebFlux 어노테이션으로 리액티브 처리 기능을 활성화 합니다.

스프링 웹플러스 컨트롤러 작성하기

@Controller
@AllArgsConstructor
public class SampleController {
    private final ReservationService reservationService;

    @GetMapping("reservationQuery")
    public void setupForm() {
    }

    @PostMapping("reservationQuery")
    public String sumbitForm(ServerWebExchange serverWebExchange, Model model) {

        Flux<Reservation> reservations = serverWebExchange.getFormData()
                .map(form -> form.get("courtName"))
                .flatMapMany(Flux::fromIterable)
                .concatMap(courtName -> reservationService.query(courtName));

        model.addAttribute("reservations", reservations);
        return "reservationQuery";
    }
}
  • void setupForm() 컨트롤러는 뷰 페이지
  • String sumbitForm() 컨트롤러는 @PostMapping
    • serverWebExchange 매게변수는 요청 변수 courtName을 추출하려고 선언한 객체입니다. reservationQuery?courtName=<변수>
    • MVC 컨트로러에서는 @RequestParam 어노테이이션으로 가능하지만 스프링 웹플럭스에서는 폼 데이터를 구성하는 매개변수를 가져올 수 없고 URL에 포함된 메개변수값만 얻을 수 있습니다.

타임피르 뷰 작성하기

@Bean
public SpringResourceTemplateResolver thymeleafTemplateResolver() {
    final SpringResourceTemplateResolver resolver = new SpringResourceTemplateResolver();
    resolver.setPrefix("classpath:/templates/");
    resolver.setSuffix(".html");
    resolver.setTemplateMode(TemplateMode.HTML);
    return resolver;
}

@Bean
public ISpringWebFluxTemplateEngine thymeleafTemplateEngine(){
    final SpringWebFluxTemplateEngine templateEngine = new SpringWebFluxTemplateEngine();
    templateEngine.addDialect(new Java8TimeDialect());
    templateEngine.setTemplateResolver(thymeleafTemplateResolver());
    return templateEngine;
}


@Bean
public ThymeleafReactiveViewResolver thymeleafReactiveViewResolver() {

    final ThymeleafReactiveViewResolver viewResolver = new ThymeleafReactiveViewResolver();
    viewResolver.setTemplateEngine(thymeleafTemplateEngine());
    viewResolver.setResponseMaxChunkSizeBytes(16384);
    return viewResolver;
}

@Override
public void configureViewResolvers(ViewResolverRegistry registry) {
    registry.viewResolver(thymeleafReactiveViewResolver());
}
  • 템플릿을 실제 HTML로 변환함으로 ISpringWebFluxTemplateEngine 빈을 구성
  • 타임리프 템플릿은 템플릿 리졸버가 해석해야되기 때문에 리졸버에 타임리프 등록

레시피 [5-6] 리액티브 컨트롤러로 폼 처리하기

과제

폼 컨트롤러는 유저에 폼을 보여주고 유저가 제출한 폼을 처리하는 일을 담당합니다.

해결책

  • HTTP GET 요청 하면 초기 폼 뷰를 유저에게 반한 한다.
  • HTTP POST 전송하면 유저가 입력한 데이터를 검증한 다음 정해진 처리를 담당한다. 폼이 정상 처리되면 성공뷰를, 도중 실패하면 에러 메시지가 담딘 폼뷰를 유저에게 돌려준다.

풀이

폼 뷰작성하기

<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
    <title>Reservation Form</title>
    <style>
        .error {
            color: #ff0000;
            font-weight: bold;
        }
    </style>
</head>

<body>
<form method="post" th:object="${reservation}">

    <table>
        <tr>
            <td>Court Name</td>
            <td><input type="text" th:field="*{courtName}" required/></td>
            <td><span class="error" th:if="${#fields.hasErrors('courtName')}" th:errors="*{courtName}"></span></td>
        </tr>
        <tr>
            <td>Date</td>
            <td><input type="date" th:field="*{date}" required/></td>
            <td><span class="error" th:if="${#fields.hasErrors('date')}" th:errors="*{date}"></span></td>
        </tr>
        <tr>
            <td>Hour</td>
            <td><input type="number" min="8" max="22" th:field="*{hour}"/></td>
            <td><span class="error" th:if="${#fields.hasErrors('hour')}" th:errors="*{hour}"></span></td>
        </tr>
        <tr>
            <td>Player Name</td>
            <td><input type="text" th:field="*{player.name}" required/></td>
            <td><span class="error" th:if="${#fields.hasErrors('player.name')}" th:errors="*{player.name}"></span></td>
        </tr>
        <tr>
            <td>Player Phone</td>
            <td><input type="text" th:field="*{player.phone}" required/></td>
            <td><span class="error" th:if="${#fields.hasErrors('player.phone')}" th:errors="*{player.phone}"></span>
            </td>
        </tr>
        <tr>
            <td>Sport Type</td>
            <td>
                <select th:field="*{sportType}">
                    <option th:each="sportType : ${sportTypes}" th:value="${sportType.id}" th:text="${sportType.name}"/>
                </select>
            </td>
            <td><span class="error" th:if="${#fields.hasErrors('sportType')}" th:errors="*{sportType}"></span></td>
        </tr>
        <tr>
            <td colspan="3"><input type="submit"/></td>
        </tr>
    </table>

</form>
</body>
</html>

폼 처리 서비스 작성하기

@Service
public class InMemoryReservationService implements ReservationService {
    ...

    @Override
    public Mono<Reservation> make(Reservation reservation) {

        long cnt = reservations.stream()
                .filter(made -> Objects.equals(made.getCourtName(), reservation.getCourtName()))
                .filter(made -> Objects.equals(made.getDate(), reservation.getDate()))
                .filter(made -> made.getHour() == reservation.getHour())
                .count();

        if (cnt > 0) {
            return Mono.error(new ReservationNotAvailableException(reservation
                    .getCourtName(), reservation.getDate(), reservation
                    .getHour()));
        } else {
            reservations.add(reservation);
            return Mono.just(reservation);
        }
    }
  • 예약 내역을 조회하는 기능을 make()의 메서드입니다.
  • 중복 예약건이 있으면 Mono.error() 메서드를 통해 ReservationNotAvailableException 예외를 발생시킵니다.

폼 컨트롤러 작성하기

@PostMapping
    public String submitForm(@Validated @ModelAttribute("reservation") Reservation reservation, BindingResult result) {
        reservationService.make(reservation);
        return "redirect:reservationSuccess";
    }
  • submitForm() 메서드의는 위에서 작성한 make() 메서드를 통해 Reservation 객체를 추가하 합니다.

폼데이터 검증하기

@Component
public class ReservationValidator implements Validator {
    @Override
    public boolean supports(Class<?> clazz) {
       return Reservation.class.isAssignableFrom(clazz);
    }

    @Override
    public void validate(@Nullable Object target, Errors errors) {
        ValidationUtils.rejectIfEmptyOrWhitespace(errors, "courtName",
                "required.courtName", "Court name is required.");
        ValidationUtils.rejectIfEmpty(errors, "date",
                "required.date", "Date is required.");
        ValidationUtils.rejectIfEmpty(errors, "hour",
                "required.hour", "Hour is required.");
        ValidationUtils.rejectIfEmptyOrWhitespace(errors, "player.name",
                "required.playerName", "Player name is required.");
        ValidationUtils.rejectIfEmpty(errors, "sportType",
                "required.sportType", "Sport type is required.");

        Reservation reservation = (Reservation) target;
        LocalDate date = reservation.getDate();
        int hour = reservation.getHour();
        if (date != null) {
            if (date.getDayOfWeek() == DayOfWeek.SUNDAY) {
                if (hour < 8 || hour > 22) {
                    errors.reject("invalid.holidayHour", "Invalid holiday hour.");
                }
            } else {
                if (hour < 9 || hour > 21) {
                    errors.reject("invalid.weekdayHour", "Invalid weekday hour.");
                }
            }
        }

    }
}
  • 폼을 처리하기 전에 유저가 입력한 데이터를 검증하는 과정 입니다.
  • 스프링 웹플럭스에서도 스프링 MVC 처럼 Validator 인터페이스를 구현한 검증 객체가 이 일을 감당합니다.
  • 필숫 값 존재 여부는 ValidationUtils 클래스의 rejectIfEmpty() 메서드를 통해서 이루어집니다.
  • 두 번째 인수는 프로퍼티명, 세 번째, 네 번째 인수는 각각 에러 코드 및 에기본 에러 메시지입니다.
@PostMappingpublic String submitForm(@Validated @ModelAttribute("reservation") Reservation reservation, BindingResult result) {
    if (result.hasErrors()) {
        return "reservationForm";
    } else {
        reservationService.make(reservation);
        return "redirect:reservationSuccess";
    }
}
  • @Validated 어노테이션으로 검증을 위에서 등록한 폼 검증을 진행합니다.
  • result.hasErrors() 메서드를 통해 폼 검증 실패 유무를 boolean 타입으로 리턴 받습니다.

[레시피 7-1] URL 접근 보안하기

과제

대다수 웹 애플리케이션에는 특별히 보안에 슨경 써야할 만큼 민감할 URL이 있습니다. 이러한 URL에 미인가 외부 유저가 제약 없이 접근할 수 없도록 보안하세요

해결책

스프링은 WebSecurityConfigurerAdater라는 구성 어댑터에 준비된 다양한 configure()메서드를 이용하면 웹 애플리케이션 보안을 쉽게 구성할 수 있습니다.

  • 폼 기반 로그인 서비스 : 유저가 애플리케이션 로그인하는 기본 폼 페이지를 제공합니다.
  • HTTP 기본 인증: 요청 헤더에 표시된 HTTP 기본 인증 크레덴션을 처리합니다. 원격 프로토콜, 웹 서비스를 이용해 인증 요청을 할 때에도 쓰입니다.
  • 로그아웃 서비스 : 유저를 로그아웃 시키는 핸들러는 기본 제공합니다.
  • 익명 로그인 : 익명 유저도 주체를 할당하고 권한을 부여해서 마치 일반 유저처럼 처리합니다.
  • 서블릿 API 연계 : HttpServletRequest.isUserInRole(), HttpServletRequest.getUserPrincipal() 같은 표준 서블릿 API를 이용해 웹애플리케이션 위치한 보안 정보에 접근합니다.
  • CSRF : 사이트 간 요청 위조 방어용 토큰을 생성해 HttpSession에 넣습니다.
  • 보안 헤더 : 보안이 적용된 패키지에 대해서 캐시를 해제하는 식으로 XSS 방어, 전송 보안, X-Frame 보안 기능을 제공합니다.

풀이

@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {

        //@formatter:off
        http
                .authorizeRequests()
                    .anyRequest().authenticated()
                .and()
                    .formLogin()
                .and()
                    .httpBasic()
                .and()
                    .csrf().disable()
        ;
        //@formatter:on
    }


    @Autowired
    public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception {

        //@formatter:off
        auth
                .inMemoryAuthentication()
                .withUser("user").password("{noop}pass").roles("USER");
        //@formatter:on

    }
}
  • URL 접근 보안은 authorizeRequests() 부터 시작되며 여러 가지 matchers()로 매치 규칙을 지정합니다.

[레시피 7-2] 웹 애플리케이션 로그인하기

과제

  • 웹 애플리케이션 유저가 자신의 크레덴션을 입력해서 로그인하는 창구를 제공하세요

해결책

  • 스프링 시큐리티는 다양한 앙법으로 유저가 로그인 할 수 있게 지원
  • 로그인 폼 지닌 기본 페이지가 내장되어 폼 기반 로그인은 그냥 지원 됨
  • 커스텀 로그엔 페이지를 맞춰 개발할 술수도 있음
  • HTTP 요청 헤더 포함된 기본 인증 크레덴셜 처리 기능도 시큐리티에 규현되어 있음
  • 리멤버미 기능으로 최초 한번 로그인한 다음 다시 로그인하 필요가 없도록 브라우저 세션에 걸쳐 유저의 신원 기억하는 기능 제공

풀이

로그인

@Override
protected void configure(HttpSecurity http) throws Exception {
    ...
    http
            .formLogin()
                .loginPage("/login")
                .defaultSuccessUrl("/home")
                .failureUrl("login");
}
  • 로그인 폼에 대한 다양한 지원

로그아웃

protected void configure(HttpSecurity http) throws Exception {
    ...
    http
        //.logoutUrl ( "/ doLogout") 이렇게 지정할 수도 있지만 아래 처럼 지정하는것이 바람직함
        .logout().permitAll()
            .logoutRequestMatcher(new AntPathRequestMatcher("/doLogout", "GET"))

}

리멤버 미

protected void configure(HttpSecurity http) throws Exception {
    ...
    http
        ...
        .and
        .remeberMe();
}
  • 정적이 리멤버 미 토큰은 해커가 빼낼수 있어 잠재적인 보안 이슈가 있음
  • 스프링 시큐리티는 토큰을 회전 시키는 고급 기술도 지원함 이렇게 하려면 토큰을 보관할 DB가 별도로 필요함

[레시피 7-3] 유저 인증하기

과제

  • 유저가 애플리케이션에 로그인 해서 보안 리소스에 접근하려면 주체를 인증하고 권한을 부여해야합니다.

해결책

  • 스프링 시큐리테에서는 연쇄적으로 연결된 하나 이상의 AuthenticationProvider(인증 공급자)를 이용해 인증을 수행합니다.
  • 다양한 인증 방법을 제공하는 스프링 시큐리트는 기본 공급자 구현체가 내장되어 있고 자체 XML 엘리컨트를 이용해서 쉽게 구성할 수 있습니다.
  • 대부분의 인증 곱급자는 유저 세부를 보관하는 저장소 (메모리, RDBMS, LDAP)에서 가져온 결과와 대조해 유저를 인증합니다.
  • 유저 세부를 저장할 때 패스워드는 평문을 암호화(MD5, SHA) 하여 저장합니다.
  • 로그인 할 때마다 저장소에서 유저 새뷰를 조회하면 애플리케이션 성능에 좋지 않아 스프링 시큐리티는 원격 쿼리를 하는 과정에서 발생하는 오버헤드를 줄이고자 유저 세부를 로컬 메모리에 저장 공간에 캐시하는 기능을 제공합니다.

풀이

인메모리 방식으로 유저 인증하기

@Configuration
@EnableWebSecurity
public class TodoSecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.inMemoryAuthentication()
                .withUser("admin@ya2do.io").password("secret").authorities("ADMIN","USER").and()
                .withUser("marten@@ya2do.io").password("user").authorities("USER").and()
                .withUser("jdoe@does.net").password("unknown").disabled(true).authorities("USER");
    }
}
  • inMemoryAuthentication() 메서드를 통해서 인메모리에 유저 정보를 저장합니다.
security:
  user:
    name: user
    password: pass
    role: ROLE_USER
  
  basic:
    authorize-mode: authenticated
    path: /**
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    ...
    @Autowired
    public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception {

        //@formatter:off
        auth
                .inMemoryAuthentication()
                .withUser("user").password("{noop}pass").roles("USER");
        //@formatter:on

    }

DB 조회 결과에 따라 유저 인증하기

CREATE TABLE USERS (
    USERNAME    VARCHAR(50)    NOT NULL,
    PASSWORD    VARCHAR(60)    NOT NULL,
    ENABLED     SMALLINT,
    PRIMARY KEY (USERNAME)
);

CREATE TABLE AUTHORITIES (
    USERNAME    VARCHAR(50)    NOT NULL,
    AUTHORITY   VARCHAR(50)    NOT NULL,
    FOREIGN KEY (USERNAME) REFERENCES USERS
);
@Configuration
@EnableWebSecurity
public class TodoSecurityConfig extends WebSecurityConfigurerAdapter {

    @Bean
    public DataSource dataSource() {
        return new EmbeddedDatabaseBuilder()
                .setType(EmbeddedDatabaseType.H2)
                .setName("board")
                .addScript("classpath:/schema.sql")
                .addScript("classpath:/data.sql")
                .build();
    }

        @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth
            .jdbcAuthentication()
                .dataSource(dataSource())
                .usersByUsernameQuery("SELECT username, password, 'true' as enabled FROM member WHERE username = ?")
                .authoritiesByUsernameQuery(
                        "SELECT member.username, member_role.role as authorities " +
                                "FROM member, member_role " +
                                "WHERE  member.username = ? AND member.id = member_role.member_id");
    }

}
  • 스프링 시큐리티에서 두 테이블에 접근하려면 데이터 소스를 선언하고 DB에 접속합니다.
  • jdbcAuthentication()메서드를 통해 dataSoruce 정보인 dataSource()메서드를 넘겨줍니다.
  • 유저 기본 정보 및 권한 정보를 쿼리하는 SQL문으로 질의합니다.

패스워드 암호화

@Bean
    public BCryptPasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth
            .jdbcAuthentication()
                .passwordEncoder(passwordEncoder())
                .dataSource(dataSource());
    }
  • BCryptPasswordEncoder() 메서드로 패스워드 인코더를 지정하면 유저 저장소에 패스워드를 암호화하여 저장할 수있습니다.

LDAP 저장소에 조화 결과에 따라 유저 인증하기

dn: dc=springrecipes,dc=com
objectClass: top
objectClass: domain
dc: springrecipes

dn: ou=groups,dc=springrecipes,dc=com
objectclass: top
objectclass: organizationalUnit
ou: groups

dn: ou=people,dc=springrecipes,dc=com
objectclass: top
objectclass: organizationalUnit
ou: people

dn: uid=admin,ou=people,dc=springrecipes,dc=com
objectclass: top
objectclass: uidObject
objectclass: person
uid: admin
cn: admin
sn: admin
userPassword: secret

dn: uid=user1,ou=people,dc=springrecipes,dc=com
objectclass: top
objectclass: uidObject
objectclass: person
uid: user1
cn: user1
sn: user1
userPassword: 1111

dn: cn=admin,ou=groups,dc=springrecipes,dc=com
objectclass: top
objectclass: groupOfNames
cn: admin
member: uid=admin,ou=people,dc=springrecipes,dc=com

dn: cn=user,ou=groups,dc=springrecipes,dc=com
objectclass: top
objectclass: groupOfNames
cn: user
member: uid=admin,ou=people,dc=springrecipes,dc=com
member: uid=user1,ou=people,dc=springrecipes,dc=com
  • 기본 LDAP 도메인 dc=springrecipes, dc=com
  • 그릅과 유저를 저장히가 위한 그룹 및 유저 조작단위 (organization unit)
  • 패스워드가 secret인 유저 admin, 패스워드가 1111인 유저 user1
  • admin 그룹과 user그룹
@Configuration
@EnableWebSecurity
public class TodoSecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth
            .ldapAuthentication()
                .contextSource()
                    .ldif("")
                    .url("ldap://ldap-server:389/dc=springrecipes,dc=com")
                    .managerDn("cn=Directory Manager").managerPassword("ldap")
            .and()
                .userSearchFilter("uid={0}").userSearchBase("ou=people")
                .groupSearchFilter("member={0}").groupSearchBase("ou=groups")
                .passwordCompare()
                    .passwordEncoder(new LdapShaPasswordEncoder())
                    .passwordAttribute("userPassword");
    }
}
  • LDAP 저장소 구성은 ldapAuthentication() 메서드가 담당합니다.
  • 지정한 속상값을 이용해서 스프링 시큐리티는 people 조직 단위에서 특정 ID를 가진 유저를 groups 조직 단위에서 유저 그룹을 검색하며 각 그룹마다 접두어 ROLE_를 앞여 부텨 권한으로 사용합니다.
  • OpenDS는 기본적으로 SSHA를 사용 해 패스워드를 인코딩하므로 LdapShaPasswordEncoder를 지정합니다.

[레시피 7-4] 접근 통제 결정하기

과제

  • 성공적으로 인증을 마친 유저에게 일련의 권한을 부여합니다.
  • 유저가 리소스에 접근을 시도하면 애플리케이션은 유저의 권한을 확인해서 접근 가능 여부를 판단합니다.

해결책

  • 리소스에 접근 가능한 판단은 유저의 인증 상태와 리소스 속성에 따라 좌우됩니다.
  • 스프링 시큐리테에서는 AccessDesisionManager 인터페이스를 구현한 접근 옽제 결정 관리자가 판단합니다.
  • 필요 시 직접 이 인터페이스를 구현체를 만들어 쓸 수도있지만 스프링 시큐리테는 거수 방식으로 동작하는 세 가지 간편한 접근 통제 결정 관리자를 제공합니다.
접근 통제 결정 관리자 접근 허용 조건
AffirmatevieBased 하나의 거수기만 거수해도 접근 허용
ConsensuBased 거수기 전원이 만장일치해야 접근 허용
UnanimousBased 거수기 전원이 기권 또는 찬성해야(적어도 반대하는 기수기는 없어야) 접근허용

각 거수기는 AccessDesisionVoter 인터페이스를 구현하며 유저의 리소스 접근 요청에 대해서 AccessDesisionVoter 인터페이스인 상수 필드 찬성, 기권, 반대 의사를 표현 합니다.

풀이

public class IpAddressVoter implements AccessDecisionVoter<Object> {

    private static final String IP_PREFIX = "IP_";
    private static final String IP_LOCAL_HOST = "IP_LOCAL_HOST";

    public boolean supports(ConfigAttribute attribute) {
        return (attribute.getAttribute() != null) && attribute.getAttribute().startsWith(IP_PREFIX);
    }

    @Override
    public boolean supports(Class<?> clazz) {
        return true;
    }

    public int vote(Authentication authentication, Object object, Collection<ConfigAttribute> configList) {
        if (!(authentication.getDetails() instanceof WebAuthenticationDetails)) {
            return ACCESS_DENIED;
        }

        WebAuthenticationDetails details = (WebAuthenticationDetails) authentication.getDetails();
        String address = details.getRemoteAddress();

        int result = ACCESS_ABSTAIN;

        for (ConfigAttribute config : configList) {
            result = ACCESS_DENIED;

            if (Objects.equals(IP_LOCAL_HOST, config.getAttribute())) {
                if (address.equals("127.0.0.1") || address.equals("0:0:0:0:0:0:0:1")) {
                    return ACCESS_GRANTED;
                }
            }
        }

        return result;
    }
}
  • 유저 IP 주오에 따라 허용 여부를 거수하는 방식
  • 유저의 IP 주소가 127.0.0.1 or 0:0:0:0:0:0:0:1(리눅스 워크스테이션일 경우) 이면 찬성 그렇지 않으면 반대
@Bean
public AffirmativeBased accessdecisionManager() {
    List<AccessDesisionVoter> descisionVoters = Arrays.asLst(new RoleVoter(), new AuthenticatiedVoter(), new IpaddressVoter());

    return new AffirmativeBased(descisionVoters);
}

@Override
    protected void configure(HttpSecurity http) throws Exception {

        http.authorizeRequests()
                .antMatchers("/todos*").hasAuthority("USER")
                .antMatchers(HttpMethod.DELETE, "/todos*").access("ADMIN, IP_LOCAL_HOST);
    }
  • 서버 관리자에 한하여 로그인을 안해도 해당 작업을 수행할 수 있는 권한을 줄 수 있습니다.

표현식을 이용해 접근 통제 결정하기

  • 더 정교허고 복잡한 접근 통제 규칙을 저용해야한다면 SpEl(스프링 표현식 언어)을 사용 합니다.

Security Expressions는 다음과 같습니다.

  • hasRole, hasAnyRole
  • hasAuthority, hasAnyAuthority
  • permitAll, denyAll
  • isAnonymous, isRememberMe, isAuthenticated, isFullyAuthenticated
  • principal, authentication
  • hasPermission
@Override
protected void configure(HttpSecurity http) throws Exception {

    http.authorizeRequests()
            .antMatchers("/todos*").hasAuthority("USER")
            .antMatchers(HttpMethod.DELETE, "/todos*").access("hasAuthority('ADMIN') or hasIpAddress('0:0:0:0:0:0:0:1')");

public class ExtendedWebSecurityExpressionRoot extends WebSecurityExpressionRoot {

    public ExtendedWebSecurityExpressionRoot(Authentication a, FilterInvocation fi) {
        super(a, fi);
    }

    public boolean localAccess() {
        return hasIpAddress("127.0.0.1") || hasIpAddress("0:0:0:0:0:0:0:1");

    }
}
  • access() 메서드 안에 SpEL식으로 작성 할 수있습니다.

Spring Security Expressions 정리

hasRole, hasAnyRole

이 표현식은 응용 프로그램의 특정 URL 또는 메소드에 대한 액세스 제어 또는 권한 부여를 정의합니다. 예제를 살펴 보겠습니다.

@Orverride
proteted void configure(final HttpSecurity http) throws Exception{
    ...
    .antMatchers("/auth/admin/*").hasRole("ADMIN")
    .antMatchers("/auth/*").hasAnyRole("ADMIN", "USER")

}

hasAuthority, hasAnyAuthority

약할거ㅏ 권한은 Spring 에서도 비슷합니다. 가장 큰 차이점은 역할에 특별한 의미가 있다는 것입니다. Spring Security 4 부터는 Role 관련 메서드에 의해서 "ROLE_" 가 자동으로 추가됩니다. (ROLE_이 없다면 추가)

그래서 hasAuthority ( 'ROLE_ADMIN') 은 ' ROLE_ '접두사가 자동으로 추가 되기 때문에 hasRole ( 'ADMIN') 과 동일합니다.

Authority 에서는 ROLE_ 접두사를 사용할 필요가 없습니다.

@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
    auth.inMemoryAuthenication()
        .withUser("user1").password("password")
        .authroities("USER")
        .and().withUser("admin").password("password")
        .authorities("ADMIN");
}

Override
protected void configure(final HttpSecurity http) throws Exception {
    ...
    .antMatchers("/auth/admin/*").hasAuthority("ADMIN")
    .antMatchers("/auth/*").hasAnyAuthority("ADMIN", "USER")
    ...
}

permitAll, denyAll

서비스 일부 URL에 대한 액새스를 허용하거나 액세스를 거부할 수 있습니다.

...
.antMatchers("/*").permitAll() // 매칭 되는 모든 URL 허용
.antMatchers("/*").denyAll() // 매칭 되는 모든 URL Access 거절
...

isAnonymous, isRememberMe, isAuthenticated, isFullyAuthenticated

사용자의 로그인 상태와 관련된 표현에 중점을 둡니다.

다음을 지정하면 권한이없는 모든 사용자가 기본 페이지에 액세스 할 수 있습니다.

...
.antMatchers("*").anonymous()
...

사용하는 모든 사용자가 로그인해야하는 웹 사이트를 보호하려면 isAuthenticated () 메소드 를 사용해야합니다.

...
.antMatchers("*").authenticated();
...

sRememberMe () 및 isFullyAuthenticated () 쿠키 사용을 통해 Spring은 remember-me 기능을 사용하므로 매번 시스템에 로그인 할 필요가 없습니다

...
.antMatchers("/*").rememberMe()
...

마지막으로, 우리 서비스의 일부에서는 사용자가 이미 로그인되어 있어도 사용자가 다시 인증을 받아야합니다. 예를 들어, 사용자는 설정이나 지불 정보를 변경하려고합니다. 물론 시스템의보다 민감한 영역에서 수동 인증을 요청하는 것이 좋습니다.

그렇게하기 위해서 isFullyAuthenticated ()를 지정할 수 있습니다 .이 함수 는 사용자가 익명 또는 기억하는 사용자가 아닌 경우 true 를 반환 합니다 .

...
.antMatchers("/*").fullyAuthenticated()
...

principal, authentication

이 표현식을 사용 하면 현재 권한이 부여 된 (또는 익명의) 사용자와 SecurityContext 의 현재 Authentication 객체를 나타내는 주요 객체에 각각 액세스 할 수 있습니다.

@RequestMapping(value = "/current-username", method = RequestMethod.GET)
public String currentUsername(Authentication authentication){
    return authentication.getName(); // session에 저장되있는 유저 출력
}

[레시피 7-7] 도메인 객체 보안 처리하기

과제

  • 도메인 객체 레벨에서 보안을 처리해야 하는 까다로운 요건도 있습니다. 즉 도메인 객체마다 주체별로 접근 속성을 달리 하는 겁니다.

해결책

스프링 시큐리티는 ACL을 설정하는 전용 모듈을 지원합니다. ACL에는 도메인 객체와 연결하는 ID를 비록해서 여러 개의 ACE(접근 통제 엔티티)가 들어 있습니다. ACE는 다음 두 가지 핵심 요소로 구성됩니다.

퍼미션(Permission, 인가 받은 권한)

ACE 퍼미션은 각 비트 값을 특성 퍼미션을 의미하는 비트 마스크 입니다. BasePermission 클래스는 다섯 가지 기본 퍼미션을 갖고 있습니다.

권한 비트 정수
READE 0 1
WRITE 1 2
CREATE 2 4
DELETE 3 8
ADMINPERMISSION 4 16

사용하지 않은 비트를 통해서 임의로 퍼미션을 지정할 수 있습니다.

보안 식별자(SID, Security IDentity)

각 ACE는 특정 SID에 대한 퍼미션을 가집니다. SID 주체는 (PrincipalSid)일 수도 있고 퍼미션과 관련된 권한(GrantedAuthoritySid)일 수도 있습니다. 스프링 시큐리티에는 ACL 객체 모델의 정의 뿐만 아니라 이 모델을 읽고 관리하는 API도 정의도어있습니다. 또 이 API를 구현한 고성능 JDBC 구현체까지 제공합니다. 아울러 ACL을 더욱쉽게 사용할 수 있도록 접근 통제결정 거수기나 JSP 태그 같은 편의 기능도 마련되어 있어서 애플리케이션 다른 보안 장치들과 일관된 방향으로 사용할 수 있습니다.

풀이

  • ACL 서비스를 설정하는 방법과 엔티티의 ACL 퍼미션을 다루는 방법을 차례로 설명
  • ACL 퍼미션을 이용해서 엔티티에 보안 접근을 하는 보안 표현식의 사용법 설명

ACL 서비스 설정하기

CREATE TABLE ACL_SID(
    ID         BIGINT        NOT NULL GENERATED BY DEFAULT AS IDENTITY,
    SID        VARCHAR(100)  NOT NULL,
    PRINCIPAL  SMALLINT      NOT NULL,
    PRIMARY KEY (ID),
    UNIQUE (SID, PRINCIPAL)
);

CREATE TABLE ACL_CLASS(
    ID     BIGINT        NOT NULL GENERATED BY DEFAULT AS IDENTITY,
    CLASS  VARCHAR(100)  NOT NULL,
    PRIMARY KEY (ID),
    UNIQUE (CLASS)
);

CREATE TABLE ACL_OBJECT_IDENTITY(
    ID                  BIGINT    NOT NULL GENERATED BY DEFAULT AS IDENTITY,
    OBJECT_ID_CLASS     BIGINT    NOT NULL,
    OBJECT_ID_IDENTITY  BIGINT    NOT NULL,
    PARENT_OBJECT       BIGINT,
    OWNER_SID           BIGINT,
    ENTRIES_INHERITING  SMALLINT  NOT NULL,
    PRIMARY KEY (ID),
    UNIQUE (OBJECT_ID_CLASS, OBJECT_ID_IDENTITY),
    FOREIGN KEY (PARENT_OBJECT)   REFERENCES ACL_OBJECT_IDENTITY,
    FOREIGN KEY (OBJECT_ID_CLASS) REFERENCES ACL_CLASS,
    FOREIGN KEY (OWNER_SID)       REFERENCES ACL_SID
);

CREATE TABLE ACL_ENTRY(
    ID                  BIGINT    NOT NULL GENERATED BY DEFAULT AS IDENTITY,
    ACL_OBJECT_IDENTITY BIGINT    NOT NULL,
    ACE_ORDER           INT       NOT NULL,
    SID                 BIGINT    NOT NULL,
    MASK                INTEGER   NOT NULL,
    GRANTING            SMALLINT  NOT NULL,
    AUDIT_SUCCESS       SMALLINT  NOT NULL,
    AUDIT_FAILURE       SMALLINT  NOT NULL,
    PRIMARY KEY (ID),
    UNIQUE (ACL_OBJECT_IDENTITY, ACE_ORDER),
    FOREIGN KEY (ACL_OBJECT_IDENTITY) REFERENCES ACL_OBJECT_IDENTITY,
    FOREIGN KEY (SID)                 REFERENCES ACL_SID
);
  • 스프링 JDBC로 RDBMS에 접속해서 ACL 데이터 저장/조회 하는 기능을 기본적 지원합니다.
  • 스프링 시큐리티에는 테이블에 지정된 ACL 데이터에 액세스할 수 있는 고성능 JDBC 구현체 및 API가 준비되어 있음
  • ACL 개수는 상당히 많아 질수 있어 스프링 시쿠ㅠ리티는 ACL 객체를캐시하는 기능을 지원함
@Configuration
public class TodoAclConfig {

    private final DataSource dataSource;

    public TodoAclConfig(DataSource dataSource) {
        this.dataSource = dataSource;
    }

    @Bean
    public AclEntryVoter aclEntryVoter(AclService aclService) {
        return new AclEntryVoter(aclService, "ACL_MESSAGE_DELETE", new Permission[]{BasePermission.ADMINISTRATION, BasePermission.DELETE});
    }

    @Bean
    public EhCacheCacheManager ehCacheManagerFactoryBean() {
        return new EhCacheCacheManager();
    }

    @Bean
    public AuditLogger auditLogger() {
        return new ConsoleAuditLogger();
    }

    @Bean
    public PermissionGrantingStrategy permissionGrantingStrategy() {
        return new DefaultPermissionGrantingStrategy(auditLogger());
    }

    @Bean
    public AclAuthorizationStrategy aclAuthorizationStrategy() {
        return new AclAuthorizationStrategyImpl(new SimpleGrantedAuthority("ADMIN"));
    }

    @Bean
    public AclCache aclCache(CacheManager cacheManager) {
        return new SpringCacheBasedAclCache(cacheManager.getCache("aclCache"), permissionGrantingStrategy(), aclAuthorizationStrategy());
    }

    @Bean
    public LookupStrategy lookupStrategy(AclCache aclCache) {
        return new BasicLookupStrategy(this.dataSource, aclCache, aclAuthorizationStrategy(), permissionGrantingStrategy());
    }

    @Bean
    public AclService aclService(LookupStrategy lookupStrategy, AclCache aclCache) {
        return new JdbcMutableAclService(this.dataSource, lookupStrategy, aclCache);
    }

    @Bean
    public AclPermissionEvaluator permissionEvaluator(AclService aclService) {
        return new AclPermissionEvaluator(aclService);
    }
}
  • 스프링 시큐리티에서 ACL 서비스 작업은 AclService, MutableAclService 두 인터페이스로 정의합니다.
  • AclService는 읽기 작업을, 그 하위 인터페이스는 MutableAclService는 나머지 ACL 작업들(생성, 수정, 삭제)를 각각 기술합니다.
  • 그냥 ACL 읽기만 할 경우 JdbcAclService 같은 AclService 구현체를, 그 외에는 JdbcMutableAclService같은 MutableAclService 구현체를 각각 골라쓰면 됩니다.
  • 예제에서는 ADMIN 권한을 지닌 유저만 ACL 소유권 ACE 검사 세부 등 여러가지 ACL/ACE 상세 정보를 수정할 수 있습니다.
  • PermissionGrantingStrategy형 생성자 인수는 자신이 가지고 있는데 Permission 값으로 주어진 SID에 ACL 액세스를 허용할지 결정합니다.
  • JdbcMutableAclService에는 ACL 데이터를 RDBMS에서 관리할 때 필요한 표준 SQL문이 들어 있지만 모든 DB제품이 호환되는건 아닙니다.(아파티 더비)

도메인 객체에 대한 ACL 관리하기

백엔드 서비스와 DAO에는 의존성 주입을 이용해서 앞서 정의한 ACL 서비스를 이용하여도 도메인 객체용 ACL을 관리해야합니다. 가령 스케쥴 관리 앱에서는 할 일을 등록/삭제할 때마다 각각 ACL/생성 삭제해야합니다.

@Service
@Transactional
class TodoServiceImpl implements TodoService {

    private final TodoRepository todoRepository;
    private final MutableAclService mutableAclService;

    TodoServiceImpl(TodoRepository todoRepository, MutableAclService mutableAclService) {
        this.todoRepository = todoRepository;
        this.mutableAclService = mutableAclService;
    }

    @Override
    @PreAuthorize("hasAuthority('USER')")
    public void save(Todo todo) {

        this.todoRepository.save(todo);

        ObjectIdentity oid = new ObjectIdentityImpl(Todo.class, todo.getId());
        MutableAcl acl = mutableAclService.createAcl(oid);
        acl.insertAce(0, READ, new PrincipalSid(todo.getOwner()), true);
        acl.insertAce(1, WRITE, new PrincipalSid(todo.getOwner()), true);
        acl.insertAce(2, DELETE, new PrincipalSid(todo.getOwner()), true);

        acl.insertAce(3, READ, new GrantedAuthoritySid("ADMIN"), true);
        acl.insertAce(4, WRITE, new GrantedAuthoritySid("ADMIN"), true);
        acl.insertAce(5, DELETE, new GrantedAuthoritySid("ADMIN"), true);

    }
}

유저가 할 일등 등록하면 할일 ID와 ACL 객체의 ID를 이용해 ACL을 생성하고 반대로 할일을 삭제하면 해당 ACL도 함께 삭제합니다. 새로 등록한 할 일에 대해서는 다음 ACE를 ACL에 삽입합니다.

  • 할 일 등록자는 할일을 READE, WRITE, DELETE를 할 수 있습니다.
  • ADMIN 권한 유저도 할일을 READE, WRITE, DELETE 할 수 있습니다.

표현식을 이용해 접근 통제 결정하기

@Service
@Transactional
class TodoServiceImpl implements TodoService {

    ...

    @Override
    @PreAuthorize("hasAuthority('USER')")
    @PostFilter("hasAnyAuthority('ADMIN') or hasPermission(filterObject, 'read')")
    public List<Todo> listTodos() {
        return todoRepository.findAll();
    }

    @Override
    @PreAuthorize("hasPermission(#id, 'com.apress.springrecipes.board.Todo', 'write')")
    public void complete(long id) {
        Todo todo = findById(id);
        todo.setCompleted(true);
        todoRepository.save(todo);
    }

    @Override
    @PreAuthorize("hasPermission(#id, 'com.apress.springrecipes.board.Todo', 'delete')")
    public void remove(long id) {
        todoRepository.remove(id);

        ObjectIdentity oid = new ObjectIdentityImpl(Todo.class, id);
        mutableAclService.deleteAcl(oid, false);
    }

    @Override
    @PostFilter("hasPermission(filterObject, 'read')")
    public Todo findById(long id) {
        return todoRepository.findOne(id);
    }
}
  • 도메인 객체마다 ACL이 부착되어 있으니 이 객체에 속한 메서드마다 접그 텅제 결정을 내릴 수 있습니다.
  • 유저가 할 일을 삭제하려고 하면 ACL을 보고 그 유저가 정말 삭제할 권한이 있는지 체크할 수 있습니다,
  • @PreAuthorize / @PreFilter, @PostAuthorize / @PostFilter 애노테이션을 이용하면 해당 리소스에 대한 사용 권한이 있는지 체크할 수 있습니다.
  • @EnableGobalMethodSecurity(prePostEnalbe = true) 설정을 해햐 위의 어노테이션을 사용할 수 있습니다.
  • ``@PreAuthorize`는 유저가 메서드를 수행할 퍼미션을 갖고 있는지 체크합니다.

[레시피 11-1] 스프링 배치 기초 공사하기

과제

  • JobRepository용 데이터 저장소가 필요하고 그 밖에도 스프링 배치 작동에 필요한 협력 객체들이 필요함. 이 구성은 대부분 표준화가 되어있음

해결책

스프링 배치에 메타데이터를 어느 DB에 저장하는지 설정

풀이

  • 스프링 배치에 있어 JobRepository는 핵심 입니다.
  • SimpleJobRepository는 이 인터페이스를 구현한 사실상 유일한 클래스로 JobRepostiroyFactoryBean을 이용해 생상하며 배치 처리 상태를 데이터 저장소에 보관하는 일을합니다.
  • MapJobRepositoryFatoryBean 역시 SimpleJobRepository를 생성하는 표준 팩토리 빈이지만 인메모리 구현체라서 상태 정보가 저장되지 않기 때문에 주로 테스트 용도로 쓰입니다.
  • JobRepository 인스턴스는 DB를 전제로 작동하므로 스프링 배치용 스키마는 미리 구성되어 있어야합니다. 이 스키마는 DB 제품별로 스프링 배치 배포판에 준비되어 있습니다.
  • DDL 설정 파일은 /org/springframework/batch/core 디렉터에 들어 있음 H2 메모리 디비를 사용 예정이라 schema-h2.sq 사용
@Configuration
@PropertySource("classpath:batch.properties")
public class BatchConfiguration {

    @Autowired
    private Environment env;

    @Bean
    public DataSource dataSource() {
        DriverManagerDataSource dataSource = new DriverManagerDataSource();
        dataSource.setUrl(env.getRequiredProperty("dataSource.url"));
        dataSource.setUsername(env.getRequiredProperty("dataSource.username"));
        dataSource.setPassword(env.getRequiredProperty("dataSource.password"));
        return dataSource;
    }

    @Bean
    public DataSourceInitializer dataSourceInitializer() {
        DataSourceInitializer initializer = new DataSourceInitializer();
        initializer.setDataSource(dataSource());
        initializer.setDatabasePopulator(databasePopulator());
        return initializer;
    }

    private DatabasePopulator databasePopulator() {
        ResourceDatabasePopulator databasePopulator = new ResourceDatabasePopulator();
        databasePopulator.setContinueOnError(true);
        databasePopulator.addScript(new ClassPathResource("org/springframework/batch/core/schema-h2.sql"));
        databasePopulator.addScript(new ClassPathResource("sql/reset_user_registration.sql"));
        return databasePopulator;
    }

    @Bean
    public PlatformTransactionManager transactionManager() {
        return new DataSourceTransactionManager(dataSource());
    }

    @Bean
    public JobRepositoryFactoryBean jobRepository() {
        JobRepositoryFactoryBean jobRepositoryFactoryBean = new JobRepositoryFactoryBean();
        jobRepositoryFactoryBean.setDataSource(dataSource());
        jobRepositoryFactoryBean.setTransactionManager(transactionManager());
        return jobRepositoryFactoryBean;
    }

    @Bean
    public JobLauncher jobLauncher() throws Exception {
        SimpleJobLauncher jobLauncher = new SimpleJobLauncher();
        jobLauncher.setJobRepository(jobRepository().getObject());
        return jobLauncher;
    }

    @Bean
    public JobRegistryBeanPostProcessor jobRegistryBeanPostProcessor() {
        JobRegistryBeanPostProcessor jobRegistryBeanPostProcessor = new JobRegistryBeanPostProcessor();
        jobRegistryBeanPostProcessor.setJobRegistry(jobRegistry());
        return jobRegistryBeanPostProcessor;
    }

    @Bean
    public JobRegistry jobRegistry() {
        return new MapJobRegistry();
    }

}
  • DataSource, PlatformTransactionManager, dataSourceInitializer 구성합니다.
  • jobRegistry() 메서드는 MapJobRegistry 인스턴스를 반환합니다. 이 빈은 특정 잡에 관한 정보를 담고 있는 중ㅇ앙 저장소이자. 시스템 내부 전체 잡들을 큰 그림을 그리며 관장하는 빈 입니다.
  • SimpleJobLauncher의 유일한 임무는 배치 잡을 시동하는 매커니즘을 컨네주는 일입니다.
@Configuration
@EnableBatchProcessing
@ComponentScan("com.apress.springrecipes.springbatch")
@PropertySource("classpath:/batch.properties")
public class BatchConfiguration {

    @Autowired
    private Environment env;

    @Bean
    public DataSource dataSource() {
        DriverManagerDataSource dataSource = new DriverManagerDataSource();
        dataSource.setUrl(env.getRequiredProperty("dataSource.url"));
        dataSource.setUsername(env.getProperty("dataSource.username"));
        dataSource.setPassword(env.getProperty("dataSource.password"));
        return dataSource;
    }

    @Bean
    public DataSourceInitializer databasePopulator() {
        ResourceDatabasePopulator populator = new ResourceDatabasePopulator();
        populator.addScript(new ClassPathResource("org/springframework/batch/core/schema-h2.sql"));
        populator.addScript(new ClassPathResource("sql/reset_user_registration.sql"));
        populator.setContinueOnError(true);
        populator.setIgnoreFailedDrops(true);

        DataSourceInitializer initializer = new DataSourceInitializer();
        initializer.setDatabasePopulator(populator);
        initializer.setDataSource(dataSource());
        return initializer;
    }
   
}
  • 스프링 배치에는 @EnableBatchProcessing 을 붙여 기본 값을 바로 구성하는 방법도 제공합니다.
  • 데이터 소스를 가져오고 DB를 초기화 하는 빈 2개가 전부입니다.
  • 나머지 일들은 @EnableBatchProcessing을 사용해서 스프링 배치가 처리합니다.
public class Main {
    public static void main(String[] args) throws Throwable {
        ApplicationContext context = new AnnotationConfigApplicationContext(BatchConfiguration.class);

        JobRegistry jobRegistry = context.getBean("jobRegistry", JobRegistry.class);
        JobLauncher jobLauncher = context.getBean("jobLauncher", JobLauncher.class);
        JobRepository jobRepository = context.getBean("jobRepository", JobRepository.class);

        System.out.println("JobRegistry: " + jobRegistry);
        System.out.println("JobLauncher: " + jobLauncher);
        System.out.println("JobRepository: " + jobRepository);
    }
}
  • JobRepository, JobRegistry, JobLauncher 기본으로 구성됩니다.

[레시피 11-2] 데이터 읽기/쓰기

과제

  • CSV 파일에서 데이터를 읽어 DB에 입력하려고합니다.

해결책

  • 가급적 최소한의 노력으로 실제 응용 가능한 솔루션을 작성해보겠습니다.임의 길이의 파일일 읽어 그 데이터를 DB에 넣는 애플리케이션 입니다.

풀이

  • 콤마와 개행문자로 구분된 CSV 파일에 데이터를 읽어 DB 테이블에 레코드를 삽입하는 일이 전부입니다.
  • 스프링 배치가 선사하는 영리한 인프라를 사용하면 확장성은 걱절할 피룡가 없습니다. 트랜잭션 기능이나 재시도 같은 문제는 앞으로 신경 쓰지 않아도 좋습니다.
create table USER_REGISTRATION
(
  ID BIGINT NOT NULL PRIMARY KEY GENERATED ALWAYS AS IDENTITY (START WITH 1, INCREMENT BY 1),
  FIRST_NAME VARCHAR(255) not null,
  LAST_NAME VARCHAR(255) not null,
  COMPANY VARCHAR(255) not null,
  ADDRESS VARCHAR(255) not null,
  CITY VARCHAR(255) not null,
  STATE VARCHAR(255) not null,
  ZIP VARCHAR(255) not null,
  COUNTY VARCHAR(255) not null,
  URL VARCHAR(255) not null,
  PHONE_NUMBER VARCHAR(255) not null,
  FAX VARCHAR(255) not null
) ;

잡구성하기

@Configuration
public class UserJob {

    private static final String INSERT_REGISTRATION_QUERY =
            "insert into USER_REGISTRATION (FIRST_NAME, LAST_NAME, COMPANY, ADDRESS,CITY,STATE,ZIP,COUNTY,URL,PHONE_NUMBER,FAX)" +
            " values " +
            "(:firstName,:lastName,:company,:address,:city,:state,:zip,:county,:url,:phoneNumber,:fax)";

    @Autowired
    private JobBuilderFactory jobs;

    @Autowired
    private StepBuilderFactory steps;

    @Autowired
    private DataSource dataSource;

    @Value("file:${user.home}/batches/registrations.csv")
    private Resource input;

    @Bean
    public Job insertIntoDbFromCsvJob() {
        return jobs.get("User Registration Import Job")
                .start(step1())
                .build();
    }

    @Bean
    public Step step1() {
        return steps.get("User Registration CSV To DB Step")
                .<UserRegistration,UserRegistration>chunk(5)
                .reader(csvFileReader())
                .writer(jdbcItemWriter())
                .build();
    }

    @Bean
    public FlatFileItemReader<UserRegistration> csvFileReader() {
        FlatFileItemReader<UserRegistration> itemReader = new FlatFileItemReader<>();
        itemReader.setLineMapper(lineMapper());
        itemReader.setResource(input);
        return itemReader;
    }

    @Bean
    public JdbcBatchItemWriter<UserRegistration> jdbcItemWriter() {
        JdbcBatchItemWriter<UserRegistration> itemWriter = new JdbcBatchItemWriter<>();
        itemWriter.setDataSource(dataSource);
        itemWriter.setSql(INSERT_REGISTRATION_QUERY);
        itemWriter.setItemSqlParameterSourceProvider(new BeanPropertyItemSqlParameterSourceProvider<>());
        return itemWriter;
    }

    @Bean
    public DefaultLineMapper<UserRegistration> lineMapper() {
        DefaultLineMapper<UserRegistration> lineMapper = new DefaultLineMapper<>();
        lineMapper.setLineTokenizer(tokenizer());
        lineMapper.setFieldSetMapper(fieldSetMapper());
        return lineMapper;
    }

    @Bean
    public BeanWrapperFieldSetMapper<UserRegistration> fieldSetMapper() {
        BeanWrapperFieldSetMapper<UserRegistration> fieldSetMapper = new BeanWrapperFieldSetMapper<>();
        fieldSetMapper.setTargetType(UserRegistration.class);
        return fieldSetMapper;
    }

    @Bean
    public DelimitedLineTokenizer tokenizer() {
        DelimitedLineTokenizer tokenizer = new DelimitedLineTokenizer();
        tokenizer.setDelimiter(",");
        tokenizer.setNames("firstName","lastName","company","address","city","state","zip","county","url","phoneNumber","fax");
        return tokenizer;
    }
}
  • 잡은 여러 스텝으로 구성되며 각 스텝은 우어진 잡을 수생합니다. 스텝은 잡을 수행하는 가장 닥은 단위로 비즈니스로직에 따라서 복잡해 질수도 있고 단순할 수 도 있습니다.
  • 입력이 스텝으로 전해지고 처리가 끝나면 출럿이 만들어집니다. 처리 로직은 Tasket으로 기술합니다.
  • 배치 처리의 가장 중요한 단명중 하나인 청크 지향 처리를 할 경우 chenk()라는 구성 메서드를 사용합니다.
  • 청크 지향 처리에서는 입력기기가 입력을 읽고 부가적인 처리를 한 뒤에 애그리게이션(종료)합니다. 마지막으로 commit-interval 속성으로 처리해 주기를 설정해서 트랜잭션 커밋하기전에 얼마나 많은 아이템을 출력기로 보낼지 정합니다.커밋 직전에 DB 메타 데이터를 수정해서 해당 잡을 완료했다는 사실을 알립니다.

입력

@Bean
public FlatFileItemReader<UserRegistration> csvFileReader() {
    FlatFileItemReader<UserRegistration> itemReader = new FlatFileItemReader<>();
    itemReader.setLineMapper(lineMapper());
    itemReader.setResource(input);
    return itemReader;
}

@Bean
public JdbcBatchItemWriter<UserRegistration> jdbcItemWriter() {
    JdbcBatchItemWriter<UserRegistration> itemWriter = new JdbcBatchItemWriter<>();
    itemWriter.setDataSource(dataSource);
    itemWriter.setSql(INSERT_REGISTRATION_QUERY);
    itemWriter.setItemSqlParameterSourceProvider(new BeanPropertyItemSqlParameterSourceProvider<>());
    return itemWriter;
}

@Bean
public DefaultLineMapper<UserRegistration> lineMapper() {
    DefaultLineMapper<UserRegistration> lineMapper = new DefaultLineMapper<>();
    lineMapper.setLineTokenizer(tokenizer());
    lineMapper.setFieldSetMapper(fieldSetMapper());
    return lineMapper;
}

@Bean
public BeanWrapperFieldSetMapper<UserRegistration> fieldSetMapper() {
    BeanWrapperFieldSetMapper<UserRegistration> fieldSetMapper = new BeanWrapperFieldSetMapper<>();
    fieldSetMapper.setTargetType(UserRegistration.class);
    return fieldSetMapper;
}

@Bean
    public DelimitedLineTokenizer tokenizer() {
        DelimitedLineTokenizer tokenizer = new DelimitedLineTokenizer();
        tokenizer.setDelimiter(",");
        tokenizer.setNames("firstName","lastName","company","address","city","state","zip","county","url","phoneNumber","fax");
        return tokenizer;
    }
  • FlatFileItemReader<T> 클래스는 파일의 필드와 값을 구분하는 각업을 LineMapper<T>에 맡기고 LineMapper<T>는 전달받은 레코드에서 필드를식별한 작업을 다시 LineTokenizer에게 맡깁니다. 해당 예제는 , 기반으로 필드를 구분하고 있습니다.
  • 예제는 UserRegistraction형 POJO를 생성하는 BeanWrapperFieldSetMapper를 사용했습니다. 레코드를 한 줄씩 읽을 때마다 해당 값들은 POJO 인스턴스에 적용한 뒤 그 객체를 반환합니다.
public class UserRegistration implements Serializable {
    private String firstName;
    private String lastName;
    private String company;
    private String address;
    private String city;
    private String state;
    private String zip;
    private String county;
    private String url;
    private String phoneNumber;
    private String fax;

    // getter, setter
}

출력

@Bean
public JdbcBatchItemWriter<UserRegistration> jdbcItemWriter() {
    JdbcBatchItemWriter<UserRegistration> itemWriter = new JdbcBatchItemWriter<>();
    itemWriter.setDataSource(dataSource);
    itemWriter.setSql(INSERT_REGISTRATION_QUERY);
    itemWriter.setItemSqlParameterSourceProvider(new BeanPropertyItemSqlParameterSourceProvider<>());
    return itemWriter;
}
  • 출력기는 입력기가 일긍ㄴ 아이템 컬렉션을 모아 처리하는 작업을 담당합니다.
  • 예제에서는 새 컬랙션을 만들어 계석 데이터를 써넣으면 그 개수가 chunk 엘리먼트의 commit-interval 속성값을 초과할 때마다 다시 초기화합니다.

[레시피 11-3] 커스텀 ItemWriter/ItemReader작성하기

과제

  • 스프링 배치 자체로 접속 방법을 알 수 없은 리소스(RESS 피드, 기타...)와 통신하세요

해결책

ItemWriter/ItemReader를 직접 작성합니다. 인터페이스가 단순해서 구현 클래스도 별로하는 일이 없습니다.

풀이

커스텀 ItemReader 작성하기

public class UserRegistrationItemReader implements ItemReader<UserRegistration> {

    private final UserRegistrationService userRegistrationService;

    public UserRegistrationItemReader(UserRegistrationService userRegistrationService) {
        this.userRegistrationService = userRegistrationService;
    }

    public UserRegistration read() throws Exception {
        final Date today = new Date();
        Collection<UserRegistration> registrations = userRegistrationService.getOutstandingUserRegistrationBatchForDate(1, today);
        return registrations.stream().findFirst().orElse(null);
    }
}
  • 중요한 작업은 원격 서비스에게 넙깁니다. ItemReader 인터페이스의 reade()메서드는 레코드 1개를 매개변수화한 아이템 타입으로 변환합니다.

커스텀 ItemWriter 작성하기

public class UserRegistrationServiceItemWriter implements ItemWriter<UserRegistration> {

    private static final Logger logger = LoggerFactory.getLogger(UserRegistrationServiceItemWriter.class);

    // this is the client interface to an HTTP Invoker service.
    private final UserRegistrationService userRegistrationService;

    public UserRegistrationServiceItemWriter(UserRegistrationService userRegistrationService) {
        this.userRegistrationService = userRegistrationService;
    }

    /**
     * takes aggregated input from the reader and 'writes' them using a custom implementation.
     */
    public void write(List<?extends UserRegistration> items) throws Exception {
        items.forEach(this::write);
    }

    private void write(UserRegistration userRegistration) {
        UserRegistration registeredUserRegistration = userRegistrationService.registerUser(userRegistration);
        logger.debug("Registered: {}", registeredUserRegistration);

    }
}
  • ItemWriter 출력할 아이템 타입으로 매개변수화한 인터페이스로 구성됩니다.
  • 주어진 타입의 객체리스트의 write() 메서드하나만 있습니다.

[레시피 13-4] 스프링에서 이메일 보내기

과제

  • JavaMail API로 이 메일을 보내지만 이 API만 사용하면 결국 특정 API에 구속되어 다른 이메일 API로 전환하기가 어렵습니다.

해결책

  • 스프링은 이메일 지원 기능은 구현체와 상관없이 추상화한 API를 제공하므로 이메일을쉽게 보낼 수 있습니다.
  • MailSender는 핵심 인터페이스로, 하위 인터페이스은 JavaMailSerder에는 MIME(다용도 인터넷 확장) 메시지 지원 같은 구체적인 JavaMail 기능이 구현되어 있습니다.

풀이

// 파일 복제기에서 에라가 발생하면 관리자에게 이메일로 알려주는 기능 인터페이스
public interface ErrorNotifier {
    void notifyCopyError(String srcDir, String destDir, String filename);
}

SMT 이메일 서버 구성

> telnet 127.0.0.1 4555
JAMES Remote Administation Toll 2.3.2
Please enter your login and password
Login id :
root
password :
itroot
Welcole root, HELP for a list of commands
adduser ststem 12345
User System added
adduser admin 12345
User admin added
listusers
Existring accouts 2
user: admin
user system
quet
Bye

JavaMail API로 이메일 보내기

// ErrorNotifier 인터페이스를 구현해서 에러가 나면 이메일을 보내 알리는 기능을 작성
public class EmailErrorNotifier implements ErrorNotifier {

    public void notifyCopyError(String srcDir, String destDir, String filename) {
        // SMTP 서버 접속에 필요한 프로퍼티값을 지정하여 이메일 세션을 열고 이세션에서 이메일에 넣을 메시지를 가져와 작성
        Properties props = new Properties();
        props.put("mail.smtp.host", "localhost");
        props.put("mail.smtp.port", "25");
        props.put("mail.smtp.username", "system");
        props.put("mail.smtp.password", "12345");
        Session session = Session.getDefaultInstance(props, null);
        try {
            Message message = new MimeMessage(session);
            message.setFrom(new InternetAddress("system@localhost"));
            message.setRecipients(Message.RecipientType.TO,
                    InternetAddress.parse("admin@localhost"));
            message.setSubject("File Copy Error");
            message.setText(
                "Dear Administrator,\n\n" +
                "An error occurred when copying the following file :\n" +
                "Source directory : " + srcDir + "\n" +
                "Destination directory : " + destDir + "\n" +
                "Filename : " + filename);
            Transport.send(message);
        } catch (MessagingException e) {
            //JavaMail API를 직접사용할 경우 checked Exception이 발생하기 때문에 반드시 해당 처리 필요
            throw new RuntimeException(e);
        }
    }
}
// 이메일을 보낼 ErrorNotifier 인스턴스를 IoC 컨테이너에 선언합니다.
@Configuration
public class MailConfiguration {
    @Bean
    public ErrorNotifier errorNotifier() {
        return new EmailErrorNotifier();
    }
}

public class Main {
    public static void main(String[] args) {
        ApplicationContext context =
            new AnnotationConfigApplicationContext("com.apress.springrecipes.replicator.config");

        ErrorNotifier errorNotifier = context.getBean(ErrorNotifier.class);
        errorNotifier.notifyCopyError("c:/documents", "d:/documents", "spring.doc");
    }
}
> telnet 127.0.0.1 110
OK workstation POP3 Server <....> ready
User Aadmin
+OK
PSS 12345
+OK welcome admin
LIST
+ OK 1 698
RETR 1
+OK Message follows
...

스프링 MailSender로 이메일 보내기

// 스프링 MailSender 인터페이스를 이용하면 send() 메서드로 SimpleMailMessage를 보낼수 있음
// JavaMail에 종속되지 않은 코드를 작성할 수 있고 테스트하기도 쉽습니다.
public class EmailErrorNotifier implements ErrorNotifier {

    private MailSender mailSender;

    public void setMailSender(MailSender mailSender) {
        this.mailSender = mailSender;
    }

    public void notifyCopyError(String srcDir, String destDir, String filename) {
        SimpleMailMessage message = new SimpleMailMessage();
        message.setFrom("system@localhost");
        message.setTo("admin@localhost");
        message.setSubject("File Copy Error");
        message.setText(
                "Dear Administrator,\n\n" +
                "An error occurred when copying the following file :\n" +
                "Source directory : " + srcDir + "\n" +
                "Destination directory : " + destDir + "\n" +
                "Filename : " + filename);
        mailSender.send(message);
    }
}

// MailSender 구현체를 EmailErrorNotifiter에 주입
// 스프링에서 MailSender 인터페이스를 이용해서 JavaMail를 이용해서 이메일을 보냅니다.
@Configuration
public class MailConfiguration {

    @Bean
    public ErrorNotifier errorNotifier() {
        EmailErrorNotifier errorNotifier = new EmailErrorNotifier();
        errorNotifier.setMailSender(mailSender());
        return errorNotifier;
    }

    @Bean
    public JavaMailSenderImpl mailSender() {
        JavaMailSenderImpl mailSender = new JavaMailSenderImpl();
        mailSender.setHost("localhost");
        mailSender.setPort(25);
        mailSender.setUsername("system");
        mailSender.setPassword("12345");
        return mailSender;
    }
}

이메일 템플릿 정의하기

// String.format() 메서드로 실제 메시지 매개변수값으로 치환
// Velocity 같은 템플릿을 활용하는 것을 권장
// 이메일 메시지 템필릿은 빈 구성 파일과 분리하는 것이 더 좋음
@Bean
public SimpleMailMessage copyErrorMailMessage() {
    SimpleMailMessage message = new SimpleMailMessage();
    message.setFrom("system@localhost");
    message.setTo("admin@localhost");
    message.setSubject("File Copy Error");
    message.setText("Dear Administrator,\n" +
            "\n" +
            "                       An error occurred when copying the following file :\n" +
            "\t\t       Source directory : %s\n" +
            "\t\t       Destination directory : %s\n" +
            "\t\t       Filename : %s");
    return message;
}

public class EmailErrorNotifier implements ErrorNotifier {
    // 템플릿이 주입된 SimpleMailMessage 인스턴스가 생성되고 %s 기반으로 치환해서 텍스트가 완성
    private MailSender mailSender;
    private SimpleMailMessage copyErrorMailMessage;
    ...
    public void notifyCopyError(String srcDir, String destDir, String filename) {
        SimpleMailMessage message = new SimpleMailMessage(copyErrorMailMessage);
        message.setText(String.format(
                copyErrorMailMessage.getText(), srcDir, destDir, filename));
        mailSender.send(message);
    }
}

[레시피 13-5] 스프링 쿼츠로 작업 스케쥴링하기

과제

  • 쿼츠 스케쥴러를 이용해서 잡 스케쥴링을 구성 하세요

해결책

  • 스프링 제공하는 쿼치 유틸리티 클래스를 이요하면 쿼치 API를 직접 프로그래밍하지 않고 잡을 스케쥴링할 수 있습니다.

풀이

스프링 유틸리티 클래스 없이 쿼츠를 사용하는 기본적인 방법을 먼저 살펴 보고 쿼치를 사용하는 방법을 소개

스프링 없이 쿼츠 직접 사용하기

// Job 인터페이스를 구현한 잡을 생성
// JobExecutionContext 객체를 이용해 잡 데이터 맵을 가져옵니다.
public class FileReplicationJob implements Job {
    public void execute(JobExecutionContext context)
            throws JobExecutionException {
        Map<String, Object> dataMap = context.getJobDetail().getJobDataMap();
        FileReplicator fileReplicator =
            (FileReplicator) dataMap.get("fileReplicator");
        try {
            fileReplicator.replicate();
        } catch (IOException e) {
            throw new JobExecutionException(e);
        }
    }
}
// 60초마다 한번씩 처음 한번은 5초 있다가 파일을  복제 잡을 생항하는 스케쥴러입니다.
public class Main {

    public static void main(String[] args) throws Exception {
        ApplicationContext context =
                new AnnotationConfigApplicationContext("com.apress.springrecipes.replicator.config");

        FileReplicator documentReplicator = context.getBean(FileReplicator.class);

        // JobDataMap을 생성, 이 맵에 파일 복제 잡을 하나 추가합니다.
        JobDataMap jobDataMap = new JobDataMap();
        jobDataMap.put("fileReplicator", documentReplicator);

        JobDetail job = JobBuilder.newJob(FileReplicationJob.class)
                .withIdentity("documentReplicationJob")
                .storeDurably()
                .usingJobData(jobDataMap)
                .build();
                
        // SimpleTrigger 객체를 만들어 스케쥴링 프로퍼티를 구성하고 마지막에 이 트리거로 잡을 싱핼할 스케쥴러를 설정합니다.
        Trigger trigger = TriggerBuilder.newTrigger()
                .withIdentity("documentReplicationTrigger")
                .startAt(new Date(System.currentTimeMillis() + 5000))
                .forJob(job)
                .withSchedule(SimpleScheduleBuilder.simpleSchedule()
                        .withIntervalInSeconds(60)
                        .repeatForever())
                .build();

        // 스케쥴러 설정
        Scheduler scheduler = new StdSchedulerFactory().getScheduler();
        scheduler.start();
        scheduler.scheduleJob(job, trigger);

    }
}

스프링 이용해 쿼츠 사용하기

// 쿼츠에서 잡은 Job 인터페이스를 구현해 생성
public class FileReplicationJob extends QuartzJobBean {

    private FileReplicator fileReplicator;

    public void setFileReplicator(FileReplicator fileReplicator) {
        this.fileReplicator = fileReplicator;
    }

    // 잡 데이터는 JobExecutionContext에서 JobDataMpa를 가져와 얻어옴
    protected void executeInternal(JobExecutionContext context)
            throws JobExecutionException {
        try {
            fileReplicator.replicate();
        } catch (IOException e) {
            throw new JobExecutionException(e);
        }
    }
}
// 쿼치 JobDetail 객체는 JobDetailBean을 사용해서 스프링 빈 구성 파일에 구성합니다.
// 기본적으로 스프링 빈 이름을 잡 이름으로 사용
// name 프로퍼티에 원하는 잡 이름을 설정 가능
@Configuration
public class QuartzConfiguration {

    @Bean
    @Autowired
    // 잡정의
    public JobDetailFactoryBean documentReplicationJob(FileReplicator fileReplicator) {
        JobDetailFactoryBean documentReplicationJob = new JobDetailFactoryBean();
        documentReplicationJob.setJobClass(FileReplicationJob.class);
        documentReplicationJob.setDurability(true);
        documentReplicationJob.setJobDataAsMap(Collections.singletonMap("fileReplicator", fileReplicator));
        return documentReplicationJob;
    }


    @Bean
    @Autowired
    // 쿼츠 트리거 구성
    // 스프링이 지원하는 트리거는 SimpleTriggerFactoryBean, CronTriggerFactoryBean  두 종류
    // SimpleTriggerFactoryBean는 JobDetail 객체 레퍼린스를 필요로 하며 시작 시간이나 반복 횟수처럼 자주 쓰이는 스케쥴러 프로퍼티 값을 설정 할 수있음
    public SimpleTriggerFactoryBean documentReplicationTrigger(JobDetail documentReplicationJob) {
        SimpleTriggerFactoryBean documentReplicationTrigger = new SimpleTriggerFactoryBean();
        documentReplicationTrigger.setJobDetail(documentReplicationJob);
        documentReplicationTrigger.setStartDelay(5000);
        documentReplicationTrigger.setRepeatInterval(60000);
        return documentReplicationTrigger;
    }

    @Bean
    @Autowired
    // CronTriggerFactoryBean을 이용한 스케쥴링 방식
    public CronTriggerFactoryBean documentReplicationTrigger(JobDetail documentReplicationJob) {
        CronTriggerFactoryBean documentReplicationTrigger = new CronTriggerFactoryBean();
        documentReplicationTrigger.setJobDetail(documentReplicationJob);
        documentReplicationTrigger.setStartDelay(5000);
        documentReplicationTrigger.setCronExpression("0/60 * * * * ?");
        return documentReplicationTrigger;
    }


    @Bean
    @Autowired
    // SchedulerFactoryBean 인스턴스를 만들어 트리거를 실행할 Scheduler 객체를 생성
    public SchedulerFactoryBean scheduler(Trigger[] triggers) {
        SchedulerFactoryBean scheduler = new SchedulerFactoryBean();
        scheduler.setTriggers(triggers);
        return scheduler;
    }
}

[레시피 13-6] 스프링에서 작업 스케쥴링하기

괘제

쿼츠를 쓰지 않고 크론 표현식으로 주기나 빈도를 설정하여 일관된 방향으로 메서드가 실행 되도록 스케쥴링 하고 싶습니다.

해결책

  • 스프링 TaskExcutor와 TaskScheduler를 구성할 수 있게 지원합니다.

풀이

@Configuration
@EnableScheduling
// @EnableScheduling 애너테이션을 이용해서 스케쥴링을 활성화
public class SchedulingConfiguration implements SchedulingConfigurer {
    @Override
    public void configureTasks(ScheduledTaskRegistrar taskRegistrar) {
        taskRegistrar.setScheduler(scheduler());
    }
    @Bean
    public Executor scheduler() {
        // 스레드풀 갯수 지정
        return Executors.newScheduledThreadPool(10);
    }
}
public class FileReplicatorImpl implements FileReplicator {
    ...
    // 60초마다 한번씩 실행되도록 설정
    // fixedDelay 설정을 통해 시작 시간의 간격을 일정하게 조절
    // @Scheduled(cron = "0/59 * * * * ? ") 정교하게 메서드를 실행 주기를 조정하고 싶습니다.
    @Scheduled(fixedDelay = 60 * 1000)
    public synchronized void replicate() throws IOException {
        File[] files = new File(srcDir).listFiles();
        for (File file : files) {
            if (file.isFile()) {
                fileCopier.copyFile(srcDir, destDir, file.getName());
            }
        }
    }
}

[레시피 16-6] 통합 테스트 트랜잭션 관리하기

과제

  • DB에 접속하는 통합 테스트는 보통 초기화 메서드에 테스트 데이터를 준비합니다.
  • 테스트 메서드가 하나씩 실행되면 그때마다 DB 데이터가 수정되므로 그다음 테스트 메서드를 일관성 있게 실행하려면 DB를 정리 해야합니다.

해결책

  • JUnit과 TestNG에서는 클래스/메서드 레벨 @Transactional을 붙여 테스트 컨텍스트 지원 클래스를 상속하지 않고도 테스트 메서드에 트랜잭션을 걸 수 있습니다.

풀이

public class JdbcAccountDao extends JdbcDaoSupport implements AccountDao {

    public void createAccount(Account account) {
        String sql = "INSERT INTO ACCOUNT (ACCOUNT_NO, BALANCE) VALUES (?, ?)";
        getJdbcTemplate().update(
                sql, account.getAccountNo(), account.getBalance());
    }

    public void updateAccount(Account account) {
        String sql = "UPDATE ACCOUNT SET BALANCE = ? WHERE ACCOUNT_NO = ?";
        getJdbcTemplate().update(
                sql, account.getBalance(), account.getAccountNo());
    }

    public void removeAccount(Account account) {
        String sql = "DELETE FROM ACCOUNT WHERE ACCOUNT_NO = ?";
        getJdbcTemplate().update(sql, account.getAccountNo());
    }

    public Account findAccount(String accountNo) {
        String sql = "SELECT BALANCE FROM ACCOUNT WHERE ACCOUNT_NO = ?";
        double balance = getJdbcTemplate().queryForObject(
                sql, Double.class, accountNo);
        return new Account(accountNo, balance);
    }
}

public static class JdbcBankConfiguration {
    @Bean
    public DataSource dataSource() {
        DriverManagerDataSource dataSource = new DriverManagerDataSource();
        dataSource.setUrl(env.getProperty("jdbc.url"));
        dataSource.setUsername(env.getProperty("jdbc.username"));
        dataSource.setPassword(env.getProperty("jdbc.password"));
        return dataSource;
    }

    @Bean
    public DataSourceTransactionManager transactionManager(DataSource dataSource) {
        return new DataSourceTransactionManager(dataSource);
    }

    @Bean
    public AccountDao accountDao(DataSource dataSource) {
        JdbcAccountDao accountDao = new JdbcAccountDao();
        accountDao.setDataSource(dataSource);
        return accountDao;
    }
}
  • DAO 구현체를 호출해 계정 정보를 지정하는 AccountService의 통합 테스트를 작성하기 전, 빈 구성 파일에서 InMemoryAccountDao를 JdbcAccountDao로 바꾸고 대상 데이터 소스를 설정합니다.

JUnit에서 테스트 컨텍스트 프레임워크 트랜잭션 관리하기

@RunWith(SpringRunner.class)
@ContextConfiguration(classes = BankConfiguration.class)
@Transactional
@Sql(scripts = {"classpath:/bank.sql"})
public class AccountServiceJUnit4ContextTests {

    private static final String TEST_ACCOUNT_NO = "1234";

    @Autowired
    private AccountService accountService;

    @Before
    public void init() {
        accountService.createAccount(TEST_ACCOUNT_NO);
        accountService.deposit(TEST_ACCOUNT_NO, 100);
    }

    @Test
    public void deposit() {
        accountService.deposit(TEST_ACCOUNT_NO, 50);
        assertEquals(accountService.getBalance(TEST_ACCOUNT_NO), 150, 0);
    }

    @Test
    public void withdraw() {
        accountService.withdraw(TEST_ACCOUNT_NO, 50);
        assertEquals(accountService.getBalance(TEST_ACCOUNT_NO), 50, 0);
    }

}
  • 테스트 컨텍스트 프레임워크에서 작성한 테스트 클래스/메스드 레벨에 @Transactional을 붙이면 테스트 메서드에 트랜잭션이 적용됩니다.
  • JUnit에서 지원 클래스를 상속하지 않아도 SpringRunner를 테스트를 실행기로 지정할 수 있습니다.
  • @Transactional을 붙이면 그 클래스의 모든 테스트 메서드에 트랜잭션이 적용됩니다.
  • 기본적으로 테스트 메서드에 적용된 트랜잭션은 메서드 실행이 끝나면 무조건 롤백됩니다. 이 로직을 변경하고 싶으면 @TransactionConfiguration의 defaultRollback 속성을 false로 설정합니다.

[레시피 16-7] 통합 테스트에서 DB 엑세스하기

과제

  • DB에 접속하는 애플리케이션, ORM 프레임워크로 개발된 애플리케이션 통합 테스트할 때 테스트 데이터를 미리 준비하고 테스트 메서드 실행 이후 데이터 검증할 수 있게 DB에 직접 엑세스하세요

해결책

  • 스프링 테스트 지원 기능을 이용하면 테스트에서 각종 DB 작업 시 JDBC 템플릿을 사용할 수 있습니다.
  • 테스트 컨텍스트 프레임워크의 지원 클래스를 상속하면 미리 준비된 JdbcTemplate 인스턴스를 가져올 수 있습니다.

풀이

  • 테이블 로우 개수를 세거나 테이블에서 로우를 삭제하고 SQL 스크립트를 실행하는 등 정형화된 작업을 수행하는 편의성 메서드를 제공합니다.
CREATE TABLE ACCOUNT (
    ACCOUNT_NO    VARCHAR(10)    NOT NULL,
    BALANCE       DOUBLE         NOT NULL,
    PRIMARY KEY (ACCOUNT_NO)
);
@ContextConfiguration(classes = BankConfiguration.class)
@Sql(scripts="classpath:/bank.sql")
public class AccountServiceJUnit4ContextTests extends AbstractTransactionalJUnit4SpringContextTests {

    private static final String TEST_ACCOUNT_NO = "1234";

    @Autowired
    private AccountService accountService;

    @Before
    public void init() {
        executeSqlScript("classpath:/bank.sql",true);
        jdbcTemplate.update(
                "INSERT INTO ACCOUNT (ACCOUNT_NO, BALANCE) VALUES (?, ?)",
                TEST_ACCOUNT_NO, 100);
    }

    @Test
    public void deposit() {
        accountService.deposit(TEST_ACCOUNT_NO, 50);
        double balance = jdbcTemplate.queryForObject(
                "SELECT BALANCE FROM ACCOUNT WHERE ACCOUNT_NO = ?",
                Double.class, TEST_ACCOUNT_NO);
        assertEquals(balance, 150.0, 0);
    }

    @Test
    public void withassets() {
        accountService.withassets(TEST_ACCOUNT_NO, 50);
        double balance = jdbcTemplate.queryForObject(
                "SELECT BALANCE FROM ACCOUNT WHERE ACCOUNT_NO = ?",
                Double.class, TEST_ACCOUNT_NO);
        assertEquals(balance, 50.0, 0);
    }

}
  • @Before() 메서드로 매번 해당 테이블을 생서앟고 데이터가 insert 됩니다.
  • executeSqlScript() 메서드 대신 클래스/ 메서드 레벨에 @Sql을 붙이면 원하는 SQL이나 스크릅트를 실핼 할 수 있습니다.

[레시피 16-8] 스프링 공통 테스트 애너테이션 활용하기

과제

  • 어떤 예외가 던져지길 기대하고, 테스트 메서드를 여러 번 반복 실행하고, 테스트 메서드가 특정 시간 이내에 완료되는지 확인하고...
  • 이런 반복적인 테스트 작업을 편하게 구현 하는 방법

해결책

  • 스프링에서 지원하는 공통 테스트 애너테이션을 활용하면 테스트를 간결하게 작성할 수 있습니다.
애너테이션 설명
@Repeat 여러 번 실행 할 테스트 메서드에 붙입니다. 반복 횟수는 애너테이션 값에 지정합니다.
@Timed 테스트 메서드는 주어진 시간 내에 끄탠야합니다. 이 시간을 초과하면 테스트는 실패합니다.
@IfProfileValue 특정 테스트 환경에서만 실행할 테스트 메서드를 붙입니다. 실제 프로파일 값이 주어진 값과 일치라는 경우메난 실행합니다

풀이

@ContextConfiguration(classes = BankConfiguration.class)
@Sql(scripts="classpath:/bank.sql")
public class AccountServiceJUnit4ContextTests extends AbstractTransactionalJUnit4SpringContextTests {

    private static final String TEST_ACCOUNT_NO = "1234";

    @Autowired
    private AccountService accountService;

    @Before
    public void init() {
        jdbcTemplate.update(
                "INSERT INTO ACCOUNT (ACCOUNT_NO, BALANCE) VALUES (?, ?)",
                TEST_ACCOUNT_NO, 100);
    }

    @Test
    @Timed(millis = 1000)
    public void deposit() {
        accountService.deposit(TEST_ACCOUNT_NO, 50);
        double balance = jdbcTemplate.queryForObject(
                "SELECT BALANCE FROM ACCOUNT WHERE ACCOUNT_NO = ?",
                Double.class, TEST_ACCOUNT_NO);
        assertEquals(balance, 150.0, 0);
    }

    @Test
    @Repeat(5)
    public void withassets() {
        accountService.withassets(TEST_ACCOUNT_NO, 50);
        double balance = jdbcTemplate.queryForObject(
                "SELECT BALANCE FROM ACCOUNT WHERE ACCOUNT_NO = ?",
                Double.class, TEST_ACCOUNT_NO);
        assertEquals(balance, 50.0, 0);
    }
}

[레시피 16-9] 스프링 MVC 컨트롤러에 대한 통합 테스트 작성하기

과제

스프링 MVC 프레임워크로 개발한 웹 컨트롤러를 통합 테스트하세요

해결책

  • 스프링은 MVC 테스트를 지원하므로 Mock 서블릿 환경에서 쉽게 구성할 수 있습니다.

풀이

@ContextConfiguration(classes= { BankWebConfiguration.class, BankConfiguration.class})
@WebAppConfiguration
@Sql(scripts ="classpath:/bank.sql")
public class DepositControllerJUnit4ContextTests extends AbstractTransactionalJUnit4SpringContextTests {

    private static final String ACCOUNT_PARAM = "accountNo";
    private static final String AMOUNT_PARAM = "amount";

    private static final String TEST_ACCOUNT_NO = "1234";
    private static final String TEST_AMOUNT = "50.0";

    @Autowired
    private WebApplicationContext webApplicationContext;

    private MockMvc mockMvc;

    @Before
    public void init() {
        jdbcTemplate.update(
                "INSERT INTO ACCOUNT (ACCOUNT_NO, BALANCE) VALUES (?, ?)",
                TEST_ACCOUNT_NO, 100);
        mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext).build();

    }


    @Test
    public void deposit() throws Exception {
        mockMvc.perform(
                get("/deposit.do")
                        .param(ACCOUNT_PARAM, TEST_ACCOUNT_NO)
                        .param(AMOUNT_PARAM, TEST_AMOUNT))
                .andDo(print())
                .andExpect(forwardedUrl("/WEB-INF/views/success.jsp"))
                .andExpect(status().is(200));
    }
}
  • 테스트할 계정은 init() 메서드에서 SQL문으로 미리 설정합니다.
  • mockMvc.perform 객체를 통해서 해당 테스트에 대한 검증을 진행합니다.

[레시피 16-10] REST 클라이언트에 대한 통합 테스트 작성하기

과제

  • RestTemplate 클라이언트를 통합 테스트하세요

해결책

  • REST 기반의 클라이언트를 통합 테스트하려고합니다. 목 서버에서 예상 결과를 반환하도록 강치하면 실제 엔트포인트를 호출하지 않고도 통합 테스트 할 수 있습니다.

풀이

@RunWith(SpringRunner.class)
@ContextConfiguration(classes= { BankConfiguration.class})
public class OpenIBANValidationClientTest {

    @Autowired
    private OpenIBANValidationClient client;

    private MockRestServiceServer mockRestServiceServer;

    @Before
    public void init() {
        mockRestServiceServer = MockRestServiceServer.createServer(client);
    }

    @Test
    public void validIban() {

        mockRestServiceServer
                .expect(requestTo("https://openiban.com/validate/NL87TRIO0396451440?getBIC=true&validateBankCode=true"))
                .andRespond(withSuccess(new ClassPathResource("NL87TRIO0396451440-result.json"), MediaType.APPLICATION_JSON));

        IBANValidationResult result = client.validate("NL87TRIO0396451440");
        assertTrue(result.isValid());
    }

    @Test
    public void invalidIban() {

        mockRestServiceServer
                .expect(requestTo("https://openiban.com/validate/NL28XXXX389242218?getBIC=true&validateBankCode=true"))
                .andRespond(withSuccess(new ClassPathResource("NL28XXXX389242218-result.json"), MediaType.APPLICATION_JSON));

        IBANValidationResult result = client.validate("NL28XXXX389242218");
        assertFalse(result.isValid());
    }


}
<!-- NL28XXXX389242218-result.json -->
{
  "valid": false,
  "messages": [
    "Validation failed.",
    "Invalid bank code: XXXX",
    "No BIC found for bank code: XXXX"
  ],
  "iban": "NL28XXXX0389242218",
  "bankData": {
    "bankCode": "",
    "name": ""
  },
  "checkResults": {
    "bankCode": false
  }
}
  • 실제 API를 콜하고 반환되는 값을 검증합니다.

스프링 부트 테스트

어노테이션 설명 Bean
@SpringBootTest 통합 테스트, 전체 Bean 전체
@WebMvcTest 단위 테스트, Mvc 테스트 MVC 관련된 Bean
@DataJpaTest 단위 테스트, Jpa 테스트 JPA 관련 Bean
@RestClientTest 단위 테스트, Rest API 테스트 일부 Bean
@JsonTest 단위 테스트, Json 테스트 일부 Bean

@SpringBootTest

  • @SpringBootTest는 통합 테스트를 제공하는 기본적인 스프링 부트 테스트 어노테이션
  • @SpringBootTest는 실제 구동되는 애플리케이션과 똑강이 애플리케이션 컨텍스트를 로드하여 테스트 하기 때문에 모든 빈들을 메모리에 다 올립니다.
  • 애플리케이션 설정된 빈을 모두 로드하기 때문에 테스트 구동 시간이 오래 걸립니다.
@RunWith(SpringRunner.class)
@SpringBootTest
public class CartControllerTest {

    @Autowired
    private WebApplicationContext context;

    private MockMvc mockMvc;

    @Before
    public void setUp() throws Exception {
        this.mockMvc = MockMvcBuilders.webAppContextSetup(this.context)
                .build();
    }

    @Test
    public void getCarts() throws Exception {

        mockMvc.perform(get("/carts/{id}", 1L)
                .contentType(MediaType.APPLICATION_JSON))
                .andDo(print())
                .andDo(document.document(
                        pathParameters(parameterWithName("id").description("cart id"))
                ))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.cartProducts", is(notNullValue())))
                .andExpect(jsonPath("$.cartProducts[0].product", is(notNullValue())))
        ;
    }
}

@WebMvcTest

  • MVC를 위한 테스트입니다. 웹에서 테스트하기 힘든 컨트롤러를 테슽츠하는데 적합합니다.
  • 웹상에 요청, 응답, 시큐리티, 필터까지 자동으로 테스트하며 수동으로 추가 삭제 할 수 있습니다.
  • @WebMvcTest는 MVC관련된 @Controller, @ControllerAdvice, @JsonCompoent, Filter, WebMvcConfigure, HanlderMethodArgumentResolver 만도르디 되기때문에 가볍게 테스타 할 수 있습니다.
  • WebMvc를 테스트 하는 단위 테스트입니다. 모든 것을 테스트하는 것을 테스트의 범위가 넓어 지는 것이며 해당 단위 테스트 위주로 테스트하는 것이 다양한 장점들이 있습니다.
@RunWith(SpringRunner.class)
@WebMvcTest(MemberApi.class)
@ContextConfiguration(classes = ApiApplication.class)
public class MemberApiTest {

    @Autowired
    private MockMvc mvc;

    @Autowired
    private WebApplicationContext context;

    @MockBean
    private MemberSignUpService memberSignUpService;

    @Before
    public void setUp() throws Exception {

        this.mvc = MockMvcBuilders.webAppContextSetup(this.context)
                .build();
    }

    @Test
    public void signUp() throws Exception {
        //given
        final Email email = Email.of("test@asd.com");


        final SignUpRequest dto = SignUpRequest.builder()
                .email(email)
                .build();

        //when
        when(memberSignUpService.doSignUp(any())).thenReturn(member);

        //then
        final ResultActions resultActions = mvc.perform(post("/members")
                .contentType(MediaType.APPLICATION_JSON)
                .content(signUpRequest(dto.getEmail().getValue())))
                .andDo(print());

        resultActions
                .andExpect(status().isOk());

    }
}
  • @MockBean 어노테이션으로 Mock 기반으로 해당 의존성을 주입합니다.
  • when() 메서드를 통해서 받고자 하는 값을 Mocking 합니다.

@DataJpaTest

  • @DataJpaTest는 JPA 관련된 테스트 설정만 로드합니다.
  • 데이터소스의 설정이 정상 적인지, JPA를 사용하여 데이터를 제대로 생성, 수정, 삭제 하는 지 등의 테스가 가능합니다.
  • 내장 데이터베이스를 사용해 실제 데이터베이슬 사용하지 않고도 테스트 데이터베이스로 테스트 할 수 도 있습니다.
@RunWith(SpringRunner.class)
@DataJpaTest
@ActiveProfiles("local")
public class ProductRepositoryTest {

    @Autowired
    private ProductRepository productRepository;
    
    @Test
    public void save() {
        final Product product = Product.builder()
                .name("name")
                .provider("123")
                .shipping(Shipping.freeShipping())
                .build();

        final Product save = productRepository.save(product);

        assertThat(save.getShipping().getPrice(), is(product.getShipping().getPrice()));
        assertThat(save.getShipping().getMethod(), is(product.getShipping().getMethod()));
    }
}
  • @ActiveProfiles("local") local 환경으로 테스트 진행, H2 설정이 Local
  • productRepository에대한 테스트 코드 진행