해당 글은 지난 글에 이어서 작성하는 글이며, 김영한님의 '스프링 DB 1편 - 데이터 접근 핵심 원리' 강의를 참고했습니다.
<이전글>
멀티스레드 환경에서의 트랜잭션 동작 - feat. 테스트 코드
최근에 한 프로젝트에서 테스트 코드를 짜다가 트러블 슈팅 과정에서 새롭게 안 사실이 있어서 블로그를 작성하게 되었다. "동일한 이메일로 사용자가 동시에 가입할 경우, 하나의 계정만 생성
judyalwayswantscarrot.tistory.com
지난번 글에서 "멀티스레드 환경에서의 트랜잭션 동작"을 살펴보았다!
아래와 같이 테스트 코드를 짰을때, 메인 스레드인 테스트가 시작되는 메서드와 내부에서 생성된 3개의 스레드들에서 트랜잭션이 제대로 동작하고 있는지 확인보았다.
결과적으로는 메인 스레드인 테스트 메서드에서는 트랜잭션이 작동했고, 내부 스레드에서는 트랜잭션이 작동하고 있지 않았다.
@SpringBootTest
class MemberRepositoryTest {
@Autowired
private MemberRepository memberRepository;
@Test
@DisplayName("동일한 이메일로 사용자가 동시에 가입할 경우, 하나의 계정만 생성된다")
@Transactional
void joinMemberWithConcurrent() throws InterruptedException {
//given
ExecutorService executorService = Executors.newFixedThreadPool(3);
CountDownLatch latch = new CountDownLatch(3);
List<Throwable> exceptions = Collections.synchronizedList(new ArrayList<>());
//when
for (int i = 0; i < 3; i++) {
executorService.execute(() -> {
try {
Member newMember = Member.create( // ★ 새로운 객체 생성
"yoon",
"sss@naver.com",
true,
"자기소개",
"image",
null
);
memberRepository.save(newMember);
} catch (DataIntegrityViolationException e) {
exceptions.add(e);
}
latch.countDown();
});
}
latch.await();
//then
List<Member> savedMember = memberRepository.findAllByEmail("sss@naver.com");
}
}
어 그러면 아래와 같이 코드를 수정하면 내부의 스레드에서도 트랜잭션이 잘 동작할까? 라는 궁금증이 생겼다.
(saveMember()라는 메서드를 분리하고 @Transactional 어노테이션을 달았다.)
@SpringBootTest
class MemberRepositoryTest {
@Autowired
private MemberRepository memberRepository;
@Test
@DisplayName("동일한 이메일로 사용자가 동시에 가입할 경우, 하나의 계정만 생성된다")
@Transactional
void joinMemberWithConcurrent() throws InterruptedException {
//given
ExecutorService executorService = Executors.newFixedThreadPool(3);
CountDownLatch latch = new CountDownLatch(3);
List<Throwable> exceptions = Collections.synchronizedList(new ArrayList<>());
System.out.println("🟢 Current Thread: " + Thread.currentThread().getName());
System.out.println("🟢 Transaction Name: " + TransactionSynchronizationManager.getCurrentTransactionName());
System.out.println("🟢 Is Actual Transaction Active? " + TransactionSynchronizationManager.isActualTransactionActive());
//when
for (int i = 0; i < 3; i++) {
executorService.execute(() -> {
try {
Member newMember = Member.create(
"yoon",
"sss@naver.com",
true,
"자기소개",
"image",
null
);
saveMember(newMember);
Thread.sleep(1000);
} catch (DataIntegrityViolationException e) {
exceptions.add(e);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
latch.countDown();
});
}
latch.await();
//then
List<Member> savedMember = memberRepository.findAllByEmail(member.getEmail());
assertAll(
() -> assertThat(exceptions).hasSize(2),
() -> assertThat(savedMember).hasSize(1)
);
}
@Transactional
void saveMember(Member newMember) {
System.out.println("🟢 Current Thread: " + Thread.currentThread().getName());
System.out.println("🟢 Transaction Name: " + TransactionSynchronizationManager.getCurrentTransactionName());
System.out.println("🟢 Is Actual Transaction Active? " + TransactionSynchronizationManager.isActualTransactionActive());
memberRepository.save(newMember);
}
}
결과적으로는, 이렇게 바꿔도 내부 스레드에서는 트랜잭션이 활성화되지 않았다.
🟢 Current Thread: main
🟢 Transaction Name: com.favoriteplace.app.repository.MemberRepositoryTest.joinMemberWithConcurrent
🟢 Is Actual Transaction Active? true
🟢 Current Thread: pool-4-thread-1
🟢 Transaction Name: null
🟢 Is Actual Transaction Active? false
🟢 Current Thread: pool-4-thread-2
🟢 Transaction Name: null
🟢 Is Actual Transaction Active? false
🟢 Current Thread: pool-4-thread-3
🟢 Transaction Name: null
🟢 Is Actual Transaction Active? false
이유가 무엇일까?! 답은 스프링 트랜잭션 동작 원리에서 찾을 수 있었다.
Spring은 @Transactional을 AOP(프록시) 방식으로 관리하고 있다.
먼저 AOP란 무엇일까?
🪽 AOP (Aspect Oriented Programming)
흩어진 관심사를 별도의 클래스로 모듈화하여, OOP를 더욱 잘 지킬 수 있도록 도와주는 것
(여기서 흩어진 관심사란, 로깅, 트랜잭션 등 여러 클래스에서 공통적으로 사용될 수 있는 부가적인 기능을 의미한다.)
트랜잭션 프록시 코드 예시를 살펴보면서 이해해 보자.
- 프록시 도입 전
//트랜잭션 시작
TransactionStatus status = transactionManager.getTransaction(new
DefaultTransactionDefinition());
try {
//비즈니스 로직
bizLogic(fromId, toId, money);
transactionManager.commit(status); //성공시 커밋
} catch (Exception e) {
transactionManager.rollback(status); //실패시 롤백
throw new IllegalStateException(e);
}
- 프록시 도입 후
public class TransactionProxy {
private MemberService target;
public void logic() {
// 트랜잭션 시작
TransactionStatus status = transactionManager.getTransaction(..);
try {
// 실제 대상 호출
target.logic();
transactionManager.commit(status); // 성공 시 커밋
} catch (Exception e) {
transactionManager.rollback(status); // 실패 시 롤백
throw new IllegalStateException(e);
}
}
}
public class Service {
public void logic() {
//트랜잭션 관련 코드 제거, 순수 비즈니스 로직만 남음
bizLogic(fromId, toId, money);
}
}
✅ 프록시 도입 전: 서비스에 비즈니스 로직과 트랜잭션 처리 로직이 함께 섞여있다.
✅ 프록시 도입 후: 트랜잭션 프록시가 트랜잭션 처리 로직을 모두 가져간다. 그리고 트랜잭션을 시작한 후에 실제 서비스를 대신 호출한다. 트랜잭션 프록시 덕분에 서비스 계층에는 순수한 비즈니즈 로직만 남길 수 있다.
스프링 AOP는 기본적으로 프록시 방식으로 동작한다. (*프록시 객체란, 쉽게 말해 가짜 객체로, 객체를 직접적으로 참조 하는 것이 아니라, 해당 객체를 대행(대리, proxy)하는 객체를 통해 대상객체에 접근하는 방식을 말한다.)
🪽 @Transactoinal의 동작
- 스프링 트랜잭션은 AOP 방식으로 관리하고 있다.
- 스프링 컨테이너는 @Transactional 어노테이션이 있으면, 해당 타겟 빈을 상속받은 프록시 객체를 생성한다. => 따라서 해당 클래스, 메서드에 대한 요청이 오면 프록시 객체가 대신 호출되어 트랜잭션 관리를 수행한 뒤, 실제 객체의 메서드를 호출한다,
스프링 컨테이너는 @Transactional 어노테이션이 있으면, 해당 타겟 빈을 상속받은 프록시 객체를 생성하므로, private 메서드는 상속이 불가하기 때문에 어노테이션을 붙여도 동작하지 않는다.

스프링에서 프록시 객체를 생성하는 방법이 크게 2가지가 있다.

CGLib Proxy
Target 클래스를 상속 받아 프록시를 만든다.
스프링에서 proxy-target-class: true가 default라서 CGLIB이 기본으로 작동한다.
JDK Proxy
Target의 상위 인터페이스를 상속 받아 프록시를 만든다.
아래의 테스트코드를 통해 트랜잭션 전파에 대해서 테스트해보았다.
해당 코드는 다음의 블로그를 참고했습니다.(https://coding-business.tistory.com/83)
@Slf4j
@SpringBootTest
public class SimpleTest {
@Autowired
CallService callService;
@Test
void externalCall() {
//문제 상황 발생
callService.external();
}
@TestConfiguration
static class InternalCallV1Config {
@Bean
CallService callService() {
return new CallService();
}
}
@Slf4j
static class CallService {
public void external() {
log.info("call external");
printTxInfo();
//내부에 트랜잭션 처리되는 internal() 메소드 호출
//this.internal()과 같은 의미이다.
internal();
}
@Transactional
public void internal() {
log.info("call internal");
printTxInfo();
}
private void printTxInfo() {
boolean txActive =
TransactionSynchronizationManager.isActualTransactionActive();
log.info("tx active={}", txActive);
}
}
}
02-12 19:00:05 INFO [c.f.a.repository.SimpleTest$CallService] call external
02-12 19:00:05 INFO [c.f.a.repository.SimpleTest$CallService] tx active=false
02-12 19:00:05 INFO [c.f.a.repository.SimpleTest$CallService] call internal
02-12 19:00:05 INFO [c.f.a.repository.SimpleTest$CallService] tx active=false
- outer, inner 메서드 모두 트랜잭션이 동작하지 않는다.
- internal()은 this.internal()과 같아서, 프록시 객체가 아닌 target 객체의 메서드를 호출하게 되어, 트랜잭션이 동작하지 않음
🤔 outer에 @Transactional을 붙이고 Inner에 안 붙이면?!
- 트랜잭션 전파속성에 따라 둘다 트랜잭션이 동작한다. (스프링에서 트랜잭션 전파타입의 기본값은 REQUIRED 이다.)
- internal의 트랜잭션이 external 트랜잭션에 참여하게 된다.
그렇다면 처음으로 돌아가서, 여기서는 왜 내부 스레드에서 트랜잭션이 적용되지 않은걸까?!
그건 바로 외부 스레드와 내부 스레드는 트랜잭션이 별개로 동작하기 때문이다. Spring의 @Transactional은 기본적으로 스레드 로컬(ThreadLocal) 기반으로 관리된다. 따라서 즉, 트랜잭션이 현재 실행 중인 스레드 내에서만 유지되게 되는것이다.
도움받은글
[Spring]같은 클래스 내의 다른 메소드에서 @Transactional이 있는 메소드를 호출하면 트랜잭션이 처리
@Transactional 어노테이션을 사용했는데 등록, 수정이 안돼요😥위는 실제로 내가 사수에게 물었던 질문이다. 때는, 스프링도 JPA도 거의 모르고 그저 일을 쳐내기 급급했던 시절...상사가 컨트롤러
conpang.tistory.com
스프링 Trasaction AOP 동작 과정과 주의 사항
관련 내용 해당 프로젝트 깃허브 순수 JDBC만을 사용한 Transaction 사용 방법 알아보기 JDBC를 사용한 Trasaction 처리와 이해 순수 JDBC-Transaction 문제 Spring TransactionManger로 해결하기 - 트랜잭션 템플릿,
coding-business.tistory.com
[Spring] AOP와 @Transactional의 동작 원리
오늘은 @Transactional의 동작 원리를 AOP와 함께 좀 더 자세하게 조사해보려고 한다.여기서 다루는 내용은 다음과 같다.AOP란 무엇이며 왜 사용하는가Spring AOP는 왜 프록시를 사용하는가@Transactional은
velog.io
테스트 코드 - 멀티쓰레드 환경의 트랜잭션
멀티 쓰레드 환경의 트랜잭션은 테스트 메서드의 트랜잭션과 별개다
velog.io
'백엔드 > SpringBoot' 카테고리의 다른 글
[Spring Boot] HTTP 요청/응답 로깅 (0) | 2025.04.05 |
---|---|
[페이징] offset pagination vs cursor pagination (0) | 2025.02.17 |
멀티스레드 환경에서의 트랜잭션 동작 - feat. 테스트 코드 (0) | 2025.02.10 |
② 실전! 스프링 부트와 JPA 활용1 (1) | 2023.11.14 |
① 실전! 스프링 부트와 JPA 활용1 (2) | 2023.11.06 |
해당 글은 지난 글에 이어서 작성하는 글이며, 김영한님의 '스프링 DB 1편 - 데이터 접근 핵심 원리' 강의를 참고했습니다.
<이전글>
멀티스레드 환경에서의 트랜잭션 동작 - feat. 테스트 코드
최근에 한 프로젝트에서 테스트 코드를 짜다가 트러블 슈팅 과정에서 새롭게 안 사실이 있어서 블로그를 작성하게 되었다. "동일한 이메일로 사용자가 동시에 가입할 경우, 하나의 계정만 생성
judyalwayswantscarrot.tistory.com
지난번 글에서 "멀티스레드 환경에서의 트랜잭션 동작"을 살펴보았다!
아래와 같이 테스트 코드를 짰을때, 메인 스레드인 테스트가 시작되는 메서드와 내부에서 생성된 3개의 스레드들에서 트랜잭션이 제대로 동작하고 있는지 확인보았다.
결과적으로는 메인 스레드인 테스트 메서드에서는 트랜잭션이 작동했고, 내부 스레드에서는 트랜잭션이 작동하고 있지 않았다.
@SpringBootTest
class MemberRepositoryTest {
@Autowired
private MemberRepository memberRepository;
@Test
@DisplayName("동일한 이메일로 사용자가 동시에 가입할 경우, 하나의 계정만 생성된다")
@Transactional
void joinMemberWithConcurrent() throws InterruptedException {
//given
ExecutorService executorService = Executors.newFixedThreadPool(3);
CountDownLatch latch = new CountDownLatch(3);
List<Throwable> exceptions = Collections.synchronizedList(new ArrayList<>());
//when
for (int i = 0; i < 3; i++) {
executorService.execute(() -> {
try {
Member newMember = Member.create( // ★ 새로운 객체 생성
"yoon",
"sss@naver.com",
true,
"자기소개",
"image",
null
);
memberRepository.save(newMember);
} catch (DataIntegrityViolationException e) {
exceptions.add(e);
}
latch.countDown();
});
}
latch.await();
//then
List<Member> savedMember = memberRepository.findAllByEmail("sss@naver.com");
}
}
어 그러면 아래와 같이 코드를 수정하면 내부의 스레드에서도 트랜잭션이 잘 동작할까? 라는 궁금증이 생겼다.
(saveMember()라는 메서드를 분리하고 @Transactional 어노테이션을 달았다.)
@SpringBootTest
class MemberRepositoryTest {
@Autowired
private MemberRepository memberRepository;
@Test
@DisplayName("동일한 이메일로 사용자가 동시에 가입할 경우, 하나의 계정만 생성된다")
@Transactional
void joinMemberWithConcurrent() throws InterruptedException {
//given
ExecutorService executorService = Executors.newFixedThreadPool(3);
CountDownLatch latch = new CountDownLatch(3);
List<Throwable> exceptions = Collections.synchronizedList(new ArrayList<>());
System.out.println("🟢 Current Thread: " + Thread.currentThread().getName());
System.out.println("🟢 Transaction Name: " + TransactionSynchronizationManager.getCurrentTransactionName());
System.out.println("🟢 Is Actual Transaction Active? " + TransactionSynchronizationManager.isActualTransactionActive());
//when
for (int i = 0; i < 3; i++) {
executorService.execute(() -> {
try {
Member newMember = Member.create(
"yoon",
"sss@naver.com",
true,
"자기소개",
"image",
null
);
saveMember(newMember);
Thread.sleep(1000);
} catch (DataIntegrityViolationException e) {
exceptions.add(e);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
latch.countDown();
});
}
latch.await();
//then
List<Member> savedMember = memberRepository.findAllByEmail(member.getEmail());
assertAll(
() -> assertThat(exceptions).hasSize(2),
() -> assertThat(savedMember).hasSize(1)
);
}
@Transactional
void saveMember(Member newMember) {
System.out.println("🟢 Current Thread: " + Thread.currentThread().getName());
System.out.println("🟢 Transaction Name: " + TransactionSynchronizationManager.getCurrentTransactionName());
System.out.println("🟢 Is Actual Transaction Active? " + TransactionSynchronizationManager.isActualTransactionActive());
memberRepository.save(newMember);
}
}
결과적으로는, 이렇게 바꿔도 내부 스레드에서는 트랜잭션이 활성화되지 않았다.
🟢 Current Thread: main
🟢 Transaction Name: com.favoriteplace.app.repository.MemberRepositoryTest.joinMemberWithConcurrent
🟢 Is Actual Transaction Active? true
🟢 Current Thread: pool-4-thread-1
🟢 Transaction Name: null
🟢 Is Actual Transaction Active? false
🟢 Current Thread: pool-4-thread-2
🟢 Transaction Name: null
🟢 Is Actual Transaction Active? false
🟢 Current Thread: pool-4-thread-3
🟢 Transaction Name: null
🟢 Is Actual Transaction Active? false
이유가 무엇일까?! 답은 스프링 트랜잭션 동작 원리에서 찾을 수 있었다.
Spring은 @Transactional을 AOP(프록시) 방식으로 관리하고 있다.
먼저 AOP란 무엇일까?
🪽 AOP (Aspect Oriented Programming)
흩어진 관심사를 별도의 클래스로 모듈화하여, OOP를 더욱 잘 지킬 수 있도록 도와주는 것
(여기서 흩어진 관심사란, 로깅, 트랜잭션 등 여러 클래스에서 공통적으로 사용될 수 있는 부가적인 기능을 의미한다.)
트랜잭션 프록시 코드 예시를 살펴보면서 이해해 보자.
- 프록시 도입 전
//트랜잭션 시작
TransactionStatus status = transactionManager.getTransaction(new
DefaultTransactionDefinition());
try {
//비즈니스 로직
bizLogic(fromId, toId, money);
transactionManager.commit(status); //성공시 커밋
} catch (Exception e) {
transactionManager.rollback(status); //실패시 롤백
throw new IllegalStateException(e);
}
- 프록시 도입 후
public class TransactionProxy {
private MemberService target;
public void logic() {
// 트랜잭션 시작
TransactionStatus status = transactionManager.getTransaction(..);
try {
// 실제 대상 호출
target.logic();
transactionManager.commit(status); // 성공 시 커밋
} catch (Exception e) {
transactionManager.rollback(status); // 실패 시 롤백
throw new IllegalStateException(e);
}
}
}
public class Service {
public void logic() {
//트랜잭션 관련 코드 제거, 순수 비즈니스 로직만 남음
bizLogic(fromId, toId, money);
}
}
✅ 프록시 도입 전: 서비스에 비즈니스 로직과 트랜잭션 처리 로직이 함께 섞여있다.
✅ 프록시 도입 후: 트랜잭션 프록시가 트랜잭션 처리 로직을 모두 가져간다. 그리고 트랜잭션을 시작한 후에 실제 서비스를 대신 호출한다. 트랜잭션 프록시 덕분에 서비스 계층에는 순수한 비즈니즈 로직만 남길 수 있다.
스프링 AOP는 기본적으로 프록시 방식으로 동작한다. (*프록시 객체란, 쉽게 말해 가짜 객체로, 객체를 직접적으로 참조 하는 것이 아니라, 해당 객체를 대행(대리, proxy)하는 객체를 통해 대상객체에 접근하는 방식을 말한다.)
🪽 @Transactoinal의 동작
- 스프링 트랜잭션은 AOP 방식으로 관리하고 있다.
- 스프링 컨테이너는 @Transactional 어노테이션이 있으면, 해당 타겟 빈을 상속받은 프록시 객체를 생성한다. => 따라서 해당 클래스, 메서드에 대한 요청이 오면 프록시 객체가 대신 호출되어 트랜잭션 관리를 수행한 뒤, 실제 객체의 메서드를 호출한다,
스프링 컨테이너는 @Transactional 어노테이션이 있으면, 해당 타겟 빈을 상속받은 프록시 객체를 생성하므로, private 메서드는 상속이 불가하기 때문에 어노테이션을 붙여도 동작하지 않는다.

스프링에서 프록시 객체를 생성하는 방법이 크게 2가지가 있다.

CGLib Proxy
Target 클래스를 상속 받아 프록시를 만든다.
스프링에서 proxy-target-class: true가 default라서 CGLIB이 기본으로 작동한다.
JDK Proxy
Target의 상위 인터페이스를 상속 받아 프록시를 만든다.
아래의 테스트코드를 통해 트랜잭션 전파에 대해서 테스트해보았다.
해당 코드는 다음의 블로그를 참고했습니다.(https://coding-business.tistory.com/83)
@Slf4j
@SpringBootTest
public class SimpleTest {
@Autowired
CallService callService;
@Test
void externalCall() {
//문제 상황 발생
callService.external();
}
@TestConfiguration
static class InternalCallV1Config {
@Bean
CallService callService() {
return new CallService();
}
}
@Slf4j
static class CallService {
public void external() {
log.info("call external");
printTxInfo();
//내부에 트랜잭션 처리되는 internal() 메소드 호출
//this.internal()과 같은 의미이다.
internal();
}
@Transactional
public void internal() {
log.info("call internal");
printTxInfo();
}
private void printTxInfo() {
boolean txActive =
TransactionSynchronizationManager.isActualTransactionActive();
log.info("tx active={}", txActive);
}
}
}
02-12 19:00:05 INFO [c.f.a.repository.SimpleTest$CallService] call external
02-12 19:00:05 INFO [c.f.a.repository.SimpleTest$CallService] tx active=false
02-12 19:00:05 INFO [c.f.a.repository.SimpleTest$CallService] call internal
02-12 19:00:05 INFO [c.f.a.repository.SimpleTest$CallService] tx active=false
- outer, inner 메서드 모두 트랜잭션이 동작하지 않는다.
- internal()은 this.internal()과 같아서, 프록시 객체가 아닌 target 객체의 메서드를 호출하게 되어, 트랜잭션이 동작하지 않음
🤔 outer에 @Transactional을 붙이고 Inner에 안 붙이면?!
- 트랜잭션 전파속성에 따라 둘다 트랜잭션이 동작한다. (스프링에서 트랜잭션 전파타입의 기본값은 REQUIRED 이다.)
- internal의 트랜잭션이 external 트랜잭션에 참여하게 된다.
그렇다면 처음으로 돌아가서, 여기서는 왜 내부 스레드에서 트랜잭션이 적용되지 않은걸까?!
그건 바로 외부 스레드와 내부 스레드는 트랜잭션이 별개로 동작하기 때문이다. Spring의 @Transactional은 기본적으로 스레드 로컬(ThreadLocal) 기반으로 관리된다. 따라서 즉, 트랜잭션이 현재 실행 중인 스레드 내에서만 유지되게 되는것이다.
도움받은글
[Spring]같은 클래스 내의 다른 메소드에서 @Transactional이 있는 메소드를 호출하면 트랜잭션이 처리
@Transactional 어노테이션을 사용했는데 등록, 수정이 안돼요😥위는 실제로 내가 사수에게 물었던 질문이다. 때는, 스프링도 JPA도 거의 모르고 그저 일을 쳐내기 급급했던 시절...상사가 컨트롤러
conpang.tistory.com
스프링 Trasaction AOP 동작 과정과 주의 사항
관련 내용 해당 프로젝트 깃허브 순수 JDBC만을 사용한 Transaction 사용 방법 알아보기 JDBC를 사용한 Trasaction 처리와 이해 순수 JDBC-Transaction 문제 Spring TransactionManger로 해결하기 - 트랜잭션 템플릿,
coding-business.tistory.com
[Spring] AOP와 @Transactional의 동작 원리
오늘은 @Transactional의 동작 원리를 AOP와 함께 좀 더 자세하게 조사해보려고 한다.여기서 다루는 내용은 다음과 같다.AOP란 무엇이며 왜 사용하는가Spring AOP는 왜 프록시를 사용하는가@Transactional은
velog.io
테스트 코드 - 멀티쓰레드 환경의 트랜잭션
멀티 쓰레드 환경의 트랜잭션은 테스트 메서드의 트랜잭션과 별개다
velog.io
'백엔드 > SpringBoot' 카테고리의 다른 글
[Spring Boot] HTTP 요청/응답 로깅 (0) | 2025.04.05 |
---|---|
[페이징] offset pagination vs cursor pagination (0) | 2025.02.17 |
멀티스레드 환경에서의 트랜잭션 동작 - feat. 테스트 코드 (0) | 2025.02.10 |
② 실전! 스프링 부트와 JPA 활용1 (1) | 2023.11.14 |
① 실전! 스프링 부트와 JPA 활용1 (2) | 2023.11.06 |