728x90
반응형
SMALL

이제 새로운 마이크로 서비스인 CatalogService를 만들자. 이 서비스는 상품에 대한 데이터를 관리하는 서비스이다.

상품명, 상품아이디, 수량, 단일가격 등에 대한 정보를 가지고 있는 엔티티를 가지고 있을 것이고 그 엔티티에 대한 관리가 일어나는 서비스이다.

SMALL

 

우선, 스프링 프로젝트를 만들어야 하는데 Spring Initializer를 사용하는 방법은 기존 포스팅에 작성해 두었으니 dependencies부터 시작하자.

 

build.gradle

plugins {
    id 'java'
    id 'org.springframework.boot' version '3.2.1'
    id 'io.spring.dependency-management' version '1.1.4'
}

group = 'springmsa'
version = '0.0.1-SNAPSHOT'

java {
    sourceCompatibility = '21'
}

configurations {
    compileOnly {
        extendsFrom annotationProcessor
    }
}

repositories {
    mavenCentral()
}

ext {
    set('springCloudVersion', "2023.0.0")
}

dependencies {
    implementation 'org.modelmapper:modelmapper:3.1.1'

    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.cloud:spring-cloud-starter-netflix-eureka-client'
    compileOnly 'org.projectlombok:lombok'
    developmentOnly 'org.springframework.boot:spring-boot-devtools'
    runtimeOnly 'com.h2database:h2'
    annotationProcessor 'org.projectlombok:lombok'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
}

dependencyManagement {
    imports {
        mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}"
    }
}

tasks.named('test') {
    useJUnitPlatform()
}

 

필요한 dependencies는 다음과 같다.

  • Lombok
  • Spring Data JPA
  • Spring Web
  • Eureka Client
  • Spring Boot Devtools
  • H2
  • ModelMapper

application.yml

server:
  port: 0

spring:
  application:
    name: catalog-service
  jpa:
    hibernate:
      ddl-auto: create-drop
    properties:
      hibernate:
        format_sql: true
        show_sql: true
        default_batch_fetch_size: 500
    defer-datasource-initialization: true
  sql:
    init:
      mode: always

  datasource:
    url: jdbc:h2:tcp://localhost/~/h2/msacatalog
    driver-class-name: org.h2.Driver
    username: sa
    password:

eureka:
  client:
    register-with-eureka: true
    fetch-registry: true # Eureka Server로부터 Eureka Server에 등록된 다른 인스턴스의 정보를 주기적으로 갱신하는 옵션
    service-url:
      defaultZone: http://localhost:8761/eureka
  instance:
    instance-id: ${spring.cloud.client.hostname}:${spring.application.instance_id:${random.value}}

 

여기서는 서버를 띄울 때 데이터베이스에 초기데이터를 만들어주기 위해 spring.jpa.defer-datasource-initialization: true 속성과 spring.sql.init.mode: always 속성을 추가했다. 이는 resources 폴더에 data.sql 파일이 있을 때 해당 파일을 보고 초기데이터를 넣어주기 위함이다.

 

data.sql (resources)

insert into catalog(product_id, product_name, stock, unit_price) values ('CATALOG-001', 'Berlin', 100, 1500);
insert into catalog(product_id, product_name, stock, unit_price) values ('CATALOG-002', 'Tokyo', 200, 1000);
insert into catalog(product_id, product_name, stock, unit_price) values ('CATALOG-003', 'Stockholm', 300, 2500);

 

 

 

Entity

BaseEntity

package springmsa.springmsacatalogservice.entity;

import jakarta.persistence.Column;
import jakarta.persistence.EntityListeners;
import jakarta.persistence.MappedSuperclass;
import lombok.Getter;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;

import java.time.LocalDateTime;

@EntityListeners(AuditingEntityListener.class)
@MappedSuperclass
@Getter
public class BaseEntity {

    @CreatedDate
    @Column(updatable = false)
    private LocalDateTime createdDate;

    @LastModifiedDate
    private LocalDateTime lastModifiedDate;
}

 

Catalog

package springmsa.springmsacatalogservice.entity;

import jakarta.persistence.*;
import lombok.Data;


@Data
@Entity
public class Catalog extends BaseEntity {

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

    @Column(nullable = false, length = 120, unique = true)
    private String productId;

    @Column(nullable = false)
    private String productName;

    @Column(nullable = false)
    private Integer stock;

    @Column(nullable = false)
    private Integer unitPrice;
}

 

Repository

 

CatalogRepository

package springmsa.springmsacatalogservice.repository;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import springmsa.springmsacatalogservice.entity.Catalog;

@Repository
public interface CatalogRepository extends JpaRepository<Catalog, Long> {
    Catalog findByProductId(String productId);
}

 

 

Service

CatalogService

package springmsa.springmsacatalogservice.service;

import springmsa.springmsacatalogservice.dto.CreateCatalogDto;
import springmsa.springmsacatalogservice.entity.Catalog;

public interface CatalogService {
    Iterable<Catalog> getAllCatalogs();

    Catalog createCatalog(CreateCatalogDto catalogDto);
}

 

인터페이스는 두 개의 메서드가 있다. 전체 조회와 생성.

 

CatalogServiceImpl

package springmsa.springmsacatalogservice.service;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.modelmapper.ModelMapper;
import org.springframework.stereotype.Service;
import springmsa.springmsacatalogservice.dto.CreateCatalogDto;
import springmsa.springmsacatalogservice.entity.Catalog;
import springmsa.springmsacatalogservice.repository.CatalogRepository;

@Slf4j
@RequiredArgsConstructor
@Service
public class CatalogServiceImpl implements CatalogService {

    private final CatalogRepository catalogRepository;
    private final ModelMapper modelMapper;

    @Override
    public Iterable<Catalog> getAllCatalogs() {
        return catalogRepository.findAll();
    }

    @Override
    public Catalog createCatalog(CreateCatalogDto catalogDto) {
        Catalog catalog = modelMapper.map(catalogDto, Catalog.class);
        catalogRepository.save(catalog);

        return catalog;
    }
}

 

 

DTO

CatalogDto

package springmsa.springmsacatalogservice.dto;

import lombok.Data;

@Data
public class CatalogDto {
    private String productId;
    private Integer quantity;
    private Integer unitPrice;
    private Integer totalPrice;
    private String orderId;
    private String userId;
}

 

CreateCatalogDto

package springmsa.springmsacatalogservice.dto;

import lombok.Data;

@Data
public class CreateCatalogDto {
    private String productId;
    private String productName;
    private Integer stock;
    private Integer unitPrice;
}

 

ResponseCatalogDto

package springmsa.springmsacatalogservice.dto;

import lombok.Data;

import java.time.LocalDateTime;

@Data
public class ResponseCatalogDto {
    private String productId;
    private String productName;
    private Integer unitPrice;
    private Integer stock;
    private LocalDateTime createdDate;
}

 

ApiResponseDto

package springmsa.springmsacatalogservice.dto;

import lombok.AllArgsConstructor;
import lombok.Data;

@Data
@AllArgsConstructor
public class ApiResponseDto<T> {
    private T data;
    private String errorMessage;
}

 

 

Controller

CatalogController

package springmsa.springmsacatalogservice.controller;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.modelmapper.ModelMapper;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import springmsa.springmsacatalogservice.dto.ApiResponseDto;
import springmsa.springmsacatalogservice.dto.CreateCatalogDto;
import springmsa.springmsacatalogservice.dto.ResponseCatalogDto;
import springmsa.springmsacatalogservice.entity.Catalog;
import springmsa.springmsacatalogservice.service.CatalogService;

import java.util.ArrayList;
import java.util.List;

@Slf4j
@RestController
@RequestMapping("/catalogs")
@RequiredArgsConstructor
public class CatalogController {

    private final CatalogService catalogService;
    private final ModelMapper modelMapper;

    @GetMapping("")
    public ResponseEntity<ApiResponseDto<List<ResponseCatalogDto>>> getCatalogs() {
        Iterable<Catalog> allCatalogs = catalogService.getAllCatalogs();

        List<ResponseCatalogDto> result = new ArrayList<>();

        allCatalogs.forEach(catalog -> {
            ResponseCatalogDto catalogDto = modelMapper.map(catalog, ResponseCatalogDto.class);
            result.add(catalogDto);
        });

        return ResponseEntity.status(HttpStatus.OK).body(new ApiResponseDto<>(result, null));
    }

    @PostMapping("")
    public ResponseEntity<ApiResponseDto<ResponseCatalogDto>> createCatalog(@RequestBody CreateCatalogDto catalogDto) {
        Catalog catalog = catalogService.createCatalog(catalogDto);

        ResponseCatalogDto responseCatalogDto = modelMapper.map(catalog, ResponseCatalogDto.class);

        return ResponseEntity
                .status(HttpStatus.CREATED)
                .body(new ApiResponseDto<>(responseCatalogDto, null));
    }
}

 

 

 

마무리

User Service와 구조나, 코드 내용이 상이한게 거의 없기 때문에 코드만 써도 괜찮아 보인다. 전체적인 핵심은 이렇게 도메인 별 MicroService를 만들어서 서비스의 크기를 작게 나누고 API Gateway를 통해 서비스로 접근하는 방식을 고수하고 있다는 점이다. 이 서비스 역시 유레카 서버에 등록되고 API Gateway가 관리하는 서비스이다.

728x90
반응형
LIST
728x90
반응형
SMALL

유저 서비스에서 Security 관련 설정을 진행해보자. 사실 지금 딱히 Security 관련 설정할 내용이 있는건 아니다만 구현하고자 하는 서비스는 웹 관련 서비스가 아니기 때문에 설정해주면 좋을만한게 있다.

SMALL

 

WebSecurity

다른 설정 파일과 똑같이 @Configuration 애노테이션으로 클래스를 만들고 그 안에 @Bean 등록으로 설정 내용을 적용하면 된다. 


WebSecurity

package springmsa.springmsa_user_service.security;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.annotation.web.configurers.HeadersConfigurer;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
@EnableWebSecurity
public class WebSecurity {

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http.csrf(AbstractHttpConfigurer::disable);

        http.authorizeHttpRequests((requests) -> requests.requestMatchers("/users/**").permitAll());

        return http.build();
    }
}

 

추후에 설정 내용이 더 추가될 예정이다. 지금은 저 csrf를 disable()하는것에 초점을 두자. 우선 csrf는 무엇인지부터 확인해보자.

csrf

Cross Site Request Forgery의 약자로 사이트 간 위조 요청을 말한다. 이는 정상적인 유저가 의도치 않게 비정상적인 요청을 하는 것을 말하는데 특정 사이트에 정상 권한을 가지고 있는 유저에게 비정상적인 링크를 누군가가 보내고 그 링크를 아무런 의심없이 해당 유저가 클릭할 때 역시 이 비정상적인 요청을 할 수 있다. 그리고 해당 사이트는 이러한 요청에 대해 이 사용자가 악용된 사용자인지 일반 유저인지 구분할 수 없다. 

 

그래서 이를 방어하기 위해 csrf 토큰을 웹 사이트에서는 부여하여 이 토큰이 요청에 포함되어야만 요청을 받아들인다. 그럼 csrf를 왜 disable()했을까?

 

