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:9411 로 접근하면,
ZIPKIN을 통해 등록된 서비스의 구조 등 전반적인 부분을 확인할 수 있습니다.
http://localhost:8761 로 접근하면,
Eureka에 등록된 서비스를 확인할 수 있습니다.
'Java' 카테고리의 다른 글
[TDD] Test Driven Development (0) | 2024.05.23 |
---|---|
[Spring AOP] 스프링 관점 지향 프로그래밍(Spring Aspect Oriented Programming) (0) | 2024.05.23 |
[JPA(Jakarta Persistence API)] JPQL 문법(경로 표현식, 페치 조인, 다형성 쿼리, 엔티티 직접 사용, Named 쿼리, 벌크연산) (0) | 2024.03.15 |
[JPA(Jakarta Persistence API)] JPQL 문법(SELECT, PAGING, JOIN, 서브쿼리, 조건식, 함수) (0) | 2024.03.14 |
[JPA(Jakarta Persistence API)] 다양한 쿼리 방법과 예시 (0) | 2024.03.14 |