SpringBoot (5)

Spring Boot에서 효율적인 트랜잭션 관리 방법

Spring Boot 애플리케이션에서 트랜잭션 관리는 데이터 일관성을 유지하는 핵심 요소입니다. 올바르게 설정하지 않으면 데이터 손실이나 불일치 문제가 발생할 수 있습니다. 이번 글에서는 Spring의 트랜잭션 관리 전략효율적인 사용 방법을 정리해보겠습니다.


1.  트랜잭션 관리 방식

Spring Boot에서 트랜잭션을 관리하는 방법은 크게 두 가지로 나뉩니다.

 

1) 선언적 트랜잭션 관리 (@Transactional)

가장 일반적인 방식으로, 메서드나 클래스에 @Transactional 어노테이션을 사용하여 트랜잭션을 관리합니다.

  • 해당 메서드 실행 중 예외가 발생하면 자동으로 롤백
  • 커밋 또는 롤백을 신경 쓰지 않아도 되므로 유지보수가 용이

 

2) 프로그래밍 방식 트랜잭션 관리 (TransactionTemplate, PlatformTransactionManager)

트랜잭션을 직접 제어해야 할 때 사용합니다.

@Service
public class PaymentService {
    private final TransactionTemplate transactionTemplate;
    private final PaymentRepository paymentRepository;
    public PaymentService(PlatformTransactionManager transactionManager, PaymentRepository paymentRepository) {
    	this.transactionTemplate = new TransactionTemplate(transactionManager);
        this.paymentRepository = paymentRepository;
    }
    public void processPayment(Payment payment) {
    	transactionTemplate.executeWithoutResult(status -> {
        	paymentRepository.save(payment); // 추가적인 비즈니스 로직 수행
    	});
    }
}
  • 세밀한 트랜잭션 제어가 필요할 때 유용
  • 코드가 길어지고 복잡해질 수 있음

2. @Transactional 사용 시 주의할 점

1) 프록시 기반이므로 같은 클래스 내에서 호출하면 동작하지 않음

Spring의 @Transactional은 기본적으로 프록시 방식을 사용하기 때문에, 같은 클래스 내에서 트랜잭션 메서드를 호출하면 적용되지 않습니다.

잘못된 예:

@Service
public class UserService {
    @Transactional
    public void createUser(User user) {
    	saveUser(user);
    }
    
    public void saveUser(User user) {
    	// 같은 클래스 내 호출 → 트랜잭션이 적용되지 않음
        userRepository.save(user);
    }
}

 

해결 방법:

  • 외부에서 호출하도록 설계하거나, self-invocation 문제를 해결하기 위해 AOP를 활용합니다.
  • @Transactional을 사용하는 메서드를 다른 빈(Bean)에서 호출하도록 설계합니다.

2) readOnly 옵션을 적절히 활용하기

데이터 조회 시 @Transactional(readOnly = true)를 사용하면 성능 최적화에 도움이 됩니다.

@Transactional(readOnly = true)
public User getUser(Long id) {
    return userRepository.findById(id).orElseThrow();
}
  • Hibernate는 readOnly = true일 때 더티 체킹(DIRTY CHECKING)을 수행하지 않으므로 성능이 향상됩니다.
  • 단, 읽기 전용 트랜잭션 내에서 save()를 호출하면 예외(Exception)가 발생할 수 있으므로 주의해야 합니다.

3. 트랜잭션 전파 옵션 (Propagation)

Spring은 트랜잭션이 중첩될 때 동작을 제어할 수 있도록 여러 가지 전파(Propagation) 옵션을 제공합니다.

  • 전파 옵션 종류
REQUIRED 기본값. 기존 트랜잭션이 있으면 참여, 없으면 새 트랜잭션 생성
REQUIRES_NEW 기존 트랜잭션을 무시하고 항상 새로운 트랜잭션 시작
NESTED 기존 트랜잭션 내에서 중첩 트랜잭션 실행
SUPPORTS 트랜잭션이 있으면 참여, 없으면 트랜잭션 없이 실행
NOT_SUPPORTED 트랜잭션 없이 실행
NEVER 트랜잭션이 있으면 예외 발생
MANDATORY 기존 트랜잭션이 없으면 예외 발생

 

