Kryo Serializer 공부/BE, DB

Date 2022. 12. 27. 16:14

Kryo is a fast and efficient binary object graph serialization framework for Java. The goals of the project are high speed, low size, and an easy to use API. The project is useful any time objects need to be persisted, whether to a file, database, or over the network.

Background

Redis 와의 입출력이 많은 어플리케이션의 경우 serilaize / deserialize cost를 고민해볼 필요가 있음
현재 개발중인 어플리케이션에서는 network bandwidth 확보를 위해 bytecode로 serialize 후 GZip을 적용해 압축까지 해서 저장함
async profiler로 JVM 리소스 할당량 떠보면 CPU I/O , Heap allocation 모두 상위 차지 하고 있음.

Kryo?

  • Java 객체를 효율적으로 + 빠르게 serialize 하는 라이브러리

장점

  • backward compatibility 같이 호환성에 대해서도 고려해주는 Serializer도 있음
  • 굉장히 빠름

단점

  • serialize를 하는 kryo 객체가 thread safe 하지 못함 (Kryo is not thread safe. Each thread should have its own Kryo, Input, and Output instances.)
  • kryo 객체 생성 비용도 비쌈 (constructing and configuring a Kryo instance is relatively expensive)
    (MVC 같은 멀티 쓰레드 환경에서는 kryo 객체 pool을 만들어서 써야함. ( Kryo에서 제공하는 기능 중 하나 ))
  • Serializer Configuration이 불편함 => compile time에 어떤 객체를 serilaizer에 등록할지 설정 필요

초간단 Benchmark

  • Target class : 가장 common하게 사용하는 객체 선정 ( 대략 30개의 field? )

  • Serializer

    • kryo VersionFieldSerializer
    • Spring JdkSerializationRedisSerializer ( Redis serializer 기본 )
  • 총 80만번 Serialize 수행

memory alloc

  • JdkSerializer 3GB
  • Kryo 800MB

cpu sample

  • JdkSerializer 9,540
  • Kryo 5,278

소요 시간

  • kryo 1366ms
  • jdk 2429ms
fun serializeTest() {  
    val dto = UserDto.fromUser(user)  
    val jdkSerializer = JdkSerializationRedisSerializer()  

    val kryo = Kryo()  
    val kryoSerializer = VersionFieldSerializer<UserDto>(kryo, UserDto::class.java)  

    val stratTime = System.currentTimeMillis()  
    (1..800000).forEach {  
        //val buffer = Output(1024, -1)  
        //kryoSerializer.write(kryo, buffer, dto)        
        val t = jdkSerializer.serialize(dto)  
    }  

    val endTime = System.currentTimeMillis()  

    println("time: ${endTime - stratTime}")
}

결론

쓸만한데, Serializer 튜닝하기가 좀 귀찮아보임 & 그렇지만 redis I/O가 많은 어플리케이션에서는 충분히 고려해볼만한 옵션인듯

TestContainers를 이용한 테스트 환경 구성 공부/BE, DB

Date 2022. 3. 1. 17:25

Test 환경 구성 방법

테스트 환경을 구성하기 위해서는 다양한 방법이 존재한다.
대표적으로 Spring Boot는 Data Access 관련 테스트를 위해 H2 database(in-memory db)를 자동으로 Configuration 해준다.

많이 쓰이는 다른 방법으로는 바로 Docker를 이용하는 방법이 있는데, 컨테이너들을 띄워 테스트를 하기 위해 필요한 의존관계(대표적으로 데이터베이스, 다른 dependent 서비스들 포함)을 편리하게 설정해줄 수 있다.

또다른 방법으로는 글에서 소개할 TestContainers 를 이용하는 방법이 있다.

TestContainers란 ?

도커를 이용하여 테스트 환경을 구성해줄 수 있는 라이브러리다. 기존에 도커를 이용해 테스트 환경을 구성한다 하면, 보통 docker-compose을 이용해 구성할 수 있는데 이는 테스트 환경 구성을 스프링 외부에서 해야한다는 단점이 존재한다.

반면 TestContainers 를 이용한다면 코드 레벨에서의 컨테이너 설정, inter-container 간의 네트워크 설정 등을 할 수 있다.

 

TestContainers의 장점

위에서 언급한 코드 레벨에서의 컨테이너 설정 외에도 다양한 장점이 존재한다.

