이번에는 Service Registry에 등록할 Client 서버를 개발해보려고 한다.
User Service에서 회원가입후 로그인 API에 요청을 보내면 비즈니스 로직중 Auth 서비스에 토큰 생성을 요청하는 로직이 수행되어 두 클라이언트 서버간 통신이 이루어지는 작업을 함께 알아보려고 한다. 이때 사용하는 FeignClient에 대해서도 같이 알아보자.
우선 프로젝트를 모두 생성하고나서 비즈니스 로직과 함께 FeignClient에 대해 알아보겠다.
Dependency 설정
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-netflix-eureka-client'
implementation 'org.springframework.cloud:spring-cloud-starter-openfeign'
compileOnly 'org.projectlombok:lombok'
runtimeOnly 'com.mysql:mysql-connector-j'
implementation 'org.springframework.boot:spring-boot-starter-validation'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
implementation 'org.springframework.boot:spring-boot-starter-actuator'
}
아직 Security 설정에 대한 고민이 남아있어 Security는 추가하지 않았다. 두 클라이언트 서비스 통신을 위한 openfeign과 클라이언트 서버로 지정하기 위해 netflix-eureka-client 의존성을 추가했다.
yml 설정
spring:
application:
name: user-service
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/[database이름]
username: [username]
password: [password]
jpa:
hibernate:
ddl-auto: update
show-sql: true
eureka:
instance:
prefer-ip-address: true
client:
service-url:
defaultZone: http://localhost:19090/eureka/
register-with-eureka: true
fetch-registry: true
server:
port: 19091
- eureka.instance.prefer-ip-address : IP주소를 Eureka Server에 등록할지 여부
- 기본값은 false, 해당 옵션을 true로 설정하지 않으면 각 서비스들이 호스트 이름으로 등록된다.
- 서비스가 실제 배포 환경이라면 호스트 이름을 할당받고 정상적으로 작동되므로 false로 해줘도 무방하다
- 컨테이너 기반(Docker) 배포 환경이라면 컨테이너가 임의의 호스트 이름이 부여되기 때문에 false로 한 경우 정상적으로 호스트 위치를 얻지 못하기도 한다.
- eureka.client.service-url.defaultZone : 통신할 서버의 주소를 설정해준다. 이전에도 말했듯이 꼭 카멜케이스로 적을것!
- eureka.client.register-with-eureka : 자기 자신을 등록할지 여부로 true로 설정해준다.
- eureka.client.fetch-registry : 레지스트리에 서비스를 등록해야 하기에 true로 설정해준다.
- spring.application.name : Eureka환경에서는 고유 ID로 통신하기 때문에 지정해준다.
Application에 FeignClients 적용
@SpringBootApplication
@EnableJpaAuditing
@EnableFeignClients
public class MovieReservationUserApplication {
public static void main(String[] args) {
SpringApplication.run(MovieReservationUserApplication.class, args);
}
}
이전에도 말했듯이 스프링부트의 강력한 기능인 PSA기능을 활용해서 어노테이션 @FeignClients를 적용해준다.
Entity 생성
회원가입을 하기 위한 엔티티를 생성해준다. 실습을 위한 프로젝트로 별도 refreshToken을 저장하지 않고 User 테이블에 저장해준다.
비밀번호 암호화는 BCryptPasswordEncoder를 사용해줬다.
import java.time.LocalDateTime;
@Entity
@Builder
@Getter
@AllArgsConstructor
@NoArgsConstructor
@EntityListeners(AuditingEntityListener.class)
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "user_id", nullable = false)
private Long id;
@Column(nullable = false, unique = true)
private String email;
@Column(nullable = false)
private String password;
@Column(nullable = false)
private String userName;
private String gender;
private String phoneNumber;
@Column(nullable = false)
private String birthday;
private UserRole role;
private Boolean isDeleted = Boolean.FALSE;
private String refreshToken;
@CreatedDate
private LocalDateTime createdAt;
@LastModifiedDate
private LocalDateTime updatedAt;
public void updateRefreshToken(String refreshToken) {
this.refreshToken = refreshToken;
}
public static User fromSignUpRequest(UserSignUpRequest userSignUpRequest) {
PasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
return User.builder()
.email(userSignUpRequest.getEmail())
.password(passwordEncoder.encode(userSignUpRequest.getPassword()))
.userName(userSignUpRequest.getUserName())
.birthday(userSignUpRequest.getBirthday())
.role(userSignUpRequest.getRole())
.phoneNumber(userSignUpRequest.getPhoneNumber())
.gender(userSignUpRequest.getGender())
.build();
}
public static UserResponse toResponse(User user) {
return UserResponse.builder()
.userId(user.getId())
.email(user.getEmail())
.userName(user.getUserName())
.birthday(user.getBirthday())
.phoneNumber(user.getPhoneNumber())
.gender(user.getGender())
.role(user.getRole())
.build();
}
}
Controller
@RestController
@Slf4j
@RequiredArgsConstructor
@RequestMapping("/users")
public class UserController {
private final UserService userService;
@PostMapping("/signUp")
public ResponseEntity<Void> signUp(
@RequestBody UserSignUpRequest userSignUpRequest
){
userService.signUp(userSignUpRequest);
return ResponseEntity.ok().build();
}
//로그인 후 토큰을 발급해서 반환해줘야 함 auth 서버 필요
@PostMapping("/signIn")
public ResponseEntity<Void> signIn(
@RequestBody UserSignInRequest userSignInRequest
) {
TokenResponse token = userService.signIn(userSignInRequest);
return ResponseEntity.ok()
.header(HttpHeaders.AUTHORIZATION, "Bearer " + token.getAccessToken())
.header("Refresh-Token", token.getRefreshToken())
.build();
}
@PutMapping("/logout")
public ResponseEntity<Void> logout(
@RequestHeader("X-User-Id") String userId
){
userService.logout(Long.valueOf(userId));
return ResponseEntity.ok().build();
}
}
회원가입을 위한 signUp, 로그인을 위한 signIn, 로그아웃을 위한 logout 메서드가 존재한다.
회원가입은 다른 서비스와 별도 통신없이 회원가입 데이터를 받아 DB에 저장하는 역할.
로그인은 Auth 서비스 서버와 통신하여 토큰을 발급받고 토큰값을 헤더에 담아 반환하는 역할.
로그아웃은 user의 refreshToken을 삭제하여 로그아웃 처리를 해주는 역할이다.
이후에는 refreshToken을 쿠키에 저장하고 삭제하는 방식으로 수정할 계획이다.
그럼 비즈니스 로직을 살펴보자.
UserService
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
private final AuthClient authClient;
//회원가입
@Transactional
public void signUp(UserSignUpRequest userSignUpRequest) {
userRepository.save(User.fromSignUpRequest(userSignUpRequest));
}
//로그인
@Transactional
public TokenResponse signIn(UserSignInRequest userSignInRequest) {
PasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
TokenResponse token;
//회원 조회
User user = userRepository.findByEmail(userSignInRequest.getEmail()).orElseThrow(
() -> new IllegalArgumentException("아이디 또는 비밀번호를 다시 확인해주세요")
);
//회원 존재할경우 비밀번호 매칭
if(passwordEncoder.matches(userSignInRequest.getPassword(), user.getPassword())) {
UserResponse response = User.toResponse(user);
token = authClient.getToken(response);// auth 서비스 서버와 통신
}else{
throw new IllegalArgumentException("비밀번호가 일치하지 않습니다.");
}
user.updateRefreshToken(token.getRefreshToken()); //리프레쉬 토큰 유저 엔티티에 저장
return token;
}
@Transactional
public void logout(Long userId) {
User user = userRepository.findById(userId).orElseThrow(
() -> new IllegalArgumentException("회원을 찾을 수 없습니다.")
);
user.updateRefreshToken(null);
}
}
복잡한 로직은 존재하지 않고 여기서 눈여겨봐야 할 부분은
token = authClient.getToken(response);// auth 서비스 서버와 통신
이 부분이다. 먼저 authClient 코드를 살펴보자.
authClient
@FeignClient(name = "auth-service")
public interface AuthClient {
@PostMapping("/auth/token")
TokenResponse getToken(@RequestBody UserResponse userRequest);
@GetMapping("/auth/refresh-token")
String renewToken(@RequestHeader("refreshToken") String refreshToken);
}
@FeignClient를 적용하고 통신할 서버의 이름을 지정해주면 Eureka서버에 해당 이름으로 질의를 날려 주소를 얻고 요청을 보낼 수 있다. 이후에는 Gateway가 관여를 하겠지만 이 부분은 차후에 추가된 게이트웨이와 함께 설명하겠다.
이때 주의해야 할 점은 Mapping 주소, 서버 이름, 요청시 보내는 데이터들의 일관성이다. 두 번 확인해서 통신중 실수한 부분이 없나 체크하자! 안그러면 또 삽질을 시작하게 된다....
위에서 사용한 FeignClient에 대해 알아보도록 하자
이 어노테이션은 어떻게 다른 서비스 서버와 통신할 수 있도록 해주는 걸까?
FeignClient
FeignClient는 REST Call을 추상화한 Spring Cloud Netflix 라이브러리다. Feign Client를 사용하면 HTTP Client를 직접 구현하지 않아도 REST API를 호출할 수 있다.
FeignClient와 동일한 기술인 RestTemplate이 있지만 FeignClient가 보다 직관적이고 코드가 간결하다는 장점이 있다. 또한 Spring Cloud의 다른 기능들과도 호환이 되기 때문에 서비스 디스커버리, 로드 밸런싱 등의 기능을 사용하면서 FeignClient를 활용할 수 있다.
특히 MSA에서는 FeignClient에는 Ribbon
과 통합되어 있고, CircuitBraker
를 사용해서 상태 확인 및 장애 시 대처가 가능하니 더 많은 장점이 있다고 생각한다. 하지만 단점으로는 RestTemplate보다 많은 설정 및 사용방법이 있기에 복잡성이 증가하고 직접적으로 HTTP 통신 제어가 어렵다. 이 기술에 대해서는 별도로 포스팅해서 공식 문서를 함께 참조해서 정리할 예정이다!
결과 확인하기
User 서비스 서버와 Auth 서비스 서버가 통신하는 SignIn API를 실행시켜보겠다.
실행 후 로그를 살펴보자 정상적으로 User에서 요청이 들어왔고 로직 중간에 auth Server에 요청해 데이터를 받아 출력하는 것을 볼 수 있다.
2025-02-08T00:55:49.008+09:00 INFO 1332 --- [auth-service] [io-19092-exec-1] c.s.c.m.AuthController : userResponse: UserResponse(userId=3, email=admin@test.com, userName=leesunro, birthday=1129, phoneNumber=01012341234, gender=MALE, role=ADMIN)
2025-02-08T00:55:49.009+09:00 INFO 1332 --- [auth-service] [io-19092-exec-1] c.s.c.m.AuthService : userRequest.getId =3
User의 refreshToken을 로그인 할 때마다 갱신해주기때문에 update쿼리가 날아간다.
update user set birthday=?,created_at=?,email=?,gender=?,is_deleted=?,password=?,phone_number=?,refresh_token=?,role=?,updated_at=?,user_name=? where user_id=?
로그인 후 성공적으로 토큰을 헤더에 담아 클라이언트에게 넘겨준 것을 알 수 있었다.
'Spring > Spring Cloud' 카테고리의 다른 글
[MSA - Resilienc4j] CircuitBreaker, fallback 메서드 개발하기 (0) | 2025.02.11 |
---|---|
[MSA - Spring Cloud] Spring Cloud Gateway 개발하기 (1) | 2025.02.08 |
[MSA] 멀티 모듈에서 중복되는 코드를 서브모듈끼리 공유하는 방법 (0) | 2025.02.08 |
[MSA - Spring Cloud] Eureka Server 개발하기(feat. Service Discovery란? 서버/클라이언트 사이드 디스커버리 전략) (0) | 2025.02.07 |
[MSA] 모노 레포와 멀티 레포 전략 (0) | 2025.02.07 |