본문 바로가기

Back-End/Spring Boot

[Spring Boot] Multi-DataSource Routing 구현 삽질기

동일한 스키마, 동일한 데이터, 동일한 API에 대해 MySQL과 PostgreSQL의 성능 차이를 비교하기 위한 플랫폼을 구상하여 만들던 중 DataSource Routing에 관련한 문제를 겪어 해결하기 위해 삽질한 기록을 공유하고자 한다. 

이 프로젝트에서는 구체적으로는 다음을 목표로 했다.

  • LIKE '%keyword%' 같은 Full Scan 쿼리의 실행 속도 비교
  • 인덱스 종류(B-Tree, GIN, Full-Text 등)별 성능 차이 분석
  • EXPLAIN / EXPLAIN ANALYZE 실행 계획의 상세 비교
  • 동일 조건에서 두 DB 엔진의 옵티마이저 동작 차이 관찰

이를 위해 하나의 Spring Boot 애플리케이션에서 요청 헤더(X-Database-Type)로 MySQL/PostgreSQL을 런타임에 전환하는 구조가 필요했다.


1차 시도: AbstractRoutingDataSource

AbstractRoutingDataSource는 Spring Framework가 제공하는 DataSource 구현체로, 여러 개의 실제 DataSource를 Map으로 들고 있다가 determineCurrentLookupKey() 메서드의 반환값을 기준으로 런타임에 적절한 DataSource를 선택하는 라우팅 메커니즘이다.

내부적으로 targetDataSources라는 Map<Object, DataSource>를 유지하며, getConnection() 호출 시 lookup key에 해당하는 DataSource에서 커넥션을 획득한다. Read/Write 분리(Master-Slave 구조)에서 자주 사용되는 패턴이다.

접근

이 프로젝트에서는 ThreadLocal 기반의 DataSourceContextHolder에 필터 단에서 HTTP 헤더 값을 저장하고, AbstractRoutingDataSource가 이를 읽어 MySQL 또는 PostgreSQL DataSource로 라우팅하는 구조를 설계했다.

public class RoutingDataSource extends AbstractRoutingDataSource {
    @Override
    protected Object determineCurrentLookupKey() {
        return DataSourceContextHolder.getDataSourceType();
    }
}

전체 흐름은 다음과 같다

결과

DataSource 라우팅 자체는 정상 동작했다. 로그를 찍어보면 RoutingDataSourcePOSTGRES를 선택하는 것을 확인할 수 있었다.

DEBUG RoutingDataSource - Current datasource type: POSTGRES

하지만 실제 쿼리가 실행되면 에러가 발생했다.

ERROR: LIMIT #,# syntax is not supported
  Hint: Use separate LIMIT and OFFSET clauses.

Hibernate가 생성한 SQL을 보면:

SELECT ... FROM products p1_0 WHERE ... ORDER BY p1_0.created_at LIMIT ?,?

MySQL 전용 문법인 LIMIT offset, count가 PostgreSQL로 전달되고 있었다. 커넥션은 PostgreSQL로 갔지만, SQL 자체가 MySQL 문법으로 생성된 것이다.

이 시점에서는 왜 DataSource는 올바르게 전환되는데 SQL 문법이 바뀌지 않는지 원인을 파악하지 못했다.

추가로 시도한 것들

  • LazyConnectionDataSourceProxy로 감싸서 커넥션 획득 시점을 지연 → SQL 문법 문제는 그대로
  • DataSource 설정을 여러 방식으로 변경 → 근본 원인이 아니었음

AbstractRoutingDataSource의 한계

이 시도를 통해 AbstractRoutingDataSource가 해결하는 문제의 범위를 이해하게 되었다. 이 클래스는 JDBC 커넥션을 어디서 가져올지만 결정한다. SQL 문자열이 어떤 문법으로 생성되는지에는 전혀 관여하지 않는다. 같은 DB 엔진 간의 라우팅(예: Master-Slave)에서는 문법이 동일하므로 문제가 없지만, 이종 DB 간 라우팅에서는 커넥션 전환만으로 충분하지 않다.


2차 시도: Hibernate MultiTenancy

MultiTenancy란

Hibernate의 MultiTenancy는 하나의 애플리케이션 인스턴스에서 여러 테넌트(고객사, 조직 등)의 데이터를 격리하기 위한 기능이다. 크게 세 가지 전략을 제공한다:

