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 1/5] =?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 2/5] =?UTF-8?q?feat:=20auth=20=EB=8F=84=EB=A9=94=EC=9D=B8?=
=?UTF-8?q?=20OAuth=20=EC=A0=84=EB=9E=B5=20=EC=9D=B8=ED=84=B0=ED=8E=98?=
=?UTF-8?q?=EC=9D=B4=EC=8A=A4=EC=99=80=20=EC=84=9C=EB=B9=84=EC=8A=A4=20?=
=?UTF-8?q?=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 3/5] =?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 4/5] =?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 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 5/5] =?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);