Spring Data JPA 동시성 테스트 에러: expected: X but was: Y

🐛 에러 상황

스프링 부트 프로젝트에서 트랜잭션 서비스 통합 테스트를 작성하는 중, 동시성 테스트에서 아래와 같은 에러가 발생했다.

org.opentest4j.AssertionFailedError:
expected: 35L
 but was: 1L
Expected :35L
Actual   :1L

 

 

관련 테스트 코드

@Slf4j
@SpringBootTest
@ExtendWith(SpringExtension.class)
@ActiveProfiles("payment-test") // application-payment-test.yml 설정 사용
public class TransactionServiceIntgTest {
    // ===== 의존성 주입 =====
    // SUT
    @Autowired TransactionService transactionService;       // 실제 트랜잭션 서비스
    // 의존성 주입
    @Autowired TransactionRepository transactionRepository; // 실제 트랜잭션 리포지토리
    @Autowired WalletService walletService;                 // 실제 지갑 서비스
    @Autowired WalletRepository walletRepository;           // 실제 리포지토리

    // 각 테스트 격리
    @AfterEach
    void tearDown() {
        transactionRepository.deleteAll();                  // 트랜잭션 먼저 삭제
        walletRepository.deleteAll();                       // 지갑 삭제
    }

    @Test
    @DisplayName("동시에 같은 orderId로 충전 요청 시, 트랜잭션은 1건만 생성되고 잔액은 정확히 1회만 반영된다")
    void charge_concurrent_sameOrderId_isIdempotent_andUnique() throws InterruptedException {
        // given
        // 사용자 지갑 생성
        CreatedWalletResponse wallet = walletService.createWallet(new CreateWalletRequest(1L));
        // 생성된 지갑 ID
        Long walletId = wallet.id();

        // 동일 orderId 준비
        // 충전 금액 1000원
        String orderId = "order-123";
        BigDecimal amount = BigDecimal.valueOf(1000);

        // 동시성 환경 준비
        int threadCount = 20;                                                 // 동시에 20개의 스레드에서 충전 시도
        ExecutorService executor = Executors.newFixedThreadPool(threadCount); // 스레드풀 생성
        CountDownLatch latch = new CountDownLatch(threadCount);               // 모든 스레드 완료 대기용

        List<ChargeTransactionResponse> results = new ArrayList<>();          // 결과 수집용 (동기화 필요)

        // when
        // 모든 스레드에서 동시에 요청 시작
        for (int i = 0; i < threadCount; i++) {
            // 각 스레드에서 충전 시도
            executor.submit(() -> {
                try {
                    log.debug("[{}] charge() 호출 시작", Thread.currentThread().getName());

                    // 실제 서비스 호출
                    ChargeTransactionResponse response = transactionService.charge(
                            new ChargeTransactionRequest(walletId, orderId, amount)
                    );

                    log.debug("[{}] charge() 성공 → walletId={}, balance={}",
                            Thread.currentThread().getName(), response.walletId(), response.balance());

                    // 결과 수집 (동기화 필요)
                    synchronized (results) {
                        results.add(response);
                    }
                } catch (DataIntegrityViolationException e) {
                    // 중복 예외 무시 (멱등성 테스트이므로 무시)

                    log.warn("[{}] DataIntegrityViolationException 발생: {}",
                            Thread.currentThread().getName(), e.getMessage());

                } finally {
                    latch.countDown(); // 완료 표시
                }
            });
        }

        // 모든 스레드 완료 대기
        latch.await();

        // then
        // 디버깅 출력
        log.info("최종 results.size() = {}", results.size());
        results.forEach(r -> log.info("→ result walletId={}, balance={}", r.walletId(), r.balance()));

        // 최종적으로 DB에 트랜잭션은 1건만 존재하는지 확인
        assertThat(results).hasSizeGreaterThanOrEqualTo(1);

        // 멱등성 보장: 모든 응답의 walletId는 동일해야 한다
        assertThat(results.get(0).walletId()).isEqualTo(walletId);
        // 멱등성 보장: 모든 응답의 잔액은 동일해야 한다
        assertThat(results.get(0).balance()).isEqualByComparingTo(BigDecimal.valueOf(1000));

        // 디버깅 출력
        System.out.printf("✅ concurrent charge test: orderId=%s, txCount=%d, finalBalance=%s%n",
                orderId, results.size(), results.get(0).balance());
    }
}

 

 