전략 설명 격리 수준
DATABASE 테넌트별 별도 데이터베이스 높음
SCHEMA 같은 DB, 테넌트별 별도 스키마 중간
DISCRIMINATOR 같은 테이블, 컬럼 값으로 구분 낮음

구현을 위해 두 가지 인터페이스를 제공한다:

  • MultiTenantConnectionProvider: 테넌트 식별자를 받아 해당 테넌트의 커넥션을 반환
  • CurrentTenantIdentifierResolver: 현재 요청의 테넌트 식별자를 결정

접근

회사에서 멀티 테넌트 기반의 동적 스키마 선택 구조를 설계하고 구현한 경험이 있었다. 당시에는 동일한 PostgreSQL 인스턴스 내에서 테넌트별로 스키마를 분리하는 SCHEMA 전략을 사용했고, CurrentTenantIdentifierResolver가 요청 컨텍스트에서 테넌트를 식별하면 MultiTenantConnectionProviderSET search_path TO tenant_schema로 스키마를 전환하는 방식이었다.

그 경험을 바탕으로, 이번에는 테넌트 식별자를 MYSQL / POSTGRES로 사용하고 DATABASE 전략으로 아예 다른 DB 인스턴스에 연결하면 되지 않을까 생각했다.

결과

커넥션 레벨의 라우팅은 되었지만, 여전히 같은 문제가 발생했다.

ERROR: LIMIT #,# syntax is not supported

회사에서 MultiTenancy를 성공적으로 사용할 수 있었던 이유가 명확해졌다. 당시에는 같은 DB 엔진(PostgreSQL) 내에서 스키마만 전환하는 것이었기 때문에 Dialect가 하나(PostgreSQLDialect)로 충분했다. 하지만 이번에는 이종 DB 간 전환이므로, Dialect 자체가 달라져야 하는 상황이었다.

MultiTenancy도 AbstractRoutingDataSource와 본질적으로 같은 한계를 가지고 있었다. 둘 다 커넥션(DataSource) 레벨의 전환이지, SQL 생성 방식(Dialect)을 바꾸는 것이 아니다.

핵심 차이: 같은 DB 엔진 vs 이종 DB 엔진

  같은 DB 엔진 (회사) 이종 DB 엔진 (이번 프로젝트)
예시 PostgreSQL Schema A ↔ Schema B MySQL ↔ PostgreSQL
Dialect 하나면 충분 각각 필요
SQL 문법 동일 상이 (LIMIT ?,? vs LIMIT ? OFFSET ?)
MultiTenancy 적합 부적합
AbstractRoutingDataSource 적합 부적합

이 시점에서 "커넥션을 바꾸는 것만으로는 안 된다"는 것을 확실히 인지하게 되었고, SQL이 생성되는 계층을 더 깊이 조사하기 시작했다.


문제의 본질 파악: Dialect와 EntityManagerFactory

1차, 2차 시도 모두 같은 에러로 실패한 후, 표면적인 증상이 아니라 Hibernate가 SQL을 생성하는 내부 메커니즘 자체를 파고들어가기로 했다. Spring Boot가 JPA를 초기화하는 전체 과정을 소스 코드 레벨에서 추적한 내용을 정리한다.

1. Spring Boot의 JPA 초기화 과정

Spring Boot 애플리케이션이 기동되면, JPA 관련 Bean들은 다음 순서로 생성된다:

여기서 주목할 것은 9 단계 (Dialect 결정 및 고정) 이다. 이 시점 이후로 해당 EntityManagerFactory에서 생성되는 모든 EntityManager, 모든 쿼리는 이 Dialect를 사용한다. 런타임에 변경할 수 있는 방법은 없다.

2. Dialect가 결정되는 시점과 경로

LocalContainerEntityManagerFactoryBean.afterPropertiesSet()이 호출되면, 내부적으로 Hibernate의 SessionFactoryBuilder를 통해 SessionFactory가 생성된다. 이 과정에서 Dialect가 결정되는 경로는 다음과 같다:

hibernate.dialect 프로퍼티를 명시하면 해당 Dialect 클래스를 직접 인스턴스화하고, 명시하지 않으면 JDBC DatabaseMetaDatagetDatabaseProductName()getDatabaseMajorVersion() 등을 조회하여 Dialect를 자동 결정한다.

