Java

[Microservices] SpringBoot 3, Eureka, MSA 데모 프로젝트

ride-dev 2024. 5. 1. 14:29

0. MSA란?

마이크로 서비스 아키텍처, MSA는 애플리케이션을 구성하는 소프트웨어 개발 접근 방식입니다.

MSA는 느슨하게 결합도를 가졌습니다.

MSA는 독립적으로 배포 가능한 서비스의 모음으로 구성됩니다.

MSA의 각 서비스는 특정 기능을 수행하도록 되어있습니다.

정의된 API를 통해 다른 서비스와 통신합니다.

 

각 서비스별로 개발, 배포할 수 있기 때문에 더 쉽게 업데이트할 수 있습니다.

(더 빠른 릴리즈 주기를 가집니다)

 

현재 게시글에서 구현할 MSA는 아래와 같습니다.

1. 애플리케이션의 gateway가 클라이언트의 요청을 받고

2. Discovery server에 등록된 서비스를 바탕으로 리디렉션합니다.

리디렉션은 HTTP 통신을 기반으로 진행합니다.

Config server에서 각 애플리케이션의 구성 관리를 합니다.

각 서비스(Students, Schools)는 단일 DB를 보유합니다.

각 서비스 간 통신은 HTTP Client를 통해 이루어집니다.

d3. ZIPKIN에서 gateway, 각 서비스(students, schools)의 모든 요청을 추적합니다.

1. 프로젝트 생성 및 구성 관리 파일 작성

(IDE는 인텔리제이, 빌드도구는 gradle을 사용했습니다)

먼저, 상위 프로젝트를 생성합니다.

이후 생성할 프로젝트는 하나로 묶어줍니다.

최상단 프로젝트에 하위 프로젝트를 넣고,

최상단 프로젝트의 settings.gradle에 프로젝트 명을 선언합니다.

rootProject.name = 'micro-services'
include 'config-server'
include 'discovery'
include 'gateway'
include 'school'
include 'student'

아래와 같이 표기된 것을 확인할 수 있습니다.

Eureka Server (Discovery Server)

Eureka Server를 생성합니다.

서비스 등록을 담당합니다.

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-actuator'
    implementation 'org.springframework.cloud:spring-cloud-starter-config'
    implementation 'org.springframework.cloud:spring-cloud-starter-netflix-eureka-server'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
}

@EnableEurekaServer 어노테이션을 작성하여 Eureka Server임을 선언합니다.

(의존성이 설치되어 있지 않거나, 빌드되지 않으면 동작하지 않습니다)

package com.ride.discovery;

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

@EnableEurekaServer
@SpringBootApplication
public class DiscoveryApplication {

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

}

src/resources 에 application.yml을 작성합니다.

spring:
  application:
    name: discovery
  config:
    import: optional:configserver:http://localhost:8888

(config server에서 구성관리를 할 예정이므로)

(discovery의 application.yml에  eureka, port 등 아래 코드를 선언하지 않습니다)

#eureka:
#  instance:
#    hostname: localhost
#  client:
#    register-with-eureka: false
#    fetch-registry: false
#    service-url:
#      defaultZone: http://${eureka.instance.hostname}:${server.port}/eureka/
#server:
#  port: 8761

Gateway

Gateway를 생성합니다.

Gateway는 Eureka Server에 등록된 서비스로 리디렉션합니다.

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-actuator'
    implementation 'org.springframework.cloud:spring-cloud-starter-config'
    implementation 'org.springframework.cloud:spring-cloud-starter-gateway'
    implementation 'org.springframework.cloud:spring-cloud-starter-netflix-eureka-client'
    implementation 'io.micrometer:micrometer-tracing-bridge-brave'
    implementation 'io.zipkin.reporter2:zipkin-reporter-brave'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
}

application.yml 에 아래 코드를 선언합니다.

spring:
  application:
    name: gateway
  config:
    import: optional:configserver:http://localhost:8888

Services

각 서비스별 프로젝트를 생성합니다.

각 서비스는 eureka server(discovery server)에 등록하고,

게이트웨이가 리디렉션 할 수 있도록 게이트웨이 설정을 합니다.

School, Student 프로젝트를 생성하겠습니다.

