블로그
전체 2#카테고리
- 백엔드
#태그
- SpringBatch
- 스프링배치
- jdbcClient
- spring

2025. 11. 24.
1
왜 내 배치는 로컬 JobParameter로 실행됐을까?
신규 배치 잡을 개발하면서, 로컬에서 먼저 실행해 정상 동작을 확인한 뒤 개발 서버에 배포해 실행해보았다.그런데 이상한 문제가 발생했다.배치 실행 시 항상 로컬에서 사용했던 JobParameter로만 실행되는 것.Jenkins에서 jobParameter를 넘겨 실행해도, 스프링 배치는 계속 로컬에서 실행한 JobParameter를 고정해서 사용했다.새로 만든 잡에서는 동일 JobParameter로도 배치를 반복 실행할 수 있도록 RunIdIncrementer를 사용한다.이 incrementer는 run.id를 자동으로 증가시켜 새로운 JobInstance를 만들 수 있게 해주는 기능이다.그런데 RunIdIncrementer로 인해 run.id는 정상적으로 1, 2, 3… 증가하는데,정작 내가 설정한 jobParameter가 아니라 로컬에서 실행한 파라미터만 계속 적용되고 있었다.그래서 원인을 제대로 파악해 보기로 했다.RunIdIncrementer는 정확히 무엇을 할까?스프링 소스코드를 보면 역할은 매우 단순하다.public JobParameters getNext(@Nullable JobParameters parameters) { JobParameters params = (parameters == null) ? new JobParameters() : parameters; JobParameter runIdParameter = params.getParameters().get(this.key); long id = 1; if (runIdParameter != null) { id = Long.parseLong(runIdParameter.getValue().toString()) + 1; } return new JobParametersBuilder(params).addLong(this.key, id).toJobParameters(); } 즉:기존 JobParameter에서 run.id가 있으면 +1없으면 1그 외 나머지 JobParameter는 건드리지 않고 그대로 사용즉, RunIdIncrementer는 오직 run.id만 증가시키고그 외 파라미터는 건드리지 않는다.그럼 “그 외 파라미터”는 어디서 오는 걸까?JobParametersBuilder.getNextJobParameters스프링은 다음과 같은 순서로 JobParameter를 만든다.JobInstance lastInstance = jobExplorer.getLastJobInstance(name); JobExecution previousExecution = jobExplorer.getLastJobExecution(lastInstance); nextParameters = incrementer.getNext(previousExecution.getJobParameters()); ... nextParametersMap.putAll(this.parameterMap); // 현재 실행 파라미터 우선 요약하면:이전에 실행한 동일 Job의 JobParameter를 조회한다.RunIdIncrementer로 run.id를 증가시킨 nextParameters를 만든다.이번 실행에서 전달한 jobParameter를 맨 마지막에 merge한다. (가장 높은 우선순위)즉 스프링 배치의 JobParameter 우선순위는 다음과 같다.Spring Batch JobParameter 우선순위이전 실행의 JobParameterIncrementer(getNext) 결과이번 실행 시 전달된 JobParameter (최우선)그렇다면 이번 실행에서 넘겨준 파라미터가 최우선인데 왜 적용되지 않았던 걸까? 전달되지 않을 것 같아 배치 실행 부분을 살펴봤다.문제의 진짜 원인: Jenkins → Docker 실행 시 파라미터가 전달되지 않음스프링 배치 로직상으로는 “이번 실행 파라미터가 가장 우선순위”가 맞다.그런데 실제로 Jenkins에서 JobParameter를 넘겨줘도 실행 결과에는 반영되지 않았다.조사해보니:Jenkins가 전달한 jobParameter를 Dockerfile 내부에서 배치를 실행할 때 넘겨주지 않고 있었다.즉,Jenkins → Docker run → Spring Boot이 체인을 따라 파라미터가 전달되지 않아서스프링은 “이번 실행 파라미터 없음”그럼 당연히 이전 JobParameter를 그대로 가져다 씀incrementer는 run.id만 증가결과적으로 run.id만 달라지고 나머지는 로컬 실행 때의 파라미터가 계속 유지됨그래서 Dockerfile을 수정해 Jenkins에서 받은 파라미터를 그대로 전달해 실행하도록 수정했고문제는 바로 해결되었다.결론1) RunIdIncrementer는 run.id만 올려주는 단순한 기능이다.다른 파라미터는 절대 건드리지 않는다.2) Spring Batch는 이전 실행의 JobParameter를 기본값으로 사용한다.그리고 incrementer를 적용한 뒤,이번 실행 파라미터를 merge한다.3) 이번 실행의 JobParameter가 적용되지 않은 이유는Dockerfile에서 파라미터를 누락해서 전달되지 않았기 때문.4) 따라서 Jenkins → Docker → Spring Boot 실행 구조를 사용할 때는반드시 jobParameter 전달 경로를 점검해야 한다.
백엔드
・
SpringBatch
・
스프링배치

