| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | |||||
| 3 | 4 | 5 | 6 | 7 | 8 | 9 |
| 10 | 11 | 12 | 13 | 14 | 15 | 16 |
| 17 | 18 | 19 | 20 | 21 | 22 | 23 |
| 24 | 25 | 26 | 27 | 28 | 29 | 30 |
| 31 |
- jre
- 이진트리
- docker
- event
- EDA
- code blocks
- 알고리즘
- 삽입
- 탐색
- 티스토리챌린지
- 프로그래머스
- Kotlin
- jdk
- Java
- 연습문제
- 플러스 백엔드
- stack
- Gradle
- Kafka
- JPA
- 트리
- 백준
- Unity
- 오블완
- redis
- bean
- 코딩테스트
- MSA
- 아키텍처
- Spring
- Today
- Total
Repository
Routemate: 5%의 복잡도로 80%의 기능을 구현한 경량 DB 라우터 개발기 본문
영감은 아마추어들을 위한 것이다. 우리 나머지는 그냥 나가서 일을 한다. - 차크 클로스 -
서론
이 글은 제가 최근 오픈소스로 공개한 경량 데이터베이스 라우터, Routemate의 개발 여정을 담고 있습니다.
실무에서 서비스가 성장함에 따라 데이터베이스의 읽기 부하를 분산하기 위한 Read/Write 분리는 선택이 아닌 필수가 됩니다.
하지만 이 당연한 요구사항을 해결하기 위한 기존 솔루션들은 종종 너무 무겁거나, 설정이 복잡하거나, 혹은 매번 프로젝트마다 비슷한 코드를 반복해서 구현해야 하는 불편함이 있었습니다.
Routemate는 바로 이 "불편함"에서 시작되었습니다. 이 프로젝트는 단순히 또 하나의 라이브러리를 만드는 것을 넘어, 엔지니어링 세계의 'accidental complexity'에 대한 저항이기도 합니다.
"5%의 복잡도로 80%의 핵심 기능을 제공하자"는 목표 아래, 가장 실용적이고 가벼운 해결책을 고민했던 과정과 그 결과물을 공유하고자 합니다.
1. 개요: 왜 Routemate를 만들게 되었는가?
모든 소프트웨어 개발은 '불편함'을 해결하는 과정에서 시작됩니다. DB Read/Write 분리라는 명확한 과제 앞에서, 저는 몇 가지 선택지를 마주했습니다. 하지만 어떤 선택지도 속 시원한 해답을 주지 못했고, 그 불편함이 Routemate를 만드는 직접적인 계기가 되었습니다.
기존 솔루션들의 불편함
기존 솔루션들은 각자의 장점이 명확했지만, '단순한 Read/Write 분리'라는 목표 앞에서는 저마다 아쉬운 점이 있었습니다.
| 솔루션 | 장점 | 단점 |
| ShardingSphere | 분산 트랜잭션, 샤딩 등 기능의 끝판왕 | 단순 R/W 분리에는 너무 무겁고, 학습 비용이 큽니다. "닭 잡는 데 소 잡는 칼"을 쓰는 기분입니다. |
| Spring Cloud LoadBalancer / Ribbon | Spring 생태계에 통합되어 편리함 | 갑자기 제 단순한 라우팅 문제가 서비스 디스커버리와 클라이언트 사이드 로드 밸런싱이라는 거대한 세계를 이해해야 하는 과제로 변했습니다. 배보다 배꼽이 더 커지는, 전형적으로 해결책이 문제보다 더 커지는 상황이었습니다. |
| 직접 구현 | 필요한 기능만 가볍게 만들 수 있음 | 매번 비슷한 코드를 작성하고 검증하는 과정이 비효율적입니다. 잠재적 버그(동시성, 트랜잭션 전파 등) 발생 가능성이 높습니다. |
이러한 불편함 속에서 저는 명확한 목표를 세웠습니다. "가장 보편적인 80%의 사용 사례를, 5%의 설정 복잡도로 해결하자."
Routemate의 접근법: 가볍고, 명확하게
Routemate는 거대하고 복잡한 기능을 모두 담는 대신, 개발자가 가장 필요로 하는 핵심 기능에 집중했습니다.
- Zero-Code R/W 자동 분리: @Transactional(readOnly = true) 애노테이션을 기반으로 코드 수정 없이 자동으로 Read Replica로 라우팅 합니다.
- 내장된 헬스 체크와 자동 Failover/Fallback: 백그라운드 스레드가 주기적으로 Read Replica의 상태를 점검하고, 장애 발생 시 자동으로 로테이션에서 제외합니다. 모든 Replica가 다운되면 안전하게 Master DB로 Fallback 합니다.
- 확장 가능한 로드 밸런싱 전략: Round-Robin, Weighted Round-Robin 등 다양한 로드 밸런싱 전략을 인터페이스 기반으로 쉽게 교체하고 확장할 수 있습니다.
- 간편한 설정: Spring Boot Starter를 제공하여 application.yml 파일에 몇 줄의 설정만 추가하면 즉시 동작합니다.
Routemate는 ShardingSphere와 같은 거대한 솔루션을 대체하려는 것이 아닙니다. 대신, 특정 문제 영역에서 가장 실용적이고 군더더기 없는 대안을 제공하는 것을 목표로 합니다.
이제 Routemate가 어떤 철학을 바탕으로 설계되었는지 더 깊이 살펴보겠습니다.
2. 철학: 무엇을 가장 중요하게 생각했는가?
성공적인 오픈소스 프로젝트는 단순히 기능의 합이 아니라, 명확한 철학 위에서 만들어진다고 믿습니다.
Routemate를 개발하며 저는 세 가지 핵심 가치를 꾸준히 되새겼습니다.
이 가치들은 모든 설계 결정의 기준이 되었습니다.
1. 가벼움
Routemate의 핵심 모듈은 Spring Boot에 대한 의존성 없이 순수 Java와 최소한의 라이브러리만으로 구성되었습니다.
이는 특정 프레임워크에 종속되지 않고, 어떤 환경에서든 쉽게 통합될 수 있는 유연성을 확보하기 위함이었습니다.
특히 WebFlux, Kafka, 심지어 JPA 같은 무거운 의존성들은 core 모듈에서 철저히 배제하고, starter 레벨에서 선택적으로 통합할 수 있도록 설계했습니다.
무거운 의존성을 추가하는 것은 언제나 신중하게 결정해야 할 트레이드오프라고 생각합니다.
2. 문서화
좋은 코드는 기능의 절반이고, 좋은 문서는 나머지 절반입니다. 사용자가 소스 코드를 열어보지 않고도 프로젝트의 가치를 이해하고 사용법을 익힐 수 있어야 합니다. Routemate는 사용자가 README와 예제 프로젝트만 보고 5분 안에 자신의 프로젝트에 적용하는 것을 목표로 문서화를 진행했습니다.
3. 사용 편의성
개발자의 경험은 무엇보다 중요합니다. Routemate는 Spring Boot Starter와 Auto-Configuration을 통해 사용자가 복잡한 @Bean 설정 없이 application.yml 파일 수정만으로 즉시 사용할 수 있도록 설계했습니다.
개발자는 라우팅의 내부 동작을 몰라도, 그저 의존성을 추가하고 데이터소스 정보만 입력하면 됩니다.
철학의 반영: "Slave DB는 신기루와 같다"
이러한 철학이 실제 아키텍처에 어떻게 반영되었는지 보여주는 좋은 예시가 있습니다.
개발 초기, "Hibernate의 DDL 자동 생성 기능이 왜 Master DB에만 스키마를 생성하고 Slave DB는 무시할까?"라는 고민에 빠졌습니다. 결론은 이것이 버그가 아니라, 의도된 정상 동작이라는 것이었습니다.
Slave DB는 Hibernate 입장에서 '신기루'와 같습니다. 볼 수(Read)는 있지만, 만질 수(Write)는 없는 존재인 셈이죠.
데이터베이스 스키마 변경과 같은 쓰기 작업은 오직 Master DB에서만 일어나야 하며, 그 결과가 복제(Replication)를 통해 Slave로 전파되는 것이 올바른 흐름입니다.
만약 Routemate나 Hibernate가 임의로 Slave DB의 스키마를 변경하려 한다면, 이는 전체 데이터 정합성을 깨뜨리는 위험한 행위입니다.
이 깨달음은 Routemate가 Hibernate의 DDL 생성을 Master DB에만 위임하고, Slave DB의 존재를 간섭하지 않도록 설계하는 근거가 되었습니다. 나아가 이 "신기루" 원칙은 프로젝트의 테스트 범위까지 명확하게 만들어 주었습니다.
Routemate 라이브러리의 책임은 '올바른 DataSource를 선택하는 로직'을 테스트하는 것이지, 사용자의 책임인 데이터베이스 복제 자체를 테스트하는 것이 아님을 분명히 했습니다. 이처럼 명확한 철학은 복잡한 기술적 문제 앞에서 길을 잃지 않게 해주는 등대와 같았습니다.
이러한 철학이 어떻게 구체적인 코드와 아키텍처로 구현되었는지 다음 섹션에서 자세히 살펴보겠습니다.
3. 주요 코드와 아키텍처
Routemate의 핵심 철학은 실제 코드 레벨에서 몇 가지 주요 컴포넌트들의 유기적인 상호작용으로 구현됩니다. 전체적인 요청 처리 흐름은 다음과 같습니다.
Application Layer (@Transactional)
↓ (Method Call)
RoutingAspect (AOP)
↓ (Sets context: "READ" or "WRITE")
RoutingContext (ThreadLocal)
↓ (Provides key on demand)
DataSourceRouter (Core Logic)
↓ (Selects specific DataSource using key)
DataSource Map (Master/Slaves)
각 컴포넌트가 어떤 역할을 수행하며, 어떤 설계적 고민이 담겨 있는지 살펴보겠습니다.
RoutingContext: 트랜잭션의 맥락을 기억하는 저장소
RoutingContext는 현재 스레드에서 어떤 데이터소스를 사용해야 하는지에 대한 정보를 담는 컨텍스트 홀더입니다.
중첩된 트랜잭션을 정확하게 처리하기 위해 ThreadLocal<Deque<String>> 구조를 사용했습니다.
- Deque (스택) 구조: 트랜잭션이 시작될 때 push 하고 끝날 때 pop 하여, 내부 트랜잭션이 종료되어도 외부 트랜잭션의 컨텍스트로 안전하게 복귀할 수 있습니다.
- ThreadLocal.remove(): 스택이 비워지면 remove()를 호출하여 스레드 풀 환경에서 발생할 수 있는 메모리 누수를 원천적으로 방지합니다.
public class RoutingContext {
private static final ThreadLocal<Deque<String>> CONTEXT = ThreadLocal.withInitial(ArrayDeque::new);
public static final String READ = "READ";
public static final String WRITE = "WRITE";
public static void set(String dataSourceKey) {
if (dataSourceKey == null || dataSourceKey.trim().isEmpty()) {
throw new IllegalArgumentException("dataSourceKey cannot be null or empty");
}
CONTEXT.get().push(dataSourceKey);
}
public static String get() {
Deque<String> stack = CONTEXT.get();
return stack.isEmpty() ? null : stack.peek();
}
public static void clear() {
Deque<String> stack = CONTEXT.get();
if (!stack.isEmpty()) {
stack.pop();
}
if (stack.isEmpty()) {
CONTEXT.remove();
}
}
}
RoutingAspect: @Transactional을 감지하는 레이더
이 Aspect는 @Transactional 애노테이션이 붙은 메서드 호출을 가로채, readOnly 속성을 분석하여 RoutingContext에 적절한 키를 설정하는 역할을 합니다.
초기에는 @Around("@annotation(transactional)") 포인트컷을 사용하고 Transactional transactional 애노테이션을 메서드 파라미터로 직접 바인딩하려는 순진한 시도를 했습니다. 하지만 이 방법은 Spring AOP가 생성한 프록시 객체의 메서드 시그니처에는 애노테이션이 없는 경우가 많아 IllegalStateException을 유발하며 실패했습니다.
최종적으로는 Spring의 TransactionSynchronizationManager가 동작하는 방식과 유사하게, 프록시가 아닌 실제 타겟 클래스와 메서드에서 애노테이션을 안정적으로 찾는 AnnotationUtils를 사용하는 견고한 방식으로 개선했습니다.
@Order(Ordered.HIGHEST_PRECEDENCE)를 설정하여 Spring의 트랜잭션 Aspect(Ordered.LOWEST_PRECEDENCE)보다 항상 먼저 실행되도록 보장하는 것이 핵심입니다.
@Aspect
@Order(Ordered.HIGHEST_PRECEDENCE)
public class RoutingAspect {
@Around("@annotation(org.springframework.transaction.annotation.Transactional) || @within(org.springframework.transaction.annotation.Transactional)")
public Object proceed(ProceedingJoinPoint joinPoint) throws Throwable {
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
Class<?> targetClass = joinPoint.getTarget().getClass();
Method specificMethod = ClassUtils.getMostSpecificMethod(method, targetClass);
Transactional transactional = AnnotatedElementUtils.findMergedAnnotation(specificMethod, Transactional.class);
if (transactional == null) {
transactional = AnnotatedElementUtils.findMergedAnnotation(specificMethod.getDeclaringClass(),
Transactional.class);
}
try {
if (transactional != null && transactional.readOnly()) {
RoutingContext.set(RoutingContext.READ);
} else {
RoutingContext.set(RoutingContext.WRITE);
}
return joinPoint.proceed();
} finally {
RoutingContext.clear();
}
}
}
DataSourceRouter: 최종 결정을 내리는 컨트롤 타워
DataSourceRouter는 Spring의 AbstractRoutingDataSource를 상속받아 구현된 핵심 로직입니다. 이 클래스는 실제 쿼리가 실행되기 직전에 어떤 데이터소스를 사용할지 결정합니다.
- determineCurrentLookupKey(): 이 메서드가 RoutingContext.get()을 호출하여 현재 스레드에 설정된 라우팅 키를 가져옵니다.
- 헬스체커 연동: unhealthyKeys라는 Set을 ConcurrentHashMap.newKeySet()으로 관리하여 스레드 안전성을 확보했습니다. 헬스체커가 특정 Read Replica를 비정상으로 판단하면 이 Set에 추가되고, 라우팅 시 해당 키는 동적으로 제외됩니다.
public class DataSourceRouter extends AbstractRoutingDataSource {
private final DataSource writeDataSource;
private final Map<String, DataSource> readDataSources = new ConcurrentHashMap<>();
private final List<String> readDataSourceKeys = new CopyOnWriteArrayList<>();
private final Set<String> unhealthyKeys = ConcurrentHashMap.newKeySet();
private LoadBalancer loadBalancer;
@Override
protected Object determineCurrentLookupKey() {
String key = RoutingContext.get();
if (RoutingContext.READ.equals(key)) {
List<String> healthyKeys = new ArrayList<>();
for (String k : readDataSourceKeys) {
if (!unhealthyKeys.contains(k)) {
healthyKeys.add(k);
}
}
if (healthyKeys.isEmpty()) {
log.warn("No healthy read replicas available. Falling back to WRITE DataSource.");
return "WRITE";
}
return loadBalancer.select(healthyKeys);
}
return "WRITE";
}
// ... markHealthy, markUnhealthy 메서드
}
LoadBalancer 전략: 트래픽을 분산하는 교통경찰
로드 밸런싱 로직은 LoadBalancer 인터페이스를 통해 분리되어, 다양한 전략을 쉽게 구현하고 교체할 수 있습니다.
RoundRobinLoadBalancer는 AtomicInteger를 사용해 멀티스레드 환경에서 Race Condition 없이 안전하게 다음 인덱스를 순회합니다.
WeightedRoundRobinLoadBalancer는 AtomicReference를 사용해 가중치가 반영된 분산 목록을 불변 객체로 관리합니다. 이는 고성능 동시성 라이브러리에서 흔히 사용되는 Copy-on-Write 패턴입니다.
가중치가 변경될 때, 참조 자체를 새로운 목록으로 통째로 교체하므로, 읽기 스레드는 락 없이 항상 일관된 스냅샷을 보며 높은 동시 읽기 성능을 보장받습니다.
public class RoundRobinLoadBalancer implements LoadBalancer {
private final AtomicInteger counter = new AtomicInteger(0);
@Override
public String select(List<String> keys) {
if (keys == null || keys.isEmpty()) {
return null;
}
if (keys.size() == 1) {
return keys.get(0);
}
int index = ThreadLocalRandom.current().nextInt(keys.size());
return keys.get(index);
}
}
잘 설계된 아키텍처라도 개발 과정에서 수많은 문제에 부딪히기 마련입니다. 다음 섹션에서는 이러한 문제들을 어떻게 해결했는지의 여정을 공유하겠습니다.
4. 트러블슈팅: 고민과 해결의 기록
오픈소스 개발 과정은 단순히 코드를 작성하는 것을 넘어, 예상치 못한 문제들을 해결하고 더 나은 설계를 고민하는 여정입니다. Routemate를 만들며 겪었던 몇 가지 주요 트러블슈팅 사례는 프로젝트를 더욱 단단하게 만드는 계기가 되었습니다.
Hibernate와 정면 충돌했던 Datasource 설계 문제
- 문제 상황: Hibernate는 애플리케이션 시작 시점에 설정 파일에 정의된 Datasource를 로딩하여 내부 초기화 작업을 수행합니다. 초기 Routemate는 Datasource를 직접 제어하는 구조를 목표로 설계되었고, 이로 인해 애플리케이션 부팅 단계에서 Hibernate와 정면으로 충돌하는 문제가 발생했습니다.
- 당시 설계 의도:Routemate는 Read/Write 분리를 유연하게 처리하기 위해 Datasource를 직접 핸들링하고, 필요에 따라 동적으로 Datasource를 구성하는 것을 핵심 철학으로 삼고 있었습니다. 하지만 이 접근은 ORM 프레임워크의 생명주기를 충분히 고려하지 못한 설계였습니다.
- 문제 증상:
- 애플리케이션 기동 시 Master Datasource 생성 시점이 불안정
- Hibernate가 Datasource 변경을 감지할 때마다 재등록을 시도
- 결과적으로 Datasource 등록 로직이 무한 루프에 빠지는 현상 발생
- 근본 원인 분석:
- Hibernate의 Datasource 등록 정책
- Hibernate는 애플리케이션 시작 시 Datasource를 등록
- 이후 Datasource 변경이 감지되면 재등록 수행
- Routemate의 커스텀 Datasource 구조로 인해 이 과정이 반복됨
- Routemate의 Hibernate 영역 침범
- Hibernate는 DDL 생성과 같은 핵심 작업을 부팅 시점에 수행
- Routemate가 Datasource 생성을 주도하면서
ddl-auto 설정 여부에 따라 동작이 불안정해짐
- Hibernate의 Datasource 등록 정책
- 결론적으로, ORM이 책임져야 할 영역을 라이브러리가 침범한 구조적 문제였습니다.
- 해결 과정 및 설계 전환:
- Write 노드의 주체를 Hibernate로 위임하여 큰 흐름에 영향을 끼치지 않는 설계로 전환하였습니다.
- Routemate는 Read 노드만을 관리하도록 하여 책임 소재를 명확히 분리하였습니다.
- Read 노드가 존재하지 않을 경우 Master를 fallback으로 사용하도록 기존 환경에서 필요한 자원을 사용하도록 구상하였습니다.
동시성과의 싸움: "부하를 주니 라운드로빈이 왜 한쪽으로 쏠릴까?"
- 문제 증상: 부하 테스트를 진행하자, 라운드로빈 로드 밸런싱이 여러 Read Replica에 균등하게 분산되지 않고 특정 노드로 쏠리는 현상이 발견되었습니다.
- 근본 원인: 단순 int 변수를 사용하여 인덱스를 관리하고 있었습니다. 멀티스레드 환경에서 여러 스레드가 동시에 index++ 연산을 수행하면서 Race Condition가 발생했고, 일부 스레드가 동일한 인덱스 값을 읽어가면서 분산이 깨졌습니다.
- 해결 과정: int 변수를 java.util.concurrent.atomic.AtomicInteger로 교체했습니다. getAndIncrement() 메서드는 원자적으로 값을 읽고 1 증가시키는 연산을 보장하므로, 여러 스레드가 동시에 접근해도 데이터 부정합 없이 스레드 안전하게 인덱스를 순회시킬 수 있었습니다.
배포 지옥: "Maven Central, 왜 나를 받아주지 않는가?"
Maven Central에 라이브러리를 배포하는 과정은 그야말로 인내심을 시험하는 장대한 서사시였습니다. 수많은 실패 로그와 싸우며 얻은 교훈들입니다.
첫 번째 관문은 GPG 서명이었습니다.
수많은 온라인 튜토리얼이 맹세처럼 이야기하던 secring.gpg 파일이 GPG 2.4 버전부터는 분산 키 관리 방식으로 파일이 생성된다는 사실을 몰랐습니다. 빌드는 그저 오류 한 줄 "Could not read PGP secret key"를 뱉어냈습니다. 몇 시간의 삽질 끝에 useInMemoryPgpKeys 옵션으로 환경 변수에서 직접 키를 읽어오는 현대적인 방식으로 전환해야 했습니다.
파일이 존재하지만, 못 읽었다는 동일한 오류를 뱉어내는 것은 Gradle의 코드를 분석하여 파악하였습니다.
해당 내용은 현재 Gradle.Gradle Opensource에 기여 중이므로, 추후 결과를 공유하며 글을 작성하도록 하겠습니다.
다음은 401 Unauthorized와의 싸움이었습니다.
인증이 Maven Central의 Token으로 변경되면서 이에 대한 문제가 많은 상황이었습니다. Maven Central에서는 공식적으로 Maven 방식을 제공하고 있으며, Gradle방식은 Opensource를 사용하기를 권장하고 있었습니다.
이에 많이 사용하는 "com.vanniktech.maven.publish" 라이브러리를 사용하기로 결정하였습니다.
여기서 문제가 여럿 발생하게 되는데, Window환경 오류, GPG2.4.8 사용으로 인한 키 파일 내부 정책 변경으로 인한 오류등을 경험하게 되었습니다.
이러한 문제점은 vanniktech의 gpg key파일을 읽어오는 내부 정책의 최신 GPG반영이 안된 것으로 판단하였습니다.
해당 내용 또한 Opensource에 기여를 목표로 하고 있으나, 지금 당장 해결을 해야 하기 때문에 기존 pgp인증을 위해 사용하던 signing을 활용하여 vanniktech환경의 Maven Publish 환경에서 pgp 인증 설정 영역만 대체하면서 해당 오류를 임시적으로 해결하였습니다.
mavenPublishing {
// ... 설정들
publishToMavenCentral()
// vanniktech 환경의 signing 기능 오픈
signAllPublications()
}
// signing 기능 설정을 덮어씀으로 GpgCmd 기능이 최종적으로 작동
signing {
useGpgCmd()
sign publishing.publications
}
마지막으로 ClassNotFoundException이 기다리고 있었습니다. starter 모듈이 core 모듈을 implementation으로 의존하고 있어, 라이브러리 사용자에게 core의 클래스들이 전달되지 않았던 것입니다. 의존성을 api로 변경하여 의존성이 전이되도록 수정하고 나서야 길고 길었던 배포 지옥이 막을 내렸습니다.
이러한 트러블슈팅 경험은 단순히 버그를 잡는 것을 넘어, 분산 시스템의 동시성 제어, Spring AOP의 내부 동작, 그리고 Gradle 빌드 시스템과 Maven 배포 파이프라인에 대한 깊은 이해로 이어졌습니다.
5. 결론: Routemate를 공개하며
Routemate 개발 여정은 단순히 하나의 도구를 만드는 것을 넘어, 문제를 정의하고, 철학을 세우고, 아키텍처를 설계하며, 예상치 못한 난관을 해결해 나가는 값진 경험이었습니다.
오픈소스는 코드를 세상에 공개하는 행위를 넘어, 문제 해결 과정을 커뮤니티와 공유하고 함께 성장하는 과정이라는 것을 다시 한번 깨닫게 되었습니다. Routemate는 과잉 엔지니어링의 세계에서 실용주의에 던지는 한 표입니다.
Routemate는 다음과 같은 고민을 하는 팀에게 유용한 도구가 될 수 있습니다.
- 서비스의 읽기 부하가 점차 증가하여 DB 확장이 필요한 팀
- 샤딩과 같은 복잡한 솔루션은 아직 필요 없지만, 빠르고 간단하게 Read/Write 분리를 도입하고 싶은 팀
- 매번 비슷한 DB 라우팅 코드를 직접 구현하는 데 비효율을 느끼는 팀
Routemate는 이제 막 첫걸음을 뗀 프로젝트입니다. 앞으로 커뮤니티의 피드백을 통해 더욱 발전해 나가기를 기대합니다.
GitHub 저장소: https://github.com/KrongDev/routemate
프로젝트를 사용해 보시고 피드백, 이슈 제보, 그리고 Pull Request 등 어떤 형태의 기여든 언제나 환영합니다. 이 글을 읽는 여러분의 관심이 Routemate를 더욱 가치 있는 프로젝트로 만들어나가는 가장 큰 원동력이 될 것입니다.
'Server' 카테고리의 다른 글
| [Kotlin 입문] 아토믹 코틀린 1챕터 — 기본 문법 정리 (0) | 2026.03.02 |
|---|---|
| [Kotlin 입문] Kotlin이란 어떤 언어인가? (0) | 2026.03.02 |
| [gradle] generateMetadataFileForMavenPublication FAILED 오류 해결 가이드 (0) | 2025.12.11 |
| Platform MSA 분산 트랜잭션, 왜 어렵고 어떻게 풀까 (0) | 2025.12.11 |
| MSA 환경에서의 알림 서비스 설계와 구현 (2) | 2025.07.13 |