슈퍼코딩/주특기(JAVA)

2024.06.05(수) 슈퍼코딩 신입연수원 10주차 Day 3 후기 - 테스팅, 서버 업무, 서블릿 컨테이너, filter, interceptor

곰돌이볼 2024. 6. 5. 10:44
  • 강의
    • 104강(SpringBoot와 테스팅) ~ 108강(서블릿 컨테이너와 WEB layer 좀 더 살펴보기)

테스팅


테스트 코드

  • 반복되는 검증(테스트) 과정을 줄이는 코드
  • 종류
    • Acceptance Test(E2E Test)
      • 전체 코드 검증
    • Integration Test(통합 테스트)
      • 전체 코드 검증 → 부분 또는 전체 통합 테스트
    • Unit Test
      • 코드 일부 테스트
      • 종류
        • 모킹 유닛 테스트
          • service layer 코드를 주로 검증
        • 순수 유닛 테스트
          • 의부 의존성이 없는 소스 코드 검증
          • 외부 라이브러리 동작 검증에 사용
  • 사용방법
    • 의존성 추가
      testImplementation 'org.springframework.boot:spring-boot-starter-test'
      
      // test lombok
      testCompileOnly 'org.projectlombok:lombok'
      testAnnotationProcessor 'org.projectlombok:lombok'
    • 테스트 코드 구조 = 소스 코드 구조
      • resource 폴더 추가 : Mark Directory as → Test Resources Root

테스트 실행 프레임워크

  • JUnit5
    • @Test : 메서드 실행
    • @BeforeEach : 테스트 초기화
    • @DisplayName : 테스트 이름 설정
  • AssertJ
    • JUnit과 함께 사용
    • assertEquals() : 기대(예상)와 결과가 동일한지 판단
    • assetTure : 참과 거짓 판단
    • assertThatExceptionOfType : 예외 검증
  • Test Code 시나리오
    • Given-When-Then
    • Given : 데스트 초기 상태 설정
    • When : 테스트 진행
    • Then : 기대와 결과가 동일한지 판단

순수 유닛 테스트

  • 의부 의존성이 없는 소스 코드 검증
  • 외부 라이브러리 동작 검증에 사용
import com.github.supercodingspring.repository.items.ItemEntity;
import com.github.supercodingspring.repository.storeSales.StoreSales;
import com.github.supercodingspring.web.dto.items.Item;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.*;

@Slf4j
class ItemMapperUtilTest {

    @DisplayName("ItemEntity의 itemEntityToItem 메소드 테스트")
    @Test
    void ItemEntityToItem() {
        // given
        ItemEntity itemEntity = ItemEntity.builder()
                                          .name("name")
                                          .type("type")
                                          .id(1)
                                          .price(1000)
                                          .stock(0)
                                          .cpu("CPU 1")
                                          .capacity("5G")
                                          .storeSales(new StoreSales())
                                          .build();

        // when
        Item item = ItemMapper.INSTANCE.itemEntityToItem(itemEntity);

        // then
        log.info("만들어진 item: " + item);
        assertEquals(itemEntity.getPrice(), item.getPrice());
        assertEquals(itemEntity.getId().toString(), item.getId());
        assertEquals(itemEntity.getCapacity(), item.getSpec().getCapacity());
        assertEquals(itemEntity.getCpu(), item.getSpec().getCpu());
    }
}

모킹 유닛 테스트

  • service layer 코드를 주로 검증
  • Mocking
    • 테스트 진행 시 의존성 문제를 해결하기 위해서 가짜 객체를 생성해서 사용하는 방법
    • 라이브러리 : mockito
      • mock(가짜 객체) 생성 및 주입
      • when으로 행동 정의
  • 생성자 주입이 안되기 때문에 필드 주입 사용하기
import com.github.supercodingspring.repository.airlineTicket.*;
import com.github.supercodingspring.repository.users.*;
import com.github.supercodingspring.service.exceptions.*;
import com.github.supercodingspring.web.dto.airline.Ticket;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;

import java.util.*;

import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;

@Slf4j
class AirReservationServiceUnitTest {

    @Mock // mock 객체
    private UserJpaRepository userJpaRepository;

    @Mock // mock 객체
    private AirlineTicketJpaRepository airlineTicketJpaRepository;

    @InjectMocks // 생성한 mock 객체 주입
    private AirReservationService airReservationService;

    @BeforeEach
    public void setUp(){
        MockitoAnnotations.openMocks(this);
    }

