반응형

최근 회사에서 개발을 하다가 StaleObjectStateException를 마주쳤다.

org.hibernate.StaleObjectStateException: Row was updated or deleted by another transaction (or unsaved-value mapping was incorrect): [***#57faa1af-a453-46a3-842c-df45bfb1817b]
        at org.hibernate.event.internal.DefaultMergeEventListener.entityIsDetached(DefaultMergeEventListener.java:426) ~[hibernate-core-6.6.22.Final.jar:6.6.22.Final]
        at org.hibernate.event.internal.DefaultMergeEventListener.merge(DefaultMergeEventListener.java:214) ~[hibernate-core-6.6.22.Final.jar:6.6.22.Final]
        at org.hibernate.event.internal.DefaultMergeEventListener.doMerge(DefaultMergeEventListener.java:152) ~[hibernate-core-6.6.22.Final.jar:6.6.22.Final]
        at org.hibernate.event.internal.DefaultMergeEventListener.onMerge(DefaultMergeEventListener.java:136) ~[hibernate-core-6.6.22.Final.jar:6.6.22.Final]
        at org.hibernate.event.internal.DefaultMergeEventListener.onMerge(DefaultMergeEventListener.java:89) ~[hibernate-core-6.6.22.Final.jar:6.6.22.Final]
        at org.hibernate.event.service.internal.EventListenerGroupImpl.fireEventOnEachListener(EventListenerGroupImpl.java:127) ~[hibernate-core-6.6.22.Final.jar:6.6.22.Final]
        at org.hibernate.internal.SessionImpl.fireMerge(SessionImpl.java:854) ~[hibernate-core-6.6.22.Final.jar:6.6.22.Final]
        at org.hibernate.internal.SessionImpl.merge(SessionImpl.java:840) ~[hibernate-core-6.6.22.Final.jar:6.6.22.Final]
        at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:103) ~[na:na]
        at java.base/java.lang.reflect.Method.invoke(Method.java:580) ~[na:na]
        at org.springframework.orm.jpa.ExtendedEntityManagerCreator$ExtendedEntityManagerInvocationHandler.invoke(ExtendedEntityManagerCreator.java:364) ~[spring-orm-6.2.9.jar:6.2.9]
        at jdk.proxy4/jdk.proxy4.$Proxy138.merge(Unknown Source) ~[na:na]
        at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:103) ~[na:na]
        at java.base/java.lang.reflect.Method.invoke(Method.java:580) ~[na:na]
        at org.springframework.orm.jpa.SharedEntityManagerCreator$SharedEntityManagerInvocationHandler.invoke(SharedEntityManagerCreator.java:320) ~[spring-orm-6.2.9.jar:6.2.9]
        at jdk.proxy4/jdk.proxy4.$Proxy138.merge(Unknown Source) ~[na:na]
        at org.springframework.data.jpa.repository.support.SimpleJpaRepository.save(SimpleJpaRepository.java:657) ~[spring-data-jpa-3.5.2.jar:3.5.2]
        at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:103) ~[na:na]
        at java.base/java.lang.reflect.Method.invoke(Method.java:580) ~[na:na]
        at org.springframework.aop.support.AopUtils.invokeJoinpointUsingReflection(AopUtils.java:360) ~[spring-aop-6.2.9.jar:6.2.9]
        at org.springframework.data.repository.core.support.RepositoryMethodInvoker$RepositoryFragmentMethodInvoker.lambda$new$0(RepositoryMethodInvoker.java:277) ~[spring-data-commons-3.5.2.jar:3.5.2]
        at org.springframework.data.repository.core.support.RepositoryMethodInvoker.doInvoke(RepositoryMethodInvoker.java:170) ~[spring-data-commons-3.5.2.jar:3.5.2]
        at org.springframework.data.repository.core.support.RepositoryMethodInvoker.invoke(RepositoryMethodInvoker.java:158) ~[spring-data-commons-3.5.2.jar:3.5.2]
        at org.springframework.data.repository.core.support.RepositoryComposition$RepositoryFragments.invoke(RepositoryComposition.java:515) ~[spring-data-commons-3.5.2.jar:3.5.2]
        at org.springframework.data.repository.core.support.RepositoryComposition.invoke(RepositoryComposition.java:284) ~[spring-data-commons-3.5.2.jar:3.5.2]
        at... 후략

 

예외 메시지를 확인해 보면 Row was updated or deleted by another transaction라고 하는데 말 그대로 수정하고자 하는 행이 다른 트랜잭션에 의해 수정됐거나 삭제됐다고 한다.

 

트랜잭션의 동시성 문제로 발생했다는 뜻인데… 이상하다고 느껴졌다.

1. 오류 사항

이 오류가 발생했던 것은 단순 .save(Entity) 작업을 수행할 때이다.

 

그나마 기존에 있던 행을 수정하는 로직이었다면 고개를 끄덕일 수 있겠지만 새로운 엔티티를 저장하는 로직이었다. 그리고 테스트 메서드를 작성하여 실행하였기 때문에 동시에 다른 트랜잭션이 처리되었을 리가 만무하다.

 

우선 save하려고 작성했던 소스코드를 한번 확인해 보자.

...
@Entity
public class EntityExample { 

    @Id
    @GeneratedValue(strategy = GenerationType.UUID)
    private UUID id;

    ...

}

 

엔티티의 키값은 UUID로 @GeneratedValue 어노테이션을 이용하여 insert 할 때 자동으로 생성되도록 하고 있다.

void test(){
    EntityExample entityExample = EntityExample.builder()
        .id(UUID.randomUUID())
        .build();
    entityExampleRepository.save(entityExample);
}

 

그리고 엔티티를 만들 때 id에 직접 랜덤한 UUID를 생성하여 주입하고 저장하도록 한다.

 

결론적으로 GeneratedValue 어노테이션이 있음에도 직접 수동으로 값을 지정하면 문제가 발생한다.

2. Hibernate 소스코드 분석

그렇다면 왜 이런 오류가 발생했는지 출력된 Stack Trace를 따라서 분석해 보자.

SimpleJpaRepository.save() 분석