테스트 시나리오는 다음과 같다:

  • 여러 개의 스레드가 동시에 같은 donationId / orderId로 결제·충전 요청을 보낸다.
  • 멱등성이 보장되어야 하므로 트랜잭션은 1건만 생성되고, 지갑 잔액은 정확히 한 번만 반영되어야 한다.
  • 그러나 실제 결과에서 walletId 값이 엉뚱하게 반환되거나, 아예 트랜잭션이 생성되지 않는 문제가 반복적으로 발생했다.

🔎 원인 분석

테스트에서 반복적으로 로그를 찍어본 결과, 일부 스레드에서 지갑이 존재하지 않는다 (WalletNotFoundException) 는 에러가 발생하거나, DB 반영이 꼬이는 현상이 있었다.

 

즉, 문제의 핵심은:

  • transactionService.charge() or payment() 호출 과정에서
  • DB에 insert된 Wallet 엔티티가 아직 flush되지 않아, 다른 스레드에서 해당 지갑을 찾을 수 없는 경우가 발생한 것.

JPA는 트랜잭션 커밋 시점에만 flush를 보장한다.

따라서 테스트 코드에서 walletService.createWallet() 호출 직후, 다른 스레드가 동시에 findWalletByWalletId() 를 호출하면 아직 DB에 반영되지 않은 상태일 수 있다.


✅ 해결 방법

해결책은 간단했다.

// 지갑 생성 직후 강제로 flush 수행
CreatedWalletResponse wallet = walletService.createWallet(new CreateWalletRequest(1L));
Long walletId = wallet.id();

walletRepository.flush();  // 💡 DB에 즉시 반영

walletRepository.flush() 를 호출하여 지갑 엔티티가 DB에 즉시 반영되도록 보장했다.

 

그 결과:

  • 다른 스레드에서 findWalletByWalletId() 호출 시 반드시 DB에서 조회 가능
  • 멱등성 테스트에서도 더 이상 WalletNotFoundException 이 발생하지 않음
  • 기대했던 대로 트랜잭션은 정확히 1건만 생성되고, 잔액도 올바르게 반영됨

✅ 요약

에러 상황

  • 동시성 테스트(charge_concurrent_sameOrderId_isIdempotent_andUnique)에서
    WalletNotFoundException 혹은 AssertionFailedError (expected: X but was: Y) 발생.
  • 원인: 메인 스레드에서 만든 Wallet 엔티티가 아직 DB에 제대로 반영되지 않은 상태에서,
    다른 스레드들이 동시에 findWalletByWalletId()를 호출 → DB에서 못 찾음.

해결 방법

  1. flush() 추가 
    • Wallet을 생성한 직후 DB에 강제로 반영(commit 아님).
    • 따라서 이후 다른 스레드에서 트랜잭션을 열어도 해당 Wallet row를 조회할 수 있게 됨.
    • → expected != actual 에러 해결됨.
walletRepository.flush();