중요한 것은, 어떤 경로든 최종적으로 SessionFactoryImpldialect 필드에 저장되며, 이 필드는 final이다. SessionFactory의 생명주기 동안 변경되지 않는다.

3. Dialect가 SQL 생성에 미치는 영향

Hibernate에서 JPQL이나 Criteria API로 작성된 쿼리가 실제 SQL 문자열로 변환되는 과정에서 Dialect는 다음과 같은 역할을 한다:

Dialect가 영향을 미치는 구체적인 영역들:

영역 MySQLDialect PostgreSQLDialect
페이징 LIMIT ?, ? (offset, count) LIMIT ? OFFSET ?
문자열 결합 CONCAT(a, b) a || b
Boolean TINYINT(1) / BIT BOOLEAN
Identity 컬럼 AUTO_INCREMENT GENERATED BY DEFAULT AS IDENTITY
UPSERT ON DUPLICATE KEY UPDATE ON CONFLICT ... DO UPDATE
시퀀스 지원 미지원 (8.0 이전) 네이티브 지원
JSON 함수 JSON_EXTRACT() ->, ->> 연산자
Lock 문법 FOR UPDATE FOR UPDATE (동일하지만 내부 구현 다름)

특히 페이징(LimitHandler) 이 이번 문제의 직접적 원인이었다. MySQLDialectLimitHandlerLIMIT offset, count 형식의 SQL을 생성하는데, 이 문법은 PostgreSQL에서 지원하지 않는다.

4. EntityManagerFactory, SessionFactory, Dialect의 관계

이 세 가지의 관계를 정확히 이해하는 것이 문제 해결의 핵심이었다.

핵심 포인트:

  1. JPA의 EntityManagerFactory는 Hibernate의 SessionFactoryImpl로 구현된다. 두 명칭은 같은 객체를 가리킨다.
  2. SessionFactoryImplDialectfinal 필드로 보유한다. 생성자에서 한 번 설정되면 생명주기 동안 변경 불가.
  3. SessionFactoryImpl에서 생성된 모든 SessionImpl(= EntityManager)은 같은 SessionFactoryImpl의 참조를 가진다. 따라서 같은 Dialect를 사용한다.
  4. Spring Boot에서 일반적으로는 EntityManagerFactory는 싱글톤 Bean이다. 애플리케이션 컨텍스트에 하나만 존재한다.

이 네 가지를 종합하면 결론은 명확하다:

싱글톤 EntityManagerFactory → 하나의 SessionFactory → 하나의 Dialect → 모든 쿼리가 같은 SQL 문법
DataSource를 아무리 바꿔도, 그 위에서 동작하는 Dialect는 EntityManagerFactory가 하나인 한 절대 바뀌지 않는다.

5. 왜 DataSource 라우팅이 Dialect에 영향을 줄 수 없는가

Spring의 요청 처리 과정을 시간 순서대로 추적하면, DataSource와 Dialect가 관여하는 시점이 완전히 분리되어 있음을 알 수 있다.

이 다이어그램에서 보이듯이:

  1. Dialect 결정: 서버 기동 시 EntityManagerFactory 생성과 함께 단 한 번 (좌측)
  2. SQL 생성: 요청 시 Dialect를 참조하여 SQL 문자열 확정 (중간)
  3. 커넥션 획득: SQL이 완성된 후에야 DataSource에서 커넥션을 가져옴 (우측)

AbstractRoutingDataSourceMultiTenantConnectionProvider든, 이들이 개입하는 시점은 3번(커넥션 획득) 이다. 하지만 SQL 문법은 이미 2번에서 확정된 상태다. 순서가 근본적으로 뒤바뀌어 있으므로, 커넥션 레벨에서 아무리 라우팅해도 SQL 문법에는 영향을 줄 수 없다.

6. Spring Boot Auto-Configuration과 EntityManagerFactory의 관계

Spring Boot의 HibernateJpaAutoConfiguration은 다음 조건으로 동작한다:

@Configuration
@ConditionalOnClass({ LocalContainerEntityManagerFactoryBean.class, EntityManager.class })
@ConditionalOnBean(DataSource.class)
@ConditionalOnMissingBean({ LocalContainerEntityManagerFactoryBean.class, EntityManagerFactory.class })
public class HibernateJpaAutoConfiguration extends JpaBaseConfiguration {
    // ...
}

