init: 클린 기반 auth 서버 설계

This commit is contained in:
DongHyeonka
2026-07-24 14:30:18 +09:00
parent 471db0203d
commit 8a1ac1e769
3642 changed files with 275893 additions and 1 deletions
@@ -0,0 +1,55 @@
package com.project.auth.infrastructure.persistence.user;
import com.project.auth.application.auth.identity.port.out.LoadKeycloakUserPort;
import com.project.auth.application.auth.identity.port.out.RegisterKeycloakUserPort;
import com.project.auth.domain.user.model.AuthProvider;
import com.project.auth.domain.user.model.User;
import com.project.auth.domain.user.model.UserEmail;
import com.project.auth.infrastructure.persistence.user.entity.UserJpaEntity;
import com.project.auth.infrastructure.persistence.user.mapper.UserPersistenceMapper;
import com.project.auth.infrastructure.persistence.user.repository.UserJpaRepository;
import java.util.Objects;
import java.util.Optional;
public class JpaUserRepositoryAdapter implements LoadKeycloakUserPort, RegisterKeycloakUserPort {
private final UserJpaRepository userJpaRepository;
private final UserPersistenceMapper userPersistenceMapper;
public JpaUserRepositoryAdapter(UserJpaRepository userJpaRepository, UserPersistenceMapper userPersistenceMapper) {
this.userJpaRepository = Objects.requireNonNull(userJpaRepository, "userJpaRepository must not be null");
this.userPersistenceMapper = Objects.requireNonNull(
userPersistenceMapper,
"userPersistenceMapper must not be null"
);
}
@Override
public Optional<User> findByProviderAndProviderSubject(
AuthProvider provider,
String providerSubject
) {
return userJpaRepository.findByProviderAndProviderSubject(provider, providerSubject)
.map(userPersistenceMapper::toDomain);
}
@Override
public boolean existsByEmail(UserEmail email) {
UserEmail nonNullEmail = Objects.requireNonNull(email, "email must not be null");
return userJpaRepository.existsByEmail(nonNullEmail.value());
}
@Override
public User register(User user) {
return save(user);
}
public User save(User user) {
User nonNullUser = Objects.requireNonNull(user, "user must not be null");
UserJpaEntity savedUser = userJpaRepository.save(userPersistenceMapper.toEntity(nonNullUser));
return userPersistenceMapper.toDomain(savedUser);
}
}
@@ -0,0 +1,95 @@
package com.project.auth.infrastructure.persistence.user.entity;
import com.project.auth.domain.user.model.AuthProvider;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.time.Instant;
import java.util.Objects;
import java.util.UUID;
@Entity
@Table(name = "users")
public class UserJpaEntity {
@Id
@Column(name = "id", nullable = false, updatable = false)
private UUID id;
@Column(name = "email", nullable = false, columnDefinition = "text")
private String email;
@Column(name = "name", nullable = false, columnDefinition = "text")
private String name;
@Enumerated(EnumType.STRING)
@Column(name = "provider", nullable = false, columnDefinition = "text")
private AuthProvider provider;
@Column(name = "provider_subject", nullable = false, columnDefinition = "text")
private String providerSubject;
@Column(name = "created_at", nullable = false, updatable = false)
private Instant createdAt;
@Column(name = "updated_at", nullable = false, insertable = false, updatable = false)
private Instant updatedAt;
protected UserJpaEntity() {
}
private UserJpaEntity(
UUID id,
String email,
String name,
AuthProvider provider,
String providerSubject,
Instant createdAt
) {
this.id = Objects.requireNonNull(id, "id must not be null");
this.email = Objects.requireNonNull(email, "email must not be null");
this.name = Objects.requireNonNull(name, "name must not be null");
this.provider = Objects.requireNonNull(provider, "provider must not be null");
this.providerSubject = Objects.requireNonNull(providerSubject, "providerSubject must not be null");
this.createdAt = Objects.requireNonNull(createdAt, "createdAt must not be null");
}
public static UserJpaEntity of(
UUID id,
String email,
String name,
AuthProvider provider,
String providerSubject,
Instant createdAt
) {
return new UserJpaEntity(id, email, name, provider, providerSubject, createdAt);
}
public UUID getId() {
return id;
}
public String getEmail() {
return email;
}
public String getName() {
return name;
}
public AuthProvider getProvider() {
return provider;
}
public String getProviderSubject() {
return providerSubject;
}
public Instant getCreatedAt() {
return createdAt;
}
}
@@ -0,0 +1,41 @@
package com.project.auth.infrastructure.persistence.user.mapper;
import com.project.auth.domain.user.exception.DomainException;
import com.project.auth.domain.user.model.User;
import com.project.auth.domain.user.model.UserEmail;
import com.project.auth.domain.user.model.UserName;
import com.project.auth.infrastructure.persistence.user.entity.UserJpaEntity;
import com.project.auth.infrastructure.support.exception.InfrastructureErrorCode;
import com.project.auth.infrastructure.support.exception.InfrastructureException;
public class UserPersistenceMapper {
public UserJpaEntity toEntity(User user) {
return UserJpaEntity.of(
user.getId(),
user.getEmail(),
user.getName(),
user.getProvider(),
user.getProviderSubject(),
user.getCreatedAt()
);
}
public User toDomain(UserJpaEntity userJpaEntity) {
try {
return User.restore(
userJpaEntity.getId(),
UserEmail.from(userJpaEntity.getEmail()),
UserName.from(userJpaEntity.getName()),
userJpaEntity.getProvider(),
userJpaEntity.getProviderSubject(),
userJpaEntity.getCreatedAt()
);
} catch (DomainException | IllegalArgumentException | NullPointerException exception) {
throw new InfrastructureException(
InfrastructureErrorCode.PERSISTED_DATA_INVALID,
exception
);
}
}
}
@@ -0,0 +1,15 @@
package com.project.auth.infrastructure.persistence.user.repository;
import com.project.auth.domain.user.model.AuthProvider;
import com.project.auth.infrastructure.persistence.user.entity.UserJpaEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
import java.util.UUID;
public interface UserJpaRepository extends JpaRepository<UserJpaEntity, UUID> {
Optional<UserJpaEntity> findByProviderAndProviderSubject(AuthProvider provider, String providerSubject);
boolean existsByEmail(String email);
}
@@ -0,0 +1,30 @@
package com.project.auth.infrastructure.support.exception;
import com.project.auth.application.support.exception.ExternalErrorCode;
/**
* 내부 전용 인프라 장애 코드. 로그·모니터링·알람 라우팅 분류 용도로만 사용한다.
* 외부 HTTP 응답에서는 의도적으로 COMMON-999로 정규화되어 노출된다.
*/
public enum InfrastructureErrorCode implements ExternalErrorCode {
PERSISTED_DATA_INVALID("INFRA-003", "저장된 데이터가 도메인 규칙에 맞지 않습니다."),
EXTERNAL_SERVICE_ERROR("INFRA-999", "외부 시스템 연동 중 오류가 발생했습니다.");
private final String code;
private final String message;
InfrastructureErrorCode(String code, String message) {
this.code = code;
this.message = message;
}
@Override
public String code() {
return code;
}
@Override
public String message() {
return message;
}
}
@@ -0,0 +1,43 @@
package com.project.auth.infrastructure.support.exception;
/**
* 인프라 계층(DB, 외부 HTTP 클라이언트, 시크릿 저장소 등) 장애를 표현하는 내부 전용 예외.
*
* 등록된 @ExceptionHandler는 ERROR 레벨로 전체 스택트레이스와 함께 {@code detailMessage}를
* 로깅한다. 따라서 어댑터는 {@code detailMessage}에 민감 정보를 절대 포함시키면 안 된다.
*
* detailMessage에 금지: 커넥션 문자열, DB 자격 증명, Vault 토큰, JWT 페이로드 내용,
* 정제되지 않은 사용자 입력, 외부 호출의 전체 요청/응답 바디.
*
* detailMessage에 허용: 식별 불가 형태의 상관 ID, 호스트/서비스 이름, 정제된 상태 코드,
* 장애 모드를 설명하는 일반 문장. 이 detail은 운영자 트리아지용이며, 클라이언트는 항상
* COMMON-999만 받는다.
*/
public class InfrastructureException extends RuntimeException {
private final InfrastructureErrorCode errorCode;
public InfrastructureException(InfrastructureErrorCode errorCode) {
super(errorCode.message());
this.errorCode = errorCode;
}
public InfrastructureException(InfrastructureErrorCode errorCode, String detailMessage) {
super(detailMessage);
this.errorCode = errorCode;
}
public InfrastructureException(InfrastructureErrorCode errorCode, Throwable cause) {
super(errorCode.message(), cause);
this.errorCode = errorCode;
}
public InfrastructureException(InfrastructureErrorCode errorCode, String detailMessage, Throwable cause) {
super(detailMessage, cause);
this.errorCode = errorCode;
}
public InfrastructureErrorCode getErrorCode() {
return errorCode;
}
}
@@ -0,0 +1,30 @@
create schema if not exists auth;
create or replace function auth.set_updated_at()
returns trigger as $$
begin
new.updated_at := current_timestamp;
return new;
end;
$$ language plpgsql;
create table auth.users (
id uuid not null,
email text not null,
encoded_password text not null,
name text not null,
provider text not null,
created_at timestamp with time zone not null default current_timestamp,
updated_at timestamp with time zone not null default current_timestamp,
constraint pk_users primary key (id),
constraint uq_users__email unique (email),
constraint ck_users__provider check (provider in ('LOCAL', 'GOOGLE', 'GITHUB'))
);
create index ix_users__created_at on auth.users (created_at);
create trigger trg_users__set_updated_at
before update on auth.users
for each row
when (old.* is distinct from new.*)
execute function auth.set_updated_at();
@@ -0,0 +1,6 @@
alter table auth.users alter column encoded_password drop not null;
alter table auth.users add column provider_subject text;
alter table auth.users
add constraint uq_users__provider_provider_subject unique (provider, provider_subject);
@@ -0,0 +1,13 @@
alter table auth.users
add constraint ck_users__local_password_required
check (
(provider = 'LOCAL' and encoded_password is not null and provider_subject is null)
or (provider <> 'LOCAL')
);
alter table auth.users
add constraint ck_users__social_subject_required
check (
(provider <> 'LOCAL' and provider_subject is not null and encoded_password is null)
or (provider = 'LOCAL')
);
@@ -0,0 +1,4 @@
alter table auth.users drop constraint ck_users__provider;
alter table auth.users
add constraint ck_users__provider check (provider in ('LOCAL', 'KEYCLOAK', 'GOOGLE', 'GITHUB'));
@@ -0,0 +1,10 @@
alter table auth.users drop constraint if exists ck_users__local_password_required;
alter table auth.users drop constraint if exists ck_users__social_subject_required;
alter table auth.users drop constraint if exists ck_users__provider;
alter table auth.users drop column if exists encoded_password;
alter table auth.users alter column provider_subject set not null;
alter table auth.users
add constraint ck_users__provider check (provider = 'KEYCLOAK');
@@ -0,0 +1,200 @@
package com.project.auth.infrastructure.persistence.user;
import com.project.auth.domain.user.model.AuthProvider;
import com.project.auth.domain.user.model.User;
import com.project.auth.domain.user.model.UserEmail;
import com.project.auth.domain.user.model.UserName;
import com.project.auth.infrastructure.persistence.user.entity.UserJpaEntity;
import com.project.auth.infrastructure.persistence.user.mapper.UserPersistenceMapper;
import com.project.auth.infrastructure.persistence.user.repository.UserJpaRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.boot.autoconfigure.AbstractDependsOnBeanFactoryPostProcessor;
import org.springframework.boot.SpringBootConfiguration;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
import org.springframework.boot.jpa.test.autoconfigure.TestEntityManager;
import org.springframework.boot.persistence.autoconfigure.EntityScan;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.springframework.test.context.TestPropertySource;
import org.flywaydb.core.Flyway;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import jakarta.persistence.EntityManagerFactory;
import javax.sql.DataSource;
import java.time.Instant;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@ContextConfiguration(classes = {
UserJpaRepositoryTest.TestApplication.class,
UserJpaRepositoryTest.TestFlywayConfiguration.class
})
@Testcontainers
@TestPropertySource(properties = {
"spring.jpa.hibernate.ddl-auto=validate",
"spring.jpa.properties.hibernate.default_schema=auth"
})
class UserJpaRepositoryTest {
@Container
private static final PostgreSQLContainer<?> POSTGRES =
new PostgreSQLContainer<>("postgres:16-alpine");
@Autowired
private UserJpaRepository userJpaRepository;
@Autowired
private TestEntityManager entityManager;
private JpaUserRepositoryAdapter adapter;
@DynamicPropertySource
static void registerDataSourceProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", POSTGRES::getJdbcUrl);
registry.add("spring.datasource.username", POSTGRES::getUsername);
registry.add("spring.datasource.password", POSTGRES::getPassword);
registry.add("spring.datasource.driver-class-name", POSTGRES::getDriverClassName);
}
@BeforeEach
void setUp() {
adapter = new JpaUserRepositoryAdapter(userJpaRepository, new UserPersistenceMapper());
}
@Test
void savePersistsKeycloakUserAndReloadsFromDatabase() {
User user = keycloakUser(
"11111111-1111-1111-1111-111111111111",
"tester@example.com",
"keycloak-subject-1"
);
adapter.save(user);
entityManager.flush();
entityManager.clear();
assertThat(userJpaRepository.findByProviderAndProviderSubject(AuthProvider.KEYCLOAK, "keycloak-subject-1"))
.isPresent()
.get()
.satisfies(entity -> {
assertThat(entity.getEmail()).isEqualTo("tester@example.com");
assertThat(entity.getProvider()).isEqualTo(AuthProvider.KEYCLOAK);
assertThat(entity.getCreatedAt()).isEqualTo(Instant.parse("2026-04-17T00:00:00Z"));
});
}
@Test
void saveRejectsDuplicateEmailOnFlush() {
userJpaRepository.save(keycloakEntity(
"11111111-1111-1111-1111-111111111111",
"duplicate@example.com",
"keycloak-subject-1"
));
userJpaRepository.flush();
entityManager.clear();
userJpaRepository.save(keycloakEntity(
"22222222-2222-2222-2222-222222222222",
"duplicate@example.com",
"keycloak-subject-2"
));
assertThatThrownBy(() -> userJpaRepository.flush())
.isInstanceOf(DataIntegrityViolationException.class);
}
@Test
void saveRejectsDuplicateProviderSubjectOnFlush() {
userJpaRepository.save(keycloakEntity(
"11111111-1111-1111-1111-111111111111",
"first@example.com",
"keycloak-subject-1"
));
userJpaRepository.flush();
entityManager.clear();
userJpaRepository.save(keycloakEntity(
"22222222-2222-2222-2222-222222222222",
"second@example.com",
"keycloak-subject-1"
));
assertThatThrownBy(() -> userJpaRepository.flush())
.isInstanceOf(DataIntegrityViolationException.class);
}
private static User keycloakUser(String id, String email, String providerSubject) {
return User.registerKeycloak(
UUID.fromString(id),
UserEmail.from(email),
UserName.from("테스터"),
providerSubject,
Instant.parse("2026-04-17T00:00:00Z")
);
}
private static UserJpaEntity keycloakEntity(String id, String email, String providerSubject) {
return UserJpaEntity.of(
UUID.fromString(id),
email,
"테스터",
AuthProvider.KEYCLOAK,
providerSubject,
Instant.parse("2026-04-17T00:00:00Z")
);
}
@SpringBootConfiguration
@EnableAutoConfiguration
@EntityScan(basePackageClasses = UserJpaEntity.class)
@EnableJpaRepositories(basePackageClasses = UserJpaRepository.class)
static class TestApplication {
}
@Configuration(proxyBeanMethods = false)
static class TestFlywayConfiguration {
private static final String MIGRATION_LOCATION = "classpath:db/migration";
private static final String SCHEMA = "auth";
@Bean
Flyway flyway(DataSource dataSource) {
return Flyway.configure()
.dataSource(dataSource)
.locations(MIGRATION_LOCATION)
.defaultSchema(SCHEMA)
.schemas(SCHEMA)
.load();
}
@Bean("testFlywayMigrationInitializer")
InitializingBean testFlywayMigrationInitializer(Flyway flyway) {
return flyway::migrate;
}
@Configuration(proxyBeanMethods = false)
static class FlywayDependsOnPostProcessor extends AbstractDependsOnBeanFactoryPostProcessor {
FlywayDependsOnPostProcessor() {
super(EntityManagerFactory.class, "testFlywayMigrationInitializer");
}
}
}
}
@@ -0,0 +1,34 @@
package com.project.auth.infrastructure.persistence.user.mapper;
import com.project.auth.domain.user.model.AuthProvider;
import com.project.auth.infrastructure.persistence.user.entity.UserJpaEntity;
import com.project.auth.infrastructure.support.exception.InfrastructureErrorCode;
import com.project.auth.infrastructure.support.exception.InfrastructureException;
import org.junit.jupiter.api.Test;
import java.time.Instant;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class UserPersistenceMapperTest {
private final UserPersistenceMapper mapper = new UserPersistenceMapper();
@Test
void toDomain_translates_invalid_persisted_user_state_to_infrastructure_exception() {
UserJpaEntity invalidEntity = UserJpaEntity.of(
UUID.fromString("11111111-1111-1111-1111-111111111111"),
"not-an-email",
"테스터",
AuthProvider.KEYCLOAK,
"keycloak-subject-1",
Instant.parse("2026-04-17T00:00:00Z")
);
assertThatThrownBy(() -> mapper.toDomain(invalidEntity))
.isInstanceOf(InfrastructureException.class)
.extracting(exception -> ((InfrastructureException) exception).getErrorCode())
.isEqualTo(InfrastructureErrorCode.PERSISTED_DATA_INVALID);
}
}