개요
저번 포스팅에서는 프로젝트의 루트 테이블이 될 Game 엔티티를 설계했었습니다.
이번 포스팅에서는 브랜치 API 중 IP 기반 국가 코드 조회 API를 개발 및 테스트 코드까지 작성하는 시간을 가져보겠습니다.
IP 기반 국가코드 조회 API
IP 기반 국가코드 조회 API는 말 그대로 사용자의 IP에 따라 국가 코드를 조회해서 응답해 주는 API입니다.
국가 코드는 ISO 3166-1 alpha-2 (두 자리 국가코드) 형식을 따라 응답을 내려주도록 하겠습니다.
보통 위 형식을 가장 많이 사용하는 것으로 알고 있습니다.
세 자리 국가코드도 있고 MCC(모바일 국가 코드) 형식도 있지만 최대한 표준화된 방식을 따라보겠습니다.
이 API를 제작하는 이유는 보통 글로벌 게임에서 유저의 국가를 바탕으로 다국어를 지원해 주거나 결제에서는 국내와 해외에서 사용하는 모듈이 다르기 때문에 사용자의 국가 정보를 알아야 할 경우가 빈번하기 때문에 플랫폼에서 지원해주기 좋은 API라고 생각합니다.
IP 기반 국가코드 조회 API - 요청과 응답 설계
그렇다면 Request와 Response에는 어떤 프로퍼티들이 필요할지 설계해보겠습니다.
@Data
public class CountryCodeGetRequest {
@NotBlank()
private String ip;
@Builder
public CountryCodeGetRequest(String ip) {
this.ip = ip;
}
}
위 클래스는 API 요청시 필요한 DTO 클래스로 이 API에서는 IP만 파라미터로 받으면 국가 코드를 내려줄 수 있습니다.
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class IpGeolocationApiResponse implements Serializable {
private String ip;
private String country;
public static IpGeolocationApiResponse defaultResponse(String ip) {
return builder()
.ip(ip)
.country("US")
.build();
}
}
위 클래스는 API 응답으로 내려줄 Data 값으로 프로퍼티에는 요청 파라미터의 IP와 국가 코드를 같이 내려줍니다.
요청을 받을 때나 응답을 내려줄 때 모두 최대한 DTO를 사용하는 것을 추천합니다.
물론 요청 파라미터가 1개인 경우 @RequestParam으로 간단하게 처리해 줄 수도 있지만 이후 파라미터가 5개, 10개씩 필요한 상황이 올 때 코드의 가독성도 매우 떨어지고, 유지보수하기도 힘들어집니다.
또한 DTO Validation을 통해 파라미터 검증도 편하게 진행할 수 있습니다.
IP 기반 국가코드 조회 API - 어떻게 국가 코드를 조회해오면 좋을까?
국가 코드를 조회해오는 방법은 여러 가지가 있습니다.
IP 주소의 범위와 국가 코드를 매칭 시켜놓은 DB 테이블을 구축해놓고 서버에서 조회해서 리턴해주는 방법도 있고 외부 API 서버에 위임하여 가져오는 방법도 있습니다.
방법은 여러 가지고 각각의 장단점도 존재합니다.
예를 들어 DB 테이블을 구축해놓고 사용한다고 가정하면 DB 서버에 무리가 갈 수도 있는 단점이 있고 외부 API 서버에 위임한다면 쿼리를 따로 사용하지 않기에 DB 서버에 부담이 가진 않겠지만 외부 API 서버에 장애가 발생하거나 그 서버의 Latency가 매우 느리다면 우리 서비스에 영향이 발생할 수 있습니다.
이번 프로젝트에서는 외부 API 서버에 위임하여 API를 설계해 보도록 하겠습니다.
위에서 언급한 단점들이 존재하지만 이미 여러 사람들이 사용하고 있는 검증된 서버라면 어느정도 믿고 사용해도 충분할 거 같습니다.
물론 100% 신뢰할 수는 없고 외부 서버에서 장애가 발생할 수도 있기에 Gameple 서버에서 따로 예외처리를 하여 사용하도록 하겠습니다.
외부 API 서버는 아래 사이트를 참고하여 연동하겠습니다.
IP 기반 국가코드 조회 API - Response Body 설계
@Data
public class ApiResponse implements Serializable {
@Schema(description = "서버 정의 상태코드" , example = "1")
private String statusCode;
@Schema(description = "서버 정의 메세지" , example = "1")
private String message;
@Schema(description = "데이터")
private Object data;
@Builder
public ApiResponse(String statusCode, String message, Object data) {
this.statusCode = statusCode;
this.message = message;
this.data = data;
}
}
위 객체는 Gameple API 서버에서 공통으로 내려줄 Response Body입니다.
- 상태 코드 : 서버에서 내부적으로 정의한 상태 코드를 의미합니다.
- 서버 정의 메세지 : 서버에서 내부적으로 정의한 메세지를 의미합니다.
- 데이터 : API 요청 성공 시 응답해 줄 데이터입니다.
상태 코드는 위에서도 언급했듯이 서버에서 내부적으로 정의한 상태 코드로 우리가 흔히 알고 있는 HTTP 상태 코드와는 별개입니다.
HTTP 상태 코드만으로는 모든 예외에 대해 다르게 처리해 줄 수 없기 때문에 보통 서버 내부에서 Enum으로 정의한 상태 코드를 쓰는 것이 일반적입니다.
저희 회사에서는 Enum으로 모든 케이스를 관리하고 API를 사용하는 사람들이 참고할 수 있도록 Confluence와 같은 Docs에 추가적인 내용들을 명세하여 공유하고 있습니다.
추가로 Class를 직렬화(serialize) 해준 이유는 아래 언급할 캐시를 사용을 위해서는 직렬화가 필요하기 때문입니다.
더 자세한 내용은 밑의 글을 참고하시길 바랍니다.
IP 기반 국가코드 조회 API - 외부 API 등록
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/gameple_db?serverTimezone=Asia/Seoul
username: root
password: root
jpa:
properties:
hibernate:
show_sql: true
format_sql: true
hibernate:
ddl-auto: update
cache:
ehcache:
config: classpath:ehcache.xml
branch:
ip-geolocation:
api: https://api.country.is/
외부 API는 https://country.is/를 참고하여 사용하기로 결정했으니 해당 API URI를 환경 파일에서 관리해 줄 수 있도록 yml에 등록하여 줍시다.
IP 기반 국가코드 조회 API - API 구현
@Tag(name = "Branch", description = "브랜치 - 브랜치 API")
@RestController
@RequiredArgsConstructor
@RequestMapping("/branch")
public class BranchController {
private final BranchService branchService;
@GetMapping("/country/code")
public ApiResponse getCountryCodeByIp(@ModelAttribute @Valid CountryCodeGetRequest request) {
return branchService.getCountryCodeByIp(request);
}
}
@Service
@RequiredArgsConstructor
@Slf4j
public class BranchService {
@Value("${branch.ip-geolocation.api}")
private String ipGeoLocationApiUri;
private final RestTemplate restTemplate;
@Cacheable(value="branchGetCountryCodeCache", key = "#request")
public ApiResponse getCountryCodeByIp(CountryCodeGetRequest request) {
log.info("BRANCH API - IP GEOLOCATION API REQUEST : {}", request);
String clientIp = request.getIp();
IpGeolocationApiResponse ipGeolocationApiResponse = IpGeolocationApiResponse.defaultResponse(request.getIp());
try {
ResponseEntity<IpGeolocationApiResponse> responseEntity =
restTemplate.getForEntity(ipGeoLocationApiUri + clientIp, IpGeolocationApiResponse.class);
ipGeolocationApiResponse = responseEntity.getBody();
log.info("BRANCH API - IP GEOLOCATION API RESULT : {}", responseEntity);
} catch (Exception e) {
log.warn("BRANCH API - IP GEOLOCATION API ERROR REQUEST: {}", request);
}
return ApiResponse.builder()
.statusCode(StatusCode.SUCCESS.getStatusCode())
.message(StatusCode.SUCCESS.getMessage())
.data(ipGeolocationApiResponse)
.build();
}
}
본격적으로 API를 구현해 보겠습니다.
우선 외부 API에 요청을 보내기 위해 RestTemplate를 사용하여 환경 파일에 설정된 외부 API의 URI를 읽고 Request의 IP을 파라미터로 추가하여 요청을 보냅니다.
외부 API의 결과가 항상 성공(200)이라는 보장이 없기 때문에 Try ~ Catch로 묶어 예외가 발생하면 디폴트로 설정된 응답을 보내줄 수 있도록 합시다.
성공했다면 그 결과 자체를 Response Body의 Data에 담아 리턴해줍니다.
보통 이런 API의 경우 요청 횟수가 많기 때문에 캐시하여 사용하는 것이 좋습니다.
Spring에서 사용할 수 있는 Ehcache를 사용하여 API 응답을 캐싱해줄 수 있도록 합시다.
물론 Redis나 Memcached도 있지만 현재는 한 대의 서버에서 운영할 생각으로 임하고 있기 때문에 Ehcache를 사용하겠습니다.
IP 기반 국가코드 조회 API - Ehcache 설정
implementation 'org.springframework.boot:spring-boot-starter-cache'
implementation 'net.sf.ehcache:ehcache:2.10.3'
build.gradle에 Ehcache 의존성을 추가해줍시다.
@SpringBootApplication
@EnableCaching
public class GamepleconnectApplication {
public static void main(String[] args) {
SpringApplication.run(GamepleconnectApplication.class, args);
}
@Bean
public RestTemplateBuilder restTemplateBuilder() {
return new RestTemplateBuilder();
}
}
Application(main) 부분에 @EnableCaching 어노테이션을 선언해줘야 @Cacheable, @CacheEvict을 사용할 수 있습니다.
<?xml version="1.0" encoding="UTF-8"?>
<ehcache xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="http://ehcache.org/ehcache.xsd"
updateCheck="false">
<diskStore path="java.io.tmpdir" />
<cache name="getCountryCodeByIpCache"
maxEntriesLocalHeap="10000"
maxEntriesLocalDisk="1000"
eternal="false"
diskSpoolBufferSizeMB="20"
timeToIdleSeconds="60"
timeToLiveSeconds="3600"
memoryStoreEvictionPolicy="LFU"
transactionalMode="off">
<persistence strategy="localTempSwap" />
</cache>
</ehcache>
환경 변수 파일에 설정해 준 대로 resources 경로 밑에 ehcache.xml 파일(ehcache 설정 파일)을 생성하고 위 내용을 입력해 줍시다.
설정 파일에서는 각 캐시마다 개별 설정을 해줄 수 있거나 Default 캐시를 설정해 줄 수 있습니다.
여러 속성이 있기 때문에 아래 사이트를 참고하시면 도움이 됩니다.
https://javacan.tistory.com/entry/133
IP 기반 국가코드 조회 API - API 호출
API 개발을 마쳤으니 Postman으로 직접 API를 호출해보겠습니다.
위와 같이 정상적으로 응답이 오는 것을 확인할 수 있습니다.
2023-09-14 23:02:41.520 INFO 26468 --- [nio-8080-exec-2] c.g.branch.service.BranchService : BRANCH API - IP GEOLOCATION API REQUEST : CountryCodeGetRequest(ip=27.100.128.0)
2023-09-14 23:02:41.759 INFO 26468 --- [nio-8080-exec-2] c.g.branch.service.BranchService : BRANCH API - IP GEOLOCATION API RESULT : <200,IpGeolocationApiResponse(ip=27.100.128.0, country=KR),[Date:"Thu, 14 Sep 2023 14:02:48 GMT", Content-Type:"application/json; charset=utf-8", Content-Length:"36", Connection:"keep-alive", Access-Control-Allow-Origin:"*", Cache-Control:"public, max-age=3600", ETag:""24-yQEiekwMSEi9OtRLt4wDUboKOHU"", CF-Cache-Status:"HIT", Age:"290", Accept-Ranges:"bytes", Report-To:"{"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v3?s=sIgVBBaRX%2BDHUitzmyUSuEN0%2BNZTQ1Unk5wtNS%2FiKAVCmovJHlYNT1SUsf518UrZkrzdgIVhg7tV9Q7aF60bjuxbfgVLzt9yGAi9bTYoHAJ2Sg0l%2B3yhyo7F09fDFTqo"}],"group":"cf-nel","max_age":604800}", NEL:"{"success_fraction":0,"report_to":"cf-nel","max_age":604800}", Server:"cloudflare", CF-RAY:"80692b7229688d25-KIX"]>
로그 또한 확인해보면 Gameple 서버에서 외부 API 서버를 통해 정상적으로 응답된 결과를 로깅한 것을 확인할 수 있습니다.
IP 기반 국가코드 조회 API - 테스트 코드 작성
@AutoConfigureCache
@AutoConfigureMockMvc
@SpringBootTest
class BranchControllerTest {
@SuppressWarnings("SpringJavaInjectionPointsAutowiringInspection")
@Autowired
private MockMvc mockMvc;
@SuppressWarnings("SpringJavaInjectionPointsAutowiringInspection")
@Autowired
ObjectMapper objectMapper;
@Test
@DisplayName("IP 기반 국가코드 조회 - 성공")
void test() throws Exception {
// US IP
final String clientIp = "100.42.19.255";
LinkedMultiValueMap<String, String> requestParam = new LinkedMultiValueMap<>();
requestParam.set("ip", clientIp);
mockMvc.perform(get("/branch/country/code")
.params(requestParam)
)
.andExpect(status().isOk())
.andExpect(jsonPath("$.statusCode").value("1"))
.andExpect(jsonPath("$.data.ip").value("100.42.19.255"))
.andExpect(jsonPath("$.data.country").value("US"))
.andDo(print());
}
@Test
@DisplayName("IP 기반 국가코드 조회 - 실패(디폴트 응답)")
void test1() throws Exception {
final String clientIp = "127.0.0.1";
LinkedMultiValueMap<String, String> requestParam = new LinkedMultiValueMap<>();
requestParam.set("ip", clientIp);
mockMvc.perform(get("/branch/country/code")
.params(requestParam)
)
.andExpect(status().isOk())
.andExpect(jsonPath("$.statusCode").value("1"))
.andExpect(jsonPath("$.data.ip").value("127.0.0.1"))
.andExpect(jsonPath("$.data.country").value("US"))
.andDo(print());
}
}
@AutoConfigureCache
@SpringBootTest
class BranchServiceTest {
@Autowired
BranchService branchService;
@SuppressWarnings("SpringJavaInjectionPointsAutowiringInspection")
@Autowired
ObjectMapper objectMapper;
@Test
@DisplayName("IP 기반 국가코드 조회 - 성공")
void test1() {
// US IP
final String clientIp = "100.42.19.255";
CountryCodeGetRequest request = CountryCodeGetRequest.builder()
.ip(clientIp)
.build();
ApiResponse apiResponse = branchService.getCountryCodeByIp(request);
IpGeolocationApiResponse apiResponseData = objectMapper.convertValue(apiResponse.getData(), IpGeolocationApiResponse.class);
assertEquals("1", apiResponse.getStatusCode());
assertEquals("100.42.19.255", apiResponseData.getIp());
assertEquals("US", apiResponseData.getCountry());
}
@Test
@DisplayName("IP 기반 국가코드 조회 - 실패(디폴트 응답)")
void test2() {
final String clientIp = "127.0.0.1";
CountryCodeGetRequest request = CountryCodeGetRequest.builder()
.ip(clientIp)
.build();
ApiResponse apiResponse = branchService.getCountryCodeByIp(request);
IpGeolocationApiResponse apiResponseData = objectMapper.convertValue(apiResponse.getData(), IpGeolocationApiResponse.class);
assertEquals("1", apiResponse.getStatusCode());
assertEquals("127.0.0.1", apiResponseData.getIp());
assertEquals("US", apiResponseData.getCountry());
}
}
이렇게 BranchController와 BranchService 클래스에 대한 테스트 코드를 작성합니다.
미국(US) IP를 넣은 정상적인 요청과 루프백(loopback) 주소를 넣은 잘못된 요청을 보내 테스트를 해보면
모두 성공한 것을 확인할 수 있습니다.
레포지토리 주소
'Project > Gameple' 카테고리의 다른 글
[Project - Gameple] Project Gameple(8) - 프로모션 API - 사전예약 API (0) | 2023.09.17 |
---|---|
[Project - Gameple] Project Gameple(6) - Game Entity 설계 (0) | 2023.07.16 |
[Project - Gameple] Project Gameple(5) - Spring Security 적용하기 (0) | 2023.07.07 |
[Project - Gameple] Project Gameple(4) - Swagger 적용하기 (0) | 2023.06.29 |
[Project - Gameple] Project Gameple(3) - 프로젝트 생성 (0) | 2023.06.27 |