Excel 사용을 중단했습니다: Spring Boot 3와 Angular 17으로 엔터프라이즈급 잡 트래커를 구축한 방법

발행: (2025년 12월 23일 오후 07:59 GMT+9)
7 min read
원문: Dev.to

Source: Dev.to

Note: 이것은 단순한 CRUD 앱이 아닙니다. 저는 이를 System Design Challenge 로 간주하고 보안, 동시성, 그리고 클라우드‑네이티브 스토리지 패턴에 중점을 두었습니다.

🔗 제품 소개

🛠️ 기술 스택

레이어기술
백엔드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
DevOpsDocker + Google Cloud Run + GitHub Actions

Source:

🧠 Architectural Deep Dive

아래는 JobTrackPro를 구축하면서 해결한 네 가지 가장 흥미로운 엔지니어링 과제입니다.

1️⃣ Hybrid Authentication (Google/GitHub + Local)

Goal – 사용자가 Google 또는 GitHub으로 로그인하고 나중에 외부 계정을 분리하기 위해 비밀번호를 설정할 수 있게 합니다.

Flow

Hybrid Authentication 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 리디렉션을 보안하며, 분석 기능을 확장 가능하게 만드는 것이 핵심이었습니다. 이 프로젝트는 오픈 소스로 유지되어 유사한 대시보드를 만들고자 하는 개발자들에게 도움이 되고자 합니다.

💬 Discussion

Cloudflare R2를 이미지 저장소로 사용해 보셨나요? 저는 S3보다 훨씬 저렴하다는 것을 발견했습니다. 댓글에 아키텍처에 대한 여러분의 생각을 알려 주세요!

읽어 주셔서 감사합니다! 즐거운 코딩 되세요! 🚀

Back to Blog

관련 글

더 보기 »