일단 첫 번째로 JpaRepository의 구현체인 SimpleJpaRepository Stack을 분석해 보자.

at org.springframework.data.jpa.repository.support.SimpleJpaRepository.save(SimpleJpaRepository.java:657) ...
@Override
@Transactional
public <S extends T> S save(S entity) {

    Assert.notNull(entity, ENTITY_MUST_NOT_BE_NULL);

    if (entityInformation.isNew(entity)) {
        entityManager.persist(entity);
        return entity;
    } else {
        return entityManager.merge(entity); // <- 657번 라인
    }
}

 

SimpleJpaRepository의 657번 라인에서 오류가 발생했는데 벌써 이상하다. isNew라는 메서드로 저장하고자 하는 엔티티가 새로운 엔티티인지 판별하고 새로운 엔티티라면 persist(insert), 이미 존재하는 엔티티라면 merge(update)를 하는 로직이다.

 

그런데 새로운 엔티티를 save 한 것임에도 새로운 엔티티라고 판단하지 않고 merge를 하게 되었다. Hibernate에서는 Merge 하려고 했는데 해당 key값에 대한 행이 데이터베이스에 존재하지 않으니 다른 트랜잭션에 의해 삭제됐다고 판단하여 예외를 발생시키는 것이다.

 

그렇다면 Hibernate는 해당 엔티티를 왜 새로운 엔티티라고 인식하지 못하였을까?

isNew() 판단 기준

이제 두 번째로. isNew(Entity) 메서드를 분석해 보자.

 

isNew 메서드는 AbstractEntityInformation라는 추상 클래스에서 내용을 확인해 볼 수 있다.

@Override
public boolean isNew(T entity) {

    ID id = getId(entity);
    Class<ID> idType = getIdType();

    if (!idType.isPrimitive()) { // <- 필자가 save한 엔티티의 ID는 UUID(Non-primitive)이다.
        return id == null;
    }

    if (id instanceof Number n) {
        return n.longValue() == 0L;
    }

    throw new IllegalArgumentException(String.format("Unsupported primitive id type %s", idType));
}

 

필자가 save 하려고 했던 엔티티의 ID는 UUID로 기본형(Primitive) 자료형이 아니다. 그렇기 때문에 해당 엔티티가 null일 때에만 새로운 엔티티로 인식하게 된다. EntityExample.builder().id(UUID.randomUUID())를 통해 나는 이미 ID 값을 직접 주입했다. 당연히 ID는 null이 아니었고, isNew() 메서드는 false를 반환했다.

 

이로 인해 merge가 호출되었고, 데이터베이스에 존재하지 않는 ID를 업데이트하려다 StaleObjectStateException가 발생한 것이다.

 

여기까지만 봐도 우선 문제는 해결했지만 궁금증이 더 남는다.

 

그렇다면 @GeneratedValue 어노테이션을 붙이지 않고 수동으로 ID를 할당하면 동일한 예외가 발생하여야 하는데 발생하지 않는다. 그렇다면 merge() 메서드의 로직에 다른 무언가가 있을 것이다.

merge() 내부 동작

이제 세 번째로 .merge(entity) 메서드를 분석해 보자.

at org.hibernate.event.internal.DefaultMergeEventListener.entityIsDetached(DefaultMergeEventListener.java:426) ~[hibernate-core-6.6.22.Final.jar:6.6.22.Final]
at org.hibernate.event.internal.DefaultMergeEventListener.merge(DefaultMergeEventListener.java:214) ~[hibernate-core-6.6.22.Final.jar:6.6.22.Final]
at org.hibernate.event.internal.DefaultMergeEventListener.doMerge(DefaultMergeEventListener.java:152) ~[hibernate-core-6.6.22.Final.jar:6.6.22.Final]
at org.hibernate.event.internal.DefaultMergeEventListener.onMerge(DefaultMergeEventListener.java:89) ~[hibernate-core-6.6.22.Final.jar:6.6.22.Final]
at org.hibernate.internal.SessionImpl.fireMerge(SessionImpl.java:854) ~[hibernate-core-6.6.22.Final.jar:6.6.22.Final]
at org.hibernate.internal.SessionImpl.merge(SessionImpl.java:840) ~[hibernate-core-6.6.22.Final.jar:6.6.22.Final]
protected void entityIsDetached(MergeEvent event, Object copiedId, Object originalId, MergeContext copyCache) {
    ...
    final Object result = source.getLoadQueryInfluencers().fromInternalFetchProfile(
            CascadingFetchProfile.MERGE,
            () -> source.get( entityName, clonedIdentifier )
    );
    ...

    if ( result == null ) {
        final Boolean knownTransient = persister.isTransient( entity, source );

        if ( knownTransient == Boolean.FALSE ) {

            throw new StaleObjectStateException( entityName, id ); // <- 여기서 예외 발생

        }

        ...
    }
    ...
}

 

중간에 많은 로직들이 있지만 핵심은 merge 로직 내에서 엔티티 객체 내에 ID 값은 존재하니 Detached 상태로 인식하게 된다. 이때 판별된 엔티티들 중 Transient 상태가 있는지 한번 더 확인한다.

 

이 Transient 상태를 판별하는 로직이 @GeneratedValue 어노테이션이 있을 때와 없을 때 다소 차이가 있다.

// org.hibernate.persister.entity.AbstractEntityPersister
public Boolean isTransient(Object entity, SharedSessionContractImplementor session) throws HibernateException {
    final Object id = getIdentifier( entity, session );
    ...
    // check the id unsaved-value
    final Boolean result = identifierMapping.getUnsavedStrategy().isUnsaved( id );
    if ( result != null ) {
        return result;
    }
    ...
}
// org.hibernate.engine.spi.IdentifierValue
// @GeneratedValue 어노테이션이 있을 때 로직
@Override
public @Nullable Boolean isUnsaved(@Nullable Object id) {
    LOG.tracev( "ID unsaved-value: {0}", value );
    return id == null || id.equals( value );
}

 

@GeneratedValue 어노테이션이 있을 때에는 위의 로직이 반영된다.

 

