코틀린과 스프링 부트 3에서 Feign Client 적용하기
📌 서론
코틀린을 적용한 알림 서버에 feign client를 적용하는 방법을 알아보자
현재 프로젝트 기술 스택이다.
- 코틀린 1.9.22 버전
- 스프링 부트 3.2.2 버전
1. build.gradle.kts에 openfeign 의존성 주입
2024년 2월 기준으로 build.gradle.kts에 추가해줘야 할 의존성은 다음과 같다.
extra["springCloudVersion"] = "2023.0.0"
dependencies {
implementation("org.springframework.cloud:spring-cloud-starter-openfeign")
}
dependencyManagement {
imports {
mavenBom("org.springframework.cloud:spring-cloud-dependencies:${property("springCloudVersion")}")
}
}
만약 더 정확하게 알고 싶다면 start.spring.io에서 Dependencies에서 'openFeign'을 검색하고 [EXPLORE] 버튼을 클릭하면 build.gradle에 어떤 디펜던시가 추가되었는지 확인할 수 있다.
2. @EnableFeignClients로 feign client 활성화
Applicatoin.kt에 @EnableFeignClients 어노테이션을 달아도 되지만 나는 코드의 가독성과 유지 보수성을 향상하기 위해 따로 config 클래스로 설정해 줬다. Application 클래스는 애플리케이션의 시작점을 정의하는 데 집중하고, 별도의 설정 클래스는 Feign 클라이언트와 같은 특정 기능의 설정을 담당한다.
FeignClient 활성화
내가 만든 FeignClientConfig는 다음과 같다.
@EnableFeignClients 어노테이션은 스프링 부트의 자동 구성 메커니즘과 함께 동작하여, 지정된 패키지 내의 모든 @FeignClient 어노테이션이 붙은 인터페이스를 찾아 스프링 컨테이너에 등록한다. 이 과정에서 Feign 클라이언트는 스프링의 의존성 주입 기능을 활용하여 자동으로 구성되고, 필요한 다른 빈들과 함께 연결된다.
@EnableFeignClients(basePackages = ["org.recipia.alarm.feign"])
@Configuration
class FeignClientConfig {
}
🔔 알림: basePackages를 작성한 이유
현재 우리 프로젝트의 패키지 트리를 보면 다음과 같다.
기본적으로 @EnableFeignClients 어노테이션은 선언된 위치의 패키지와 그 하위 패키지 내의 @FeignClient 어노테이션이 붙은 인터페이스를 스캔한다. 만약 @FeignClient 어노테이션이 붙은 인터페이스가 다른 패키지 경로에 위치한다면, 이를 자동으로 스캔하지 못해 해당 Feign 클라이언트를 인식할 수 없다.
예를 들어, MemberFeignClient 인터페이스가 org.recipia.alarm.feign 패키지에 위치하고 있고, @EnableFeignClients가 선언된 FeignClientConfig 클래스가 org.recipia.alarm.config 패키지에 있을 때, 두 패키지 경로가 달라 자동 스캔의 기본 범위에 포함되지 않는다.
이러한 문제를 해결하기 위해서는 @EnableFeignClients 어노테이션에 basePackages 속성을 명시하여 Feign 클라이언트 인터페이스가 위치한 패키지를 직접 지정해야 한다. 이는 Spring이 지정된 패키지 경로를 통해 @FeignClient 어노테이션이 붙은 모든 인터페이스를 적절히 스캔하고 인식할 수 있게 만들어, 문제를 해결한다.
3. FeignClient 로깅 활성화 (선택 사항)
Feign Logging Configuration을 작성하는 방법은 아래 링크에 잘 정리되어있다.
https://www.baeldung.com/java-feign-logging
application.yml에 로깅 레벨 설정
logging:
level:
root: info
feign.Logger: debug
org:
recipia:
alarm:
feign: debug
- root (선택): 이는 애플리케이션 전체에 대한 기본 로깅 레벨을 설정한다. 여기서 info로 설정했다면, INFO 레벨 이상의 로그(예: INFO, WARN, ERROR)만 출력된다. 이 설정은 애플리케이션의 모든 부분에 영향을 미치며, 특정 패키지나 클래스에 대한 로깅 레벨이 별도로 지정되지 않은 경우 적용된다.
- feign.Logger: Feign 클라이언트의 내부 로깅 메커니즘에 대한 로깅 레벨을 설정한다. Feign은 자체 로깅 추상화를 제공하며, 이 설정은 Feign 로깅 추상화에 영향을 미친다. 여기서 debug로 설정했다면, Feign 로거를 사용하는 코드에서 DEBUG 레벨의 로그가 출력된다.
- <패키지명>: 이는 특정 패키지 경로 내의 로깅 레벨을 설정한다. 여기서는 @FeignClient 어노테이션이 적용된 인터페이스가 있는 패키지의 경로를 지정하고 있으며, 이 패키지 내에서 발생하는 로그에 대해 debug 레벨을 설정한다. 이 설정은 org.recipia.alarm.feign 패키지 내의 클래스들이 생성하는 로그가 DEBUG 레벨에서 기록되도록 한다. 나는 @FeignClient 인터페이스를 정의한 패키지 경로를 정확히 지정했다.
🔔 알림: 패키지명에 해당하는 부분은 필수로 작성해야 한다!
Feign 클라이언트의 HTTP 요청과 응답에 대한 세부 로깅을 활성화하기 위해서는 두 가지 설정이 필요하다.
- Feign 클라이언트가 정의된 패키지의 로깅 레벨 설정: Feign 클라이언트의 요청과 응답에 대한 세부 로깅을 활성화하려면, 해당 클라이언트가 위치한 패키지의 로깅 레벨을 DEBUG 또는 필요한 로깅 레벨로 설정해야 한다. 이는 Feign 클라이언트의 세부적인 HTTP 통신 정보를 로그로 남기기 위해 필수적이다.
- Feign.Logger 설정 조정: Feign의 내부 로깅 메커니즘을 조정하기 위해, feign.Logger의 레벨을 적절히 설정해야 한다. 이 설정은 Feign 클라이언트의 로깅 동작을 결정하며, 요청과 응답의 세부 사항을 포착하는 데 중요한 역할을 한다.
로깅 설정 파일
FULL 레벨은 요청과 응답의 헤더, 바디, 메타데이터를 포함한 모든 정보를 로그에 기록함으로써 디버깅에 매우 유용하다.
import feign.Logger
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
@Configuration
class LoggingConfig {
@Bean
fun feignLoggerLevel(): Logger.Level {
return Logger.Level.FULL
}
}
4. @FeignClint 인터페이스 생성
이제 실제로 feign client를 사용할 인터페이스를 작성해 보자.
feign client로 요청해서 응답받을 객체 생성
우리는 멤버 서버에 memberId에 해당하는 닉네임을 반환받아야 한다. 이때 멤버 서버는 요청된 memberId에 매칭되는 닉네임을 포함하는 응답 객체(DTO)를 반환한다.
data class NicknameDto(
val memberId: Long,
val nickname: String
)
@FeignClient 선언할 인터페이스 생성
import org.recipia.alarm.config.LoggingConfig
import org.recipia.alarm.dto.NicknameDto
import org.springframework.cloud.openfeign.FeignClient
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestMethod
import org.springframework.web.bind.annotation.RequestParam
@FeignClient(name = "member-service", url = "\${feign.member_url}", configuration = [LoggingConfig::class])
interface MemberFeignClient {
/**
* 멤버 서버로부터 Id로 해당하는 Nickname 값을 받아온다.
*/
@RequestMapping(method = [RequestMethod.POST], value = ["/feign/member/getNickname"])
fun getNickname(@RequestParam("memberId") memberId: Long): NicknameDto
}
name 속성 (필수)
@FeignClient의 name 속성은 Feign 클라이언트를 식별하기 위한 유니크한 이름을 지정하는 데 사용된다. 이 이름은 리본(Ribbon) 클라이언트와 같은 로드 밸런싱이나 서비스 디스커버리 기능을 사용할 때 해당 서비스의 논리적 이름으로 활용될 수 있지만, 프로젝트에서 이러한 기능을 사용하지 않더라도 name 속성의 지정은 필수다. 이는 Spring Cloud OpenFeign을 사용하여 외부 서비스와의 통신을 설정할 때 각 Feign 클라이언트를 구분하기 위한 식별자로서의 역할을 한다. 따라서, 서비스 디스커버리나 로드 밸런싱과 같은 기능을 활용하지 않는 경우에도, Feign 클라이언트에는 반드시 유니크한 이름을 name 속성을 통해 제공해야 한다.
url 속성 (필수)
url 속성은 Feign 클라이언트가 요청을 보낼 서비스의 기본 URL을 지정한다. 이 속성은 개발, 테스트 또는 프로덕션 환경에 따라 다른 값을 갖도록 환경 변수(\${feign.member_url})를 통해 설정했다. 이는 외부 서비스의 엔드포인트 주소를 유연하게 관리할 수 있게 해 준다.
configuration 속성
configuration 속성은 이 Feign 클라이언트에 대한 추가적인 설정을 제공하는 클래스(들)를 지정한다.
[LoggingConfig::class]는 LoggingConfig 클래스를 Feign 클라이언트의 구성으로 사용하겠다는 의미다. LoggingConfig 클래스에서는 주로 로깅 레벨을 설정하여, Feign 클라이언트를 통한 HTTP 요청과 응답의 세부 사항을 로그로 기록할 수 있게 한다.
@RequestMapping 및 메서드
@RequestMapping 어노테이션은 Feign 클라이언트가 호출할 멤버 서비스의 HTTP 엔드포인트를 지정한다. 여기서는 HTTP POST 메서드를 사용하여 "/feign/member/getNickname" 경로에 요청을 보내도록 설정되어 있다. getNickname 함수는 @RequestParam("memberId")을 통해 전달받은 memberId를 사용하여 멤버 서비스에 닉네임 정보를 요청하며, 응답으로 NicknameDto 객체를 반환받는다.
NicknameDto
NicknameDto는 멤버 서비스로부터 응답받은 데이터를 담는 데이터 전송 객체(Data Transfer Object)다.
5. 외부 서비스에서 feignClient 인터페이스 사용
우리 프로젝트는 회원가입 이벤트를 통해 멤버 서버에 닉네임을 요청하는 프로세스를 진행한다. 이 과정은 크게 두 부분으로 나뉜다: 회원가입 이벤트 발행과 이벤트 리스너에서의 Feign 클라이언트 사용이다.
회원가입 이벤트 발행
AWS SQS 메시지를 수신하는 리스너에서 회원가입 이벤트를 발행한다. AwsSqsListener 클래스는 SQS 메시지를 수신하여 해당 메시지로부터 회원 ID를 추출한 후, MemberSignupSpringEvent를 생성하고 이를 스프링의 이벤트 퍼블리셔를 통해 발행한다.
@Component
class AwsSqsListener (
private val eventPublisher: ApplicationEventPublisher
) {
@SqsListener("\${cloud.aws.sqs.member-signup-sqs}")
fun receiveMemberSignupMessage(message: String) {
// .. 기존 로직 진행 ..
// 회원가입 이벤트 발행
val memberSignupEvent = MemberSignupSpringEvent(memberIdDto.memberId)
eventPublisher.publishEvent(memberSignupEvent)
}
}
회원가입 이벤트 리스너
이벤트 리스너는 MemberSignupSpringEvent를 수신하여, 이벤트에 포함된 memberId를 사용해 MemberFeignClient를 통해 멤버 서버에 닉네임을 요청한다.
RequestFeignClient 클래스는 이벤트 리스너로, 회원가입 이벤트가 발행되면 memberFeignClient.getNickname(memberId)를 호출하여 멤버 서버로부터 NicknameDto를 받아온다. 이후, 받아온 NicknameDto를 사용하여 필요한 로직을 처리한다.
import jakarta.transaction.Transactional
import org.recipia.alarm.common.springevent.MemberSignupSpringEvent
import org.recipia.alarm.dto.NicknameDto
import org.recipia.alarm.feign.MemberFeignClient
import org.springframework.context.event.EventListener
import org.springframework.stereotype.Component
@Component
class RequestFeignClient (
val memberFeignClient: MemberFeignClient
) {
/**
* Feign 클라이언트로 Member서버에서 회원가입되어 저장된 닉네임을 요청하는 리스너
*/
@Transactional
@EventListener
fun requestSignUpMemberNickname(event: MemberSignupSpringEvent) : String {
val memberId = event.memberId
val nicknameDto: NicknameDto = memberFeignClient.getNickname(memberId)
// 추후 nicknameDto로 필요한 프로세스 진행
nicknameDto.let {
println(nicknameDto.memberId)
println(nicknameDto.nickname)
}
return nicknameDto.nickname
}
}
이 프로세스는 회원가입 이벤트를 통해 멤버 서버에 닉네임 요청을 자동화하는 예시를 보여준다. AwsSqsListener는 AWS SQS로부터 메시지를 받아 회원가입 이벤트를 발행하고, RequestFeignClient는 이 이벤트를 수신하여 멤버 서버에 닉네임을 요청한다. 이 구조를 통해 분산 시스템 간에 효율적인 비동기 통신을 구현할 수 있었다.
6. 테스트 코드로 검증
나는 MemberFeignClient 인터페이스를 통해 멤버 서비스로부터 닉네임을 받아오는 기능을 테스트해 볼 것이다.
여기서는 Mockito를 사용해 MemberFeignClient의 getNickname 메서드 호출을 가상으로 모방(given)하고, 이 메서드가 예상대로 호출되었는지(verify)와 반환값이 기대한 결과와 일치하는지 확인한다(assert).
이 테스트 코드는 실제 네트워크 호출을 하지 않고도 Feign 클라이언트가 올바르게 작동하는지 검증할 수 있다. 다만, 이 방법은 Feign 클라이언트의 통합 부분을 직접 테스트하는 것이 아니라, Feign 클라이언트를 사용하는 로직이 기대한 대로 외부 서비스와의 통신을 추상화하고 있는지를 검증한다.
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.mockito.BDDMockito.given
import org.mockito.InjectMocks
import org.mockito.Mock
import org.mockito.Mockito.verify
import org.mockito.MockitoAnnotations
import org.recipia.alarm.common.springevent.MemberSignupSpringEvent
import org.recipia.alarm.dto.NicknameDto
import org.recipia.alarm.feign.MemberFeignClient
@DisplayName("[단위] 멤버 서버로 feign 요청 보내는 서비스 클래스 테스트")
class RequestFeignClientTest {
@Mock
lateinit var sut: MemberFeignClient // lateinit을 사용하여 non-nullable 타입으로 선언
@InjectMocks
lateinit var requestFeignClient: RequestFeignClient
@BeforeEach
fun setUp() {
MockitoAnnotations.openMocks(this)
}
@DisplayName("멤버 서비스로부터 닉네임을 성공적으로 받아오는지 확인")
@Test
fun requestSignUpMemberNicknameTest() {
// Given
val memberId = 1L
val nicknameDto = NicknameDto(1L, "TestNickname")
given(sut.getNickname(memberId)).willReturn(nicknameDto)
// When
val nickname = requestFeignClient.requestSignUpMemberNickname(MemberSignupSpringEvent(memberId))
// Then
verify(sut).getNickname(memberId)
assert(nickname.equals(nicknameDto.nickname))
}
}
위 테스트 코드를 실행하면 다음과 같이 성공한 모습을 확인할 수 있다.
lateinit 키워드는 Kotlin에서 나중에 초기화할 프로퍼티를 선언할 때 사용한다. 일반적으로, Kotlin에서는 모든 프로퍼티가 선언과 동시에 초기화되거나 생성자에서 초기화되어야 하지만, lateinit을 사용하면 그 요구사항을 완화할 수 있다. lateinit은 주로 의존성 주입이나 단위 테스트에서 Mockito와 같은 모킹 프레임워크를 사용할 때 유용하다.
lateinit 사용 조건
- 프로퍼티는 반드시 var로 선언되어야 한다. 즉, 변경 가능해야 한다.
- 프로퍼티는 non-nullable 타입이어야 한다.
- 프로퍼티는 기본 타입(Int, Boolean 등)에 사용할 수 없다.
장점
초기화를 나중으로 미룸으로써, 클래스의 인스턴스가 생성될 때 바로 해당 프로퍼티의 값이 필요하지 않은 경우에 유용하다. 이는 특히 의존성 주입이나 단위 테스트 케이스 작성 시에 객체를 더 유연하게 다룰 수 있게 해 준다.
주의사항
- lateinit 프로퍼티는 선언 후에 반드시 초기화해야 한다. 만약 객체가 초기화되기 전에 접근하려고 하면 UninitializedPropertyAccessException이 발생한다.
- 프로그래머는 lateinit 프로퍼티가 초기화되었는지를 확인하기 위해 .isInitialized를 사용할 수 없다. 이는 Kotlin의 ::프로퍼티.isInitialized 형태로만 lateinit 프로퍼티에 대해 사용 가능하다.
다시 말하자면, 위 테스트 코드에서 MemberFeignClient 인터페이스의 모의 인스턴스인 sut(system under test)는 테스트 실행 전에 준비되어야 한다. 이 준비 과정은 @BeforeEach 어노테이션이 붙은 메서드 내에서 MockitoAnnotations.openMocks(this) 호출을 통해 이루어진다. 이 메서드 호출은 sut 포함한 모든 @Mock, @InjectMocks 어노테이션이 붙은 필드에 대한 Mockito의 모의 객체를 자동으로 생성하고 주입하는 과정을 초기화한다.
이 방식은 sut를 포함한 모든 모의 객체가 테스트 메소드 실행 전에 적절히 준비되도록 보장한다. 따라서, 테스트 코드 내에서 수동으로 의존성을 생성하고 주입하는 대신, Mockito와 테스트 프레임워크에 의해 자동으로 의존성이 관리되고 주입되는 것이다. 이러한 접근 방식은 테스트 코드의 명확성과 유지 관리 용이성을 향상하며, 개발자가 테스트 대상의 의존성 관리에 집중하기보다는 테스트 로직 자체에 더 집중할 수 있도록 한다.
7. 실제 검증
postman을 이용해서 실제로 테스트해 봐도 다음과 같이 잘 실행된다
로깅도 확인해 보자!
다음과 같이 로깅도 잘되는 모습을 확인할 수 있다.
🔥 결론
코틀린과 스프링 부트 3에서 Feign Client를 적용하는 과정은 조금 어려웠지만 매우 보람찬 경험이었다. 아직 코틀린을 사용하는데 익숙하진 않지만 그래도 자바와 비슷한 언어여서 그런지 코드를 보고 이해하기에는 조금 괜찮았던 것 같다.
아직 코틀린을 사용하면서 배워야 할 것이 많지만, 이번 경험을 통해 기술 스택에 대한 이해도를 한층 더 높일 수 있었다. 다음에는 DynamoDB와 같은 다른 기술과의 통합을 탐색함으로써, 코틀린에 더 익숙해지도록 해야겠다.
🔻 코틀린 말고 자바 + Spring Boot 3에서 Feign Client 사용하는 방법이 궁금하다면? 🔻
Spring Boot에서 Feign 클라이언트 사용하기(@FeignClient 사용 가이드)
사이드 팀원인 "개발자의 서랍"님의 블로그도 한번 방문해 보세요! 좋은 글이 많이 있습니다 :)
'프로그래밍 언어 > Kotlin(코틀린)' 카테고리의 다른 글
코틀린과 스프링 부트 3에서 DynamoDB 항목 추가하기 (0) | 2024.02.27 |
---|---|
코루틴과 suspend로 간단한 비동기 처리 (2) | 2024.02.27 |