❌ 왜 @Transactional을 쓰지 않았는가?

  1. Spring Test 기본 동작
    • @Transactional을 테스트에 붙이면 테스트 메서드 전체를 하나의 트랜잭션으로 감쌈.
    • 테스트 끝나면 무조건 롤백.
    • 즉, 테스트 본문에서 insert된 Wallet은 커밋되지 않은 상태로만 존재.
  2. 동시성 테스트와 충돌
    • 멀티스레드로 실행된 서비스 호출은 각각 자기 트랜잭션에서 실행됨.
    • 하지만 메인 트랜잭션은 아직 커밋되지 않았으므로, 다른 스레드에서는 Wallet을 볼 수 없음.
    • 결과적으로 WalletNotFoundException이 발생.
  3. 정리
    • 동시성 테스트에서는 각 스레드가 DB에서 커밋된 데이터를 주고받아야 함.
    • 따라서 @Transactional을 테스트 클래스/메서드에 붙이지 않고,
      대신 Wallet 생성 후 flush()로 강제 반영해줌.

📝 교훈

  1. JPA의 flush 타이밍은 커밋 시점까지 지연될 수 있다. 멀티스레드/동시성 테스트에서는 flush 타이밍이 중요하다.
  2. 테스트 환경에서 동시에 여러 스레드가 같은 데이터를 접근한다면, flush()로 DB 반영을 명시적으로 강제하는 것이 안전하다.
  3. 처음에는 통과했는데 반복 실행에서 실패
    → 동시성 테스트는 한 번의 성공으로 넘어가면 안 되고, 여러 번 연속 실행해서 안정성을 확인해야 함.

 

이 테스트에 대한 문제를 뒤늦게 발견한 이유는, 첫 테스트 땐 문제 없이 통과했었기 때문이다.

첫 테스트 이후 재차 테스트를 시도했어야 했는데, 그러지 않았고 결국 뒤늦게 문제를 발견하게 되었다.

 

🔄 왜 처음엔 통과했을까?

  1. 첫 실행 시에는 통과
    • 테스트 클래스에서 Wallet 생성 직후 곧바로 동시성 테스트가 실행됨.
    • MySQL(InnoDB)나 H2 같은 DB는 트랜잭션 내에서 insert한 데이터가 세션 캐시에 남아있을 수 있고,
      때로는 다른 스레드가 DB에 반영된 것처럼 접근 가능해지는 운 좋은 케이스가 발생.
    • 그래서 단발성 실행에서는 테스트가 우연히 통과할 수 있음.
  2. 연속 실행 시에는 실패
    • 두 번째 테스트 실행부터는 트랜잭션 롤백/커밋 타이밍, DB 세션 정리, 캐시 초기화 등이 달라짐.
    • 즉, Wallet 엔티티가 아직 flush/commit 되지 않은 상태에서 다른 스레드들이 조회 → WalletNotFoundException 발생.
    • 테스트는 항상 DB 레벨에서 일관된 상태를 가정해야 하는데, flush 없이는 그게 깨짐.

🚀 결론

  • 에러 메시지 expected: X but was: Y 는 단순히 Assertion 실패가 아니라,
  • DB 반영 타이밍 불일치로 인한 동시성 문제였다.
  • 동시성 테스트에서는 테스트 메서드에 @Transactional 금지.
  • 필요한 데이터는 saveAndFlush() 또는 flush()로 DB에 즉시 반영.
  • 그래야 멀티스레드 환경에서 모든 스레드가 동일한 데이터를 볼 수 있고, 멱등성 검증이 가능하다.

🎯 Mockito @Captor 애노테이션 정리

- 호출된 메서드의 인자를 캡처하고 검증하는 도구


✅ 1. @Captor란?

@Captor는 Mockito에서 메서드가 호출될 때 전달된 인자(argument)를 캡처하기 위한 어노테이션입니다.
내부적으로는 ArgumentCaptor<T> 객체를 생성해주며, 테스트 대상이 호출한 메서드의 인자를 검증할 수 있게 해줍니다.


💡 왜 사용하나요?

  • 단순히 verify()만으로는 인자 값이 무엇이었는지 정확히 확인하기 어려움
  • 테스트 대상 내부에서 만들어진 객체를 외부로 전달할 때 그 전달된 객체의 필드 값을 검증하고 싶을 때 사용

🧩 2. 기본 사용 방법