id는 사용자가 임의 할당하여 null이 아니고 기존에는 저장되었던 적이 없기에 value는 null로 id와 다르게 되어 isUnsaved가 false가 되는 것이다. 그렇기에 isTransient는 false가 되어 실제로는 Transient 상태인 객체이나 Detached 상태인 객체로 인식되어 오류가 발생한다.

 

// org.hibernate.engine.spi.IdentifierValue
/**
* Assume nothing.
*/
public static final IdentifierValue UNDEFINED = new IdentifierValue() {
@Override
public @Nullable Boolean isUnsaved(Object id) {
    LOG.trace( "ID unsaved-value strategy UNDEFINED" );
    return null;
}

@Override
public @Nullable Serializable getDefaultValue(Object currentValue) {
    return null;
}

@Override
public String toString() {
    return "UNDEFINED";
}
};

반대로 @GeneratedValue 어노테이션이 없을 때에는 식별자가 UNDEFINED가 주입된다. 그렇기에 isUnsaved 로직 자체가 null만을 반환하게 된다. 따라서 isTransient 메서드 또한 FALSE가 아닌 다른 값이 반환되어 객체가 Transient 상태로 인식된다.

@GeneratedValue 있음

(ID 수동 지정)

Entity ID != null

isNew() → false

merge() 호출

IdentifierValue → Default (Not matched)

Hibernate: Detached 상태로 인식

DB에 해당 ID 없음 → 예외 발생

 

@GeneratedValue 없음

(ID 수동 지정)

Entity ID != null

isNew() → false

merge() 호출

IdentifierValue → UNDEFINED (Skip)

Hibernate: Transient 상태로 인식

DB에 insert 수행 → 정상 처리

3. 결론

Hibernate의 StaleObjectStateException은 단순히 트랜잭션 충돌로만 발생하는 예외가 아니다. 내부적으로 엔티티의 상태를 Detached로 잘못 판단했을 때도 발생할 수 있다.

 

이번 케이스처럼 @GeneratedValue가 붙어 있는 엔티티의 ID 값을 개발자가 수동으로 할당하면, Hibernate는 해당 객체를 Transient가 아닌 Detached 상태로 인식하게 된다. 그리고 이 Detached 엔티티를 merge() 하려다 DB에 해당 ID를 가진 레코드가 존재하지 않으면, 마치 "다른 트랜잭션에서 삭제되었다"라고 판단하여 StaleObjectStateException을 던지게 된다.

 

반면, @GeneratedValue가 붙어있지 않은 경우에는 ID 값이 있어도 Hibernate는 이를 명확하게 Transient 상태로 인식할 수 있다. 이때는 merge 시에도 예외 없이 insert로 처리되기 때문에 문제가 발생하지 않는다.

 

즉, 핵심은 Hibernate가 엔티티의 생명주기를 어떻게 판단하느냐에 있다.

자동 생성 전략을 사용한다면 ID 값을 수동으로 주입하지 않아야 한다.

 

 

 

[ 참고 자료 ]

  1. https://ji-hwaja.tistory.com/50
  2. https://jy2694.tistory.com/20

 

반응형
반응형

얼마 전 개인 저장소를 편하게 사용하기 위해 저렴한 도메인을 구매하여 사용하고 있었다. 이번에 처음 도메인을 구매하였고 초기에 연결하여 잘 사용하고 있었는데 어느 날 접속하려고 보니 아래와 같이 접속되지 않았다.

 

개인 저장소를 OCI(Oracle Cloud Infrastructure)에 올려서 사용하고 있었기에 처음에 OCI 인스턴스에 문제가 생긴 줄 알았으나 접속해서 확인해 보니 인스턴스는 아무런 문제 없이 잘 돌아가고 있더랬다.

 

혹시 FireFox를 사용하고 있는데 다른 브라우저로 접속하면 뭐라도 보일까 하여 유일하게 다른 브라우저인 Microsoft Edge로 접속해 보니 아래 사진과 같은 화면이 보였다.

 

 

도메인이 정지됐단다. 사유를 읽어보니 연락처 인증을 마치지 않아서 잠시 사용이 정지된 것 같은데 이게 무슨 일인가 하고 찾아보았다.

 

찾아보니 국제 인터넷 주소 관리 기구란 곳이 ICANN(Internet Corporation for Assigned Names and Numbers)이라는 곳인데 도메인 등록 업체와의 인증 계약 상 도메인 등록 시 작성한 소유자의 연락처가 유효한지 인증을 반드시 해야 한다고 한다.

 

부리나케 도메인 구입 시 작성했던 이메일 계정의 메일함을 찾아보니 아래와 같은 메일이 한참 전에 와있었다.

 

 

다행히 인증기한이 지나 도메인 사용이 중지된 다음에 인증 작업을 하여도 중지가 풀린다고 한다.

메일에 작성된 인증 웹사이트로 접속하여 인증 작업을 완료했다.

 

 

그런데 인증을 해도 해당 도메인으로 접속할 수 없었는데 알고 보니 DNS 전파 속도가 있어서 그렇다고 한다.

ICANN에서 인증을 해서 도메인 정지를 풀어준다고 해도 정지가 풀린 신호가 내가 사용하는 DNS 서버까지 도달하여야 실제로 내가 접근할 수 있게 되는 것이라고 한다.

들어보니 DNS 전파가 하루 이상 걸릴 수도 있다고 하는데 메일을 똑바로 확인하지 않은 탓이니 겸허히 받아들이고 기다리는 수밖에 없다.

 

p.s. 인증 후 1시간만에 도메인 접속이 정상화되었다.

 

반응형
반응형

필자가 최근 개발 외주를 진행하며 발생했던 요구사항을 공유하고자 한다.

 

요구 사항

서버의 구성은 Proxy 서버를 앞에 두고 그 뒤에 여러 개의 백엔드 서버가 붙어서 동작한다. Proxy 서버는 벡엔드 서버를 별칭으로 가지고 있어 사용자가 별칭을 이용한 접속 요청에 따라 라우팅하여 백엔드 서버로 접속시키는 역할을 한다. 이러한 구조는 기본적으로 각각 백엔드 서버가 완전히 독립되어있고 백엔드 서버 간 통신이 되지 않는다. 그렇기 때문에 백엔드 서버 간 데이터 공유가 필요할 경우 플러그인으로 개발하여 해결하여야 한다.

 