@Service
public class NotificationService {
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void sendNotification(Notification notification) {
    	notificationRepository.save(notification);
    }
}
 
  • REQUIRES_NEW를 사용하면 부모 트랜잭션과 별개로 커밋/롤백이 가능합니다.
  • 전파 옵션을 잘 활용하면 한쪽 로직이 실패해도 다른 트랜잭션이 영향을 받지 않도록 할 수 있습니다.

4. 트랜잭션 롤백 전략

1) 기본 롤백 동작

  • @Transactional은 체크 예외(Checked Exception) 발생 시 롤백하지 않고, 런타임 예외(Runtime Exception) 발생 시 롤백합니다.
@Transactional
public void processOrder() throws IOException {
    // 체크 예외 throw new IOException("파일 처리 오류");
    // 롤백되지 않음
}
@Transactional public void processPayment() {
    throw new RuntimeException("결제 오류");
    // 롤백됨
}
 

2) 특정 예외에 대해 롤백 설정

  • rollbackFor 옵션을 사용하여 특정 예외가 발생해도 롤백되도록 설정할 수 있습니다.
@Transactional(rollbackFor = IOException.class)
public void processFile() throws IOException {
    throw new IOException("파일 처리 오류"); // 롤백됨
}
  • 반대로, 특정 예외에서 롤백되지 않도록 noRollbackFor 옵션을 사용할 수도 있습니다.
@Transactional(noRollbackFor = IllegalArgumentException.class)
public void updateUser(User user) {
    throw new IllegalArgumentException("잘못된 입력"); // 롤백되지 않음
}

 

Spring Boot에서 트랜잭션을 적절히 관리하면 데이터 무결성을 보장하고 성능을 최적화할 수 있습니다.

@Transactional을 적극 활용하되, 내부 호출(self-invocation) 문제를 주의
읽기 전용 트랜잭션(readOnly = true)을 적절히 활용하여 성능을 최적화
Propagation 옵션을 이해하고, 비즈니스 로직에 맞게 선택
체크 예외와 런타임 예외를 rollbackFor, noRollbackFor를 통해 롤백 여부 제어

 

 

 

 

프로젝트를 진행하면서 처음보는 오류를 발견하였다. 🙄

LazyInitializationException

같은 백엔드 팀원분과 서칭해서 반나절만에 해결할 수 있었다. 

 

환경 : AWS ec2 인스턴스에서 서버 배포 + Spring Data JPA(hibernate) + MySQL

 

어떤 오류?

