if (한글로_코딩한다면()) { return 세금 서버 개발팀의 분투기 ; }

“사립학교교직원연금을 영어로 어떻게 네이밍할까요?”

프롤로그: 네이밍 늪에 빠진 개발자들

“사립학교교직원연금”, “장애인65세이상건강보험산정특례자의료비”, “별정우체국연금” …

TAX팀은 이런 용어들을 변수명으로 만들어야 하는 고민이 늘 있었습니다. 영어로 번역하자니 privateSchoolTeacherPensionInsuranceDeductionAmount 같은 괴물 같은 네이밍이 나오고, 그렇다고 축약어를 쓰자니 piaSchlPnsnDdcAmt처럼 암호 같은 코드가 되어버렸죠.

이런 고민들 속에서 한글 코딩을 도입하기로 결정하였습니다. 이 글은 한글 코딩을 선택할 수 밖에 없었던 이유와 그 과정에서 마주친 기술적 난관, 그리고 그것을 해결하며 얻은 경험을 공유하기 위해 작성되었습니다.


1. 영어 네이밍, 그 한계의 벽

영어 네이밍의 현실적 한계

세금 계산의 복잡성을 이해하기 위해 먼저 근로소득지급명세서를 살펴보겠습니다. 이 서식은 모든 세무 데이터의 기본이 되는 문서입니다.

  • 연금보험료 공제: 공무원연금, 군인연금, 사립학교교직원연금…
  • 특별세액공제: 보장성보험료, 장애인전용보장성보험료, 미숙아선천성이상아의료비…

이미지에서 볼 수 있듯이, ‘소득공제’ 같은 큰 항목 아래에는 눈으로만 봐도 복잡한 세부 항목들이 가득합니다.
한글로 봐도 어려운데 이 모든 용어들을 영어 변수명으로 만들어야 했습니다. 이 용어들을 그대로 영어로 직역하면 어떤 코드가 탄생할까요..? 아래와 같이 읽기 힘든, 괴물 같은 변수명들이 탄생했을 겁니다.

healthInsuranceDeductionAmount;                 // 건강보험료 공제금액
privateSchoolEmployeePensionDeductionAmount;    // 사립학교교직원연금 공제금액
prematureBabyCongenitalAbnormalityMedicalExpenses; // 미숙아선천성이상아의료비
medicalExpensesOver65DisabledHealthInsuranceSpecialCases; // 65세이상장애인건강보험산정특례자의료비

이러한 코드를 마주한다면 어떤 문제점이 있을까요?

  • 직관성 부재: 변수명과 실제 의미 간의 연결고리가 전혀 보이지 않았을 겁니다.
  • 높은 학습 비용: 새로운 팀원이 코드를 이해하는데 훨씬 더 큰 학습 비용이 필요했을 겁니다.
  • 어려운 유지보수: 유지보수 시 변수의 역할을 파악하는 데 많은 시간을 썼을 겁니다.

그리고 이런 네이밍의 어려움은 실제로 설계했던 계산 결과를 담는 데이터 클래스에서도 찾아볼 수 있습니다.

// (Before) 영어로 작성된 계산 결과 데이터 클래스
public class GitaxConclusion {
  private final BigDecimal totalGrossIncome;  
  private final BigDecimal taxBase;
  private final BigDecimal computedTax;
  private final BigDecimal taxReduction;
  private final BigDecimal taxCredit;
  private final BigDecimal determinedTax;
  private final BigDecimal penaltyTax;
  private final BigDecimal prepaidTax;
  private final BigDecimal interimPrepaidTax;
  // ...
}

이 코드를 한글로 바꾼다면 명확해집니다.

// (After) 한글 코딩 적용 후
public class Gitax계산결과 {
  private final BigDecimal 총수입금액;  
  private final BigDecimal 과세표준;
  private final BigDecimal 산출세액;
  private final BigDecimal 세액감면;
  private final BigDecimal 세액공제;
  private final BigDecimal 결정세액;
  private final BigDecimal 가산세;
  private final BigDecimal 기납부세액;
  private final BigDecimal 중간예납세액;
    // ...
}

코드 자체가 명확한 문서가 되다

한글 코딩의 가장 큰 이점은 코드의 의미를 파악하기 위해 거쳐야 했던 ‘해독’ 과정을 완전히 생략할 수 있다는 점입니다. determinedTax 라는 변수가 결정세액을 의미하는지, totalGrossIncome총수입금액이 맞는지 확인하기 위해서 문서를 찾거나 주석을 작성하지 않아도 됩니다. 코드에 적힌 결정세액, 총수입금액 이라는 이름 자체가 그 의미를 전달하니까요.


2. 실전 사례: 코드가 비즈니스 명세서가 되다

한글 코딩의 장점은 비즈니스 로직을 만났을 때 드러나는데요, 실제 코드의 일부를 영어버전과 한글 버전으로 나란히 비교하면 그 효과를 확인할 수 있습니다.
아래는 종합소득세 산출세액을 계산하는 로직의 일부입니다. 먼저 영어 변수만 보고 비즈니스 로직을 한번 유추해 보시겠어요?

if (comprehensiveIncomeAmount == 0) {
    return BigDecimal.ZERO;
}

if (employeeIncomeAmount == 0) {
    return BigDecimal.ZERO;
}

BigDecimal adjustedEmployeeIncomeAmount = businessIncomeAmount < 0 ?
    BigDecimal.ZERO.max(employeeIncomeAmount.add(businessIncomeAmount)) : employeeIncomeAmount;