대표적으로 로그 관련으로, 컨테이너의 로그를 Spring Process로 파이프할 수 있다.

즉 테스트 환경에서 Applciation Log와 Container Log를 함께 보며 디버깅 할 수 있다는 장점이 있다.

 

또한 Docker에 의존성이 존재하지만, 로컬에 있는 도커에 연결(docker.sock) 뿐만 아니라 tcp를 통해서 외부에 있는 도커 서버와도 연결해 사용할 수 있다.

그리고 구체적인 Container 클래스를 제공한다. 대표적으로는 JdbcDatabaseContainer로 객체가 jdbcUrl 등의 멤버들을 제공한다.

 

Configuration : 1. dependency 설정

예제 환경은 Spring Boot(2.6.2), Kotlin(1.6.10), Gradle Kotlin DSL을 이용해 진행하였다.

다음과 같은 디펜던시가 필요하다. build.gradle.kts에 다음 의존성을 추가한다. 여기서는 MySQL을 이용했다.

 

testImplementation("org.testcontainers:junit-jupiter:1.16.3")
testImplementation("org.testcontainers:mysql:1.16.3")

 

Configuration : 2. Container 설정

생성할 컨테이너와 관련된 설정이다. Spring과 의존성을 위해 @TestConfiguration 을 이용하였다.

 

@TestConfiguration("TestMySQLContainer")
class TestMySQLContainer {
        companion object {
          @Container
          @JvmStatic
          val container = MySQLContainer<Nothing>("mysql:8.0.19")
              .apply {
                  withDatabaseName("test")
                  withUsername("root")
                  withPassword("root")
              }
              .apply {
                  start()
              }
    }
}

 

Configuration : 3. Datasource 빈 작성하기

해당 컨테이너와 연결될 데이터소스를 직접 정의한다.

 

@TestConfiguration
class DataSourceConfig {
    @Bean
    @DependsOn("TestMySQLContainer")
    fun dataSource(): HikariDataSource {
        return DataSourceBuilder.create()
            .type(HikariDataSource::class.java)
            .url(TestMySQLContainer.container.jdbcUrl)
            .username(TestMySQLContainer.container.username)
            .password(TestMySQLContainer.container.password)
            .build()
    }
}

 

컨테이너가 생성된 이후 빈이 생성되도록 DependsOn 어노테이션을 이용하였다.
또한 MySQLContainer 타입으로 컨테이너를 생성했기 때문에 객체가 jdbcUrl, username 등 데이터소스 생성에 필요한 변수들을 제공해준다.

특히 jdbcUrl 을 통해서 포트, dbname 등에 상관없이 데이터소스를 생성할 수 있다.

 

DataJpaTest 에서의 테스트

DataJpaTest를 이용하면 테스트에 필요한 빈들만 띄워서 테스트할 수 있다.

 

@DataJpaTest
class FooTest {}

 

DataJpaTesth2 데이터베이스를 테스트에 이용하는데, 먼저 이거를 disable 시켜줘야 한다.

 

@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)

 

또한 DataJpaTest는 컴포넌트스캔을 하지 않으므로, 명시적으로 필요한 빈들을 Import 해야 한다. 위에서 생성한 두개의 클래스를 Import한다.

 

@DataJpaTest
@Import(TestMySQLContainer::class, DataSourceConfig::class)
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)

 

이제 테스트를 실행한다면 기존의 AutoConfiguration 대신 직접 생성한 DataSource가 적용되어 테스트가 실행된다.

Streaming Log(Container -> Spring Process)

마지막으로 MySQL 컨테이너 로그를 스프링 테스트 환경에서 볼 수 있도록 로그를 파이프 해줄 수 있다.

먼저 앞에서 생성한 TestMySQLContainer 클래스를 다음과 같이 수정한다.

 

@TestConfiguration("TestMySQLContainer")
class TestMySQLContainer {
        companion object {
          val logger = LoggerFactory.getLogger(TestMySQLContainer::class.java)
          @Container
          @JvmStatic
          val container = MySQLContainer<Nothing>("mysql:8.0.19")
              .apply {
                  withDatabaseName("test")
                  withUsername("root")
                  withPassword("root")
              }
              .apply {
                  start()
              }
              .apply {
                followOutput(Slf4jLogConsumer(logger))
              }
    }

 