School

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-actuator'
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.cloud:spring-cloud-starter-config'
    implementation 'org.springframework.cloud:spring-cloud-starter-netflix-eureka-client'
    implementation 'org.springframework.cloud:spring-cloud-starter-openfeign'
    implementation 'io.micrometer:micrometer-tracing-bridge-brave'
    implementation 'io.zipkin.reporter2:zipkin-reporter-brave'
    compileOnly 'org.projectlombok:lombok'
    runtimeOnly 'org.postgresql:postgresql'
    annotationProcessor 'org.projectlombok:lombok'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
}

School 서비스에서, school id에 속한 student를 조회하기 위해 Student DB에 접근합니다.

(DB에 직접 접근하는 것이 아니라 Student 서비스의 HTTP 통신을 통해 접근합니다)

이 때 사용하는 의존성이 OpenFeign입니다.

 

(서비스 규모가 작은 데모 프로젝트이기에 OpenFeign(동기 통신)을 사용했습니다)

(규모가 크다면 RestClient나 WebClient(비동기 통신 지원)를 고려합니다)

(이 포스트에선 HTTP Client를 깊게 다루지 않겠습니다)

(RestClient는 Spring 6.1부터 지원하는, 상대적으로 최신 기술로 의존성이 필요하지 않습니다)

(WebClient는 의존성이 필요합니다)

 

@EnableFeignClients 주석을 작성하여 Feign을 사용할 것임을 선언합니다.

package com.ride.school;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.openfeign.EnableFeignClients;

@EnableFeignClients
@SpringBootApplication
public class SchoolApplication {

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

}

 

application.yml을 작성합니다.

spring:
  application:
    name: schools
  config:
    import: optional:configserver:http://localhost:8888

Student

 

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-actuator'
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.cloud:spring-cloud-starter-config'
    implementation 'org.springframework.cloud:spring-cloud-starter-netflix-eureka-client'
    implementation 'io.micrometer:micrometer-tracing-bridge-brave'
    implementation 'io.zipkin.reporter2:zipkin-reporter-brave'
    compileOnly 'org.projectlombok:lombok'
    runtimeOnly 'org.postgresql:postgresql'
    annotationProcessor 'org.projectlombok:lombok'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
}

 

application.yml을 작성합니다.

spring:
  application:
    name: students
  config:
    import: optional:configserver:http://localhost:8888

Config Server

Config Server는 각 애플리케이션(프로젝트)의 구성 관리를 담당합니다.

dependencies {
    implementation 'org.springframework.cloud:spring-cloud-config-server'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
}

@EnableConfigServer 주석을 작성하여 Config Server임을 선언합니다.

package com.ride.configserver;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.config.server.EnableConfigServer;

@EnableConfigServer
@SpringBootApplication
public class ConfigServerApplication {

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

}

application.yml을 작성합니다.

server:
  port: 8888
spring:
  profiles:
    active: native
  application:
    name: config-server
  cloud:
    config:
      server:
        native:
          search-locations: classpath:/configurations

 

이제, 각 애플리케이션 별 프로파일을 작성합니다.

yml 파일 명은 각 애플리케이션 내부의 application.yml파일에 작성한 application.name과 일치해야 합니다.

#spring.application.name=discovery
eureka:
  instance:
    hostname: localhost
  client:
    register-with-eureka: false
    fetch-registry: false
    service-url:
      defaultZone: http://${eureka.instance.hostname}:${server.port}/eureka/
server:
  port: 8761
spring:
  application:
    name: discovery
# spring.application.name=gateway
eureka:
  client:
    register-with-eureka: false
server:
  port: 8222
