본문 바로가기
개발/기타

ktor을 통해 택배 배송상태 조회 시스템을 만들어보자

by 카펀 2023. 6. 4.

Kotlin을 주로 사용하고 있는 BE 개발자로서, 항상 ktor 프레임워크에 관심을 가지고 있습니다.
그러던 와중, 전자책 기기를 샀는데 배송이 언제 오는지 궁금해하다가, 택배 배송상태를 조회하는 API를 호출하고 응답을 보여주는 시스템을 만들어 보면 어떨까 싶었습니다.
그래서? 개발해 봤습니다 ㅎ

지금은 잘 도착했습니다 ㅎㅎ 크레마 모티프!

목차

0. 택배사 조회 API 선정
1. 만드는 시스템 소개
2. ktor로 만들어보자!
3. 결과

0. 택배사 조회 API 선정

일단 각 택배사에서 배송상태 조회 사이트를 제공하고 있긴 한데, 이걸 open API 형태로 제공하고 있는 것은 아닙니다.
다행히도 택배사 상관 없이 조회할 수 있는 서비스가 몇 개 있습니다.

스마트택배 API는 무료 플랜이 있지만 어느 정도 이상은 유료이고, 응답을 xml 형식으로 주고 있어서(...) 손이 안 가게 되더라구요.
그래서 무료인 Delivery Tracker를 이용해 개발해 보기로 했습니다.

다양한 택배사를 지원합니다.

1. 만드는 시스템 소개

그래서 사실, 제가 만들게 된 배경은 아래와 같습니다.

  • 택배사의 조회 시스템은 자사 택배 상태만 조회가 가능하다.
  • 쇼핑몰의 배송조회(로그인 - 주문내역 - 배송상태 조회)와 택배사의 조회 시스템(송장번호 매번 입력해야 함)은 조회 과정이 너무 불편하다.
  • 그래서 송장번호를 클라이언트에 저장해 두고, 간단히 확인할 수 있는 앱이 있으면 좋겠다.
  • 이왕 그러는 김에, 담당하는 BE를 먼저 만들고, 내 클라이언트가 내 BE를 의존하도록 하자.

위 내용을 제가 전부 다 개발하게 될지는 모르겠습니다. 일단 클라이언트는 flutter를 공부해서 앱을 한번 만들어볼까 하고 있긴 한데...
당장은 BE만 간단하게 만들어 봤습니다 ㅎㅎ
 
아직 초보 단계이기 때문에, 아래 역할만 합니다.

  • Delivery Tracker API를 호출
  • 응답을 Response Dto 객체로 수신 (TrackerDeliveryResponse)
  • Dto 객체의 값을 가지고, 우리 API의 response 형태에 맞추어 리턴 (LogisticsRoutingResponse)

즉 흐름을 그려 보자면 아래와 같습니다.

대충 그린 흐름

즉 Delivery Tracker API에 의존하지 않도록 ktor BE server라는 계층을 하나 두는 것이죠.
 
Delivery Tracker API의 응답은 아래와 같이 옵니다.

{
   "from": {
      "name": "예*",
      "time": "2023-05-26T19:36:20+09:00"
   },
   "to": {
      "name": "정*",
      "time": null
   },
   "state": {
      "id": "at_pickup",
      "text": "상품인수"
   },
   "progresses": [
      {
         "time": "2023-05-26T19:36:20+09:00",
         "status": {
            "id": "in_transit",
            "text": "상품이동중"
         },
         "location": {
            "name": "예스24_B센터"
         },
         "description": "물류터미널로 상품이 이동중입니다."
      },
      {
         "time": "2023-05-26T21:19:48+09:00",
         "status": {
            "id": "at_pickup",
            "text": "상품인수"
         },
         "location": {
            "name": "YES24(직영)_B"
         },
         "description": "보내시는 고객님으로부터 상품을 인수받았습니다"
      }
   ],
   "carrier": {
      "id": "568833612760",
      "name": "CJ대한통운",
      "tel": "+8212345678"
   }
}