BigDecimal wageSalaryComputedTaxRate = adjustedEmployeeIncomeAmount.divide(comprehensiveIncomeAmount, 10, RoundingMode.HALF_UP);

return computedTaxAmount.multiply(wageSalaryComputedTaxRate)
    .setScale(0, RoundingMode.FLOOR);

아마 comprehensiveIncomeAmount 가 종합소득금액 이라는 것을 바로 알기 어려우셨을 겁니다. 이처럼 영어 코드는 ‘해독’ 과정이 필요하지만, 한글로 변환된 코드는 별도의 해석 없이 비즈니스 로직에 집중할 수 있게 해줍니다.

if (종합소득금액 == 0) {
  return BigDecimal.ZERO;
}

if (근로소득금액 == 0) {
  return BigDecimal.ZERO;
}

BigDecimal 보정된_근로소득금액 = 사업소득금액 < 0 ?
    근로소득금액.add(사업소득금액) : 근로소득금액;

BigDecimal 근로소득세율 = 보정된_근로소득금액.divide(종합소득금액, 10, RoundingMode.HALF_UP);

return 산출세액.multiply(근로소득세율)
  .setScale(0, RoundingMode.FLOOR)

이러한 가독성의 차이는 조건문이 복잡해지고 어려운 명칭이 사용될수록 더욱 더 드러나게 되는데요, 의료비 세액공제 계산 로직의 일부를 비교하면 그 차이는 더욱 명확해집니다.

영문 코드

for (MedicalExpenseCalculationTarget medExp : medExpList) {
  ...
  disabledOver65HiSpecialMedExp = disabledOver65HiSpecialMedExp.add(medExp.getGeneralMedicalExpense());
  ...

  // 실손의료비 차감: 2019년부터 적용
  BigDecimal targetMedicalExpense = medExp.getGeneralMedicalExpense().subtract(
      year >= 2019 ? medExp.getActualLossMedicalInsuranceAmount() : BigDecimal.ZERO);
      
  // 산후조리원 비용: 복잡한 조건을 영어로 표현하기 어려움
  if (year >= 2024 || (year >= 2019 && totalSalaryLaborIncome.longValue() <= 70_000_000)) {
    targetMedicalExpense = targetMedicalExpense.add(medExp.getPostnatalCareCenterCost());
  }

  // 미숙아선천성이상아의료비: 2022년부터 적용
  BigDecimal applicablePrematureBabyCongenitalAbnormalityMedicalExpense = year >= 2022 ? 
      medExp.getPrematureBabyCongenitalAbnormalityMedicalExpense() : BigDecimal.ZERO;
}

한글 코드

for (A의료비 의료비 : 의료비List) {  
  ...
  장애인65세이상건강보험산정특례자의료비 = 장애인65세이상건강보험산정특례자의료비.add(의료비.일반의료비());
  ...

  // 실손의료비 차감: 2019년부터 적용
  BigDecimal 대상_의료비 = 의료비.일반의료비().subtract(
      year >= 2019 ? 의료비.실손의료보험금() : BigDecimal.ZERO);

  // 산후조리원 비용: 2019년부터 총급여 7천만원 이하, 2024년부터 조건 삭제
  if (year >= 2024 || (year >= 2019 && 총급여_근로소득 <= 70_000_000)) {
    대상_의료비 = 대상_의료비.add(의료비.산후조리원비용());
  }

  // 미숙아선천성이상아의료비: 2022년부터 적용
  BigDecimal 적용가능_미숙아선천성이상아의료비 = year >= 2022 ? 
      의료비.미숙아선천성이상아의료비() : BigDecimal.ZERO;
}

한글 코드를 보면 몇가지 명확한 장점이 보입니다.

  1. 전문 용어의 정확성이 높아집니다.
    prematureBabyCongenitalAbnormalityMedicalExpenses 같은 긴 영어 이름 대신, 세법 용어 그대로 미숙아선천성이상아의료비라고 표현하니 코드의 신뢰도가 올라갑니다.

  2. 조건문의 가독성이 향상됩니다.
    "2019년부터 총급여 7천만 원 이하"라는 세법 규정이 총급여_근로소득 <= 70_000_000 이라는 코드로 바로 읽힙니다. totalSalaryLaborIncome이라는 변수명을 보고 총급여를 떠올리는 변환 과정이 완전히 사라진 것이죠.

  3. 마지막으로, 코드가 비즈니스 규칙을 그대로 담고 있으니 새로운 개발자도 빠르게 흐름을 파악할 수 있어 유지보수성이 크게 향상됩니다.

하지만 이처럼 코드의 가독성을 높이는 과정이 마냥 순탄하지만은 않았는데요, 몇 가지 기술적 이슈를 만났고, 그중 일부는 완벽한 해결책이 없어 현실적인 우회 방법을 선택하거나 일부 기능을 포기하는 타협을 하기도 했습니다. 이어지는 글에서는 그 문제들을 어떻게 마주하고 해결(또는 우회)했는지 그 경험을 다룹니다.


3. 한글 코딩 도입 과정에서 만난 기술적 이슈들

3.1 AWS CodeBuild 에서 한글 파일명이 (물음표???) 로 깨지는 이슈

로컬에서 잘 되던 빌드가 AWS CodeBuild 환경에 올라가면 실패하는 경우가 있었는데요, 로그를 살펴보면 아래와 같이 한글 파일명을 제대로 인식하지 못하고 물음표(?) 로 깨져있는 상황이었습니다.

