fix: 코드 생성기 템플릿 MissingFormatArgumentException 수정

String.formatted() %s 플레이스홀더 방식에서
명명된 플레이스홀더 {{}} + String.replace() 방식으로 변경

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
HYOJIN 2026-03-26 16:44:50 +09:00
부모 9c393f9137
커밋 d2932f4032

파일 보기

@ -84,23 +84,27 @@ public class BypassCodeGenerator {
StringBuilder fieldLines = new StringBuilder(); StringBuilder fieldLines = new StringBuilder();
for (BypassApiField field : fields) { for (BypassApiField field : fields) {
String jsonProp = field.getJsonProperty() != null ? field.getJsonProperty() : field.getFieldName(); String jsonProp = field.getJsonProperty() != null ? field.getJsonProperty() : field.getFieldName();
fieldLines.append(" @JsonProperty(\"%s\")\n".formatted(jsonProp)); fieldLines.append(" @JsonProperty(\"").append(jsonProp).append("\")\n");
fieldLines.append(" private %s %s;\n\n".formatted(field.getFieldType(), field.getFieldName())); fieldLines.append(" private ").append(field.getFieldType()).append(" ").append(field.getFieldName()).append(";\n\n");
} }
return """ return """
package %s; package {{PACKAGE}};
%s {{IMPORTS}}
@Getter @Getter
@Setter @Setter
@Builder @Builder
@NoArgsConstructor @NoArgsConstructor
@AllArgsConstructor @AllArgsConstructor
public class %sBypassDto { public class {{CLASS_NAME}} {
%s} {{FIELDS}}}
""".formatted(packageName, imports, domainCap, fieldLines); """
.replace("{{PACKAGE}}", packageName)
.replace("{{IMPORTS}}", imports.toString())
.replace("{{CLASS_NAME}}", domainCap + "BypassDto")
.replace("{{FIELDS}}", fieldLines.toString());
} }
/** /**
@ -115,7 +119,7 @@ public class BypassCodeGenerator {
boolean isList = "LIST".equalsIgnoreCase(config.getResponseType()); boolean isList = "LIST".equalsIgnoreCase(config.getResponseType());
boolean isPost = "POST".equalsIgnoreCase(config.getHttpMethod()); boolean isPost = "POST".equalsIgnoreCase(config.getHttpMethod());
String returnType = isList ? "List<%s>".formatted(dtoClass) : dtoClass; String returnType = isList ? "List<" + dtoClass + ">" : dtoClass;
String methodName = "get" + domainCap + "Data"; String methodName = "get" + domainCap + "Data";
String fetchMethod = buildFetchMethodCall(config, params, isList, isPost); String fetchMethod = buildFetchMethodCall(config, params, isList, isPost);
String methodParams = buildMethodParams(params); String methodParams = buildMethodParams(params);
@ -123,52 +127,51 @@ public class BypassCodeGenerator {
String listImport = isList ? "import java.util.List;\n" : ""; String listImport = isList ? "import java.util.List;\n" : "";
return """ return """
package %s; package {{PACKAGE}};
import %s.%s; import {{DTO_IMPORT}};
import com.snp.batch.common.web.service.BaseBypassService; import com.snp.batch.common.web.service.BaseBypassService;
%simport org.springframework.beans.factory.annotation.Qualifier; {{LIST_IMPORT}}import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.core.ParameterizedTypeReference; import org.springframework.core.ParameterizedTypeReference;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.web.reactive.function.client.WebClient; import org.springframework.web.reactive.function.client.WebClient;
/** /**
* %s bypass 서비스 * {{DISPLAY_NAME}} bypass 서비스
* 외부 Maritime API에서 데이터를 실시간 조회하여 그대로 반환 * 외부 Maritime API에서 데이터를 실시간 조회하여 그대로 반환
*/ */
@Service @Service
public class %sBypassService extends BaseBypassService<%s> { public class {{DOMAIN_CAP}}BypassService extends BaseBypassService<{{DTO_CLASS}}> {
public %sBypassService( public {{DOMAIN_CAP}}BypassService(
@Qualifier("%s") WebClient webClient) { @Qualifier("{{WEBCLIENT_BEAN}}") WebClient webClient) {
super(webClient, "%s", "%s", super(webClient, "{{DISPLAY_NAME}}", "{{EXTERNAL_PATH}}",
new ParameterizedTypeReference<>() {}, new ParameterizedTypeReference<>() {},
new ParameterizedTypeReference<>() {}); new ParameterizedTypeReference<>() {});
} }
/** /**
* %s 데이터를 조회합니다. * {{DISPLAY_NAME}} 데이터를 조회합니다.
* *
* @return %s * @return {{DISPLAY_NAME}}
*/ */
public %s %s(%s) { public {{RETURN_TYPE}} {{METHOD_NAME}}({{METHOD_PARAMS}}) {
%s {{FETCH_METHOD}}
} }
} }
""".formatted( """
packageName, .replace("{{PACKAGE}}", packageName)
dtoPackage, dtoClass, .replace("{{DTO_IMPORT}}", dtoPackage + "." + dtoClass)
listImport, .replace("{{LIST_IMPORT}}", listImport)
config.getDisplayName(), .replace("{{DISPLAY_NAME}}", config.getDisplayName())
domainCap, dtoClass, .replace("{{DOMAIN_CAP}}", domainCap)
domainCap, .replace("{{DTO_CLASS}}", dtoClass)
config.getWebclientBean(), .replace("{{WEBCLIENT_BEAN}}", config.getWebclientBean())
config.getExternalPath(), .replace("{{EXTERNAL_PATH}}", config.getExternalPath())
config.getDisplayName(), .replace("{{RETURN_TYPE}}", returnType)
config.getDisplayName(), .replace("{{METHOD_NAME}}", methodName)
returnType, methodName, methodParams, .replace("{{METHOD_PARAMS}}", methodParams)
fetchMethod .replace("{{FETCH_METHOD}}", fetchMethod);
);
} }
/** /**
@ -187,7 +190,7 @@ public class BypassCodeGenerator {
boolean isList = "LIST".equalsIgnoreCase(config.getResponseType()); boolean isList = "LIST".equalsIgnoreCase(config.getResponseType());
boolean isPost = "POST".equalsIgnoreCase(config.getHttpMethod()); boolean isPost = "POST".equalsIgnoreCase(config.getHttpMethod());
String responseGeneric = isList ? "List<%s>".formatted(dtoClass) : dtoClass; String responseGeneric = isList ? "List<" + dtoClass + ">" : dtoClass;
String mappingAnnotation = isPost ? "@PostMapping" : "@GetMapping"; String mappingAnnotation = isPost ? "@PostMapping" : "@GetMapping";
String listImport = isList ? "import java.util.List;\n" : ""; String listImport = isList ? "import java.util.List;\n" : "";
@ -205,59 +208,58 @@ public class BypassCodeGenerator {
String requestMappingPath = "/api/" + domain; String requestMappingPath = "/api/" + domain;
return """ return """
package %s; package {{PACKAGE}};
import %s.%s; import {{DTO_IMPORT}};
import %s.%s; import {{SERVICE_IMPORT}};
import com.snp.batch.common.web.ApiResponse; import com.snp.batch.common.web.ApiResponse;
import com.snp.batch.common.web.controller.BaseBypassController; import com.snp.batch.common.web.controller.BaseBypassController;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
%simport lombok.RequiredArgsConstructor; {{LIST_IMPORT}}import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
%s%s%s%s {{PATH_VARIABLE_IMPORT}}{{REQUEST_PARAM_IMPORT}}{{REQUEST_BODY_IMPORT}}
/** /**
* %s bypass API * {{DISPLAY_NAME}} bypass API
* S&P Maritime API에서 데이터를 실시간 조회하여 그대로 반환 * S&P Maritime API에서 데이터를 실시간 조회하여 그대로 반환
*/ */
@RestController @RestController
@RequestMapping("%s") @RequestMapping("{{REQUEST_MAPPING_PATH}}")
@RequiredArgsConstructor @RequiredArgsConstructor
@Tag(name = "%s", description = "%s bypass API") @Tag(name = "{{DOMAIN_CAP}}", description = "{{DISPLAY_NAME}} bypass API")
public class %sController extends BaseBypassController { public class {{DOMAIN_CAP}}Controller extends BaseBypassController {
private final %s %s; private final {{SERVICE_CLASS}} {{SERVICE_FIELD}};
@Operation( @Operation(
summary = "%s 조회", summary = "{{DISPLAY_NAME}} 조회",
description = "S&P API에서 %s 데이터를 요청하고 응답을 그대로 반환합니다." description = "S&P API에서 {{DISPLAY_NAME}} 데이터를 요청하고 응답을 그대로 반환합니다."
) )
%s {{MAPPING_ANNOTATION}}
public ResponseEntity<ApiResponse<%s>> get%sData(%s) { public ResponseEntity<ApiResponse<{{RESPONSE_GENERIC}}>> get{{DOMAIN_CAP}}Data({{PARAM_ANNOTATIONS}}) {
return execute(() -> %s.get%sData(%s)); return execute(() -> {{SERVICE_FIELD}}.get{{DOMAIN_CAP}}Data({{SERVICE_CALL_ARGS}}));
} }
} }
""".formatted( """
packageName, .replace("{{PACKAGE}}", packageName)
dtoPackage, dtoClass, .replace("{{DTO_IMPORT}}", dtoPackage + "." + dtoClass)
servicePackage, serviceClass, .replace("{{SERVICE_IMPORT}}", servicePackage + "." + serviceClass)
listImport, .replace("{{LIST_IMPORT}}", listImport)
pathVariableImport, requestParamImport, requestBodyImport, .replace("{{PATH_VARIABLE_IMPORT}}", pathVariableImport)
mappingAnnotation.equals("@PostMapping") ? "" : "", .replace("{{REQUEST_PARAM_IMPORT}}", requestParamImport)
config.getDisplayName(), .replace("{{REQUEST_BODY_IMPORT}}", requestBodyImport)
requestMappingPath, .replace("{{DISPLAY_NAME}}", config.getDisplayName())
domainCap, config.getDisplayName(), .replace("{{DOMAIN_CAP}}", domainCap)
domainCap, .replace("{{REQUEST_MAPPING_PATH}}", requestMappingPath)
serviceClass, serviceField, .replace("{{SERVICE_CLASS}}", serviceClass)
config.getDisplayName(), config.getDisplayName(), .replace("{{SERVICE_FIELD}}", serviceField)
mappingAnnotation + mappingPath, .replace("{{MAPPING_ANNOTATION}}", mappingAnnotation + mappingPath)
responseGeneric, domainCap, .replace("{{RESPONSE_GENERIC}}", responseGeneric)
paramAnnotations.isEmpty() ? "" : paramAnnotations, .replace("{{PARAM_ANNOTATIONS}}", paramAnnotations.isEmpty() ? "" : paramAnnotations)
serviceField, domainCap, serviceCallArgs .replace("{{SERVICE_CALL_ARGS}}", serviceCallArgs);
);
} }
/** /**
@ -265,10 +267,6 @@ public class BypassCodeGenerator {
*/ */
private String buildFetchMethodCall(BypassApiConfig config, List<BypassApiParam> params, private String buildFetchMethodCall(BypassApiConfig config, List<BypassApiParam> params,
boolean isList, boolean isPost) { boolean isList, boolean isPost) {
List<BypassApiParam> pathParams = params.stream()
.filter(p -> "PATH".equalsIgnoreCase(p.getParamIn()))
.sorted((a, b) -> Integer.compare(a.getSortOrder(), b.getSortOrder()))
.toList();
List<BypassApiParam> queryParams = params.stream() List<BypassApiParam> queryParams = params.stream()
.filter(p -> "QUERY".equalsIgnoreCase(p.getParamIn())) .filter(p -> "QUERY".equalsIgnoreCase(p.getParamIn()))
.sorted((a, b) -> Integer.compare(a.getSortOrder(), b.getSortOrder())) .sorted((a, b) -> Integer.compare(a.getSortOrder(), b.getSortOrder()))
@ -276,12 +274,9 @@ public class BypassCodeGenerator {
StringBuilder uriBuilder = new StringBuilder(); StringBuilder uriBuilder = new StringBuilder();
uriBuilder.append("uri -> uri.path(getApiPath())"); uriBuilder.append("uri -> uri.path(getApiPath())");
for (BypassApiParam p : pathParams) {
// PATH 파라미터는 path() 직접 치환되므로 별도 처리 불필요
}
for (BypassApiParam p : queryParams) { for (BypassApiParam p : queryParams) {
uriBuilder.append("\n .queryParam(\"%s\", %s)" uriBuilder.append("\n .queryParam(\"")
.formatted(p.getParamName(), p.getParamName())); .append(p.getParamName()).append("\", ").append(p.getParamName()).append(")");
} }
uriBuilder.append("\n .build()"); uriBuilder.append("\n .build()");
@ -292,10 +287,10 @@ public class BypassCodeGenerator {
.filter(p -> "BODY".equalsIgnoreCase(p.getParamIn())) .filter(p -> "BODY".equalsIgnoreCase(p.getParamIn()))
.findFirst().orElse(null); .findFirst().orElse(null);
String bodyArg = bodyParam != null ? bodyParam.getParamName() : "null"; String bodyArg = bodyParam != null ? bodyParam.getParamName() : "null";
return "return %s(%s, %s);".formatted(fetchName, bodyArg, uriBuilder); return "return " + fetchName + "(" + bodyArg + ", " + uriBuilder + ");";
} else { } else {
fetchName = isList ? "fetchGetList" : "fetchGetOne"; fetchName = isList ? "fetchGetList" : "fetchGetOne";
return "return %s(%s);".formatted(fetchName, uriBuilder); return "return " + fetchName + "(" + uriBuilder + ");";
} }
} }
@ -329,24 +324,20 @@ public class BypassCodeGenerator {
return params.stream() return params.stream()
.sorted((a, b) -> Integer.compare(a.getSortOrder(), b.getSortOrder())) .sorted((a, b) -> Integer.compare(a.getSortOrder(), b.getSortOrder()))
.map(p -> { .map(p -> {
String annotation;
String description = p.getDescription() != null ? p.getDescription() : p.getParamName(); String description = p.getDescription() != null ? p.getDescription() : p.getParamName();
switch (p.getParamIn().toUpperCase()) { String javaType = toJavaType(p.getParamType());
case "PATH" -> annotation = """ String paramName = p.getParamName();
@Parameter(description = "%s") return switch (p.getParamIn().toUpperCase()) {
@PathVariable %s %s""".formatted(description, toJavaType(p.getParamType()), p.getParamName()); case "PATH" -> "@Parameter(description = \"" + description + "\")\n"
case "BODY" -> annotation = """ + " @PathVariable " + javaType + " " + paramName;
@Parameter(description = "%s") case "BODY" -> "@Parameter(description = \"" + description + "\")\n"
@RequestBody %s %s""".formatted(description, toJavaType(p.getParamType()), p.getParamName()); + " @RequestBody " + javaType + " " + paramName;
default -> { default -> {
String required = Boolean.TRUE.equals(p.getRequired()) ? "true" : "false"; String required = Boolean.TRUE.equals(p.getRequired()) ? "true" : "false";
annotation = """ yield "@Parameter(description = \"" + description + "\")\n"
@Parameter(description = "%s") + " @RequestParam(required = " + required + ") " + javaType + " " + paramName;
@RequestParam(required = %s) %s %s""".formatted(
description, required, toJavaType(p.getParamType()), p.getParamName());
} }
} };
return annotation;
}) })
.collect(Collectors.joining(",\n ")); .collect(Collectors.joining(",\n "));
} }
@ -365,7 +356,7 @@ public class BypassCodeGenerator {
String path = pathParams.stream() String path = pathParams.stream()
.map(p -> "{" + p.getParamName() + "}") .map(p -> "{" + p.getParamName() + "}")
.collect(Collectors.joining("/", "/", "")); .collect(Collectors.joining("/", "/", ""));
return "(\"%s\")".formatted(path); return "(\"" + path + "\")";
} }
/** /**