기존에는 Redis Pub/Sub 기능을 사용하여 이를 자주 해결했었다. Proxy 서버를 통하는 Plugin Messaging이라는 기능이 있지만 이 기능은 메시지를 수신하여야 하는 서버에 사용자가 접속해 있지 않다면 메시지를 수신할 수 없는 치명적인 문제가 있기에 Redis Pub/Sub를 사용했다.

 

하지만 이번에는 MongoDB와 MySQL을 이미 사용하고 있는 서버 구성이였고 Redis를 추가적으로 도입하기에는 모니터링 포인트가 많아져 유지보수가 번거로울 것이라 생각하여 도입을 하지 않으려 하였다. 그렇기에 MongoDB의 Change Stream을 사용하여 백엔드 서버 간 데이터 실시간 공유 기능을 구현해보고자 한다.

 

MongoDB의 Change Stream은 Change Data Capture(CDC) 아키텍처를 구현하는 한 방법이다. 이 CDC 아키텍처는 Change Stream 뿐만 아니라 Apache Kafka, RabbitMQ와 같은 다양한 메시지 브로커로 이벤트를 전송하여 후 처리를 할 수도 있다. 다만, 이번 요구사항에서는 추가적인 소프트웨어 도입 없이 진행해야 하므로 Change Stream을 선택하여 구현하였다.

 

구현

MongoDB의 Replica Set

 

Change Data Capture(CDC) 아키텍처를 사용하려면 Standalone 구성으로는 사용할 수 없다. CDC 아키텍처는 MongoDB에서 데이터 변경이 발생했을 때 복제본 세트로 구성된 다른 노드에도 해당 변경을 반영할 때 생기는 operation log를 감시하며 이벤트가 발생하는 것이기 때문에 Replica Set(복제본 세트) 구성이 필수적이다. MongoDB의 ReplicaSet 설정에 대한 내용은 다음 글에서 확인할 수 있다.

 

MongoDB 구성

필자는 위 사진과 같이 한 개의 노드는 호스트에 직접, 2개의 노드는 Docker에서 실행하였다. 각 노드는 27017, 27018, 27019 포트에서 실행되도록 하였고 명령어로 초기화를 진행하여 27017 포트에서 실행되는 노드가 Primary node 로 실행될 수 있도록 하였다.

 

프로젝트 구성

 

프로젝트에서 mongodb를 사용하기 위해 driver 의존성을 build.gradle 파일에 추가한다. 필자가 진행할 때에는 5.5.1버전이 최신 버전이여서 아래와 같이 구성하였다.

dependencies {
  	...
    implementation 'org.mongodb:mongodb-driver-sync:5.5.1'
    ...
}

 

우선 MongoDB에 접속할 수 있는 URL을 만들어보자.

record HostSet(String host, int port){}

private String getConnectionString(List<HostSet> hostSet,
	String username, String password, String replicaSet){
      StringBuilder connectionString = new StringBuilder("mongodb://");
      
      //인증 정보가 기입된 경우 url에도 인증 정보 명시
      if(username != null && password != null){
          connectionString.append(username).append(":").append(password).append("@");
      }
      
      //replicaSet을 구성하는 멤버들의 접속 정보를 모두 명시
      for(HostSet host : hostSet){
	      connectionString.append(host.host()).append(":").append(host.port()).append(",");
      }
      connectionString.deleteCharAt(connectionString.length() - 1);
      
      //replicaSet을 명시
      connectionString.append("/?replicaSet=").append(replicaSet);
      
      return connectionString.toString();
  }

 

이렇게 여러 개의 접속 정보를 동시에 받을 수 있게 하는 건 ReplicaSet 구성이 되어있을 때 특정 노드로만 접속을 시도한다면 고가용성의 이점을 누릴 수 없다. 여러 노드의 접속 정보를 명시하게 되면 그 접속 정보들 중 접속이 가능한 한 노드로 자동으로 접속이 되므로 위와 같이 ReplicaSet을 구성하는 멤버들의 접속 정보 모두 URL에 명시하도록 한다.

// ReplicaSet 멤버 접속 정보
List<HostSet> hostSet = List.of(
	new HostSet("123.456.789.321", 27017),
	new HostSet("123.456.789.321", 27018),
	new HostSet("123.456.789.321", 27019)
);

// MongoDB 클라이언트 객체 생성
MongoClient client = client = MongoClients.create(
	getConnectionString(hostSet, "user", "1234", "rs0")
);

//Change Stream으로 감시할 Collection 객체 획득
MongoDatabase database = client.getDatabase("test-database");
MongoCollection<Document> collection = database.getCollection("test-collection");

//Change Stream 감시 시작
for(ChangeStreamDocument<Document> document : collection.watch()){
	BsonDocument documentKey = document.getDocumentKey();
  String objectId = documentKey.getObjectId("_id").toString();
	switch(document.getOperationType()) {
		case INSERT, UPDATE -> {
			System.out.println(objectId + " object 가 삽입 혹은 변경되었습니다.");
		}
		case DELETE -> {
			System.out.println(objectId + " object 가 삭제되었습니다.");
		}
	}
}

이제 위와 같이 MongoDB 클라이언트 객체를 만들고 감시하고자 하는 Collection 객체를 얻어 감시할 수 있다. collection.watch() 메소드는 ChangeStreamIterable<Document> 를 반환하는데 새로운 변화가 감지가 되면 해당 Iterable에 새로운 요소가 추가되고 for문이 작동한다. 만약 새로운 요소가 추가되지 않는다면 새로운 요소가 추가될 때까지 Blocking 상태가 된다. 한번 실제로 테스트를 진행해보면

BsonObjectId{value=6891a6f72b6273f61e6ddeaa} object 가 삽입 혹은 변경되었습니다.
BsonObjectId{value=6891a6f72b6273f61e6ddeaa} object 가 삭제되었습니다.

 

