신용대출 찾기 서비스 제휴사 Mock 서버 개발기 #2
안녕하세요, 토스 Financial Marketplace Platform Team Server Developer 류경린입니다.
이번 글에서는 지난 아티클에서 소개한 제휴사 Mock 서버를 더 효과적으로 활용하기 위해 어떤 고민을 했는지 를 소개하고자 해요. 이를 위해 통합 테스트 작성 과정과, 유지보수의 어려움을 줄이기 위해 어떤 접근 방식을 시도했는지 경험을 나눠보려 합니다.
통합 테스트
현재 신용대출 찾기 서비스의 경우, E2E(End-to-End) 테스트가 자동화 되어 있지 않습니다.
이로 인해 Mock 서버를 만들었지만 제휴사 코드를 변경할 때마다 사람이 직접 테스트를 해야하고, 이는 곧 테스트를 누락할 수 있음을 의미해요. 완전한 E2E 테스트를 도입하는 것이 가장 이상적이지만, 팀 상황 상 당장 적용하기는 어려웠습니다.
이에 Docker 환경에서 Mock 서버를 띄우고 통합 테스트를 Github Action에서 수행하는 방식으로 문제를 해결하고자 했어요.
Mock 서버 띄우기
토스는 Spring Cloud Config 를 활용해 프로퍼티를 주입하고 있고, Local, Alpha, Staging, Live 4개의 환경을 운영하고 있습니다. (제휴사 Mock 서버의 경우 Local, Alpha 환경만을 운영하고 있어요.)
내부 라이브러리에서 참조하는 프로퍼티들이 Spring Cloud Config 값을 의존하고 있기 때문에, Mock 서버를 띄울 때 Local, Alpha가 아닌 제 3의 Profile을 사용하는 것에는 어려움이 있었습니다.
이런 어려움 때문에 2개의 환경 중 하나를 희생해야했고, Alpha 환경은 E2E 테스트를 진행했어야 했기에 Local 환경을 포기하고자 했습니다. 하지만 새로운 서비스들이 Mock 서버 연동을 해야하는 상황에서 Local 환경을 사용할 수 없게 되는 것은, 앞으로의 생산성에 영향을 크게 미치는 문제였고 이 방법을 채택할 수 없었습니다.
Spring Cloud Config를 활용하면서, 내가 원하는 값에 대해서만 Mock 서버 기반 통합테스트 환경에서 오버라이드 할 수 있는 방법이 없을까를 고민하다 Multi Profile 을 사용하면 문제를 해결할 수 있음이 떠올랐습니다.
Mock 서버 기반 통합테스트를 위한 mockServerTest라는 Profile을 정의하고, 내부적으로 Docker 환경에서 사용할 인프라 정보를 오버라이드 함으로써 문제를 해결할 수 있었습니다.
application-find-loan-mockServerTest.properties
- mockServerTest 프로파일에 docker 환경에서 사용할 인프라 환경을 구성합니다.
spring.datasource.find-loan.jdbc-url=jdbc:mysql://mysql:3306/{database}
...
# find-loan
toss.servers.find-loan.domain=http://host.docker.internal:8080
application-mockServerTest.properties
- application-find-loan-mockServerTest.properties 를 config 에 병합함으로써, docker 환경에 맞는 인프라 가 spring config 에 설정됩니다.
spring.config.import+=classpath:application-find-loan-mockServerTest.properties
JPA 설정
지난 아티클에서 말씀드렸듯이, Mock 서버는 각 서비스가 모듈로 존재하는 멀티 모듈 프로젝트 구조입니다. 이때 서비스 별로 패키지 구조가 정의되다 보니, 동일한 패키지 경로가 겹칠 수 있는 문제가 있었습니다.
이로 인해 패키지 구조가 같은 Entity들이 각각의 DataSource에 중복으로 추가되는 문제가 발생했어요.
예를 들어 org.test.entity
라는 패키지를 사용하는 test-a
모듈과 test-b
모듈이 있을 경우, test-a
의 DataSource에 entity-a
와 entity-b
가 모두 생성되고, test-b
의 DataSource에도 동일한 entity-a
와 entity-b
가 중복으로 생성됩니다.
이렇게 불필요한 테이블이 생성되는 문제를 막기 위해 ddl-auto
설정을 끄고, init.sql
을 사용해 Spring Application이 실행될 때 필요한 테이블만 생성되도록 처리했습니다.
# JPA ddl-auto 비활성
spring.jpa.generate-ddl=false
spring.jpa.hibernate.ddl-auto=none
# mockServerTest 에서만 init 을 활성화
spring.sql.init.mode=always
spring.sql.init.schema-locations=classpath:init_base_tables.sql,classpath:init_find_loan_api_tables.sql,classpath:init_find_loan_api_data.sql
통합테스트
테스트는 원칙상 테스트 간 순서 의존성을 가지면 안됩니다.
하지만 신용대출 찾기 서비스의 경우, 퍼널 별로 통합 테스트를 하기 위해서는 반드시 ‘가심사 신청’ 퍼널을 진행해야 해요. 이 경우, 하나의 퍼널을 테스트하기 위해 N분씩 Action의 수행 시간이 길어지는 문제가 있습니다.
테스트 실행 시간이 길어지는 문제 때문에 ‘테스트는 독립적이어야 한다’ 라는 대명제를 깨고, 가심사는 통합 테스트에서 1회만 돌리고, 해당 원장 데이터를 사용해 각각의 퍼널에 대해 테스트를 하는 형태로 진행했습니다.
단일 클래스 내에서는 @Order 어노테이션을 사용해 순서를 지정할 수 있지만, 클래스 간의 테스트 순서를 보장할 수는 없었습니다. (모든 퍼널에 대해 단일 클래스 내에서 @Order를 사용하는 방식은 여러 퍼널에 대한 테스트 관심사가 하나의 파일 내에 존재하여 지양하고자 했습니다.)
junit의 @Suite를 사용하는 경우, 클래스 간의 테스트 순서를 보장할 수 있어 해당 방식을 채택했습니다.
- a test suite is a collection of tests grouped together and run as a single unit (reference)
@Suite
@SelectClasses(value = [수기입력가심사::class, 대출실행::class, 대출거절::class])
class
아래 의존성을 추가하면 @Suite를 사용할 수 있습니다.
val junitJupiterVersion = "5.10.0"
val junitPlatformVersion = "1.10.0"
testImplementation("org.junit.jupiter:junit-jupiter:$junitJupiterVersion")
testImplementation("org.junit.jupiter:junit-jupiter-api:$junitJupiterVersion")
testImplementation("org.junit.jupiter:junit-jupiter-engine:$junitJupiterVersion")
testImplementation("org.junit.platform:junit-platform-suite-api:$junitPlatformVersion")
testImplementation("org.junit.platform:junit-platform-suite-engine:$junitPlatformVersion")
testImplementation("org.junit.platform:junit-platform-commons:$junitPlatformVersion")
testImplementation("org.junit.platform:junit-platform-engine:$junitPlatformVersion")
testRuntimeOnly("org.junit.platform:junit-platform-launcher:$junitPlatformVersion")
Github Action을 사용한 통합 테스트
Github Action을 사용해 Mock 서버를 활용한 통합 테스트를 실행하고, 테스트 결과에 대한 가시성을 높이기 위해 아래 2개의 정책을 정의했습니다.
- feature branch → develop으로 merge 할 때, 테스트가 실패한다면 PR 작성자에게 테스트가 실패했음을 알려준다.