@ConditionalOnMissingBean에 의해, 개발자가 직접 LocalContainerEntityManagerFactoryBean이나 EntityManagerFactory Bean을 등록하면 auto-configuration은 비활성화된다. 이는 곧

  • spring.jpa.* 프로퍼티를 읽어 EntityManagerFactory에 적용하는 코드가 실행되지 않음
  • spring.jpa.hibernate.ddl-auto, spring.jpa.properties.hibernate.dialect 등이 무시됨
  • Dialect, naming strategy, ddl-auto 등을 직접 EntityManagerFactory에 설정해야 함

을 의미한다. 이 메커니즘을 이해하지 못하면, spring.jpa 프로퍼티를 아무리 변경해도 적용되지 않는 원인을 찾기 어렵다.

7. 결론

계층 클래스 결정 시점 런타임 전환 역할
DataSource HikariDataSource 기동 시 O (AbstractRoutingDataSource) JDBC 커넥션 대상 결정
Dialect MySQLDialect EntityManagerFactory 생성 시 X (final 필드) SQL 문법 생성 규칙
EntityManagerFactory SessionFactoryImpl 기동 시 X (싱글톤) Dialect + DataSource를 묶는 단위
EntityManager SessionImpl 트랜잭션 시작 시 O (매번 새로 생성) 실제 DB 작업 수행

DataSource 라우팅이 아니라 EntityManagerFactory 라우팅이 필요하다.

각 DB에 맞는 Dialect를 가진 EntityManagerFactory를 별도로 만들고, 요청 시점에 올바른 EntityManagerFactory를 선택해야 한다.


3차 시도: 패키지 분리 + EntityManagerFactory 분리

접근

EntityManagerFactory를 분리해야 한다는 것을 알게 된 후, Spring에서 표준적으로 사용하는 Multi-DataSource 패턴을 적용했다.

@Configuration
@EnableJpaRepositories(
    basePackages = "com.searchplatform.mysql",
    entityManagerFactoryRef = "mysqlEntityManagerFactory",
    transactionManagerRef = "mysqlTransactionManager"
)
public class MysqlJpaConfig { ... }

@Configuration
@EnableJpaRepositories(
    basePackages = "com.searchplatform.postgres",
    entityManagerFactoryRef = "postgresEntityManagerFactory",
    transactionManagerRef = "postgresTransactionManager"
)
public class PostgresJpaConfig { ... }

패키지를 mysql/, postgres/로 분리하고, 각 패키지의 Repository가 각자의 EntityManagerFactory를 사용하도록 구성한다.

문제점

이 방식은 동작은 하지만, 현재 프로젝트 목적과 맞지 않았다.

  • Entity, Repository, Service 코드가 완전히 동일한 내용으로 두 개 존재해야 한다
  • 또는 Service에서 두 Repository를 의존하고 if문으로 분기해야 한다
// 이런 코드가 모든 Service 메서드에 필요
public ProductResponse getProduct(Long id) {
    if (isPostgres()) {
        return postgresProductRepository.findById(id);
    }
    return mysqlProductRepository.findById(id);
}

최종 해결: RoutingEntityManagerFactory

핵심 아이디어

EntityManagerFactory 인터페이스를 직접 구현한 RoutingEntityManagerFactory를 만들어서, 모든 메서드 호출 시점에 DataSourceContextHolder를 확인하고 올바른 EntityManagerFactory로 위임하는 방식이다.

public class RoutingEntityManagerFactory implements EntityManagerFactory {

    private final EntityManagerFactory mysqlEmf;
    private final EntityManagerFactory postgresEmf;

    private EntityManagerFactory resolve() {
        // DataSourceContextHolder: 요청 마다 요구하는 DB 타입 정보를 저장하는 커스텀 Context 객체
        // DataSourceType: 요청 디비 종류 Enum
        return DataSourceContextHolder.getDataSourceType() == DataSourceType.POSTGRES
            ? postgresEmf : mysqlEmf;
    }

    @Override
    public EntityManager createEntityManager() {
        return resolve().createEntityManager();
    }

    // ... 나머지 모든 메서드도 resolve()로 위임
}

Bean 등록 (JpaConfig)