이렇게 잘 감지가 되는 것을 확인할 수 있다. 단, 이때 주의해야할 점이 하나 있다. 변경이 발생한 Document 내부의 값을 가져올 때 주의 하여야 하는데, INSERT나 UPDATE 같이 변경 후에도 Collection 내에 존재하면 document.getFullDocument()로 변경이 발생한 Document를 가져올 수 있다. 하지만 Operation Type이 DELETE인 경우 변경이 일어난 시점 이후를 반환하는 것이므로 document.getFullDocument()이 null로 반환되게 된다.

 

만약 삭제 직전의 Full Document를 얻고 싶다면 Pre-Image 기능을 사용하면 가능하다. Pre-Image 기능은 Document가 변경 혹은 삭제되기 전의 상태를 저장하는 기능이다. 물론 해당 기능은 변경 이후의 상태와 변경 이전의 상태 모두를 저장하고 있어야 하기 때문에 추가적인 저장 공간이 필요하고 추가적인 I/O가 발생하는 작업이므로 성능이 소폭 하락할 수도 있다. 이 기능을 사용하기 위해서는 mongodb에서 특정 Collection에 대해 기능을 활성화해야 한다.

 

mongo shell에서 Pre-Image를 활성화하고자 하는 Collection이 있는 Database로 이동한다.

use test-database

그리고 아래 명령어를 입력하여 Pre-Image 기능을 활성화한다.

db.runCommand({
  collMod: "test-collection",
  changeStreamPreAndPostImages: { enabled: true }
})

 

 

활성화 한 후 collection.watch() 메소드 뒤에 .fullDocumentBeforeChange(FullDocumentBeforeChange.WHEN_AVAILABLE) 옵션을 추가한다.

그리고 document.getFullDocumentBeforeChange() 메소드로 변경 이전의 Full Document에 접근할 수 있다.

소스코드는 아래와 같이 수정하였다.

// ReplicaSet 멤버 접속 정보
List<HostSet> hostSet = List.of(
	new HostSet("123.456.789.321", 27017),
	new HostSet("123.456.789.321", 27018),
	new HostSet("123.456.789.321", 27019)
);

// MongoDB 클라이언트 객체 생성
MongoClient client = client = MongoClients.create(
	getConnectionString(hostSet, "user", "1234", "rs0")
);

//Change Stream으로 감시할 Collection 객체 획득
MongoDatabase database = client.getDatabase("test-database");
MongoCollection<Document> collection = database.getCollection("test-collection");

//Change Stream 감시 시작
for(ChangeStreamDocument<Document> document : collection.watch()
		.fullDocumentBeforeChange(FullDocumentBeforeChange.WHEN_AVAILABLE)){
	// 변경 후 Full Document
	Document fullDocument = document.getFullDocument();
	// 변경 전 Full Document
  Document fullDocumentBeforeChange = document.getFullDocumentBeforeChange();
  
	switch(document.getOperationType()) {
		case INSERT, UPDATE -> {
      String id = fullDocument.get("_id").toString();
			System.out.println(id + " object 가 삽입 혹은 변경되었습니다.");
		}
		case DELETE -> {
      String id = fullDocumentBeforeChange.get("_id").toString();
			System.out.println(id + " object 가 삭제되었습니다.");
		}
	}
}

이때 full document before change는 변경 이전 document이므로 insert 시에는 당연히 getFullDocumentBeforeChange() 가 null로 반환된다.

 

MongoDB의 Change Stream을 알기 전에는 다양한 고민을 했었다. 별도의 Socket을 띄워서 통신을 할까, MySQL의 Trigger 기능을 사용해서 변경을 감지할까 등 여러 고민을 했었는데 이번 요구사항 같은 경우에는 실시간으로 많은 데이터들이 부하 없이 자주 변경되며 모든 서버에 반영될 수 있어야 했기 때문에 구현이 번거로운 Socket이나 부하가 걸릴 수 있는 MySQL의 Trigger 기능을 사용하지 않고 MongoDB의 Change Stream을 사용하였다. 물론 위 소스코드를 바로 적용할 수는 없었고 메인 스레드가 Non-Blocking 상태로 유지되어야 하기 때문에 collection.watch() 메소드를 메인 스레드에서 실행시킬 수 없고 별도의 스레드에서 감시를 하는 방식으로 비동기 처리하여 적용하였다.

반응형
반응형

대용량 데이터를 저장하고 처리할 때 CLOBBLOB은 매우 유용한 데이터 타입이다. 각각의 특징과 용도를 자세히 살펴보면, 어떤 상황에서 어느 타입을 선택해야 할지 명확해진다.

발음 참고: 흔히 "클롭, 블롭"으로 읽히지만, Oracle 공식 문서에 따르면 올바른 발음은 '씨랍'과 '비랍'이다.

https://docs.oracle.com/cd/E11882_01/appdev.112/e18294/glossary.htm

CLOB의 특징

CLOB (Character Large Object)는 대량의 텍스트 데이터를 저장할 수 있는 데이터 타입이다.

  • 저장 용량: 일반적으로 최대 4GB까지 저장 가능하며, 데이터베이스 시스템에 따라 다를 수 있다.
  • 용도: 긴 문서, HTML/XML 문서, 다양한 언어(UTF-8, UTF-16 등)로 된 콘텐츠 등 대용량 텍스트 데이터 관리에 최적화되어 있다.
  • 기능: 텍스트 검색과 조작이 용이해 문서 버전 관리나 콘텐츠 관리 시스템(CMS)에서 효과적으로 활용된다.

BLOB의 특징

BLOB (Binary Large Object)는 이미지, 오디오, 비디오 등 이진 데이터를 저장하는 데 적합한 데이터 타입이다.

  • 저장 용량: 텍스트 데이터와 마찬가지로 대략 최대 4GB까지 저장 가능하며, 데이터베이스 시스템에 따라 다를 수 있다.
  • 용도: 멀티미디어 파일이나 기타 이진 데이터(실제 파일의 원본 형식 그대로)가 필요한 상황에서 주로 사용된다.
  • 기능: 원본 이진 데이터를 그대로 저장하여, 후에 파일의 형식이나 내용에 영향을 주지 않고 활용할 수 있다.