현재로서 응답 필드 중에서 불필요한 정보는 없다고 판단하여, LogisticsRoutingResponse의 응답 역시 이와 동일하게 가져가려고 합니다.

2. ktor로 만들어보자!

ktor는 완전 처음이므로, 튜토리얼 문서를 읽어 가며 개발했습니다.

블로그에서 다룬 모든 코드는 GitHub에서 확인하실 수 있습니다.

IntelliJ IDEA Ultimate에서 간편하게 ktor 프로젝트를 생성할 수 있습니다.

위처럼 프로젝트를 생성했습니다.
프로젝트 생성은 Spring과 크게 다르지 않습니다. 간단히 정보를 입력하고, 맨 처음에 함께 사용할 dependency를 고르면 됩니다.
 

초기 ktor 프로젝트 구조

프로젝트를 생성하고 나면, 위와 같은 구조를 가지고 있습니다.
plugins 내의 Routing과 Serialization은 각각 라우팅 목록, 직렬화/역직렬화 플러그인에 대한 파일입니다.
 
Application.kt는 아래 형태를 가집니다.

Application.kt

Netty 서버의 포트 주소 등의 정보가 들어 있습니다.
저는 이 정보를 별도로 관리하고자, resources 패키지 아래에 application.conf 파일을 만들었습니다.

ktor {
    deployment {
        port = 8080
        port = ${?PORT}
    }
    application {
        modules = [ com.tistory.katfun.ApplicationKt.module ]
    }
}

이 형태를 HOCON 포맷이라고 합니다. 저는 개인적으로 이런 설정은 별도로 관리하는 것을 좋아합니다.
이 정보를 읽어들이기 위해, Application.kt는 아래와 같이 수정하였습니다.

fun main(args: Array<String>): Unit = EngineMain.main(args)

fun Application.module() {
    configureSerialization()
    configureRouting()
}

훨씬 간단해졌습니다 ㅎㅎ
 
다음은 패키지 구조입니다.
logistics 패키지를 만들고, 이 안에 Routing, RoutingResponse, Client, models/TrackerDeliveryResponse를 각각 만들었습니다.

패키지 구조

아까 말씀드렸던 TrackerDeliveryResponse, LogisticsRoutingResponse를 각각 작성했습니다.
이들은 data class 형태이며, Spring과 크게 다르지 않기 때문에, 자세한 설명은 생략하겠습니다.
 
먼저 TrackerDeliveryResponse입니다. TrackerDelivery API의 response를 담는 객체입니다.

@Serializable
data class TrackerDeliveryResponse(
    val from: TrackerDeliveryPerson,
    val to: TrackerDeliveryPerson,
    val state: TrackerDeliveryStatus,
    val progresses: List<TrackerDeliveryProgress>,
    val carrier: TrackerDeliveryCarrier
)

@Serializable
data class TrackerDeliveryPerson(
    val name: String,
    val time: String?
)

@Serializable
data class TrackerDeliveryStatus(
    val id: String,
    val text: String
)

@Serializable
data class TrackerDeliveryProgress(
    val time: String,
    val status: TrackerDeliveryStatus,
    val location: TrackerDeliveryLocation,
    val description: String
)

@Serializable
data class TrackerDeliveryLocation(
    val name: String
)

@Serializable
data class TrackerDeliveryCarrier(
    val id: String,
    val name: String,
    val tel: String
)

 
다음으로, 우리 API가 response로 내보낼 LogisticsRoutingResponse입니다.

