go closure 범위는 최소화해야 한다. (feat. entgo 트랜잭션 사용하기)

dasd412·2024년 12월 29일
0

golang

목록 보기
4/5

entgo 트랜잭션

entgoGo 진영에서 사용되는 ORM 중 하나다. 주요 기능 중 하나로 트랜잭션도 제공한다.

다음은 공식 예제 코드다.

func WithTx(ctx context.Context, client *ent.Client, fn func(tx *ent.Tx) error) error {
    tx, err := client.Tx(ctx)
    
    if err != nil {
        return err
    }
    
    defer func() {
        if v := recover(); v != nil {
            tx.Rollback()
            panic(v)
        }
    }()
    
    if err := fn(tx); err != nil {
        if rerr := tx.Rollback(); rerr != nil {
            err = fmt.Errorf("%w: rolling back transaction: %v", err, rerr)
        }
        return err
    }
    
    if err := tx.Commit(); err != nil {
        return fmt.Errorf("committing transaction: %w", err)
    }
    
    return nil
}

WithTx 함수는 파라미터로 fn을 받는데, 트랜잭션 내부에서 실행할 작업을 정의한 함수다.
defer를 사용하여 함수 종료 시 panic이 발생하면 트랜잭션을 롤백하고 상위 호출자에게 전달한다.
뿐만 아니라 fn을 실행했을 때 에러가 발생하면 트랜잭션을 롤백한다.
작업이 성공하면 트랜잭션을 커밋한다.

함수 안에 함수를 넣어서 함수 전후 동작을 컨트롤하는 것이 파이썬데코레이터SpringAOP와 비슷한 느낌이다.

위 코드를 사용하는 코드는 다음과 같다. 공식 예제긴 한데, 바로 이해가 잘 안 갈 것이다.
entgo는 공식 문서가 좀 불친절 한 것 같다.

func Do(ctx context.Context, client *ent.Client) {
    // WithTx helper.
    if err := WithTx(ctx, client, func(tx *ent.Tx) error {
        return Gen(ctx, tx.Client())
    }); err != nil {
        log.Fatal(err)
    }
}

출처 : https://entgo.io/docs/transactions


closure를 사용한 이유

WithTx 를 사용하려면, 다음처럼 작성할 수 있다.

	err := tx.WithTx(
		ctx,
		client,
		func(tx *ent.Tx) error {
			session, err := s.sessionRepository.Create(
				ctx,
				tx.Client(),
				input,
			)

			if err != nil {
				return err
			}

			return nil
		})

위 코드에서 나는 다음 두 가지를 하고 싶었다.
1. session이라는 변수를 WithTx라는 코드가 끝난 이후에도 사용해야 한다.
2. WithTx의 리턴 값 형식을 바꾸고 싶지 않다. (다른 곳에서도 이미 사용하고 있는 공통 코드이기 때문)

그래서 내부 함수에서 외부 함수의 변수를 참조하는 closure를 사용하기로 했다.

다음은 그 코드다.

 	var createdSession *ent.Session
    
	err := tx.WithTx(
		ctx,
		client,
		func(tx *ent.Tx) error {
			session, err := s.sessionRepository.Create(
				ctx,
				tx.Client(),
				input,
			)

			if err != nil {
				return err
			}
            
            createdSession = session//익명 함수에서 외부 변수를 사용함.

			return nil
		})

closure 범위를 최소화해야 하는 이유

하지만 closure는 주의해야 할 요소가 있다.

  1. closure는 외부 변수에 접근하고 변경할 수 있기 때문에 동시성 문제가 발생할 수 있다.
  2. closure는 스코프를 벗어나는 것이다. 전역 변수 문제는 스코프를 벗어나는 대표적인 문제다. closure역시 잘못하면 전역 변수처럼 디버깅이 어려워질 수 있다. 어떤 문제가 발생해도 데이터의 추적이 끔찍해서 원인을 찾아내기 어렵다.
  3. closure에서 사용하는 외부 변수는 closure가 살아있는 동안 계속 메모리에 존재하므로 메모리 누수가 발생할 수 있다.

코드

다음은 최종 코드다.

  1. createdSession이라는 closure의 외부 변수 범위를 메서드 내로 제한하여 사이드 이펙트를 줄였다. 메서드 내에서 선언된 지역 변수이므로 메서드 실행이 끝나면 가비지 컬렉션의 대상이 될 것이다.
  2. createdSession은 사용자 id마다 다르다. 따라서 각 사용자 요청마다 만들어지는 것도 다르다. 따라서 사용자 별로 고립된 상태가 보장되서 동시성 문제가 발생하지 않는다.
func (s *somethingService) createSession(ctx context.Context, client *ent.Client, input ent.CreateSessionInput) (*ent.Session, error) {
	// 익명 함수에서 사용하는 외부 변수.
	// 생성과 사용 영역을 전역이 아닌, 함수 내부로 좁히기 위해 함수를 따로 만듬.
    var createdSession *ent.Session
    
	err := tx.WithTx(
		ctx,
		client,
		func(tx *ent.Tx) error {
			session, err := s.sessionRepository.Create(
				ctx,
				tx.Client(),
				input,
			)

			if err != nil {
				return err
			}
            
            createdSession = session//익명 함수에서 외부 변수를 사용함.

			return nil
		})
        
  	 return createdSession, err
}

profile
시스템 아키텍쳐 설계에 관심이 많은 백엔드 개발자입니다. (Go/Python/MSA/graphql/Spring)

0개의 댓글