    @DisplayName("airlineTicket에 해당하는 유저 항공권들이 모두 있어서 성공하는 경우")
    @Test
    void FindUserFavoritePlaceTicketsCase1() {
        // given
        Integer userId = 5;
        String likePlace = "파리";
        String ticketType = "왕복";

        UserEntity userEntity = UserEntity.builder()
                                          .userId(userId)
                                          .likeTravelPlace(likePlace)
                                          .userName("name1")
                                          .phoneNum("1234")
                                          .build();

        List<AirlineTicket> airlineTickets = Arrays.asList(
                AirlineTicket.builder()
                             .ticketId(1)
                             .arrivalLocation(likePlace)
                             .ticketType(ticketType)
                             .build(),
                AirlineTicket.builder()
                             .ticketId(2)
                             .arrivalLocation(likePlace)
                             .ticketType(ticketType)
                             .build(),
                AirlineTicket.builder()
                             .ticketId(3)
                             .arrivalLocation(likePlace)
                             .ticketType(ticketType)
                             .build(),
                AirlineTicket.builder()
                             .ticketId(4)
                             .arrivalLocation(likePlace)
                             .ticketType(ticketType)
                             .build()
        );

        // when
        when(userJpaRepository.findById(any())).thenReturn(Optional.of(userEntity));
        when(airlineTicketJpaRepository.findAirlineTicketsByArrivalLocationAndTicketType(likePlace, ticketType))
                .thenReturn(airlineTickets);

        // then
        List<Ticket> tickets = airReservationService.findUserFavoritePlaceTickets(userId, ticketType);
        log.info("tickets: " + tickets);
        assertTrue(
                tickets.stream()
                       .map(Ticket::getArrival)
                       .allMatch((arrival) -> arrival.equals(likePlace))
        );
    }

    @DisplayName("User를 찾을 수 없는 경우, Exception 발생해야 함  ")
    @Test
    void FindUserFavoritePlaceTicketsCase4() {
        // given
        Integer userId = 5;
        String likePlace = "파리";
        String ticketType = "왕복";

        UserEntity userEntity = null;

        List<AirlineTicket> airlineTickets = Arrays.asList(
                AirlineTicket.builder().ticketId(1).arrivalLocation(likePlace).ticketType(ticketType).build(),
                AirlineTicket.builder().ticketId(2).arrivalLocation(likePlace).ticketType(ticketType).build(),
                AirlineTicket.builder().ticketId(3).arrivalLocation(likePlace).ticketType(ticketType).build(),
                AirlineTicket.builder().ticketId(4).arrivalLocation(likePlace).ticketType(ticketType).build()
        );

        // when
        when(userJpaRepository.findById(any())).thenReturn(Optional.ofNullable(userEntity));
        when(airlineTicketJpaRepository.findAirlineTicketsByArrivalLocationAndTicketType(likePlace, ticketType))
                .thenReturn(airlineTickets);

        // then
        assertThrows(NotFoundException.class,
                     () -> airReservationService.findUserFavoritePlaceTickets(userId, ticketType)
        );
    }
}

 

통합 슬라이스 테스트

  • 원하는 layer만 테스트하는 방식 → 실제 생성자 호출
  • 실제 해당하는 layer 속하는 bean만 호출 → 빠르게 테스트 가능
    • ex) Repository Layer 테스트를 한다면 @Controller, @Service 빈이 아닌 @Repository 빈만 호출
  • @WebMvcTest : Web Layer(Controller 부분) 슬라이스 테스트
  • @DataJpaTest : Repository layer 슬라이스 테스트
    • 실제 DB와 연결함
import com.github.supercodingspring.repository.airlineTicket.*;
import com.github.supercodingspring.repository.passenger.*;
import com.github.supercodingspring.service.AirReservationService;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;

import java.util.List;

import static org.junit.jupiter.api.Assertions.*;

@DataJpaTest // slice test => Dao Lay / Jpa 사용하고 있는 Slice Test
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Slf4j
class ReservationJpaRepositoryJpaTest {

// 다른 layer의 생성자 주입하면 안됨
//    @Autowired
//    private AirReservationService airReservationService;

	// 생성자 주입
    @Autowired
    private ReservationJpaRepository reservationJpaRepository;

    @Autowired
    private PassengerJpaRepository passengerJpaRepository;

    @Autowired
    private AirlineTicketJpaRepository airlineTicketJpaRepository;

    @DisplayName("ReservationRepository로 항공편 가격과 수수료 검색")
    @Test
    void FindFlightPriceAndCharge() {
        // given
        Integer userId = 10;

        // when
        List<FlightPriceAndCharge> flightPriceAndCharges = reservationJpaRepository.findFlightPriceAndCharge(userId);

        // then
        log.info("결과: " + flightPriceAndCharges);
    }

    @DisplayName("Reservation 예약 진행")
    @Test
    void saveReservation(){
        // given
        Integer userId = 10;
        Integer ticketId = 5;

        Passenger passenger = passengerJpaRepository.findPassengerByUserUserId(userId).get();
        AirlineTicket airlineTicket = airlineTicketJpaRepository.findById(5).get();

        // when
        Reservation reservation = new Reservation(passenger, airlineTicket);
        Reservation res = reservationJpaRepository.save(reservation);

        // then
        log.info("결과: " + res);
        assertEquals(res.getPassenger(), passenger);
        assertEquals(res.getAirlineTicket(), airlineTicket);

    }
}

 

