Post

전문 해석기 배치 - File Line to Domain Model 변환

전문 해석 시 고려해야 하는 것들은, align, padding, trim, 날짜 포맷, 숫자 포맷 변환 등이다.

문자 타입은 끝문자 trim 정도만 처리하면 제대로 매핑되지만,

날짜, 숫자 포맷은 전문 송신처에 따라 포맷이 각각 달라 디테일한 처리가 필요하다.

  • e.g., 0.8%을 어디서는 00080000 으로 보내고, 어디서는 00.80으로 보냄.
  • padding도 어디서는 (0, LEFT)로, 어디서는 (' ', RIGHT)로 할 수 있음
  • 날짜를 어디서는 yyyyMMdd를 사용하고, 어디서는 yyMMdd 사용함.

따라서 전문 해석 케이스를 정리해보면, 크게 2가지 클래스가 필요하다.

  • class A ⇒ fieldSet에서 꺼내서 type 변환하고, align에 따라 padding trim처리 하는 클래스 하나 (⇒ 어떤 전문이든 공통으로 적용되는 기능)
  • class B ⇒ 날짜 포맷 커버, 숫자 포맷 커버하는 디테일한 변환을 담당하는 클래스 하나. (⇒ 해당 전문에 specific하게 적용되는 기능)

1. file → (enum+Domain Model)로 변환하는 방식.

