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);