@Serializable
data class LogisticsRoutingResponse(
    val from: LogisticsRoutingCustomer,
    val to: LogisticsRoutingCustomer,
    val carrier: LogisticsRoutingCarrier,
    val status: LogisticsStatus,
    val statusName: String,
    val progresses: List<LogisticsRoutingProgress>
) {
    companion object {
        fun from(response: TrackerDeliveryResponse): LogisticsRoutingResponse {
            val status = LogisticsStatus.valueOf(response.state.id.uppercase())
            return LogisticsRoutingResponse(
                from = LogisticsRoutingCustomer.from(response.from),
                to = LogisticsRoutingCustomer.from(response.to),
                carrier = LogisticsRoutingCarrier.from(response.carrier),
                status = status,
                statusName = status.description,
                progresses = response.progresses.map { progress ->
                    LogisticsRoutingProgress.from(progress)
                }
            )
        }
    }
}

@Serializable
data class LogisticsRoutingCustomer(
    val name: String,
    @Serializable(with = LocalDateTimeSerializer::class) val time: LocalDateTime?
) {
    companion object {
        fun from(customer: TrackerDeliveryPerson): LogisticsRoutingCustomer {
            with(customer) {
                return LogisticsRoutingCustomer(
                    name = name,
                    time = time?.let { LocalDateTime.parse(it, ISO_OFFSET_DATE_TIME) }
                )
            }
        }
    }
}

@Serializable
data class LogisticsRoutingCarrier(
    val id: String,
    val name: String,
    val tel: String
) {
    companion object {
        fun from(carrier: TrackerDeliveryCarrier): LogisticsRoutingCarrier {
            with(carrier) {
                return LogisticsRoutingCarrier(
                    id = id,
                    name = name,
                    tel = tel
                )
            }
        }
    }
}

@Serializable
data class LogisticsRoutingProgress(
    @Serializable(with = LocalDateTimeSerializer::class) val time: LocalDateTime,
    val status: LogisticsStatus,
    val statusName: String,
    val locationName: String,
    val description: String
) {
    companion object {
        fun from(progress: TrackerDeliveryProgress): LogisticsRoutingProgress {
            val status = LogisticsStatus.valueOf(progress.status.id.uppercase())
            return LogisticsRoutingProgress(
                time = LocalDateTime.parse(progress.time, ISO_OFFSET_DATE_TIME),
                status = status,
                statusName = status.description,
                locationName = progress.location.name,
                description = progress.description
            )
        }
    }
}

enum class LogisticsStatus(val description: String) {
    IN_TRANSIT("상품이동중"),
    AT_PICKUP("상품인수"),
    OUT_FOR_DELIVERY("배송출발"),
    DELIVERED("배송완료");
}

각 data class의 팩토리 메소드 때문에 분량이 많아 보이긴 하지만, 전체적으로 LogisticsRoutingResponse는 TrackerDeliveryResponse와 거의 동일한 형태를 가지고 있습니다.
다만 일부 자료형을 String에서 LocalDateTime으로, enum으로 변환하여 관리하고 있습니다.
 
위 코드를 보시면 눈에 띄는 점이 둘 있습니다.
첫 번째는 @Serializable 어노테이션입니다. 이전에 제가 Spring을 통해 메이플스토리 API를 받아 오는 프로젝트를 간단히 만들었던 적이 있는데, 이 때는 dependency에

implementation 'com.fasterxml.jackson.module:jackson-module-kotlin'

을 추가한 것 외에, 각 data class에 별다른 annotation 처리를 하지 않았습니다.
비슷한 역할을 ktor에서는 kotlinx-serialization이 해 줍니다. 다만 data class마다 @Serializable 어노테이션을 붙여야 하는 점이 좀 귀찮네요.
 
두 번째는 @Serializable(with = LocalDateTimeSerializer::class) 입니다.
Spring에서도 LocalDateTime은 다른 타입과 다르게 바로 serialize/deserialize 시킬 수 없습니다.
따라서 ObjectMapper bean을 정의하고, objectMapper.registerModule(new JavaTimeModule()) 같은 내용을 추가해 주셔야 합니다.
ktor에서는 'LocalDateTimeSerializer라는 클래스를 통해 serialize 하면 된다'고 명시해 준 셈인데요.
문제는 LocalDateTimeSerializer가 기본으로 포함되어 있지 않습니다.
 