DTO 없이, 파일에서 바로 읽어서 fileLine → Domain Model 로 매핑. 따라서 FieldSetMapper를 각 data 전문마다 정의해야 함.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
enum class MerchantInfoDataTokenInfo(
    val tokenName: String,
    val range: Range,
    val type: TokenType,
    val alignment: AlignmentType
) {
    recDvCd("recDvCd", Range(1, 1), STRING, LEFT),
    regDvCd("regDvCd", Range(2, 2), String, LEFT),
    ...;
    companion object {
        val tokenizer = FixedByteLengthTokenizer(Charset.forName("euc-kr")).apply {
            setNames(*values().map { it.tokenName }.toTypedArray())
            setColumns(*values().map { it.range }.toTypedArray())
        }
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class MerchantInfoFieldSetMapper: FieldSetMapper<MerchantInfo> {
    override fun mapFieldSet(fieldSet: FieldSet): MerchantInfo {
        return MerchantInfo(
            registrationDate = CustomLocalDate.parse(fieldSet.readString(MerchantInfoDataTokenInfo.regDate.tokenName).trimEnd(), dateFormatter),
            cancelDate = if (fieldSet.readString(MerchantInfoDataTokenInfo.cnlDate.tokenName).trimEnd().isNotEmpty()) {
                CustomLocalDate.parse(fieldSet.readString(MerchantInfoDataTokenInfo.cnlDate.tokenName).trimEnd(), dateFormatter)
            } else null,
            merchantNo = fieldSet.readString(MerchantInfoDataTokenInfo.merNo.tokenName).trimEnd(),
            phoneNumber = fieldSet.readString(MerchantInfoDataTokenInfo.bizlTelRgnNo.tokenName).trimEnd() + "-" +
                fieldSet.readString(MerchantInfoDataTokenInfo.bizlTelHno.tokenName).trimEnd() + "-" +
                fieldSet.readString(MerchantInfoDataTokenInfo.bizlTelRapsNo.tokenName).trimEnd(),
            creditCardCommissionRate = fieldSet.readBigDecimal(MerchantInfoDataTokenInfo.feeRtN8.tokenName).movePointLeft(7),
            installmentAvailabilityYn = fieldSet.readString(MerchantInfoDataTokenInfo.selDvCd.tokenName).trimEnd().let {
                when (it) {
                    "1" -> YnEnum.Y
                    "2" -> YnEnum.N
                    "" -> null
                    else -> throw IllegalStateException("### Unknown value : $it")
                }
            },
            ...
        )
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
setTokenizers(
    mapOf(
        "H*" to MerchantInfoHeaderTokenInfo.tokenizer,
        "D*" to MerchantInfoDataTokenInfo.tokenizer,
        "T*" to MerchantInfoTrailerTokenInfo.tokenizer
    )
)
setFieldSetMappers(
    mapOf(
        "H*" to TelegramFieldSetMapper(MerchantInfoTelegramHeader::class.java),
        "D*" to MerchantInfoFieldSetMapper(),
        "T*" to TelegramFieldSetMapper(MerchantInfoTelegramTrailer::class.java)
    )
)

fun itemProcessor() = ItemProcessor<MerchantInfoInterface, MerchantInfo>() {
    when (it) {
        is MerchantInfoTelegramHeader -> {     // 얘는 DTO
            logger.info { "### Header : $it" }
            null
        }
        is MerchantInfo -> {                   // 얘는 Domain Model
            logger.debug { "### Data : $it" }
            it
        }
        is MerchantInfoTelegramTrailer -> {    // 얘는 DTO
            logger.info { "### Trailer : $it" }
            null
        }
        else -> throw IllegalStateException("unknown telegram type : ${it.recDvCd}")
    }
}
  • trim 같은 공통 처리하는 class A → enum
  • 디테일한 변환 처리하는 class B → MerchantInfoFieldSetMapper

  • 단점)
    • processor에서 Domain Model을 바로 받기 때문에 DTO에 대한 로깅을 하기가 애매하다.
    • Header, Trailer는 DTO인데 Data영역은 Domain Model을 전달받으니 통일성이 없어보인다.
    • Header, Data, Trailer를 묶어줄 MerchantInfoInterface라는 공통의 상위타입을 정의해야 Reader → Processor로 전달이 가능한데… DTO도 이걸 구현하고, Domain Model도 이걸 구현하니 상속계층이 좀 애매해진다. 단순히 processor로 전달하기 위한 목적의 Interface라..?
    • FieldSetMapper에서 readString 및 trim 처리를 enum으로 옮길 수는 있지만, 그렇게 해도 엄청 깨끗해지지는 않는다.

2. file → (DTO+enum) → Domain Model로 변환하는 방식.

1
2
3
4
5
6
7
8
9
10
enum class MerchantInfoDataTokenInfo(
    val tokenName: String,
    val range: Range,
    val type: TokenType,
    val alignment: AlignmentType
) {
    recDvCd("recDvCd", Range(1, 1), STRING, LEFT),
    regDvCd("regDvCd", Range(2, 2), String, LEFT),
    ...
}
1
2
3
4
5
6
7
8
9
10
11
data class MerchantInfoTelegramData(
    override val recDvCd: String,
    ...
) : MerchantInfoTelegram() {
    fun toEntity(): MerchantInfo {
        return MerchantInfo(
            registrationDate = CustomLocalDate.parse(regDate, dateFormatter),
            ...
        )
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
setTokenizers(
    mapOf(
        "H*" to MerchantInfoHeaderTokenInfo.tokenizer,
        "D*" to MerchantInfoDataTokenInfo.tokenizer,
        "T*" to MerchantInfoTrailerTokenInfo.tokenizer
    )
)
setFieldSetMappers(
    mapOf(
        "H*" to TelegramFieldSetMapper(MerchantInfoTelegramHeader::class.java),
        "D*" to TelegramFieldSetMapper(MerchantInfoTelegramData::class.java),
        "T*" to TelegramFieldSetMapper(MerchantInfoTelegramTrailer::class.java)
    )
)
fun itemProcessor() = ItemProcessor<MerchantInfoTelegram, MerchantInfo>() {
    when (it) {
        is MerchantInfoTelegramHeader -> {   // DTO
            logger.info { "### Header : $it" }
            null
        }
        is MerchantInfoTelegramData -> {     // DTO
            logger.debug { "### Data : $it" }
            it.toEntity()
        }
        is MerchantInfoTelegramTrailer -> {  // DTO
            logger.info { "### Trailer : $it" }
            null
        }
        else -> throw IllegalStateException("unknown telegram type : ${it.recDvCd}")
    }
}
  • trim 같은 공통 처리하는 class A → TelegramFieldSetMapper
  • 디테일한 변환 처리하는 class B → DTO에서 담당 (toEntity 메서드)

  • 1 대비 장점)
    • Processor에서 전달받는 Header, Data, Trailer는 모두 DTO라 통일성이 있음.
    • 모두 DTO이므로 MerchantInfoTelegram를 상속받는 것에 문제가 없음. (abstract class로 만들어 레코드 구분 필드를 사용 할 수도 있음)
  • 아쉬운 부분)
    • 전문 필드 이름, range, 타입, align 정보 등은 MerchantInfoDataTokenInfo enum에서 관리하고 전문 필드 매핑은 MerchantInfoTelegramData dto에서 담당하니 전문을 나타내는 표현 클래스가 2개로 이원화됨.
    • tokenizer에서 사용할 이름은 enum에서 관리하고, 이 이름을 dto로 매핑하게 되는데 컴파일 타임 연관관계를 가지는 것이 아니라서 fragile함. (enum에서만 이름을 바꿨다면?)

3. file → (DTO using annotation) → Domain Model로 변환하는 방식.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
data class MerchantInfoTelegramData(
    @field:TelegramField(1, 1, STRING, LEFT)
    override val recDvCd: String,
    @field:TelegramField(2, 2, STRING, LEFT)
    val regDvCd: String,
    ...
) : MerchantInfoTelegram() {
    fun toEntity(): MerchantInfo { ... }
    companion object {
        val tokenizer = FixedByteLengthTokenizer(Charset.forName("euc-kr")).apply {
            val telegramFields = TelegramField.listOf(MerchantInfoTelegramData::class.java)
            setNames(*telegramFields.map { it.first }.toTypedArray())
            setColumns(*telegramFields.map { Range(it.second.start, it.second.end) }.toTypedArray())
        }
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Target(AnnotationTarget.FIELD)
@Retention(AnnotationRetention.RUNTIME)
annotation class TelegramField(
    val start: Int,
    val end: Int,
    val type: TokenType,
    val alignment: AlignmentType
) {
    companion object {
        fun <T> listOf(cls: Class<T>): List<Pair<String, TelegramField>> {
            return cls.declaredFields
                .filter { it.isAnnotationPresent(TelegramField::class.java) }
                .map {
                    ReflectionUtils.makeAccessible(it)
                    Pair(it.name, it.getAnnotation(TelegramField::class.java))
                }
        }
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
setTokenizers(
    mapOf(
        "H*" to MerchantInfoTelegramHeader.tokenizer,
        "D*" to MerchantInfoTelegramData.tokenizer,
        "T*" to MerchantInfoTelegramHeader.tokenizer
    )
)
setFieldSetMappers(
    mapOf(
        "H*" to TelegramFieldSetMapper(MerchantInfoTelegramHeader::class.java),
        "D*" to TelegramFieldSetMapper(MerchantInfoTelegramData::class.java),
        "T*" to TelegramFieldSetMapper(MerchantInfoTelegramTrailer::class.java)
    )
)
  • trim 같은 공통 처리하는 class A → TelegramFieldSetMapper
  • 디테일한 변환 처리하는 class B → DTO에서 담당 (toEntity 메서드)

  • 2 대비 장점)
    • 전문 필드 이름, range, 타입, align 정보 관리, 전문 필드 매핑을 모두 단일 클래스 MerchantInfoTelegramData에서 담당하므로 관리포인트 감소

4. file → (DTO using TField) → Domain Model로 변환하는 방식.

3과 거의 유사하므로 디테일한 내용은 생략.

1
2
3
4
5
6
// DTO에서 애너테이션 사용하는 방식 (3)
@TelegramField(1, 2, STRING, LEFT...)
String field

// DTO에서 TField 객체 사용하는 방식 (4)
TelegramField field = new TelegramField(1, 2, STRING, LEFT...)

관리 포인트 관점에서 두 방식에 차이가 있지는 않고
다만 TelegramField 객체 사용하는 방식은 필드 타입이 원시타입이 아닌 객체이기 때문에, 기존에 predefined 된 FieldSetMapper나 ObjectMapper를 바로 사용 할 수는 없다.
반드시 FieldSetMapper를 직접 커스텀으로 구현하거나, TelegramField 내부에 deserialize를 위한 로직을 넣어주어야 한다.

게다가 DTO 필드인 TelegramField는 DTO 생성하면서 미리 초기화 되어 있고, 나중에 전문을 받으면 DTO.field.data = … 로 데이터를 채워넣는 방식이 될 수 밖에 없어

  1. 불변성 측면에서 좋지 않고
  2. 메타데이터 관리 책임과 실제 데이터 관리 책임이 둘 다 TelegramField로 모이게 된다는 단점이 있다.

상기 이유를 생각해보면, 객체 방식(4) 보다는 애너테이션 방식(3)이 더 나아보인다.

This post is licensed under CC BY 4.0 by the author.