 SLF4J를 이용해 로그 인스턴스를 만들어 파이프해준다.

 

References

TestContainers

TestContainer로 멱등성 있는 테스트..

[Spring] Kotlin+JPA에서의 No-Args Constructor 이용 공부/BE, DB

Date 2022. 2. 23. 21:38

JPA 구현체는 엔티티를 Instantiate할 때 no args constructor 즉, Foo() 를 이용한다.
JSR-338 Specification을 보면 거의 맨 앞쪽에 언급 되어있다.

 

The entity class must have a no-arg constructor. The entity class may have other constructors as well. The no-arg constructor must be public or protected.

 

kotlin에서는 이러한 부분을 좀 더 쉽게 쓸 수 있게 하기 위해 gradle plugin을 제공해 주는데,

이는 no-arg 플러그인으로 특정 어노테이션에 대해 no args constructor를 생성해준다.

 

또한 이를 좀 더 편리하게 사용할 수 있는 org.jetbrains.kotlin.plugin.jpa 플러그인 또한 존재한다. 이는 no-arg 플러그인을 래핑한것이다.

 

코틀린 코드에서의 No Args Constructor 이용

 

실제로 No Args Constructor가 추가되었을까? 하고 직접 확인해보면, 컴파일이 되지 않는 것을 확인할 수 있다.

이에 대해 kotlin 공식 doc에서는 다음과 같이 언급하고 있다.

 

The generated constructor is synthetic so it can’t be directly called from Java or Kotlin, but it can be called using reflection.

 

즉 생성된 Constructor는 synthetic 이라서 코드에서 직접 접근이 안되고, Reflection을 통해 접근해야 한다는 것이다.

이는 다음과 같은 테스트코드가 통과함을 통해 확인할 수 있다.

 

@Test
fun `synthetic constructor`() {
    val constructors = User::class.constructors
    constructors.size shouldBe 1
}

 

또한 다음과 같은 코드는 예외가 발생한다.

 

@Test
fun `synthetic constructor`() {
    val user = User::class.createInstance()
    // Class should have a single no-arg constructor..
}

 

Reflection을 이용한 No Args Constructor 접근

Reflection을 이용해 다음과 같이 No Args Constructor에 접근해 객체를 생성할 수 있다.

 

fun `access noargs`() {
    val constructor = User::class.java.getConstructor() // get no-args constrcutor
    val user = constructor.newInstance() as User
    user ShouldNotBe null
    user.username shouldBe null
}

 

결론

 

Kotlin에서는 no-args constructor가 synthetic constructor라 직접 접근이 안되고, Reflection을 통해서만 접근할 수 있다.

번거롭지만 굳이 no-args constructor를 이용하려면 Reflection API를 이용하자.

 

References

kotlin no-arg compiler plugin
stackoverflow - kotlin noarg..

[Spring] QueryDSL Introduction ( w/ Spring Data JPA, Kotlin ) 공부/BE, DB

Date 2022. 2. 22. 15:11

Dependency

QueryDSL generates QClass that used to write typesafe query.
then, We should add annotation processor to generate QClass from entity.

 

plugins {
  ...
  kotlin("kapt") version "1.6.10"
}

dependencies {
  ...

  implementation("com.querydsl:querydsl-core")
  implementation("com.querydsl:querydsl-jpa")
  kapt(group = "com.querydsl", name = "querydsl-apt", classifier = "jpa")
  sourceSets.main {
        withConvention(org.jetbrains.kotlin.gradle.plugin.KotlinSourceSet::class) {
            kotlin.srcDir("build/generated/source/kapt/main")
        }
  }
}

QueryDSL Configuration

To use QuerydslRepositorySupport after, We need JPAQueryFactory type Bean.

 

@Configuration
class QuerydslConfig(
    @PersistenceContext val entityManager: EntityManager
) {
  @Bean
  fun jpaQueryFactory() = JpaQueryFactory(entityManager)
}

 

If We have more than one datasource, We should qualify bean for disambiguation. ( Using @Qualifier Annotation ? )

Now Ready to use QuerydslRepositorySupport in Spring App.

Build : Generate QClass

 

gradle build

Predicate : QuerydslPredicateExecutor

 

Spring Data JPA supports QueryDSL Integration ( org.springframework.data.querydsl )

Using QuerydslPredicateExecutor interface, you can write typesafe query using Predicate.

QuerydslPredicateExecutor interface use Predicate to execution query.

For Example...

 

Optional<T> findOne(Predicate predicate)
Page<T> findAll(Predicate predicate, Pageable pageable)
Iterable<T> findAll(Predicate predicate, OrderSpecifier<?>... orders)

 

extends QuerydslPredicateExecutor interface

 

interface UserRepository: JpaRepository<User, Long>, QuerydslPredicateExecutor<User> {
  ...
}

 

then you can use Predicate in Repository.

 

fun findByUsername(username: String) {
    val predicate = QUser.user.username.like("%foo%")
    val pageable = PageRequest.of(page = 0, size = 10)
    val users = userRepository.findAll(predicate, pageable)
    users.forEach {
        it.username shouldContain "foo"
    }
}

QuerydslRepositorySupport : JpaQueryFactory

 

write complicate query in type-safe way.

 

You can write query without QuerydslRepositorySupport. just Inject JpaQueryFactory Bean.

@Repository
class UserRepositorySupport(
    @Autowired val query: JPAQueryFactory
): QuerydslRepositorySupport(User::class.java) {
  private val user = QUser.user

  fun findByUsername(username: String): User {
      val where = BooleanBuilder()
    where.and(user.username.like("%$username%")
    return query.select(user).from(user).where(where).fetchFirst()
  }
}

fetchFirst method shorten limit(1).fetchOne()

 

[Typeorm] 쿼리가 두번 나가는 이유? 공부/BE, DB

Date 2022. 2. 19. 21:57

로그에 찍힌 쿼리를 보다 분명 하나의 find만 호출했는데 쿼리가 두번 나가는 경우가 발생했다. 

 

코드를 살펴보니 바로 Pagination(OFFSET, LIMIT)과 Join을 함께 이용한 경우에는 두번의 쿼리를 통해 값을 리턴하게 된다.

 

첫번째는 Pagination 범위에 있는 레코드들의 id를 가져오는 쿼리고,

두번쨰는 실제로 해당 id들을 이용해( id IN [ids...]) 실제 레코드를 가져온다.

사례

만약 Team 테이블과 Person 테이블이 1:N 관계를 맺고 있고,

Team에 Person을 Join해서 가져온다고 하자. 즉 다음과 같은 typeorm 메소드를 실행한다.

 

TeamRepository.find({
	relations: ['person']
});

 

그렇다면 다음과 비슷한 쿼리결과가 나올 것이다.

 

team_id team_col person_id...
1 Lorem 1
1 Lorem 2
2 Ipsum 3

 

만약 여기에 LIMIT 2를 적용한다면 다음과 같은 쿼리 결과가 나올것이고, Typeorm 메소드가 반환하는 엔티티 갯수는 1개가 될것이다.

(즉, 원하는 결과가 아니다.)

 

team_id team_col person_id...
1 Lorem 1
1 Lorem 2

 

이러한 문제를 해결하기 위해 Typeorm은 서두에 언급했던 방식(두번의 쿼리)를 이용해 엔티티를 가져오게 된다.

간략하게 말하자면, Pagination + Join 조건이 적용된 경우

1) SELECT DISTINCT와 Pagination을 이용해 id들을 찾고,

2) 해당 id들을 where 절로 적용해 실제로 쿼리