@Bean
public LocalContainerEntityManagerFactoryBean mysqlEntityManagerFactory(
    @Qualifier("mysqlDataSource") DataSource mysqlDataSource) {
    return createEntityManagerFactory(mysqlDataSource);
}

@Bean
public LocalContainerEntityManagerFactoryBean postgresEntityManagerFactory(
    @Qualifier("postgresDataSource") DataSource postgresDataSource) {
    return createEntityManagerFactory(postgresDataSource);
}

@Bean
@Primary
public EntityManagerFactory entityManagerFactory(
    @Qualifier("mysqlEntityManagerFactory") EntityManagerFactory mysqlEmf,
    @Qualifier("postgresEntityManagerFactory") EntityManagerFactory postgresEmf) {
    return new RoutingEntityManagerFactory(mysqlEmf, postgresEmf);
}
  • mysqlEntityManagerFactory: MySQL DataSource + Hibernate가 JDBC 메타데이터에서 MySQLDialect 자동 감지
  • postgresEntityManagerFactory: PostgreSQL DataSource + PostgreSQLDialect 자동 감지
  • entityManagerFactory (@Primary): 두 EntityManagerFactory를 감싸는 라우팅 구현체. Spring Data JPA Repository들이 이 Bean을 사용

주의사항

1. Context 설정은 반드시 트랜잭션 진입 전에

JpaTransactionManager는 트랜잭션 시작 시점에 createEntityManager()를 호출한다. 이 시점에 resolve()가 실행되므로, 트랜잭션 진입 전에 Context가 설정되어 있어야 한다.

@Component
public class DataSourceRoutingFilter extends OncePerRequestFilter {
    
    @Override
    protected void doFilterInternal(HttpServletRequest request, 
                                     HttpServletResponse response,
                                     FilterChain chain) throws ServletException, IOException {
        try {
            // DataSourceContextHolder: 요청 마다 요구하는 DB 타입 정보를 저장하는 커스텀 Context 객체
            // DataSourceType: 요청 디비 종류 Enum
            DataSourceType type = resolveFromRequest(request);
            DataSourceContextHolder.set(type);
            chain.doFilter(request, response);
        } finally {
            DataSourceContextHolder.clear();
        }
    }
}

2. 트랜잭션 중 Context 변경 금지

트랜잭션이 시작되면 EntityManager가 이미 생성된 상태다. 이후 Context를 변경해도 기존 EntityManager는 그대로 유지되어 의도와 다른 DB를 사용하게 된다.

@Transactional
public void someMethod() {
    // 1. 트랜잭션 시작 → RoutingEMF.resolve() → mysqlEmf 선택
    // 2. EntityManager 생성 (MySQL용)
    // 3. bindResource(routingEmf, mysqlEntityManager) ← 키가 routingEmf
    
    userRepository.findAll();  
    // → SimpleJpaRepository가 routingEmf 키로 EM 조회
    // → MySQL EM 사용 (정상)
    
    DataSourceContextHolder.set(POSTGRES);  // 중간에 변경하면?
    
    productRepository.findAll();
    // → 같은 트랜잭션이라 기존 MySQL EM 계속 사용
    // → context 바꿔도 무시됨 (혼란 유발)
}

3. 트랜잭션 전파 시 주의

전파 옵션동작주의점
REQUIRED (기본값) 기존 트랜잭션 참여 기존 EntityManager 사용, Context 변경 무시됨
REQUIRES_NEW 새 트랜잭션 생성 새 EntityManager 생성, 이 시점의 Context 사용

REQUIRES_NEW로 새 트랜잭션을 시작하면 그 시점의 Context로 다시 resolve()가 호출되므로, 의도치 않게 다른 DB를 바라볼 수 있다.

그런데 왜 @Bean 메서드에서 단순 if문으로 못 하는가

// 이렇게 하면 안 된다
@Bean
public EntityManagerFactory entityManagerFactory(...) {
    if (DataSourceContextHolder.getDataSourceType() == DataSourceType.POSTGRES) {
        return postgresEmf;  // 여기 안 옴 - 기동 시 한 번만 실행됨
    }
    return mysqlEmf;  // 항상 이것만 반환, 싱글톤으로 캐시
}