그래서 위 패키지 구조에 보시면, 제가 uilities 패키지를 만들고, 그 아래에 LocalDateTimeSerializer 객체를 만들어 둔 것을 보실 수 있습니다.

@Serializer(forClass = LocalDateTime::class)
object LocalDateTimeSerializer : KSerializer<LocalDateTime> {
    private val formatter: DateTimeFormatter = DateTimeFormatter.ISO_LOCAL_DATE_TIME

    override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("LocalDateTime", PrimitiveKind.STRING)

    override fun serialize(encoder: Encoder, value: LocalDateTime) {
        val formatted = formatter.format(value)
        encoder.encodeString(formatted)
    }

    override fun deserialize(decoder: Decoder): LocalDateTime {
        val formatted = decoder.decodeString()
        return LocalDateTime.parse(formatted, formatter)
    }
}

이를 사용하면 serialize가 가능합니다.
 
다음으로, TrackingDeliveryClient를 살펴 보겠습니다.
먼저, 아래 dependencies를 추가해 주세요.

implementation("io.ktor:ktor-client-core:$ktor_version")
implementation("io.ktor:ktor-client-cio:$ktor_version")
implementation("io.ktor:ktor-client-content-negotiation:$ktor_version")
implementation("io.ktor:ktor-client-json:$ktor_version")
implementation("io.ktor:ktor-client-logging:$ktor_version")

ktor가 server가 아닌 client 역할을 하기 위해 필요한 dependencies 입니다.
저희 ktor는 BE server지만, 외부 API를 호출할 때는 client의 역할이 되니까요.
 
아래는 TrackerDeliveryClient입니다.

suspend fun trackerDeliveryClient(carrier: String, invoiceNumber: String): TrackerDeliveryResponse {
    val client = HttpClient(CIO) {
        install(Logging) {
            level = LogLevel.INFO
        }
        install(ContentNegotiation) {
            json()
        }
    }

    val response: TrackerDeliveryResponse = client.get("https://apis.tracker.delivery/carriers") {
        url {
            appendPathSegments(carrier, "tracks", invoiceNumber)
        }
    }.body()
    client.close()
    return response
}

여기서부터가 ktor 스타일의 코드라고 할 수 있습니다.
재밌는 점은, 그리고 저도 아직 100% 이해하지 못한 점은, 별도의 class 없이 function이 바로 정의되어 있다는 점입니다.
function만 달랑 정의되어 있고, 내부에서 client 객체를 생성한 뒤, 이를 통해 외부 API를 get으로 호출합니다.
appendPathSegments는 기본 url 뒤에 문자열을 붙이는 형식입니다.
자세한 부분은 ktor 공식 문서를 참고하시면 됩니다.
 
이렇게 해서 API를 호출하는 client를 만들었습니다.
마지막으로, 저희 API의 요청을 받을 (Spring으로 따지면 Controller 역할을 할) LogisticsRouting.kt를 작성합니다.

fun Route.logisticsRouting() {
    route("/api/v1/logistics") {
        get("/tracking") {
            val carrier = call.request.queryParameters["carrier"] ?: return@get call.respondText(
                "택배사를 입력해 주세요.",
                status = HttpStatusCode.BadRequest
            )
            val invoiceNumber = call.request.queryParameters["number"] ?: return@get call.respondText(
                "송장번호를 입력해 주세요.",
                status = HttpStatusCode.BadRequest
            )
            val tracking = LogisticsRoutingResponse.from(trackerDeliveryClient(carrier, invoiceNumber))

            call.respond(tracking)
        }
    }
}

GET /api/v1/logistics/tracking 이라는 API를 정의했습니다.
carrier과 number를 각 queryParameter로 받고, 해당 내용이 없다면 400과 함께 정의된 메세지를 리턴합니다.
두 값이 문제가 없다면, Client를 통해 Tracker Delivery API를 호출하고, 이를 LogisticsRoutingResponse의 팩토리 메소드에 넘겨 줍니다.
팩토리 메소드를 통해 생성된 객체를 응답 값으로 리턴합니다.
 