REST API만을 사용한다면 CSRF는 의미가 없다. Spring security 문서를 보면 non-browser-clients만을 위한 서비스라면 csrf를 disable해도 상관이 없다. REST API만을 이용하는 클라이언트는 요청 시 요청에 인증 정보(예: JWT)를 포함하여 요청하고 서버에서 인증 정보를 저장하지 않기 때문에 굳이 불필요한 csrf 코드들을 포함할 필요가 없는것이다. 

 

 

그니까 결론은, 브라우저를 이용하지 않고 모든 요청은 REST API로 들어온다면 CSRF 관련 코드를 빼주는 게 더 효율적인 서비스가 될 수 있다. 

 

 

authorizeHttpRequests()

두번째 라인은 특정 패턴의 요청이 들어왔을 때 요청을 허용할지에 대한 코드이다. 다음 코드를 보자.

http.authorizeHttpRequests((requests) -> requests.requestMatchers("/users/**").permitAll());

 

요청에 "/users/**" 패턴이 있으면 어떤 요청이든지 허가하겠다는 코드이다. 추후에 변경할 예정이지만 일단은 이렇게 해두자. 인증 관련 설정은 추후에 계속 할 예정이다.

728x90
반응형
LIST
728x90
반응형
SMALL

이번에는 UserService에서 조회 부분에 대한 API를 만들어보자.

SMALL

 

Service

기존에 만들었던 UserService 인터페이스에 다음 메서드들을 추가한다.

UserDto findUserById(Long id);

Iterable<Users> findAll();

 

UserService

package springmsa.springmsa_user_service.service;

import springmsa.springmsa_user_service.dto.UserDto;
import springmsa.springmsa_user_service.entity.Users;

public interface UserService {
    Users createUser(UserDto userDto);

    UserDto findUserById(Long id);

    Iterable<Users> findAll();
}

 

메서드를 새롭게 추가 했으니 구현 클래스에서 해당 메서드들을 구현해야한다.

UserServiceImpl

package springmsa.springmsa_user_service.service;

import jakarta.ws.rs.NotFoundException;
import lombok.RequiredArgsConstructor;
import org.modelmapper.ModelMapper;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;
import springmsa.springmsa_user_service.dto.ResponseOrderDto;
import springmsa.springmsa_user_service.dto.UserDto;
import springmsa.springmsa_user_service.entity.Users;
import springmsa.springmsa_user_service.repository.UserRepository;

import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.UUID;

@Service
@RequiredArgsConstructor
public class UserServiceImpl implements UserService {
    private final UserRepository userRepository;
    private final ModelMapper modelMapper;
    private final BCryptPasswordEncoder bCryptPasswordEncoder;

    @Override
    public Users createUser(UserDto userDto) {
        userDto.setUserId(UUID.randomUUID().toString().substring(0, 8));

        Users users = modelMapper.map(userDto, Users.class);
        users.setEncryptedPwd(bCryptPasswordEncoder.encode(userDto.getPwd()));

        userRepository.save(users);

        return users;
    }

    @Override
    public UserDto findUserById(Long id) {
        Optional<Users> user = userRepository.findById(id);

        if (user.isEmpty()) {
            throw new NotFoundException("User not found");
        }

        UserDto userDto = modelMapper.map(user.get(), UserDto.class);

        List<ResponseOrderDto> orders = new ArrayList<>();
        userDto.setOrders(orders);

        return userDto;
    }

    @Override
    public Iterable<Users> findAll() {
        return userRepository.findAll();
    }
}

 

findUserById(Long id)findAll() 메서드를 구현하는데 내용은 간단하다.

findAll()은 repository에 위임하는것이 끝이고 findUserById(Long id)는 유저 아이디를 파라미터로 받으면 repository에서 먼저 유저를 찾은 후 있다면 ModelMapper를 이용해서 DTO로 변환한다. 유저는 추후에 만들 Order MicroService에 존재하는 주문 내역을 가지는데 우선은 Order MicroService를 만들지 않았으니 유저가 가지고 있는 주문 내역은 빈 리스트로 넣어 반환한다.

 

DTO는 다음과 같다.

ResponseOrderDto

package springmsa.springmsa_user_service.dto;

import lombok.Data;

import java.time.LocalDateTime;

@Data
public class ResponseOrderDto {
    private String productId;
    private Integer qty;
    private Integer unitPrice;
    private Integer totalPrice;
    private LocalDateTime createdAt;
    private String orderId;
}

 

UserDto

package springmsa.springmsa_user_service.dto;

import lombok.Data;

import java.time.LocalDateTime;
import java.util.List;

@Data
public class UserDto {
    private String email;
    private String name;
    private String pwd;
    private String encryptedPwd;
    private String userId;
    private LocalDateTime createdAt;

    private List<ResponseOrderDto> orders;
}

 

 

Controller

이제 유저 조회에 대한 컨트롤러 작업을 해보자. URI는 다음과 같다.

  • 유저 전체 조회: /users 
  • 유저 단일 조회: /users/{id}

UserController

package springmsa.springmsa_user_service.controller;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.modelmapper.ModelMapper;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import springmsa.springmsa_user_service.dto.*;
import springmsa.springmsa_user_service.entity.Users;
import springmsa.springmsa_user_service.service.UserService;

import java.util.ArrayList;
import java.util.List;

@Slf4j
@RestController
@RequiredArgsConstructor
@RequestMapping("/users")
public class UserController {

    private final UserService userService;
    private final ModelMapper modelMapper;

    @PostMapping("")
    public ResponseEntity<ApiResponseDto<ResponseUserDto>> createUser(@RequestBody RequestUserDto requestUserDto) {
        log.info("createUser payload: {}", requestUserDto);

        try {
            Users createdUser = userService.createUser(modelMapper.map(requestUserDto, UserDto.class));
            return ResponseEntity
                    .status(HttpStatus.CREATED)
                    .body(new ApiResponseDto<>(modelMapper.map(createdUser, ResponseUserDto.class), null));
        } catch (DataIntegrityViolationException e) {
            return ResponseEntity
                    .status(HttpStatus.BAD_REQUEST)
                    .body(new ApiResponseDto<>(null, e.getMessage()));
        }
    }

    @GetMapping("")
    public ResponseEntity<ApiResponseDto<List<ResponseUsersDto>>> getUsers() {
        Iterable<Users> users = userService.findAll();

        List<ResponseUsersDto> result = new ArrayList<>();

        users.forEach(user -> {
            ResponseUsersDto userDto = modelMapper.map(user, ResponseUsersDto.class);
            result.add(userDto);
        });

        return ResponseEntity.status(HttpStatus.OK).body(new ApiResponseDto<>(result, null));
    }

    @GetMapping("/{id}")
    public ResponseEntity<ApiResponseDto<ResponseUserDto>> getUser(@PathVariable Long id) {
        UserDto findUser = userService.findUserById(id);

        ResponseUserDto userDto = modelMapper.map(findUser, ResponseUserDto.class);

        return ResponseEntity.status(HttpStatus.OK).body(new ApiResponseDto<>(userDto, null));
    }
}

 

컨트롤러를 보면 getUsers()getUser(@PathVariable Long id)가 있다. 

 

전체 조회 코드를 먼저 보면, 서비스로부터 전체 유저 데이터를 받아온다. 그 다음 받아온 결과를 DTO로 변환해주는 코드가 필요하다. 

항상 컨트롤러에서 데이터를 반환할 땐 엔티티 자체가 아닌 DTO로 변환하여 돌려주어야 한다. 그래야 해당 엔티티의 변화에도 API 스펙에 영향이 가지 않을 뿐더러 (사실 이게 제일 중요) 엔티티를 리턴하는 것 자체가 좋은 방법이 아니다. 불필요한 데이터까지 API에 모두 태울 수 있으니. 

 

단일 조회 코드를 보면, URI로부터 유저 ID를 받아온다. 그 ID로 서비스로부터 유저를 조회하여 받아온다. 받아온 유저를 역시나 DTO로 변환한다. 굳이 ResponseUserDto와 ResponseUsersDto로 구분지은 이유는 전체 유저를 조회할 땐 유저의 주문 내역을 반환하지 않기 위해서다.

 

ResponseUsersDto

package springmsa.springmsa_user_service.dto;

import lombok.Data;

import java.util.List;

@Data
public class ResponseUsersDto {
    private String userId;
    private String email;
    private String name;
}

 

ResponseUserDto

package springmsa.springmsa_user_service.dto;

import lombok.Data;

import java.util.List;

@Data
public class ResponseUserDto {
    private String userId;
    private String email;
    private String name;

    private List<ResponseOrderDto> orders;
}

 

 

테스트

Postman으로 테스트를 해보자. 일단 유레카 서버와 API Gateway 서버가 모두 띄워져 있어야 한다. 나는 API Gateway로 요청할것이기 때문에. 확인을 위해 유레카 서버에 접속해보자. 

 

유레카 서버

인스턴스에 APIGATEWAY-SERVICE, USER-SERVICE가 등록되어 있으면 정상이다. 

 

  • http://localhost:8000/users (전체 조회)

전체 조회 API에 대해 테스트한 결과이다.

 

  • http://localhost:8000/users/1 (유저 단일 조회)

유저 단일 조회 API다. 이 데이터는 유저가 가지는 주문 내역에 대한 데이터 'orders'가 있다. 테스트에서 조회한 유저는 주문 내역이 없기 때문에 응답된 데이터가 없다.

 

728x90
반응형
LIST
728x90
반응형
SMALL
반응형
SMALL

UserService의 API 중 Create 부분을 만들어보자.

 

 

VO

우선, vo 패키지를 하나 추가하고 그 패키지 안에서 CreateUser.java 파일을 생성한다.

이 클래스는 User를 생성하고자 하는 사용자의 요청을 받을 때 Payload를 담는 클래스다. 

package com.example.tistoryuserservice.vo;

import lombok.Data;

@Data
public class CreateUser {
    private String email;
    private String name;
    private String password;
}

위처럼 작성하면 되는데 좀 더 완성도를 높여보자. 각 필드 별 Validation을 걸 수 있다. 예를 들면 최소한의 길이, 최대 길이, NotNull 조건 등 여러 유효성 검사를 필드에 걸어놓을 수 있는데 이를 위해 dependency 하나를 추가해야 한다.

<!-- https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-starter-validation -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
    <version>3.1.3</version>
</dependency>

위 의존성을 내려받고 아래와 같이 코드를 수정해 보자.

 

package com.example.tistoryuserservice.vo;

import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.Data;

@Data
public class CreateUser {
    @NotNull(message = "Email must be required")
    @Size(min = 2, message = "Email should be more than two characters")
    @Email
    private String email;
    
    @NotNull
    @Size(min = 2, message = "Name should be more than two characters")
    private String name;
    
    @NotNull
    @Size(min = 8, message = "Password should be more than 8 characters")
    private String password;
}

@NotNull, @Size, @Email과 같은 어노테이션은 방금 내려받은 dependency에 의해 사용할 수 있다. 이런 제약조건을 걸어놓으면 payload로 받은 데이터를 이 클래스에 담으려고 할 때 조건에 해당하지 않으면 담지 못한다. 이와 같이 유효성 검사를 간단하게 적용할 수 있다.

 

 

