📖 왜 JPY 청구서가 100배로 찍혔을까
일본 법인 청구서에 12,500엔이 나가야 하는데 화면에는 125엔, IDoc에는 1,250,000엔이 찍히는 사고. ABAP 금액 처리에서 가장 자주 터지는 유형이며, 원인은 거의 항상 하나입니다. 통화별 소수 자릿수를 정의하는 TCURX 테이블을 무시하고 금액을 "그냥 숫자"로 다뤘기 때문입니다. 이 글에서 다음을 확인할 수 있습니다.
- ABAP이 금액을 내부적으로 항상 소수점 2자리로 저장하는 이유와 그 부작용
TCURX-CURRDEC가 0(JPY, KRW)이거나 3(BHD, KWD)일 때 벌어지는 자릿수 이동의 원리CURRENCY_AMOUNT_SAP_TO_DISPLAY/CURRENCY_AMOUNT_DISPLAY_TO_SAP를 쓴 안전한 변환- 통화가 섞인 금액 합산을 프로덕션 품질로 구현하는 패턴
📚 읽기 전에 갖춰두면 좋은 배경
ABAP 기본 문법(SELECT, 내부 테이블, 함수모듈 호출)과 딕셔너리의 CURR/CUKY 타입 개념을 알고 있으면 충분합니다. 판매 오더나 회계 전표에서 금액 필드(NETWR, WRBTR 류)를 다뤄본 경험이 있다면 문제 상황이 더 생생하게 와닿을 것입니다. 중급 난도이지만 원리 자체는 입문자도 따라올 수 있게 풀어서 설명합니다.
🔧 검증 환경과 준비물
예제는 SAP S/4HANA 2023 (ABAP Platform 2023, 7.58 문법) 기준으로 작성했으며, 핵심 원리는 ECC 6.0 이후 어떤 릴리스에서도 동일하게 적용됩니다. 준비물은 다음과 같습니다.
- SE16/SE16N에서
TCURX조회 권한 — 통화키(CURRKEY)와 소수 자릿수(CURRDEC) 확인용 - 테스트 통화 3종: JPY(0자리), KRW(0자리), BHD(3자리). 시스템에 KRW가 TCURX에 등록되어 있는지 먼저 확인하세요. 원화가 빠져 있는 시스템에서 소수점 둘째 자리까지 찍힌 원화 금액이 그대로 인터페이스로 흘러나가는 사고가 실제로 발생합니다.
- 통화 소수 자릿수 설정은 트랜잭션 OY04(IMG: 일반 설정 > 통화)에서 관리되며, 운영 중 변경은 기존 저장 데이터 전체에 영향을 주므로 일반적으로 금지 수준으로 신중하게 다룹니다.
💡 TCURX와 "항상 2자리" 저장 방식의 원리
ABAP 딕셔너리의 금액 타입 CURR는 패킹 십진수(DEC)이며, 데이터베이스에는 통화와 무관하게 항상 소수점 2자리 형태로 저장됩니다. 그런데 현실의 통화는 소수 자릿수가 제각각입니다. 엔화와 원화는 소수 단위가 없고(0자리), 바레인 디나르는 밀(fils) 단위까지 3자리를 씁니다.
이 간극을 메우는 것이 TCURX입니다. 이 테이블은 소수 자릿수가 2가 아닌 통화만 등록하는 예외 목록입니다. 통화키가 TCURX에 없으면 2자리로 간주합니다.
| 통화 | TCURX-CURRDEC | 실제 금액 | DB 내부 저장값 |
|---|---|---|---|
| USD | (미등록=2) | 1,250.00 | 1250.00 |
| JPY | 0 | 1,250 | 12.50 |
| KRW | 0 | 1,250 | 12.50 |
| BHD | 3 | 1.250 | 12.50 |
변환 공식은 내부값 = 실제값 ÷ 10^(2 − CURRDEC)입니다. JPY 1,250엔은 내부에 12.50으로, BHD 1.250디나르는 12.50으로 저장됩니다. 자로 비유하면, DB는 모든 길이를 "cm 눈금 자" 하나로만 기록하고, TCURX는 "이 통화는 사실 m 단위 자로 읽어야 한다"는 환산표인 셈입니다. 환산표를 안 보고 눈금 숫자만 읽으면 JPY는 100분의 1로 줄고, 화면 입력값을 그대로 DB에 넣으면 100배로 부풀어 오릅니다.
여기서 중요한 원칙 세 가지가 나옵니다.
- 금액은 반드시 통화키와 쌍으로 다닌다. 통화 없는 금액은 해석 불가능한 숫자일 뿐입니다.
- 경계에서만 변환한다. 시스템 내부(DB↔ABAP 변수) 이동은 변환 없이, 화면·파일·외부 인터페이스로 나가고 들어올 때만 변환합니다.
- 합산은 같은 통화끼리, 내부값 기준으로. 내부값은 같은 통화라면 자릿수 체계가 동일하므로 그대로 더해도 되지만, 통화가 다르면 더하기 전에 환율 변환이 먼저입니다.
💻 실전 코드 세 단계
1단계 — TCURX 조회와 기본 변환
먼저 통화의 소수 자릿수를 읽고, 내부값을 표시값으로 바꿔봅니다. WRITE ... CURRENCY도 같은 원리로 동작합니다.
REPORT zbs_tcurx_step1.
DATA(lv_waers) = CONV waers( 'JPY' ).
" 예외 테이블 TCURX 조회 — 없으면 2자리로 간주
SELECT SINGLE currdec FROM tcurx
WHERE currkey = @lv_waers
INTO @DATA(lv_dec).
IF sy-subrc <> 0.
lv_dec = 2.
ENDIF.
WRITE: / |{ lv_waers } 소수 자릿수: { lv_dec }|.
" DB에 12.50으로 저장된 JPY 금액 → 실제로는 1,250엔
DATA lv_internal TYPE wrbtr VALUE '12.50'.
DATA lv_display TYPE bapicurr-bapicurr.
CALL FUNCTION 'CURRENCY_AMOUNT_SAP_TO_DISPLAY'
EXPORTING
currency = lv_waers
amount_internal = lv_internal
IMPORTING
amount_display = lv_display
EXCEPTIONS
internal_error = 1
OTHERS = 2.
IF sy-subrc = 0.
WRITE: / |표시 금액: { lv_display } { lv_waers }|. " 1250
ENDIF.
" 출력 전용이라면 WRITE의 CURRENCY 보충도 같은 효과
WRITE: / lv_internal CURRENCY lv_waers. " 1.250
2단계 — 실무 시나리오: 통화가 섞인 주문 금액 합산
수출 주문 목록에 USD·JPY·KRW가 섞여 있습니다. 통화를 무시한 단순 SUM은 의미 없는 숫자를 만들고, 사용자 입력값을 변환 없이 DB값과 비교하는 것도 사고의 지름길입니다. 통화별로 그룹핑해 내부값을 합산하고, 입력값은 CURRENCY_AMOUNT_DISPLAY_TO_SAP으로 내부화한 뒤에 비교합니다.
TYPES: BEGIN OF ty_order,
order_id TYPE vbeln,
net_amt TYPE netwr, " 내부 저장값
currency TYPE waers,
END OF ty_order.
DATA(lt_orders) = VALUE STANDARD TABLE OF ty_order(
( order_id = '3000001001' net_amt = '1250.00' currency = 'USD' )
( order_id = '3000001002' net_amt = '12.50' currency = 'JPY' ) " 실제 1,250엔
( order_id = '3000001003' net_amt = '985.00' currency = 'KRW' ) )." 실제 98,500원
" (X) 잘못된 방식: 통화 무시 단순 합산 → 2,247.50 이라는 무의미한 값
" (O) 통화별 그룹 합산 후 표시 변환
LOOP AT lt_orders INTO DATA(ls_order)
GROUP BY ( currency = ls_order-currency )
INTO DATA(lg_curr).
DATA(lv_sum) = REDUCE netwr( INIT s TYPE netwr
FOR m IN GROUP lg_curr
NEXT s = s + m-net_amt ).
DATA lv_out TYPE bapicurr-bapicurr.
CALL FUNCTION 'CURRENCY_AMOUNT_SAP_TO_DISPLAY'
EXPORTING currency = lg_curr-currency
amount_internal = lv_sum
IMPORTING amount_display = lv_out
EXCEPTIONS internal_error = 1 OTHERS = 2.
IF sy-subrc <> 0.
" 실무에서는 BAL 로그(애플리케이션 로그)에 남기고 해당 통화만 스킵
MESSAGE e001(zbs_fin) WITH lg_curr-currency INTO DATA(lv_msg).
CONTINUE.
ENDIF.
WRITE: / |{ lg_curr-currency } 합계: { lv_out }|.
ENDLOOP.
" 사용자 입력 1250 (JPY) → 내부값으로 변환해서 저장·비교
DATA lv_db_amount TYPE wrbtr.
CALL FUNCTION 'CURRENCY_AMOUNT_DISPLAY_TO_SAP'
EXPORTING currency = 'JPY'
amount_display = CONV bapicurr-bapicurr( '1250' )
IMPORTING amount_internal = lv_db_amount
EXCEPTIONS internal_error = 1 OTHERS = 2.
" lv_db_amount = 12.50 → 이제 DB의 net_amt와 안전하게 비교 가능
3단계 — 프로덕션: 캐싱 클래스와 단위 테스트
대량 처리에서 건마다 함수모듈을 호출하면 낭비이므로, TCURX 자릿수를 캐싱하는 변환 클래스로 감싸는 패턴이 일반적으로 권장됩니다. 자릿수 시프트만 필요하면 산술 연산으로 처리해 호출 비용을 줄일 수 있고, 테스트 클래스로 JPY·BHD 경계 케이스를 고정해 회귀를 막습니다.
CLASS zcl_bs_currency_shift DEFINITION CREATE PRIVATE.
PUBLIC SECTION.
CLASS-METHODS get_instance RETURNING VALUE(ro) TYPE REF TO zcl_bs_currency_shift.
METHODS to_external IMPORTING iv_currency TYPE waers
iv_internal TYPE wrbtr
RETURNING VALUE(rv_amount) TYPE p LENGTH 15 DECIMALS 3.
PRIVATE SECTION.
CLASS-DATA go_instance TYPE REF TO zcl_bs_currency_shift.
DATA mt_dec_cache TYPE HASHED TABLE OF tcurx WITH UNIQUE KEY currkey.
METHODS get_decimals IMPORTING iv_currency TYPE waers
RETURNING VALUE(rv_dec) TYPE currdec.
ENDCLASS.
CLASS zcl_bs_currency_shift IMPLEMENTATION.
METHOD get_instance.
IF go_instance IS INITIAL.
go_instance = NEW #( ).
SELECT * FROM tcurx INTO TABLE @go_instance->mt_dec_cache.
ENDIF.
ro = go_instance.
ENDMETHOD.
METHOD get_decimals.
rv_dec = VALUE #( mt_dec_cache[ currkey = iv_currency ]-currdec DEFAULT 2 ).
ENDMETHOD.
METHOD to_external.
" 외부값 = 내부값 × 10^(CURRDEC−2) ... JPY는 ×100, BHD는 ÷10
rv_amount = iv_internal * ipow( base = 10 exp = get_decimals( iv_currency ) - 2 ).
ENDMETHOD.
ENDCLASS.
CLASS ltc_shift DEFINITION FOR TESTING RISK LEVEL HARMLESS DURATION SHORT.
PRIVATE SECTION.
METHODS jpy_zero_decimals FOR TESTING.
ENDCLASS.
CLASS ltc_shift IMPLEMENTATION.
METHOD jpy_zero_decimals.
cl_abap_unit_assert=>assert_equals(
act = zcl_bs_currency_shift=>get_instance( )->to_external(
iv_currency = 'JPY' iv_internal = '12.50' )
exp = 1250 ).
ENDMETHOD.
ENDCLASS.
화면단에서는 직접 변환하지 않는 것이 원칙입니다. ALV라면 필드 카탈로그의 cfieldname(통화 참조 필드)을 지정하고, CDS 뷰라면 @Semantics.amount.currencyCode 애너테이션과 DECIMAL_SHIFT 함수를 활용하면 프레임워크가 TCURX 기준으로 알아서 자릿수를 맞춰 줍니다. 외부 시스템(비 SAP) 인터페이스에는 BAPI_CURRENCY_CONV_TO_EXTERNAL / BAPI_CURRENCY_CONV_TO_INTERNAL도 같은 목적으로 널리 쓰입니다.
⚠️ 자주 겪는 함정과 FAQ
- CSV/Excel 업로드 금액을 그대로 DB에 저장 — 파일의 1250(JPY)은 표시값입니다.
DISPLAY_TO_SAP없이 넣으면 DB에는 125,000엔에 해당하는 값이 들어갑니다. - ALV 필드 카탈로그에 통화 참조 누락 —
cfieldname을 안 채우면 모든 통화가 2자리로 표시되어 JPY가 100분의 1로 보입니다. - 문자열 변환 후 산술 연산 —
WRITE TO로 만든 문자열(천단위 구분자 포함)을 다시 숫자로 캐스팅해 더하는 코드는 통화·로케일 양쪽에서 깨집니다.
Q1. TCURX에 KRW가 없으면 어떻게 되나요?
2자리 통화로 간주되어 원화에 소수점이 붙습니다. 신규 시스템 셋업 시 OY04에서 KRW=0을 확인하는 것이 일반적으로 권장됩니다. 단, 이미 데이터가 쌓인 뒤의 변경은 기존 저장값 전체의 해석이 바뀌는 위험한 작업이므로 SAP 지원 절차를 따라야 합니다.
Q2. 같은 통화끼리는 내부값을 그냥 더해도 되나요?
네. 내부값은 동일 통화 안에서 일관된 자릿수 체계이므로 합산 자체는 안전합니다. 문제는 합산 결과를 표시·출력하는 시점이며, 그때 반드시 변환을 거쳐야 합니다.
Q3. CURRENCY_AMOUNT_SAP_TO_DISPLAY와 BAPI_CURRENCY_CONV_TO_EXTERNAL의 차이는?
동작 원리는 같습니다. BAPI 계열은 원격 호출(RFC) 가능하도록 릴리스되어 외부 인터페이스에서 주로 쓰이고, 반환 타입(소수 4자리 필드) 차이가 있습니다. 사내 리포트에서는 어느 쪽을 써도 무방합니다.
🚀 이어서 살펴볼 주제
자릿수를 맞췄다면 다음 관문은 환율 변환입니다. TCURR(환율)·TCURF(환산 팩터)와 CONVERT_TO_LOCAL_CURRENCY, CDS의 CURRENCY_CONVERSION 함수가 이어지는 주제입니다. RAP 기반 앱이라면 @Semantics 애너테이션 체계와 Fiori Element의 금액 포맷팅, 인터페이스 개발자라면 IDoc 세그먼트의 통화 필드 처리(CURRENCY_AMOUNT_SAP_TO_IDOC)까지 확장해 보길 권합니다.
📚 더 깊이 파고들 자료
댓글 0
아직 댓글이 없습니다.