📌 의존성 (Gradle 기준)

dependencies {
    testImplementation 'org.mockito:mockito-core'
    testImplementation 'org.mockito:mockito-junit-jupiter' // JUnit 5 사용 시
}

📌 예제 코드

@ExtendWith(MockitoExtension.class)
class MemberServiceTest {

    @Mock
    private MemberRepository memberRepository;

    @InjectMocks
    private MemberService memberService;

    @Captor
    private ArgumentCaptor<Member> memberCaptor;

    @Test
    void 회원가입_테스트() {
        // when
        memberService.join("철수");

        // then
        verify(memberRepository).save(memberCaptor.capture());
        Member captured = memberCaptor.getValue();
        assertEquals("철수", captured.getName());
    }
}

🧠 흐름 요약

  1. memberService.join("철수") 호출
  2. 내부적으로 memberRepository.save(new Member("철수")) 실행
  3. verify()로 save()가 호출됐는지 확인
  4. @Captor로 넘겨진 인자(Member)를 꺼내서 검증

🔍 @Captor vs ArgumentCaptor.forClass(...)

방식 설명
@Captor 선언만 해두면 Mockito가 자동으로 초기화
ArgumentCaptor.forClass() 직접 객체 생성 (더 번거로움)
// 수동 방식
ArgumentCaptor<Member> captor = ArgumentCaptor.forClass(Member.class);
verify(repo).save(captor.capture());

✅ 가독성과 유지보수 면에서는 @Captor 방식이 더 선호됨


⚠️ 사용 시 주의사항

항목 설명
@Captor 초기화 @ExtendWith(MockitoExtension.class) 또는 MockitoAnnotations.openMocks(this) 필요
제네릭 타입 명확히 지정 ArgumentCaptor<List<String>>처럼 사용할 때는 타입 명확히 작성 필요
capture()는 verify()와 함께 verify(mock).method(captor.capture()) 형태로 같이 사용해야 함

🧪 실전 상황 예시: 이벤트 발행 여부 검증

@Captor
ArgumentCaptor<Event> eventCaptor;

@Test
void 이벤트_검증() {
    service.doSomething();
    verify(eventPublisher).publish(eventCaptor.capture());

    Event captured = eventCaptor.getValue();
    assertEquals("SOME_TYPE", captured.getType());
}

✔ 서비스 내부에서 정확한 이벤트가 발행되었는지 확인할 수 있음


✅ 마무리 정리

항목 설명
어노테이션 @Captor
제공자 Mockito
목적 메서드 호출 시 전달된 인자 캡처 및 검증
내부 객체 ArgumentCaptor<T>
초기화 방법 MockitoExtension or openMocks() 필요
사용 위치 @Mock, @InjectMocks와 함께 사용하는 테스트 클래스 내부

 

@Captor는 단순히 메서드 호출을 "했는가"를 넘어서
"무엇을" 호출했는지를 정밀하게 검증하는 데 필수적인 도구입니다.
특히 객체 생성 후 전달하는 로직이 많은 도메인에서는 매우 유용하게 쓰입니다. 😊

 

'방법론 > Test' 카테고리의 다른 글

ReflectionTestUtils  (2) 2025.07.16

🔍 ReflectionTestUtils란?

- 테스트에서 private 필드와 메서드도 강제로 다뤄야 할 때!


✅ 1. ReflectionTestUtils란?

ReflectionTestUtils는 Spring Test 모듈에서 제공하는 유틸리티 클래스입니다.
주로 테스트 코드에서 접근 제한(private) 되어 있는 필드나 메서드를
리플렉션(reflection)을 이용해 강제로 접근하거나 값을 설정할 때 사용됩니다.

📍 의존성 추가 (Gradle)

dependencies {
    testImplementation 'org.springframework:spring-test'
}
  • Spring Boot를 사용 중이라면 별도 추가 필요 없음
    → spring-boot-starter-test 안에 이미 포함돼 있습니다.
