
이번 프로젝트에서 기술을 선택할 때 가장 중요하게 본 기준은 세 가지였습니다.
- 기능이 늘어나도 유지보수가 가능한가
- 실제 운영 환경까지 고려했을 때 안정적인가
- 구현 경험 자체가 학습으로 이어지는가
실제 동아리 운영 서비스라는 프로젝트를 운영할 때 사용되는 툴로 선정하였고 추가적으로 학습 해보고 싶은 기술도 추가적으로 확장 할 예정입니다.
| 분류 | 기술 |
|---|---|
| UI | React 19 + TypeScript 5 |
| 번들러 | Vite |
| 상태 관리 | React Context API |
| 아키텍처 | FSD (Feature-Sliced Design) |
| 라우팅 | React Router v7 |
| 캘린더 | FullCalendar v6 |
| 테스트 | Vitest + React Testing Library + MSW |
| 배포 | Vercel |
출퇴근, 일정, 채팅, 파일 드라이브처럼 성격이 다른 도메인이 하나의 앱에 공존하는 구조라 타입 없이 개발하면 리팩터링 과정에서 실수가 많아 질 수 있습니다.
TypeScript로 도메인 모델과 API 응답 구조를 정의하고 컴파일 단계에서 오류를 잡을 수 있었습니다.
동아리 내부 운영 도구라는 규모에서 전역 상태 복잡도가 Redux를 도입할 만큼 크지 않다고 판단했습니다.
auth, chat, calendar, tasks처럼 도메인별로 Context를 분리해 관심사를 나누는 것만으로 충분히 관리 가능했습니다.
FSD는 app → pages → widgets → features → entities → shared 계층으로 의존 방향을 강제합니다.
기능이 추가될수록 "이 코드는 어디에 두는 게 맞는가"에 대한 기준이 생겼고 구조적 일관성을 유지하는 데 큰 도움이 됐습니다.
MSW는 서비스 워커 수준에서 요청을 가로채기 때문에 실제 fetch 흐름 그대로 테스트할 수 있습니다. 백엔드가 미완성인 기능은 MSW 핸들러로 먼저 개발하고 실제 API가 붙으면 핸들러만 제거하는 방식으로 프론트와 백엔드 개발을 병렬로 진행할 수 있었습니다.
이 프로젝트의 프론트엔드는 Claude Code와 협업하며 개발했습니다.
FSD 구조 규칙, TDD 순서, 커밋 컨벤션, MSW 사용 원칙 등을 docs내에 지침을 모두 문서화해두고 AI가 그 기준 안에서 작업하도록 했습니다.
프론트엔드를 개발하면서 가장 크게 느낀 점은 AI를 잘 활용하려면 오히려 구조와 기준이 더 명확해야 하고 프롬프트를 많은 파일에서 짧게 유지하는 것이 좋다고 생각합니다.
프롬프트가 자세할수록 결과물이 안정적이었고 지침을 세분화하여 작성하고 유지하는 과정이 중요하다고 생각합니다.
| 분류 | 기술 |
|---|---|
| Language | Java 21 |
| Framework | Spring Boot 3.5 |
| ORM | Spring Data JPA + QueryDSL 5 |
| DB | PostgreSQL 15 |
| Migration | Flyway 11 |
| Auth | Spring Security 6 + JWT |
| Storage | MinIO |
| Docs | SpringDoc OpenAPI (Swagger UI) |
| Test | JUnit5 + AssertJ + Testcontainers |
| 편의 | Lombok |
| Infra | Docker + Nginx + Cloudflare + 온프레미스 서버 (systemd) |
| CI/CD | GitHub Actions |
Spring Boot 3.x가 Java 17 이상을 요구하는 만큼 어차피 최신 버전을 선택해야 한다면 21 버전을 선택하는 것이 합리적이라고 판단했습니다.
실제로 21을 사용해서 느낀 좋았던 점은 record입니다.
모든 요청·응답 DTO를 record로 정의했는데 불변 객체를 간결하게 선언할 수 있고 equals, hashCode, toString이 자동으로 생성되서 편하다고 느꼈습니다.
보안, 데이터 접근, 유효성 검증, 트랜잭션 같은 인프라 관심사를 프레임워크에 위임하고 비즈니스 로직에 집중하기 위해 선택했습니다.
Spring Boot 3.x는 Jakarta EE 10 기반으로 전환되면서 패키지명이 javax.*에서 jakarta.*로 변경됐습니다.
특히 QueryDSL APT 설정 시 :jakarta classifier를 명시하지 않으면 Q 클래스 생성이 되지 않아 빌드가 실패합니다.
2.x 기반 자료를 그대로 참고하면 이 부분에서 막히는 경우가 많았습니다.
단순 CRUD는 JpaRepository로 처리하고, 조건이 복잡한 조회는 QueryDSL로 분리했습니다.
출퇴근 기록을 날짜 범위와 팀명으로 필터링하거나 멤버 목록을 팀명과 역할로 동시에 조회하는 경우처럼 조건이 상황에 따라 달라지는 쿼리가 여럿 있었습니다.
JPQL은 문자열 기반이라 컴파일 단계에서 오류를 잡을 수 없고 동적 조건을 처리할 때 코드가 지저분해집니다.
QueryDSL의 BooleanBuilder를 사용하면 조건을 타입 안전하게 조합할 수 있습니다.
Repository는 도메인 레이어에 인터페이스만 정의하고 구현체는 infrastructure 레이어에 두었습니다.
스키마 변경 이력을 SQL 버전 파일로 관리하기 위해 Flyway를 도입했습니다.
V1__init.sql부터 V15__create_audit_log.sql까지 누적된 파일이 DB 변경 이력을 기록하며 서버를 새로 띄우거나 로컬 환경을 구성할 때 수작업 없이 동일한 스키마를 재현할 수 있었습니다.
하지만 개발 과정에서 Flyway와 관련해 여러번 이슈가 생겼습니다.
첫 번째는 SQL 문법 오류였습니다.
MySQL에서는 컬럼명에 백틱을 사용할 수 있지만 PostgreSQL은 이를 지원하지 않아 마이그레이션 실행 시 즉시 오류가 발생했습니다.
또 MySQL에서는 BIGINT AUTO_INCREMENT로 자동 증가 PK를 만들 수 있지만 PostgreSQL에서는 반드시 BIGSERIAL을 사용해야 합니다.
원래 MySQL에 익숙해 있어서 적응하는데 조금 시간이 걸렸습니다.
두 번째는 체크섬 불일치였습니다.
Flyway는 이미 DB에 적용된 마이그레이션 파일이 변경되면 체크섬 불일치를 감지하고 서버 실행을 거부합니다.
처음에는 귀찮았지만 운영 환경에서 스키마 이력을 보호하는 것이라고 이해됬습니다.
개발 환경에서는 docker compose down -v로 볼륨째 초기화하는 방식으로 해결했고 이후 마이그레이션 파일은 한 번 적용하면 절대 수정하지 않는 원칙을 세웠습니다.
인증은 Spring Security와 JWT 기반으로 구현했습니다.
세션 방식은 서버가 상태를 보관해야 하지만 JWT는 토큰 자체에 정보를 담아 서버를 무상태에 가깝게 운영할 수 있습니다.
무상태에만 집중하면 보안 문제가 생깁니다.
JWT는 서버에 상태를 저장하지 않기 때문에 탈취된 토큰을 즉시 무효화할 수 없습니다. 이 문제를 해결하기 위해 RTR(Refresh Token Rotation) 전략을 적용했습니다.
Refresh Token을 사용할 때마다 즉시 revoke하고 새 토큰을 발급하며 이미 revoke된 토큰으로 재발급 요청이 들어오면 탈취로 간주하고 해당 계정의 모든 토큰을 즉시 무효화합니다.
구현 과정에서 같은 밀리초 안에 Refresh Token을 발급하면 payload가 동일해 서명값도 동일한 JWT가 생성되는 문제가 있었습니다.
재발급 시 새 토큰과 기존 토큰이 같은 값이 되어 RTR이 제대로 동작하지 않았고 jti 클레임에 UUID를 추가해 매번 고유한 토큰이 생성되도록 해결했습니다.
파일 저장소로 MinIO를 선택한 이유는 AWS S3 호환 API를 제공하면서 온프레미스에서 직접 운영할 수 있기 때문입니다.
애플리케이션 코드에서 S3와 동일한 방식으로 파일을 다루기 때문에 추후 AWS S3로 전환하더라도 설정만 교체하면 됩니다.
파일 저장 시 UUID 기반의 storedName을 생성해 원본 파일명 충돌을 방지하고 DB에는 originalName과 storedName을 모두 저장해 다운로드 시 원본 파일명을 복원할 수 있도록 했습니다.
API 문서화 도구로 SpringDoc OpenAPI를 선택했습니다.
컨트롤러의 어노테이션만으로 Swagger UI가 자동 생성되기 때문에 별도 문서를 유지관리할 필요가 없습니다.
프론트엔드와 백엔드가 분리된 구조에서 API 스펙을 공유하는 가장 간단한 방법이었고 JWT 인증 헤더를 Swagger UI에서 직접 입력해 테스트할 수 있도록 Bearer 인증 스키마도 설정했습니다.
백엔드 구조는 presentation → application → domain ← infrastructure 계층으로 의존 방향을 설계했습니다.
도메인 레이어는 Spring, JPA 어떤 프레임워크도 import하지 않는 순수 Java로 유지했고 비즈니스 규칙은 엔티티 안에 메서드로 직접 표현했습니다.
예를 들어 출퇴근 검증은 서비스가 아닌 Attendance.checkOut() 메서드 안에 있습니다. 퇴근 시간이 출근 시간보다 앞서면 엔티티 자신이 예외를 던집니다.
어느 서비스에서 호출하든 같은 규칙이 보장되고 도메인 단위로 독립적인 테스트를 작성할 수 있습니다.
감사 로그도 쉽게 개발했는데 역할 변경, 팀 변경, 계정 활성화·비활성화가 발생할 때마다 application 레이어의 서비스가 AuditLogService.log()를 직접 호출해 변경 이력을 기록합니다.
이 흐름이 도메인과 application 레이어에만 존재하기 때문에 presentation 계층이 바뀌어도 감사 로그 누락이 생기지 않습니다.
테스트를 세 계층으로 나눠 구성했습니다.
도메인 단위 테스트는 엔티티의 비즈니스 규칙을 직접 검증합니다.
스프링 컨텍스트 없이 순수 Java로 실행되기 때문에 빠르고 외부 의존이 없습니다. Attendance의 퇴근 시간 검증, Member의 계정 잠금 로직, LeaveRequest의 승인·반려 상태 전환, WorkSchedule의 시간 역전 방지가 모두 여기서 검증됩니다.
서비스 단위 테스트는 Fake Repository 패턴을 사용했습니다.
Mockito로 Repository를 mock하면 save() 이후 findById()가 저장된 값을 반환하지 않아 상태 기반 로직 검증이 어렵습니다. Fake Repository는 HashMap으로 실제 저장소처럼 동작하기 때문에 DB 없이도 서비스 로직도 검증할 수 있습니다. Attendance, Member, Task, Leave, Calendar, Drive, Team, RefreshToken 등 모든 도메인에 Fake 구현체를 작성했고 Repository 인터페이스가 도메인 레이어에 분리되어 있어 교체가 자연스러웠습니다.
통합 테스트는 H2 대신 Testcontainers로 실제 PostgreSQL 컨테이너를 띄워 실행합니다.
H2는 PostgreSQL과 문법 차이가 있어 테스트는 통과해도 운영에서 실패하는 쿼리가 생길 수 있습니다.
클라우드 대신 온프레미스 서버를 선택한 이유는 비용과 학습 목적 때문입니다.
사실 Docker Compose 하나로 모든 서비스를 묶어서 띄우는 게 리소스 면에서는 훨씬 효율적이라고 생각합니다. 하지만 학교 수업에서 VM을 직접 구성해본 경험이 있었고 그 경험을 실제 운영 환경에서 직접 해보고 싶었습니다. 서버를 역할별로 분리해 운영하면서 책임 분리, 네트워크 구성, 배포 자동화 등을 직접 다뤄보는 것을 경험해보고 싶어서 따로 VM을 뜨워 서버를 구동했습니다.
서버 구성은 역할에 따라 app-server에는 Nginx와 Spring Boot, data-server에는 PostgreSQL을 배치, 파일 저장소인 MinIO는 별도의 TrueNAS 서버 위에 올려 운영했습니다.
서버 셋업 과정에서 삽질이 꽤 있었습니다.
Nginx는 리버스 프록시로 사용했습니다.
처음에는 Spring Boot의 8080 포트를 외부에 직접 노출하고 있었는데, 공인 IP와 포트가 그대로 드러나는 구조였고 HTTPS 적용도 어려웠습니다. Nginx를 통해 외부 80/443 요청을 내부 8080으로 전달하는 구조로 변경했습니다. 이 과정에서도 예상치 못한 문제들이 있었습니다. Nginx 기본 사이트가 활성화되어 있으면 직접 설정한 server block 대신 기본 블록이 먼저 처리됐고 HTTP 포트에 HTTPS 요청이 직접 들어와 Tomcat이 헤더를 파싱하지 못하는 문제도 있었습니다. sites-enabled/default를 비활성화하고 외부 노출 포트를 80/443으로 통일하면서 해결했습니다.
DNS는 Cloudflare를 통해 관리했습니다.
도메인 구매 후 Cloudflare에 네임서버를 등록하고 api.<도메인>을 서버 공인 IP에 연결했습니다. Cloudflare를 처음 추가할 때 기존 DNS 정보를 자동으로 스캔하면서 현재 프로젝트와 무관한 레코드가 자동 등록되는 경우가 있었고 이를 직접 삭제하고 올바른 레코드로 재등록해야 했습니다. CD 파이프라인에서 SERVER_HOST에 도메인을 그대로 넣었다가 Cloudflare 프록시를 거쳐 SSH 접속이 실패하는 문제도 있었습니다. SSH는 Cloudflare 프록시를 우회하지 못하기 때문에 SERVER_HOST에는 공인 IP를 직접 사용해야 합니다.
GitHub Actions는 CI와 CD를 분리해 구성했습니다.
CI는 PR과 push 이벤트에서 테스트를 실행하고, CD는 main 브랜치 push 시 GitHub 호스팅 러너에서 JAR를 빌드한 뒤 SCP로 서버에 파일을 전송하고 SSH로 접속해 systemd 서비스를 재시작하는 방식입니다. CD에서 SSH 접속 시 공개키가 아닌 개인키를 Secret에 등록해야 하고 서버의 ~/.ssh/authorized_keys에 배포 전용 공개키가 등록되어 있어야 합니다. 초기에 워크플로우 파일을 .github/ci.yml로 작성했는데 실행이 되지 않는 문제가 있었는데, GitHub Actions는 반드시 .github/workflows/ 디렉토리 안에 있어야 인식합니다.
삽질이 많았지만 Docker를 사용했다면 쉽게 띄울 수 있었지만 좋은 경험을 했다고 생각합니다.
VM 구성, 네트워크 설정, Nginx 동작 방식, Cloudflare DNS 흐름, systemd 서비스 관리까지 직접 부딪히면서 익혔고 온프레미스 서버를 구축하며 많은 지식을 습득할 수 있었습니다.
이번 프로젝트의 기술 선택은 유명한 도구를 쓰는 것보다 현재 규모에서 감당 가능한 복잡도를 유지하면서 실제 운영과 유지보수 경험을 쌓는 데 초점을 맞췄으며 추후에는 학습해보고 싶은 기술들도 도입하며 진행해볼 예정입니다.
모든 선택에는 장점과 비용이 함께 있었고 그 트레이드오프를 직접 겪어보면서 어떤 기술이 왜 필요한지 이전보다 훨씬 구체적으로 이해할 수 있었습니다.