Introduction
I will test concurrency using a synchronously written social login API and study pessimistic and optimistic locking in transactions.
Feel free to provide feedback!
How to Identify Concurrency Issues?
1. Identifying Shared Resources
Most concurrency issues occur when multiple threads try to modify the same data simultaneously.
Common shared resources include:
- Global variables or static fields
- State of singleton objects
- Specific records in the database
- Cache keys (e.g., Redis)
- Files, sessions, queues, etc.
2. Stateless vs. Stateful
If a service or method is stateless, it is generally free from concurrency issues. On the other hand, stateful objects can be risky.
For example, a stateless singleton might look like this:
@Component
class MutableCounter {
var count = 0
fun increment() { count++ }
}
This class is injected as a singleton, and its count can be modified simultaneously across multiple requests → This introduces a risk of Race Condition.
3. Suspicions at Transaction Boundaries
Just because a database operation is wrapped in a transaction doesn't always make it safe.
For example, if you have logic to prevent duplicate user registrations like this:
@Transactional
fun register(email: String) {
if (userRepository.existsByEmail(email)) {
throw IllegalStateException("Email already registered.")
}
userRepository.save(User(email))
}
There is a potential race condition between existsByEmail and save, where other threads may intervene in between, leading to concurrency issues.
Now, let’s examine the social login code to find potential concurrency issues.
Social Login Flow
Concurrency issues in a social login API often occur when "the same user tries to log in multiple times simultaneously."
For example, if the signup logic uses an "if (not found) save" structure, multiple incoming requests may all conclude that the user doesn’t exist and create duplicate users.
The token issuance logic is similar. The logic that refreshes the user's refreshToken and deletes the old one is prone to race conditions because it deals with shared resources.
Therefore, whenever there is a structure like "retrieve → if not found, save" or "refresh and delete," it’s crucial to validate through concurrency testing and defend with database constraints or transaction handling.
Let's first take a simple look at the social login process.
The steps are as follows. Let’s briefly review them with the code:
1. User clicks the social login button