하는 방식으로 동작한다.

 

실제 코드는 여기를 참고하면 된다.

 

 

[Typeorm] Repository.save 메소드 알아보기 공부/BE, DB

Date 2022. 2. 11. 20:01

Typescript(javascript) 에서 이용할 수 있는 ORM 라이브러리인 Typeorm은 Repository 클래스를 통해 엔티티에 대한 연산(Insert, Remove..)등을 수행할 수 있다.

 

그러한 메소드 중에서는 범용으로 이용할 수 있는 save() 메소드가 존재하는데, 공식 Document의 설명을 요약하면 해당 메소드의 기능은 다음과 같다.

 

    1. 만약 인자로 들어온 엔티티가 이미 디비에 존재하면, 업데이트를 수행한다.

    2. 그렇지 않으면 Insert를 수행한다.

 

이는 Oracle, MySQL 등 여러 dbms가 제공하는 Upsert(Update or Insert)와 비슷한데, 실제로 어떤 방식으로 구현되어 있는지 궁금해서 찾아보게 되었다.

TL;DR

결론부터 말하자면, 다음과 같이 동작한다.

1. entity에 id가 존재하지 않으면 > Insert 동작

2. entity에 id가 존재하면? id를 이용해 SELECT 쿼리를 날린다.

  • 2-1: 존재하면, Update 동작
  • 2-2: 존재하지 않으면: Insert 동작