dependencies {
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
}

✔ 위 starter에는 spring-test, junit, mockito, assertj 등 대부분의 테스트 유틸리티가 함께 포함되어 있어 별도로 추가하지 않아도 됩니다.


💡 왜 필요할까?

  • 객체의 private 필드 값을 강제로 세팅해야 할 때
  • setter나 생성자가 없는 클래스를 테스트해야 할 때
  • 캡슐화된 내부 상태를 검증하거나 조작해야 할 때

즉, 테스트 목적상 "강제 접근" 이 필요한 경우에 사용하는 도구입니다.


🧩 2. 주요 기능 요약

메서드 설명
setField() private 필드의 값을 강제로 설정
getField() private 필드 값을 강제로 가져옴
invokeMethod() private 메서드를 실행함

🧪 3. 사용 예제

📍 예제 클래스

public class UserService {
    private String secretKey = "default";

    private String encode(String value) {
        return value + ":" + secretKey;
    }
}

📌 setField() — 필드 값 강제 주입

UserService service = new UserService();

// private 필드 secretKey 값 변경
ReflectionTestUtils.setField(service, "secretKey", "newKey");

📌 getField() — 필드 값 읽기

String key = (String) ReflectionTestUtils.getField(service, "secretKey");
System.out.println(key); // newKey

📌 invokeMethod() — private 메서드 실행

String result = (String) ReflectionTestUtils.invokeMethod(service, "encode", "token");
System.out.println(result); // token:newKey

🛠 4. 생성자 없는 클래스 테스트

@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class Token {
    private String value;
}

➡ 테스트 코드에서 강제로 인스턴스 생성 및 필드 주입 가능:

Token token = new Token();
ReflectionTestUtils.setField(token, "value", "abc123");

⚠️ 5. 주의사항

항목 설명
리플렉션은 느리다 성능이 중요한 상황에는 지양
캡슐화 깨짐 실제 코드에서는 절대 사용 금지 (테스트 전용 도구)
필드명/메서드명 오타 주의 컴파일러가 체크하지 않음 (런타임 오류)

✅ 마무리 정리

항목 설명
클래스 org.springframework.test.util.ReflectionTestUtils
주요 용도 테스트에서 private 필드/메서드 조작
주요 메서드 setField(), getField(), invokeMethod()
주 사용 상황 생성자/세터 없는 객체, 내부 로직 검증, 레거시 테스트
주의사항 오용 주의, 성능 이슈, 리플렉션 한계 인식 필요

 

ReflectionTestUtils는 테스트의 "편법"이자 "마지막 수단"입니다.
하지만 꼭 필요한 경우에만 제한적으로 사용하면,
접근할 수 없었던 코드의 테스트 가능성을 넓혀주는 강력한 도구가 될 수 있습니다. 🛠

'방법론 > Test' 카테고리의 다른 글

Mockito @Captor 애노테이션  (1) 2025.07.16

📄 Spring Data JPA 페이징 처리 완벽 정리

- Pageable, Page, Slice를 활용한 효율적인 Pagination


✅ 1. 왜 페이징 처리가 필요할까?

대량의 데이터를 한 번에 모두 불러오는 것은
📉 성능 저하, 😵 메모리 낭비, ⛔ 네트워크 부담을 일으킵니다.

👉 그래서 데이터를 페이지 단위로 나눠서 요청하는 페이징(Pagination)이 필요합니다!


🔧 2. Spring Data JPA가 제공하는 페이징 기능

Spring Data JPA는 페이징을 위해 다음 두 가지를 제공합니다:

타입 특징
Pageable 페이징 조건 (page 번호, 크기, 정렬 기준 등)
Page<T> 페이징 결과 (컨텐츠 + 전체 개수 + 페이지 정보 포함)
Slice<T> 페이징 결과 (컨텐츠 + 다음 페이지 여부만 제공, count 쿼리 없음)