DTO

이제 DTO를 만들 차례다. 즉, 외부 요청에 의해 전달된 새로운 유저를 만들 데이터를 DB에 저장하기 전 DB에 들어갈 알맞은 형식의 데이터가 필요한데 그때 사용되는 클래스라고 보면 된다.

package com.example.tistoryuserservice.dto;

import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.Data;

import java.util.Date;

@Data
@JsonIgnoreProperties(ignoreUnknown = true)
@JsonInclude(value = JsonInclude.Include.NON_NULL)
public class CreateUserDto {
    private String email;
    private String name;
    private String password;
    private String userId;
    private Date createdAt;

    private String encryptedPassword;
}

 

dto 패키지를 추가한 후 CreateUserDto라는 클래스로 만들고 위와 같이 작성했다. CreateUser 클래스에는 없는 userId, createdAt, encryptedPassword 필드는 DB에 넣기 전 서비스 클래스에서 추가될 내용이고 나머지는 CreateUser 클래스에서 받아올 거다.

 

CrudRepository

이제 CrudRepository를 사용해서 기본적인 CRUD API를 제공하는 JPA의 도움을 받을 것이다.

repository라는 패키지를 하나 만들고 그 안에 UserRepository 인터페이스를 생성하자.

package com.example.tistoryuserservice.repository;

import com.example.tistoryuserservice.entity.User;
import org.springframework.data.repository.CrudRepository;

public interface UserRepository extends CrudRepository<User, Long> {}

이렇게 인터페이스를 만들면 User Entity에 대한 기본적인 CRUD 메서드를 가져다가 사용할 수 있다. 그 방법은 이후에 서비스 클래스를 구현하면서 볼 수 있다.

 

Service

UserService를 구현해 볼 차례다. 인터페이스를 만들고 필요한 메서드들을 정의한 뒤 그 인터페이스를 구현한 서비스 클래스를 만들어보자. 우선 service라는 패키지에 UserService 인터페이스를 만들자.

package com.example.tistoryuserservice.service;

import com.example.tistoryuserservice.dto.CreateUserDto;

public interface UserService {
    CreateUserDto createUser(CreateUserDto createUserDto);
}

 

 

그리고 이 인터페이스를 상속받는 서비스 클래스를 만든다. 우선은 메서드들을 정의한 인터페이스 먼저 만들자.

service라는 패키지안에 UserService 인터페이스를 만들어준다.

package com.example.tistoryuserservice.service;

import com.example.tistoryuserservice.dto.CreateUserDto;

public interface UserService {
    CreateUserDto createUser(CreateUserDto createUserDto);
}

이 인터페이스를 구현하는 UserServiceImpl 클래스를 만들어준다.

package com.example.tistoryuserservice.service;

import com.example.tistoryuserservice.dto.CreateUserDto;
import com.example.tistoryuserservice.entity.User;
import com.example.tistoryuserservice.repository.UserRepository;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;

import java.util.UUID;

@Slf4j
@Service
@RequiredArgsConstructor
public class UserServiceImpl implements UserService {

    private final UserRepository userRepository;

    @Override
    public CreateUserDto createUser(CreateUserDto createUserDto) {
        createUserDto.setUserId(UUID.randomUUID().toString());

        ObjectMapper mapper = new ObjectMapper();
        User user = mapper.convertValue(createUserDto, User.class);
        user.setEncryptedPassword("encrypted_password");

        userRepository.save(user);
        return createUserDto;
    }
}

이 서비스 클래스에서 createUser()를 구현하고 있다. 여기서는 DTO에는 없는 userId와 encryptedPassword를 직접 추가해 준다. encryptedPassword를 만들어 내는 것을 구현하지 않았기 때문에 일단은 텍스트로 써넣는다. 이건 추후에 구현 예정이다.

 

DTO 데이터를 가지고 실제 데이터베이스에 들어갈 User라는 Entity로 타입 변환을 해준다. 그리고 그렇게 변환한 객체를 UserRepository를 주입받아서 save() 메서드를 호출한다. CrudRepository가 제공하는 save() 메서드에 어떠한 문제도 발생하지 않는다면 정상적으로 DTO 데이터를 다시 리턴한다.

 

이제 이 서비스 클래스를 호출할 Controller를 구현해야 한다. 실제로 유저가 사용할 API를 받아줄 수 있는.

 

Controller

controller 패키지 안에 UserController.java 파일을 만들자. 

package com.example.tistoryuserservice.controller;

import com.example.tistoryuserservice.dto.CreateUserDto;
import com.example.tistoryuserservice.service.UserService;
import com.example.tistoryuserservice.vo.CreateUser;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@Slf4j
@RestController
@RequiredArgsConstructor
@RequestMapping("/user-service")
public class UserController {

    private final UserService userService;

    @PostMapping("/users")
    public CreateUserDto createUser(@RequestBody CreateUser createUser) {
        ObjectMapper mapper = new ObjectMapper();
        CreateUserDto createUserDto = mapper.convertValue(createUser, CreateUserDto.class);

        return userService.createUser(createUserDto);
    }
}

별건 없다. PostMapping으로 유저를 생성하는 API를 선언하고, @RequestBody 어노테이션을 사용하여 Request의 Body에서 데이터를 받아 CreateUser 클래스로 변환시킨다. 이걸 스프링이 알아서 다 해주는 것이다. 매우 편하다.

 

받아온 데이터를 서비스가 받아줄 수 있는 DTO 타입의 데이터로 변환해 주기 위해 ObjectMapper를 이용한다.

그리고 서비스를 호출해 실제 유저를 생성하고 DB에 저장한 뒤 서비스가 돌려주는 데이터인 CreateUserDto 데이터를 컨트롤러도 리턴한다. 테스트해보자!

 

Create User

Gateway를 통해 UserService를 호출한다. Gateway로부터 요청을 UserService는 전달받을 거고 요청에 대한 처리를 해준 후 응답한 결과다.

728x90
반응형
LIST
728x90
반응형
SMALL
반응형
SMALL

H2 Database를 연동해서 좀 더 그럴싸한 서비스를 구현해 보자. H2 Database는 Java로 만들어진 오픈 소스 형태의 RDBMS다.

 

 

H2 Dependency

Maven Repository 사이트에 들어가서 H2라고 검색해보자. https://mvnrepository.com

검색하면 가장 상단에 H2 Database Engine 이라는 dependency가 노출된다.

거기에 가장 최신 버전을 클릭해서 dependency를 복사한 후 pom.xml 파일에 추가하자.

<!-- https://mvnrepository.com/artifact/com.h2database/h2 -->
<dependency>
    <groupId>com.h2database</groupId>
    <artifactId>h2</artifactId>
    <version>2.2.224</version>
    <scope>runtime</scope>
</dependency>

한 가지 변경사항이 있는데 scope를 runtime으로 변경해 주자. test로 하게 되면 실제로 서비스를 실행할 때 H2 Database를 사용할 수 없기 때문에 runtime scope으로 바꿔서 추가한 후 maven build를 다시 실행.

 

그 후 application.yml 파일에 h2 관련 설정을 추가해줘야 한다.

server:
  port: 0
spring:
  application:
    name: user-service
  h2:
    console:
      enabled: true
      settings:
        web-allow-others: true
      path: /h2-console

eureka:
  instance:
    instance-id: ${spring.cloud.client.hostname}:${spring.application.instance_id:${random.value}}
  client:
    register-with-eureka: true
    fetch-registry: true
    service-url:
      defaultZone: http://127.0.0.1:8761/eureka

h2.console.enabled: true = console이라는 웹 브라우저에서 h2 database를 작업할 수 있는 설정을 적용한다는 의미

h2.console.settings.web-allow-others: true = 웹 브라우저로 접속하는 것을 허용

h2.console.path: /h2-console = 웹 브라우저 접속 URI는 /h2-console

 

이렇게 작성하고 서버를 실행해보자.

 

 

 

H2 console

브라우저에 H2 Database를 연동하기 위해 실행한 서비스 URL에 path로 /h2-console을 입력해 보면, 위와 같은 화면이 노출된다. 이것이 H2가 제공하는 console인데 여기서 데이터베이스에 접속할 수 있다. 

 

Settings는 H2, MariaDB, MySQL 등 여러 Database가 있지만 H2를 그대로 사용할 것이고 Driver Class는 org.h2.Driver, JDBC URL은 jdbc:h2:mem:testdb라고 작성하면 In Memory 형식의 데이터베이스로 testdb라는 데이터베이스 이름을 가진 데이터베이스에 접속하겠다는 의미가 된다. Username과 Password는 default로 Username은 sa, Password는 없다.

 

이 상태에서 접속을 시도해 보면 이러한 에러를 마주치게 된다.

데이터베이스를 찾지 못했다는 뜻인데, 예전 버전에서는 데이터베이스를 못 찾으면 데이터베이스를 생성해 줬는데 이제는 그렇지 않다. 이제는 데이터베이스를 직접 만들어 놓은 상태로 접속해야 한다. 이를 해결하기 위해 UserService에 Entity 클래스를 만들어 User 테이블을 하나 추가해서 데이터베이스를 만들 거다.

 

 

User Entity

Entity 클래스를 만들려면 JPA라는 Dependency를 추가해야 한다.

<!-- https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-starter-data-jpa -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>
    <version>3.1.3</version>
</dependency>

JPA는 Java Persistence API의 약자로, RDB와 Java Objects 간 데이터에 대한 접근, 관리, 지속성에 대한 명세라고 생각하면 된다.

좀 더 간단하게는 Relational Database와 Java application이 상호작용하게 도움을 주는 녀석이라고 생각하자.

JPA는 아래 작성한 포스팅을 통해 조금 더 자세하게 이해할 수 있다.

2023.10.11 - [Spring, Apache, Java] - JPA(Java Persistence API)란?

 

JPA(Java Persistence API)란 ?

JPA는 Java Persistence API의 약자로, RDB와 Java Objects 간 데이터 접근, 관리, 지속성에 대한 명세라고 생각하면 된다. 좀 더 간단하게는 Relational Database와 Java application이 상호작용하게 도움을 주는 녀석

cwchoiit.tistory.com

 

그래서 이제 UserEntity를 만들기 위해 entity라는 패키지를 하나 생성하고 그 안에 User.java파일을 생성하자.

package com.example.tistoryuserservice.entity;

import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonInclude;
import jakarta.persistence.*;
import lombok.Data;

@Data
@Entity
@JsonIgnoreProperties(ignoreUnknown = true)
@JsonInclude(value = JsonInclude.Include.NON_NULL)
@Table(name = "users")
public class User {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, length = 50, unique = true)
    private String email;

    @Column(nullable = false, length = 50)
    private String name;

    @Column(nullable = false, unique = true)
    private String userId;

    @Column(nullable = false, unique = true)
    private String encryptedPassword;
}

필드로는 id, email, name, userId, encryptedPassword가 있다.

이 클래스에서 @Entity, @Table, @Id, @GeneratedValue, @Column 어노테이션 모두가 JPA로부터 사용가능한 어노테이션인데 데이터베이스의 테이블을 어떻게 표현할지에 대한 도움을 주는 어노테이션이다.

 