실행되는 과정을 보고싶다면 TypeORM 커넥션 옵션에 logging 관련 옵션을 주면 된다. query, error 등 로그를 남길 level을 설정할 수 있는데, query를 추가하면 실제 TypeORM이 생성하는 쿼리를 볼 수 있다.

 

Repository.save()

Repository 클래스의 save 메소드는 다음과 같이 정의되어 있다.

 

save(entityOrEntities: Entity|Entity[], options?: RemoveOptions): Promise<Entity|Entity[]> {
  return this.manager.save(this.metadata.target as any, entityOrEntities as any, options);
}

 

save()를 호출하면 EntityManagersave 메소드를 다시 호출한다.

 

EntityManager.save()

EntityManager 클래스의 save 메소드는 다음과 같이 정의되어 있다.

 

save<Entity, T extends DeepPartial<Entity>>(targetOrEntity: (T|T[])|EntityTarget<Entity>, maybeEntityOrOptions?: T|T[], maybeOptions?: SaveOptions): Promise<T|T[]> {

        // ...normalize mixed parameter

        // execute save operation
        return new EntityPersistExecutor(this.connection, this.queryRunner, "save", target, entity, options)
            .execute()
            .then(() => entity);
    } 

 

실제로 실행하는 부분은 EntityPersistExecutor 객체를 생성하고 execute() 메소드를 수행한다.

EntityPersistExecutor.execute()

EntityPersistManager 객체의 execute() 메소드는 다음과 같다. 실제 필요한 부분만 가져왔다.

 