🧩 3. 페이징 기본 사용법

📍 ① Repository 메서드 정의

Page<Member> findByTeamName(String teamName, Pageable pageable);

✔ Pageable을 파라미터로 추가하면 자동으로 페이징 기능이 활성화됩니다.


📍 ② Controller에서 요청 받기

@GetMapping("/members")
public Page<MemberDto> getMembers(@RequestParam String teamName, Pageable pageable) {
    return memberRepository.findByTeamName(teamName, pageable)
                           .map(MemberDto::from);
}

✔ Pageable은 스프링이 자동으로 바인딩해줍니다.
예: /members?teamName=A&page=0&size=5&sort=age,desc


📍 ③ Page 객체로 응답 받기

Page는 다음 정보를 포함합니다:

  • getContent() : 현재 페이지의 데이터 리스트
  • getTotalElements() : 전체 데이터 수
  • getTotalPages() : 전체 페이지 수
  • getNumber() : 현재 페이지 번호
  • hasNext() / isFirst() / isLast() : 페이지 상태

🛠 4. 예제

📌 Repository

public interface MemberRepository extends JpaRepository<Member, Long> {
    Page<Member> findByTeamName(String teamName, Pageable pageable);
}

📌 Controller

@GetMapping("/members")
public Page<MemberDto> getMembers(@RequestParam String teamName, Pageable pageable) {
    return memberRepository.findByTeamName(teamName, pageable)
                           .map(MemberDto::from);
}

📌 Request URL 예시

/members?teamName=dev&page=0&size=5&sort=age,desc

🔄 5. Page, Slice, List 차이점

반환 타입 count 쿼리 다음 페이지 여부 전체 페이지 수
Page<T> ✅ 실행함 ✅ 포함 ✅ 포함
Slice<T> ❌ 없음 ✅ 포함 ❌ 없음
List<T> ❌ 없음 ❌ 없음 ❌ 없음
 

✔ Slice는 무한 스크롤이나 다음 페이지 여부만 필요한 경우에 적합


🎯 6. 정렬 사용하기

PageRequest.of(page, size, Sort.by(...))를 활용해 정렬도 적용할 수 있습니다.

Pageable pageable = PageRequest.of(0, 10, Sort.by("age").descending());

✔ 여러 필드 정렬도 가능:

Sort sort = Sort.by("teamName").ascending().and(Sort.by("age").descending());

🎨 7. 프론트 UI 연동 팁

  • Spring에서 Page<T>를 그대로 반환하면 JSON으로 다음과 같이 직렬화됨:
{
  "content": [...],
  "totalElements": 120,
  "totalPages": 12,
  "size": 10,
  "number": 0,
  "sort": {...},
  "first": true,
  "last": false
}

✔ 프론트에서는 이 데이터를 기반으로 페이지네이션 버튼이나 "다음" 버튼을 구성할 수 있습니다.


✅ 마무리 요약

항목 내용
페이징 도구 Pageable, Page<T>, Slice<T>
기본 사용 방식 리포지토리에서 Pageable 받는 메서드 선언
정렬 방식 URL 파라미터 or PageRequest.of(..., Sort)
반환 정보 Page는 전체 정보, Slice는 다음 페이지 여부 중심
활용 예 목록 조회, 검색 결과, 무한 스크롤 등
 

Spring Data JPA의 페이징은 단순한 기능 그 이상입니다.
성능 최적화와 사용자 경험을 모두 고려한 정교한 데이터 처리 전략입니다.

 

❓왜 HTML 폼 <form>은 PUT, DELETE 요청을 직접 보낼 수 없을까? - HTTP는 지원하지만, HTML은 제한한다?


✅ 1. 기본 개념: HTML <form>의 method 속성

HTML의 <form> 태그는 서버로 데이터를 전송할 때 사용하는 기본 요소입니다.

