Window 함수는 쿼리에서 OVER() 절과 함께 사용된다. 이 OVER() 절은 함수가 어떻게 적용될지에 대한 범위(윈도우)를 정의한다. 윈도우는 PARTITION BY, ORDER BY 등을 사용하여 특정 그룹이나 순서대로 계산할 수 있게 한다.
2. 기본 구조
<window_function> OVER (PARTITION BY <column> ORDER BY <column>)
<window_function>: 집계 함수나 순위 함수 등.
PARTITION BY <column>: 데이터를 그룹으로 나누어 계산. 이 부분을 생략하면 전체 테이블을 하나의 그룹으로 간주한다.
ORDER BY <column>: 각 파티션 내에서 데이터를 정렬하여 계산.
3. 자주 사용되는 함수
ROW_NUMBER(): 각 행에 대해 순차적인 번호를 매긴다.
SELECT id, ROW_NUMBER() OVER (PARTITION BY id ORDER BY created_at) AS row_num
FROM orders;
PARTITION BY 를 써서 특정 컬럼으로 조건을 정하고 rank를 분리할 수도 있다.
RANK(): 순위 번호를 매기고, 동점인 경우 동일 순위를 부여한다.
SELECT id, amount, RANK() OVER (ORDER BY amount DESC) AS rank
FROM sales;
DENSE_RANK(): RANK()와 비슷하지만 동점인 경우에도 순위를 건너뛰지 않는다.
SELECT id, amount, DENSE_RANK() OVER (ORDER BY amount DESC) AS dense_rank
FROM sales;
NTILE(n): 데이터를 n개의 구간으로 나누어 각 행에 구간 번호를 부여 한다. .
SELECT id, amount, NTILE(4) OVER (ORDER BY amount) AS quartile
FROM sales;
SUM(), AVG(), MIN(), MAX() 등 집계 함수: 특정 윈도우 내에서 값을 집계한다.
SELECT id, amount, SUM(amount) OVER (PARTITION BY region ORDER BY date) AS running_total
FROM sales;
4. 예시
판매 데이터에서 지역별로 매출의 누적 합계를 계산하는 예시이다.
SELECT region, amount,
SUM(amount) OVER (PARTITION BY region ORDER BY sale_date) AS running_total
FROM sales;
PARTITION BY region: 지역별로 데이터를 그룹화한다.
ORDER BY sale_date: 각 지역 내에서 날짜별로 정렬하여 누적 합계를 계산한다.
5. ROWS BETWEEN 절
ROWS BETWEEN을 사용하면 윈도우 범위를 더 세밀하게 지정할 수 있다. 예를 들어, 현재 행을 기준으로 이전 3개의 행까지의 데이터를 윈도우로 설정할 수 있다.
SELECT id, amount,
SUM(amount) OVER (ORDER BY sale_date ROWS BETWEEN 3 PRECEDING AND CURRENT ROW) AS moving_avg
FROM sales;
ROWS BETWEEN 3 PRECEDING AND CURRENT ROW: 현재 행과 이전 3개의 행을 포함하는 윈도우 범위를 설정한다.
6. 주의사항
Window 함수는 집계 함수와는 다르게 GROUP BY와 함께 사용할 수 없다. 대신 PARTITION BY를 사용하여 그룹화한다.
쿼리 성능에 영향을 미칠 수 있으므로 적절한 인덱스와 함께 사용하는 것이 좋다.
7. JPQL 적용
Window 함수의 JPQL 적용 시 단점은 nativeQuery로만 동작한다는 것이다.
예를들어 아래와 같이 함수를 구성할 수 있다.
@Query(value = """
SELECT sub.id, sub.name, sub.height
FROM (
SELECT m.id, m.name, m.height
ROW_NUMBER() OVER (PARTITION BY m.id ORDER BY m.created_at DESC) AS row_num
FROM member m
WHERE m.created_at BETWEEN :start AND :end
) sub
WHERE sub.row_num = 1
""", nativeQuery = true)
List<Object[]> getMemberData(
@Param("start") LocalDateTime start,
@Param("end") LocalDateTime end
);
nativeQuery=true 옵션을 주어야 하며, 반환 값은 Object[]로 사용하여야 한다.
@Data
@AllArgsConstructor
@NoArgsConstructor
public class ReadDto {
public static final int MODEL_LENGTH = 35;
private Integer dcv;
private Integer dca;
private Integer dcw;
}
여기서 만약, 필드 별 필요한 데이터의 타입 등이 있을 경우 아래와 같이 어노테이션을 만들어서 Dto 필드에 넣어준다.
@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
public @interface ModbusValue {
int offset();
ModbusValueType type() default UINT_16;
}
바뀐 Dto
@Data
@AllArgsConstructor
@NoArgsConstructor
public class ReadDto {
public static final int MODEL_LENGTH = 35;
@ModbusValue(offset = 0, type = INT_16)
private Integer dcv;
@ModbusValue(offset = 1, type = INT_16)
private Integer dca;
@ModbusValue(offset = 2, type = INT_16)
private Integer dcw;
}
특정한 Dto가 아닌, 제네릭으로 받아서 Dto마다의 결과값을 반환하기 위해 제네릭 메소드로 생성한다.
public <T> T getDataObject(T object) {
try {
// Dto의 static 필드값이 필요한 경우
int quantity = object.getClass().getField(MODEL_LENGTH)
.getInt(object);
Class<?> modbusEntity = object.getClass();
Field[] columns = modbusEntity.getDeclaredFields();
// 필드 별 로직 구성
Arrays.stream(columns)
.parallel()
.forEach(column -> {
try {
ModbusValue annotation = column.getAnnotation(ModbusValue.class);
if (annotation == null) {
return;
}
int offset = annotation.offset();
var type = annotation.type();
Object value = getModbusValue(type, offset);
column.setAccessible(true);
column.set(object, castType(value, column.getType().getSimpleName()));
} catch (IllegalAccessException e) {
throw new RuntimeException(e);
}
});
return object; // 최종 DTO 객체 반환
} catch (Exception e) {
log.error("[ getDtoWithModbusRawData Error ] message: {}", e.getMessage(), e);
throw new RuntimeException(e);
}
}
이렇게 하면, 원하는 Dto의 필드들에 값을 주입하고 다시 Dto를 반환하게 할 수 있다.
emailCertNumberMap: 이메일 검증 시에 쓰이며, Dto에는 code와 createdAt이 있다. 검증 로직에서 아래 상수로 시간이 지나면 검증이 실패하게끔 만들어준다. 이 포스트에서는 인메모리로 다루어봤다.
VERIFY_TIMEOUT: 서버에서의 이메일 검증 만료 시간
@Service
@RequiredArgsConstructor
@Transactional
@Slf4j
public class UserService {
public static final int VERIFY_TIMEOUT = 35;
private final UserRepository repository;
private final EmailService emailService;
private final UserGroupRepository userGroupRepository;
private final PasswordEncoder passwordEncoder;
@Getter
public Map<String, MfaEmailVerifyMapDto> emailCertNumberMap = new HashMap<>();
...
public void registerMfaTypes(MfaRegisterDto mfaRegisterDto) {
User user = repository.findByUsernameAndDeletedAtIsNull(mfaRegisterDto.getId())
.orElseThrow();
List<MfaTypesDto> mfaTypes = user.getMfaTypes();
List<MfaType> mfaTypeList = mfaTypes
.stream()
.map(MfaTypesDto::getMfaType)
.toList();
if (mfaTypeList.contains(mfaRegisterDto.getMfaType())) {
throw new IllegalArgumentException("이미 등록된 인증 입니다.");
}
mfaTypes.add(new MfaTypesDto(mfaRegisterDto.getMfaType(), LocalDateTime.now()));
user.updateIsUsingMfa(true);
user.updateMfaTypes(mfaTypes);
}
public void deleteMfaTypes(MfaDeleteDto deleteDto) {
User user = repository.findByUsernameAndDeletedAtIsNull(deleteDto.getId())
.orElseThrow();
List<MfaTypesDto> mfaTypes = user.getMfaTypes();
List<MfaType> mfaTypeList = mfaTypes
.stream()
.map(MfaTypesDto::getMfaType)
.toList();
if (!mfaTypeList.contains(deleteDto.getMfaType())) {
throw new IllegalArgumentException("등록되지 않은 인증 입니다.");
}
mfaTypes.removeIf(dto -> dto.getMfaType() == deleteDto.getMfaType());
if (mfaTypes.size() == 0) {
user.updateIsUsingMfa(false);
}
user.updateMfaTypes(mfaTypes);
}
public String registerAuthenticator(MfaOtpRegisterDto dto) {
return repository
.findByUsernameAndDeletedAtIsNull(dto.getId())
.orElseThrow()
.getSecret();
}
public void verifyAuthenticatorCode(MfaOtpVerifyDto dto) {
String name = dto.getEmail();
User user = repository.findByUsernameAndDeletedAtIsNull(name).orElseThrow();
Totp totp = new Totp(user.getSecret());
if (!isValidLong(dto.getCode()) || !totp.verify(dto.getCode())) {
throw new AuthorizationException("Code Invalid");
}
}
private boolean isValidLong(String code) {
try {
Long.parseLong(code);
} catch (NumberFormatException e) {
return false;
}
return true;
}
public void sendAuthenticationCodeByEmail(String userId) {
repository.findByUsernameAndDeletedAtIsNull(userId).orElseThrow();
Random num = new Random();
StringBuilder randomNumber = new StringBuilder();
for (int i = 0; i < 6; i++) {
randomNumber.append(num.nextInt(10));
}
EmailSendDto emailSendDto = new EmailSendDto(
AUTH,
"이메일 서버",
포트,
"이메일 주소",
userId,
"",
"",
true,
TLS_V_1_2,
"인증번호",
"인증번호는 " + randomNumber + " 입니다.",
null
);
emailService.sendMail(emailSendDto);
this.emailCertNumberMap.put(
userId,
new MfaEmailVerifyMapDto(
randomNumber.toString(),
LocalDateTime.now()
)
);
}
public void certEmail(MfaEmailVerifyRequestDto mfaEmailVerifyRequestDto) {
String name = mfaEmailVerifyRequestDto.getEmail();
MfaEmailVerifyMapDto mapDto = this.emailCertNumberMap.get(name);
Duration between = Duration.between(LocalDateTime.now(), mapDto.getCreatedAt());
if (Math.abs(between.getSeconds()) > VERIFY_TIMEOUT) {
throw new AuthorizationException("Code Expired");
}
if (!Objects.equals(mapDto.getCode(), mfaEmailVerifyRequestDto.getCode())) {
throw new AuthorizationException("Code Invalid");
}
}
}
11. AuthService
@Service
@RequiredArgsConstructor
@Transactional
@Slf4j
public class AuthService {
private final UserRepository userRepository;
private final AuthenticationManager authenticationManager;
private final PasswordEncoder passwordEncoder;
@Getter
public Map<String, MfaEmailVerifyMapDto> emailCertNumberMap = new HashMap<>();
public ResponseLoginDto login(RequestLoginDto loginDto) {
User user = userRepository.findByUsernameAndDeletedAtIsNull(loginDto.getId()).orElseThrow();
if (!passwordEncoder.matches(loginDto.getPassword(), user.getPassword())) {
log.error("[Password does not match] Username: {}", user.getUsername());
throw new TokenProblemException("Password does not match");
}
if (user.getIsBlocked()) {
log.error("[User login is blocked] Username: {}", user.getUsername());
throw new AccessDeniedException("Blocked user.");
}
Authentication authentication = this.authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(loginDto.getId(), loginDto.getPassword()));
SecurityContextHolder.getContext().setAuthentication(authentication);
UserDetailsImpl userDetails = (UserDetailsImpl) authentication.getPrincipal();
List<String> roles = userDetails.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.toList();
List<String> groupNames = new ArrayList<>();
for (UserGroup group : user.getGroups()) {
groupNames.add(group.getName());
}
return new ResponseLoginDto(
user.getIsUsingMfa(),
user.getMfaTypes(),
user.getId(),
user.getUsername(),
user.getFirstName(),
user.getLastName(),
groupNames,
roles,
null
);
}
@Retry(value = 2)
public ResponseLoginDto refreshToken(String username) {
User user = userRepository.findByUsernameAndDeletedAtIsNull(username).orElseThrow();
Set<String> roles = new HashSet<>();
List<String> groupNames = new ArrayList<>();
for (UserGroup group : user.getGroups()) {
roles.addAll(List.of(group.getRolesList()));
groupNames.add(group.getName());
}
return new ResponseLoginDto(
user.getIsUsingMfa(),
user.getMfaTypes(),
user.getId(),
user.getUsername(),
user.getFirstName(),
user.getLastName(),
groupNames,
roles.stream().toList(),
null);
}
public long getExpiredTime(Cookie jwtCookie) {
return Timestamp.valueOf(LocalDateTime.now().plusSeconds(jwtCookie.getMaxAge()))
.getTime();
}
}
최종적으로 API를 만들기 위한 단계이다. API는 로그인 시 필요한 AuthController, 사용자 별 복합 인증 관리에 필요한 UserController 두 곳에서 사용할 것이다.
12. AuthController
@Slf4j
@RequiredArgsConstructor
@RestController
@RequestMapping("/auth")
public class AuthController {
private static final int CLIENT_VERIFY_TIMEOUT = 30;
private final AuthService service;
private final UserService userService;
private final JwtUtil jwtUtil;
@PostMapping("/login")
@EventLogging(code = SecurityEventCode.USER_LOGIN)
public CommonResponse<ResponseLoginDto> login(
HttpServletResponse response,
HttpSession session,
@Valid @RequestBody RequestLoginDto loginDto
) {
ResponseLoginDto responseLoginDto = service.login(loginDto);
if (!responseLoginDto.isUsingMfa()) {
long exp = addJwtCookie(response, loginDto.getId());
responseLoginDto.setExp(exp);
return new CommonResponse<>(responseLoginDto);
}
return new CommonResponse<>(responseLoginDto);
}
@PostMapping("/mfa/authenticator/verify")
public CommonResponse<Long> verifyAuthenticatorCode(
HttpServletResponse response,
HttpSession session,
@Valid @RequestBody MfaOtpVerifyDto dto
) {
verifySessionMfaType(session, OTP);
userService.verifyAuthenticatorCode(dto);
long exp = addJwtCookie(
response,
dto.getEmail()
);
return new CommonResponse<>(exp);
}
@PostMapping("/mfa/email/send")
public CommonResponse<Integer> sendAuthenticationCodeByEmail(
@RequestBody MfaEmailSendRequestDto mfaEmailSendRequestDto
) {
userService.sendAuthenticationCodeByEmail(mfaEmailSendRequestDto.getEmail());
return new CommonResponse<>(CLIENT_VERIFY_TIMEOUT);
}
@PostMapping("/mfa/email/verify")
public CommonResponse<Long> certEmail(
HttpServletResponse response,
HttpSession session,
@RequestBody MfaEmailVerifyRequestDto dto
) {
verifySessionMfaType(session, EMAIL);
userService.certEmail(dto);
long exp = addJwtCookie(
response,
dto.getEmail()
);
return new CommonResponse<>(exp);
}
@PostMapping("/logout")
@EventLogging(code = SecurityEventCode.USER_LOGOUT)
public CommonResponse<Boolean> logout(
HttpServletResponse response,
HttpSession session
) {
ResponseCookie jwtCookie = jwtUtil.getCleanJwtCookie();
response.addHeader(HttpHeaders.SET_COOKIE, jwtCookie.toString());
return new CommonResponse<>(true);
}
@PostMapping("/refresh")
public CommonResponse<ResponseLoginDto> refresh(
HttpServletResponse response,
HttpServletRequest request
) {
String token = this.jwtUtil.getJwtFromCookies(request);
if ((token == null) || (token.length() < 1)) {
log.error("[Invalid token] token: {}", token);
throw new TokenProblemException("Invalid token");
}
if (!jwtUtil.validateJwtToken(token)) {
log.error("[The token has expired] token: {}", token);
throw new TokenProblemException("The token has expired.");
}
String username = jwtUtil.getUsernameFromJwtToken(token);
Cookie jwtCookie = jwtUtil.generateJwtCookie(username);
response.addCookie(jwtCookie);
var exp = service.getExpiredTime(jwtCookie);
ResponseLoginDto responseLoginDto = service.refreshToken(username);
responseLoginDto.setExp(exp);
return new CommonResponse<>(responseLoginDto);
}
private void verifySessionMfaType(HttpSession session, MfaType mfaType) {
MfaType isUsingMfa = sessionUtil.getIsUsingMfa(session);
if (isUsingMfa != null && isUsingMfa != mfaType) {
throw new IllegalArgumentException("이미 다른 복합 인증이 사용중입니다.");
}
sessionUtil.setIsUsingMfa(session, mfaType);
}
private long addJwtCookie(HttpServletResponse response, String name) {
Cookie jwtCookie = jwtUtil.generateJwtCookie(name);
response.addCookie(jwtCookie);
return service.getExpiredTime(jwtCookie);
}
}
13. UserController
@Slf4j
@RequiredArgsConstructor
@RestController
@RequestMapping("/user")
@PreAuthorize("hasRole('ROLE_USER_READ') || hasRole('ROLE_USER_ADMIN')")
public class UserController {
private final UserService service;
@GetMapping
public CommonResponse<List<ResponseUserDto>> findAll() {
return new CommonResponse<>(service.findAll());
}
@GetMapping("/{id}")
public CommonResponse<ResponseUserDto> findByIndexId(
@PathVariable(value = "id") long userId) {
return new CommonResponse<>(service.findByIndexId(userId));
}
@GetMapping("/name/{username}")
public CommonResponse<Boolean> findByUsername(
@PathVariable(value = "username") String username) {
return new CommonResponse<>(service.isUserEmptyByUsername(username));
}
@PreAuthorize("hasRole('ROLE_USER_ADMIN')")
@PostMapping
@EventLogging(code = SecurityEventCode.USER_USER_CREATE)
public CommonResponse<Long> create(
@RequestBody @Valid UserCreateDto createDto) {
Long id = service.create(createDto);
createDto.setPassword("");
return new CommonResponse<>(id);
}
@PreAuthorize("hasRole('ROLE_USER_ADMIN')")
@PutMapping
@EventLogging(code = SecurityEventCode.USER_USER_UPDATE)
public CommonResponse<Boolean> updateUser(
@RequestBody @Valid UserUpdateDto updateDto) {
service.updateUser(updateDto);
return new CommonResponse<>(true);
}
@PreAuthorize("hasRole('ROLE_USER_ADMIN')")
@PutMapping("/password")
@EventLogging(code = SecurityEventCode.USER_USER_PASSWORD_CHANGE)
public CommonResponse<Boolean> updatePasswordByUser(
@RequestBody @Valid UserUpdatePasswordDto updateDto) {
service.updatePasswordByUser(updateDto);
return new CommonResponse<>(true);
}
@PreAuthorize("hasRole('ROLE_USER_ADMIN')")
@PutMapping("/me")
@EventLogging(code = SecurityEventCode.USER_USER_UPDATE_OWN_USER)
public CommonResponse<Boolean> updateOwnUser(
@RequestBody @Valid OwnUserUpdateDto updateDto) {
service.updateOwnUser(updateDto);
return new CommonResponse<>(true);
}
@PostMapping("/mfa/register")
public CommonResponse<Boolean> registerMfaTypes(
@Valid @RequestBody MfaRegisterDto registerDto
) {
service.registerMfaTypes(registerDto);
return new CommonResponse<>(true);
}
@PostMapping("/mfa/authenticator/verify")
public CommonResponse<Boolean> verifyAuthenticatorCode(
@Valid @RequestBody MfaOtpVerifyDto dto
) {
service.verifyAuthenticatorCode(dto);
return new CommonResponse<>(true);
}
@PostMapping("/mfa/delete")
public CommonResponse<Boolean> deleteMfaTypes(
@Valid @RequestBody MfaDeleteDto deleteDto
) {
service.deleteMfaTypes(deleteDto);
return new CommonResponse<>(true);
}
@PostMapping("/mfa/authenticator/register")
public CommonResponse<String> registerAuthenticator(
@Valid @RequestBody MfaOtpRegisterDto otpRegisterDto
) {
return new CommonResponse<>(service.registerAuthenticator(otpRegisterDto));
}
@PostMapping("/mfa/email/send")
public CommonResponse<Boolean> sendAuthenticationCodeByEmail(
@Valid @RequestBody MfaEmailSendRequestDto mfaEmailSendRequestDto
) {
service.sendAuthenticationCodeByEmail(mfaEmailSendRequestDto.getEmail());
return new CommonResponse<>(true);
}
@PostMapping("/mfa/email/verify")
public CommonResponse<Boolean> certEmail(
@Valid @RequestBody MfaEmailVerifyRequestDto dto
) {
service.certEmail(dto);
return new CommonResponse<>(true);
}
@PreAuthorize("hasRole('ROLE_USER_ADMIN')")
@PutMapping("/admin")
@EventLogging(code = SecurityEventCode.USER_USER_PASSWORD_CHANGE_BY_ADMIN)
public CommonResponse<Boolean> updatePasswordByAdmin(
@RequestBody @Valid UserUpdatePasswordDto updateDto) {
service.updatePasswordByAdmin(updateDto);
return new CommonResponse<>(true);
}
@PreAuthorize("hasRole('ROLE_USER_ADMIN')")
@DeleteMapping
@EventLogging(code = SecurityEventCode.USER_USER_DELETE)
public CommonResponse<Boolean> deleteUsers(
@Valid @RequestBody CommonIdListDto listDto) {
service.deleteUsers(listDto);
return new CommonResponse<>(true);
}
}
@ToString
@Getter
@NoArgsConstructor
@Builder
public class ApiResponseDto<T> {
private T data;
private ApiResponseDto(T data){
this.data=data;
}
public static <T> ApiResponseDto<T> of(T data) {
return new ApiResponseDto<>(data);
}
}
utils/consts/EnumDocs
@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class EnumDocs {
// 문서화하고 싶은 모든 enum값을 명시
Map<String,String> Sex;
Map<String,String> memberStatus;
}
utils/consts/CommonDocController
@ToString
@Getter
@NoArgsConstructor
@Builder
public class ApiResponseDto<T> {
private T data;
private ApiResponseDto(T data){
this.data=data;
}
public static <T> ApiResponseDto<T> of(T data) {
return new ApiResponseDto<>(data);
}
}
utils/consts/CommonDocControllerTest
// restdocs의 get 이 아님을 주의!!
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
class CommonDocControllerTest extends RestDocsTestSupport {
@Test
public void enums() throws Exception {
// 요청
ResultActions result = this.mockMvc.perform(
get("/test/enums")
.contentType(MediaType.APPLICATION_JSON)
);
// 결과값
MvcResult mvcResult = result.andReturn();
// 데이터 파싱
EnumDocs enumDocs = getData(mvcResult);
// 문서화 진행
result.andExpect(status().isOk())
.andDo(restDocs.document(
customResponseFields("custom-response", beneathPath("data.memberStatus").withSubsectionId("memberStatus"), // (1)
attributes(key("title").value("memberStatus")),
enumConvertFieldDescriptor((enumDocs.getMemberStatus()))
),
customResponseFields("custom-response", beneathPath("data.sex").withSubsectionId("sex"),
attributes(key("title").value("sex")),
enumConvertFieldDescriptor((enumDocs.getSex()))
)
));
}
// 커스텀 템플릿 사용을 위한 함수
public static CustomResponseFieldsSnippet customResponseFields
(String type,
PayloadSubsectionExtractor<?> subsectionExtractor,
Map<String, Object> attributes, FieldDescriptor... descriptors) {
return new CustomResponseFieldsSnippet(type, subsectionExtractor, Arrays.asList(descriptors), attributes
, true);
}
// Map으로 넘어온 enumValue를 fieldWithPath로 변경하여 리턴
private static FieldDescriptor[] enumConvertFieldDescriptor(Map<String, String> enumValues) {
return enumValues.entrySet().stream()
.map(x -> fieldWithPath(x.getKey()).description(x.getValue()))
.toArray(FieldDescriptor[]::new);
}
// mvc result 데이터 파싱
private EnumDocs getData(MvcResult result) throws IOException {
ApiResponseDto<EnumDocs> apiResponseDto = objectMapper
.readValue(result.getResponse().getContentAsByteArray(),
new TypeReference<ApiResponseDto<EnumDocs>>() {}
);
return apiResponseDto.getData();
}
}
@Repository
public interface ApiRouteRepository extends R2dbcRepository<ApiRoute, String> {
}
7. ApiRouteService 생성
Service Interface는 아래의 Override 한 메소드만 추가해주면 된다.
@Service
@RequiredArgsConstructor
public class ApiRouteServiceImpl implements ApiRouteService {
private final ApiRouteRepository apiRouteRepository;
@Override
public Flux<ApiRoute> getAll() {
return this.apiRouteRepository.findAll();
}
public Mono<ApiRoute> create(ApiRoute apiRoute) {
return this.apiRouteRepository.save(apiRoute);
}
public Mono<ApiRoute> getById(String id) {
return this.apiRouteRepository.findById(id);
}
}
8. ApiPathRouteLocatorImpl 생성
@AllArgsConstructor
public class ApiPathRouteLocatorImpl implements RouteLocator {
private final ApiRouteService apiRouteService;
private final RouteLocatorBuilder routeLocatorBuilder;
@Override
public Flux<Route> getRoutes() {
RouteLocatorBuilder.Builder routesBuilder = routeLocatorBuilder.routes();
return apiRouteService.getAll()
.map(apiRoute -> routesBuilder.route(String.valueOf(apiRoute.getRouteIdentifier()),
predicateSpec -> setPredicateSpec(apiRoute, predicateSpec)))
.collectList()
.flatMapMany(builders -> routesBuilder.build()
.getRoutes());
}
private Buildable<Route> setPredicateSpec(ApiRoute apiRoute, PredicateSpec predicateSpec) {
BooleanSpec booleanSpec = predicateSpec.path(apiRoute.getPath());
if (!StringUtils.isEmpty(apiRoute.getMethod())) {
booleanSpec.and()
.method(apiRoute.getMethod());
}
return booleanSpec.uri(apiRoute.getUri());
}
@Override
public Flux<Route> getRoutesByMetadata(Map<String, Object> metadata) {
return RouteLocator.super.getRoutesByMetadata(metadata);
}
}
9. GatewayConfig 생성
@Configuration
@Slf4j
public class GatewayConfig {
@Bean
public RouteLocator routeLocator(ApiRouteService routeService,
RouteLocatorBuilder routeLocationBuilder) {
return new ApiPathRouteLocatorImpl(routeService, routeLocationBuilder);
}
}
여기까지가 기본적인 프록시를 위한 라우터이다.
다음 부터는 API로 route를 CRUD 하기 위한 작업이다.
10. ApiRouteRouter Configuration 생성
@Configuration
public class ApiRouteRouter {
@Bean
public RouterFunction<ServerResponse> route(ApiRouteHandler apiRouteHandler) {
return RouterFunctions.route(POST("/routes")
.and(accept(MediaType.APPLICATION_JSON)), apiRouteHandler::create)
.andRoute(GET("/routes/{routeId}")
.and(accept(MediaType.APPLICATION_JSON)), apiRouteHandler::getById)
.andRoute(GET("/routes/refresh-routes")
.and(accept(MediaType.APPLICATION_JSON)), apiRouteHandler::refreshRoutes);
}
}
11. ApiROuteHandler 생성
@RequiredArgsConstructor
@Component
@Slf4j
public class ApiRouteHandler {
private final ApiRouteService routeService;
private final GatewayRoutesRefresher gatewayRoutesRefresher;
public Mono<ServerResponse> create(ServerRequest serverRequest) {
Mono<ApiRoute> apiRoute = serverRequest.bodyToMono(ApiRoute.class);
return apiRoute.flatMap(route ->
ServerResponse.status(HttpStatus.OK)
.contentType(MediaType.APPLICATION_JSON)
.body(routeService.create(route), ApiRoute.class));
}
public Mono<ServerResponse> getById(ServerRequest serverRequest) {
log.info("serverRequest.pathVariable(\"routeId\") = {}",
serverRequest.pathVariable("routeId"));
final String apiId = serverRequest.pathVariable("routeId");
Mono<ApiRoute> apiRoute = routeService.getById(apiId);
return apiRoute.flatMap(route -> ServerResponse.ok()
.body(fromValue(route)))
.switchIfEmpty(ServerResponse.notFound()
.build());
}
public Mono<ServerResponse> refreshRoutes(ServerRequest serverRequest) {
gatewayRoutesRefresher.refreshRoutes();
return ServerResponse.ok().body(BodyInserters.fromObject("Routes reloaded successfully"));
}
}
12. GatewayRoutesRefresher 생성
@Component
public class GatewayRoutesRefresher implements ApplicationEventPublisherAware {
private ApplicationEventPublisher applicationEventPublisher;
@Override
public void setApplicationEventPublisher(ApplicationEventPublisher applicationEventPublisher) {
this.applicationEventPublisher = applicationEventPublisher;
}
/**
* Refresh the routes to load from data store
*/
public void refreshRoutes() {
applicationEventPublisher.publishEvent(new RefreshRoutesEvent(this));
}
}
만약, domain Hostname에 따라 Proxy되는 서버의 주소를 변경하고자 하면 다음과 같은 방법을 쓸 수 있다.
@Override
public GatewayFilter apply(HostNameFilterDto hostNameFilterdto) {
return (exchange, chain) -> {
ServerHttpRequest request = exchange.getRequest();
request.getHeaders();
String[] split = request.getURI().getHost().split("\\.");
String siteId = split[0];
Mono<Site> siteMono = siteService.findById(siteId);
Site site = siteMono.share().block();
String uri = Objects.requireNonNull(site).getConnectHost();
int port = site.getConnectPort();
if (port != 80) {
uri += ":" + port;
}
Route route = exchange.getAttribute(GATEWAY_ROUTE_ATTR);
Route newRoute = Route.async()
.id(site.getId())
.uri(uri)
.predicate(serverWebExchange -> false)
.order(Objects.requireNonNull(route).getOrder())
.filters(route.getFilters())
.build();
exchange.getAttributes().put(GATEWAY_ROUTE_ATTR, newRoute);
return chain.filter(exchange);
};
}