전체 통합 테스트

  • Mocking Bean 없이 모든 Bean을 호출하여 전체 코드 테스트하는 방법
  • MockMVC : 실제 API 호출하듯이 해서 Mock이 붙여짐, Mock을 사용해서 그렇게 하는 것은 아님
  • @SpringBootTest : 통합 테스트
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;

import java.nio.charset.StandardCharsets;

import static org.junit.jupiter.api.Assertions.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;


@SpringBootTest
@AutoConfigureMockMvc
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Slf4j
class AirReservationControllerSpringTest {

    @Autowired
    private MockMvc mockMvc;

    @DisplayName("Find Airline Tickets 성공")
    @Test
    void FindAirlineTickets() throws Exception {
        // given
        Integer userId = 5;
        String ticketType = "왕복";

        // when & then
        String content = mockMvc.perform(
                get("/v1/api/air-reservation/tickets")
                        .param("user-Id", userId.toString())
                        .param("airline-ticket-type", ticketType)
                        .contentType(MediaType.APPLICATION_JSON))
                .andExpect(status().isOk())
                .andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8);

        log.info("결과: " + content);
    }

    @DisplayName("Find Airline Tickets 실패 1")
    @Test
    void FindAirlineTicketsCase2() throws Exception {
        // given
        Integer userId = 5;
        String ticketType = "왕";

        // when & then
        String content = mockMvc.perform(
                                        get("/v1/api/air-reservation/tickets")
                                                .param("user-Id", userId.toString())
                                                .param("airline-ticket-type", ticketType)
                                                .contentType(MediaType.APPLICATION_JSON))
                                .andExpect(status().is4xxClientError())
                                .andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8);

        log.info("결과: " + content);
    }
}

실제 서버 업무


  • 비즈니스 로직 구현
  • 로그인 이슈, 보안 이슈 → 쿠키 & 섹션 & 토큰, Spring Security
  • 성능 및 속도 이슈, 트래픽 쏠림 이슈 → 서블릿 컨테이너, Web layer, HTTp 캐싱과 SpringBoot 캐싱

서블릿 컨테이너


  • WAS의 HTTP 요청시 → Servlet Requet/Response 생성
    • HttpServletRequest
      • 요청 정보를 서블릿에게 전달하기 위한 객체
      • header, URL, method 등을 확인하는 메서드 존재
      • Body Stream을 읽는 메서드 존재
    • HttpServletResponse
      • 요청을 보낸 클라이언트의 응답을 보내는 객체
      • 응답 정보 전송
    • HTTP Request/Response ↔ HttpServletRequest/HttpServletResponse ↔ Spring Container
  • Filter와 Interceptor가 추가된 요청 과정
    • 클라이언트 요청 → Filter Chain → Servlet Container → HTTP Servlet → Filters → Spring Container → Dispatcher Servlet → Interceptor → AOP(Advice)  → @RestController, @Controller

Filter

  • = Web Filter
  • 특징
    • Spring Container 밖에 있고, Spring 자원 X
    • doFilter() 메서드로 request/response 동작
    • HttPServlet의 Request와 Response 객체에 관여
  • 기능
    • 이미지 및 데이터 압축 및 문자열 인코딩
    • 모든 요청에 대한 로깅
    • 공통 보안 및 인증/인가
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

@Component
@Slf4j
public class LoggingFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {

        String method = request.getMethod();
        String uri = request.getRequestURI();

        log.info(method + uri + " 요청이 들어왔습니다.");

        filterChain.doFilter(request, response);

        log.info(method + uri + "가 상태 " + response.getStatus() + " 로 응답이 나갑니다." );
    }
}

Interceptor

  • 특징
    • Spring Container 자원
    • preHandler(), postHandler()로 요청과 응답 분리
    • HttpServlet의 Request와 Response 객체에 관여 X → 주로 요청 전후에 특정 작업을 수행하기 위해 사용되지만, 모든 시점에서 이 객체들에 대해 원하는 모든 작업을 할 수 있는 것 X
  • 주사용 기능
    • API 호출 시간 로깅
    • Session과 Cookie 확인
    • 세부적인 보안 및 인증
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

@Component
@Slf4j
public class RequestTimeLoggingInterceptor implements HandlerInterceptor {

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

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        long startTime = (Long) request.getAttribute("requestStartTime");
        long endTime = System.currentTimeMillis();
        long executeTIme = endTime - startTime;

        log.info("{} {} executed in {} ms", request.getMethod(), request.getRequestURI(), executeTIme);
    }
}
import com.github.supercodingspring.web.Interceptors.RequestTimeLoggingInterceptor;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
@RequiredArgsConstructor
public class WebConfig implements WebMvcConfigurer {

    private final RequestTimeLoggingInterceptor requestTimeLoggingInterceptor;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(requestTimeLoggingInterceptor);
    }
}