<form action="/users" method="post">
  <input type="text" name="username">
  <button type="submit">등록</button>
</form>

여기서 method는 전송 방식(HTTP 메서드)을 의미합니다.


📌 2. HTML에서 지원하는 method는 단 2개뿐

HTML 사양(HTML5 기준)에서는 <form> 태그의 method 속성에 다음 2가지만 허용합니다:

지원되는 method 의미
GET 조회 요청
POST 데이터 전송 요청

🔒 PUT, DELETE, PATCH는 명시적으로 허용되지 않음!
즉, 브라우저는 <form method="put"> 같은 걸 이해하지 못함


 

💡 왜 PUT과 DELETE는 <form>에서 지원되지 않을까?

📍 이유 1. HTML 표준의 역사적 제약

HTML은 처음부터 웹 폼을 간단한 데이터 입력용으로 설계했습니다.
초기 웹은 단순한 문서 공유 중심이었기 때문에 GET과 POST만으로 충분했습니다.
PUT, DELETE는 비교적 후대의 RESTful API 설계에서 더 중요하게 다뤄짐


📍 이유 2. 브라우저 구현 간의 호환성

  • 모든 브라우저에서 GET과 POST는 기본 동작으로 구현되어 있음
  • PUT, DELETE는 브라우저에서 직접 폼 요청으로 처리하지 않음
    → HTML 폼과 브라우저가 지원하지 않기 때문

🔄 3. 그렇다면 PUT/DELETE 요청은 어떻게 보낼까?

✅ 방법 1: JavaScript + Fetch / Axios

fetch('/users/1', {
  method: 'DELETE'
});

 

✔ 클라이언트에서 JS 코드로 명시적 요청 가능


✅ 방법 2: _method 파라미터와 서버측 변환 (Spring 방식)

Spring MVC 등에서는 HiddenHttpMethodFilter를 사용하여 POST 요청을 PUT, DELETE로 변환할 수 있습니다.

<form action="/users/1" method="post">
  <input type="hidden" name="_method" value="delete">
  <button type="submit">삭제</button>
</form>
// Spring Boot 설정에서 자동 등록됨
@Bean
public HiddenHttpMethodFilter hiddenHttpMethodFilter() {
    return new HiddenHttpMethodFilter();
}

✔ 서버가 이 hidden 필드를 보고 DELETE로 내부 처리함
✔ HTML 폼의 제한을 우회하는 대표적인 전략


🔧 4. Spring MVC 예시

@DeleteMapping("/users/{id}")
public String deleteUser(@PathVariable Long id) {
    userService.delete(id);
    return "redirect:/users";
}

위처럼 서버는 DELETE 매핑을 하고,
HTML은 POST 방식으로 보내되 hidden 필드로 _method=delete를 전송합니다.


📌 요약 정리

항목 설명
HTML <form>이 지원하는 method GET, POST 만 가능
PUT, DELETE가 안 되는 이유 HTML 표준 제한 + 브라우저 미지원
우회 방법 1 JavaScript로 요청 (fetch, axios 등)
우회 방법 2 _method 파라미터 + 서버 필터 사용 (Spring 등)
실제 권장 방식 REST API는 JS 요청, 폼 기반 앱은 _method 전략 사용

✅ 마무리

HTML 폼은 간단한 입력 처리를 위해 만들어졌기 때문에
HTTP 전체 메서드를 직접 지원하지 않습니다.
하지만, RESTful 설계를 지키기 위해
Spring과 같은 프레임워크는 이를 보완할 수단을 제공합니다.

 

실무에서는 HTML 폼으로는 POST까지만,
그 외 HTTP 메서드는 JS 기반 요청이나 서버 필터 우회 전략을 사용하세요. 💡

'백엔드' 카테고리의 다른 글

객체지향 프로그래밍(OOP)  (0) 2024.12.03

🔍 JPA findById() vs getReferenceById() 완벽 정리

