예전에 진행했던 프로젝트 착수 직전에 아키텍팅하면서 작성했던 플랫폼 백엔드 개발 가이드이다.
해당 프로젝트는 사업 취소로 오픈도 못하고 사장되었다(...).
Java 21 Virtual Thread, Spring Boot 3.5, Spring Cloud 2025를 중심으로 구성하였으며,
프로젝트 셋업부터 인증, 서비스 간 통신, 코딩 컨벤션까지 다룬다.
목차
- 프로젝트 개요 & 기술 스택
- 멀티 모듈 구성
- 독립 모듈
- Get Started — 빠른 시작
- Local 프로파일 설정
- 인증 & JWT
- 가상 스레드 & FeignClient Wrapper
- 패키지 구조 & 유틸리티
- 코딩 컨벤션
- 개발 수행 고려 사항
1. 프로젝트 개요 & 기술 스택
마이크로서비스 기반의 플랫폼 백엔드 서버로 4개의 독립 서비스 모듈로 구성된다.
| 카테고리 | 기술 스택 |
|---|---|
| 언어 & 프레임워크 | Java 21, Kotlin 1.9, Spring Boot 3.5.3, Spring Cloud 2025 |
| 데이터베이스 | H2 (테스트), MySQL 5.7 (로컬), AWS RDS (운영) |
| 메시징 & 게이트웨이 | AWS SQS, Apache APISIX, Netflix Eureka |
| 캐시 & 모니터링 | Redis / Valkey, Micrometer, Prometheus |
2. 멀티 모듈 구성
프로젝트는 서비스 모듈 4개 + 공통 라이브러리 1개로 구성됩니다.
root/
├── users/ # 회원 관리 (/users) — ort 8081
├── platform-service/ # 플랫폼 관리 (/platform) — Port 8082
├── plan/ # 사업 관리 (/plan) — Port 8083
├── contents/ # 컨텐츠 (/contents) — Port 8084
└── library/ # 공통 모듈 (공유 컴포넌트)
library (공통 모듈) 역할
- 프로젝트 공통 Config
- 보안 / 인증 (JWT)
- Cache 클라이언트
- Messaging 프로듀서 (AWS SQS)
- Entity 정의
- Error Code, Exception 정의
- 공통 Response 포맷
- 유틸리티 클래스
3. 독립 모듈
Service Discovery — Port 8761
Netflix Eureka (Spring Cloud) 기반의 서비스 레지스트리.
- 서비스 등록을 위한 레지스트리 제공
- 클라이언트의 동적 서비스 검색 지원
- Load Balancing 및 Health Check
Back-Office API — Port 8089
어드민/운영 전용 API Gateway 서비스.
- API Gateway Pattern 적용
- DB Connection 없음 — 데이터는 도메인 서비스 모듈에 의존 (FeignClient)
- GraalVM 기반의 네이티브 이미지로 빌드 및 배포
4. Get Started — 빠른 시작
필수 조건
- Java: OpenJDK 21+
- Gradle: 8.5+
1) 프로젝트 클론
git clone https://github.com/your-org/platform.git
cd platform
2) 프로젝트 빌드
# 전체 빌드
./gradlew clean build
# 특정 서비스만 빌드
./gradlew :users:build
./gradlew :platform-service:build
./gradlew :plan:build
./gradlew :contents:build
3) 서비스 실행
# 기본 실행 (캐시 없음)
./gradlew :users:bootRun # http://localhost:8081
./gradlew :platform-service:bootRun # http://localhost:8082
./gradlew :plan:bootRun # http://localhost:8083
./gradlew :contents:bootRun # http://localhost:8084
# Redis/Valkey와 함께 실행 (예: Users Service)
valkey-server --port 6380 &
./gradlew :users:bootRun \
--args='--spring.profiles.active=local --spring.data.redis.enabled=true'
5. Local 프로파일 설정
로컬 개발 환경은 Docker Desktop 위에서 MySQL과 Valkey 컨테이너를 띄워 사용한다.
docker-compose로 컨테이너 생성 후 Spring Profile을 local로 지정하여 실행한다.
application.yaml (local 프로파일)
spring:
config:
activate:
on-profile: local
datasource:
url: jdbc:mysql://localhost:3307/platform
driverClassName: com.mysql.cj.jdbc.Driver
username: bp_user
password: bp_pass
jpa:
database-platform: org.hibernate.dialect.MySQL8Dialect
hibernate:
ddl-auto: none
show-sql: true
defer-datasource-initialization: false
sql:
init:
mode: never
h2:
console:
enabled: true
data:
redis:
enabled: true
host: localhost
port: 6380
timeout: 2000ms
lettuce:
pool:
max-active: 8
max-idle: 8
min-idle: 0
6. 인증 & JWT
인증 방식
Authorization: Bearer {JWT_TOKEN}
Content-Type: application/json
Accept: application/json
@ParsedAuthToken 애노테이션
JWT 토큰에서 자동으로 사용자 ID를 추출하여 컨트롤러 메서드 파라미터에 주입한다.
| 기능 | 설명 |
|---|---|
| 자동 JWT 파싱 | Authorization 헤더의 JWT 토큰을 파싱하여 userId 추출 |
| 코드 간소화 | 수동 토큰 파싱 코드를 애노테이션 하나로 대체 |
| 일관성 보장 | 모든 컨트롤러에서 동일한 방식으로 인증 정보 처리 |
| 타입 안전성 | String 타입 파라미터에만 사용 가능 |
기존 방식 (수동 파싱)
@PostMapping("/notification")
public Rest<?> saveNotification(
@RequestHeader("Authorization") String accessToken,
@RequestBody NotificationRequest request
) {
// 수동으로 JWT 파싱 필요
String userId = tokenProvider.getUseridFromToken(accessToken);
platformService.saveNotificationInfo(userId, request);
return Rest.success();
}
새로운 방식 (@ParsedAuthToken 사용)
@PostMapping("/notification")
public Rest<?> saveNotification(
@ParsedAuthToken String userId, // JWT에서 자동으로 userId 추출
@RequestBody NotificationRequest request
) {
platformService.saveNotificationInfo(userId, request);
return Rest.success();
}
AOP 기반 사용자 검증
@ParsedAuthToken으로 추출된 userId가 실제 DB에 존재하는지 메서드 실행 전 자동으로 검증한다.
- 자동 사용자 검증: JWT에서 추출한 userId를 DB에서 사전 확인
- 존재하지 않는 사용자 차단:
UserNotFoundException발생 → 404 응답 - 서비스별 구현: 각 서비스 모듈에서
UserValidator인터페이스를 구현하여 사용
서비스별 구현 예시
Users 서비스 — 직접 DB 접근
@Component
public class UsersValidatorImpl implements UserValidator {
private final UsersRepository usersRepository;
@Override
public boolean existsById(String userId) {
return usersRepository.existsById(userId);
}
}
Platform 서비스 — FeignClient 사용
@Component
public class PlatformUserValidatorImpl implements UserValidator {
private final UsersConnector usersConnector;
@Override
public boolean existsById(String userId) {
Users users = usersConnector.getUserById(userId);
return users != null && StringUtils.isNotEmpty(users.email());
}
}
컨트롤러 사용법
@RestController
public class ExampleController {
@ValidateUser // 사용자 검증 AOP 활성화
@PostMapping("/example")
public Rest<?> createExample(
@ParsedAuthToken String userId,
@RequestBody ExampleRequest request
) {
// userId는 이미 검증된 상태로 전달됨
// DB에 없는 사용자라면 이 메서드가 실행되기 전에 예외 발생
return exampleService.create(userId, request);
}
}
검증 흐름
HTTP 요청
→ JWT 토큰에서 userId 추출 (@ParsedAuthToken)
→ AOP Aspect가 메서드 실행 전 인터셉트 (@ValidateUser)
→ UserValidator를 통해 DB에서 사용자 존재 여부 확인
├─ 존재 O → 정상 메서드 실행
└─ 존재 X → UserNotFoundException 발생 → 404 응답
7. 가상 스레드 & FeignClient Wrapper
Java 21의 Virtual Thread를 활용하여 FeignClient 호출을 효율적으로 처리한다.
| 특징 | 설명 |
|---|---|
| 경량 스레드 | Virtual Thread로 수천 개의 동시 요청 처리 가능 |
| 간단한 사용법 | 동기 코드처럼 작성하면서 비동기 성능 확보 |
| 자동 관리 | 스레드 풀 관리 없이 자동으로 최적화된 스레드 운영 |
구현 구조
FeignClient 인터페이스 정의
@FeignClient(
name = "users-service",
url = "${interface.endpoint.users}",
fallbackFactory = UsersClientFallbackFactory.class
)
public interface UsersClient {
@GetMapping("/users/{userId}")
Rest<Users> getUserById(@PathVariable("userId") String userId);
}
FeignClient 래퍼 (VirtualThread 연동)
@Component
public class UsersConnector {
private final UsersClient usersClient;
private final VirtualThreadRunner virtualThreadRunner;
public Users getUserById(String userId) {
return virtualThreadRunner.runInVirtualThread(() ->
usersClient.getUserById(userId).data()
);
}
}
사용법
1. 기본 사용 — 단일 API 호출
@Service
public class PlatformService {
private final UsersConnector usersConnector;
public void processUserData(String userId) {
// 가상 스레드에서 비동기로 실행되지만 동기 코드처럼 작성
Users user = usersConnector.getUserById(userId);
processUser(user);
}
}
2. 다중 API 병렬 호출
@Service
public class DataAggregationService {
public AggregatedData getAggregatedData(String userId) {
// 여러 API를 병렬로 호출 (각각 가상 스레드에서 실행)
CompletableFuture<Users> userFuture =
CompletableFuture.supplyAsync(() -> usersConnector.getUserById(userId));
CompletableFuture<Vehicle> vehicleFuture =
CompletableFuture.supplyAsync(() -> vehicleConnector.getVehicleByUserId(userId));
CompletableFuture<Content> contentFuture =
CompletableFuture.supplyAsync(() -> contentConnector.getContentByUserId(userId));
return new AggregatedData(
userFuture.join(),
vehicleFuture.join(),
contentFuture.join()
);
}
}
8. 패키지 구조 & 유틸리티
서비스 모듈 패키지 구조
src/main/kotlin/com/example/platform/{service}/
├── controller/ # REST API 컨트롤러
├── service/ # 비즈니스 로직
├── repository/ # 데이터 접근 계층
├── dto/ # 데이터 전송 객체
├── config/ # 설정 클래스 (Security, SpringDoc 등)
└── remote/ # 외부 서비스 연동 (Feign Client)
공통 라이브러리 구조
library/src/main/kotlin/com/example/platform/library/
├── entity/ # 공통 JPA 엔티티
├── response/ # 공통 응답 포맷
├── service/ # AWS SQS 등 공통 서비스
├── util/ # 유틸리티 클래스
└── config/ # 공통 설정
제공 유틸리티 클래스
| 클래스 | 용도 |
|---|---|
DateUtils |
날짜 형식 변환 |
UnitUtils |
미터법 ↔ 야드-파운드법 단위 변환 |
TokenProvider |
JWT AuthToken 복호화 |
VirtualThreadRunner |
가상 스레드 비동기 실행 래퍼 |
9. 코딩 컨벤션
일관된 명명 규칙으로 코드의 가독성과 유지보수성을 높인다.
- Language: Kotlin, Java
- Code Style: IntelliJ IDEA 기본 설정
- Naming:
PascalCase(클래스),camelCase(메서드/변수),kebab-case(API URL Path)
레이어별 메서드 명명 패턴
| 레이어 | 조회 | 저장/생성 | 수정 | 삭제 | 팩토리 |
|---|---|---|---|---|---|
| Controller | get* |
save*, create* |
update* |
delete* |
— |
| Service | get*, find* |
save*, create* |
update* |
delete* |
— |
| Repository | find*, select* |
save*, insert* |
update* |
delete* |
— |
| DTO / Entity | get* |
— | — | — | of* |
| Utility | get*, to* |
create* |
format* |
— | — |
Controller Layer 예시
// 조회 (GET)
public Rest<UsersSimpleInfo> getUserById(@PathVariable String id)
public Rest<List<NotificationResponse>> getUsersNotifications(@RequestParam String id)
// 저장 (POST)
public Rest<?> saveNotification(
@ParsedAuthToken String userId,
@RequestBody NotificationRequest request
)
// 조회 (GET) — Kotlin
fun getUserInfos(@PathVariable userId: String)
fun getCategoriesById(@RequestParam id: String)
Service Layer 예시
// 조회
public Optional<UsersSimpleInfo> findById(String id)
public List<NotificationResponse> getUsersLanguageByEmail(String userId)
// 저장
public void saveNotificationInfo(String userId, NotificationRequest request)
// 조회 — Kotlin
fun getUserInfos(userId: String)
fun findCategories(id: String)
Repository Layer 예시
// JPA Repository
Optional<UserInfo> findById(String id)
// MyBatis Mapper
int insertNotification(NotificationInfo notificationInfo)
List<NotificationInfoMap> selectNotificationsByUserId(String userId)
// JPA Repository — Kotlin
fun findByEmail(email: String): List<UserInfo>
fun findCategoriesByCategoryId(categoryId: String)
DTO / Entity 예시
// 팩토리 메서드 — of 패턴
companion object {
fun of(userInfo: UserInfo): UsersSimpleInfo
fun of(categoryInfo: CategoryInfo, users: Users?): CategoriesAndUsers
}
// Getter
fun getPeriodStart(): Long = DateUtils.toTimestamp(periodStart)
fun getScoreCalculatedAt(): Long? = scoreCalculatedAt?.let { DateUtils.toTimestamp(it) }
Utility Class 예시
// 현재값 조회
public static String getCurrentTimeString()
public static LocalDateTime getCurrentUtcLocalDateTime()
public static Long getCurrentUtcTimestamp()
// 변환
public static Long toTimestamp(LocalDateTime localDateTime)
public static LocalDateTime toLocalDateTime(Long timestamp)
// 포매팅
public static String formatDateTime(LocalDateTime dateTime)
// JWT
public String getUseridFromToken(String accessToken)
public Authentication getAuthentication(String accessToken)
public long getTokenExpiredDays(String token)
주의사항
camelCase사용 원칙 준수- 동사 + 명사 조합으로 명확한 의도 표현
- 레이어별 명명 일관성 유지
- 도메인 용어 통일 (User, Notification, Vehicle 등)
- 컬렉션 반환 시 복수형 사용 (
getUsers,getNotifications)
10. 개발 수행 고려 사항
- 간단한 CRUD는 최대한 바이브 코딩(Vibe Coding) 활용
RequestBody/ResponseBody용 DTO는 불변 클래스 사용
→ Java의Record, Kotlin의data class에서val사용- 1주일에 한 번 Default Branch(main)에 전체 소스 코드 merge
- API 스펙 변경 및 협의는 개발자 간 직접 오픈 채널에서 진행