이제 application.yml 파일을 수정해서, 만든 User Entity를 데이터베이스에 적용해 보자.

 

 

application.yml

수정하고 확인할 부분은 다음과 같다.

spring:
  application:
    name: user-service
  datasource:
    url: jdbc:h2:mem:users
    username: sa
  jpa:
    hibernate:
      ddl-auto: update
    show-sql: true
  h2:
    console:
      enabled: true
      settings:
        web-allow-others: true
      path: /h2-console

spring.datasource.url: 데이터베이스 URL

spring.datasource.username: H2 Database의 Username(Default: sa)

spring.jpa.show-sql: 수행되는 SQL문을 출력할지에 대한 여부

 

spring.jpa.hibernate.ddl-auto

ddl-auto는 어떻게 데이터베이스 스키마가 생성과 변경되는지에 대한 제어값인데, 여러 값이 있다. 

  • create: 애플리케이션이 실행될 때마다 Hibernate가 데이터베이스 스키마를 매번 scratch 상태에서 만들어낸다. 이는 기존에 존재했던 데이터들에 손상이 있을 수 있다.
  • update: Hibernate는 애플리케이션의 엔티티 클래스들의 현재 상태와 매치되도록 데이터베이스 스키마를 업데이트한다. 이는 테이블, 칼럼, 제약 조건들이 추가되거나 변경될 수 있다. 그러나 기존에 존재했던 것들을 삭제하지는 않는다. 이 옵션은 개발 또는 테스트 단계에서 유용할 수 있다.
  • validate: Hibernate는 어떠한 데이터베이스 스키마에도 변경을 가하진 않는다. 오직 애플리케이션 내 정의된 엔티티들의 매핑들이 유효한지를 판단한다. 만약 애플리케이션에서 정의된 값과 데이터베이스 사이에 어떠한 불일치라도 있으면 에러를 던진다.
  • none: Hibernate는 스키마에 대해 어떠한 생성이나 유효성 검증에 대한 수행을 하지 않는다. 정상적으로 또는 엔티티 클래스들의 매핑값과 일치한 데이터베이스 스키마를 만들어내는 모든 책임은 사용자에게 가해진다.  

 

create/update 속성값은 Production 환경에서는 주의 또 주의해야 한다. 데이터 손실 또는 예상하지 못한 스키마의 변화가 생길 수 있으며 이 속성값들은 흔히 개발 단계 또는 테스팅 단계에서 간편하게 데이터베이스 셋업을 하기 위해 설정하는 옵션들이기 때문에 Production 환경에서는 validate 또는 none 속성값을 사용할 것을 권장한다.

 

 

 

H2 Database 연동 확인하기

이렇게 모든 설정을 마치고 나면 서버를 재실행해보자. Hibernate 관련 로그들이 여럿 찍히는 걸 확인할 수 있다.

H2 Database에 User Table을 생성한 로그가 찍힌 것이다. H2 Console로 가서 다시 로그인해 보자.

JDBC URL을 jdbc:h2:mem:users라고 변경한 후 접속하면 정상적으로 접속이 되고 Users라는 테이블 하나가 보인다.

또한, User.java 파일에서 정의한 필드들이 칼럼으로 만들어진 것도 확인할 수 있다.

 

728x90
반응형
LIST

'MSA' 카테고리의 다른 글

[MSA] Part 10. User Service (Find User/s)  (0) 2024.01.08
[MSA] Part 9. UserService (Create User)  (2) 2023.10.11
[MSA] Part 7. API Gateway를 Eureka에 등록하기  (0) 2023.10.10
[MSA] Part 6. Gateway Filter  (2) 2023.10.10
[MSA] Part 5. API Gateway  (0) 2023.10.06
728x90
반응형
SMALL
반응형
SMALL

이전 Part 6 까지는 API Gateway Service는 Eureka에 등록하지 않았다. 이제 이 Gateway도 등록을 해야한다.

등록해서 Load Balancer를 사용해보자.

 

 

API Gateway Service의 application.yml

server:
  port: 8000
eureka:
  client:
    register-with-eureka: true
    fetch-registry: true
    service-url:
      defaultZone: http://localhost:8761/eureka