Failed to create MD5 hash for file '.../dto/????_????.java' as it does not exist.

CodeBuild에서 빌드를 실행하면, 빌드 과정 중 파일을 처리하는 단계(해시 생성, 복사 등)에서 위와 같이 파일명을 물음표로 인식하며 does not exist 에러를 발생시킵니다.

분명 파일은 존재하는데, CodeBuild는 왜 파일이 존재하지 않는다고 할까요? 이 문제의 근본적인 원인과 buildspec.yml 파일을 이용한 해결 방법을 알아보겠습니다.

에러의 근본 원인: OS의 언어 설정

이 문제의 원인은 코드가 실행되는 AWS CodeBuild 환경에 있습니다.

CodeBuild 는 빌드를 위해 Docker 컨테이너 위에서 Amazon Linux나 Ubuntu 같은 운영체제를 실행합니다. 이때 사용되는 기본 OS 이미지들은 용량을 최소화하고 범용성을 높이기 위해, 보통 영문(en_US) 환경에 맞춰진 최소한의 설정만 가지고 있습니다.

이렇게 되면 한글을 포함한 non-ASCII 문자를 어떻게 해석하고 표시해야 하는지에 대한 언어 정보(로케일, Locale)가 누락된 상태가 됩니다.

이런 환경에서 시스템이 한글 파일을 만나면, 자신이 모르는 글자인 한글을 표현할 방법이 없어 대체 문자인 물음표(?) 로 바꿔버립니다. 결국 시스템은 ?????.java 와 같이 깨진 파일명으로 파일을 찾게 되고, 실제 파일명과 다르므로 “파일이 존재하지 않는다”고 판단하게 되는 것입니다.

해결방안: buildspec.yml 에서 한글 로케일(Locale) 설정하기

해결책은 간단합니다. 빌드가 시작되기 전에 CodeBuild 환경에 한글 설정을 해주면 됩니다.

  1. 한글 언어팩 설치: OS 가 한글을 이해할 수 있도록 관련 패키지를 설치합니다.
  2. 로케일 환경 변수 설정: 시스템의 기본 언어를 한국어로 설정합니다.

아래는 Amazon Linux 2 (CodeBuild의 표준 이미지 중 하나) 기준의 buildspec.yml 설정입니다.

phases:
  pre_build:
    commands:
      # 1. yum을 이용해 한국어 관련 패키지를 설치합니다.
      - yum install -y -q glibc-langpack-ko
      # 2. 시스템 전체에 적용될 언어 환경 변수를 설정합니다.
      - export LANG=ko_KR.UTF-8        # 기본 언어를 한국어로 설정
      - export LANGUAGE=ko_KR:ko       # 언어 우선순위 설정
      - export LC_ALL=ko_KR.UTF-8      # 모든 로케일 카테고리를 한국어로 설정

이처럼 pre_build 단계에 로케일 설정 명령어를 추가해주면, 이후 build 단계에서는 시스템이 한글 파일명을 정상적으로 인식하여 빌드가 성공적으로 완료됩니다.

이 문제는 CodeBuild뿐만 아니라 Docker 기반의 CI/CD 환경 (GitHub Actions, Jenkins on Docker 등)에서 공통적으로 발생할 수 있습니다. 문제의 원리는 OS의 로케일 설정 부재로 동일하므로, 각 환경에 맞는 언어팩 설치와 환경 변수 설정 방법을 적용하면 해결할 수 있습니다.


3.2 Gradle 테스트 리포트 한글 클래스명 실패

테스트 코드를 실행했을 때 File name too long 이라는 낯선 에러와 함께 테스트 실행이 실패하는 경우가 있었습니다.

이 문제는 특히 클래스 이름에 한글이나 다른 non-ASCII 문자를 사용하는 프로젝트에서 발생할 수 있는데요, 이 문제가 왜 발생했는지 원인을 파악해보고 임시적인 우회 방법부터 근본적인 해결책을 찾기까지의 여정을 공유해 보겠습니다.

먼저 간단한 코드로 문제를 재현해보겠습니다. 아래와 같이 한글로 된 긴 이름을 가진 테스트 클래스가 있다고 가정합시다.

class 아주긴한글이름을가진테스트클래스입니다Test {

    @Test
    void 한글테스트메소드() {
        System.out.println("테스트 성공!");
    }
}

이제 테스트를 실행해보겠습니다. 테스트 자체는 성공하지만, 빌드 마지막 단계에서 아래와 같은 에러가 발생하며 실패합니다.

Execution failed for task ':test'.
> Could not write XML test results for ...
  ... #c544#c2a4#c2a4#c5f4 ... .xml (File name too long)

테스트는 성공했는데, 왜 XML 리포트를 작성하다가 파일 이름이 너무 길다는 에러가 발생한 걸까요?

에러의 근본 원인: 한글 인코딩과 파일명 길이 제한

이 문제의 원인은 Gradle 이 테스트 결과를 파일로 저장하는 방식에 있습니다. 테스트가 끝나면 Gradle 이 Junit XML/HTML 같은 리포트 파일을 생성하는데, 이때 파일 시스템에서 문제를 일으키지 않도록 '안전한 파일명(safe file name)' 으로 변환하는 과정을 거칩니다.

// Binary2JUnitXmlReportGenerator.generate() 에서 호출하는 FileUtils.toSafeFileName()
    
