Testing Concurrency in Social Login API with Kotlin Spring Boot

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:

  1. OAuthClientComposite.getClient(type: SocialType)
fun getClient(type: SocialType): OAuthClient =
    clients.find { it.supports(type) }
        ?: throw AuthException(ErrorStatus.UNSUPPORTED_SOCIAL_TYPE)

The clients list is immutable, injected via the constructor, and the find operation is read-only, making it Thread-safe.

  1. Request and Parsing Logic
    The requestProfile method calls functions like requestGet and parseBody, which internally use:
val response = restTemplate.exchange(...)
val parsed = objectMapper.readValue(...)

Both RestTemplate and ObjectMapper are 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 call upsertRefreshToken() 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 call removeOldestTokenIfLimitExceeded(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 @Version field 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!