spring:
  application:
    name: gateway
  cloud:
    gateway:
      discovery:
        locator:
          enabled: true
      routes:
        - id: students
          uri: http://localhost:8090
          predicates:
            - Path=/api/v1/students/**
        - id: schools
          uri: http://localhost:8070
          predicates:
            - Path=/api/v1/schools/**

management:
  tracing:
    sampling:
      probability: 1.0
# spring.application.name=schools
eureka:
  instance:
    hostname: localhost
  client:
    service-url:
      defaltZone: http://localhost:8761/eureka

server:
  port: 8070

spring:
  application:
    name: schools
  datasource:
    driver-class-name: org.postgresql.Driver
    url: jdbc:postgresql://localhost:5432/schools
    hikari:
      username: username
      password: password
  jpa:
    hibernate:
      ddl-auto: update
    database: postgresql
    database-platform: org.hibernate.dialect.PostgreSQLDialect

application:
  config:
    students-url: http://localhost:8222/api/v1/students

management:
  tracing:
    sampling:
      probability: 1.0
# spring.application.name=student
eureka:
  instance:
    hostname: localhost
  client:
    service-url:
      defaltZone: http://localhost:8761/eureka

server:
  port: 8090

spring:
  application:
    name: students
  datasource:
    driver-class-name: org.postgresql.Driver
    url: jdbc:postgresql://localhost:5432/students
    hikari:
      username: username
      password: password
  jpa:
    hibernate:
      ddl-auto: update
    database: postgresql
    database-platform: org.hibernate.dialect.PostgreSQLDialect
    
management:
  tracing:
    sampling:
      probability: 1.0
 
 

각 서비스는 동일한 postgreSQL 내부의 다른 스키마를 사용하도록 했습니다.

2. 각 서비스 별 로직

이제 Student와 School의 코드를 작성하겠습니다.

Student

package com.ride.student;

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
import lombok.*;

@Entity
@Getter @Setter
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class Student {

    @Id @GeneratedValue
    private Integer id;
    private String firstname;
    private String lastname;
    private String email;
    private Integer schoolId;
}
package com.ride.student;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

import java.util.List;

@Repository
public interface StudentRepository extends JpaRepository<Student, Integer> {
    List<Student> findAllBySchoolId(Integer schoolId);
}
package com.ride.student;

import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

import java.util.List;

@Service
@RequiredArgsConstructor
public class StudentService {
    private final StudentRepository repository;

    public void saveStudent(Student student) {
        repository.save(student);
    }

    public List<Student> findAllStudents() {
        return repository.findAll();
    }

    public List<Student> findAllStudentsBySchoolId(Integer schoolId) {
        return repository.findAllBySchoolId(schoolId);
    }
}
package com.ride.student;

import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.List;

@RestController
@RequestMapping("/api/v1/students")
@RequiredArgsConstructor
public class StudentController {

    private final StudentService service;

    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    public void save(
            @RequestBody Student student
    ) {
        service.saveStudent(student);
    }

    @GetMapping
    public ResponseEntity<List<Student>> findAllStudents() {
        return ResponseEntity.ok(service.findAllStudents());
    }
    @GetMapping("/school/{school-id}")
    public ResponseEntity<List<Student>> findAllStudents(
            @PathVariable("school-id")Integer schoolId
    ) {
        return ResponseEntity.ok(service.findAllStudentsBySchoolId(schoolId));
    }
}

school-id 를 기반으로 student를 조회할 수 있도록 메서드와 엔드포인트를 작성합니다.

School

package com.ride.school;

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
import lombok.*;

@Entity
@Getter @Setter
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class School {

    @Id @GeneratedValue
    private Integer id;
    private String name;
    private String email;
}
package com.ride.school;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

@Repository
public interface SchoolRepository extends JpaRepository<School, Integer> {
}
package com.ride.school;

import com.ride.school.client.StudentClient;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

import java.util.List;

@Service
@RequiredArgsConstructor
public class SchoolService {
    private final SchoolRepository repository;
    private final StudentClient client;

    public void saveSchool(School school) {
        repository.save(school);
    }

    public List<School> findAllSchools() {
        return repository.findAll();
    }

    public FullSchoolResponse findSchoolsWithStudents(Integer schoolId) {
        var school = repository.findById(schoolId)
                .orElse(School.builder()
                        .name("NOT_FOUND")
                        .email("NOT_FOUND")
                        .build());
        var students = client.findAllStudentBySchool(schoolId); //find all the students from the student micro-service
        return FullSchoolResponse.builder()
                .name(school.getName())
                .email(school.getEmail())
                .students(students)
                .build();
    }
}

client객체를 사용하여 Student 애플리케이션과 통신할 수 있도록 합니다.

SchoolService에서 사용하는 findAllStudentBySchool 메서드는 StudentClient interface에 선언했습니다.

package com.ride.school.client;

import com.ride.school.Student;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;

import java.util.List;

@FeignClient(name = "student-service", url = "${application.config.students-url}")
public interface StudentClient {

    @GetMapping("/school/{school-id}")
    List<Student> findAllStudentBySchool(@PathVariable("school-id") Integer schoolId);
}

School에서 사용할 Student class를 생성합니다.

package com.ride.school;

import lombok.*;

@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class Student {

    private String firstname;
    private String lastname;
    private String email;
}

특정 school-id를 가진 student를 반환할 수 있도록 DTO를 생성합니다.

package com.ride.school;

import lombok.*;

import java.util.List;

@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class FullSchoolResponse {
    private String name;
    private String email;
    List<Student> students;
}
package com.ride.school;

import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.List;

@RestController
@RequestMapping("/api/v1/schools")
@RequiredArgsConstructor
public class SchoolController {

    private final SchoolService service;

    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    public void save(
            @RequestBody School school
    ) {
        service.saveSchool(school);
    }

    @GetMapping
    public ResponseEntity<List<School>> findAllStudents() {
        return ResponseEntity.ok(service.findAllSchools());
    }

    @GetMapping("/with-students/{school-id}")
    public ResponseEntity<FullSchoolResponse> findAllStudents(
            @PathVariable("school-id") Integer schoolId
    ) {
        return ResponseEntity.ok(service.findSchoolsWithStudents(schoolId));
    }
}

School, Student Controller에서 사용할 엔드포인트는 config-server의 gateway.yml에 선언하여 사용합니다.

spring:
  application:
    name: gateway
  cloud:
    gateway:
      discovery:
        locator:
          enabled: true
      routes:
        - id: students
          uri: http://localhost:8090
          predicates:
            - Path=/api/v1/students/**
        - id: schools
          uri: http://localhost:8070
          predicates:
            - Path=/api/v1/schools/**

config-server의 schools.yml에서 아래처럼 스크립트를 선언하는 것으로,

School이 Student의 엔드포인트를 사용할 수 있습니다.

application:
  config:
    students-url: http://localhost:8222/api/v1/students

docker-compose

ZIPKIN과 postgreSQL, pgadmin을 관리할 docker-compose.yml파일을 생성합니다.

services:
  postgresql:
    container_name: postgresql
    image: postgres
    environment:
      POSTGRES_USER: username
      POSTGRES_PASSWORD: password
      PGDATA: /data/postgres
    volumes:
      - postgres:/data/postgres
      - ./init.sql:/docker-entrypoint-initdb.d/init.sql
    ports:
      - "5432:5432"
    networks:
      - postgres
    restart: unless-stopped
  pgadmin:
    container_name: pgadmin
    image: dpage/pgadmin4
    environment:
      PGADMIN_DEFAULT_EMAIL: ${PGADMIN_DEFAULT_EMAIL:-pgadmin4@pgadmin.org}
      PGADMIN_DEFAULT_PASSWORD: ${PGADMIN_DEFAULT_PASSWORD:-admin}
      PGADMIN_CONFIG_SERVER_MODE: 'False'
    volumes:
      - pgadmin:/var/lib/pgadmin
    ports:
      - "5050:80"
    networks:
      - postgres
    restart: unless-stopped
  zipkin:
    container_name: zipkin
    image: openzipkin/zipkin
    ports:
      - "9411:9411"
    networks:
      - zipkin

networks:
  postgres:
    driver: bridge
  zipkin:
    driver: bridge

volumes:
  postgres:
  pgadmin:

동일한 경로에 init.sql 파일을 생성하여,

각 서비스에 해당하는 DB를 생성합니다.

-- Create the 'students' database
CREATE DATABASE students;

-- Create the 'schools' database
CREATE DATABASE schools;

-- Connect to 'students' database to set up schema and permissions
\c students

-- Create schema and initial setup if necessary
GRANT ALL PRIVILEGES ON DATABASE students TO username;

-- Connect to 'schools' database to set up schema and permissions
\c schools

-- Create schema and initial setup if necessary
GRANT ALL PRIVILEGES ON DATABASE schools TO username;

 

 

school, student에 대한 각 요청은 gateway에 해당하는 8222를 사용합니다.

 

http://localhost:5050 으로 접근하면,

pgadmin(postgresql)을 사용할 수 있습니다.

http://localhost:8761/ 에서 ip를 확인할 수 있습니다.
ip, username, password를 입력하고 save합니다

 

http://localhost:9411 로 접근하면,

ZIPKIN을 통해 등록된 서비스의 구조 등 전반적인 부분을 확인할 수 있습니다.

http://localhost:8761 로 접근하면,
Eureka에 등록된 서비스를 확인할 수 있습니다.

 

728x90