Excel 사용을 중단했습니다: Spring Boot 3와 Angular 17으로 엔터프라이즈급 잡 트래커를 구축한 방법
Source: Dev.to
Note: 이것은 단순한 CRUD 앱이 아닙니다. 저는 이를 System Design Challenge 로 간주하고 보안, 동시성, 그리고 클라우드‑네이티브 스토리지 패턴에 중점을 두었습니다.
🔗 제품 소개
- 실제 시연 – https://thughari.github.io/JobTrackPro-UI
- 백엔드 저장소 – https://github.com/thughari/JobTrackPro
- 프론트엔드 저장소 – https://github.com/thughari/JobTrackPro-UI
🛠️ 기술 스택
| 레이어 | 기술 |
|---|---|
| 백엔드 | Java 21 + Spring Boot 3.2 |
| 보안 | Spring Security 6 (OAuth2 + JWT) |
| 데이터베이스 | PostgreSQL (production) / MySQL (dev) |
| 스토리지 | Cloudflare R2 (AWS S3‑compatible) |
| 이메일 | Spring Mail (SMTP) + Brevo |
| 프론트엔드 | Angular 17 (Signals) + TailwindCSS + D3.js |
| DevOps | Docker + Google Cloud Run + GitHub Actions |
Source: …
🧠 Architectural Deep Dive
아래는 JobTrackPro를 구축하면서 해결한 네 가지 가장 흥미로운 엔지니어링 과제입니다.
1️⃣ Hybrid Authentication (Google/GitHub + Local)
Goal – 사용자가 Google 또는 GitHub으로 로그인하고 나중에 외부 계정을 분리하기 위해 비밀번호를 설정할 수 있게 합니다.
Flow