2022-09-02 06:59:34.637 ERROR 35806 --- [nio-8080-exec-1] o.a.c.c.C.[.[.[/].[dispatcherServlet]    : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed; nested exception is org.hibernate.LazyInitializationException: failed to lazily initialize a collection of role: com.lucky7.stackoverflow.question.entity.Question.comment, could not initialize proxy - no Session] with root cause

org.hibernate.LazyInitializationException: failed to lazily initialize a collection of role: com.lucky7.stackoverflow.question.entity.Question.comment, could not initialize proxy - no Session
        at org.hibernate.collection.internal.AbstractPersistentCollection.throwLazyInitializationException(AbstractPersistentCollection.java:614) ~[hibernate-core-5.6.10.Final.jar!/:5.6.10.Final]

요약하자면 Lazy(지연) Initialization(초기화) 오류이다.

프록시를 초기화할 수 없다고 나오며 no Session이라고 세션이 없다는 로그가 보인다.

 

 

오류가 발생하는 이유?

 

일단 Spring Data JPA의 Lazy 전략에 대한 이해가 부족하였고 영속성 컨텍스트의 생명주기에 대해 알지 못하여서 발생하는 문제였다.

간단하게 정리해보자면 이렇다.

  • 서비스 단에 @Transactional을 붙여놓은 상태여서 메서드가 종료되면 Hibernate의 Session도 함께 종료되어 영속성 컨텍스트에서 사라진다.
  • 영속성 컨텍스트는 보통 트랜잭션과 생명주기를 같이한다. → service 호출 끝나고 controller로 돌아가면 영속성 상태가 끝난다.
  • jpa의 효율 문제 때문에 엔티티가 List나 객체로 참조하고있는 부분을 전부 쿼리문을 날려서 채워놓진 않고 일단 프록시 객체로 채워놓은 이후 나중에 getter를 사용하면 쿼리를 보내서 실제 데이터로 채운다.
  • Question에 조회하는 요청 시 owner나 comment, answer 에 실제 데이터를 쿼리를 날려서 저장하는 것이 아닌 프록시 객체로 채워진다.(stub 데이터라고 보면 됨) -> 프록시 객체로 채워진 상태에서 Service 메서드 호출이 끝나면 영속성 컨텍스트의 관리대상에서 제외되기 때문에 이후엔 gatter를 사용해도 쿼리문을 날려서 해당 객체를 실제 데이터로 채우지 않는다.
  • QuestionService에 @Transactional 애너테이션 붙어있음 → controller에서 service의 메서드 호출한 순간부터 트랜젝션 생성 → 서비스 메서드 끝나면서 트랜젝션 종료됨 → mapper에서 question객체를 dto 객체로 변환시켜주면서 owner, comment, answer 객체를 불러오는 쿼리 보냄 → 영속성 컨텍스트에 존재하지 않아서 프록시 객체를 실제 객체로 채우지 못함 → LazyInitializationException !

 

 

참고 자료 : https://cantcoding.tistory.com/78

 

 

 

해결 방법

 

mapper에서 entity 객체에 gatter 메서드를 호출하는 부분까지 트랜잭션 안으로 포함시켜주면 해결할 수 있다.

-> controller 에 @Transactional 애너테이션 붙여서 mapper 사용하는 부분까지 전부 하나의 트랜잭션으로 포함시켜 주었다.

이렇게 하면 http 요청이 와서 컨트롤러에 메서드가 호출하는 시점부터 트랜잭션이 생성되고 서비스에서 저장이나 삭제를 처리하고 다시 컨트롤러로 돌아와서 매퍼를 호출할 때까지도 트랜잭션이 유지되기 때문에 영속성 컨텍스트에서 관리하는 상태가 된다.

그리고 컨트롤러에서 응답을 보내게 되면(return) 메서드 호출이 끝나므로 트랜잭션이 종료된다.

 

 

아직은 이해가 깊지 않아 이게 가장 좋은 방법인지는 모르겠다.

하지만 해결법이 간단하고 성능상의 문제 없이 해결할 수 있었고 Spring Data JPA라는 녀석에 대해서 한걸음 더 이해할 수 있었다.

 

 

 

 

Rest Docs 기능 활성을 위해 build.gradle 파일에 코드를 추가하고 build 하니까 자꾸만 찾을 수 없다고 떠서 이번 기회에 build.gradle 에 추가해야 하는 코드를 정리해 놓으려고 한다.

버전 정보는 바뀔 수 있으므로 불러오기에 실패할 경우 버전을 체크하자.

 

plugins {
	id 'org.springframework.boot' version '2.7.2'
	id 'io.spring.dependency-management' version '1.0.12.RELEASE'
	id 'org.asciidoctor.jvm.convert' version '3.3.2'
	id 'java'
}

group = 'com.wisejade'		// initializr에서 프로젝트 생성시 설정한 group이 자동으로 들어감
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '11'

repositories {
	mavenCentral()
}


ext {
	set('snippetsDir', file("build/generated-snippets"))	// 추가해야하는 코드
}

configurations {
	asciidoctorExtensions		// asciidoctorExtensions를 사용하기 위해 추가
}

dependencies {
	testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc'	// mockmvc 사용을 위해 추가
	asciidoctorExtensions 'org.springframework.restdocs:spring-restdocs-asciidoctor'	// asciidoctor
	implementation 'org.springframework.boot:spring-boot-starter-data-jpa'	// Spring data jpa 사용을 위해 추가
	implementation 'org.springframework.boot:spring-boot-starter-validation'
	implementation 'org.springframework.boot:spring-boot-starter-web'
	compileOnly 'org.projectlombok:lombok'
	runtimeOnly 'com.h2database:h2'
	annotationProcessor 'org.projectlombok:lombok'
	testImplementation 'org.springframework.boot:spring-boot-starter-test'
	implementation 'org.mapstruct:mapstruct:1.5.2.Final'					// mapstruct 사용을 위해 추가
	annotationProcessor 'org.mapstruct:mapstruct-processor:1.5.1.Final'		// mapstruct 사용을 위해 추가
	implementation 'org.springframework.boot:spring-boot-starter-mail'

	implementation 'com.google.code.gson:gson'	// gson은 Json을 String으로 쉽게 바꿔줌
}

tasks.named('test') {	// 추가해야하는 코드
	outputs.dir snippetsDir
	useJUnitPlatform()
}

tasks.named('asciidoctor') {	// 추가해야하는 코드
	configurations "asciidoctorExtensions"
	inputs.dir snippetsDir
	dependsOn test
}

task copyDocument(type: Copy) {	// 생성된 rest docs를 static/docs로 자동으로 copy
	dependsOn asciidoctor
	from file("${asciidoctor.outputDir}")
	into file("src/main/resources/static/docs")
}

build {	// task build 할 때마다 copy
	dependsOn copyDocument
}

bootJar {
	dependsOn copyDocument
	from("${asciidoctor.outputDir}") {
		into 'static/docs'
	}
}

 

 

 

 

 

 

SpringBoot에서 JPA를 사용할 때

application.yml 또는 application.properties 파일에서 설정을 해주어야 한다.

ddl-auto는 JPA 설정중에 빌드시 JPA가 어떻게 자동으로 테이블을 생성해줄지에 대한 설정을 지정한다.

 

 

 

➤ ddl-auto 옵션 종류

 

  • create: 기존테이블 삭제 후 다시 생성 (DROP + CREATE)
  • create-drop: 테이블 생성 후 종료시점에 테이블 DROP
  • update: 변경분만 반영(운영DB에서는 사용하면 안됨)
  • validate: 엔티티와 테이블이 정상 매핑되었는지만 확인
  • none: 사용하지 않음(사실상 없는 값이지만 관례상 none이라고 한다.)

 

 

⚠️ 주의사항

 

  • 운영 장비에서는 절대 crate, create-drop, update를 사용하면 안된다.
  • create는 로컬환경에서만 사용한다
  • 개발 초기 단계에는 create 또는 update 를 사용한다.
  • 테스트 서버는 update 또는 validate 를 사용한다.
  • 스테이징과 운영 서버는 validate 또는 none 을 사용한다.

 

 

 

출처 : https://smpark1020.tistory.com/140

 

 

 

 

✔️ 오류가 난 환경

 

운영체제 : MAC

Gradle 기반의 Spring Boot 프로젝트 내의 .jar 파일을 터미널에서 build 하는 과정에서

 

zsh: ./gradlew: Permission denied

 

 

 

✔️ 해결

 

실행권한이 없어서 실행 거부된 거라서

실행권한을 부여해주면 된다.

아래 명령어를 터미널에 입력하면 해결된다.

 

chmod +x gradlew

 

 

 

'chmod' 명령어에 대한 이전 포스팅

2022.05.03 - [TIL(Today I Learned)] - 5/2 (월) Linux 기초2

 

'+x' : 실행 권한을 부여(+) 해준다.

 

권한을 부여해주고 다시 실행해보면 정상적으로 실행된다.

 

 

 

감사합니다.

도움이 되셨길 바랍니다. 😄

 

 

1