CLOB과 BLOB 사용 사례

두 데이터 타입은 사용 목적에 따라 적용 분야가 명확히 구분된다.

BLOB 사용에 적합한 경우:

  • 멀티미디어 콘텐츠: 웹사이트의 사용자 업로드 이미지, 동영상, 오디오 파일 등 미디어 파일 저장
  • 원본 데이터 보존: 파일의 형식을 변형 없이 그대로 저장해야 하는 경우
  • 통합 백업과 복구: 데이터베이스 수준에서 파일과 메타데이터를 함께 백업하여 일관성 유지

 

CLOB 사용에 적합한 경우:

  • 문서 저장 및 관리: 긴 텍스트 문서, 보고서, 리뷰나 게시글 등 대용량 텍스트 저장
  • 전문 검색 기능: 텍스트 내에서 특정 단어나 구문 검색
  • 콘텐츠 관리 시스템: HTML이나 XML 기반 문서 파싱 및 버전 관리가 필요한 콘텐츠 처리

 

일반적으로 대용량 파일은 파일 시스템에 저장하고, 해당 파일의 메타데이터만 데이터베이스에 기록하는 방식이 성능과 관리 측면에서 더 효율적일 수 있다.

 

데이터베이스 설계 시에는 저장할 데이터의 유형, 접근 방식, 백업 및 복구 필요성 등을 종합적으로 고려해야 한다.

특히 대규모 파일 저장의 경우, 단순 데이터베이스 저장보다는 파일 시스템과의 하이브리드 방식을 고려하여 성능 및 운영 비용 면에서 최적의 방안을 마련하는 것이 중요하다.

반응형
반응형

웹 애플리케이션에서 클라이언트 측 데이터 저장 방식으로 Session Storage, Local Storage, IndexedDB가 있다. 이 저장소들은 각각 고유한 목적과 특성을 가지고 있어, 다양한 웹 개발 시나리오에 유연하게 대응할 수 있다. 


1. Session Storage

Session Storage는 브라우저 세션 동안에만 임시로 데이터를 저장하는 클라이언트 측 저장소이다.

사용자가 브라우저 탭을 닫거나 새로고침 하면 저장된 데이터가 즉시 삭제된다.

이는 단기적이고 일시적인 데이터 관리에 최적의 설루션이다.

 

장점

- 세션 고유의 데이터 관리

- 직관적이고 간편한 데이터 조작

 

단점

- 일시적인 데이터 저장

// 데이터 저장
sessionStorage.setItem("username", "JohnDoe");

// 데이터 읽기
let username = sessionStorage.getItem("username");
console.log(username);  // JohnDoe

// 데이터 삭제
sessionStorage.removeItem("username");

// 모든 데이터 삭제
sessionStorage.clear();

2. Local Storage

Local Storage는 영구적으로 데이터를 브라우저에 저장하는 클라이언트 측 저장소이다.

사용자가 브라우저를 종료하거나 시스템을 재시작해도 데이터가 그대로 유지된다. 

지속적이고 반영구적인 데이터 저장에 적합하다. 단, 대부분의 브라우저에서는 도메인 별 5MB 용량 제한을 두고 있다.

 

장점

- 영구적인 데이터 보존

- 간편한 데이터 관리

 

단점

- 제한된 저장 공간

- 동기식 처리로 인한 성능 제약

 

// 데이터 저장
localStorage.setItem("userEmail", "john.doe@example.com");

// 데이터 읽기
let userEmail = localStorage.getItem("userEmail");
console.log(userEmail);  // john.doe@example.com

// 데이터 삭제
localStorage.removeItem("userEmail");

// 모든 데이터 삭제
localStorage.clear();

3. IndexedDB

IndexedDB는 대규모 구조화된 데이터를 클라이언트 측에서 저장하고 관리할 수 있는 데이터베이스 시스템이다. Local storage와 동일하게 브라우저와 컴퓨터가 종료되어도 데이터가 유지된다. 비동기식 API와 트랜잭션 지원으로 복잡한 데이터 처리에 탁월하다. 제한 용량은 별도로 명시되어 있지는 않고 디스크의 일정 비율을 사용하는 방식으로 용량이 제한된다.

 

장점

- 대용량 데이터 저장

- 비동기 처리

- 유연한 데이터 구조

 

단점

- 제한적인 브라우저 지원

// 데이터베이스 열기
let db;
let request = indexedDB.open("MyDatabase", 1);

request.onerror = function(event) {
  console.log("Database error: " + event.target.errorCode);
};

request.onsuccess = function(event) {
  db = event.target.result;
  console.log("Database opened successfully");
};

// 데이터 저장하기
let transaction = db.transaction(["users"], "readwrite");
let objectStore = transaction.objectStore("users");

let user = { id: 1, name: "John Doe", email: "john.doe@example.com" };
let requestPut = objectStore.put(user);

requestPut.onsuccess = function() {
  console.log("User data saved successfully!");
};

requestPut.onerror = function() {
  console.log("Error saving user data.");
};

 


각 저장소는 특정 사용 사례와 요구 사항에 따라 선별적으로 활용된다. Session Storage는 일시적 데이터, Local Storage는 영구적 데이터, IndexedDB는 대용량 및 복잡한 데이터 처리에 적합하다.

반응형
반응형

비트(Bit)와 바이트(Byte)

컴퓨터는 2진수를 사용한다. 전기를 이용하여 표현할 수 있는 건 전기가 흐르거나(1) 전기가 흐르지 않거나(0)로 2가지뿐이라는 것이다. 이 최소 단위를 비트(Bit)라고 부른다. 언뜻 보이기에는 2진수를 이용해 표현할 수 있는 것이 많아 보이진 않지만 이 비트를 여러 개 조합하면 아주 많은 것들을 표현할 수 있다. 이 비트를 4개만 합쳐도 0000, 0001, 0010, 0011, 0100, 0101, 0110… 1111까지 2^4개인 16개 표현이 가능해진다. 비트를 늘릴수록 2의 제곱수로 가짓수가 늘어나므로 부족하진 않은 셈이다. 하지만 우리는 컴퓨터를 사용하면서 비트라는 단위보다 더 많이 듣는 단위가 있다. 바로 바이트(Byte)이다.

