익명 사용자 초기화 엔드포인트에서 동시성 제어가 필요해(에러 발생) 비관적 락을 적용했습니다. 처음에는 서비스 레이어에 락을 걸었지만, 코드의 책임을 더 명확히 하고 성능을 해치지 않도록 리팩토링을 진행했습니다. 이 글에선 그 과정에서 만난 문제들과 해결 과정을 정리해보겠습니다!
배경
사실 같은 uuid로 동시 요청이 들어오는게 굉장히 엣지 케이스라 예측하지 못했었는데, 막상 개발 서버에 머지하고 나니 다음과 같은 에러가 떴습니다.

이처럼 실제 개발 환경에서는 초기화 API는 같은 uuid로 동시에 요청이 들어올 수 있습니다. 이때 같은 사용자가 중복 생성되지 않도록, 조회 단계에서 비관적 락으로 선점해주는 전략이 필요했습니다.
그런데!! 그 전에 빠른 문제 해결을 위해 일단 전체 메서드에 락을 걸어놓은 코드가 존재했습니다. 이를 리팩토링한 과정입니다.
문제 1: 락을 서비스 메소드에 달아 범위가 넓음
서비스 메소드 전체에 @Lock(PESSIMISTIC_WRITE)를 달면, 단 하나의 쿼리가 아니라 메소드 전반이 락의 영향권에 들어갑니다. 비즈니스 로직이 길어질수록 불필요한 대기 시간이 생기고, 코드 책임도 흐려집니다.
'서비스 메서드 하나 실행시간이 길면 얼마나 길다고'라고 생각할수도 있지만, ThatzFit의 케이스는 가상피팅이라는 약 10초짜리 연산이 있기때문에 무려 10초 동안 락을 걸고 커넥션을 점유하고 있을 수 있습니다.
물론 비동기로 처리하고 폴링이나 SSE를 활용하는 것이 베스트겠지만 아직은 일단 동작하는 것을 확인하는 단계라 하나의 Rest API로 처리했습니다(추후 리팩토링 예정)
해결 1: 락을 Repository 메소드에만 적용했습니다
락은 “데이터를 읽는 바로 그 쿼리”에만 붙는 것이 가장 명확하고 안전합니다. 그래서 Repository 메소드로 책임을 옮겼습니다. 이렇게 하면 락의 범위가 쿼리 단위로 좁혀지고, 서비스는 순수 비즈니스 흐름에 집중할 수 있습니다.
문제 2 : 인증 필터에서 트랜잭션이 없어 예외가 발생
해결1에서 언급했듯이 레포지토리 레이어의 findByUuid 에 락을 걸어 동시성 문제를 해결했습니다. 그런데 UuidAuthenticationFilter가 findByUuid를 호출하는데, 여기에 락을 달아두니와 같은 아래 예외가 발생했습니다.
- TransactionRequiredException: 트랜잭션이 필요한데 현재 컨텍스트가 트랜잭션이 아님
필터는 트랜잭션 밖에서 동작합니다. 즉, “락이 필요한 메소드”를 트랜잭션과 무관한 필터에서 호출해서 위의 에러가 발생한 것입니다.
해결 2 — 조회 메소드를 두 가지로 분리
findByUuid: 락 없이 단순 조회 (필터·읽기 전용 경로에 사용)findByUuidWithLock:@Lock(PESSIMISTIC_WRITE)로 보호된 조회 (서비스 트랜잭션 내에서 사용)
서비스의 initializeUser에서는 findByUuidWithLock으로 동시성 제어를 하고, 필터에서는 락 없는 findByUuid로 인증만 수행하도록 역할을 나눴습니다.
문제 3 — Spring Data JPA가 메소드 이름을 잘못 해석
메소드 이름을 findByUuidWithLock으로 만들었더니 Spring Data가 이를 “엔티티 속성 경로”로 파싱하려고 해서 다음 에러가 났습니다.
- No property 'withLock' found for type 'String'
해결 3 — @Query로 의도를 명시
메소드 이름 파싱에 의존하지 않고, JPQL을 명시했습니다.
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT u FROM User u WHERE u.uuid = :uuid")
Optional<User> findByUuidWithLock(@Param("uuid") String uuid);
이렇게 하면 “이 쿼리는 uuid로 조회하고, 비관적 락을 건다”는 의도가 분명해집니다.
검증
기대한 그대로 동작함을 여러 경로를 통해 파악했습니다.
- SQL 로그에
FOR NO KEY UPDATE가 출력되어 락 적용을 확인했습니다. - 같은
uuid로 동시 요청이 와도, 첫 요청만 생성하고 나머지는 기존 사용자를 반환합니다.(같은 사용자 동시 요청 10개, Locust로 RPS 30까지 부하 올려서 테스트 수행) - 필터는 락 없는 조회를 쓰므로 트랜잭션이 없어도 이론상 안전하게 동작합니다.

교훈
- 락은 Repository 메소드에만, 필요한 쿼리에만 최소 범위로 적용합니다.
- 필터/인터셉터 등 트랜잭션 밖에서 호출되는 경로는 “락 없는 조회”를 사용합니다.
- 락이 필요한 조회는 서비스 트랜잭션 내에서만 호출합니다.
- 메소드 이름이 애매하면
@Query로 의도를 명확히 합니다.
결과적으로, 책임이 분리되어 코드가 읽기 좋아졌고, 락 범위가 줄어 성능과 안정성이 함께 개선되었습니다.
'프로덕트 문제 해결 기록' 카테고리의 다른 글
| WebClient와 CachingFilter 사용 시 InputStream 재사용 오류 해결 (0) | 2025.09.03 |
|---|---|
| [ThatzFit] AOP로 로깅 일원화 (0) | 2025.09.02 |
| [ThatzFit] 트랜잭션 최적화: 쓰기 경계 분리 (1) | 2025.09.01 |
| [ThatzFit] API 에러 모니터링: 체계적인 슬랙(Slack) 알림 시스템 구축기 (0) | 2025.08.14 |
| [WIMI] Spring에서 "No acceptable representation" 오류 (0) | 2025.03.28 |