커스텀 OAuth2SuccessHandler를 구현하여:
- 이메일이 이미 존재하는지 확인합니다.
- 필요하면 즉시 사용자를 생성합니다.
- 아바타 URL을 동기화합니다.
- 무상태 프론트엔드를 위해 JWT를 발급합니다.
@Component
public class OAuth2SuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
@Override
public void onAuthenticationSuccess(HttpServletRequest request,
HttpServletResponse response,
Authentication authentication) throws IOException {
// Extract provider‑specific attributes
OAuth2AuthenticationToken authToken = (OAuth2AuthenticationToken) authentication;
OAuth2User oAuth2User = authToken.getPrincipal();
String registrationId = authToken.getAuthorizedClientRegistrationId();
UserInfo userInfo = extractUserInfo(registrationId, oAuth2User.getAttributes());
// Upsert user (create if absent, update otherwise)
User user = userService.upsertUser(userInfo);
// Generate JWT
String token = jwtService.createToken(user);
// Redirect back to Angular with the token
String redirectUrl = uiUrl + "/login-success?token=" + token;
getRedirectStrategy().sendRedirect(request, response, redirectUrl);
}
}
2️⃣ Multi‑Threaded Analytics with CompletableFuture
대시보드에 네 가지 핵심 지표가 표시됩니다. 쿼리를 순차적으로 실행하면 병목이 발생했기 때문에 이를 병렬화했습니다:
// Run aggregations in parallel
CompletableFuture<Long> activeFuture = CompletableFuture.supplyAsync(
() -> jobRepository.countActiveJobs(email), executor);
CompletableFuture<Long> interviewFuture = CompletableFuture.supplyAsync(
() -> jobRepository.countInterviews(email), executor);
CompletableFuture<Long> offerFuture = CompletableFuture.supplyAsync(
() -> jobRepository.countOffers(email), executor);
// Wait for all to finish
CompletableFuture.allOf(activeFuture, interviewFuture, offerFuture).join();
Long activeCount = activeFuture.join();
Long interviewCount = interviewFuture.join();
Long offerCount = offerFuture.join();
3️⃣ Atomic Profile Updates & Cloudflare R2
이미지를 BLOB으로 저장하면 백업 용량이 급증하고, 로컬 디스크는 서버리스 환경에서 동작하지 않습니다. 해결책은 Cloudflare R2를 사용하고 단일 원자적 트랜잭션을 수행하는 것입니다:
@Transactional
public void updateProfile(ProfileDto dto, MultipartFile avatar) {
// 1. Upload new avatar to R2 (if present)
String newAvatarUrl = null;
if (avatar != null && !avatar.isEmpty()) {
newAvatarUrl = r2Service.upload(avatar);
}
// 2. Update profile fields
User user = userRepository.findById(dto.getUserId())
.orElseThrow(() -> new NotFoundException("User not found"));
user.setFullName(dto.getFullName());
user.setBio(dto.getBio());
// 3. Replace avatar URL atomically
if (newAvatarUrl != null) {
String oldAvatarUrl = user.getAvatarUrl();
user.setAvatarUrl(newAvatarUrl);
// Delete old file after successful DB commit
if (oldAvatarUrl != null) {
r2Service.delete(oldAvatarUrl);
}
}
userRepository.save(user);
}
4️⃣ Async Email System
“비밀번호 찾기” 흐름이 즉시 반환되고 이메일 전송은 백그라운드에서 비동기로 처리됩니다:
@Service
public class EmailService {
@Async
public void sendPasswordReset(String to, String resetLink) {
SimpleMailMessage message = new SimpleMailMessage();
message
```.setTo(to);
message.setSubject("Password Reset Request");
message.setText("Click the link to reset your password: " + resetLink);
mailSender.send(message);
}
}
⚡ Angular Signals & Optimistic UI
프론트엔드는 Angular 17 Signals에 전적으로 의존합니다. 상태(작업 목록, 통계, 프로필)는 반응형이며, 낙관적 UI 업데이트를 가능하게 합니다.
“매직” 순간 – 작업이 추가될 때, POST 요청이 백그라운드에서 실행되는 동안 UI가 Signal 목록을 즉시 업데이트합니다. 대시보드 차트가 즉시 다시 렌더링됩니다.
// Angular service: optimistic update
async addJob(job: Job) {
// 1️⃣ Optimistically update the local signal
const current = this.jobsSignal();
this.jobsSignal.set([...current, job]);
// 2️⃣ Send request to backend
try {
const saved = await firstValueFrom(this.http.post(`${this.apiUrl}/jobs`, job));
// Replace the optimistic entry with the server‑generated one (e.g., ID)
this.jobsSignal.update(list => list.map(j => (j.tempId === job.tempId ? saved : j)));
} catch (err) {
// Rollback on error
this.jobsSignal.update(list => list.filter(j => j.tempId !== job.tempId));
throw err;
}
}
🎉 최종 생각
JobTrackPro는 잘 설계된 클라우드‑네이티브 스택이 개인 생산성 도구를 엔터프라이즈‑급 애플리케이션으로 전환할 수 있음을 보여줍니다. 주요 요점:
- 하이브리드 OAuth2/JWT 인증
CompletableFuture를 활용한 병렬 분석- Cloudflare R2에서의 원자적 미디어 처리
- 비동기 이메일 전송
- 리액티브 Angular Signals
이 조합은 빠르고 안전하며 즐거운 사용자 경험을 제공합니다—현대적인 구직 워크플로우가 마땅히 받아야 할 바로 그 경험입니다. 데모를 자유롭게 탐색하고, 저장소에 별표를 달거나, 제안이 있으면 이슈를 열어 주세요! 🚀
🏁 결론
JobTrackPro를 구축하면서 “풀 스택”이 단순히 데이터베이스를 프론트엔드와 연결하는 것이 아니라는 것을 배웠습니다. 이미지 업로드 실패와 같은 엣지 케이스를 처리하고, OAuth2 리디렉션을 보안하며, 분석 기능을 확장 가능하게 만드는 것이 핵심이었습니다. 이 프로젝트는 오픈 소스로 유지되어 유사한 대시보드를 만들고자 하는 개발자들에게 도움이 되고자 합니다.
- Live App: https://thughari.github.io/JobTrackPro-UI
- Backend Source: https://github.com/thughari/JobTrackPro
- Frontend Source: https://github.com/thughari/JobTrackPro-UI
💬 Discussion
Cloudflare R2를 이미지 저장소로 사용해 보셨나요? 저는 S3보다 훨씬 저렴하다는 것을 발견했습니다. 댓글에 아키텍처에 대한 여러분의 생각을 알려 주세요!
읽어 주셔서 감사합니다! 즐거운 코딩 되세요! 🚀