2. Send login request to the server
When a request is sent in the format /login/{type}?code=xxx, the server retrieves user information based on the type of social platform.
3. Retrieve user information
The server sends a request to the social platform using the received code and retrieves the user’s email, profile image, etc.
4. Check if the user already exists
The server checks if the user with the given email already exists in the system.
- If yes → Proceed with login
- If no → Register as a new user
5. Issue tokens and respond
Once login or registration is complete, the server responds with the Access Token and Refresh Token.
Let’s review the code that handles this process.
Code Review
Here is the controller code:
@Operation(summary = "Social Login API", description = "Performs login or registration based on SocialType (KAKAO, GOOGLE, NAVER, etc.)")
@ApiResponses(
// omitted
)
@GetMapping("/login/{type}")
fun signIn(
@RequestParam("code") accessCode: String, // Auth code received from the social platform
@PathVariable("type") type: SocialType, // Social platform type (KAKAO, NAVER, etc.)
@Parameter(hidden = true) @ExtractDeviceId deviceId: String, // Client device ID extracted from the header
): BaseResponse<AuthUserResponse> {
return BaseResponse.onSuccess(
SuccessStatus.OK,
authService.signInWithSocial(accessCode, type, deviceId),
)
}
Here’s the corresponding service method called by the controller:
@Transactional
override fun signInWithSocial(
accessCode: String,
type: SocialType,
deviceId: String,
): AuthUserResponse {
// 1. Request user profile information from the social platform using accessCode
val profile = oAuthClientComposite.getClient(type).requestProfile(accessCode = accessCode)
val email = profile.getEmail()
// 2. Find existing user by email, or save new user if not found
val user = userRepository.findByEmail(email) ?: userRepository.save(
User(
email,
profileImage = s3Properties.s3.defaultProfileImageUrl, // Default profile image
),
)
// 3. Generate accessToken and refreshToken for the user
val (refreshToken, accessToken) = issueNewToken(email, deviceId)
// 4. Return the user information with the tokens
return AuthUserResponse(
email,
accessToken = accessToken,
refreshToken = refreshToken,
signUpStatus = user.signUpStatus,
)
}
The method oAuthClientComposite.getClient(type).requestProfile(accessCode = accessCode) involves some internal logic which doesn’t introduce concurrency issues, so I’ll skip explaining that here.
🔍 Why OAuthClientComposite and OAuthClient are free from concurrency issues:
OAuthClientComposite.getClient(type: SocialType)fun getClient(type: SocialType): OAuthClient = clients.find { it.supports(type) } ?: throw AuthException(ErrorStatus.UNSUPPORTED_SOCIAL_TYPE)The
clientslist is immutable, injected via the constructor, and thefindoperation is read-only, making it Thread-safe.
- Request and Parsing Logic
TherequestProfilemethod calls functions likerequestGetandparseBody, which internally use:val response = restTemplate.exchange(...) val parsed = objectMapper.readValue(...)Both
RestTemplateandObjectMapperare Spring beans, designed to be thread-safe even when injected as singletons, ensuring no concurrency issues.
Here’s the issueNewToken method used to generate the tokens:
private fun issueNewToken(email: String, deviceId: String): Pair<String, String> {
val now = Instant.now()
// 1. Generate Access Token and Refresh Token based on current time
val accessToken = jwtProvider.createAccessToken(now, email, deviceId)
val refreshToken = jwtProvider.createRefreshToken(now, email, deviceId)
// 2. Upsert the refresh token in the database (update if exists, insert if not)
refreshTokenService.upsertRefreshToken(email, deviceId, refreshToken)
// 3. If there’s a limit on the number of tokens, remove the oldest one to maintain the count
refreshTokenService.removeOldestTokenIfLimitExceeded(email)
// 4. Return the generated Refresh Token and Access Token
return Pair(refreshToken, accessToken)
}
Now, where can concurrency issues occur in this code?
Potential Concurrency Issues
1. User Creation in signInWithSocial
val user = userRepository.findByEmail(email) ?: userRepository.save(
User(email, profileImage = s3Properties.s3.defaultProfileImageUrl)
)
The problem here is that if multiple requests with the same email come in at the same time, findByEmail will return null, but another request may have already saved the user in between.
→ This can result in (1) a duplicate user being created, or (2) a database constraint violation exception.
However, since the database has a unique constraint on the email field, (2) a database constraint violation exception is more likely.
2. Token Update and Deletion
In issueNewToken, the method calls upsertRefreshToken and deletes the oldest token if needed.
- Upsert Conflict
If two threads callupsertRefreshToken()nearly simultaneously, a race condition can occur.
Both threads will think the token doesn’t exist and will try to insert it, causing a constraint violation. - Deletion Condition Conflict
If two threads callremoveOldestTokenIfLimitExceeded(email)at the same time, both may determine the same oldest token to delete and end up trying to delete the same token, causing a potential conflict.
Concurrency Testing
When multiple requests with the same accessCode, SocialType, and deviceId come in simultaneously:
- Ensure that the User is created only once.
- Ensure that the RefreshToken is stored correctly and not duplicated.
Here’s a test case for it:
@Test
@DisplayName("Even with multiple simultaneous requests, user should only be created once")
fun signInWithKakaoForConcurrencyTest() {
// Given
val accessCode = "mock-code"
val type = SocialType.KAKAO
val deviceId = "deviceId"
val email = "test@example.com"
val newRefreshToken = "new-refresh-token"
val newAccessToken = "new-access-token"
val kakaoProfile = KakaoProfile(kakaoAccount = KakaoAccount(email = email))
val threadCount = 10
val latch = CountDownLatch(threadCount)
val executor = Executors.newFixedThreadPool(threadCount)
// When
whenever(oAuthClientComposite.getClient(SocialType.KAKAO)).thenReturn(kakaoOAuthClient)
whenever(kakaoOAuthClient.requestProfile(any())).thenReturn(kakaoProfile)
whenever(jwtProvider.createRefreshToken(any(), any(), any())).thenReturn(newRefreshToken)
whenever(jwtProvider.createAccessToken(any(), any(), any())).thenReturn(newAccessToken)
val futures = mutableListOf<Future<*>>()
repeat(threadCount) {
val future = executor.submit {
try {
authService.signInWithSocial(accessCode, type, deviceId)
logger.info("Request successful")
} finally {
latch.countDown()
}
}
futures.add(future)
}
latch.await()
// Verify all future results and check for any exceptions
futures.forEach { future ->
future.get() // Fail the test if an exception is thrown here
}
logger.info("All threads completed")
}
If you run this test, you might encounter exceptions, and the logs will indicate where the issues occur.
Pessimistic Lock vs Optimistic Lock
1. Pessimistic Lock
"Let’s lock it because another transaction might modify this data."
- Lock the data during retrieval.
- Other transactions are blocked from accessing the record until the lock is released.
- Example:
SELECT ... FOR UPDATE
Advantages:
- Ensures conflict prevention.
- Useful for critical processes like financial transactions or stock reduction.
Disadvantages:
- Lock contention can slow down performance.
- Deadlocks are possible.
2. Optimistic Lock
"Everyone can modify freely, but if there’s a change during saving, it will fail."
- No lock during retrieval.
- Conflict is detected during saving, using version fields.
- Add a
@Versionfield to the entity.
@Entity
class User(
...
@Version
var version: Long? = null
)
Advantages:
- No lock, so it performs better.
- Suitable for read-heavy systems.
Disadvantages:
- Requires retry logic if a conflict occurs.
Improving the Code
1. User Creation (findByEmail() → save())
The issue is that if two requests come in simultaneously, both threads might conclude that the user doesn’t exist and both call save(), violating the unique key constraint.
Here’s how to fix it:
(1) Pessimistic Lock
Apply a row-level lock based on email, so if one thread locks it, others must wait.
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT u FROM User u WHERE u.email = :email")
fun findByEmail(email: String): User?
val user = userRepository.findByEmailForUpdate(email) ?: userRepository.save(User(...))
This ensures that if two threads try to save the same email simultaneously, one will wait.
(2) Unique Constraint + Exception Handling (Optimistic Approach)
Apply a unique constraint on email in the database, and if a conflict occurs, catch the exception and handle it.
try {
userRepository.save(User(...))
} catch (e: DataIntegrityViolationException) {
userRepository.findByEmail(email)!!
}
Performance is better, but retry logic is required when exceptions occur.
Refresh Token Save/Delete (upsertRefreshToken & removeOldest...)
When the same user logs in from the same device at the same time, conflicts occur with upsert and deletion.
(1) Pessimistic Lock
Lock the user's token list FOR UPDATE before performing upsert and deletion.
Example query:
SELECT * FROM refresh_token WHERE email = ? FOR UPDATE;
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT rt FROM RefreshToken rt WHERE rt.email = :email AND rt.deviceId = :deviceId")
fun findTokenForUpdate(email: String, deviceId: String): RefreshToken?
(2) Optimistic Lock
Add a @Version field to the RefreshToken entity and check for conflicts during save.
@Entity
class RefreshToken(
...
@Version
var version: Long? = null
)
If a conflict occurs, an OptimisticLockException is thrown and retry logic is needed.
Which Method to Choose?
Choosing between pessimistic and optimistic locking depends on the situation.
Here’s a comparison:
| Criterion | Pessimistic Lock | Optimistic Lock |
|---|---|---|
| Conflict Likelihood | Frequent (e.g., same email login, stock reduction) | Rare (e.g., mostly read-heavy, occasional updates) |
| Performance | Slower (locks cause delays with many requests) | Faster (no locks, but retries needed) |
| Data Integrity | Essential (e.g., preventing duplicates, financial calculations) | Resolves via retries (acceptable for occasional failures) |
| Complexity | Simple (handled within transactions) | Complex (requires @Version, retry logic) |
| Use Cases | Duplicate user prevention, stock reduction, payment processing | Post likes, detecting changes in read-heavy screens |
In my case, there’s a high likelihood of conflicts, I need to prevent duplicates, and the logic is simple enough (handled within @Transactional + FOR UPDATE), so I chose Pessimistic Locking.
Now, let’s refactor the code to ensure only one user is created and RefreshTokens are safely upserted without duplication.
Add Pessimistic Locking to UserRepository
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT u FROM User u WHERE u.email = :email")
fun findByEmail(email: String?): User?
PESSIMISTIC_WRITE locks the record, ensuring other transactions must wait to read or write it.
Conclusion
In this post, I explored the flow of a social login API, identified potential concurrency issues, and provided solutions to prevent them.
Key concurrency issues to be aware of include:
- Duplicate user creation when multiple requests for the same email come in simultaneously.
- Token saving/deletion conflicts when the same user logs in from multiple devices.
To prevent such issues, the following strategies are necessary:
- Ensure unique constraints on the database.
- Apply Pessimistic Lock or Optimistic Lock depending on the situation.
- Validate with real concurrency tests in the test code.
APIs like social logins, which are the entry points for users, require extra caution in testing. Thanks for reading!