spring:
  application:
    name: api-gateway-service
  cloud:
    gateway:
      default-filters:
        - name: GlobalFilter
          args:
            baseMessage: Spring Global Filter
            preLogger: true
            postLogger: true
      routes:
        - id: user-service
          uri: lb://USER-SERVICE
          predicates:
            - Path=/user-service/**
          filters:
            - name: CustomFilter

기존에 GatewayService의 application.yml 파일은 eureka.client.register-with-eureka가 false, eureka.client.fetch-registry: false였는데 둘 다 true로 변경해주었다. 그래서 eureka에 이 서비스를 등록하고 eureka에 등록되어 있는 서비스들을 실시간으로 데이터 조회하게 된다.

 

그 다음, 중요한 부분인 spring.cloud.gateway.routes에 기록되는 각각의 서비스들의 uri다.

보면 user-service의 uri가 lb://USER-SERVICE로 변경되었는데 lb는 Load Balancer의 약자로 eureka에 등록된 Named Service 기반으로 서비스를 찾겠다는 뜻이고 그 서비스의 이름이 USER-SERVICE가 된다는 뜻이다. 

 

주의

서비스의 이름에 "_"가 들어가면 안된다. 나도 이거 때문에 한참 고생했는데 만약 있다면 저렇게 USER-SERVICE로 바꾸자.

 

기존에 eureka 서버를 띄우면 application name으로 등록된 서비스들의 이름이 보였다. (아래 사진을 참고)

이렇게 eureka에 등록된 서비스들의 이름을 가지고 Gateway가 실제 서비스의 uri를 찾아간다.

 

 

UserService의 application.yml 

server:
  port: 0
spring:
  application:
    name: user-service

eureka:
  instance:
    instance-id: ${spring.cloud.client.hostname}:${spring.application.instance_id:${random.value}}
  client:
    register-with-eureka: true
    fetch-registry: true
    service-url:
      defaultZone: http://127.0.0.1:8761/eureka

server.port를 우선 0으로 지정해서 임의의 포트를 할당받게 했다. 실행할 때마다 번거롭게 포트를 새롭게 지정해 줄 필요 없이 원하는 만큼 서비스의 인스턴스를 실행할 수 있다. 실제로 서비스의 인스턴스를 두 개 정도 띄워보자. IntelliJ에서 인스턴스를 여러개 띄우는 방법은 이미 전 포스팅에서 설명했던 부분이니 넘어간다.

두 개를 띄우고 eureka 서버에 들어가보면 위 사진처럼 두개의 인스턴스가 기동되어 있음을 확인할 수 있다. 그리고 각 인스턴스의 포트를 신경쓰지 않고 Named 기반의 서비스를 Gateway에서 등록했으니 UserService로의 요청을 Gateway는 잘 전달해줄 수 있을 것이다.

 

 

Load Balancing 확인

실제로 유저 서비스는 인스턴스가 두개가 등록됐는데 외부 요청이 들어올 때마다 Gateway가 어떤 인스턴스에 요청을 보내는지 확인해보자. 그리고 어떤 방식으로 유저 서비스에 대한 요청을 Load Balancing해주는지도 확인해보자.

 

Postman으로 테스트해 볼 예정이고 그 전에 UserService의 코드를 좀 수정해야한다.

package com.example.tistoryuserservice.controller;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.env.Environment;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@Slf4j
@RestController
@RequiredArgsConstructor
@RequestMapping("/user-service")
public class StatusController {
    private final Environment environment;

    @GetMapping("/welcome")
    public String welcomePage() {
        return "Welcome ! This is User Service.";
    }

    @GetMapping("/message")
    public String message(@RequestHeader("user-request") String header) {
        log.info("header: {}", header);
        return "header check";
    }

    @GetMapping("/check")
    public String check() {
        log.info("current port: {}", environment.getProperty("local.server.port"));
        return "Hi, check method !" + environment.getProperty("local.server.port");
    }
}

Environment 클래스를 주입한다. 이 클래스는 환경 변수에 접근할 수 있게 해주는 스프링에서 제공해주는 클래스이다. 이 클래스를 주입받고 check() method에 이 요청이 들어왔을 때 처리해주는 현재 서버의 포트를 찍어주게 변경했다. 

 

그리고 인스턴스를 두 개 띄웠으니까 각 인스턴스의 잡혀있는 포트를 확인해보자.

유레카 서버에서 보면 인스턴스 두 개에 각각 링크가 걸려있다. 각 링크를 클릭해보면 포트번호를 확인할 수 있는데 나의 경우 인스턴스 한 개는 56482, 56537로 잡혀있다.

 

이 두 개의 포트번호를 확인한 후 Postman을 열어 실제로 요청을 보내보자.

최초 요청을 보내면 응답은 포트번호 56482로 나온다. 즉, 56482로 띄워진 인스턴스가 이 요청을 처리했다는 의미인데 바로 한번 더 요청을 보내보자.

그 다음 요청은 56537로 잡힌 인스턴스가 이 요청을 처리했다. 이후에 계속 실행을 해보면 한번은 56482가 한번은 56537 인스턴스가 각 요청을 처리한다 이런 방식을 라운드로밍 방식이라고 하고 Load Balancer가 각 요청에 대해 띄워져 있는 인스턴스에게 한 번씩 요청을 전달해 처리하도록 수행해준다. 이게 Load Balancing이고 이를 Spring Cloud Gateway가 해준다.

 

 

728x90
반응형
LIST
728x90
반응형
SMALL
반응형
SMALL

외부의 요청이 Gateway를 통해 들어올 때 요청을 가로채서 추가적인 작업을 수행할수도 있다. 이 때 사용되는 방법이 Filter이다.

 

 

Filter 설정하기

config 파일 하나를 만들기 위해 config라는 패키지 하나를 만들고 그 안에 FilterConfig.java 파일을 만들었다.

package com.example.tistorygateway.config;

import org.springframework.cloud.gateway.route.RouteLocator;
import org.springframework.cloud.gateway.route.builder.RouteLocatorBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class FilterConfig {
    @Bean
    public RouteLocator gatewayRoutes(RouteLocatorBuilder builder) {
        return builder.routes()
                .route(r -> r.path("/user-service/**")
                        .filters(f -> f.addRequestHeader("user-request", "user-request-header")
                                        .addResponseHeader("user-response", "user-response-header"))
                        .uri("http://localhost:8081"))
                .build();
    }
}

@Configuration 어노테이션을 추가하면 스프링 부트가 자동으로 이 클래스를 설정 처리해준다. 

그리고 그 클래스 내부에 @Bean으로 등록한 메소드를 하나 만들고 RouteLocatorBuilder 인스턴스를 build()한다.

이 RouteLocatorBuilder로 라우트 별 설정을 할 수 있다. 이 gateway로 들어오는 요청의 path가 /user-service로 시작하는 모든 요청에 대해 RequestHeader와 ResponseHeader를 추가한다. Header를 추가할 때 key/value쌍으로 추가하면 되는데 이렇게 추가를 할 수 있고 그 요청에 대한 URL을 http://localhost:8081로 보낸다는 의미에 uri()가 있다. 

 

이렇게 Config파일을 하나 만들고 서버를 재실행한 후 UserService에 새로운 Request Controller를 만들어보자.

 

 

UserService

package com.example.tistoryuserservice.controller;

import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@Slf4j
@RestController
@RequestMapping("/user-service")
public class StatusController {

    @GetMapping("/welcome")
    public String welcomePage() {
        return "Welcome ! This is User Service.";
    }
	
    // 새롭게 추가한 부분
    @GetMapping("/message")
    public String message(@RequestHeader("user-request") String header) {
        log.info("header: {}", header);
        return "header check";
    }
}

새로운 GetMapping 메소드를 추가했고 Parameter로 RequestHeader를 받는 header 하나를 넣었다. 이렇게 파라미터에 요청 헤더를 받아올 수 있는데 이 헤더값을 로그로 찍은 후 "header check" 이라는 문자열을 응답 메세지로 반환한다.

 

이제 /user-service/message로 요청을 해서 filter가 동작하는지 확인해보자. 

 

 

 

Filter 동작 확인하기

위 사진처럼 /user-service/message로 요청을 보냈을 때 Response Headers에 User-Response라는 key가 담겨있는것을 확인할 수 있다. key에 대한 value는 'user-response-header'라고 명시되어 있음을 확인할 수 있다. 

 

여기서 한가지 더 확인할 수 있는건 Request Header에는 추가한 "user-request"가 들어가 있지 않는것을 볼 수 있는데 이는 filter를 거치기 전 request header에 정보이기 때문이다. 완벽하게 filter가 정상적으로 동작하고 있는것이다. 

그 filter를 거친 request header의 값은 어디서 확인하냐면 UserService의 Controller에서 파라미터에 넣었던 @RequestHeader를 통해 확인할 수 있다.

그러니까 흐름은 외부 요청 -> Gateway -> Filter -> UserService 이렇게 진행되고 위 브라우저에서는 외부 요청단계에 머물러 있는것이고 로그로 찍힌 상태에서는 UserService에 도달한 상태. 이렇게 외부 요청을 중간에 가로채서 추가적인 작업을 Filter를 통해서 수행할 수 있다.

 

 

이렇게 Filter를 설정할 수도 있고 application.yml 파일로도 Filter를 추가할 수 있다. 그것도 한 번 해 볼 생각이다.

 

 

application.yml 파일로 filter 추가하기

우선 위에서 등록해봤던 @Configuration을 주석처리하자. filter를 application.yml 파일로 설정할 생각이니까.

package com.example.tistorygateway.config;

import org.springframework.cloud.gateway.route.RouteLocator;
import org.springframework.cloud.gateway.route.builder.RouteLocatorBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

// @Configuration
public class FilterConfig {
    // @Bean
    public RouteLocator gatewayRoutes(RouteLocatorBuilder builder) {
        return builder.routes()
                .route(r -> r.path("/user-service/**")
                        .filters(f -> f.addRequestHeader("user-request", "user-request-header")
                                        .addResponseHeader("user-response", "user-response-header"))
                        .uri("http://localhost:8081"))
                .build();
    }
}

 

주석 처리했으면 이제 application.yml 파일에 다음과 같이 filter를 추가한다.

server:
  port: 8000
eureka:
  client:
    register-with-eureka: false
    fetch-registry: false
    service-url:
      defaultZone: http://localhost:8761/eureka

spring:
  application:
    name: api-gateway-service
  cloud:
    gateway:
      routes:
        - id: user-service
          uri: http://localhost:8081/user-service/**
          predicates:
            - Path=/user-service/**
          # application.yml 파일에 filter 추가
          filters:
            - AddRequestHeader=user-request, user-request-header2
            - AddResponseHeader=user-response, user-response-header2

추가한 필터가 하는 동작은 위에서 했던 내용과 정확히 일치한다.

Request Header에 key/value를 (user-request, user-request-header2).

Response Header에 key/value를 (user-response, user-response-header2). 

물론, value값만 차이를 구분하기 위해 뒤에 숫자 '2'를 추가했다. 

 

이제 application.yml 파일로 추가한 filter가 정상 동작하는지 또 한번 확인해보자.

 

 

application.yml에 추가한 filter 확인하기

확인해보면 역시 정상적으로 filter가 동작하고 있음을 확인할 수 있고 RequestHeader도 확인해보기 위해 로그를 확인한다.

 

 

 

CustomFilter 추가하기

사용자 정의 필터도 만들 수 있다. 이런 필터로는 사용자 인증 같은 인증된 유저만 접근가능하게 하는 경우가 제일 많이 사용되는데 이런 필터는 어떻게 만드는지 직접 구현해 볼것이다.

 

우선 filter라는 패키지를 하나 추가하고 CustomFilter.java 파일을 만든다.

package com.example.tistorygateway.filter;

import lombok.extern.slf4j.Slf4j;
import org.springframework.cloud.gateway.filter.GatewayFilter;
import org.springframework.cloud.gateway.filter.factory.AbstractGatewayFilterFactory;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.stereotype.Component;
import reactor.core.publisher.Mono;

@Slf4j
@Component
public class CustomFilter extends AbstractGatewayFilterFactory<CustomFilter.Config> {

    public CustomFilter() {
        super(Config.class);
    }

    @Override
    public GatewayFilter apply(Config config) {
        // Custom Pre Filter
        return (exchange, chain) -> {
            ServerHttpRequest request = exchange.getRequest();
            ServerHttpResponse response = exchange.getResponse();

            log.info("[CustomFilter] Start: request id {}", request.getId());

            // Custom Post Filter
            return chain.filter(exchange).then(Mono.fromRunnable(() -> {
               log.info("[CustomFilter] End: response code: {}", response.getStatusCode());
            }));
        };
    }

    public static class Config {
        // Put the configuration properties
    }
}

@Component 어노테이션을 사용해서 이 클래스를 스프링에 빈으로 등록하고, AbstractGatewayFilterFactory를 상속받는다.

apply() 함수 안에 실제 필터로 무언가 할 내용을 입력하면 된다. 위 예시에서는 필터가 시작하는 시점과 끝나는 시점의 로그를 출력해 필터의 적용 및 시작과 끝을 좀 더 보기 수월하게 했다.

 

이렇게 설정을 했으면 이 필터를 적용해야한다. 커스텀 필터는 글로벌 필터가 아니기 때문에 필요한 서비스마다 필터 설정을 걸어주면 된다.

application.yml 파일에서 필터를 설정해주자.

 

 

Custom Filter 적용하기

application.yml 파일에서 방금 만든 CustomFilter를 추가한다.

server:
  port: 8000
eureka:
  client:
    register-with-eureka: false
    fetch-registry: false
    service-url:
      defaultZone: http://localhost:8761/eureka

spring:
  application:
    name: api-gateway-service
  cloud:
    gateway:
      routes:
        - id: user-service
          uri: http://localhost:8081/user-service/**
          predicates:
            - Path=/user-service/**
          filters:
#            - AddRequestHeader=user-request, user-request-header2
#            - AddResponseHeader=user-response, user-response-header2
            - CustomFilter

spring.cloud.gateway.routes.filters에 CustomFilter를 추가했고 그 전에 입력한 AddRequestHeader와 AddResponseHeader는 주석처리했다. 이렇게 설정한 후 재실행 시켜서 다시 UserService에 요청을 보내보자.

 

UserService가 응답할 수 있는 어떤 요청도 상관없이 요청을 보내보면 gateway service에서 확인할 수 있는 로그가 있다.

필터가 적용된 UserService에 대한 요청이 들어왔을 때 찍힌 로그가 보인다. 이렇듯 사용자 정의 필터를 원하는 서비스마다 적용시킬 수 있다.

 

 

 

GlobalFilter 추가하기

GlobalFilter는 gateway service로부터 들어오는 모든 요청에 대해 필터를 적용하는 것이다.

이 또한 CustomFilter를 만드는것과 비슷하게 만들 수 있다.

 

filter 패키지 안에 GlobalFilter.java 파일을 만들고 다음과 같이 작성했다.

package com.example.tistorygateway.filter;

import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cloud.gateway.filter.GatewayFilter;
import org.springframework.cloud.gateway.filter.factory.AbstractGatewayFilterFactory;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.stereotype.Component;
import reactor.core.publisher.Mono;

@Slf4j
@Component
public class GlobalFilter extends AbstractGatewayFilterFactory<GlobalFilter.Config> {

    public GlobalFilter() {
        super(Config.class);
    }

    @Override
    public GatewayFilter apply(Config config) {
        return (exchange, chain) -> {
            ServerHttpRequest request = exchange.getRequest();
            ServerHttpResponse response = exchange.getResponse();
            log.info("[GlobalFilter] baseMessage: {}", config.getBaseMessage());

            if (config.isPreLogger()) {
                log.info("[GlobalFilter] Start: request id {}", request.getId());
            }

            return chain.filter(exchange).then(Mono.fromRunnable(() -> {
                if (config.isPostLogger()) {
                    log.info("[GlobalFilter] End: response code: {}", response.getStatusCode());
                }
            }));
        };
    }

    @Data
    public static class Config {
        private String baseMessage;
        private boolean preLogger;
        private boolean postLogger;
    }
}

GlobalFilter는 CustomFilter 만들 때와 거의 유사하다. 똑같다고 봐도 되는데 코드에서 달라지는 부분은 Config 클래스에 properties가 추가됐다. baseMessage, preLogger, postLogger 세 개의 필드를 추가했고 이 값은 application.yml 파일에서 지정해 줄 것이다.

 

 

GlobalFilter 적용하기

server:
  port: 8000
eureka:
  client:
    register-with-eureka: false
    fetch-registry: false
    service-url:
      defaultZone: http://localhost:8761/eureka

spring:
  application:
    name: api-gateway-service
  cloud:
    gateway:
      default-filters:
        - name: GlobalFilter
          args:
            baseMessage: Spring Global Filter
            preLogger: true
            postLogger: true
      routes:
        - id: user-service
          uri: http://localhost:8081/user-service/**
          predicates:
            - Path=/user-service/**
          filters:
#            - AddRequestHeader=user-request, user-request-header2
#            - AddResponseHeader=user-response, user-response-header2
            - CustomFilter

application.yml 파일에서 spring.cloud.gateway.default-filters에 GlobalFilter가 추가됐음을 확인할 수 있다. 이렇게 default-filters로 추가하면 어떤 라우트가 됐던간 이 gateway를 통과하는 모든 요청은 저 필터를 거친다. 

그리고, args로 Config 클래스에서 만든 세 가지 필드 baseMessage, preLogger, postLogger 값을 설정했다. 

 

이렇게 작성하고 gateway-service를 재실행해서 UserService에 요청을 날려보자. 그럼 gateway service에서 이런 로그를 확인할 수 있다. 

보면 GlobalFilter가 가장 먼저 시작하고 GlobalFilter가 끝나기 전 CustomFilter가 동작해서 끝나고 난 후 GlobalFilter가 마지막으로 끝난다. 모든 필터는 이렇게 동작한다. GlobalFilter로 설정한 필터가 제일 먼저 시작해서 제일 나중에 끝난다.

 

 

 

LoggingFilter 추가하기

필터를 하나 더 추가해서 적용했을 때 필터의 우선순위에 따라 필터가 적용되는 순서가 달라짐을 확인해보고 적절하게 사용할 수 있도록 해보자. CustomFilter, GlobalFilter를 만든 패키지에 LoggingFilter.java 파일을 만든다.

package com.example.tistorygateway.filter;

import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cloud.gateway.filter.GatewayFilter;
import org.springframework.cloud.gateway.filter.OrderedGatewayFilter;
import org.springframework.cloud.gateway.filter.factory.AbstractGatewayFilterFactory;
import org.springframework.core.Ordered;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.stereotype.Component;
import reactor.core.publisher.Mono;

@Slf4j
@Component
public class LoggingFilter extends AbstractGatewayFilterFactory<LoggingFilter.Config> {

    public LoggingFilter() {
        super(Config.class);
    }

    @Override
    public GatewayFilter apply(Config config) {
        /*return (exchange, chain) -> {
            ServerHttpRequest request = exchange.getRequest();
            ServerHttpResponse response = exchange.getResponse();
            log.info("[GlobalFilter] baseMessage: {}", config.getBaseMessage());

            if (config.isPreLogger()) {
                log.info("[GlobalFilter] Start: request id {}", request.getId());
            }

            return chain.filter(exchange).then(Mono.fromRunnable(() -> {
                if (config.isPostLogger()) {
                    log.info("[GlobalFilter] End: response code: {}", response.getStatusCode());
                }
            }));
        };*/


        GatewayFilter filter = new OrderedGatewayFilter((exchange, chain) -> {
            ServerHttpRequest request = exchange.getRequest();
            ServerHttpResponse response = exchange.getResponse();

            log.info("[LoggingFilter]: baseMessage: {}", config.getBaseMessage());
            if (config.isPreLogger()) {
                log.info("[LoggingFilter]: Start request id {}", request.getId());
            }

            return chain.filter(exchange).then(Mono.fromRunnable(() -> {
                if (config.isPostLogger()) {
                    log.info("[LoggingFilter]: End response code {}", response.getStatusCode());
                }
            }));
        }, Ordered.LOWEST_PRECEDENCE);
        return filter;
    }

    @Data
    public static class Config {
        private String baseMessage;
        private boolean preLogger;
        private boolean postLogger;
    }
}