public static String toSafeFileName(String name) {
    int size = name.length();
    StringBuilder rc = new StringBuilder(size * 2);
    for (int i = 0; i < size; i++) {
        char c = name.charAt(i);
        boolean valid = c >= 'a' && c <= 'z';
        valid = valid || (c >= 'A' && c <= 'Z');
        valid = valid || (c >= '0' && c <= '9');
        valid = valid || (c == '_') || (c == '-') || (c == '.') || (c == '$');
        if (valid) {
            rc.append(c);
        } else {
        // Encode the character using hex notation
        rc.append('#');
        rc.append(Integer.toHexString(c));
        }
    }
    return rc.toString();
}

이 변환 규칙은 다음과 같습니다.

  1. 허용 문자: a-z, A-Z, 0-9, _, -, . 등 일반적인 영문과 기호는 그대로 둡니다.
  2. 그 외 문자 인코딩: 허용 목록에 없는 모든 문자(한글, 특수문자 등)는 16진수 형태의 문자열로 인코딩합니다.
    예를 들어 이라는 한 글자는 #d55c라는 4개의 글자로 변환됩니다.

바로 이 ‘1글자 → 4~5글자’ 변환 규칙이 문제의 원인입니다. 한글 이름이 길어질수록 파일명 길이는 4~5배로 급격히 늘어나고, 결국 운영체제의 파일명 길이 제한(일반적으로 255바이트)를 초과하면서 File name too long 에러가 발생하는 것입니다.

그렇다면 몇 글자부터 위험할까..?

먼저, 문제가 없는 일반적인 영문 클래스의 경우 파일명이 어떻게 생성되는지 살펴보겠습니다.

  • 클래스: com.mycompany.service.MemberServiceTest
  • 생성되는 리포트 파일명: TEST-com.mycompany.service.MemberServiceTest.xml
  • 총 길이: 약 48바이트. 255바이트 제한에 한참 못 미치는 아주 안전한 길이입니다.

하지만 여기에 한글이 포함되면 이야기가 완전히 달라집니다. 대략적인 한계치를 계산해 보면 다음과 같습니다.

  • 파일 시스템 파일명 제한: 255 바이트
  • Gradle 고정 접두사/접미사: TEST- (5) + .xml (4) = 9 바이트
  • 프로젝트 패키지 경로 (예시): com.mycompany.service. = 22 바이트 (프로젝트 패키지가 길어진다면 더 늘어날 수 있습니다.)
  • 클래스명 영문 부분 (예시): Test = 4 바이트

이제 순수 한글 이름이 차지할 수 있는 길이를 계산해 봅시다.

  • 남은 공간: 255 - 9 - 22 - 4 = 220 바이트
  • 한글 1글자가 변환 후 차지하는 길이: 약 4.5 바이트
  • 예상 한계 글자 수: 220 / 4.5 = 약 48 글자

따라서 위와 같은 패키지 구조에서는, 순수 한글로만 된 클래스명이 대략 45~50자에 가까워지면 에러가 발생할 가능성이 매우 높아집니다.

해결 방안: 문제 해결을 위한 두 갈래 길

그렇다면 Gradle의 파일명 생성 규칙 자체를 바꿀 수는 없었을까요? toSafeFileName 메소드는 Gradle 내부에 static으로 고정되어 호출되기 때문에 커스텀이 불가능했습니다. 따라서 두 가지 방향으로 해결책을 모색했습니다.

1. 임시 우회책: 리포트 생성 기능 비활성화
가장 먼저 적용할 수 있는 현실적인 방법은, 문제가 되는 리포트 생성 기능 자체를 비활성화화는 것이었습니다. XML/HTML 테스트 리포트를 적극적으로 활용하고 있지는 않았기에, 빌드 성공을 위해 우선 이 방법을 선택했습니다.

// build.gradle.kts

tasks.withType<Test>().configureEach {
    // JUnit XML 리포트와 HTML 리포트 생성을 비활성화
    reports {
        junitXml.required.set(false)
        html.required.set(false)
    }
}

2. 근본적인 해결을 위한 여정: Gradle에 기여하기
하지만 이 문제에 그저 순응하기보다, 근본적인 해결책을 직접 만들어보기로 했습니다. 많은 팀에게 테스트 리포트는 매우 중요한 자산일 수 있기 때문입니다.

좀 더 확인해보니 2017년에 논의되었으나 해결되지 않은 이슈로 확인되었습니다. 문제를 해결하기 위해서 Gradle 에 새로운 이슈를 생성하고 개선 방향을 제안했습니다.

다행히도 Gradle 팀으로부터 긍정적인 답변과 함께 직접 Pull Request 를 생성해달라는 요청을 받았습니다. 그렇게 제안한 수정 사항을 담은 PR을 생성했고 merge 되었습니다.

그래서, 어떻게 코드를 수정했나?
앞서 설명했듯이, 기존 toSafeFileName 메소드는 한글을 16진수로 변환하여 파일명을 급격히 늘리는 문제가 있었습니다.

수정된 부분은, 유니코드 문자는 최대한 유지하되 파일 시스템에서 금지된 일부 특수문자만 안전하게 치환하는 것이었습니다.

수정한 toSafeFileName 메소드

private static final char ILLEGAL_CHAR_REPLACEMENT = '-';