async execute(): Promise<void> {

    //...
    try {
        const executors = await Promise.all(entitiesInChunk.map(async entities => {
            const subjects: Subject[] = [];
            entities.forEach(entity => 
                // ...

                subjects.push(new Subject({
                 metadata: this.connection.getMetadata(entityTarget),
                 entity: entity,
                 canBeInserted: this.mode === "save",
                 canBeUpdated: this.mode === "save",
                 mustBeRemoved: this.mode === "remove",
                 canBeSoftRemoved: this.mode === "soft-remove",
                 canBeRecovered: this.mode === "recover"
                }));
            );
            // ...
            await new SubjectDatabaseEntityLoader(queryRunner, subjects).load(this.mode);
            return new SubjectExecutor(queryRunner, subjects, this.options);
        });
        // ...

        const executorsWithExecutableOperations = executors.filter(...);
        for (const executor of executorsWithExecutableOperations) {
            await executor.execute();
        }
    }
}

 

코드는 크게 세부분으로 구성된다.

 

첫번째로, 실제 실행할 작업이 담긴 SubjectExecutor 배열을 생성한다. 이 때 mode는 "save" 이므로 canBeInsertedcanBeUpdatedtrue 인 Subject 들이 설정된다.


두번째로, SubjectExecutor를 생성하는 과정에서 Subject들에 대한 정보를 얻어오기 위한 다양한 전처리 과정을 거치는데, 이 중 SubjectDatabaseEntityLoader 가 Subject들에 대한 적절한 쿼리를 수행한다.


마지막으로 이렇게 생성된 SubjectExecutor들을 비동기로 수행한다.

Subject

Entity에 대해 어떠한 오퍼레이션을 할 지 결정하는 Subject 객체에 대해 살펴보자.
먼저 생성자는 다음과 같다.

 

 

constructor(options: {
    // ...
    entity? ObjectLiteral,
    canBeInserted?: boolean,
    canbeUpdated?: boolean,
    // ...
}) {
    this.entity = entity;
    this.canBeInserted = options.canBeInserted;
    this.canbeUpdated = options.canBeUpdated;
    //...
    this.recompute();
}

 

생성자 이외에 중요한 getter가 두 개 존재하는데, 다음과 같다.

 

get mustBeInserted() {
    return this.canBeInserted && !this.databaseEntity;
}

get mustbeUpdated() {
    return this.canBeUpdated &&
            this.identifier &&
            (this.databaseEntityLoaded === false || (this.databaseEntityLoaded && this.databaseEntity)) && this.changeMaps.length > 0;
}

 

기존 멤버필드를 이용해 필요한 getter들을 정의했다. 이름에서 보면 알겠지만 나중에 실행할 쿼리를 결정한다.

 

여기서 구체적인 예시를 통해 진행하고자 Subject의 Constructor에 break point를 잡고 test suite를 작성해 돌려봤다.

 

대략적인 코드는 아래와 같다.

 

const repository = connection.getRepository(MyEntity);
const plainEntity = new MyEntity();
plainEntity.title = "plainEntity";
await repository.save(plainEntity); // 여기서 Subject의 constructor가 호출된다.

const persistedEntity = await repository.findOneOrFail({title: "plainEntity"});
persistedEntity.title = "persistedEntity";
await repository.save(persistedEntity); // 여기서 Subject의 constructor가 호출된다.

 

여기서 실제 Subject 객체들의 필드값들은 다음과 같다.

 

1. plainEntity를 save할 떄 생성되는 Subject
{
  mustBeInserted: true,
  mustBeUpdated: undefined,
  canBeInserted: true,
  canBeUpdated: true,
  ...
}

2. persistedEntity를 save할 때 생성되는 Subject
{
  mustBeInserted: true,
  mustBeUpdated: false,
  canBeInserted: true,
  canBeUpdated: true
}

 

두 경우 모두 mustBeInserted가 true이고, mustBeUpdated는 undefined 또는 false이다.(?)

 

이렇게 생성된 Subject들은 SubjectDatabaseEntityLoader를 통해 갱신된다.

SubjectDatabaseEntityLoader

위에서 EntityPersistExecutor 객체는 SubjectDatabaseEntityLoader 객체를 생성하여 load 메소드를 비동기로 수행한다.

여기서 operationType은 "save"가 될 것이다.

 

async load(operationType: "save"|"remove"|"soft-remove"|"recover"): Promise<void> {

    const allIds: ObjectLiteral[] = [];
    const allSubjects: Subject[] = [];
    subjectGroup.subjects.forEach(subject => {    
        if (subject.databaseEntity || !subject.identifier)
        return;
        allIds.push(subject.identifier);
        allSubjects.push(subject);
    });


    if(operationType === "save" || ..) {
        const entities = await this.queryRunner.manager
        .getRepository<ObjectLiteral>(subjectGroup.target)
        .findByIds(allIds, findOptions);    
    }
    entities.forEach(entity => {
        const subjects = this.findByPersistEntityLike(subjectGroup.target, entity);
        subjects.forEach(subject => {
            subject.databaseEntity = entity;
            if (!subject.identifier)
            subject.identifier = subject.metadata.hasAllPrimaryKeys(entity) ? subject.metadata.getEntityIdMap(entity) : undefined;
        });
    });
}

 

즉 여기서 id가 존재하면 한번더 쿼리가 실행되고, 여기서 찾아진 entity들의 subject들의 databaseEntity 값이 채워지게 된다.

여기서 이미 존재하는 엔티티들의 subject은 mustBeUpdated가 true로 변경된다.

 

get mustBeInserted() {
    return this.canBeInserted && !this.databaseEntity;
}

get mustbeUpdated() {
    return this.canBeUpdated &&
            this.identifier &&
            (this.databaseEntityLoaded === false || (this.databaseEntityLoaded && this.databaseEntity)) && this.changeMaps.length > 0;

 

SubjectExecutor

돌아와서 이러한 Subject를 기반으로 생성한 SubjectExecutor의 execute 메소드를 실행하게 된다.


해당 메소드는 Subject들을 Insert, Update, (Soft)Remove, Recover할 Subject들로 나누고, 각각의 Subject들에 대해 executeXXOperations() 메소드를 실행한다.

 

마지막으로 이 executeXXOperation() 메소드들은 내부적으로 queryBuilder를 생성해 쿼리 오퍼레이션을 수행한다.

결론

Repository의 save 메소드는 INSERT 쿼리를 수행하거나, SELECT -> UPDATE로 이뤄지는 쿼리를 수행한다.

여기서 알 수 있는점은 여러가지가 있는데, 대표적으로

  1. Repository를 통해 업데이트를 수행할 때 find -> update object -> persist(save, update) 하는데,
    이때 save 보다는 update 메소드가 낫다.

    save는 쿼리를 두번 수행함으로써 네트워크 통신도 두번 하게 되고 상대적으로 디비에 부하(?)를 주는 반면
    update는 한번에 쿼리만 수행한다. update 메소드 주석에는 다음과 같은 문장이 적혀있다.
* Executes fast and efficient UPDATE query. Does not check if entity exist in the database.

 

  1. 반대로 신뢰할 수 있는 자바스크립트 객체일 경우 바로 save를 수행하는게 낫다. 즉 업데이트를 수행할 때 1의 과정을 거치기 보다는 find 하지 않고 객체를 수정하여 바로 save 하면 된다는 것이다.

 

References

  • TypeORM Doc
  • typeorm src(0.2.40)

[GCP] Cloud Scheduler를 이용한 주기적 작업 실행 공부/CLOUD

Date 2022. 1. 6. 14:06

클라우드를 포함한 여러 시스템들은 주기적으로 작업을 실행하게 해주는 기능들을 제공해준다.

 

리눅스에는 crontab을 이용해서 수행할 수 있고, AWS에서는 Cloudwatch Events를 이용해서 비슷한 기능을 수행할 수 있다.

마찬가지로 kubernetes는 CronJob이라는 리소스를 이용해서 주기적 작업을 수행할 수 있다.

 

비슷하게 구글 클라우드에서는 Cloud Scheduler라는 서비스를 통해 주기적 작업을 수행할 수 있다.

 

Cloud Scheduler 특징

구글 클라우드에서 제공하는 Cloud Scheduler는 다음과 같은 특징을 갖고 있다.(공식 홈페이지 참조)

 

1. 대부분의 클라우드 서비스가 그렇듯 구글 클라우드에서 제공하는 로깅 서비스와 자동으로 연동된다.

2. 작업 대상으로 HTTP 요청을 제공한다. 즉 HTTP 엔드포인트가 열려있는 곳이면 뭐든지 호출할 수 있다.

3. 작업 대상으로 구글 클라우드의 Pub/Sub을 대상으로 설정할 수 있다. 이를 통해서 해당 topic을 Subscribe하고 있는 다른 리소스들에 작업을 수행하게 할 수 있다.

4. retry 정책을 설정 가능하다. exponential backoff 등을 설정할 수 있다.

 

또한 최대 3개까지의 작업은 무료로 생성 가능하다고 한다. 

 

Cloud Scheduler 생성

 

주기적으로 HTTP 요청을 날리는 작업을 생성할 것이다. API는 모두 활성화 되어 있고 필요한 IAM 또한 할당되어 있다고 가정한다.

또한 여기서는 클라우드 콘솔(GUI)를 이용하여 생성했다.

 

1. 작업 정의

 

 

작업의 이름과, 시간대를 설정할 수 있다.

또한 빈도는 unix-cron 형식을 이용해 설정 가능하다.

 

2. HTTP 요청 구성

 

 

작업을 할 때 전송할 HTTP 요청을 구성한다.

 

기본적인 헤더, 바디를 설정할 수 있고

추가적으로 헤더에 인증을 위해 해당 작업을 수행하는 서비스 계정 정보를 담아 보낼 수 있다.

 

여기서는 작업용으로 생성한 Cloud Run에 요청을 보내기 위해서 OIDC 토큰을 함께 보내 Cloud Run 인증을 수행하도록 하였다.

 

3. 기타 configuration

 

재시도 횟수, backoff 타임 등을 설정할 수 있다. 

 

여기까지 완료되었으면 생성이 완료되었다. 주기에 상관없이 콘솔을 통해 직접 호출할 수 있다.

 

이후 작업 결과는 구글 클라우드의 Cloud Logging을 통해서 확인할 수 있다.

 

로그

 

HTTP의 경우 요청을 보내면서 AttempStarted 로그가 찍히고, 

요청에 대한 응답이 2xx 이면 AttemptFinished 로그가 찍히는걸 볼 수 있다.

[백엔드] 앞단에 Nginx 적용하기 공부/BE, DB

Date 2021. 8. 30. 18:13

이번에 프로젝트를 진행하면서 웹서버로 Nginx를 사용하였다. 총 두가지 목적으로 사용했는데

1. 정적 파일 응답 ( nginx에서 요청 처리)

2. 리버스 프록시 ( 뒷단의 nestjs 서버로 전달)

로 사용하였다.

 

여기서 리버스 프록시로 Nginx를 사용할 때 고려할 점을 알아보도록 하겠다.

기본 설정

Nestjs에서는 HTTP 요청을 기본적으로 Express 를 사용해서 처리하기 때문에 간단하게 Express 를 이용했다.

또한 앱을 띄우기 위해서 간단하게 docker-compose 를 이용했다.

 

다음은 간단한 Nginx configuration이다.

80포트로 들어오는 요청들을 모두 http://backend:3000 으로 전달한다.

 

upstream server {
    server backend:3000;
  }

  server {
    listen 80;

    location / {
        proxy_pass http://server;
        proxy_redirect off;
    }
  }

 

다음으로는 Express 서버 코드다.

 

const express = require('express');
const app = express();
const port = process.env.PORT;

app.get('/', (req, res) => {
  var json = {
      host: req.hostname,
      ip: req.ips,
      header: req.rawHeaders,
      body: req.body,
      cookies: req.cookies,
  };
  res.send(json);
})

app.listen(port, () => {
  console.log(`Example app listening at http://localhost:${port}`)
})

 

간단하게 요청이 들어오면 Request 를 적절히 파싱하여 응답한다.

서버와 Nginx를 docker-compose 를 이용해 배포한다.

 

services:
  backend:
    container_name: backend
    image: expressdghg:1.0
    environment: 
      - PORT=3000
  nginx:
    container_name: nginx 
    image: nginx
    ports:
      - 80:80
    volumes:
      - ./nginxconf:/etc/nginx/conf.d

 

이제 localhost:80로 HTTP 요청을 보낼 시 Nginx > Express 순으로 전달되고 응답하게 될 것이다.

응답

요청을 보내면 다음과 같이 응답한다.

 

{
host: "server",
ip: [ ],
header: [
"Host",
"server",
....

 

보다시피 hostip 가 제대로 설정되어있지 않다. 이는 요청을 Nginx 에서 보냈기 떄문이다.

이것을 해결하기 위해서는 두가지 설정이 필요하다.

해결방법

1. Express

 

우선 Express의 설정을 변경해야 한다. 문서에서는 다음과 같이 설명하고 있다.


When running an Express app behind a reverse proxy, some of the Express APIs may return different values than expected. In order to adjust for this, the trust proxy application setting may be used to expose information provided by the reverse proxy in the Express APIs. The most common issue is express APIs that expose the client’s IP address may instead show an internal IP address of the reverse proxy.

 

trust proxy가 설정되어야 한다. 이는 다음과 같이 설정할 수 있다.

 

app.set('trust proxy', true);

 

이렇게 설정하면 Express는 내부적으로 다음과 같이 동작한다. ( 4.17.1 기준 )

  1. app.set('trust proxy fn') 으로 변경한다. 왜인지는 모르겠다.
  2. Request 객체에 접근할 때 proxy 와 관련된 값들을 반환할 때 이 app의 trust proxy fn 값을 참고해 반환한다.

 

 

2. Nginx

 

Express에서 trust proxy 설정을 해두었다면 Express는 헤더 참고 시 기존 헤더가 아닌(RFC에 정의된 general 헤더) 특별한 헤더들을 참고한다.

  • 사실상 표준(de-facto) 헤더들로 X-Forwarded-* 헤더들이다.

nginx config을 다음과 같이 변경한다.

 

  server {
    listen 80;
    location / {
        proxy_pass http://server;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header X-Forwarded-Host $host;
        proxy_redirect off;
    }
  }

이 설정들로 앱을 다시 구동하면 정상적으로 값들이 출력된다.

 

 

결론

 

사실 이 글을 쓰게 된 계기는 Nestjs에서 Secure 설정으로 쿠키를 이용하고 있었는데, Nginx에서 SSL 종료 후 넘기기 때문에 정상적으로 적용이 안되었었다..! 해서 찾아보니 X-Forwared-Proto 를 이용해 프로토콜을 설정해줘야 한다고 해서 찾아보았다.

 

즉 Express등 앱을 직접 노출하지 않고 Nginx등 리버스 프록시를 사용하는 경우 앱에서 Host나 Client IP에 접근하기 위해서는 적절한 처리가 필요하다.

Recent Posts

Popular posts

Recent Comments

Tag List

JWT 알고리즘 운영체제 DB 리눅스 네트워크 DNS IAC API JavaScript k8s 테라폼 파이썬 ORM GCP 인가 AWS 컨테이너 도커 백준 TypeScript 클라우드 네임스페이스 인증
Total : Today : Yesterday :
Blog powered by Tistory, Designed by hanarotg