이번에는 Lambda식이 아니고 인스턴스 생성 후 인스턴스를 리턴하는 방식으로 구현해보자. 정확히 같은 내용인데 이렇게 된 코드를 Lambda 표현식으로도 사용할 수 있음을 이해하기 위해서 이렇게 작성했다.

 

다른건 다 똑같고 OrderedGatewayFilter()의 두번째 인자로 Ordered.LOWEST_PRECEDENCE를 적용하면 이 LoggingFilter가 가장 나중에 실행된다. Ordered.HIGHEST_PRECEDENCE도 있는데 이는 GlobalFilter보다도 더 먼저 실행된다. 그래서 그 차이를 확인해보자.

 

이 필터를 만들었으면 application.yml 파일에 적용해야한다. 

 

 

 

LoggingFilter 적용하기

 

server:
  port: 8000
eureka:
  client:
    register-with-eureka: false
    fetch-registry: false
    service-url:
      defaultZone: http://localhost:8761/eureka

spring:
  application:
    name: api-gateway-service
  cloud:
    gateway:
      default-filters:
        - name: GlobalFilter
          args:
            baseMessage: Spring Global Filter
            preLogger: true
            postLogger: true
      routes:
        - id: user-service
          uri: http://localhost:8081/user-service/**
          predicates:
            - Path=/user-service/**
          filters:
#            - AddRequestHeader=user-request, user-request-header2
#            - AddResponseHeader=user-response, user-response-header2
            - name: CustomFilter
            - name: LoggingFilter
              args:
                baseMessage: Logging Filter
                preLogger: true
                postLogger: true

어떠한 arguments도 가지지 않는 필터라면 그냥 필터 이름만 작성하면 되는데 그게 아니라면 name, args 키를 작성해줘야한다. 

이렇게 작성한 후 실행해서 gateway로 요청을 보내면 다음과 같은 로그가 찍힌다.

gateway에 요청을 보내고 로그를 확인해보면 다음과 같다.

GlobalFilter -> CustomFilter -> LoggingFilter 순으로 실행되고 이 역순으로 필터가 종료된다.

이렇게 필터에 우선순위를 최하위로 선언하면 가장 마지막에 실행이 되고 이번엔 최우선으로 선언해본다.

package com.example.tistorygateway.filter;

import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cloud.gateway.filter.GatewayFilter;
import org.springframework.cloud.gateway.filter.OrderedGatewayFilter;
import org.springframework.cloud.gateway.filter.factory.AbstractGatewayFilterFactory;
import org.springframework.core.Ordered;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.stereotype.Component;
import reactor.core.publisher.Mono;

@Slf4j
@Component
public class LoggingFilter extends AbstractGatewayFilterFactory<LoggingFilter.Config> {

    public LoggingFilter() {
        super(Config.class);
    }

    @Override
    public GatewayFilter apply(Config config) {
        /*return (exchange, chain) -> {
            ServerHttpRequest request = exchange.getRequest();
            ServerHttpResponse response = exchange.getResponse();
            log.info("[GlobalFilter] baseMessage: {}", config.getBaseMessage());

            if (config.isPreLogger()) {
                log.info("[GlobalFilter] Start: request id {}", request.getId());
            }

            return chain.filter(exchange).then(Mono.fromRunnable(() -> {
                if (config.isPostLogger()) {
                    log.info("[GlobalFilter] End: response code: {}", response.getStatusCode());
                }
            }));
        };*/


        GatewayFilter filter = new OrderedGatewayFilter((exchange, chain) -> {
            ServerHttpRequest request = exchange.getRequest();
            ServerHttpResponse response = exchange.getResponse();

            log.info("[LoggingFilter]: baseMessage: {}", config.getBaseMessage());
            if (config.isPreLogger()) {
                log.info("[LoggingFilter]: Start request id {}", request.getId());
            }

            return chain.filter(exchange).then(Mono.fromRunnable(() -> {
                if (config.isPostLogger()) {
                    log.info("[LoggingFilter]: End response code {}", response.getStatusCode());
                }
            }));
        }, Ordered.HIGHEST_PRECEDENCE);
        return filter;
    }

    @Data
    public static class Config {
        private String baseMessage;
        private boolean preLogger;
        private boolean postLogger;
    }
}

Ordered.HIGHEST_PRECEDENCE로 두번째 인자를 추가해서 다시 실행해보면 다음과 같은 로그가 찍힌다.

이번엔 LoggingFilter가 가장먼저 실행됐다. 이렇게 필터의 우선순위를 지정할 수도 있다.

 

728x90
반응형
LIST
728x90
반응형
SMALL
반응형
SMALL

API Gateway를 구현해보자. 그 전에 API Gateway가 있을 때 얻는 이점이 무엇이길래 이 녀석을 구현하는지 알아보자.

API Gateway가 없을 때 외부에서 어떤 요청을 하면 그 요청을 앞 단에서 해당 요청을 처리하는 서비스(뒷 단)와 통신하여 응답을 외부에게 돌려주는 구조가 될 것이다. 이 상태에서는 어떤 문제도 없지만 만약 기존에 있던 서비스들 중 어떤 것이 URL이 변경된다던지, 서비스를 운영하는 서버가 이전된다던지 등 어떤 변화가 생기게 되면 서비스의 URL같은 호출 정보가 변경된다. 호출 정보가 변경되면 그 변경 사항에 맞게 앞 단은 다시 설정 작업을 해야하고 그 작업으로 인해 서비스는 다운타임이 발생한다. 

 

이와 반대로 API Gateway와 같은 중개자가 있는 구조를 살펴보자.

이런 구조를 가졌을 때 외부에서 요청이 들어오면 앞 단은 그 요청을 API Gateway에게 보내게 되고 API Gateway는 그 요청을 처리해주는 서비스에게 전달해주기만 하면 된다. 여기서 만약 위와 같은 상황인 서비스의 URL이 변경되거나, 서비스를 운영하는 서버가 이전된다거나 하더라도 앞 단에서 수정할 부분은 없다. 앞 단은 서비스가 무엇이 있는지조차 알 필요도 없다. 그저 API Gateway와 통신만 하면 되기 때문이다. 서비스를 운영하는 서버가 이전된 경우에 그 서버를 API Gateway에 등록(정확히는 Service discovery이지만 그림에서 표현하지 않았기에 편의상)하기만 하면 된다. 심지어 같은 서비스의 여러 인스턴스가 존재할 때 Load Balancing 처리도 해주기에 좋은 점은 늘어난다.

 

그럼 이제 API Gateway를 구현해보자.

 

 

Spring Cloud Gateway 생성

Spring Initializr로 빠르게 생성해보자.

이전과 전부 동일하고 이름만 변경한 뒤 Next

Dependencies는 Lombok과 Gateway를 선택하고 프로젝트 생성.

 

 

pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.1.4</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>
    <groupId>com.example</groupId>
    <artifactId>tistory-gateway</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>tistory-gateway</name>
    <description>tistory-gateway</description>
    <properties>
        <java.version>17</java.version>
        <spring-cloud.version>2022.0.4</spring-cloud.version>
    </properties>
    <dependencies>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-gateway</artifactId>
        </dependency>

        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>
    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>org.springframework.cloud</groupId>
                <artifactId>spring-cloud-dependencies</artifactId>
                <version>${spring-cloud.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <configuration>
                    <excludes>
                        <exclude>
                            <groupId>org.projectlombok</groupId>
                            <artifactId>lombok</artifactId>
                        </exclude>
                    </excludes>
                </configuration>
            </plugin>
        </plugins>
    </build>

</project>

이번에는 전과 달리 Spring Initializr에서 모든 dependencies를 받아오는 게 아니라 직접 추가하는 법을 알아보자.

필요한 건 netflix eureka.

 

pom.xml파일에 dependencies 하위에 이렇게 추가해보자.

<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
</dependency>

이렇게 pom.xml파일을 수정했으면 pom.xml파일을 다시 load해야한다.

방법은 두가지인데 pom.xml파일에 수정이 생기면 IntelliJ에서는 우측 상단에 이런 표시가 나타난다.

이걸 클릭하거나 우측 Maven 탭이 있다.

이 탭을 눌러보면 상단에 Reload 버튼을 눌러도 된다.

pom.xml에 명시한 dependencies들을 모두 정상적으로 내려받았으면 지금은 더 할 게 없다. 이제 application.yml파일을 작성한다.

 

 

application.yml

application.yml 파일로 우선 이름을 변경하고 다음과 같이 작성했다.

server:
  port: 8000
eureka:
  client:
    register-with-eureka: false
    fetch-registry: false
    service-url:
      defaultZone: http://localhost:8761/eureka

spring:
  application:
    name: api-gateway-service
  cloud:
    gateway:
      routes:
        - id: user-service
          uri: http://localhost:8081/user-service/**
          predicates:
            - Path=/user-service/**

여기서 주의깊게 볼 부분은 spring.cloud.gateway.routes이다.

spring cloud gateway에 어떤 서비스들을 등록되어 라우팅될 것인지를 작성하는 부분인데 내가 만든 UserService를 이 gateway에 등록해서 UserService에 대한 요청이 들어오면 요청을 전달해준다. 그 때 작성하는 부분이 id, uri, predicates이다.

id는 고유값으로 서비스 이름을 설정했다. uri는 해당 서비스가 실행되고 있는 URL정보를 작성한다. predicates은 조건을 명시하는 부분인데 Path에 /user-service/**로 작성하게 되면 gateway로 들어오는 요청의 path 정보가 user-service가 붙고 그 뒤에 어떤 값이 들어오더라도 uri에 명시한 서비스로 요청을 전달한다.

 

이 application.yml 파일은 많은 변화가 있을 예정이지만 일단은 지금 상태로도 충분하다.

 

 

Start Gateway Server

이렇게 작성해놓고 Gateway Service를 실행시켜보면 다음처럼 정상적으로 실행됐다는 로그가 찍혀야한다.

정상적으로 Gateway Service가 올라왔고 이 Gateway를 통해 UserService를 호출했을 때 UserService로 요청이 전달되는지 확인해본다. 그러기 위해서는 UserService에 Controller가 필요하다.

 

UserService Controller

controller 패키지 하나를 만들고, 그 안에 StatusController.java 파일을 생성

package com.example.tistoryuserservice.controller;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/")
public class StatusController {

    @GetMapping("/welcome")
    public String welcomePage() {
        return "Welcome ! This is User Service.";
    }
}

간단한 Welcome page를 만들었다. UserService의 /welcome으로 요청하면 "Welcome ! this is User Service."라는 문장이 노출되어야 한다. 그러나, gateway를 통해 요청해보면 다음과 같은 404 에러 화면을 마주하게 된다.

이런 현상이 발생하는 이유는 gateway를 통해 호출하는 경로 http://localhost:8000/user-service/welcome 이는 곧 gateway에서 routing 설정대로 http://localhost:8081/user-service/welcome으로 전달한다. 

 

http://localhost:8000/user-service/welcome -> http://localhost:8081/user-service/welcome 

그러나, 만든 UserService의 welcome page 경로는 http://localhost:8081/welcome으로 되어 있다. (위 controller 코드 참고)

 

그렇기 때문에 404에러를 마주하게 된다. 이를 해결하기 위해 UserService의 Controller를 수정하자.

package com.example.tistoryuserservice.controller;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/user-service")
public class StatusController {

    @GetMapping("/welcome")
    public String welcomePage() {
        return "Welcome ! This is User Service.";
    }
}

StatusController 클래스의 @RequestMapping value를 "/user-service"로 명시했다. 이 클래스에 대한 context path를 지정한것과도 같다. 이렇게 설정한 후 다시 서버를 실행시켜서 확인해보면 정상 응답을 돌려받는다.

 

 

마무리

간략하게 Gateway Service를 구현해봤고 앞으로 더 많은 작업을 할 예정이다. 본 포스팅은 여기서 마무리.

728x90
반응형
LIST

'MSA' 카테고리의 다른 글

[MSA] Part 7. API Gateway를 Eureka에 등록하기  (0) 2023.10.10
[MSA] Part 6. Gateway Filter  (2) 2023.10.10
[MSA] Part 4. Service 등록 (User)  (0) 2023.10.06
[MSA] Part 3. Spring Cloud Netflix Eureka  (0) 2023.10.06
[MSA] Part 2. Spring Cloud란?  (0) 2023.10.06
728x90
반응형
SMALL
반응형
SMALL

이제 Eureka Server를 만들었으니 하나씩 비즈니스 서비스를 만들어보자. 그 중 첫 번째는 User Service.

똑같이 IntelliJ로 Spring Initializr를 사용하자.

 

User Service 생성

 

위처럼 설정했고 Next

 

Dependencies 설정하는 부분이 Eureka보다 더 많은데 하나씩 설정해보자.  

필요한 Dependencies

  • Spring Boot DevTools
  • Lombok
  • Spring Web
  • Eureka Discovery Client

위처럼 다 체크했으면 Create해서 프로젝트를 생성

 

 

pom.xml

역시 마찬가지로 가장 먼저 확인할 파일은 pom.xml파일이다.

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.1.3</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>
    <groupId>com.example</groupId>
    <artifactId>user-service</artifactId>
    <version>0.0.1</version>
    <name>user-service</name>
    <description>user-service</description>
    <properties>
        <java.version>17</java.version>
        <spring-cloud.version>2022.0.4</spring-cloud.version>
    </properties>
    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-devtools</artifactId>
            <scope>runtime</scope>
            <optional>true</optional>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>
    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>org.springframework.cloud</groupId>
                <artifactId>spring-cloud-dependencies</artifactId>
                <version>${spring-cloud.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <configuration>
                    <excludes>
                        <exclude>
                            <groupId>org.projectlombok</groupId>
                            <artifactId>lombok</artifactId>
                        </exclude>
                    </excludes>
                </configuration>
            </plugin>
        </plugins>
    </build>

</project>

추가한 dependencies들을 모두 확인했으면 메인 클래스로 넘어가자.

 

 

UserServiceApplication

package com.example.userservice;

import feign.Logger;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.client.discovery.EnableDiscoveryClient;

@SpringBootApplication
@EnableDiscoveryClient
public class UserServiceApplication {

    public static void main(String[] args) {
        SpringApplication.run(UserServiceApplication.class, args);
    }
}

이 파일에서 추가할 내용은 @EnableDiscoveryClient 어노테이션이다. 즉, 이 서버를 Service discovery의 Client로 등록하겠다는 어노테이션.

 

 

application.yml

마찬가지로 application.properties파일을 application.yml로 바꾸고 시작하자. 추가할 내용은 다음과 같다.

server:
  port: 9001

spring:
  application:
    name: user-service

eureka:
  client:
    register-with-eureka: true
    fetch-registry: true
    service-url:
      defaultZone: http://127.0.0.1:8761/eureka

이 UserService의 application.yml 파일은 많은 업데이트가 있을 예정이다. 일단 최초 시작은 이렇게 시작한다.

역시나 server.port, spring.application.name을 설정해준다.

 

eureka.client.register-with-eureka: true로 설정하면 eureka에 서비스로 등록될 서버라는 의미다.

eureka.client.fetch-registry:true로 설정하면 eureka server로부터 갱신되는 서비스 정보들을 지속적으로 받겠다는 의미이다.

eureka.client.service-url.defaultZone: http://127.0.0.1:8761/eureka eureka server 정보를 기입하는 부분이다.

 

 

UserService 실행

이제 User Service를 실행해보자. 다음과 같은 로그가 출력되면 된다.

그리고 이렇게 정상 실행이 됐으면 Eureka server를 열어보자. User Service가 등록된 모습을 확인할 수 있다.

해당 라인에서 우측 Status 칼럼에 보면 UP(1)이라고 보인다. 한 개의 인스턴스가 띄워져 있는 상태란 의미이다. 한번 여러개를 띄워보자. 같은 서비스라 할지라도 포트를 나누어 더 많은 인스턴스를 띄울 수 있다. 그리고 이렇게 여러개의 인스턴스를 띄워서 부하 분산 처리가 가능해진다. 

 

 

Start Multiple Instance

기본적으로 IntelliJ에서 상단 아이콘바에 실행버튼을 클릭하면 서버가 실행되는데 이 외 여러 방법으로 실행이 가능하다. 그리고 그 방법을 통해 여러개를 띄워보자.

 

첫번째는 Run/Debug Configurations이다.

사진처럼 실행할 애플리케이션 선택창을 클릭해서 Edit Configurations를 누르면 아래처럼 Run/Debug Configurations 창이 하나 노출된다. 

위 창에서 빨간색 네모칸으로 표시한 "Copy configurations" 버튼을 누르면 현재 실행하고 있는 애플리케이션 구성과 동일한 또 다른 인스턴스의 애플리케이션 구성을 만들 수 있다. 그렇게 하나 추가하면 다른 인스턴스로 또 하나를 실행할 수 있다. 근데 그대로 복사해서 실행하면 같은 포트를 사용할 거기 때문에 포트 충돌 에러가 발생할것이다. 그래서 포트를 변경해줘야 한다. 다음 사진을 보자.

실행할 때 VM option을 추가해줄 수 있다. VM option에 application.yml 파일에 설정한 server.port값을 위 사진처럼 변경한다.

설정 후 Apply > OK

이렇게 새로운 Configurations이 생겼고 역시 실행 버튼 또한 활성 상태가 된다. 이 인스턴스도 실행해보자.

정상 실행이 되었고 9092 포트로 실행됐다는 로그가 보인다. 이 인스턴스 역시 Eureka Server에 등록될 것인데 한번 Eureka Server를 띄워보자. Status를 보면 UP(2) 라는 표시가 보인다.

2개의 유저 서비스가 띄워져있음을 그리고 그 서비스가 모두 같은 Eureka Server에 등록되어 있음을 확인할 수 있다.  

 

 

두 번째는 Maven을 이용하는 방법이다.

pom.xml파일이 있는 경로에서 다음 명령어를 입력한다.

mvn spring-boot:run -Dspring-boot.run.jvmArguments='-Dserver.port=9003'

마찬가지로 정상적으로 실행된 로그가 찍힌다.

역시 Eureka Server에도 등록된 모습까지 확인할 수 있다.

 

 

세 번째는 패키징한 Jar파일을 실행한다.

이 또한 pom.xml파일이 있는 경로에서 다음 명령어를 입력한다.

mvn clean compile package -DskipTests=true

Maven으로 빌드 후 패키징하는 명령어인데 clean은 기존에 패키징했던 것들을 전부 말끔히 지우고, compile은 빌드를 한다. package는 말 그대로 패키징을 하는 것이고 -DskipTests=true는 프로젝트 내 테스트 파일이 있을 때 테스트를 스킵한다는 의미이다.

 

그럼 프로젝트 루트 경로에 target이라는 폴더가 생기고 해당 폴더 안에 .jar파일이 생긴다.

.jar파일의 이름은 pom.xml파일에서 설정한 값으로 그대로 만들어지는데, 앞 부분은 <name></name>안에 설정된 값이고 뒷 부분은 <version></version>안에 설정된 값이다. 

 

이 jar파일을 실행하면 된다.

java -jar -Dserver.port=9004 ./target/tistory-user-service-0.0.1-SNAPSHOT.jar

마찬가지로 서비스는 정상적으로 띄워진다.

 

 

⭐️ Random port generated

위 작업들은 모두 다 너무 귀찮다. 일일이 포트번호를 변경해주는 것은 개발자 경험을 떨어뜨린다. 그래서 어떤 포트던 상관없이 임의로 포트를 할당해준다. 

application.yml 파일에서 server.port 값을 0 으로 설정하면 사용하고 있지 않은 포트를 랜덤으로 할당한다.

server:
  port: 0
spring:
  application:
    name: user-service

eureka:
  client:
    register-with-eureka: true
    fetch-registry: true
    service-url:
      defaultZone: http://127.0.0.1:8761/eureka

이렇게 설정을 변경하고 실행해보자. 잡힌 포트번호를 출력해주는 로그를 잘 봐야한다.

실행해보면 포트를 49318이라는 포트로 잡은 것을 확인할 수 있다. Eureka Server에서도 확인해보자.

여기서 0번 포트라고 표시되어 있다. 실제로 포트를 0번으로 잡은건 아니고 우리가 설정한 0이라는 값이 그대로 출력된 모습인데 이 링크를 실제 클릭해보면 같은 49318로 연결됨을 확인할 수 있다.

 

 

하나 더 띄워보자. 터미널에서 실행한 방법 그대로 실행해보는데 이번엔 포트를 명시하지 않고 실행해보자.

mvn spring-boot:run

역시나 임의의 포트로 자동 할당된 모습이다. 이렇게 일일이 포트를 직접 명시하는 게 아닌 랜덤 포트를 할당받는 방법으로 인스턴스를 여러개 기동시킬 수 있다. 

 

근데 이대로는 문제가 있다. 어떤 문제냐면 Eureka Server를 다시 보면 분명 인스턴스를 두 개 띄웠지만 하나만 보여진다.

이는 왜일까? Eureka Server에서 서비스를 등록할 때 서비스를 표시하는 방법에서 원인이 있다.

서비스를 등록할 때 서비스 표현 방법을 59.29.234.174:user-service:0 이렇게 표현 하는데 이는 서비스가 띄워진 IP:서비스의 이름:서비스의 포트이다. 서비스의 이름과 서비스의 포트는 application.yml파일에서 설정한 spring.application.name값과 server.port값인데 이 두개의 차이가 인스턴스별 존재하지 않기 때문에 아무리 많이 몇 개를 띄우더라도 Eureka Server는 하나만을 표시할 것이다. 

 

이를 수정하기 위해, eureka.instance.instance-id 값을 부여해야한다.

server:
  port: 0
spring:
  application:
    name: user-service

eureka:
  instance:
    instance-id: ${spring.cloud.client.hostname}:${spring.application.instance_id:${random.value}}
  client:
    register-with-eureka: true
    fetch-registry: true
    service-url:
      defaultZone: http://127.0.0.1:8761/eureka

이렇게 application.yml파일을 수정 후 다시 인스턴스 두 개를 실행한 뒤 Eureka Server를 다시 확인해보자.

이제는 서버의 IP뒤에 알수없는 랜덤값이 표시된 것을 확인할 수 있고 띄운 인스턴스 개수만큼 표시됨을 확인할 수 있다. 이렇게 여러개의 인스턴스를 띄우고 같은 Eureka Server에 등록하는 방법을 알아봤다. 이렇게 여러개의 인스턴스를 띄워서 서비스를 운영하면 유저가 요청을 했을 때 해당 요청을 처리할 수 있는 인스턴스들 중 남는(놀고있는) 인스턴스를 찾아 그 인스턴스에게 요청을 할당하는 Load Balancing기술을 사용할 수 있게 된다.

 

 

User Service 내 API 및 비즈니스 로직을 구현하기 전 API Cloud Gateway를 구현해보자.

728x90
반응형
LIST

'MSA' 카테고리의 다른 글

[MSA] Part 6. Gateway Filter  (2) 2023.10.10
[MSA] Part 5. API Gateway  (0) 2023.10.06
[MSA] Part 3. Spring Cloud Netflix Eureka  (0) 2023.10.06
[MSA] Part 2. Spring Cloud란?  (0) 2023.10.06
[MSA] Part 1. Spring Microservices Architecture  (0) 2023.10.05
728x90
반응형
SMALL
반응형
SMALL

Spring Cloud Netflix Eureka는 Service discovery tool을 말한다. Service discovery는 분산 시스템에서 각 인스턴스(서비스)들을 등록하고 관리해 주는데 관리해 준다는 건 외부에서 요청이 들어올 때 그 요청을 처리할 수 있는 인스턴스(서비스)가 어떤 서비스인지를 찾아주는 것을 포함한다. 

위 그림에서 Netflix Eureka는 API Gateway 바로 다음 단계에 존재하는데, 서비스에 외부 요청이 들어오면 API Gateway는 요청을 받아 해당 요청을 처리할 수 있는 서비스를 찾기 위해 Eureka에게 물어본다. Eureka는 해당 요청을 처리할 수 있는 서비스가 본인한테 등록된 게 있는지 확인 후 있다면 해당 서비스에게 요청을 전달한다. 이렇게 각 서비스들을 관리하고 등록하는 작업을 하는 게 Service discovery고 Spring에서는 Netflix Eureka를 사용할 수 있다. 

 

위 그림에서 각 서비스는 각기 다른 서버에서 구현될 수도 있고 같은 서버내에 포트번호를 다르게 설정하여 동시에 띄울 수 있다. 그에 따라 각 서비스별 호출 URL이 달라질 수 있음을 그림에서 표현한다. 이제 이 Service discovery를 직접 구현해 보자.

 

 

Spring Eureka Server 생성

IntelliJ IDEA를 이용해서 프로젝트를 생성할 거다. 우선 New Project로 프로젝트를 만들기 시작하면 좌측 Generators 섹션에 Spring Initializr가 보인다.

여기서 나는 다음과 같이 설정을 했다.

Name, Location은 원하는 대로 설정하면 되고 Language는 Java를 Type은 Maven을 설정했다.

 

Group은 회사라면 회사 도메인을 거꾸로 쓰는 게 일반적이다. 여기서는 그냥 com.example로 설정했다.

Artifact는 애플리케이션 이름을 작성하면 되며

Package name은 Group.Artifact를 이어 붙여서 설정한다. 

JDK는 20으로 설정했고 Java 버전은 17로 설정했다.

Packaging은 Jar를 선택하면 된다.

 

Next를 누르면 Spring Boot 버전과 Dependencies를 설정할 수 있다.

Spring Boot는 3.1.4 버전을 선택했고 좌측 Dependencies에서 Spring Cloud Discovery > Eureka Server를 선택한다. 

선택하면 우측 Added dependencies 항목에 선택한 dependencies들이 추가되는 것을 확인할 수 있다. 

 

Create 누르면 프로젝트가 생성된다.

 

pom.xml

프로젝트가 생성되면 가장 먼저 확인할 것은 pom.xml 파일이다. 내가 선택한 Eureka dependency가 잘 추가되었는지, 다른 설정에 이상은 없는지 확인해 보자. 

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.1.3</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>
    <groupId>com.example</groupId>
    <artifactId>discovery-service</artifactId>
    <version>0.0.1</version>
    <name>discovery-service</name>
    <description>discovery-service</description>
    <properties>
        <java.version>17</java.version>
        <spring-cloud.version>2022.0.4</spring-cloud.version>
    </properties>
    <dependencies>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-netflix-eureka-server</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>

    </dependencies>
    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>org.springframework.cloud</groupId>
                <artifactId>spring-cloud-dependencies</artifactId>
                <version>${spring-cloud.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>

</project>

spring-cloud-starter-netflix-eureka-server가 dependency로 잘 등록되어 있는 것을 확인할 수 있으며 아래쪽 spring-cloud-dependencies로 version이 ${spring-cloud.version}으로 명시된 것을 확인할 수 있는데 이는 위에 properties안에 <spring-cloud.version>2022.0.4<spring-cloud.version>로 세팅된 값을 가져온다.

 

문제없이 잘 등록된 것 같다.

 

@SpringBootApplication

다음으로 확인할 것은 현재 상태에서 유일하게 생성되어 있는 .java 파일이다. 이 파일에서 main()이 있고 스프링은 최초의 시작점을 이 파일로 시작하는데 그때 필요한 Annotation이 @SpringBootApplication이다. 이 어노테이션이 있는 파일을 Spring Boot가 찾아서 최초의 시작을 한다. 

package com.example.discoveryservice;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class DiscoveryServiceApplication {

    public static void main(String[] args) {
        SpringApplication.run(DiscoveryServiceApplication.class, args);
    }

}

 

이 서버는 Eureka Server로 만들 거니까 위 클래스에 @EnableEurekaServer 어노테이션을 붙여주자.

package com.example.discoveryservice;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.netflix.eureka.server.EnableEurekaServer;

@SpringBootApplication
@EnableEurekaServer
public class DiscoveryServiceApplication {

    public static void main(String[] args) {
        SpringApplication.run(DiscoveryServiceApplication.class, args);
    }

}

이 밖에 추가적으로 해줄 일은 없다.

 

 

application.yml

src/main/resources경로에 보면 기본으로 application.properties 파일이 있을 건데. properties를. yml파일로 변경해서 사용할 거다.

둘 중 아무거나 사용해도 상관없다만. yml파일이 나는 더 좋다.

 

파일이름을 변경했으면 해당 파일에 설정값을 추가해야 한다.

server:
  port: 8761

spring:
  application:
    name: discoveryservice

eureka:
  client:
    register-with-eureka: false
    fetch-registry: false

server.port는 8761로 spring.application.name은 discoveryservice로 설정했다. 일반적인 설정 내용이고 중요한 부분은 eureka항목이다. 두 가지 설정을 해줬다. 

eureka.client.register-with-eureka: false

eureka.client.fetch-registry: false

Eureka server로 기동 할 서버인데 왜 client값을 설정해야 하는가에 대한 의문이 생기는데 이 내용은 spring boot가 기본적으로 eureka server를 띄우면 본인도 eureka에 서비스로 등록이 된다. 그러나 본인은 서버이기 때문에 eureka에서 서비스로 등록할 필요가 없기 때문에 본인은 client로 등록하지 않을 것을 명시하는 설정값이라고 생각하면 된다.

 

 

Started Eureka Server

이제 필수적으로 수행할 설정을 다 끝냈으니 서버를 시작해 보자. 서버를 시작하면 하단에 Console창에 아래 같은 로그가 출력되어야 한다.

2023-10-06T10:58:50.210+09:00  INFO 4334 --- [           main] o.s.b.w.embedded.tomcat.TomcatWebServer  : Tomcat started on port(s): 8761 (http) with context path ''
2023-10-06T10:58:50.212+09:00  INFO 4334 --- [           main] .s.c.n.e.s.EurekaAutoServiceRegistration : Updating port to 8761
2023-10-06T10:58:50.214+09:00  INFO 4334 --- [       Thread-9] e.s.EurekaServerInitializerConfiguration : Started Eureka Server
2023-10-06T10:58:50.244+09:00  INFO 4334 --- [           main] c.e.d.DiscoveryServiceApplication        : Started DiscoveryServiceApplication in 4.949 seconds (process running for 6.551)

서버를 띄웠으니 웹 브라우저에서 http://localhost:8761을 입력해 진입해 보면 다음과 같이 Eureka server가 띄워진다.

 

728x90
반응형
LIST

'MSA' 카테고리의 다른 글

[MSA] Part 6. Gateway Filter  (2) 2023.10.10
[MSA] Part 5. API Gateway  (0) 2023.10.06
[MSA] Part 4. Service 등록 (User)  (0) 2023.10.06
[MSA] Part 2. Spring Cloud란?  (0) 2023.10.06
[MSA] Part 1. Spring Microservices Architecture  (0) 2023.10.05

+ Recent posts