public static String toSafeFileName(String name) {
    // 1. 윈도우 파일 시스템 규칙을 기준으로 유니코드를 보존하며 변환
    //    (e.g. / : * ? " < > | 등을 '-' 문자로 치환)
    String result = FileSystem.WINDOWS.toLegalFileName(name, ILLEGAL_CHAR_REPLACEMENT);

    // 2. 추가로 웹/HTML 리포트에서 문제를 일으킬 수 있는 공백, 탭 등을 치환
    return result.replace(' ', ILLEGAL_CHAR_REPLACEMENT)
        .replace('\t', ILLEGAL_CHAR_REPLACEMENT)
        .replace('\n', ILLEGAL_CHAR_REPLACEMENT)
        .replace('\r', ILLEGAL_CHAR_REPLACEMENT);
}

이 덕분에 파일명 길이가 불필요하게 늘어나는 문제를 근본적으로 해결할 수 있습니다.

이 수정 사항은 아직 정식 버전에 릴리즈된 것은 아니지만, Gradle의 향후 버전에는 포함될 예정입니다. 정식 릴리즈가 이루어지면, 한글 클래스명을 사용하더라도 더 이상 리포트 기능을 비활성화하는 우회책을 사용하지 않아도 됩니다.


3.3 Spring Data JPA Sort 이슈

Spring Data JPA 를 사용하다보면 _(언더스코어)가 포함된 필드명을 기준으로 정렬을 시도할 때 예상치 못한 PropertyReferenceException 을 마주칠 수 있습니다.

한글 필드를 사용할때 언더스코어를 사용하기 때문에 이와 같은 에러를 마주쳤습니다.

먼저 어떤 상황에서 문제가 발생하는지 간단한 코드로 재현해 보겠습니다. 지급자_사업자등록번호 라는 필드를 가진 수입 엔티티가 있다고 가정해 보겠습니다.

@Entity
public class 수입 {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String 매출액;
    
    // 문제가 되는 필드
    private LocalDate 지급자_사업자등록번호; 
    
    // ... 생성자, Getter
}

이제 이 지급자_사업자등록번호 필드를 기준으로 동적 정렬을 실행해 보겠습니다.

Sort sort = Sort.by("지급자_사업자등록번호");
repository.findAll(sort); // 이 부분에서 예외 발생!

위 코드를 실행하면 Spring 은 지급자 라는 프로퍼티를 찾을 수 없다는 PropertyReferenceException 을 던집니다. 왜 지급자_사업자등록번호 필드가 분명히 있는데도 이런 에러가 발생하는 걸까요?

에러의 원인: ‘쿼리 생성자’의 오해

이 문제의 원인을 이해하려면 Spring Data JPA 가 쿼리 생성자(Query Creator)쿼리 실행자(Query Executor) 라는 두 가지 다른 역할을 수행한다는 사실을 알아야 합니다.
@Query 어노테이션이 없이 findAll(sort) 와 같은 메소드를 호출할 때, Spring Data JPA 는 ‘쿼리 생성자’ 역할을 합니다.

‘쿼리 생성자’ 의 임무는 다음과 같습니다.

  1. 의도 파악: findAll() 이라는 메소드 이름과 Sort 객체를 보고 “모든 데이터를 정렬해서 조회해야겠다”고 의도를 파악합니다.
  2. 안전성 검증: 실제 JPQL 쿼리를 만들기 전에, 정렬 기준으로 넘어온 지급자_사업자등록번호 라는 문자열이 수입 엔티티에 실제로 존재하는 유효한 필드인지 철저하게 검증합니다.
  3. 쿼리 생성: 검증이 완료되면 SELECT j FROM 수입 j ORDER BY j.지급자_사업자등록번호 와 같은 안전한 쿼리를 생성합니다.

문제는 바로 2번 안전성 검증 단계에서 발생합니다.

// Spring Data JPA PropertyPath

/**
 * Extracts the PropertyPath chain from the given source String and TypeInformation.
 * Uses SPLITTER by default and SPLITTER_FOR_QUOTED for quoted literals.
 * 
 * Separate parts of the path may be separated by "." or by "_" or by camel case. 
 * When the match to properties is ambiguous longer property names are preferred.
 * So for "userAddressCity" the interpretation "userAddress.city" is preferred 
 * over "user.address.city".
 */
public static PropertyPath from(String source, TypeInformation<?> type)

Spring Data JPA 의 PropertyPath 라는 내부 파서는 프로퍼티를 검증할 때 몇 가지 규칙을 따르는데, 그중 하나가 _를 중첩된 프로퍼티의 경로 구분자로 인식하는 것입니다.

즉, 지급자_사업자등록번호 를 보면 다음과 같이 오해합니다.

  • “아, 이건 지급자 라는 객체 안에 있는 사업자등록번호라는 필드를 찾으라는 뜻이구나!”

수입 엔티티에는 지급자 라는 객체 필드가 없으므로, Spring 은 “No property ‘지급자’ found…” 라는 예외를 던지며 검증 단계에서 실패 처리가 됩니다.

Defining Query Methods :: Spring Data JPA

해결 방안: ‘쿼리 실행자’로 역할 전환시키기 (@Query)

이 문제를 해결하는 가장 확실한 방법은 Spring Data JPA 의 역할을 ‘쿼리 생성자’ 에서 ‘쿼리 실행자’ 로 바꾸는 것입니다.

// 수입Repository.java

public interface 수입Repository extends JpaRepository<수입, Long> {

    @Query("SELECT j FROM 수입 j")
    List<수입> findAllWithSorting(Sort sort);
}