바이트는 8개의 비트를 합친 크기이다(8 Bits = 1 Bytes). 이 단위를 1000 곱하면 1 KB(KiloBytes, 킬로바이트), 여기에 1000 곱하면 1 MB(MegaBytes, 메가바이트), 여기에 1000 곱하면 1 GB(GigaBytes, 기가바이트)가 되는 것이다.

이제 단위를 알아보았으니 컴퓨터 내부에서 우리가 보는 수와 문자가 어떻게 저장되는지 살펴보자.

 

수의 표현

수는 2진수로 표현된다. 10진수를 2진수로 변환해 보면 되는데 “10진수 10” = “2진수 1010”가 된다. 하지만 실제로 컴퓨터의 메모리에는 10이 1010으로 저장되는 것은 아니다. 그 이유는 이러한 방식으로 저장하게 된다면 발생하는 문제를 생각해 보자.

1. 부호의 표현

우리의 수는 양수만 있는 것이 아니다. 0도 있을 것이고 음수도 있을 것이다. 그렇다면 부호는 어떻게 표현해야 하는지 생각을 먼저 해봐야 할 텐데, 이 부분은 간단하게 해결된다. 숫자가 저장되는 공간의 비트 중 MSB(Most Significant Bit; 최상위 비트)를 부호 비트로 사용하는 것이다. 0은 양수(+)로 1은 음수(-)로 말이다. 예를 들어 32비트 공간에 양수 10을 저장한다면 아래와 같이 저장되는 것이다.

00000000 00000000 00000000 00001010

그렇다면 음수 10은 아래와 같이 저장될 것이다.

10000000 00000000 00000000 00001010

꽤 간단하다. 하지만 이것도 문제가 존재한다.

2. 0의 표현

음수와 양수는 표현 방법을 찾았다. 하지만 0을 생각해 보라. 부호가 있는 숫자가 아닌데 부호 비트를 사용하여 표현하면 양수 0과 음수 0, 두 개가 존재하게 된다. 이를 해결하기 위해 음수는 “2의 보수”를 사용한다.

이 방식을 사용해서 양수 1과 음수 1을 2진수로 표시하면 아래와 같다.

1 = 00000000 00000000 00000000 00000001
-1 = 11111111 11111111 11111111 11111111

최상위 비트는 0과 1로 부호 비트로 사용되고 있음을 확인할 수 있고 0도 양수 0 하나만 존재하게 된다.

3. 덧셈과 뺄셈

컴퓨터는 뺄셈을 할 수 없다. 우리는 계산기로 뺄셈도 자유롭게 할 수 있는 게 이게 무슨 소리냐 하면, 실제로 컴퓨터에는 가산기(Adder) 회로만 있을 뿐 감산기(Subtractor)는 존재하지 않는다. 사실 감산기가 있지만 감산기 마저도 가산기로 만들기 때문에 뺄셈을 없다고 하는 것이다.

그렇다면 컴퓨터는 가산기만을 이용하여 뺄셈을 어떻게 하는 것일까. 0의 표현을 언급하면서 함께 언급하였던 음수는 2의 보수로 표현된다는 점은 이와도 연관이 있다. 1-1을 덧셈 표현으로 1+(-1)로 생각하여 연산하면 그만이기 때문이다. 한번 1과 -1을 2진수로 더해보자

    00000000 00000000 00000000 00000001
+   11111111 11111111 11111111 11111111
---------------------------------------
  1 00000000 00000000 00000000 00000000

1의 자리올림이 발생했으나 정수는 32비트로 표현되니 자리올림은 무시하고

00000000 00000000 00000000 00000000만 남게 되어 0이 된다.

 

문자의 표현

문자는 단순한 숫자로는 표현이 어렵다. 그렇기 때문에 컴퓨터를 사용할 때는 서로가 어떤 숫자가 어떤 문자를 말하는 것인지 약속을 해놓게 된다. 이것이 Character Set이다. Character Set은 여러 가지가 있는데 그중에 대표적인 것들만 말해보고자 한다.

1. ASCII(American Standard Code for Information Interchange)

1963년 미국에서 정보 교환을 목적으로 표준화한 7비트 부호 체계이다.

Wikipedia; ASCII

위와 같이 7개의 비트 중 상위 3비트, 하위 4비트로 나누어 문자를 표현할 수 있다. 다만 이는 일부 특수기호, 숫자와 영문자만을 표현할 수 있다는 한계가 있으나 오늘날 문자 인코딩의 근간이라고 할 수 있다.

2. Unicode

아스키코드는 7비트 만을 활용하는 부호 체계로 일부 특수기호, 숫자와 영문자만 표현할 수 있다는 한계로 다른 언어를 표현할 수 있는 부호 체계의 필요성이 생겼다. 그래서 생긴 표준이 Unicode이다. Unicode는 1~4바이트의 크기로 부호 체계를 갖추고 있다. 하지만, 고정 길이가 아니라 1~4바이트라는 가변 길이를 가지고 있는 특징으로 다양한 인코딩 방식을 사용하여야 한다.

그중 가장 많이 사용되는 UTF-8의 인코딩 방식을 알아보자.

우선 컴퓨터에게 특정 글자가 몇 바이트인지 알 수 있게 해주는 방법이 가장 중요한데 그 방법은 아래와 같다.

  1. 0xxxxxxx → 1바이트 (ASCII와 동일)
  2. 110xxxxx → 2바이트 문자
  3. 1110xxxx → 3바이트 문자
  4. 11110xxx → 4바이트 문자

예를 들어, 한글 '가' (U+AC00)는 1110 1010 1000 0000 형태의 3바이트 문자로 인코딩 된다.

 

그 외에도 한국어는 EUC-KR, CP949(MS949) 인코딩도 사용하는 경우가 많다.