2025. 11. 12.
1
PostgreSQL alias는 소문자로 나오는데 DTO 매핑은 잘 될까?
TL;DRPostgreSQL은 따옴표 없는 alias를 전부 소문자로 변환하지만,JDBC 드라이버의 findColumn()은 대소문자를 구분하지 않고 컬럼을 탐색하며,SimplePropertyRowMapper는 DTO 필드명 그대로→snake_case 순으로 컬럼을 찾는다.덕분에 AS blogId로 alias를 줘도 DTO의 blogId 필드로 정확히 매핑된다.예를 들어 다음과 같이 Spring의JdbcClient 를 사용해 PostgreSQL 쿼리 결과를 Kotlin data class로 매핑한다고 해봅시다val sql = """ SELECT t.parent_id AS blogId, t.id AS tagId, t.name AS tagName FROM tags t """.trimIndent() jdbcClient.sql(sql) .query(TagByBlogDto::class.java) .list() 그리고 매핑 대상은 아래처럼 단순한 data class입니다data class TagByBlogDto( val blogId: Long, val tagId: Long, val tagName: String, ) PostgreSQL은 AS blogId를 내부적으로 소문자(blogid) 로 변환하지만,JdbcClient는 이 컬럼을 TagByBlogDto.blogId에 정확히 매핑합니다.어떻게 이런 일이 가능한 걸까요?PostgreSQL alias 규칙PostgreSQL 공식 문서에 따르면 👇“Quoting an identifier also makes it case-sensitive, whereas unquoted names are always folded to lower case.For example, the identifiers FOO, foo, and "foo" are considered the same by PostgreSQL,but "Foo" and "FOO" are different from these three and each other.”— PostgreSQL Docs: Identifiers and Key Words즉,AS blogId → 내부적으로 blogidAS "blogId" → 대소문자 그대로 유지작성 형태 실제 인식되는 이름 AS blogId blogid AS “blogId” blogId AS BLOGID blogidJdbcClient는 어떤 매퍼를 사용할까?JdbcClient는 .query(Class) 호출 시 내부적으로SimplePropertyRowMapper 를 사용합니다 👇public MappedQuerySpec query(Class mappedClass) { RowMapper rowMapper = rowMapperCache.computeIfAbsent(mappedClass, key -> BeanUtils.isSimpleProperty(mappedClass) ? new SingleColumnRowMapper(mappedClass) : new SimplePropertyRowMapper(mappedClass) ); return query((RowMapper) rowMapper); } 즉, TagByBlogDto처럼 DTO를 넘기면new SimplePropertyRowMapper(TagByBlogDto.class)가 생성되어 필드 단위로 매핑됩니다.SimplePropertyRowMapper의 동작 원리SimplePropertyRowMapper는 생성자 파라미터명을 기준으로 ResultSet에서 컬럼을 찾습니다.핵심 부분은 아래와 같습니다 👇for (int i = 0; i 즉, 매퍼는 다음 순서로 필드 매칭을 시도합니다:findColumn("blogId")실패 시 findColumn("blog_id")PostgreSQL JDBC 드라이버(PgResultSet)의 findColumn 로직이제 핵심입니다.PostgreSQL JDBC 드라이버는 ResultSet.findColumn() 호출 시 대소문자를 모두 무시하고 컬럼을 찾습니다.아래는 실제 PgResultSet 코드 일부입니다private int findColumnIndex(String columnName) { Integer index = columnNameIndexMap.get(columnName); if (index != null) return index; // 1️⃣ 소문자 비교 index = columnNameIndexMap.get(columnName.toLowerCase(Locale.US)); if (index != null) { columnNameIndexMap.put(columnName, index); return index; } // 2️⃣ 대문자 비교 index = columnNameIndexMap.get(columnName.toUpperCase(Locale.US)); if (index != null) { columnNameIndexMap.put(columnName, index); return index; } return 0; } 즉, rs.findColumn("blogId")를 호출하면 드라이버가 내부적으로blogId, blogid, BLOGID를 모두 비교하기 때문에 일치하게 됩니다.
백엔드
・
jdbcClient
・
spring