이렇게 하면 왜 문제가 해결될까요?

@Query 를 명시하는 순간, Spring Data JPA 는 “개발자가 쿼리를 직접 제공했으니, 나는 이 쿼리의 유효성을 검증할 필요가 없다”고 판단합니다. 즉, 깐깐한 ‘쿼리 생성자’에서 개발자를 신뢰하는 ‘쿼리 실행자’로 역할이 바뀝니다.

‘쿼리 실행자’ 의 임무는 훨씬 단순합니다.

  1. 쿼리 신뢰: @Query 에 명시된 SELECT j FROM 수입 j를 그대로 가져옵니다. 이때 문제가 되는 PropertyPath 를 통한 유효성 검사를 건너뜁니다.
  2. 정렬 조건 추가: Sort 객체에 담긴 지급자_사업자등록번호 정보를 가져와 ORDER BY j.지급자_사업자등록번호 구문을 만듭니다.
  3. 최종 쿼리 조합 및 실행: 신뢰한 쿼리문에 정렬 구문을 단순히 붙여서 최종 쿼리를 완성하고 실행합니다.

@Query 사용 시 주의사항

@Query 는 언더스코어 문제를 해결할 수 있는 방법이긴 하지만 @Query를 사용하는 데에는 주의가 필요합니다. @Query에 담기는 JPQL은 문자열 형태이므로 컴파일 시점에는 오류를 잡을 수 없고, 사소한 오타 하나가 그대로 런타임 에러로 이어질 수 있습니다. 또한, 엔티티의 필드명을 리팩토링하더라도 @Query 안의 문자열은 자동으로 변경되지 않아 잠재적인 버그의 원인이 되기도 합니다.

또 다른 해결 방안: PropertyPath 규칙을 역이용한 객체 설계

@Query 가 문제의 현상을 우회하는 방법이라면, 문제의 원인 자체를 활용하여 정면으로 돌파하는 객체지향적인 방법도 있습니다.

Spring Data JPA 가 지급자_사업자등록번호지급자 객체의 사업자등록번호 필드로 오해한다면, 실제로 그렇게 만들어주면 어떨까요? @Embeddable@Embedded 를 사용하면 접근이 가능합니다.
예를 들어, 엔티티에 지급자_주소, 지급자_사업자등록번호, 지급자_상호 필드가 흩어져 있었다면, 아래와 같이 지급자라는 하나의 객체로 묶어주는 것입니다.

// 1. 관련 필드를 묶어 @Embeddable 클래스를 생성
@Embeddable
public class 지급자 {
    private String 사업자등록번호;
    private String 상호;
    private String 주소;
    // ...
}