Route를 작성했다면, Routing.kt 내에 아래와 같이 등록해 줍니다.

routing 내에 logisticsRouting() 등록

3. 결과

완성된 API를 호출해 볼까요?
ktor 프로젝트를 실행해 줍니다.

0.737초만에 실행 완료

실행 속도가 무지 빠릅니다 ㅎㅎ Spring Boot는 보통 이 정도 규모의 프로젝트를 제 컴퓨터에서 실행했을 시 3~4초의 시간이 걸립니다.
 
http request를 통해 API를 호출해 보았습니다.

http request

응답이 잘 오는 것을 확인할 수 있습니다 ㅎㅎ
실제 응답값입니다.

더보기
HTTP/1.1 200 OK
Content-Length: 1482
Content-Type: application/json
Connection: keep-alive

{
  "from": {
    "name": "예*",
    "time": "2023-05-26T19:36:20"
  },
  "to": {
    "name": "정*",
    "time": "2023-05-30T15:49:48"
  },
  "carrier": {
    "id": "568833612760",
    "name": "CJ대한통운",
    "tel": "+8215881255"
  },
  "status": "DELIVERED",
  "statusName": "배송완료",
  "progresses": [
    {
      "time": "2023-05-26T19:36:20",
      "status": "IN_TRANSIT",
      "statusName": "상품이동중",
      "locationName": "예스24_B센터",
      "description": "물류터미널로 상품이 이동중입니다."
    },
    {
      "time": "2023-05-26T21:19:48",
      "status": "AT_PICKUP",
      "statusName": "상품인수",
      "locationName": "YES24(직영)_B",
      "description": "보내시는 고객님으로부터 상품을 인수받았습니다"
    },
    {
      "time": "2023-05-27T02:24:38",
      "status": "IN_TRANSIT",
      "statusName": "상품이동중",
      "locationName": "옥천HUB",
      "description": "배송지역으로 상품이 이동중입니다."
    },
    {
      "time": "2023-05-30T10:39:09",
      "status": "IN_TRANSIT",
      "statusName": "상품이동중",
      "locationName": "종로B",
      "description": "고객님의 상품이 배송지에 도착하였습니다.(배송예정:XXXX 010-XXXX-XXXX)"
    },
    {
      "time": "2023-05-30T11:30:05",
      "status": "OUT_FOR_DELIVERY",
      "statusName": "배송출발",
      "locationName": "남대문대리점",
      "description": "고객님의 상품을 배송할 예정입니다.(15∼17시)(배송담당:XXXX 010-XXXX-XXXX)"
    },
    {
      "time": "2023-05-30T15:49:48",
      "status": "DELIVERED",
      "statusName": "배송완료",
      "locationName": "남대문대리점",
      "description": "고객님의 상품이 배송완료 되었습니다.(담당사원:XXXX 010-XXXX-XXXX)"
    }
  ]
}

이렇게 간단하게 ktor를 통해 서버 구색을 하도록 만들어 보았습니다 ㅎㅎ

개인적으로 ktor에 꽤 관심을 가지고 있고, 좀 더 깊이 있게 공부해 보고 싶은 생각이 있습니다.

기회가 되면 회사에서도 ktor로 개발해 보면 좋겠네요!


이후에 개발이 진행되면 추가로 글을 작성하도록 하겠습니다!
 
코드 GitHub 링크: https://github.com/kchung1995/logistics-tracking/tree/release/20230604

 

GitHub - kchung1995/logistics-tracking: ktor를 이용하여 제작한 택배 배송상태를 조회하는 서비스의 백엔

ktor를 이용하여 제작한 택배 배송상태를 조회하는 서비스의 백엔드 API입니다. Contribute to kchung1995/logistics-tracking development by creating an account on GitHub.

github.com

 

댓글