diff --git a/backend/.sdkmanrc b/backend/.sdkmanrc
new file mode 100644
index 0000000..a4417cd
--- /dev/null
+++ b/backend/.sdkmanrc
@@ -0,0 +1 @@
+java=17.0.18-amzn
diff --git a/backend/pom.xml b/backend/pom.xml
new file mode 100644
index 0000000..c99e425
--- /dev/null
+++ b/backend/pom.xml
@@ -0,0 +1,106 @@
+
+
+ 4.0.0
+
+
+ org.springframework.boot
+ spring-boot-starter-parent
+ 3.2.5
+
+
+
+ gc.mda
+ kcg
+ 0.0.1-SNAPSHOT
+ kcg
+ KCG Monitoring Dashboard Backend
+
+
+ 17
+ 0.12.6
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-web
+
+
+ org.springframework.boot
+ spring-boot-starter-data-jpa
+
+
+ org.springframework.boot
+ spring-boot-starter-validation
+
+
+
+
+ org.postgresql
+ postgresql
+ runtime
+
+
+
+
+ org.projectlombok
+ lombok
+ true
+
+
+
+
+ io.jsonwebtoken
+ jjwt-api
+ ${jjwt.version}
+
+
+ io.jsonwebtoken
+ jjwt-impl
+ ${jjwt.version}
+ runtime
+
+
+ io.jsonwebtoken
+ jjwt-jackson
+ ${jjwt.version}
+ runtime
+
+
+
+
+ com.google.api-client
+ google-api-client
+ 2.7.0
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-test
+ test
+
+
+
+
+ kcg
+
+
+ org.springframework.boot
+ spring-boot-maven-plugin
+
+
+
+ org.projectlombok
+ lombok
+
+
+
+
+
+
+
diff --git a/backend/src/main/java/gc/mda/kcg/KcgApplication.java b/backend/src/main/java/gc/mda/kcg/KcgApplication.java
new file mode 100644
index 0000000..8c11f99
--- /dev/null
+++ b/backend/src/main/java/gc/mda/kcg/KcgApplication.java
@@ -0,0 +1,14 @@
+package gc.mda.kcg;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.scheduling.annotation.EnableScheduling;
+
+@SpringBootApplication
+@EnableScheduling
+public class KcgApplication {
+
+ public static void main(String[] args) {
+ SpringApplication.run(KcgApplication.class, args);
+ }
+}
diff --git a/backend/src/main/java/gc/mda/kcg/auth/AuthController.java b/backend/src/main/java/gc/mda/kcg/auth/AuthController.java
new file mode 100644
index 0000000..655088c
--- /dev/null
+++ b/backend/src/main/java/gc/mda/kcg/auth/AuthController.java
@@ -0,0 +1,77 @@
+package gc.mda.kcg.auth;
+
+import gc.mda.kcg.auth.dto.AuthResponse;
+import gc.mda.kcg.auth.dto.GoogleAuthRequest;
+import jakarta.servlet.http.Cookie;
+import jakarta.servlet.http.HttpServletResponse;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.CookieValue;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+@RequestMapping("/api/auth")
+@RequiredArgsConstructor
+public class AuthController {
+
+ private static final String JWT_COOKIE_NAME = "kcg_token";
+
+ private final AuthService authService;
+ private final JwtProvider jwtProvider;
+
+ /**
+ * Google OAuth2 id_token 검증 후 JWT 쿠키 발급
+ */
+ @PostMapping("/google")
+ public ResponseEntity googleLogin(
+ @Valid @RequestBody GoogleAuthRequest request,
+ HttpServletResponse response) {
+
+ AuthResponse authResponse = authService.authenticateWithGoogle(request.getCredential());
+ String token = jwtProvider.generateToken(authResponse.getEmail(), authResponse.getName());
+
+ Cookie cookie = new Cookie(JWT_COOKIE_NAME, token);
+ cookie.setHttpOnly(true);
+ cookie.setSecure(false);
+ cookie.setPath("/");
+ cookie.setMaxAge((int) (jwtProvider.getExpirationMs() / 1000));
+ response.addCookie(cookie);
+
+ return ResponseEntity.ok(authResponse);
+ }
+
+ /**
+ * JWT 쿠키에서 현재 사용자 정보 반환
+ */
+ @GetMapping("/me")
+ public ResponseEntity me(
+ @CookieValue(name = JWT_COOKIE_NAME, required = false) String token) {
+
+ if (token == null || token.isBlank()) {
+ return ResponseEntity.status(401).build();
+ }
+
+ AuthResponse authResponse = authService.getUserFromToken(token);
+ return ResponseEntity.ok(authResponse);
+ }
+
+ /**
+ * JWT 쿠키 삭제 (로그아웃)
+ */
+ @PostMapping("/logout")
+ public ResponseEntity logout(HttpServletResponse response) {
+ Cookie cookie = new Cookie(JWT_COOKIE_NAME, "");
+ cookie.setHttpOnly(true);
+ cookie.setSecure(false);
+ cookie.setPath("/");
+ cookie.setMaxAge(0);
+ response.addCookie(cookie);
+
+ return ResponseEntity.ok().build();
+ }
+}
diff --git a/backend/src/main/java/gc/mda/kcg/auth/AuthFilter.java b/backend/src/main/java/gc/mda/kcg/auth/AuthFilter.java
new file mode 100644
index 0000000..d25beb4
--- /dev/null
+++ b/backend/src/main/java/gc/mda/kcg/auth/AuthFilter.java
@@ -0,0 +1,70 @@
+package gc.mda.kcg.auth;
+
+import io.jsonwebtoken.Claims;
+import jakarta.servlet.FilterChain;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.http.Cookie;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+import org.springframework.web.filter.OncePerRequestFilter;
+
+import java.io.IOException;
+import java.util.Arrays;
+
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class AuthFilter extends OncePerRequestFilter {
+
+ private static final String JWT_COOKIE_NAME = "kcg_token";
+ private static final String AUTH_PATH_PREFIX = "/api/auth/";
+
+ private final JwtProvider jwtProvider;
+
+ @Override
+ protected boolean shouldNotFilter(HttpServletRequest request) {
+ String path = request.getRequestURI();
+ return path.startsWith(AUTH_PATH_PREFIX);
+ }
+
+ @Override
+ protected void doFilterInternal(
+ HttpServletRequest request,
+ HttpServletResponse response,
+ FilterChain filterChain) throws ServletException, IOException {
+
+ String token = extractTokenFromCookies(request);
+
+ if (token == null || token.isBlank()) {
+ response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
+ response.getWriter().write("{\"error\":\"인증이 필요합니다.\"}");
+ return;
+ }
+
+ try {
+ Claims claims = jwtProvider.validateToken(token);
+ request.setAttribute("userEmail", claims.getSubject());
+ request.setAttribute("userName", claims.get("name", String.class));
+ filterChain.doFilter(request, response);
+ } catch (IllegalArgumentException e) {
+ log.warn("인증 실패: {}", e.getMessage());
+ response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
+ response.getWriter().write("{\"error\":\"유효하지 않은 토큰입니다.\"}");
+ }
+ }
+
+ private String extractTokenFromCookies(HttpServletRequest request) {
+ Cookie[] cookies = request.getCookies();
+ if (cookies == null) {
+ return null;
+ }
+ return Arrays.stream(cookies)
+ .filter(c -> JWT_COOKIE_NAME.equals(c.getName()))
+ .findFirst()
+ .map(Cookie::getValue)
+ .orElse(null);
+ }
+}
diff --git a/backend/src/main/java/gc/mda/kcg/auth/AuthService.java b/backend/src/main/java/gc/mda/kcg/auth/AuthService.java
new file mode 100644
index 0000000..876e272
--- /dev/null
+++ b/backend/src/main/java/gc/mda/kcg/auth/AuthService.java
@@ -0,0 +1,130 @@
+package gc.mda.kcg.auth;
+
+import com.google.api.client.googleapis.auth.oauth2.GoogleIdToken;
+import com.google.api.client.googleapis.auth.oauth2.GoogleIdTokenVerifier;
+import com.google.api.client.http.javanet.NetHttpTransport;
+import com.google.api.client.json.gson.GsonFactory;
+import gc.mda.kcg.auth.dto.AuthResponse;
+import gc.mda.kcg.auth.entity.LoginHistory;
+import gc.mda.kcg.auth.entity.User;
+import gc.mda.kcg.auth.repository.LoginHistoryRepository;
+import gc.mda.kcg.auth.repository.UserRepository;
+import gc.mda.kcg.config.AppProperties;
+import io.jsonwebtoken.Claims;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.LocalDateTime;
+import java.util.Collections;
+
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class AuthService {
+
+ private final AppProperties appProperties;
+ private final JwtProvider jwtProvider;
+ private final UserRepository userRepository;
+ private final LoginHistoryRepository loginHistoryRepository;
+
+ /**
+ * Google id_token 검증 후 사용자 upsert 및 로그인 이력 기록
+ */
+ @Transactional
+ public AuthResponse authenticateWithGoogle(String credential) {
+ GoogleIdToken idToken = verifyGoogleToken(credential);
+ GoogleIdToken.Payload payload = idToken.getPayload();
+
+ String email = payload.getEmail();
+ String name = (String) payload.get("name");
+ String picture = (String) payload.get("picture");
+
+ validateEmailDomain(email);
+
+ User user = upsertUser(email, name, picture);
+ recordLoginHistory(user);
+
+ return AuthResponse.builder()
+ .email(user.getEmail())
+ .name(user.getName())
+ .picture(user.getPicture())
+ .build();
+ }
+
+ /**
+ * JWT 토큰에서 사용자 정보 조회
+ */
+ @Transactional(readOnly = true)
+ public AuthResponse getUserFromToken(String token) {
+ Claims claims = jwtProvider.validateToken(token);
+ String email = claims.getSubject();
+
+ User user = userRepository.findByEmail(email)
+ .orElseThrow(() -> new IllegalArgumentException("사용자를 찾을 수 없습니다: " + email));
+
+ return AuthResponse.builder()
+ .email(user.getEmail())
+ .name(user.getName())
+ .picture(user.getPicture())
+ .build();
+ }
+
+ private GoogleIdToken verifyGoogleToken(String credential) {
+ try {
+ GoogleIdTokenVerifier verifier = new GoogleIdTokenVerifier.Builder(
+ new NetHttpTransport(), GsonFactory.getDefaultInstance())
+ .setAudience(Collections.singletonList(appProperties.getGoogle().getClientId()))
+ .build();
+
+ GoogleIdToken idToken = verifier.verify(credential);
+ if (idToken == null) {
+ throw new IllegalArgumentException("유효하지 않은 Google 토큰입니다.");
+ }
+ return idToken;
+ } catch (IllegalArgumentException e) {
+ throw e;
+ } catch (Exception e) {
+ log.error("Google 토큰 검증 실패", e);
+ throw new IllegalArgumentException("Google 토큰 검증에 실패했습니다.", e);
+ }
+ }
+
+ private void validateEmailDomain(String email) {
+ String allowedDomain = appProperties.getAuth().getAllowedDomain();
+ if (allowedDomain != null && !allowedDomain.isBlank()) {
+ String domain = email.substring(email.indexOf('@') + 1);
+ if (!domain.equals(allowedDomain)) {
+ throw new IllegalArgumentException("허용되지 않은 이메일 도메인입니다: " + domain);
+ }
+ }
+ }
+
+ private User upsertUser(String email, String name, String picture) {
+ return userRepository.findByEmail(email)
+ .map(existing -> {
+ existing.setName(name);
+ existing.setPicture(picture);
+ existing.setLastLoginAt(LocalDateTime.now());
+ return userRepository.save(existing);
+ })
+ .orElseGet(() -> {
+ User newUser = User.builder()
+ .email(email)
+ .name(name)
+ .picture(picture)
+ .lastLoginAt(LocalDateTime.now())
+ .build();
+ return userRepository.save(newUser);
+ });
+ }
+
+ private void recordLoginHistory(User user) {
+ LoginHistory history = LoginHistory.builder()
+ .user(user)
+ .loginAt(LocalDateTime.now())
+ .build();
+ loginHistoryRepository.save(history);
+ }
+}
diff --git a/backend/src/main/java/gc/mda/kcg/auth/JwtProvider.java b/backend/src/main/java/gc/mda/kcg/auth/JwtProvider.java
new file mode 100644
index 0000000..3cd15a3
--- /dev/null
+++ b/backend/src/main/java/gc/mda/kcg/auth/JwtProvider.java
@@ -0,0 +1,68 @@
+package gc.mda.kcg.auth;
+
+import gc.mda.kcg.config.AppProperties;
+import io.jsonwebtoken.Claims;
+import io.jsonwebtoken.JwtException;
+import io.jsonwebtoken.Jwts;
+import io.jsonwebtoken.security.Keys;
+import lombok.Getter;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+
+import javax.crypto.SecretKey;
+import java.nio.charset.StandardCharsets;
+import java.util.Date;
+
+@Slf4j
+@Component
+public class JwtProvider {
+
+ private final SecretKey secretKey;
+ @Getter
+ private final long expirationMs;
+
+ public JwtProvider(AppProperties appProperties) {
+ this.secretKey = Keys.hmacShaKeyFor(
+ appProperties.getJwt().getSecret().getBytes(StandardCharsets.UTF_8));
+ this.expirationMs = appProperties.getJwt().getExpirationMs();
+ }
+
+ /**
+ * JWT 토큰 생성
+ */
+ public String generateToken(String email, String name) {
+ Date now = new Date();
+ Date expiry = new Date(now.getTime() + expirationMs);
+
+ return Jwts.builder()
+ .subject(email)
+ .claim("name", name)
+ .issuedAt(now)
+ .expiration(expiry)
+ .signWith(secretKey)
+ .compact();
+ }
+
+ /**
+ * JWT 토큰 검증 및 Claims 반환
+ */
+ public Claims validateToken(String token) {
+ try {
+ return Jwts.parser()
+ .verifyWith(secretKey)
+ .build()
+ .parseSignedClaims(token)
+ .getPayload();
+ } catch (JwtException e) {
+ log.warn("JWT 토큰 검증 실패: {}", e.getMessage());
+ throw new IllegalArgumentException("유효하지 않은 토큰입니다.", e);
+ }
+ }
+
+ /**
+ * 토큰에서 이메일 추출
+ */
+ public String getEmailFromToken(String token) {
+ return validateToken(token).getSubject();
+ }
+}
diff --git a/backend/src/main/java/gc/mda/kcg/auth/dto/AuthResponse.java b/backend/src/main/java/gc/mda/kcg/auth/dto/AuthResponse.java
new file mode 100644
index 0000000..e7a5901
--- /dev/null
+++ b/backend/src/main/java/gc/mda/kcg/auth/dto/AuthResponse.java
@@ -0,0 +1,17 @@
+package gc.mda.kcg.auth.dto;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+@Getter
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class AuthResponse {
+
+ private String email;
+ private String name;
+ private String picture;
+}
diff --git a/backend/src/main/java/gc/mda/kcg/auth/dto/GoogleAuthRequest.java b/backend/src/main/java/gc/mda/kcg/auth/dto/GoogleAuthRequest.java
new file mode 100644
index 0000000..6239c4b
--- /dev/null
+++ b/backend/src/main/java/gc/mda/kcg/auth/dto/GoogleAuthRequest.java
@@ -0,0 +1,15 @@
+package gc.mda.kcg.auth.dto;
+
+import jakarta.validation.constraints.NotBlank;
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+@Getter
+@NoArgsConstructor
+@AllArgsConstructor
+public class GoogleAuthRequest {
+
+ @NotBlank(message = "credential은 필수입니다.")
+ private String credential;
+}
diff --git a/backend/src/main/java/gc/mda/kcg/auth/entity/LoginHistory.java b/backend/src/main/java/gc/mda/kcg/auth/entity/LoginHistory.java
new file mode 100644
index 0000000..e994de7
--- /dev/null
+++ b/backend/src/main/java/gc/mda/kcg/auth/entity/LoginHistory.java
@@ -0,0 +1,38 @@
+package gc.mda.kcg.auth.entity;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.FetchType;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.JoinColumn;
+import jakarta.persistence.ManyToOne;
+import jakarta.persistence.Table;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+import java.time.LocalDateTime;
+
+@Entity
+@Table(name = "login_history", schema = "kcg")
+@Getter
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class LoginHistory {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "user_id", nullable = false)
+ private User user;
+
+ @Column(name = "login_at", nullable = false)
+ @Builder.Default
+ private LocalDateTime loginAt = LocalDateTime.now();
+}
diff --git a/backend/src/main/java/gc/mda/kcg/auth/entity/User.java b/backend/src/main/java/gc/mda/kcg/auth/entity/User.java
new file mode 100644
index 0000000..d939d15
--- /dev/null
+++ b/backend/src/main/java/gc/mda/kcg/auth/entity/User.java
@@ -0,0 +1,44 @@
+package gc.mda.kcg.auth.entity;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.Table;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+
+import java.time.LocalDateTime;
+
+@Entity
+@Table(name = "users", schema = "kcg")
+@Getter
+@Setter
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class User {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @Column(nullable = false, unique = true)
+ private String email;
+
+ @Column(nullable = false)
+ private String name;
+
+ private String picture;
+
+ @Column(name = "last_login_at")
+ private LocalDateTime lastLoginAt;
+
+ @Column(name = "created_at", nullable = false, updatable = false)
+ @Builder.Default
+ private LocalDateTime createdAt = LocalDateTime.now();
+}
diff --git a/backend/src/main/java/gc/mda/kcg/auth/repository/LoginHistoryRepository.java b/backend/src/main/java/gc/mda/kcg/auth/repository/LoginHistoryRepository.java
new file mode 100644
index 0000000..b7c3556
--- /dev/null
+++ b/backend/src/main/java/gc/mda/kcg/auth/repository/LoginHistoryRepository.java
@@ -0,0 +1,7 @@
+package gc.mda.kcg.auth.repository;
+
+import gc.mda.kcg.auth.entity.LoginHistory;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+public interface LoginHistoryRepository extends JpaRepository {
+}
diff --git a/backend/src/main/java/gc/mda/kcg/auth/repository/UserRepository.java b/backend/src/main/java/gc/mda/kcg/auth/repository/UserRepository.java
new file mode 100644
index 0000000..17d9ae5
--- /dev/null
+++ b/backend/src/main/java/gc/mda/kcg/auth/repository/UserRepository.java
@@ -0,0 +1,11 @@
+package gc.mda.kcg.auth.repository;
+
+import gc.mda.kcg.auth.entity.User;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+import java.util.Optional;
+
+public interface UserRepository extends JpaRepository {
+
+ Optional findByEmail(String email);
+}
diff --git a/backend/src/main/java/gc/mda/kcg/collector/ArticleClassifier.java b/backend/src/main/java/gc/mda/kcg/collector/ArticleClassifier.java
new file mode 100644
index 0000000..83d6cd4
--- /dev/null
+++ b/backend/src/main/java/gc/mda/kcg/collector/ArticleClassifier.java
@@ -0,0 +1,39 @@
+package gc.mda.kcg.collector;
+
+import org.springframework.stereotype.Component;
+
+import java.util.Map;
+import java.util.regex.Pattern;
+
+/**
+ * 기사 분류기 (정규식 기반)
+ * TODO: 프론트엔드 classifyArticle() 로직 이식
+ */
+@Component
+public class ArticleClassifier {
+
+ private static final Map CATEGORY_PATTERNS = Map.of(
+ "military", Pattern.compile("(?i)(strike|attack|military|weapon|missile|drone|bomb|combat|war|force)"),
+ "political", Pattern.compile("(?i)(sanction|diplomat|negotiat|treaty|nuclear|iran|deal|policy)"),
+ "intelligence", Pattern.compile("(?i)(intel|spy|surveillance|intercept|sigint|osint|recon)")
+ );
+
+ /**
+ * 기사 제목/본문을 분석하여 카테고리를 반환
+ *
+ * @param title 기사 제목
+ * @param content 기사 본문
+ * @return 분류된 카테고리 (military, political, intelligence, general)
+ */
+ public String classifyArticle(String title, String content) {
+ String text = (title + " " + content).toLowerCase();
+
+ for (Map.Entry entry : CATEGORY_PATTERNS.entrySet()) {
+ if (entry.getValue().matcher(text).find()) {
+ return entry.getKey();
+ }
+ }
+
+ return "general";
+ }
+}
diff --git a/backend/src/main/java/gc/mda/kcg/collector/CentcomCollector.java b/backend/src/main/java/gc/mda/kcg/collector/CentcomCollector.java
new file mode 100644
index 0000000..3708228
--- /dev/null
+++ b/backend/src/main/java/gc/mda/kcg/collector/CentcomCollector.java
@@ -0,0 +1,11 @@
+package gc.mda.kcg.collector;
+
+import org.springframework.stereotype.Component;
+
+/**
+ * CENTCOM 보도자료 수집기
+ * TODO: CENTCOM 웹사이트에서 보도자료 수집 구현
+ */
+@Component
+public class CentcomCollector {
+}
diff --git a/backend/src/main/java/gc/mda/kcg/collector/GdeltCollector.java b/backend/src/main/java/gc/mda/kcg/collector/GdeltCollector.java
new file mode 100644
index 0000000..9433654
--- /dev/null
+++ b/backend/src/main/java/gc/mda/kcg/collector/GdeltCollector.java
@@ -0,0 +1,11 @@
+package gc.mda.kcg.collector;
+
+import org.springframework.stereotype.Component;
+
+/**
+ * GDELT 데이터 수집기
+ * TODO: GDELT API를 통한 이벤트/뉴스 데이터 수집 구현
+ */
+@Component
+public class GdeltCollector {
+}
diff --git a/backend/src/main/java/gc/mda/kcg/collector/GoogleNewsCollector.java b/backend/src/main/java/gc/mda/kcg/collector/GoogleNewsCollector.java
new file mode 100644
index 0000000..3327623
--- /dev/null
+++ b/backend/src/main/java/gc/mda/kcg/collector/GoogleNewsCollector.java
@@ -0,0 +1,11 @@
+package gc.mda.kcg.collector;
+
+import org.springframework.stereotype.Component;
+
+/**
+ * Google News RSS 수집기
+ * TODO: Google News RSS 피드를 통한 뉴스 데이터 수집 구현
+ */
+@Component
+public class GoogleNewsCollector {
+}
diff --git a/backend/src/main/java/gc/mda/kcg/config/AppProperties.java b/backend/src/main/java/gc/mda/kcg/config/AppProperties.java
new file mode 100644
index 0000000..686b67d
--- /dev/null
+++ b/backend/src/main/java/gc/mda/kcg/config/AppProperties.java
@@ -0,0 +1,36 @@
+package gc.mda.kcg.config;
+
+import lombok.Getter;
+import lombok.Setter;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.context.annotation.Configuration;
+
+@Getter
+@Setter
+@Configuration
+@ConfigurationProperties(prefix = "app")
+public class AppProperties {
+
+ private Jwt jwt = new Jwt();
+ private Google google = new Google();
+ private Auth auth = new Auth();
+
+ @Getter
+ @Setter
+ public static class Jwt {
+ private String secret;
+ private long expirationMs;
+ }
+
+ @Getter
+ @Setter
+ public static class Google {
+ private String clientId;
+ }
+
+ @Getter
+ @Setter
+ public static class Auth {
+ private String allowedDomain;
+ }
+}
diff --git a/backend/src/main/java/gc/mda/kcg/config/SecurityConfig.java b/backend/src/main/java/gc/mda/kcg/config/SecurityConfig.java
new file mode 100644
index 0000000..986479e
--- /dev/null
+++ b/backend/src/main/java/gc/mda/kcg/config/SecurityConfig.java
@@ -0,0 +1,19 @@
+package gc.mda.kcg.config;
+
+import gc.mda.kcg.auth.AuthFilter;
+import org.springframework.boot.web.servlet.FilterRegistrationBean;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+@Configuration
+public class SecurityConfig {
+
+ @Bean
+ public FilterRegistrationBean authFilterRegistration(AuthFilter authFilter) {
+ FilterRegistrationBean registration = new FilterRegistrationBean<>();
+ registration.setFilter(authFilter);
+ registration.addUrlPatterns("/api/*");
+ registration.setOrder(1);
+ return registration;
+ }
+}
diff --git a/backend/src/main/java/gc/mda/kcg/config/WebConfig.java b/backend/src/main/java/gc/mda/kcg/config/WebConfig.java
new file mode 100644
index 0000000..f18492d
--- /dev/null
+++ b/backend/src/main/java/gc/mda/kcg/config/WebConfig.java
@@ -0,0 +1,18 @@
+package gc.mda.kcg.config;
+
+import org.springframework.context.annotation.Configuration;
+import org.springframework.web.servlet.config.annotation.CorsRegistry;
+import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
+
+@Configuration
+public class WebConfig implements WebMvcConfigurer {
+
+ @Override
+ public void addCorsMappings(CorsRegistry registry) {
+ registry.addMapping("/api/**")
+ .allowedOrigins("http://localhost:5173")
+ .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
+ .allowedHeaders("*")
+ .allowCredentials(true);
+ }
+}
diff --git a/backend/src/main/java/gc/mda/kcg/domain/aircraft/package-info.java b/backend/src/main/java/gc/mda/kcg/domain/aircraft/package-info.java
new file mode 100644
index 0000000..f62a8d0
--- /dev/null
+++ b/backend/src/main/java/gc/mda/kcg/domain/aircraft/package-info.java
@@ -0,0 +1,4 @@
+/**
+ * 항공기 도메인 (향후 구현)
+ */
+package gc.mda.kcg.domain.aircraft;
diff --git a/backend/src/main/java/gc/mda/kcg/domain/event/package-info.java b/backend/src/main/java/gc/mda/kcg/domain/event/package-info.java
new file mode 100644
index 0000000..faa6bf5
--- /dev/null
+++ b/backend/src/main/java/gc/mda/kcg/domain/event/package-info.java
@@ -0,0 +1,4 @@
+/**
+ * 이벤트 도메인 (향후 구현)
+ */
+package gc.mda.kcg.domain.event;
diff --git a/backend/src/main/java/gc/mda/kcg/domain/news/package-info.java b/backend/src/main/java/gc/mda/kcg/domain/news/package-info.java
new file mode 100644
index 0000000..16142ce
--- /dev/null
+++ b/backend/src/main/java/gc/mda/kcg/domain/news/package-info.java
@@ -0,0 +1,4 @@
+/**
+ * 뉴스 도메인 (향후 구현)
+ */
+package gc.mda.kcg.domain.news;
diff --git a/backend/src/main/java/gc/mda/kcg/domain/osint/package-info.java b/backend/src/main/java/gc/mda/kcg/domain/osint/package-info.java
new file mode 100644
index 0000000..0a78dff
--- /dev/null
+++ b/backend/src/main/java/gc/mda/kcg/domain/osint/package-info.java
@@ -0,0 +1,4 @@
+/**
+ * OSINT 도메인 (향후 구현)
+ */
+package gc.mda.kcg.domain.osint;
diff --git a/backend/src/main/resources/application-local.yml.example b/backend/src/main/resources/application-local.yml.example
new file mode 100644
index 0000000..0fa5862
--- /dev/null
+++ b/backend/src/main/resources/application-local.yml.example
@@ -0,0 +1,13 @@
+spring:
+ datasource:
+ url: jdbc:postgresql://localhost:5432/kcgdb?currentSchema=kcg
+ username: kcg_user
+ password: kcg_pass
+app:
+ jwt:
+ secret: local-dev-secret-key-32chars-minimum!!
+ expiration-ms: 86400000
+ google:
+ client-id: YOUR_GOOGLE_CLIENT_ID
+ auth:
+ allowed-domain: gcsc.co.kr
diff --git a/backend/src/main/resources/application-prod.yml.example b/backend/src/main/resources/application-prod.yml.example
new file mode 100644
index 0000000..71c2848
--- /dev/null
+++ b/backend/src/main/resources/application-prod.yml.example
@@ -0,0 +1,13 @@
+spring:
+ datasource:
+ url: ${DB_URL}
+ username: ${DB_USERNAME}
+ password: ${DB_PASSWORD}
+app:
+ jwt:
+ secret: ${JWT_SECRET}
+ expiration-ms: ${JWT_EXPIRATION_MS:86400000}
+ google:
+ client-id: ${GOOGLE_CLIENT_ID}
+ auth:
+ allowed-domain: ${AUTH_ALLOWED_DOMAIN:gcsc.co.kr}
diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml
new file mode 100644
index 0000000..19fc170
--- /dev/null
+++ b/backend/src/main/resources/application.yml
@@ -0,0 +1,11 @@
+spring:
+ profiles:
+ active: ${SPRING_PROFILES_ACTIVE:local}
+ jpa:
+ hibernate:
+ ddl-auto: validate
+ properties:
+ hibernate:
+ default_schema: kcg
+server:
+ port: 8080
diff --git a/database/init.sql b/database/init.sql
new file mode 100644
index 0000000..f9b5509
--- /dev/null
+++ b/database/init.sql
@@ -0,0 +1,2 @@
+-- KCG 데이터베이스 초기화
+CREATE SCHEMA IF NOT EXISTS kcg;
diff --git a/database/migration/001_initial_schema.sql b/database/migration/001_initial_schema.sql
new file mode 100644
index 0000000..3b9eadc
--- /dev/null
+++ b/database/migration/001_initial_schema.sql
@@ -0,0 +1,83 @@
+-- 001: 초기 스키마 생성
+-- events, news, osint, users, login_history
+
+SET search_path TO kcg;
+
+-- 이벤트 테이블 (GDELT 등 이벤트 데이터)
+CREATE TABLE IF NOT EXISTS events (
+ id BIGSERIAL PRIMARY KEY,
+ event_id VARCHAR(64) UNIQUE,
+ title TEXT NOT NULL,
+ description TEXT,
+ source VARCHAR(128),
+ source_url TEXT,
+ category VARCHAR(64),
+ latitude DOUBLE PRECISION,
+ longitude DOUBLE PRECISION,
+ timestamp TIMESTAMP NOT NULL,
+ raw_data JSONB,
+ created_at TIMESTAMP NOT NULL DEFAULT NOW()
+);
+
+CREATE INDEX IF NOT EXISTS idx_events_timestamp ON events (timestamp);
+
+-- 뉴스 테이블
+CREATE TABLE IF NOT EXISTS news (
+ id BIGSERIAL PRIMARY KEY,
+ title TEXT NOT NULL,
+ summary TEXT,
+ source VARCHAR(128),
+ source_url TEXT UNIQUE,
+ category VARCHAR(64),
+ language VARCHAR(8),
+ timestamp TIMESTAMP NOT NULL,
+ raw_data JSONB,
+ created_at TIMESTAMP NOT NULL DEFAULT NOW()
+);
+
+CREATE INDEX IF NOT EXISTS idx_news_timestamp ON news (timestamp);
+
+-- OSINT 테이블
+CREATE TABLE IF NOT EXISTS osint (
+ id BIGSERIAL PRIMARY KEY,
+ title TEXT NOT NULL,
+ content TEXT,
+ source VARCHAR(128),
+ source_url TEXT UNIQUE,
+ category VARCHAR(64),
+ credibility SMALLINT,
+ latitude DOUBLE PRECISION,
+ longitude DOUBLE PRECISION,
+ timestamp TIMESTAMP NOT NULL,
+ raw_data JSONB,
+ created_at TIMESTAMP NOT NULL DEFAULT NOW()
+);
+
+CREATE INDEX IF NOT EXISTS idx_osint_timestamp ON osint (timestamp);
+CREATE INDEX IF NOT EXISTS idx_osint_category ON osint (category);
+
+-- 사용자 테이블
+CREATE TABLE IF NOT EXISTS users (
+ id BIGSERIAL PRIMARY KEY,
+ email VARCHAR(255) NOT NULL UNIQUE,
+ name VARCHAR(128) NOT NULL,
+ picture TEXT,
+ last_login_at TIMESTAMP,
+ created_at TIMESTAMP NOT NULL DEFAULT NOW()
+);
+
+CREATE INDEX IF NOT EXISTS idx_users_email ON users (email);
+
+-- 로그인 이력 테이블
+CREATE TABLE IF NOT EXISTS login_history (
+ id BIGSERIAL PRIMARY KEY,
+ user_id BIGINT NOT NULL REFERENCES users(id),
+ login_at TIMESTAMP NOT NULL DEFAULT NOW(),
+ ip_address VARCHAR(45),
+ user_agent TEXT,
+ success BOOLEAN NOT NULL DEFAULT TRUE,
+ failure_reason VARCHAR(100)
+);
+
+CREATE INDEX IF NOT EXISTS idx_login_history_user_id ON login_history (user_id);
+CREATE INDEX IF NOT EXISTS idx_login_history_login_at ON login_history (login_at);
diff --git a/prediction/README.md b/prediction/README.md
new file mode 100644
index 0000000..7f0ff41
--- /dev/null
+++ b/prediction/README.md
@@ -0,0 +1,17 @@
+# KCG Prediction Service
+
+FastAPI 기반 예측 서비스 (향후 구현 예정)
+
+## 실행
+
+```bash
+cd prediction
+python3 -m venv venv
+source venv/bin/activate
+pip install -r requirements.txt
+uvicorn main:app --reload --port 8000
+```
+
+## 엔드포인트
+
+- `GET /health` - 헬스 체크
diff --git a/prediction/main.py b/prediction/main.py
new file mode 100644
index 0000000..fbccfe3
--- /dev/null
+++ b/prediction/main.py
@@ -0,0 +1,8 @@
+from fastapi import FastAPI
+
+app = FastAPI(title="KCG Prediction Service", version="0.1.0")
+
+
+@app.get("/health")
+def health_check():
+ return {"status": "ok"}
diff --git a/prediction/requirements.txt b/prediction/requirements.txt
new file mode 100644
index 0000000..531efda
--- /dev/null
+++ b/prediction/requirements.txt
@@ -0,0 +1,2 @@
+fastapi==0.115.0
+uvicorn==0.30.6