- 둘 다 "ID로 조회"하지만, 작동 방식은 완전히 다르다?

 

✅ 개념부터 정리

메서드 설명
findById(id) 데이터베이스에서 실제 엔티티를 즉시 조회
getReferenceById(id) 프록시 객체를 반환하고, 실제 접근 시점에 DB 조회 (지연 로딩)

 


📌 findById(): 즉시 로딩 (Eager Loading)

Optional<Member> memberOpt = memberRepository.findById(1L);
Member member = memberOpt.get(); // 여기서 DB 즉시 조회
  • findById()는 바로 데이터베이스에 쿼리를 날리고, 엔티티 객체를 반환
  • 존재하지 않는 ID이면 Optional.empty() 반환
  • 즉시 로딩이라 N+1 문제 가능성 있음

📌 getReferenceById(): 지연 로딩 (Lazy Loading)

Member proxy = memberRepository.getReferenceById(1L); // 쿼리 X (프록시 반환)
String name = proxy.getName(); // 이 시점에 실제 DB 조회 발생!
  • Hibernate 프록시 객체 반환
  • 실제 데이터를 사용할 때까지는 DB 쿼리를 보내지 않음
  • 마치 가짜 객체처럼 보이지만, 내부에서 실제 엔티티로 연결됨

⚠️ 주요 차이점 요약

항목 findById() getReferenceById()
즉시 DB 조회 ✅ O ❌ X
프록시 반환 ❌ 아니오 ✅ 예
사용 즉시 값 확인 가능 ✅ 예 ❌ 프록시 초기화 필요
존재하지 않는 ID일 경우 Optional.empty() ❗ 런타임 오류 (EntityNotFoundException)
언제 사용? 값이 필요할 때 ID만 필요할 때 or 참조만 필요할 때

 

🧠 왜 getReferenceById를 쓸까?

Order order = new Order();
order.setMember(memberRepository.getReferenceById(1L)); // 단순 참조만 필요할 때

orderRepository.save(order); // 실제로는 member의 전체 정보가 필요 없음

✔ 성능 최적화를 위해 전체 데이터를 조회하지 않고,
ID 기반 참조만 필요한 상황에서는 getReferenceById()가 더 효율적입니다.


🧪 실전 예제 비교

log.info("=== findById ===");
Member m1 = memberRepository.findById(1L).get(); // DB 접근 O
log.info("이름: {}", m1.getName());

log.info("=== getReferenceById ===");
Member m2 = memberRepository.getReferenceById(1L); // DB 접근 X
log.info("이름: {}", m2.getName()); // 이 시점에 DB 접근

💣 주의해야 할 점

  • getReferenceById()로 가져온 프록시는 트랜잭션 범위를 벗어나면 LazyInitializationException 발생
    → 즉, 프록시는 DB와 연결이 살아있을 때만 안전하게 사용 가능
  • 존재하지 않는 ID를 getReferenceById()로 가져오면 실제로 사용할 때 예외 발생
Member m = memberRepository.getReferenceById(999L); // 여기선 오류 없음
m.getName(); // ❗ 여기서 EntityNotFoundException 발생

 

✅ 마무리 정리

구분 findById(id) getReferenceById(id)
DB 조회 시점 즉시 지연 (프록시 초기화 시점)
반환 객체 실제 엔티티 프록시 객체
반환 타입 Optional<T> T
성능 측면 다소 부담 가볍고 빠름 (단, 사용 시점에 주의)
사용 시점 실제 값이 필요할 때 단순 참조 or ID만 필요할 때

 

findById()는 "바로 쓰는 엔티티",
getReferenceById()는 "나중에 꺼내는 참조권"이라고 생각하면 이해하기 쉽습니다.

 

실제 프로젝트에서는 두 메서드를 필요에 따라 전략적으로 사용하여
불필요한 쿼리를 줄이고 성능을 최적화할 수 있습니다. 💡

 

 

+ Recent posts