반응형
반응형

Docker는 기본적으로 Server-Client 구조로 되어있기 때문에 원격 사용이 가능하다.

우리가 흔히 사용하는 Docker CLI는 Client이고 모든 기능은 Docker Daemon이라는 Server를 호출하여 사용하는 구조이다. 이를 모두 묶어서 Docker Engine이라고 부른다.

(Docker Desktop은 Docker CLI를 포함하고 있으며 GUI 환경으로 Docker CLI를 간편하게 사용하기 위한 도구이다.)

 

위 이미지에서 보는 것과 같이 Docker daemon은 REST API를 제공하고 이를 통해 사용이 가능한 구조이다. 그렇기 때문에 설정만 한다면 원격에서 REST API를 사용하여 Docker daemon에 접근할 수 있다.

Daemon 확인 후 서비스 수정하기

docker 원격 사용을 위해 설정을 바꿔 주어야 하기 때문에 서비스 파일 위치부터 확인한다.

$ systemctl status docker.service

위처럼 /lib/systemd/system/docker.service 위치에 서비스 파일이 있는 것을 확인하였다.

 

 

해당 파일을 vi나 nano로 열어서 ExecStart 필드를 찾아 아래와 같이 수정한다.

(수정 권한이 필요하기 때문에 sudo로 권한을 취득하여 열어 수정해야 한다.)

$ sudo vi /lib/systemd/system/docker.service

0.0.0.0:2375라고 작성하였으므로 2375 포트를 통해 Daemon에 접근할 수 있도록 한다는 뜻이다.

 

 

저장 후 daemon을 재시작한다.

$ sudo systemctl daemon-reload
$ sudo systemctl restart docker.service

daemon 상태를 확인해 보면 정상적으로 잘 실행된 모습을 볼 수 있다.

 

 

Docker CLI를 통한 원격 제어

아래와 같이 -H 옵션으로 호스트와 포트를 입력하면 원격에서 Docker에 접근할 수 있다.

$ docker -H {host}:{port} {명령어}

아래는 원격으로 Docker에 있는 이미지 목록을 확인이 가능한 모습이다.

 

 

REST API를 통한 제어

Docker engine에 대한 api 명세는 아래 웹페이지에 상세하게 기술되어 있다.

https://docs.docker.com/reference/api/engine/version/v1.47/

 

/containers/json?all=true 로 현재 docker에 생성되어 있는 모든 컨테이너를 조회해 본 결과이다.

조회 말고도 컨테이너 생성, 정보 갱신, 삭제, 이미지 풀, 삭제, 빌드까지… 로컬에서 사용할 수 있는 기능이라면 모두 REST API를 통해 사용이 가능하다.

 

반응형
반응형

프로그래밍 언어론에서는 변수의 Scope(이하 스코프)에 대한 내용을 다룬다. 스코프는 변수에 접근할 수 있는 범위를 말한다.

 

1. Global Scope & Local Scope

전역 스코프(Global Scope)

가장 바깥쪽에 있는 레벨의 스코프를 전역 스코프라고 한다. 이 전역 스코프 레벨에 선언되는 변수를 전역 변수라고 한다.

 

지역 스코프(Local Scope)

전역 스코프 내의 블럭을 모두 지역 스코프라고 한다. 또한 이 스코프 내에 선언된 변수를 지역 변수라고 한다.

 

만약 이러한 스코프가 중첩되어 있고 상위 스코프에 선언된 변수와 동일한 이름으로 하위 스코프에 선언된 경우 가장 인접한 스코프에 선언된 변수가 우선 순위를 갖는다.

//전역 스코프
const a = 100; //전역 변수

function test(){
	//지역 스코프
    const a = 10; //지역 변수
    console.log(a); //인접한 스코프의 변수인 (a=10)을 참조
}

 

2. Block Level Scope & Function Level Scope

블럭 레벨 스코프(Block Level Scope)

블럭 레벨 스코프는 변수가 선언된 블럭 내에서만 접근이 가능한 스코프이다. C언어 기반 언어의 변수는 모두 블럭 레벨 스코프를 따른다.

//Java 언어에서 블럭 레벨 스코프
public static void main(String[] args){
    int a = 10;
    if(a == 0){
    	int b = a+10;
    }
    System.out.println(b); //해당 스코프에 b 변수 없음. 오류.
}

함수 레벨 스코프(Function Level Scope)

함수 레벨 스코프는 변수가 선언된 함수 내에서 접근이 가능한 스코프이다. 함수 지역 스코프 내에 중첩된 스코프 어디에 선언되어 있든 같은 함수라면 접근할 수 있는 것이다.

//Javascript에서 함수 레벨 스코프를 가지는 var 변수
function test(){
    var a = 10;
    if(a === 0) {
    	var b = a + 10;
    }
    console.log(b); //20 출력
}

 

+ 자바스크립트의 변수 선언 예약어 let, const는 블럭 레벨 스코프, var은 함수 레벨 스코프를 따른다.

 

 

3. 상위 스코프 탐색

렉시컬 스코프(Lexical Scope)

상위 스코프가 선언 위치에 따라 결정되는 것을 렉시컬 스코프라 한다.

const x = 100;

function test() {
  var x = 10;
  testPrint(); // testPrint 내부의 x 참조는 선언 위치에 따라 전역변수 x를 참조
}

function testPrint() {
  console.log(x);
}

test(); //100 출력
testPrint(); //100 출력

 

 

동적 스코프(Dynamic Scope)

상위 스코프가 호출 위치에 따라 결정되는 것을 동적 스코프라 한다. 우선 자바스크립트는 동적 스코프를 지원하지 않고 렉시컬 스코프만 지원한다. 아래는 자바스크립트 언어로 작성되어 있지만, 동적 스코프일 경우를 가정한 실행 결과이다.

const x = 100;

function test() {
  var x = 10;
  testPrint(); // testPrint 내부의 x 참조는 호출 위치에 따라 지역변수 x를 참조
}

function testPrint() {
  console.log(x);
}

test(); //10 출력
testPrint(); //100 출력

 

반응형

+ Recent posts