// 2. 기존 엔티티에서는 @Embedded로 사용
@Entity
public class 수입 {
    // ...
    @Embedded
    private 지급자 지급자;
}```

이렇게 구조를 변경하면, Sort.by(“지급자_사업자등록번호”) 는 Spring 의 규칙에 완벽하게 부합하는 유효한 경로가 되어 아무런 문제 없이 동작합니다.

설계 원칙의 발견: 단어 나열에서 ‘의미 단위’로

@Embedded 방식을 적용하면서, 한글 코딩 시 중요한 설계 원칙을 깨닫게 되었습니다.

초기에 단순히 ‘띄어쓰기'를 기준으로 단어를 _로 연결하여 과세표준명세_합계_금액처럼 필드명을 만들었습니다. 하지만 이 방식은 PropertyPath 파서와의 충돌뿐만 아니라, 객체로 분리하기도 애매하다는 문제가 있었습니다.

하지만 PropertyPath 이슈를 겪으면서 _를 더 이상 단순한 단어 연결이 아닌, 의미 있는 '객체' 단위를 구분하는 기준으로 바라보게 되었습니다.

즉, 이제는 단순히 단어를 나열하는 것이 아니라, 지급자과세표준명세처럼 하나의 개념으로 묶일 수 있는 '의미 단위'를 먼저 생각하고, 그 객체를 기준으로 필드명을 설계하게 되었습니다.

기존 방식 (단어 나열) 개선 방식 (객체 단위) JPA 해석
과세표준명세_합계_금액 과세표준명세_합계금액 과세표준명세 객체의 합계금액 필드
예정신고_누락분_명세_세금계산서_금액 예정신고누락분명세_세금계산서금액 예정신고누락분명세 객체의 세금계산서금액 필드

결론적으로, Spring Data JPA 의 _ 파싱 이슈는 기술적인 허들을 제공했지만, 역설적으로는 더욱 객체지향적이고 구조적인 엔티티 설계를 고민하게 만드는 좋은 계기가 되었습니다.


3.4 Swagger UI 한글 인코딩 문제

Swagger UI는 API 명세를 시각적으로 보여주고 직접 테스트까지 할 수 있게 해주는 매우 유용한 도구입니다. 하지만 한글로 된 메소드명을 사용하는 프로젝트에서 가끔 이상한 현상을 겪을 수 있습니다. 바로 분명 A라는 API의 버튼을 눌렀는데, 전혀 엉뚱한 B라는 API가 호출되는 현상입니다.

아래와 같이 한글 메서드명을 가진 컨트롤러가 있다고 가정해봅시다.

@RestController
public class 한글API_컨트롤러 {

    // 1번 API
    @GetMapping("/api/월세액-조회")
    public String 월세액을_조회() {
        return "월세액 조회 결과입니다.";
    }

    // 2번 API
    @GetMapping("/api/기부금-조회")
    public String 기부금을_조회() {
        return "기부금 조회 결과입니다.";
    }
}

이제 Swagger UI를 열어보면 두 개의 API가 잘 보입니다. 하지만 /api/월세액-조회 API를 실행하면, 결과는 엉뚱하게도 "기부금 조회 결과입니다."가 반환될 수 있습니다.

에러 원인: operationId 자동 생성 규칙과 충돌

에러는 Swagger 가 내부적으로 API 를 식별하는 과정에서 발생하게 됩니다.

Swagger 는 각 API 엔드포인트마다 고유한 operaionId 를 부여하며, UI 에서 버튼을 누를 때 이 ID 를 기준으로 어떤 API 를 호출할지 결정합니다. 만약 개발자가 이 ID 를 직접 지정하지 않으면, Swagger 는 Java 메소드 이름을 기반으로 operationId 를 자동으로 생성합니다.

이때 Swagger 의 내부 함수는 operationId 를 만들면서 안전한 문자로 만드는 정제 과정을 거칩니다. 그 규칙은 다음과 같습니다.

  • 영문, 숫자, _ 를 제외한 모든 문자를 _ 로 치환한다.

이 규칙을 위 예시 코드의 메소드명에 적용해 보겠습니다.

  • 월세액을_조회한다()_________()
  • 기부금을_조회한다()_________()

두 메소드의 operationId 가 완전히 동일한 문자열로 변환되어버렸습니다. 이렇게 operationId 가 충돌하면 Swagger 는 두 API 중 하나(보통 나중에 등록된 것)만 기억하게 되고, 결국 어떤 API 를 호출하든 마지막에 기억된 API만 호출되는 현상이 발생하는 것입니다.

해결 방안: @Operation 으로 operationId 명시하기

해결책은 문제가 되는 operationId 자동 생성 기능에 의존하는 대신, 개발자가 직접 고유하고 안전한 operationId를 지정해주면 됩니다. 이때 springdoc-openapi@Operation 어노테이션을 사용할 수 있습니다.

// 수정된 컨트롤러 코드
import io.swagger.v3.oas.annotations.Operation;

@RestController
public class 한글API_컨트롤러 {

    // 1번 API
    @GetMapping("/api/월세액-조회")
    @Operation(summary = "월세액 조회", operationId = "getMonthlyRent") // 직접 지정
    public String 월세액을_조회() {
        return "월세액 조회 결과입니다.";
    }

    // 2번 API
    @GetMapping("/api/기부금-조회")
    @Operation(summary = "기부금 조회", operationId = "getDonation") // 직접 지정
    public String 기부금을_조회() {
        return "기부금 조회 결과입니다.";
    }
}

정리하면, Swagger UI에서 API 호출이 꼬이는 문제는 한글과 같은 비ASCII 문자로 된 메소드명을 사용할 때 발생하는 operationId 충돌이 원인입니다. 이때 @Operation 어노테이션으로 고유한 영문 ID를 직접 지정해주는 것이 명확한 기술적 해결책입니다.

하지만 팀 내 논의 결과, 컨트롤러 메소드는 굳이 한글로 작성하지 않아도 API의 역할을 충분히 표현할 수 있다는 판단에 따라, 앞으로 컨트롤러의 메소드명은 영문으로 작성하기로 결정되었습니다.


4. 우리만의 한글 코딩 컨벤션

도메인에 적합하고 팀의 개발 효율성을 높이기 위해 다음과 같은 한글 코딩 컨벤션을 정했습니다.

4.1 클래스 vs 인스턴스: 접두사

가장 먼저 해결해야 할 문제는 한글의 특성에서 비롯되었습니다. 한글에는 대소문자 구분이 없어 Java의 일반적인 명명 규칙인 PascalCase(클래스)와 camelCase(변수)를 사용할 수 없었습니다.

// 문제가 되는 경우
private 수입_사업소득Entity 수입_사업소득Entity;

수입_사업소득Entity 라는 클래스가 있을 때 이 클래스의 인스턴스 변수명을 어떻게 지어야 할까요? 똑같이 수입_사업소득Entity 라고 지으면 클래스인지 변수인지 구분이 모호해집니다.

이를 해결하기 위해 클래스 앞에는 영어 접두사(Prefix) 를 붙여 구분하는 규칙을 적용했습니다.

  • 클래스명 앞에는 A를 붙인다. (예: A수입_사업소득Entity)
  • 그 클래스 내부에 선언된 enum 같은 자식 클래스는 B를 붙인다. (예: B제외정보)

A나 B라는 접두사 자체에 특별한 의미가 있는 것은 아닙니다. 복잡한 의미 부여보다는 규칙의 일관성이 필요했습니다.

이 접두사가 붙어있으면 클래스나 열거형 같이 타입(Type) 정의라는 사실을 인지하게 하는 것이 목표였습니다. 이러한 단순한 규칙으로 코드의 명확성을 높이고자 했습니다.

이를 코드에 적용하면 아래와 같이 클래스와 변수의 구분이 명확해집니다.

private A수입_사업소득Entity a수입_사업소득Entity;

4.2 명사 + 동사: 우리말 어순을 닮은 메소드명

메소드명은 ‘세금계산서를 생성한다'와 같은 자연스러운 한국어 어순을 따릅니다. 이에 따라 영어의 createTaxFile()(동사+명사)과 달리 아래와 같이 '명사+동명사’ 형태로 작성합니다.

  • 세금계산서_생성()
  • 부양가족_수정()
  • 간소화_조회()

4.3 “이것은 참인가?”: 질문하는 Boolean 메소드

boolean을 반환하는 메소드는 그 자체로 하나의 질문이 되도록 작성합니다.

  • 공급가액차이가_1000원이하인가()
  • 주현근무지지급액_총수입금액_모두0원인가()
  • 과세표준이_음수인가()

이렇게 하니 if (과세표준이 음수인가()) 와 같이 조건문을 읽을 때, 마치 자연스러운 한국어 문장을 읽는 것처럼 느껴져 코드의 가독성이 크게 향상되었습니다.


5. 팀의 반응과 변화

한글 코딩을 처음 제안했을때 모두의 반응은 기대 반 우려반이었습니다. 개발자로서 지금까지 영어로 코딩해왔기에, 새로운 시도에 대한 우려는 당연했습니다.

  • “정말 한글로 코딩해도 괜찮을까요 IDE나 라이브러리가 제대로 지원할까요?”
  • “나중에 유지보수할 때 더 어려운 거 아닐까요?”
  • “다른 팀이나 외부 개발자와 협업할 때 문제가 생기지 않을까요?”

초기에는 이처럼 여러 현실적인 우려가 있었지만, 한글 코딩이 주는 명확함의 이점을 믿고 도입을 결정했습니다. 그리고 실제 프로젝트에 적용해 보니, 초기의 걱정이 무색할 만큼 팀원들의 반응은 긍정적이었습니다.

약간의 개발 편의성을 포기하더라도 변수나 테이블의 의미를 파악하기 위해 문서를 오가는 시간을 줄이는 것이 훨씬 더 큰 생산성 향상으로 이어졌습니다.

빠른 온보딩과 낮은 학습 비용이 실제로 효과가 있었고, 복잡한 도메인 지식이 코드에 그대로 녹아 있으니, 신규 입사자도 비즈니스 로직을 훨씬 빠르게 이해할 수 있었습니다.

결국 한글 코딩은 단순한 코드 스타일의 변화가 아니라, 팀의 소통 방식과 지식 공유 문화를 긍정적으로 바꾸는 중요한 계기가 되었습니다.


6. 글을 마치며

한글 코딩 도입 경험을 돌아보며, 이 여정에서 얻은 것들을 정리해보고자 합니다.

한글 코딩의 가장 큰 매력은 코드 자체가 명세서가 된다는 점이었습니다. privateSchool… 처럼 해독이 필요한 영어 대신 사립학교교직원연금을 사용할 수 있으니 도메인 지식이 코드에 그대로 녹아들었고, 그 결과 기획자나 동료와 더 원활하게 소통할 수 있었으며 신규 입사자의 온보딩 속도 역시 빨라졌습니다.

물론 아쉬운 부분도 있었습니다. 기존 개발 생태계와의 호환성 문제로 인한 트레이드오프가 발생했습니다. 이 글에서 다룬 Gradle의 테스트 리포트 생성 오류나 인프라 환경에 한글 로케일을 추가하는 등의 초기 설정 작업이 필요했죠. 다행히 이런 문제들은 대부분 해결 방법이 존재했고, 때로는 오픈소스 커뮤니티에 기여하며 근본적인 개선을 이룰 수도 있었습니다. 이러한 문제들은 한글 코딩 자체의 단점이라기보다는, 아직 다양한 언어를 완벽히 지원하지 않는 일부 도구들과의 마찰에서 오는 일시적인 아쉬움이었습니다.

한글 코딩, 시작해볼까요?

생각보다 한글 코딩의 진입 장벽은 높지 않습니다. 복잡한 도메인 용어나 비즈니스 로직을 다루는 프로젝트라면 작은 부분부터라도 시도해볼 만합니다. 클래스명이나 메서드명 몇 개를 한글로 바꿔보는 것만으로도 코드 가독성의 변화를 체감할 수 있을 것입니다.

그리고 한글 코딩을 하는 개발자들이 많아질수록, 우리가 마주한 기술적 제약들도 더 빠르게 해결될 것입니다. 혼자서는 우회책에 만족할 수밖에 없었던 문제들도, 더 많은 사람들이 관심을 갖고 함께 개선해나간다면 근본적인 해결책을 만들어갈 수 있습니다.

최종 결론: 함께 만들어가는 한글 코딩 생태계

팀의 경험을 종합해보면, 한글 코딩은 모든 프로젝트를 위한 만능 해결책은 아닙니다. 하지만 세무처럼 한국어 기반의 복잡한 도메인 지식이 비즈니스의 핵심인 영역에서는 기술 생태계와의 약간의 마찰을 감수하더라도 얻는 이점이 큽니다.

한글 코딩은 개발자만을 위한 코드를 넘어, 모든 이해관계자가 함께 읽는 살아있는 비즈니스 문서로 나아가는 의미 있는 패러다임 전환입니다. 그리고 더 많은 개발자들이 한글 코딩에 참여할수록, 우리는 더 편하고 안정적인 한글 코딩 생태계를 함께 만들어갈 수 있을 것입니다. 따라서 적절한 환경이라면 충분히 시도해 볼 가치가 있는 선택입니다.



 | 조하선
디자인 | 윤하선

본 콘텐츠의 저작권은 (주)자비스앤빌런즈에게 있으며, 본 컨텐츠에 대한 무단 전재 및 재배포를 금지합니다