From 143de60c6fca23994586bed06f24db1bb316a838 Mon Sep 17 00:00:00 2001
From: Sumin Hwang <163857590+tnals0924@users.noreply.github.com>
Date: Wed, 30 Sep 2026 17:49:41 +0900
Subject: [PATCH 01/11] =?UTF-8?q?chore:=20core:domain:auth=20Gradle=20?=
=?UTF-8?q?=EB=AA=A8=EB=93=88=20=EC=A2=8C=ED=91=9C=C2=B7jar=20=EC=9D=B4?=
=?UTF-8?q?=EB=A6=84=EC=9D=84=20gateway:auth=EC=99=80=20=EB=B6=84=EB=A6=AC?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
core/domain/auth/build.gradle.kts | 8 ++++++++
1 file changed, 8 insertions(+)
diff --git a/core/domain/auth/build.gradle.kts b/core/domain/auth/build.gradle.kts
index 0854131d..122cec81 100644
--- a/core/domain/auth/build.gradle.kts
+++ b/core/domain/auth/build.gradle.kts
@@ -4,6 +4,14 @@ plugins {
description = "auth 도메인 — gateway:auth(Security/JWT)와는 별개의 도메인 계층 로직"
+// gateway:auth와 프로젝트 이름(auth)이 같아 기본 좌표(kr.ac.kookmin:auth)가 겹치면 Gradle이 두 모듈을 같은 모듈로 보고
+// 한쪽을 다른 쪽으로 치환한다. group을 달리해 좌표를 분리하고, bootJar 안에서 jar 파일명도 겹치지 않게 바꾼다
+group = "kr.ac.kookmin.domain"
+
+base {
+ archivesName.set("domain-auth")
+}
+
dependencies {
implementation(project(":core:common"))
From 254ff19931c754fcd82c472e6c286ea98ca12eae Mon Sep 17 00:00:00 2001
From: Sumin Hwang <163857590+tnals0924@users.noreply.github.com>
Date: Wed, 30 Sep 2026 17:49:41 +0900
Subject: [PATCH 02/11] =?UTF-8?q?feat:=20auth=20=EB=8F=84=EB=A9=94?=
=?UTF-8?q?=EC=9D=B8=20OAuth=20=EC=A0=84=EB=9E=B5=20=EC=9D=B8=ED=84=B0?=
=?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=8A=A4=EC=99=80=20=EC=84=9C=EB=B9=84?=
=?UTF-8?q?=EC=8A=A4=20=EC=B6=94=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../ac/kookmin/stream/common/ErrorStatus.java | 1 +
.../auth/domain/oauth/client/OAuthClient.java | 23 +++++++++
.../domain/oauth/domain/OAuthAccount.java | 28 +++++++++++
.../domain/oauth/domain/OAuthErrorCode.java | 21 +++++++++
.../oauth/domain/OAuthLoginCommand.java | 8 ++++
.../domain/oauth/domain/OAuthProvider.java | 16 +++++++
.../domain/oauth/domain/OAuthUserInfo.java | 17 +++++++
.../repository/OAuthAccountRepository.java | 10 ++++
.../domain/oauth/service/OAuthService.java | 12 +++++
.../service/impl/OAuthClientRegistry.java | 42 +++++++++++++++++
.../oauth/service/impl/OAuthServiceImpl.java | 47 +++++++++++++++++++
.../ac/kookmin/stream/auth/package-info.java | 8 ++++
12 files changed, 233 insertions(+)
create mode 100644 core/domain/auth/src/main/java/kr/ac/kookmin/stream/auth/domain/oauth/client/OAuthClient.java
create mode 100644 core/domain/auth/src/main/java/kr/ac/kookmin/stream/auth/domain/oauth/domain/OAuthAccount.java
create mode 100644 core/domain/auth/src/main/java/kr/ac/kookmin/stream/auth/domain/oauth/domain/OAuthErrorCode.java
create mode 100644 core/domain/auth/src/main/java/kr/ac/kookmin/stream/auth/domain/oauth/domain/OAuthLoginCommand.java
create mode 100644 core/domain/auth/src/main/java/kr/ac/kookmin/stream/auth/domain/oauth/domain/OAuthProvider.java
create mode 100644 core/domain/auth/src/main/java/kr/ac/kookmin/stream/auth/domain/oauth/domain/OAuthUserInfo.java
create mode 100644 core/domain/auth/src/main/java/kr/ac/kookmin/stream/auth/domain/oauth/repository/OAuthAccountRepository.java
create mode 100644 core/domain/auth/src/main/java/kr/ac/kookmin/stream/auth/domain/oauth/service/OAuthService.java
create mode 100644 core/domain/auth/src/main/java/kr/ac/kookmin/stream/auth/domain/oauth/service/impl/OAuthClientRegistry.java
create mode 100644 core/domain/auth/src/main/java/kr/ac/kookmin/stream/auth/domain/oauth/service/impl/OAuthServiceImpl.java
create mode 100644 core/domain/auth/src/main/java/kr/ac/kookmin/stream/auth/package-info.java
diff --git a/core/common/src/main/java/kr/ac/kookmin/stream/common/ErrorStatus.java b/core/common/src/main/java/kr/ac/kookmin/stream/common/ErrorStatus.java
index 74ca85dc..3ebad06b 100644
--- a/core/common/src/main/java/kr/ac/kookmin/stream/common/ErrorStatus.java
+++ b/core/common/src/main/java/kr/ac/kookmin/stream/common/ErrorStatus.java
@@ -12,4 +12,5 @@ public final class ErrorStatus {
public static final int BAD_REQUEST = 400;
public static final int CONFLICT = 409;
public static final int FORBIDDEN = 403;
+ public static final int BAD_GATEWAY = 502;
}
diff --git a/core/domain/auth/src/main/java/kr/ac/kookmin/stream/auth/domain/oauth/client/OAuthClient.java b/core/domain/auth/src/main/java/kr/ac/kookmin/stream/auth/domain/oauth/client/OAuthClient.java
new file mode 100644
index 00000000..7100c894
--- /dev/null
+++ b/core/domain/auth/src/main/java/kr/ac/kookmin/stream/auth/domain/oauth/client/OAuthClient.java
@@ -0,0 +1,23 @@
+package kr.ac.kookmin.stream.auth.domain.oauth.client;
+
+import kr.ac.kookmin.stream.auth.domain.oauth.domain.OAuthLoginCommand;
+import kr.ac.kookmin.stream.auth.domain.oauth.domain.OAuthProvider;
+import kr.ac.kookmin.stream.auth.domain.oauth.domain.OAuthUserInfo;
+
+/**
+ * provider별 OAuth 로그인 전략. 구현체는 provider마다 하나씩 모두 빈으로 등록하고,
+ * 요청의 provider에 맞는 구현체를 OAuthClientRegistry가 고른다.
+ */
+public interface OAuthClient {
+
+ OAuthProvider provider();
+
+ /** provider에 등록한 redirect URI와 정확히 일치하는지. 외부 호출 전에 거르는 용도다. */
+ boolean isAllowedRedirectUri(String redirectUri);
+
+ /**
+ * code를 provider 토큰으로 교환하고 사용자 정보를 조회한다.
+ * provider 토큰은 이 메서드 안에서만 쓰고 밖으로 내보내지 않는다.
+ */
+ OAuthUserInfo fetchUserInfo(OAuthLoginCommand command);
+}
diff --git a/core/domain/auth/src/main/java/kr/ac/kookmin/stream/auth/domain/oauth/domain/OAuthAccount.java b/core/domain/auth/src/main/java/kr/ac/kookmin/stream/auth/domain/oauth/domain/OAuthAccount.java
new file mode 100644
index 00000000..eb75571e
--- /dev/null
+++ b/core/domain/auth/src/main/java/kr/ac/kookmin/stream/auth/domain/oauth/domain/OAuthAccount.java
@@ -0,0 +1,28 @@
+package kr.ac.kookmin.stream.auth.domain.oauth.domain;
+
+import lombok.AccessLevel;
+import lombok.AllArgsConstructor;
+import lombok.EqualsAndHashCode;
+import lombok.Getter;
+
+/**
+ * provider 계정과 회원의 연결. 한 회원은 provider마다 계정을 하나씩 연결할 수 있다.
+ */
+@Getter
+@EqualsAndHashCode
+@AllArgsConstructor(access = AccessLevel.PRIVATE)
+public class OAuthAccount {
+
+ private Long id;
+ private Long memberId;
+ private OAuthProvider provider;
+ private String providerUserId;
+
+ public static OAuthAccount create(Long memberId, OAuthProvider provider, String providerUserId) {
+ return new OAuthAccount(null, memberId, provider, providerUserId);
+ }
+
+ public static OAuthAccount of(Long id, Long memberId, OAuthProvider provider, String providerUserId) {
+ return new OAuthAccount(id, memberId, provider, providerUserId);
+ }
+}
diff --git a/core/domain/auth/src/main/java/kr/ac/kookmin/stream/auth/domain/oauth/domain/OAuthErrorCode.java b/core/domain/auth/src/main/java/kr/ac/kookmin/stream/auth/domain/oauth/domain/OAuthErrorCode.java
new file mode 100644
index 00000000..66884559
--- /dev/null
+++ b/core/domain/auth/src/main/java/kr/ac/kookmin/stream/auth/domain/oauth/domain/OAuthErrorCode.java
@@ -0,0 +1,21 @@
+package kr.ac.kookmin.stream.auth.domain.oauth.domain;
+
+import kr.ac.kookmin.stream.common.ErrorCode;
+import kr.ac.kookmin.stream.common.ErrorStatus;
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import lombok.experimental.Accessors;
+
+@Getter
+@Accessors(fluent = true)
+@AllArgsConstructor
+public enum OAuthErrorCode implements ErrorCode {
+
+ UNSUPPORTED_OAUTH_PROVIDER(ErrorStatus.BAD_REQUEST, "지원하지 않는 로그인 방식입니다."),
+ REDIRECT_URI_NOT_ALLOWED(ErrorStatus.BAD_REQUEST, "허용되지 않은 redirect URI입니다."),
+ INVALID_AUTHORIZATION_CODE(ErrorStatus.UNAUTHORIZED, "로그인이 만료되었습니다. 다시 로그인해 주세요."),
+ OAUTH_PROVIDER_UNAVAILABLE(ErrorStatus.BAD_GATEWAY, "로그인 서버에 연결할 수 없습니다. 잠시 후 다시 시도해 주세요.");
+
+ private final int status;
+ private final String message;
+}
diff --git a/core/domain/auth/src/main/java/kr/ac/kookmin/stream/auth/domain/oauth/domain/OAuthLoginCommand.java b/core/domain/auth/src/main/java/kr/ac/kookmin/stream/auth/domain/oauth/domain/OAuthLoginCommand.java
new file mode 100644
index 00000000..71dcb3eb
--- /dev/null
+++ b/core/domain/auth/src/main/java/kr/ac/kookmin/stream/auth/domain/oauth/domain/OAuthLoginCommand.java
@@ -0,0 +1,8 @@
+package kr.ac.kookmin.stream.auth.domain.oauth.domain;
+
+public record OAuthLoginCommand(
+ OAuthProvider provider,
+ String code,
+ String codeVerifier,
+ String redirectUri
+) {}
diff --git a/core/domain/auth/src/main/java/kr/ac/kookmin/stream/auth/domain/oauth/domain/OAuthProvider.java b/core/domain/auth/src/main/java/kr/ac/kookmin/stream/auth/domain/oauth/domain/OAuthProvider.java
new file mode 100644
index 00000000..62826c66
--- /dev/null
+++ b/core/domain/auth/src/main/java/kr/ac/kookmin/stream/auth/domain/oauth/domain/OAuthProvider.java
@@ -0,0 +1,16 @@
+package kr.ac.kookmin.stream.auth.domain.oauth.domain;
+
+import java.util.Arrays;
+import kr.ac.kookmin.stream.common.BusinessException;
+
+public enum OAuthProvider {
+ KCONNECT;
+
+ /** 로그인 경로의 provider 값(예: "kconnect")을 대소문자 구분 없이 변환한다. */
+ public static OAuthProvider from(String value) {
+ return Arrays.stream(values())
+ .filter(provider -> provider.name().equalsIgnoreCase(value))
+ .findFirst()
+ .orElseThrow(() -> new BusinessException(OAuthErrorCode.UNSUPPORTED_OAUTH_PROVIDER));
+ }
+}
diff --git a/core/domain/auth/src/main/java/kr/ac/kookmin/stream/auth/domain/oauth/domain/OAuthUserInfo.java b/core/domain/auth/src/main/java/kr/ac/kookmin/stream/auth/domain/oauth/domain/OAuthUserInfo.java
new file mode 100644
index 00000000..697009b8
--- /dev/null
+++ b/core/domain/auth/src/main/java/kr/ac/kookmin/stream/auth/domain/oauth/domain/OAuthUserInfo.java
@@ -0,0 +1,17 @@
+package kr.ac.kookmin.stream.auth.domain.oauth.domain;
+
+/**
+ * provider에서 확인한 사용자 정보.
+ *
+ * providerUserId는 provider 안에서 바뀌지 않는 고유 ID로, 회원을 찾는 키가 된다.
+ * studentId·major·academicStatus는 학번·학과·학적을 주지 않는 provider(구글·카카오)에서 null일 수 있다.
+ * academicStatus는 provider가 주는 학적 상태 값을 그대로 담는다.
+ */
+public record OAuthUserInfo(
+ OAuthProvider provider,
+ String providerUserId,
+ String studentId,
+ String name,
+ String major,
+ String academicStatus
+) {}
diff --git a/core/domain/auth/src/main/java/kr/ac/kookmin/stream/auth/domain/oauth/repository/OAuthAccountRepository.java b/core/domain/auth/src/main/java/kr/ac/kookmin/stream/auth/domain/oauth/repository/OAuthAccountRepository.java
new file mode 100644
index 00000000..1544c562
--- /dev/null
+++ b/core/domain/auth/src/main/java/kr/ac/kookmin/stream/auth/domain/oauth/repository/OAuthAccountRepository.java
@@ -0,0 +1,10 @@
+package kr.ac.kookmin.stream.auth.domain.oauth.repository;
+
+import java.util.Optional;
+import kr.ac.kookmin.stream.auth.domain.oauth.domain.OAuthAccount;
+import kr.ac.kookmin.stream.auth.domain.oauth.domain.OAuthProvider;
+
+public interface OAuthAccountRepository {
+ Optional findByProviderAndProviderUserId(OAuthProvider provider, String providerUserId);
+ OAuthAccount save(OAuthAccount oauthAccount);
+}
diff --git a/core/domain/auth/src/main/java/kr/ac/kookmin/stream/auth/domain/oauth/service/OAuthService.java b/core/domain/auth/src/main/java/kr/ac/kookmin/stream/auth/domain/oauth/service/OAuthService.java
new file mode 100644
index 00000000..14fd2c3f
--- /dev/null
+++ b/core/domain/auth/src/main/java/kr/ac/kookmin/stream/auth/domain/oauth/service/OAuthService.java
@@ -0,0 +1,12 @@
+package kr.ac.kookmin.stream.auth.domain.oauth.service;
+
+import java.util.Optional;
+import kr.ac.kookmin.stream.auth.domain.oauth.domain.OAuthLoginCommand;
+import kr.ac.kookmin.stream.auth.domain.oauth.domain.OAuthProvider;
+import kr.ac.kookmin.stream.auth.domain.oauth.domain.OAuthUserInfo;
+
+public interface OAuthService {
+ OAuthUserInfo authenticate(OAuthLoginCommand command);
+ Optional findMemberId(OAuthProvider provider, String providerUserId);
+ void link(OAuthProvider provider, String providerUserId, Long memberId);
+}
diff --git a/core/domain/auth/src/main/java/kr/ac/kookmin/stream/auth/domain/oauth/service/impl/OAuthClientRegistry.java b/core/domain/auth/src/main/java/kr/ac/kookmin/stream/auth/domain/oauth/service/impl/OAuthClientRegistry.java
new file mode 100644
index 00000000..46417aa3
--- /dev/null
+++ b/core/domain/auth/src/main/java/kr/ac/kookmin/stream/auth/domain/oauth/service/impl/OAuthClientRegistry.java
@@ -0,0 +1,42 @@
+package kr.ac.kookmin.stream.auth.domain.oauth.service.impl;
+
+import java.util.EnumMap;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+import kr.ac.kookmin.stream.auth.domain.oauth.client.OAuthClient;
+import kr.ac.kookmin.stream.auth.domain.oauth.domain.OAuthErrorCode;
+import kr.ac.kookmin.stream.auth.domain.oauth.domain.OAuthProvider;
+import kr.ac.kookmin.stream.common.BusinessException;
+import org.springframework.stereotype.Component;
+
+/**
+ * 빈으로 등록된 OAuthClient 구현체를 provider별로 모아 두고, 요청의 provider에 맞는 구현체를 돌려준다.
+ * 같은 provider의 구현체가 둘이면 기동 시 실패한다.
+ */
+@Component
+class OAuthClientRegistry {
+
+ private final Map clients;
+
+ OAuthClientRegistry(List clients) {
+ this.clients = clients.stream()
+ .collect(Collectors.toMap(
+ OAuthClient::provider,
+ Function.identity(),
+ (first, second) -> {
+ throw new IllegalStateException("OAuthClient 구현체가 중복됐습니다: " + first.provider());
+ },
+ () -> new EnumMap<>(OAuthProvider.class)
+ ));
+ }
+
+ OAuthClient get(OAuthProvider provider) {
+ OAuthClient client = clients.get(provider);
+ if (client == null) {
+ throw new BusinessException(OAuthErrorCode.UNSUPPORTED_OAUTH_PROVIDER);
+ }
+ return client;
+ }
+}
diff --git a/core/domain/auth/src/main/java/kr/ac/kookmin/stream/auth/domain/oauth/service/impl/OAuthServiceImpl.java b/core/domain/auth/src/main/java/kr/ac/kookmin/stream/auth/domain/oauth/service/impl/OAuthServiceImpl.java
new file mode 100644
index 00000000..aa453c65
--- /dev/null
+++ b/core/domain/auth/src/main/java/kr/ac/kookmin/stream/auth/domain/oauth/service/impl/OAuthServiceImpl.java
@@ -0,0 +1,47 @@
+package kr.ac.kookmin.stream.auth.domain.oauth.service.impl;
+
+import java.util.Optional;
+import kr.ac.kookmin.stream.auth.domain.oauth.client.OAuthClient;
+import kr.ac.kookmin.stream.auth.domain.oauth.domain.OAuthAccount;
+import kr.ac.kookmin.stream.auth.domain.oauth.domain.OAuthErrorCode;
+import kr.ac.kookmin.stream.auth.domain.oauth.domain.OAuthLoginCommand;
+import kr.ac.kookmin.stream.auth.domain.oauth.domain.OAuthProvider;
+import kr.ac.kookmin.stream.auth.domain.oauth.domain.OAuthUserInfo;
+import kr.ac.kookmin.stream.auth.domain.oauth.repository.OAuthAccountRepository;
+import kr.ac.kookmin.stream.auth.domain.oauth.service.OAuthService;
+import kr.ac.kookmin.stream.common.BusinessException;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+@Service
+@RequiredArgsConstructor
+class OAuthServiceImpl implements OAuthService {
+
+ private final OAuthClientRegistry oauthClientRegistry;
+ private final OAuthAccountRepository oauthAccountRepository;
+
+ // 외부 호출만 하므로 트랜잭션을 걸지 않는다
+ @Override
+ public OAuthUserInfo authenticate(OAuthLoginCommand command) {
+ OAuthClient client = oauthClientRegistry.get(command.provider());
+ if (!client.isAllowedRedirectUri(command.redirectUri())) {
+ throw new BusinessException(OAuthErrorCode.REDIRECT_URI_NOT_ALLOWED);
+ }
+
+ return client.fetchUserInfo(command);
+ }
+
+ @Override
+ @Transactional(readOnly = true)
+ public Optional findMemberId(OAuthProvider provider, String providerUserId) {
+ return oauthAccountRepository.findByProviderAndProviderUserId(provider, providerUserId)
+ .map(OAuthAccount::getMemberId);
+ }
+
+ @Override
+ @Transactional
+ public void link(OAuthProvider provider, String providerUserId, Long memberId) {
+ oauthAccountRepository.save(OAuthAccount.create(memberId, provider, providerUserId));
+ }
+}
diff --git a/core/domain/auth/src/main/java/kr/ac/kookmin/stream/auth/package-info.java b/core/domain/auth/src/main/java/kr/ac/kookmin/stream/auth/package-info.java
new file mode 100644
index 00000000..ae0c6da6
--- /dev/null
+++ b/core/domain/auth/src/main/java/kr/ac/kookmin/stream/auth/package-info.java
@@ -0,0 +1,8 @@
+/**
+ * 계층 패키지(domain/{도메인}/{domain|repository|service})를 그대로 공개하기 위해 OPEN으로 둔다.
+ * service.impl 접근 차단은 bootstrap의 DomainImplAccessTests(ArchUnit)가 담당한다.
+ */
+@ApplicationModule(type = ApplicationModule.Type.OPEN)
+package kr.ac.kookmin.stream.auth;
+
+import org.springframework.modulith.ApplicationModule;
From afcf1d806eecade7379244c132237f520454009f Mon Sep 17 00:00:00 2001
From: Sumin Hwang <163857590+tnals0924@users.noreply.github.com>
Date: Wed, 30 Sep 2026 17:49:41 +0900
Subject: [PATCH 03/11] =?UTF-8?q?feat:=20OAuth=20=EA=B3=84=EC=A0=95=20?=
=?UTF-8?q?=EC=97=B0=EA=B2=B0=20=ED=85=8C=EC=9D=B4=EB=B8=94=EA=B3=BC=20JPA?=
=?UTF-8?q?=20=EC=A0=80=EC=9E=A5=EC=86=8C=20=EC=B6=94=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
infrastructure/db/build.gradle.kts | 1 +
.../stream/db/auth/OAuthAccountJpaEntity.java | 64 +++++++++++++++++++
.../db/auth/OAuthAccountJpaRepository.java | 9 +++
.../db/auth/OAuthAccountRepositoryImpl.java | 26 ++++++++
.../V13__create_oauth_accounts_table.sql | 16 +++++
5 files changed, 116 insertions(+)
create mode 100644 infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/auth/OAuthAccountJpaEntity.java
create mode 100644 infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/auth/OAuthAccountJpaRepository.java
create mode 100644 infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/auth/OAuthAccountRepositoryImpl.java
create mode 100644 infrastructure/db/src/main/resources/db/migration/V13__create_oauth_accounts_table.sql
diff --git a/infrastructure/db/build.gradle.kts b/infrastructure/db/build.gradle.kts
index 903dc34b..2b98ca65 100644
--- a/infrastructure/db/build.gradle.kts
+++ b/infrastructure/db/build.gradle.kts
@@ -6,6 +6,7 @@ description = "JPA Entity, RepositoryImpl, Flyway 마이그레이션 (MySQL)"
dependencies {
implementation(project(":core:common"))
+ implementation(project(":core:domain:auth"))
implementation(project(":core:domain:member"))
implementation(project(":core:domain:event"))
implementation(project(":core:domain:welfare"))
diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/auth/OAuthAccountJpaEntity.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/auth/OAuthAccountJpaEntity.java
new file mode 100644
index 00000000..2810a3d7
--- /dev/null
+++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/auth/OAuthAccountJpaEntity.java
@@ -0,0 +1,64 @@
+package kr.ac.kookmin.stream.db.auth;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.EnumType;
+import jakarta.persistence.Enumerated;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.Table;
+import jakarta.persistence.UniqueConstraint;
+import kr.ac.kookmin.stream.auth.domain.oauth.domain.OAuthAccount;
+import kr.ac.kookmin.stream.auth.domain.oauth.domain.OAuthProvider;
+import kr.ac.kookmin.stream.db.common.BaseTimeEntity;
+import lombok.AccessLevel;
+import lombok.NoArgsConstructor;
+
+@Entity
+@Table(
+ name = "oauth_accounts",
+ uniqueConstraints = {
+ @UniqueConstraint(
+ name = "uk_oauth_accounts_provider_provider_user_id",
+ columnNames = {"provider", "provider_user_id"}
+ ),
+ @UniqueConstraint(
+ name = "uk_oauth_accounts_member_id_provider",
+ columnNames = {"member_id", "provider"}
+ )
+ }
+)
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+public class OAuthAccountJpaEntity extends BaseTimeEntity {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ @Column(name = "oauth_account_id")
+ private Long id;
+
+ @Column(name = "member_id", nullable = false)
+ private Long memberId;
+
+ @Enumerated(EnumType.STRING)
+ @Column(nullable = false, length = 20)
+ private OAuthProvider provider;
+
+ @Column(name = "provider_user_id", nullable = false)
+ private String providerUserId;
+
+ private OAuthAccountJpaEntity(OAuthAccount oauthAccount) {
+ this.id = oauthAccount.getId();
+ this.memberId = oauthAccount.getMemberId();
+ this.provider = oauthAccount.getProvider();
+ this.providerUserId = oauthAccount.getProviderUserId();
+ }
+
+ public static OAuthAccountJpaEntity from(OAuthAccount oauthAccount) {
+ return new OAuthAccountJpaEntity(oauthAccount);
+ }
+
+ public OAuthAccount toDomain() {
+ return OAuthAccount.of(id, memberId, provider, providerUserId);
+ }
+}
diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/auth/OAuthAccountJpaRepository.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/auth/OAuthAccountJpaRepository.java
new file mode 100644
index 00000000..801f2aab
--- /dev/null
+++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/auth/OAuthAccountJpaRepository.java
@@ -0,0 +1,9 @@
+package kr.ac.kookmin.stream.db.auth;
+
+import java.util.Optional;
+import kr.ac.kookmin.stream.auth.domain.oauth.domain.OAuthProvider;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+public interface OAuthAccountJpaRepository extends JpaRepository {
+ Optional findByProviderAndProviderUserId(OAuthProvider provider, String providerUserId);
+}
diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/auth/OAuthAccountRepositoryImpl.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/auth/OAuthAccountRepositoryImpl.java
new file mode 100644
index 00000000..f2f74583
--- /dev/null
+++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/auth/OAuthAccountRepositoryImpl.java
@@ -0,0 +1,26 @@
+package kr.ac.kookmin.stream.db.auth;
+
+import java.util.Optional;
+import kr.ac.kookmin.stream.auth.domain.oauth.domain.OAuthAccount;
+import kr.ac.kookmin.stream.auth.domain.oauth.domain.OAuthProvider;
+import kr.ac.kookmin.stream.auth.domain.oauth.repository.OAuthAccountRepository;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Repository;
+
+@Repository
+@RequiredArgsConstructor
+public class OAuthAccountRepositoryImpl implements OAuthAccountRepository {
+
+ private final OAuthAccountJpaRepository oauthAccountJpaRepository;
+
+ @Override
+ public Optional findByProviderAndProviderUserId(OAuthProvider provider, String providerUserId) {
+ return oauthAccountJpaRepository.findByProviderAndProviderUserId(provider, providerUserId)
+ .map(OAuthAccountJpaEntity::toDomain);
+ }
+
+ @Override
+ public OAuthAccount save(OAuthAccount oauthAccount) {
+ return oauthAccountJpaRepository.save(OAuthAccountJpaEntity.from(oauthAccount)).toDomain();
+ }
+}
diff --git a/infrastructure/db/src/main/resources/db/migration/V13__create_oauth_accounts_table.sql b/infrastructure/db/src/main/resources/db/migration/V13__create_oauth_accounts_table.sql
new file mode 100644
index 00000000..99237dab
--- /dev/null
+++ b/infrastructure/db/src/main/resources/db/migration/V13__create_oauth_accounts_table.sql
@@ -0,0 +1,16 @@
+CREATE TABLE oauth_accounts (
+ oauth_account_id BIGINT AUTO_INCREMENT PRIMARY KEY,
+ member_id BIGINT NOT NULL,
+ provider VARCHAR(20) NOT NULL, -- OAuthProvider.name()
+ provider_user_id VARCHAR(255) NOT NULL,
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
+);
+
+-- 로그인할 때 provider 계정으로 회원을 찾는다
+CREATE UNIQUE INDEX uk_oauth_accounts_provider_provider_user_id
+ ON oauth_accounts (provider, provider_user_id);
+
+-- 한 회원에게 같은 provider 계정이 둘 연결되지 않게 막는다
+CREATE UNIQUE INDEX uk_oauth_accounts_member_id_provider
+ ON oauth_accounts (member_id, provider);
From f4955038ba2a2009b0d02a0a91502dec392b3009 Mon Sep 17 00:00:00 2001
From: Sumin Hwang <163857590+tnals0924@users.noreply.github.com>
Date: Wed, 30 Sep 2026 17:49:41 +0900
Subject: [PATCH 04/11] =?UTF-8?q?docs:=20=EC=9A=94=EC=B2=AD=EB=B3=84=20?=
=?UTF-8?q?=EA=B5=AC=ED=98=84=EC=B2=B4=20=EC=84=A0=ED=83=9D=20=EB=A0=88?=
=?UTF-8?q?=EC=A7=80=EC=8A=A4=ED=8A=B8=EB=A6=AC=20=EC=BB=A8=EB=B2=A4?=
=?UTF-8?q?=EC=85=98=20=EC=B6=94=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
docs/conventions/coding-style.md | 26 ++++++++++++++++++++++++++
1 file changed, 26 insertions(+)
diff --git a/docs/conventions/coding-style.md b/docs/conventions/coding-style.md
index 893436cd..35e7486d 100644
--- a/docs/conventions/coding-style.md
+++ b/docs/conventions/coding-style.md
@@ -517,6 +517,32 @@ public class S3FileStorageClient implements FileStorageClient { ... }
public class LocalFileStorageClient implements FileStorageClient { ... }
```
+- **단, 요청마다 구현체를 골라 써야 하는 포트는 구현체를 모두 Bean으로 띄우고 레지스트리로 고른다.** 설정값으로 하나를 고르는 위 경우와 달리, 여러 구현체가 동시에 쓰이는 경우다(예: 로그인 provider별 `OAuthClient` — 요청 경로의 `{provider}`로 고른다). 포트에 자신의 키를 돌려주는 메서드를 두고, 도메인의 `service.impl`에 둔 package-private 레지스트리가 `List<포트>`를 주입받아 `EnumMap`으로 모은다. 같은 키의 구현체가 둘이면 기동 시 실패시키고, 없는 키를 요청하면 도메인 `ErrorCode`로 400을 던진다. 새 구현체를 추가할 때 서비스·UseCase·컨트롤러는 고치지 않는다.
+
+```java
+// core:domain:auth — domain/oauth/client (공개 포트)
+public interface OAuthClient {
+ OAuthProvider provider();
+ OAuthUserInfo fetchUserInfo(OAuthLoginCommand command);
+}
+
+// core:domain:auth — domain/oauth/service/impl (비공개)
+@Component
+class OAuthClientRegistry {
+
+ private final Map clients;
+
+ OAuthClientRegistry(List clients) {
+ this.clients = clients.stream().collect(Collectors.toMap(
+ OAuthClient::provider, Function.identity(),
+ (first, second) -> { throw new IllegalStateException("OAuthClient 구현체가 중복됐습니다: " + first.provider()); },
+ () -> new EnumMap<>(OAuthProvider.class)));
+ }
+
+ OAuthClient get(OAuthProvider provider) { ... } // 없으면 BusinessException(UNSUPPORTED_OAUTH_PROVIDER)
+}
+```
+
- **외부 SDK 클라이언트(`S3Client`, `S3Presigner` 등)는 구현체 생성자에서 만들지 않고, 같은 패키지의 설정 클래스(`@Configuration`)에서 `@Bean`으로 등록해 주입받는다.** 설정 클래스에도 구현체와 같은 `@ConditionalOnProperty`를 붙인다. SDK 클라이언트는 `close()`가 필요한 자원인데, 빈으로 등록하면 종료 시 스프링이 대신 호출한다.
```java
From cc5637b59347052a617a2d9e9243044f669ff91f Mon Sep 17 00:00:00 2001
From: Sumin Hwang <163857590+tnals0924@users.noreply.github.com>
Date: Wed, 30 Sep 2026 17:50:08 +0900
Subject: [PATCH 05/11] =?UTF-8?q?feat:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8?=
=?UTF-8?q?=EC=9A=A9=20=ED=9A=8C=EC=9B=90=20=EA=B0=80=EC=9E=85=C2=B7?=
=?UTF-8?q?=EA=B0=B1=EC=8B=A0=EA=B3=BC=20=ED=95=99=EC=A0=81=20=EC=83=81?=
=?UTF-8?q?=ED=83=9C=20=EC=B6=94=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../domain/member/domain/Department.java | 20 ++++++++++++++--
.../member/domain/member/domain/Member.java | 17 ++++++++++++-
.../domain/member/domain/MemberErrorCode.java | 3 ++-
.../member/domain/MemberProfileCommand.java | 11 +++++++++
.../member/repository/MemberRepository.java | 2 ++
.../domain/member/service/MemberService.java | 3 +++
.../service/impl/MemberServiceImpl.java | 24 +++++++++++++++++++
.../stream/db/member/MemberJpaEntity.java | 6 ++++-
.../stream/db/member/MemberJpaRepository.java | 2 ++
.../db/member/MemberRepositoryImpl.java | 10 ++++++++
.../V14__add_academic_status_to_members.sql | 4 ++++
11 files changed, 97 insertions(+), 5 deletions(-)
create mode 100644 core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/domain/MemberProfileCommand.java
create mode 100644 infrastructure/db/src/main/resources/db/migration/V14__add_academic_status_to_members.sql
diff --git a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/domain/Department.java b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/domain/Department.java
index c5b5c6a3..80d977a2 100644
--- a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/domain/Department.java
+++ b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/domain/Department.java
@@ -1,9 +1,25 @@
package kr.ac.kookmin.stream.member.domain.member.domain;
+import java.util.Arrays;
+import kr.ac.kookmin.stream.common.BusinessException;
+import lombok.AllArgsConstructor;
+
/**
* 학부. 학생회 부서(CouncilDepartment)와는 다른 개념이다.
*/
+@AllArgsConstructor
public enum Department {
- AI,
- SW
+ AI("인공지능전공"),
+ SW("소프트웨어전공");
+
+ // 로그인 provider가 주는 소속 문자열에서 찾는 전공명
+ private final String majorName;
+
+ /** 소속 문자열을 학부로 바꾼다. 소프트웨어융합대학 전공이 아니면 가입할 수 없다. */
+ public static Department fromMajor(String major) {
+ return Arrays.stream(values())
+ .filter(department -> major != null && major.contains(department.majorName))
+ .findFirst()
+ .orElseThrow(() -> new BusinessException(MemberErrorCode.DEPARTMENT_NOT_ALLOWED));
+ }
}
diff --git a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/domain/Member.java b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/domain/Member.java
index ef5deb80..75f43d22 100644
--- a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/domain/Member.java
+++ b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/domain/Member.java
@@ -16,21 +16,36 @@ public class Member {
private String studentId;
private String name;
private Department department;
+ // 로그인 provider가 주는 학적 상태 원문(재학·휴학·졸업 등). 로그인 전 이관 회원은 null이다
+ private String academicStatus;
private String email;
private String fcmToken;
private Role role;
private CouncilDepartment councilDepartment;
+ // 로그인으로 처음 가입하는 회원. 운영진 권한·학생회 부서는 가입 뒤 따로 부여한다
+ public static Member create(String studentId, String name, Department department, String academicStatus) {
+ return new Member(null, studentId, name, department, academicStatus, null, null, Role.STUDENT, null);
+ }
+
public static Member of(
Long id,
String studentId,
String name,
Department department,
+ String academicStatus,
String email,
String fcmToken,
Role role,
CouncilDepartment councilDepartment
) {
- return new Member(id, studentId, name, department, email, fcmToken, role, councilDepartment);
+ return new Member(id, studentId, name, department, academicStatus, email, fcmToken, role, councilDepartment);
+ }
+
+ // 로그인할 때마다 provider의 최신 이름·학부·학적 상태로 갱신한다
+ public void updateProfile(String name, Department department, String academicStatus) {
+ this.name = name;
+ this.department = department;
+ this.academicStatus = academicStatus;
}
}
diff --git a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/domain/MemberErrorCode.java b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/domain/MemberErrorCode.java
index 5df8fbb3..1bfc9911 100644
--- a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/domain/MemberErrorCode.java
+++ b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/domain/MemberErrorCode.java
@@ -11,7 +11,8 @@
@AllArgsConstructor
public enum MemberErrorCode implements ErrorCode {
- MEMBER_NOT_FOUND(ErrorStatus.NOT_FOUND, "회원을 찾을 수 없습니다.");
+ MEMBER_NOT_FOUND(ErrorStatus.NOT_FOUND, "회원을 찾을 수 없습니다."),
+ DEPARTMENT_NOT_ALLOWED(ErrorStatus.FORBIDDEN, "소프트웨어융합대학 학생만 이용할 수 있습니다.");
private final int status;
private final String message;
diff --git a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/domain/MemberProfileCommand.java b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/domain/MemberProfileCommand.java
new file mode 100644
index 00000000..a1552128
--- /dev/null
+++ b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/domain/MemberProfileCommand.java
@@ -0,0 +1,11 @@
+package kr.ac.kookmin.stream.member.domain.member.domain;
+
+/**
+ * 로그인 provider가 준 회원 프로필. major·academicStatus는 원문이며, 학부 변환은 {@link Department#fromMajor}가 한다.
+ */
+public record MemberProfileCommand(
+ String studentId,
+ String name,
+ String major,
+ String academicStatus
+) {}
diff --git a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/repository/MemberRepository.java b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/repository/MemberRepository.java
index 49c027fc..c7e98d8e 100644
--- a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/repository/MemberRepository.java
+++ b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/repository/MemberRepository.java
@@ -6,6 +6,8 @@
public interface MemberRepository {
Optional findById(Long id);
+ Optional findByStudentId(String studentId);
List findAllByIds(List ids);
List searchIdsByKeyword(String keyword);
+ Member save(Member member);
}
diff --git a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/service/MemberService.java b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/service/MemberService.java
index 46ef4bc6..7cd62f93 100644
--- a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/service/MemberService.java
+++ b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/service/MemberService.java
@@ -3,10 +3,13 @@
import java.util.List;
import java.util.Map;
import kr.ac.kookmin.stream.member.domain.member.domain.Member;
+import kr.ac.kookmin.stream.member.domain.member.domain.MemberProfileCommand;
public interface MemberService {
Member getById(Long id);
List findAllByIds(List ids);
Map getMapByIds(List ids);
List searchIdsByKeyword(String keyword);
+ Member updateProfile(Long id, MemberProfileCommand command);
+ Member registerOrUpdateByStudentId(MemberProfileCommand command);
}
diff --git a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/service/impl/MemberServiceImpl.java b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/service/impl/MemberServiceImpl.java
index b41d13e9..19d90ac7 100644
--- a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/service/impl/MemberServiceImpl.java
+++ b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/service/impl/MemberServiceImpl.java
@@ -5,12 +5,15 @@
import java.util.function.Function;
import java.util.stream.Collectors;
import kr.ac.kookmin.stream.common.BusinessException;
+import kr.ac.kookmin.stream.member.domain.member.domain.Department;
import kr.ac.kookmin.stream.member.domain.member.domain.Member;
import kr.ac.kookmin.stream.member.domain.member.domain.MemberErrorCode;
+import kr.ac.kookmin.stream.member.domain.member.domain.MemberProfileCommand;
import kr.ac.kookmin.stream.member.domain.member.repository.MemberRepository;
import kr.ac.kookmin.stream.member.domain.member.service.MemberService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
@@ -40,4 +43,25 @@ public Map getMapByIds(List ids) {
public List searchIdsByKeyword(String keyword) {
return memberRepository.searchIdsByKeyword(keyword);
}
+
+ @Override
+ @Transactional
+ public Member updateProfile(Long id, MemberProfileCommand command) {
+ Member member = getById(id);
+ member.updateProfile(command.name(), Department.fromMajor(command.major()), command.academicStatus());
+ return memberRepository.save(member);
+ }
+
+ @Override
+ @Transactional
+ public Member registerOrUpdateByStudentId(MemberProfileCommand command) {
+ Department department = Department.fromMajor(command.major());
+ Member member = memberRepository.findByStudentId(command.studentId())
+ .map(existing -> {
+ existing.updateProfile(command.name(), department, command.academicStatus());
+ return existing;
+ })
+ .orElseGet(() -> Member.create(command.studentId(), command.name(), department, command.academicStatus()));
+ return memberRepository.save(member);
+ }
}
diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/MemberJpaEntity.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/MemberJpaEntity.java
index 0f68a633..a3069ec3 100644
--- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/MemberJpaEntity.java
+++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/MemberJpaEntity.java
@@ -38,6 +38,9 @@ public class MemberJpaEntity extends BaseSoftDeleteEntity {
@Column(nullable = false, length = 30)
private Department department;
+ @Column(name = "academic_status", length = 50)
+ private String academicStatus;
+
private String email;
@Column(name = "fcm_token")
@@ -56,6 +59,7 @@ private MemberJpaEntity(Member member) {
this.studentId = member.getStudentId();
this.name = member.getName();
this.department = member.getDepartment();
+ this.academicStatus = member.getAcademicStatus();
this.email = member.getEmail();
this.fcmToken = member.getFcmToken();
this.role = member.getRole();
@@ -67,6 +71,6 @@ public static MemberJpaEntity from(Member member) {
}
public Member toDomain() {
- return Member.of(id, studentId, name, department, email, fcmToken, role, councilDepartment);
+ return Member.of(id, studentId, name, department, academicStatus, email, fcmToken, role, councilDepartment);
}
}
diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/MemberJpaRepository.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/MemberJpaRepository.java
index 24fbf7ab..2877d272 100644
--- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/MemberJpaRepository.java
+++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/MemberJpaRepository.java
@@ -10,6 +10,8 @@ public interface MemberJpaRepository extends JpaRepository findByIdAndIsDeletedFalse(Long id);
+ Optional findByStudentIdAndIsDeletedFalse(String studentId);
+
List findAllByIdInAndIsDeletedFalse(List ids);
@Query("""
diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/MemberRepositoryImpl.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/MemberRepositoryImpl.java
index 88e7e1a2..89998df2 100644
--- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/MemberRepositoryImpl.java
+++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/MemberRepositoryImpl.java
@@ -18,6 +18,11 @@ public Optional findById(Long id) {
return memberJpaRepository.findByIdAndIsDeletedFalse(id).map(MemberJpaEntity::toDomain);
}
+ @Override
+ public Optional findByStudentId(String studentId) {
+ return memberJpaRepository.findByStudentIdAndIsDeletedFalse(studentId).map(MemberJpaEntity::toDomain);
+ }
+
@Override
public List findAllByIds(List ids) {
if (ids.isEmpty()) {
@@ -32,4 +37,9 @@ public List findAllByIds(List ids) {
public List searchIdsByKeyword(String keyword) {
return memberJpaRepository.searchIdsByKeyword(keyword);
}
+
+ @Override
+ public Member save(Member member) {
+ return memberJpaRepository.save(MemberJpaEntity.from(member)).toDomain();
+ }
}
diff --git a/infrastructure/db/src/main/resources/db/migration/V14__add_academic_status_to_members.sql b/infrastructure/db/src/main/resources/db/migration/V14__add_academic_status_to_members.sql
new file mode 100644
index 00000000..151c6acc
--- /dev/null
+++ b/infrastructure/db/src/main/resources/db/migration/V14__add_academic_status_to_members.sql
@@ -0,0 +1,4 @@
+-- 로그인 provider가 주는 학적 상태 원문(재학·휴학·졸업 등). 로그인할 때마다 갱신한다.
+-- 로그인 전인 기존(이관) 회원은 값이 없으므로 NULL을 허용한다.
+ALTER TABLE members
+ ADD COLUMN academic_status VARCHAR(50) NULL AFTER department;
From aaf22a3da86bb4fc8025dac41864fe06de0be124 Mon Sep 17 00:00:00 2001
From: Sumin Hwang <163857590+tnals0924@users.noreply.github.com>
Date: Wed, 30 Sep 2026 17:50:08 +0900
Subject: [PATCH 06/11] =?UTF-8?q?feat:=20=ED=95=84=EC=88=98=20=EC=95=BD?=
=?UTF-8?q?=EA=B4=80=20=EB=8F=99=EC=9D=98=20=EC=97=AC=EB=B6=80=20=EC=A1=B0?=
=?UTF-8?q?=ED=9A=8C=20=EC=B6=94=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../MemberTermAgreementRepository.java | 8 +++++
.../member/service/MemberTermService.java | 5 ++++
.../service/impl/MemberTermServiceImpl.java | 30 +++++++++++++++++++
.../MemberTermAgreementJpaRepository.java | 8 +++++
.../MemberTermAgreementRepositoryImpl.java | 21 +++++++++++++
5 files changed, 72 insertions(+)
create mode 100644 core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/repository/MemberTermAgreementRepository.java
create mode 100644 core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/service/MemberTermService.java
create mode 100644 core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/service/impl/MemberTermServiceImpl.java
create mode 100644 infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/MemberTermAgreementJpaRepository.java
create mode 100644 infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/MemberTermAgreementRepositoryImpl.java
diff --git a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/repository/MemberTermAgreementRepository.java b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/repository/MemberTermAgreementRepository.java
new file mode 100644
index 00000000..2ad7a2ec
--- /dev/null
+++ b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/repository/MemberTermAgreementRepository.java
@@ -0,0 +1,8 @@
+package kr.ac.kookmin.stream.member.domain.member.repository;
+
+import java.util.List;
+import kr.ac.kookmin.stream.member.domain.member.domain.MemberTermAgreement;
+
+public interface MemberTermAgreementRepository {
+ List findAllByMemberId(Long memberId);
+}
diff --git a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/service/MemberTermService.java b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/service/MemberTermService.java
new file mode 100644
index 00000000..0abd77db
--- /dev/null
+++ b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/service/MemberTermService.java
@@ -0,0 +1,5 @@
+package kr.ac.kookmin.stream.member.domain.member.service;
+
+public interface MemberTermService {
+ boolean hasAgreedRequiredTerms(Long memberId);
+}
diff --git a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/service/impl/MemberTermServiceImpl.java b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/service/impl/MemberTermServiceImpl.java
new file mode 100644
index 00000000..2fdff165
--- /dev/null
+++ b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/service/impl/MemberTermServiceImpl.java
@@ -0,0 +1,30 @@
+package kr.ac.kookmin.stream.member.domain.member.service.impl;
+
+import java.util.EnumSet;
+import java.util.Set;
+import java.util.stream.Collectors;
+import kr.ac.kookmin.stream.member.domain.member.domain.MemberTermAgreement;
+import kr.ac.kookmin.stream.member.domain.member.domain.TermType;
+import kr.ac.kookmin.stream.member.domain.member.repository.MemberTermAgreementRepository;
+import kr.ac.kookmin.stream.member.domain.member.service.MemberTermService;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+
+@Service
+@RequiredArgsConstructor
+class MemberTermServiceImpl implements MemberTermService {
+
+ private static final Set REQUIRED_TERM_TYPES =
+ EnumSet.of(TermType.PRIVACY_POLICY, TermType.TERMS_OF_SERVICE);
+
+ private final MemberTermAgreementRepository memberTermAgreementRepository;
+
+ @Override
+ public boolean hasAgreedRequiredTerms(Long memberId) {
+ Set agreedTermTypes = memberTermAgreementRepository.findAllByMemberId(memberId).stream()
+ .filter(MemberTermAgreement::isAgreed)
+ .map(MemberTermAgreement::getTermType)
+ .collect(Collectors.toSet());
+ return agreedTermTypes.containsAll(REQUIRED_TERM_TYPES);
+ }
+}
diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/MemberTermAgreementJpaRepository.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/MemberTermAgreementJpaRepository.java
new file mode 100644
index 00000000..35637f1f
--- /dev/null
+++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/MemberTermAgreementJpaRepository.java
@@ -0,0 +1,8 @@
+package kr.ac.kookmin.stream.db.member;
+
+import java.util.List;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+public interface MemberTermAgreementJpaRepository extends JpaRepository {
+ List findAllByMemberId(Long memberId);
+}
diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/MemberTermAgreementRepositoryImpl.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/MemberTermAgreementRepositoryImpl.java
new file mode 100644
index 00000000..42d8f3b0
--- /dev/null
+++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/member/MemberTermAgreementRepositoryImpl.java
@@ -0,0 +1,21 @@
+package kr.ac.kookmin.stream.db.member;
+
+import java.util.List;
+import kr.ac.kookmin.stream.member.domain.member.domain.MemberTermAgreement;
+import kr.ac.kookmin.stream.member.domain.member.repository.MemberTermAgreementRepository;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Repository;
+
+@Repository
+@RequiredArgsConstructor
+public class MemberTermAgreementRepositoryImpl implements MemberTermAgreementRepository {
+
+ private final MemberTermAgreementJpaRepository memberTermAgreementJpaRepository;
+
+ @Override
+ public List findAllByMemberId(Long memberId) {
+ return memberTermAgreementJpaRepository.findAllByMemberId(memberId).stream()
+ .map(MemberTermAgreementJpaEntity::toDomain)
+ .toList();
+ }
+}
From a9298f7ce1dbfbd6c506e7370ee9ca8b899e30e0 Mon Sep 17 00:00:00 2001
From: Sumin Hwang <163857590+tnals0924@users.noreply.github.com>
Date: Fri, 2 Oct 2026 16:36:41 +0900
Subject: [PATCH 07/11] =?UTF-8?q?refactor:=20OAuth=20=EA=B3=84=EC=A0=95=20?=
=?UTF-8?q?=EB=8B=A8=EA=B1=B4=20=EC=A1=B0=ED=9A=8C=EC=9D=98=20=EB=B6=88?=
=?UTF-8?q?=ED=95=84=EC=9A=94=ED=95=9C=20readOnly=20=ED=8A=B8=EB=9E=9C?=
=?UTF-8?q?=EC=9E=AD=EC=85=98=20=EC=A0=9C=EA=B1=B0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../stream/auth/domain/oauth/service/impl/OAuthServiceImpl.java | 1 -
1 file changed, 1 deletion(-)
diff --git a/core/domain/auth/src/main/java/kr/ac/kookmin/stream/auth/domain/oauth/service/impl/OAuthServiceImpl.java b/core/domain/auth/src/main/java/kr/ac/kookmin/stream/auth/domain/oauth/service/impl/OAuthServiceImpl.java
index aa453c65..ac0f3413 100644
--- a/core/domain/auth/src/main/java/kr/ac/kookmin/stream/auth/domain/oauth/service/impl/OAuthServiceImpl.java
+++ b/core/domain/auth/src/main/java/kr/ac/kookmin/stream/auth/domain/oauth/service/impl/OAuthServiceImpl.java
@@ -33,7 +33,6 @@ public OAuthUserInfo authenticate(OAuthLoginCommand command) {
}
@Override
- @Transactional(readOnly = true)
public Optional findMemberId(OAuthProvider provider, String providerUserId) {
return oauthAccountRepository.findByProviderAndProviderUserId(provider, providerUserId)
.map(OAuthAccount::getMemberId);
From 702c0f43ac69c72e966df6c3ae9646259cd7d740 Mon Sep 17 00:00:00 2001
From: Sumin Hwang <163857590+tnals0924@users.noreply.github.com>
Date: Fri, 2 Oct 2026 16:37:14 +0900
Subject: [PATCH 08/11] =?UTF-8?q?refactor:=20Department.fromMajor=EC=9D=98?=
=?UTF-8?q?=20null=20=EA=B2=80=EC=82=AC=EB=A5=BC=20=EB=A9=94=EC=84=9C?=
=?UTF-8?q?=EB=93=9C=20=EC=A7=84=EC=9E=85=EB=B6=80=EB=A1=9C=20=EC=9D=B4?=
=?UTF-8?q?=EB=8F=99?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../stream/member/domain/member/domain/Department.java | 5 ++++-
1 file changed, 4 insertions(+), 1 deletion(-)
diff --git a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/domain/Department.java b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/domain/Department.java
index 80d977a2..3811d097 100644
--- a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/domain/Department.java
+++ b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/domain/Department.java
@@ -17,8 +17,11 @@ public enum Department {
/** 소속 문자열을 학부로 바꾼다. 소프트웨어융합대학 전공이 아니면 가입할 수 없다. */
public static Department fromMajor(String major) {
+ if (major == null) {
+ throw new BusinessException(MemberErrorCode.DEPARTMENT_NOT_ALLOWED);
+ }
return Arrays.stream(values())
- .filter(department -> major != null && major.contains(department.majorName))
+ .filter(department -> major.contains(department.majorName))
.findFirst()
.orElseThrow(() -> new BusinessException(MemberErrorCode.DEPARTMENT_NOT_ALLOWED));
}
From 1899e982d9ba911a29d7bbe16328ccc659eaae03 Mon Sep 17 00:00:00 2001
From: Sumin Hwang <163857590+tnals0924@users.noreply.github.com>
Date: Fri, 2 Oct 2026 16:37:14 +0900
Subject: [PATCH 09/11] =?UTF-8?q?refactor:=20=ED=95=84=EC=88=98=20?=
=?UTF-8?q?=EC=95=BD=EA=B4=80=20=EC=97=AC=EB=B6=80=EB=A5=BC=20TermType?=
=?UTF-8?q?=EC=9D=98=20required=20=ED=95=84=EB=93=9C=EB=A1=9C=20=EC=9D=B4?=
=?UTF-8?q?=EB=8F=99?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../member/domain/member/domain/TermType.java | 16 ++++++++++++++--
.../service/impl/MemberTermServiceImpl.java | 9 ++++-----
2 files changed, 18 insertions(+), 7 deletions(-)
diff --git a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/domain/TermType.java b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/domain/TermType.java
index 68758db3..a70eb1ce 100644
--- a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/domain/TermType.java
+++ b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/domain/TermType.java
@@ -1,6 +1,18 @@
package kr.ac.kookmin.stream.member.domain.member.domain;
+import lombok.AccessLevel;
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import lombok.experimental.Accessors;
+
+@Getter
+@Accessors(fluent = true)
+@AllArgsConstructor(access = AccessLevel.PRIVATE)
public enum TermType {
- PRIVACY_POLICY, // 개인정보 이용 동의
- TERMS_OF_SERVICE // 서비스 이용 약관 동의
+
+ PRIVACY_POLICY(true), // 개인정보 이용 동의
+ TERMS_OF_SERVICE(true); // 서비스 이용 약관 동의
+
+ /** 가입 시 반드시 동의해야 하는 약관인지 여부 */
+ private final boolean required;
}
diff --git a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/service/impl/MemberTermServiceImpl.java b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/service/impl/MemberTermServiceImpl.java
index 2fdff165..855ecb16 100644
--- a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/service/impl/MemberTermServiceImpl.java
+++ b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/service/impl/MemberTermServiceImpl.java
@@ -1,6 +1,6 @@
package kr.ac.kookmin.stream.member.domain.member.service.impl;
-import java.util.EnumSet;
+import java.util.Arrays;
import java.util.Set;
import java.util.stream.Collectors;
import kr.ac.kookmin.stream.member.domain.member.domain.MemberTermAgreement;
@@ -14,9 +14,6 @@
@RequiredArgsConstructor
class MemberTermServiceImpl implements MemberTermService {
- private static final Set REQUIRED_TERM_TYPES =
- EnumSet.of(TermType.PRIVACY_POLICY, TermType.TERMS_OF_SERVICE);
-
private final MemberTermAgreementRepository memberTermAgreementRepository;
@Override
@@ -25,6 +22,8 @@ public boolean hasAgreedRequiredTerms(Long memberId) {
.filter(MemberTermAgreement::isAgreed)
.map(MemberTermAgreement::getTermType)
.collect(Collectors.toSet());
- return agreedTermTypes.containsAll(REQUIRED_TERM_TYPES);
+ return Arrays.stream(TermType.values())
+ .filter(TermType::required)
+ .allMatch(agreedTermTypes::contains);
}
}
From 85d042f5f60a63d6bf20f7354d2928c971eb39e8 Mon Sep 17 00:00:00 2001
From: Sumin Hwang <163857590+tnals0924@users.noreply.github.com>
Date: Fri, 2 Oct 2026 18:28:29 +0900
Subject: [PATCH 10/11] =?UTF-8?q?refactor:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8?=
=?UTF-8?q?=20=ED=9A=8C=EC=9B=90=20=EA=B0=80=EC=9E=85=C2=B7=EA=B0=B1?=
=?UTF-8?q?=EC=8B=A0=20=EB=B6=84=EA=B8=B0=EB=A5=BC=20Optional=20=EC=B2=B4?=
=?UTF-8?q?=EC=9D=B8=EC=97=90=EC=84=9C=20=EC=A1=B0=EA=B1=B4=EB=AC=B8?=
=?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../service/impl/MemberServiceImpl.java | 31 +++++++++++++------
1 file changed, 21 insertions(+), 10 deletions(-)
diff --git a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/service/impl/MemberServiceImpl.java b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/service/impl/MemberServiceImpl.java
index 19d90ac7..dd68bf94 100644
--- a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/service/impl/MemberServiceImpl.java
+++ b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/service/impl/MemberServiceImpl.java
@@ -2,6 +2,7 @@
import java.util.List;
import java.util.Map;
+import java.util.Optional;
import java.util.function.Function;
import java.util.stream.Collectors;
import kr.ac.kookmin.stream.common.BusinessException;
@@ -47,21 +48,31 @@ public List searchIdsByKeyword(String keyword) {
@Override
@Transactional
public Member updateProfile(Long id, MemberProfileCommand command) {
- Member member = getById(id);
- member.updateProfile(command.name(), Department.fromMajor(command.major()), command.academicStatus());
- return memberRepository.save(member);
+ return applyProfile(getById(id), command);
}
@Override
@Transactional
public Member registerOrUpdateByStudentId(MemberProfileCommand command) {
- Department department = Department.fromMajor(command.major());
- Member member = memberRepository.findByStudentId(command.studentId())
- .map(existing -> {
- existing.updateProfile(command.name(), department, command.academicStatus());
- return existing;
- })
- .orElseGet(() -> Member.create(command.studentId(), command.name(), department, command.academicStatus()));
+ Optional found = memberRepository.findByStudentId(command.studentId());
+ if (found.isEmpty()) {
+ return register(command);
+ }
+ return applyProfile(found.get(), command);
+ }
+
+ private Member register(MemberProfileCommand command) {
+ Member member = Member.create(
+ command.studentId(),
+ command.name(),
+ Department.fromMajor(command.major()),
+ command.academicStatus()
+ );
+ return memberRepository.save(member);
+ }
+
+ private Member applyProfile(Member member, MemberProfileCommand command) {
+ member.updateProfile(command.name(), Department.fromMajor(command.major()), command.academicStatus());
return memberRepository.save(member);
}
}
From 99020700e6f3a210eb838a53b5f76af14fdefdeb Mon Sep 17 00:00:00 2001
From: Sumin Hwang <163857590+tnals0924@users.noreply.github.com>
Date: Fri, 2 Oct 2026 18:28:49 +0900
Subject: [PATCH 11/11] =?UTF-8?q?refactor:=20=ED=94=84=EB=A1=9C=ED=95=84?=
=?UTF-8?q?=20=EA=B0=92=EC=9D=B4=20=EB=B0=94=EB=80=90=20=EA=B2=BD=EC=9A=B0?=
=?UTF-8?q?=EC=97=90=EB=A7=8C=20=ED=9A=8C=EC=9B=90=EC=9D=84=20=EC=A0=80?=
=?UTF-8?q?=EC=9E=A5?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../stream/member/domain/member/domain/Member.java | 11 +++++++++--
.../domain/member/service/impl/MemberServiceImpl.java | 5 ++++-
2 files changed, 13 insertions(+), 3 deletions(-)
diff --git a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/domain/Member.java b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/domain/Member.java
index 75f43d22..261e9301 100644
--- a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/domain/Member.java
+++ b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/domain/Member.java
@@ -1,5 +1,6 @@
package kr.ac.kookmin.stream.member.domain.member.domain;
+import java.util.Objects;
import kr.ac.kookmin.stream.common.CouncilDepartment;
import kr.ac.kookmin.stream.common.Role;
import lombok.AccessLevel;
@@ -42,10 +43,16 @@ public static Member of(
return new Member(id, studentId, name, department, academicStatus, email, fcmToken, role, councilDepartment);
}
- // 로그인할 때마다 provider의 최신 이름·학부·학적 상태로 갱신한다
- public void updateProfile(String name, Department department, String academicStatus) {
+ // 로그인할 때마다 provider의 최신 이름·학부·학적 상태로 갱신한다. 바뀐 값이 있으면 true를 돌려준다
+ public boolean updateProfile(String name, Department department, String academicStatus) {
+ if (Objects.equals(this.name, name)
+ && this.department == department
+ && Objects.equals(this.academicStatus, academicStatus)) {
+ return false;
+ }
this.name = name;
this.department = department;
this.academicStatus = academicStatus;
+ return true;
}
}
diff --git a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/service/impl/MemberServiceImpl.java b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/service/impl/MemberServiceImpl.java
index dd68bf94..b646fbe4 100644
--- a/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/service/impl/MemberServiceImpl.java
+++ b/core/domain/member/src/main/java/kr/ac/kookmin/stream/member/domain/member/service/impl/MemberServiceImpl.java
@@ -72,7 +72,10 @@ private Member register(MemberProfileCommand command) {
}
private Member applyProfile(Member member, MemberProfileCommand command) {
- member.updateProfile(command.name(), Department.fromMajor(command.major()), command.academicStatus());
+ boolean changed = member.updateProfile(command.name(), Department.fromMajor(command.major()), command.academicStatus());
+ if (!changed) {
+ return member;
+ }
return memberRepository.save(member);
}
}