마지막 단위 프로젝트인 데브옵스팀 프로젝트가 마무리됐다. 팀원으로서 포트폴리오 도메인 개발과 전체적인 팀 서포트를 맡았고, 생각보다 훨씬 많은 것을 배웠다. 설계부터 배포, 모니터링까지 경험해본 첫
풀사이클 프로젝트였기에 기록을 남겨두려 한다.
SalesBoost는 B2B 수출 기업을 위한 비즈니스 지원 웹 서비스다.
총 3인 팀 (백엔드 2명 / 풀스택 1명)
내 담당: 포트폴리오 도메인 백엔드 전담 + 전체 팀 서포트
| 분류 | 기술 |
|---|---|
| Language & Framework | Java 21, Spring Boot 3.5 |
| Database | MariaDB 10.11 |
| ORM / Query | JPA (Hibernate) + MyBatis |
| 인증 | Spring Security + JWT (jjwt 0.12.6) |
| API 문서 | Springdoc OpenAPI (Swagger) |
| 빌드 도구 | Gradle 8 |
| 캐싱 | Spring Cache (인메모리) |
| 분류 | 기술 |
|---|---|
| Framework | Vue.js 3 + Vite 7 |
| 상태 관리 | Pinia |
| HTTP | Axios (인터셉터로 토큰 갱신) |
| 스타일 | Tailwind CSS 4 |
| 서버 | Nginx (리버스 프록시) |
| 분류 | 기술 |
|---|---|
| 컨테이너 | Docker (멀티스테이지 빌드) |
| 오케스트레이션 | Kubernetes (Deployment, Ingress, HPA) |
| CI/CD | Jenkins + GitHub Webhook |
| GitOps | ArgoCD |
| 보안 스캔 | Trivy |
| 컨테이너 레지스트리 | Docker Hub |
| 분류 | 기술 |
|---|---|
| 로그 수집 | ELK Stack (Elasticsearch + Logstash + Kibana) |
| 메트릭 | Prometheus + Micrometer |
| 시각화 | Grafana 11 |
| 분산 트레이싱 | Micrometer Tracing (TraceID + MDC) |
| 헬스체크 | Spring Boot Actuator |
| 부하 테스트 | k6 |
포트폴리오는 이 서비스의 핵심 콘텐츠다. 공개 사용자에게는 완료된 수출 사례를 보여주고, 어드민은 이를 관리하는 구조다.
// Portfolio.java
@Entity
public class Portfolio {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
private String description;
private String clientName;
private String industry;
private String thumbnailUrl;
private Boolean visible;
private Integer displayOrder;
@OneToMany(mappedBy = "portfolio", cascade = CascadeType.ALL, orphanRemoval = true)
private List<PortfolioImage> images = new ArrayList<>();
// 팩토리 메서드 패턴 적용
public static Portfolio create(PortfolioCreateRequest request) { ... }
public void update(PortfolioUpdateRequest request) { ... }
}
cascade = CascadeType.ALL, orphanRemoval = true 조합으로 포트폴리오 삭제 시 연관 이미지가 자동으로 삭제되도록 했다.
API 엔드포인트
┌────────┬───────────────────────────────────────┬──────────┬──────────────────────────────────┐
│ Method │ Path │ 인증 │ 설명 │
├────────┼───────────────────────────────────────┼──────────┼──────────────────────────────────┤
│ GET │ /api/portfolios │ 없음 │ 공개 포트폴리오 목록 (캐시 적용) │
├────────┼───────────────────────────────────────┼──────────┼──────────────────────────────────┤
│ GET │ /api/portfolios/{id} │ 없음 │ 포트폴리오 상세 │
├────────┼───────────────────────────────────────┼──────────┼──────────────────────────────────┤
│ GET │ /api/admin/portfolios │ JWT 필요 │ 어드민 전체 목록 │
├────────┼───────────────────────────────────────┼──────────┼──────────────────────────────────┤
│ POST │ /api/admin/portfolios │ JWT 필요 │ 포트폴리오 생성 │
├────────┼───────────────────────────────────────┼──────────┼──────────────────────────────────┤
│ PUT │ /api/admin/portfolios/{id} │ JWT 필요 │ 수정 │
├────────┼───────────────────────────────────────┼──────────┼──────────────────────────────────┤
│ DELETE │ /api/admin/portfolios/{id} │ JWT 필요 │ 삭제 │
├────────┼───────────────────────────────────────┼──────────┼──────────────────────────────────┤
│ PATCH │ /api/admin/portfolios/{id}/visibility │ JWT 필요 │ 공개/비공개 토글 │
├────────┼───────────────────────────────────────┼──────────┼──────────────────────────────────┤
│ PATCH │ /api/admin/portfolios/order │ JWT 필요 │ 노출 순서 변경 │
└────────┴───────────────────────────────────────┴──────────┴──────────────────────────────────┘
N+1 문제 해결 — @EntityGraph 적용
포트폴리오 목록을 조회할 때 이미지 리스트도 함께 가져와야 하는데, 기본 JPA Lazy Loading을 사용하면 N+1 쿼리가 발생한다. @EntityGraph로 이를 해결했다.
// PortfolioRepository.java
@EntityGraph(attributePaths = {"images"})
List findAllByVisibleTrueOrderByDisplayOrderAscIdDesc();
단 하나의 JOIN 쿼리로 포트폴리오와 이미지를 한 번에 가져온다.
캐싱 전략
공개 포트폴리오는 읽기 빈도가 높고 변경 빈도가 낮다. 스프링 캐시를 적용해 반복 DB 조회를 방지했다.
@Cacheable("publicPortfolios")
public List getPublicPortfolios() { ... }
@CacheEvict(value = "publicPortfolios", allEntries = true)
public void createPortfolio(PortfolioCreateRequest request) { ... }
어드민이 포트폴리오를 수정/생성/삭제하면 캐시가 즉시 무효화되고, 다음 공개 조회 시 최신 데이터가 DB에서 새로 로드된다.
포트폴리오 도메인 외에도 팀 전반에 걸쳐 다양한 서포트 작업을 했다.
Spring Security + JWT 인증 구조 설계 지원
Stateless 세션 정책 (SessionCreationPolicy.STATELESS)
JWT 액세스 토큰 (1시간) + 리프레시 토큰 (7일) 구조
리프레시 토큰 로테이션: 재발급 시 기존 토큰 삭제
JwtAuthenticationFilter를 UsernamePasswordAuthenticationFilter 앞에 배치
공통 응답 포맷 표준화
{
"success": true,
"data": { ... },
"message": "Optional message"
}
모든 API가 동일한 응답 구조를 반환하도록 ApiResponse 래퍼 클래스를 설계하고 팀에 공유했다.
글로벌 예외 처리
@RestControllerAdvice로 중앙화된 예외 처리 구조를 구성하고, 커스텀 BusinessException + ErrorCode 패턴을 도입했다.
모니터링 스택 구성 지원
ELK + Prometheus + Grafana 연동, Logback JSON 포맷 설정, TraceID MDC 전파 등 모니터링 인프라 구성에 참여했다.
Docker Compose / K8s 매니페스트 작성 지원
로컬 개발 환경용 docker-compose.yml 작성 및 K8s Deployment·HPA·Ingress 매니페스트 리뷰에 참여했다.
문제
포트폴리오 목록 API를 처음 구현했을 때, 포트폴리오가 5개라면 이미지 조회 쿼리가 추가로 5번 실행되는 N+1 문제가 발생했다. Hibernate 로그를 켜놓고 보니 쿼리가 생각보다 훨씬 많이 찍히고 있었다.
원인
@OneToMany 관계의 기본값인 FetchType.LAZY로 인해 이미지 컬렉션에 접근할 때마다 별도의 SELECT가 발생했다.
해결
@EntityGraph로 한 번에 JOIN 조회하도록 변경했다.
@EntityGraph(attributePaths = {"images"})
List findAllByVisibleTrueOrderByDisplayOrderAscIdDesc();
JPQL의 JOIN FETCH를 직접 쓰는 방법도 있지만, 리포지토리에 어노테이션 하나로 해결되는 쪽이 더 명확하다고 판단했다.
결과
포트폴리오 N개 조회 시 쿼리 수가 N+1 → 1로 줄었다.
문제
어드민에서 포트폴리오를 수정하거나 삭제해도, 공개 포트폴리오 API(/api/portfolios)에서는 한동안 이전 데이터가 그대로 반환되는 문제가 발생했다.
원인
@Cacheable을 공개 조회에만 적용하고, 어드민 쓰기 메서드에 @CacheEvict를 빠뜨렸다.
해결
모든 어드민 쓰기 작업(생성, 수정, 삭제, 순서 변경, 공개/비공개 토글)에 @CacheEvict를 추가했다.
@CacheEvict(value = "publicPortfolios", allEntries = true)
public void updateVisibility(Long id, PortfolioVisibilityRequest request) { ... }
배운 점
캐시를 도입할 때는 무효화 시점을 모든 쓰기 경로에서 꼼꼼히 챙겨야 한다. 캐시가 붙는 모든 쿼리와 그 캐시를 깨야 하는 모든 쓰기 작업을 표로 정리해두는 게 도움이 됐다.
문제
Kubernetes 클러스터에 배포했을 때, 백엔드 Pod가 Running 상태에서 계속 Readiness Probe를 실패해 트래픽을 받지 못하는 문제가 발생했다.
원인
초기 initialDelaySeconds를 너무 짧게 잡은 것이 문제였다. Spring Boot가 완전히 기동하는 데 걸리는 시간(DB 커넥션 풀 초기화, 빈 생성 등)보다 프로브가 먼저 실행됐다.
readinessProbe:
httpGet:
path: /actuator/health
port: 8081
initialDelaySeconds: 15 # 너무 짧음
periodSeconds: 10
해결
initialDelaySeconds를 60초로 늘리고, Liveness Probe는 90초로 설정해 기동 완료 후에만 체크하도록 조정했다.
readinessProbe:
httpGet:
path: /actuator/health
port: 8081
initialDelaySeconds: 60
periodSeconds: 10
livenessProbe:
httpGet:
path: /actuator/health
port: 8081
initialDelaySeconds: 90
periodSeconds: 20
배운 점
Spring Boot의 실제 기동 시간을 먼저 측정한 뒤 initialDelaySeconds를 여유 있게 잡아야 한다. 특히 DB 연결이 포함된 경우 예상보다 오래 걸린다.
4-4. CORS 에러 — 프론트엔드 로컬 개발 환경에서 API 호출 차단
문제
Vue.js 개발 서버(포트 5173)에서 백엔드 API를 호출하면 CORS 에러가 발생했다. 프론트엔드 팀원이 작업을 진행하지 못하는 상황이었다.
원인
CORS 허용 Origin 설정이 하드코딩된 상태로 배포 환경 URL만 등록돼 있었고, 로컬 개발 Origin(http://localhost:5173)이 빠져 있었다.
해결
CORS 허용 Origin을 환경 변수로 외부화해 환경별로 다르게 주입할 수 있도록 변경했다.
app:
cors:
allowed-origins: ${APP_CORS_ALLOWED_ORIGINS:http://localhost:5173,http://localhost:3000}
// CorsConfig.java
@Value("${app.cors.allowed-origins}")
private String[] allowedOrigins;
로컬에서는 기본값이 적용되고, 배포 환경에서는 환경 변수로 실제 도메인을 주입하는 방식이다.
4-5. Actuator 헬스체크 403 — K8s 프로브 인증 실패
문제
Spring Security를 강화한 후 /actuator/** 경로에도 인증이 요구되면서, K8s Readiness/Liveness Probe가 403 응답을 받아 Pod가 계속 재시작됐다.
원인
Security 설정에서 actuator 경로를 명시적으로 허용하지 않았다.
해결
SecurityConfig의 permitAll 목록에 Actuator 경로를 추가했다.
.requestMatchers("/actuator/**").permitAll()
단, Actuator 포트를 8081로 분리해 외부에서 직접 접근은 막고, K8s 클러스터 내부 프로브만 접근 가능하도록 구성했다.
기술적으로 성장한 것들
N+1 문제를 이론으로만 알고 있다가 실제 프로젝트에서 직접 발생시키고 해결하면서 비로소 체감했다. @EntityGraph, JOIN FETCH, FetchType 전략의 트레이드오프를 몸으로 익혔다.
캐시를 붙이는 건 쉬운데, 언제 어떻게 무효화할지 설계하지 않으면 오히려 독이 된다는 걸 배웠다. 캐시를 적용할 때는 항상 "이 데이터가 바뀌는 모든 시점"을 먼저 정리해야 한다.
하드코딩된 설정값은 반드시 후회한다. 로컬 / 개발 / 운영 환경에서 다르게 동작해야 하는 모든 값은 환경 변수로 외부화하는 습관을 이번에 제대로 들였다.
Readiness / Liveness Probe의 차이, initialDelaySeconds 튜닝의 중요성, 그리고 Actuator 포트 분리의 이점을 경험했다. 인프라 레벨에서의 헬스체크가 애플리케이션 안정성에 얼마나 중요한지 알게 됐다.
ELK + Prometheus + Grafana 스택을 구성하면서, 로깅 전략(JSON 포맷, TraceID 전파)과 메트릭 수집을 처음부터 설계하는 것이 얼마나 가치 있는지 느꼈다. 문제가 발생했을 때 로그와 메트릭이 있고 없고의 차이는 엄청나다.
일관된 API 응답 포맷의 중요성
초반에 응답 형식을 팀 내에서 합의하지 않고 각자 구현했더니, 프론트엔드에서 데이터를 파싱할 때 혼란이 생겼다. ApiResponse 래퍼를 도입하고 나서부터 프론트-백 간 소통이 훨씬 편해졌다.
서포트 역할의 가치
포트폴리오 도메인 외에도 팀원들의 막히는 부분을 함께 해결하는 서포트 역할을 했는데, 이 과정에서 프로젝트 전체 코드를 훨씬 깊게 이해하게 됐다. 자기 도메인만 아는 게 아니라 전체 구조를 파악하는 것이 결국
내 개발 속도에도 도움이 됐다.
코드 리뷰의 힘
팀 내 PR 리뷰를 통해 내가 놓친 캐시 무효화 누락이나 예외 처리 케이스를 사전에 잡을 수 있었다. 리뷰는 귀찮은 과정이 아니라 품질을 올리는 가장 빠른 방법이다.
테스트 코드 부족
기간이 촉박해 단위 테스트와 통합 테스트를 제대로 작성하지 못했다. 특히 캐시 무효화 버그처럼 런타임에서야 발견되는 문제들은 테스트로 사전에 잡을 수 있었을 것이다. 다음 프로젝트에서는 TDD까지는 아니더라도,
핵심 비즈니스 로직에 대한 테스트는 반드시 작성하겠다.
파일 업로드 미완성
FileStorageService 인터페이스와 LocalFileStorageService 구현체를 만들었지만, K8s 환경에서는 emptyDir 볼륨이라 파드 재시작 시 파일이 사라진다. S3 또는 MinIO 연동으로 마이그레이션하지 못한 게 아쉽다.
Swagger 문서화 개선
@Profile("dev")로 운영 환경에서 Swagger를 비활성화한 것은 좋은 결정이었지만, API 명세가 더 상세했으면 프론트엔드와의 협업이 더 매끄러웠을 것이다.