- develop → master로 merge 할 때, 테스트 성공 여부 및 제휴사 코드 변경 여부를 배포 파이프 라인에서 알려준다.

Mock 서버 데이터 자동 마이그레이션
Mock 서버를 안정적으로 운영하려면 E2E 테스트와 통합 테스트에 필요한 Mock 데이터를 꾸준히 유지보수 해야 합니다. 하지만 제휴사 코드를 수정할 때마다 데이터를 직접 마이그레이션하고 관리하는 일은 번거롭고 누락되기 쉽습니다.
이런 유지보수 비용을 최소화하기 위해, 제휴사 코드에 변경이 발생하면 E2E 테스트용 데이터 마이그레이션과 통합 테스트용 init
쿼리를 자동으로 수정해 PR(Pull Request)을 생성하도록 했습니다.
제휴사 코드 변경 감지
먼저 GitHub Actions에서 git diff
를 사용해 특정 패키지 내 파일에 변경이 있었는지 확인합니다.
- name: Check for partner code changes
id: check_partner
run: |
mkdir -p output
if git diff-tree --no-commit-id --name-only -r HEAD^ HEAD | grep '.*/findloan/partner/.*Dto.*'; then
# TODO(제휴사 DTO 명세 추출)
# TODO(제휴사 DTO 명세 기반 자동 PR 생성)
fi
제휴사 DTO 명세 추출
데이터 마이그레이션을 위해서는 Mock 서버가 각 서비스의 제휴사 DTO 명세를 알고 있어야 합니다.
각 서비스에서는 Kotlin Reflections를 사용해 특정 패키지 내 클래스를 스캔하고, 스키마 추출 대상임을 표시하기 위해 @GenerateSchema
라는 커스텀 어노테이션을 사용합니다.
val reflections = Reflections(packageName)
reflections
.getTypesAnnotatedWith(GenerateSchema::class.java)
.map { it.kotlin }
.map { clazz ->
val schema = schemaGen.generateSchema(clazz.java)
val jsonSchema = mapper.writerWithDefaultPrettyPrinter().writeValueAsString(schema)
...
}
스키마 추출 방법은 여러 가지가 있지만, 저희 팀은 다음 이유로 Jackson 라이브러리의 JSON Schema를 선택했습니다.
- 여러 서비스에서 동일한 포맷으로 스키마를 공유할 수 있고,
@JsonPropertyDescription
어노테이션 등을 활용해 프로퍼티에 설명이나 예약어를 쉽게 추가할 수 있음 - 별도의 추가 개발 없이 라이브러리만으로 스키마를 손쉽게 추출할 수 있음
아래는 실제로 추출된 JSON Schema 예시입니다.
{
"type" : "object",
"id" : "urn:jsonschema:com:test:service:common:TestBank:TestBankDto:PreScreeningResponse",
"properties" : {
"code" : {
"type" : "string"
},
"data" : {
"type" : "any"
}
}
}
이렇게 추출한 JSON Schema는 S3에 업로드하여 이후 프로세스에서 활용됩니다.
JSON Schema 를 활용한 json 샘플 데이터 생성
S3에서 JSON Schema를 다운로드한 뒤 이를 기반으로 각 제휴사의 퍼널별 샘플 데이터를 생성합니다.
- JSON Schema의 id는
패키지명:클래스명
으로 구성되어 있어 어떤 제휴사/퍼널에 대한 스키마인지 파악할 수 있습니다. properties
는 중첩된 형태로 key = 프로퍼티명, value = 타입으로 구성되어 있으므로 이를 역으로 분석해JsonNode
를 재구성합니다.
val factory = JsonNodeFactory.instance
fun generateExample(node: JsonNode): JsonNode {
val json = factory.objectNode()
props.fields().forEach { (key, prop) ->
val desc = prop.get("description")?.asText()
val type = prop.get("type")?.asText()
}
if (type == "object" || type == "array") {
...
val example = generateExample(childNode)
...
example.response?.fields()?.forEach { (k, v) -> json.set<JsonNode>(k, v) }
} else {
// 샘플 데이터 주입
json.set<JsonNode>(key, schemaToSample(prop))
}
}
이후 기존에 정의되어 있던 JSON 값과 새로 생성된 JsonNode 를 병합해, 기존에 있던 프로퍼티는 그대로 두고 새로 추가된 프로퍼티만 샘플 데이터를 채워넣도록 합니다.
기존 데이터가 중요한 경우를 고려해 기존 값에 우선순위를 둔 것이 핵심입니다.
데이터 마이그레이션
생성한 샘플 데이터를 사용해 E2E 테스트용 데이터를 마이그레이션하고, 통합 테스트에서 사용할 init.sql
파일을 업데이트합니다.
xxx.forEach { (type, status) ->
val existed = partnerResponses.firstOrNull { it.type == type && it.status == status } ?: return@forEach
val newJsonLayout = jsonLayout?.let { JsonMerger.merge(it, existed.jsonLayout) }
val newResponse = response?.let { JsonMerger.merge(it, existed.response) }
existed.response = newResponse
existed.jsonLayout = newJsonLayout
}
init.sql
은 마이그레이션된 데이터에 맞게 자동으로 생성됩니다.
fun migrate(localPath: File) {
val file = getFile(localPath)
val query = generate()
file.writeText(query)
}
private fun generate() {
val queryBuilder = StringBuilder()
...
return queryBuilder.toString()
}
Pull Request 자동 생성
마지막으로 자동화된 데이터 마이그레이션 결과를 자동으로 PR로 올리는 기능을 구현했습니다. 이를 위해 JGit 라이브러리를 사용해 애플리케이션 내에서 Git 기능을 실행합니다.
토스는 GitHub Enterprise 환경을 사용하고 있기 때문에 GitHub App을 통해 토큰을 발급받고, 이를 이용해 인증된 Git 명령을 실행합니다.
토큰 발급은 아래 순서를 따라주면 됩니다.
1️⃣ GitHub Enterprise App을 추가하고 Private Secret Key를 발급
2️⃣ Secret Key를 PKCS#1 → PKCS#8로 변환한 뒤 RSA Private Key 발급
openssl pkcs8 -topk8 -inform PEM -outform PEM -in {{origin secret}}.pem -out {{new secret}}.pem -nocrypt
을 활용해 포맷 변환- 아래 코드를 활용해 RSA Private Key 발급
private fun readPrivateKey(pem: String): RSAPrivateKey { val base64Content = pem .replace("-----BEGIN PRIVATE KEY-----", "") .replace("-----END PRIVATE KEY-----", "") .replace("\\s".toRegex(), "") val decoded = Base64.getDecoder().decode(base64Content) val kf = KeyFactory.getInstance("RSA") val spec = PKCS8EncodedKeySpec(decoded) return kf.generatePrivate(spec) as RSAPrivateKey }
3️⃣ JWT 발급
private fun createJsonWebToken(): String {
val algorithm = Algorithm.RSA256(null, readPrivateKey(secret))
val now = Instant.now()
return JWT.create()
.withIssuer(APP_ID)
.withIssuedAt(Date.from(now))
.withExpiresAt(Date.from(now.plusSeconds(300)))
.sign(algorithm)
}
4️⃣ JWT를 사용해 Installation Token 발급
webClient.post()
.uri("/api/v3/app/installations/$installationId/access_tokens")
.addHeaders(mapOf("Authorization" to "Bearer $jwt"))
.retrieve()
.bodyToMono<Map<String, Any>>() // JSON map 으로 받음
.block()
return response["token"] as
전체 플로우
전체 플로우를 코드 레벨로 나타내면 아래와 같습니다.
fun generate() {
val files = downloader.downloads(service)
val localPath = Files.createTempDirectory("fmd-mock-server").toFile()
// json schema 를 사용해 E2E 환경의 테스트 데이터 변경을 위한 엔티티 마이그레이션 을 진행한다.
val jsonMigrateHandler = jsonMigrateHandlers.first { it.canHandle(service) }
val isChanged = jsonMigrateHandler.migrate(files)
try {
if (isChanged) {
gitService.clone(localPath)
// 지라 티켓을 발급한다.
val issueKey = {
...
jiraService.createIssue()
}
val branch = "feature/$issueKey"
gitService.checkout(branch)
// entity 를 기반으로 통합테스트 를 위한 init.sql 파일 마이그레이션 을 진행한다.
val mockServerQueryMigrateHandler = mockServerQueryMigrateHandlers.first { it.canHandle(service) }
mockServerQueryMigrateHandler.migrate(localPath)
gitService.push(branch, "[$issueKey] [${service.description}] migrate mock data")
// PR 이 이미 올라가있는 경우, PR 생성 요청을 하지 않는다.
gitService.pullRequest("[$issueKey] [${service.description}] mock data migration PR", branch, pullRequestLink)
} else {
logger.info { "변경사항이 존재하지 않습니다. $service: ${service.name}, pullRequestLink: $pullRequestLink, assignee: $assignee" }
}
} finally {
gitService.clear()
localPath.deleteRecursively()
files.forEach { it.deleteRecursively() }
}
}
이 과정을 통해 제휴사 DTO에 변경이 발생하면 데이터 마이그레이션부터 init.sql
갱신, PR 생성까지 자동으로 처리 됩니다. 이로써 사람이 직접 테스트 데이터를 관리해야 하는 부담을 줄이고, 누락 없이 안정적으로 mock 서버 데이터를 유지할 수 있습니다.


마치며
이번 글에서는 신용대출 서비스에서 Mock 서버를 어떻게 통합 테스트와 연결하고, 데이터 마이그레이션을 자동화해 운영 비용을 줄였는지에 대한 내부 고민과 시도들을 공유했습니다.
물론 이상적인 시나리오는 E2E 테스트가 모든 케이스를 완전히 보장해주는 것이지만, 현실적인 팀의 상황과 인프라 제약 속에서 적절한 우회와 자동화가 필요했습니다. 특히 multi profile 전략, Jackson 기반 JSON Schema 추출, JGit과 Github App을 활용한 PR 자동화는 작은 노력이지만 유지보수 비용을 크게 줄여준 중요한 선택지였습니다. 이런 과정들은 서비스 규모가 커지고, 협업해야 할 대상이 많아질수록 더 큰 효과를 발휘합니다.
앞으로도 mock 서버를 통해 실제 제휴사 환경과 최대한 유사한 테스트 환경을 유지하면서, 개발자가 신뢰할 수 있는 데이터와 테스트 구조를 계속 개선해 나갈 예정입니다. 이 글이 비슷한 고민을 하고 계신 분들께 조금이나마 도움이 되길 바랍니다.
읽어주셔서 감사합니다.