@Bean 메서드는 서버 기동 시 단 한 번만 실행된다. 그 시점에는 요청 컨텍스트가 없으므로 DataSourceContextHolder는 항상 기본값(MYSQL)을 반환한다. 반환된 Bean은 싱글톤으로 캐시되어 이후 모든 요청에서 동일한 인스턴스가 사용된다.

요청마다 다른 EntityManagerFactory를 사용하려면, 두 EntityManagerFactory를 모두 들고 있으면서 매 호출 시점에 분기하는 객체가 필요하다. 그것이 RoutingEntityManagerFactory다.

요청 처리 흐름

기존 코드 변경 없음

Repository, Service, Controller 코드는 단 한 줄도 수정하지 않았다. EntityManageFactory 라우팅이 인프라 계층에서 투명하게 처리되므로, 비즈니스 코드는 단일 DB를 사용할 때와 완전히 동일하다.


7. 최종 아키텍처

전체 구조

주요 구성 요소

구성 요소 역할
DataSourceRoutingFilter HTTP 헤더에서 DB 타입을 읽어 ThreadLocal에 저장
DataSourceContextHolder ThreadLocal로 현재 스레드의 DB 타입 관리
DataSourceType MYSQL, POSTGRES enum
RoutingEntityManagerFactory 요청 시점에 올바른 EntityManagerFactory로 위임
DataSourceConfig MySQL/PostgreSQL 각각의 HikariCP + ProxyDataSource 구성
JpaConfig 두 EntityManagerFactory 생성 + RoutingEntityManagerFactory Bean 등록

 


9. 배운 점

Spring + Hibernate 내부 구조에 대한 이해

  1. DataSource와 Dialect는 완전히 다른 계층이다
    • DataSource: JDBC 커넥션을 어디로 보낼지 (네트워크 레벨)
    • Dialect: SQL 문자열을 어떻게 생성할지 (문법 레벨)
    • 둘은 독립적이다. DataSource를 바꿔도 Dialect는 바뀌지 않는다.
  2. EntityManagerFactory(SessionFactory)는 Dialect의 소유자다
    • SessionFactoryImpldialect 필드는 final이다
    • 생성자에서 결정된 이후 변경할 수 있는 setter도, 메서드도 없다
    • EntityManagerFactory가 싱글톤인 한, Dialect는 애플리케이션 생명주기 동안 고정된다
  3. SQL 생성과 커넥션 획득의 시간적 순서
    • Hibernate는 먼저 Dialect를 참조해 SQL 문자열을 완성한다
    • 그 다음에야 DataSource에서 커넥션을 획득한다
    • 이 순서 때문에, 커넥션 레벨 라우팅은 SQL 문법에 영향을 줄 수 없다
  4. @Bean과 싱글톤 스코프
    • @Bean 메서드는 기동 시 한 번 실행되고, 반환된 객체는 싱글톤으로 캐시된다
    • 요청마다 다른 동작이 필요하면, 반환하는 객체 자체가 내부에서 동적으로 분기해야 한다
    • RoutingEntityManagerFactory는 이 원리를 적용한 것이다
  5. MultiTenancy의 적용 범위
    • 같은 DB 엔진 내에서의 스키마/데이터베이스 분리에는 적합하다
    • 이종 DB 엔진 간 전환에는 Dialect 고정 문제로 부적합하다
    • 회사에서의 경험(같은 PostgreSQL, 스키마 분리)이 그대로 적용되지 않는 이유를 이해했다

설계 판단

  1. 패키지 분리 vs 라우팅 구현체
    • 패키지를 분리하면 Spring의 표준 패턴대로 깔끔하지만, 동일한 코드가 두 개 존재하게 된다
    • 프로젝트 목적이 "동일 코드로 DB 성능 비교"이므로, 코드 중복은 본질에 반한다
    • EntityManagerFactory 인터페이스를 직접 구현해서 라우팅하면 기존 코드 변경 없이 해결된다
  2. 문제 해결의 순서
    • 표면적 증상(커넥션이 안 간다)이 아니라 본질적 원인(SQL 문법이 안 바뀐다)을 찾아야 한다
    • 프레임워크의 내부 동작을 이해하면 시행착오를 줄일 수 있다
    • AbstractRoutingDataSourceMultiTenancyEntityManagerFactory 분리로 이어진 과정은, 계층 구조를 위에서 아래로 파고들어가며 진짜 원인을 찾은 과정이었다