Skill

[동시성] 비관적 락 하나로 다 해결한 줄 알았다

소범범 2026. 3. 6. 16:28

 

 

비관적 락 하나로 다 해결한 줄 알았다
(도메인 순수성과 성능 사이에서 락전략)

비관적 락 하나로 락을 정복한 줄 알았습니다..

 

잘못된 락..

 

 

TL;DR
동시성 전략은 하나로 통일하는 게 아니라, 도메인 특성에 따라 달라야 한다.
좋아요 → Atomic Update / 쿠폰 → 비관적 락 / 재고 → 비관적 락 으로 결정한 이유
그런데 도메인 아키텍처에 집착하면, 더 가벼운 도구를 놓칠 수도 있다.

 


비관적 락이면 되는 거 아니야?

이전까지 비관적락과 낙관적락을 비교하는 포스팅을 할정도로 저는 꽤 자신감이 있었습니다.

재고 차감? SELECT ... FOR UPDATE로 줄 세우면 끝. 쿠폰 이중 사용? 마찬가지로 비관적 락. 동시성 문제가 나오면 비관적 락을 꺼내면 되는 거 아닌가?

그런데 멘토링에서 이런 말씀을 하셨습니다.

"동시성 해결의 우선순위는 Atomic Update → 낙관적 락 → 비관적 락 순입니다."

 

순간 멈칫했습니다. 저는 1순위(Atomic Update)를 검토조차 하지 않고, 바로 3순위(비관적 락)로 달려간 거였거든요.

 

"아, 나는 무거운 도구를 먼저 꺼냈구나."

이 깨달음에서 고민이 시작됐습니다. 도메인별로 다시 점검해보자. 정말 비관적 락이 최선인가?

 

우선순위 전략 특징
1순위 Atomic Update DB 한 문장으로 처리. 락 보유 시간 ≈ 0
2순위 낙관적 락 먼저 실행, 나중에 충돌 감지. 충돌 적을 때 유리
3순위 비관적 락 줄 세우기. 정합성 강하지만 처리량 낮아질 수 있음

좋아요 — Atomic Update가 딱 맞는 케이스

기존 상태

기존에는 Product 엔티티에 likeCount 필드 자체가 없었습니다. 좋아요 수를 알려면 likes 테이블의 행 수를 세야 했죠.

 

"좋아요는 단순 +1/-1이잖아. 이게 Atomic Update의 교과서적 케이스 아닌가?"

맞았습니다. 좋아요 카운트는 현재 값을 읽을 필요가 없습니다. "지금 몇 개인지 모르겠지만, 1 올려줘"면 되니까요.

왜 엔티티 메서드로 안 하나?

처음에는 이렇게 하려 했습니다:

kotlin — AS-IS
// AS-IS: 엔티티 메서드로 처리
fun incrementLikeCount() {
    this.likeCount += 1  // Read-Modify-Write → Lost Update 위험
}

이게 왜 문제냐면, JPA의 dirty checking은 Read-Modify-Write 패턴이거든요.

Thread A: likeCount = 0을 읽음
Thread B: likeCount = 0을 읽음
Thread A: 0 + 1 = 1로 UPDATE
Thread B: 0 + 1 = 1로 UPDATE  ← Lost Update!
→ 2명이 좋아요했는데 likeCount = 1

그래서 DB에게 직접 맡겼습니다:

 

kotlin — TO-BE
// TO-BE: DB가 원자적으로 처리
@Modifying(clearAutomatically = true)
@Query(
    "UPDATE products SET like_count = like_count + 1 WHERE id = :productId",
    nativeQuery = true,
)
fun incrementLikeCount(@Param("productId") productId: Long)

like_count = like_count + 1은 DB가 row-level lock을 잡고 현재 값 기준으로 연산합니다. 애플리케이션이 현재 값을 읽을 필요가 없으니 Lost Update가 원천 차단됩니다.

구현하면서 마주친 함정들

1) JPA 1차 캐시 불일치

@Modifying 쿼리는 JPA 영속성 컨텍스트를 우회해서 DB를 직접 업데이트합니다. DB의 like_count는 1인데 영속성 컨텍스트에는 여전히 0이 남아있는 거죠.

해결: clearAutomatically = true로 UPDATE 실행 후 영속성 컨텍스트를 자동 클리어.

2) 음수 방지

kotlin
@Modifying(clearAutomatically = true)
@Query(
    "UPDATE products SET like_count = GREATEST(like_count - 1, 0) WHERE id = :productId",
    nativeQuery = true,
)
fun decrementLikeCount(@Param("productId") productId: Long)

정상 흐름에서는 음수가 될 일이 없지만, 데이터 마이그레이션 오류나 수동 DB 조작으로 불일치가 생길 수 있습니다. GREATEST(like_count - 1, 0)으로 DB 레벨에서 방어했습니다.

 

3) 멱등성 — 아무 때나 +1 하면 안 된다

좋아요를 누를 때 무조건 incrementLikeCount()를 호출하면, 이미 좋아요한 상태에서 다시 눌렀을 때도 카운트가 올라갑니다. 그래서 LikeService에서 상태에 따라 분기했습니다:

kotlin — LikeService.kt
@Transactional
fun addLike(userId: Long, productId: Long): LikeInfo {
    val existingLike = likeRepository.findByUserIdAndProductId(userId, productId)

    if (existingLike != null) {
        if (existingLike.isDeleted()) {
            existingLike.restore()                          // 복원 → +1
            productService.incrementLikeCount(productId)
            return LikeInfo.from(likeRepository.save(existingLike))
        }
        return LikeInfo.from(existingLike)                  // 이미 활성 → no-op
    }

    val saved = likeRepository.save(Like(userId = userId, productId = productId))
    productService.incrementLikeCount(productId)            // 신규 → +1
    return LikeInfo.from(saved)
}

 

 

기존 상태

동작 likeCount 변화
없음 (신규) Like 생성 + increment +1
활성 (중복 요청) no-op 0
삭제됨 (재좋아요) restore + increment +1

 

실제로 좋아요 상태가 변하는 경우에만 카운트를 조작합니다. 이게 빠지면 likes 테이블과 like_count의 싱크가 어긋납니다.

동시성 테스트 — 10명이 동시에 좋아요

kotlin — LikeConcurrencyTest.kt
@DisplayName("서로 다른 10명이 동시에 좋아요하면, likeCount가 정확히 10이 된다.")
@Test
fun maintainsCorrectLikeCount_whenConcurrentLikesFromDifferentUsers() {
    // arrange
    val threadCount = 10
    val product = productJpaRepository.save(Product(...))
    val latch = CountDownLatch(1)
    val executorService = Executors.newFixedThreadPool(threadCount)

    // act
    repeat(threadCount) { index ->
        val userId = (index + 1).toLong()
        executorService.submit {
            latch.await()
            likeService.addLike(userId = userId, productId = product.id)
        }
    }
    latch.countDown()
    executorService.shutdown()
    executorService.awaitTermination(10, TimeUnit.SECONDS)

    // assert
    val updatedProduct = productJpaRepository.findById(product.id).get()
    assertThat(updatedProduct.likeCount).isEqualTo(threadCount) // 정확히 10!
}

만약 엔티티 메서드(this.likeCount += 1)로 했다면 Lost Update로 10이 안 나왔을 겁니다.

Atomic Update 덕분에 정확히 10.


쿠폰 — 낙관적 락을 고려했는데 포기한 이유

처음 생각: "쿠폰은 낙관적 락이 맞지 않나?"

쿠폰은 한 유저에게 발급된 것이기 때문에, 동시 요청이라 해봐야 같은 유저가 다중 기기에서 동시에 쓰는 정도입니다. 충돌 빈도가 낮으니 낙관적 락이 맞는 것 같았습니다.

 

멘토링에서 다른 수강생 님도 같은 판단을 했더군요:

"쿠폰은 한 유저 소유 → 동시 요청은 다중 기기뿐 → 1건만 성공하고 나머지는 실패시키면 충분 → 낙관적 락"

 

합리적인 판단이었습니다. 쿠폰만 놓고 보면요.

문제: 쿠폰은 주문 트랜잭션 안에서 사용된다

쿠폰 사용은 독립된 API가 아니라, OrderFacade.createOrder() 안에서 일어납니다:

kotlin — OrderFacade.kt
@Transactional
fun createOrder(userId: Long, criteria: List<OrderItemCriteria>, couponId: Long?): OrderInfo {
    // 1. 상품 비관적 락 획득 + 재고 차감
    val products = productService.getProductsWithLock(productIds)
    val reservedProducts = productService.reserveStock(products, criteria)

    // 2. 쿠폰 적용
    if (couponId != null) {
        val issuedCoupon = couponService.getIssuedCouponWithLock(couponId)
        issuedCoupon.validateOwner(userId)
        issuedCoupon.validateUsable()
        // ... 할인 계산 + 쿠폰 사용 처리
    }

    // 3. 주문 생성
    val order = orderService.createOrder(userId, orderItemCommands, couponId)
    return OrderInfo.from(order)
}

여기서 낙관적 락을 쓰면 어떻게 되나 생각해봤습니다:

1. Thread A, B가 동시에 같은 쿠폰으로 주문 시도
2. 둘 다 재고 차감 성공 (비관적 락으로 순차 처리)
3. Thread A가 쿠폰을 USED로 변경 → 커밋 성공
4. Thread B가 쿠폰 변경 시도 → OptimisticLockException
5. 이미 차감된 재고까지 전체 롤백

재시도하면 될까요? Thread A가 이미 쿠폰을 USED로 바꿨으니, 재시도해도 validateUsable()에서 실패합니다. 결과는 똑같이 실패.

멘토님 세션에서 이 부분이 정확히 짚혔습니다:

"주의: 재시도하는 것이 낙관적 락이 아님! 실패해도 괜찮을 때 낙관적 락을 써야 함."

쿠폰 단독이면 낙관적 락이 맞습니다. 충돌이 나면 "이미 사용된 쿠폰입니다"라고 알려주면 끝이니까요. 하지만 주문 플로우 안에서는 쿠폰 충돌이 재고 롤백까지 연쇄시키는 문제가 있었습니다.

결론: 비관적 락

주문 트랜잭션의 원자성을 깨뜨리지 않으려면, 쿠폰도 비관적 락으로 "줄 세우기"를 하는 게 합리적이었습니다.

kotlin
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT ic FROM IssuedCoupon ic WHERE ic.id = :id")
fun findByIdWithLock(id: Long): IssuedCoupon?

동시성 테스트 — 같은 쿠폰으로 10명이 동시에 주문

kotlin — CouponConcurrencyTest.kt
@DisplayName("동일 발급 쿠폰으로 10개 스레드가 동시에 주문하면, 정확히 1개만 성공한다.")
@Test
fun allowsOnlyOneUsage_whenConcurrentCouponUse() {
    // arrange
    val threadCount = 10
    val userId = 1L
    // ... 상품(재고 100), 쿠폰, 발급 쿠폰 생성

    val latch = CountDownLatch(1)
    val successCount = AtomicInteger(0)
    val failCount = AtomicInteger(0)

    // act
    repeat(threadCount) {
        executorService.submit {
            latch.await()
            orderFacade.createOrder(userId = userId, criteria = criteria, couponId = issuedCoupon.id)
            successCount.incrementAndGet()
        }
    }
    latch.countDown()

    // assert
    assertThat(successCount.get()).isEqualTo(1)                          // 1건만 성공
    assertThat(failCount.get()).isEqualTo(9)                             // 9건 실패
    assertThat(updatedCoupon.status).isEqualTo(IssuedCouponStatus.USED)  // 쿠폰 USED
}

1건만 성공하고 9건은 실패. 쿠폰은 USED. 정확합니다.


재고 — 비관적 락이 합리적이었던 진짜 이유

"재고도 Atomic Update 가능하지 않나?"

이론적으로는 가능합니다:

sql
UPDATE products SET stock = stock - 3 WHERE id = 1 AND stock >= 3;

DB가 알아서 "재고 충분하면 차감, 부족하면 실패" 처리합니다. 간단하고 빠릅니다.

그런데 왜 안 썼나 — 도메인 모델이 이미 결정했다

저는 Rich Domain Model을 먼저 설계했습니다:

kotlin — Product.kt
// 도메인 엔티티에 비즈니스 로직을 캡슐화
fun reserve(quantity: Int): Boolean {
    if (quantity <= 0) {
        throw CoreException(ErrorType.BAD_REQUEST, "예약 수량은 1 이상이어야 합니다.")
    }
    if (!hasEnoughStock(quantity)) return false
    this.stock -= quantity
    return true
}

product.reserve()라는 도메인 메서드를 만든 순간, "엔티티를 메모리에 올려서 메서드를 호출한다"는 Read-Modify-Write 패턴이 확정된 겁니다.

Atomic Update는 엔티티를 거치지 않는 쿼리입니다. 풍부한 도메인 메서드와 정면 충돌합니다.

돌이켜보면, 동시성 전략은 "락을 뭘 쓸까?" 시점에서 결정된 게 아니라,

도메인 모델을 설계하는 시점에서 이미 방향이 정해져 있었습니다.

설계할 때는 동시성을 아직 고민하기 전이었으니까 자연스럽게 놓친 거죠.

"그러면 낙관적 락은?"

재고는 인기 상품에 동시 주문이 몰리는 케이스입니다. 충돌 빈도가 높죠.

여기에 더해서, 주문에 상품이 여러 개 담기면 충돌 확률이 으로 증가합니다. 상품 A 성공 확률 80%, 상품 B 성공 확률 80%라면 둘 다 성공할 확률은 64%로 떨어지고, 상품이 더 많아지면 재시도 폭풍이 옵니다.

비관적 락은 줄 세우기입니다. 선착순이 보장되고, 실패한 요청이 재시도 루프를 돌지 않아도 됩니다.

kotlin — ProductJpaRepository.kt
// IN절로 한번에 여러 상품의 락을 획득 — 데드락 방지
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT p FROM Product p WHERE p.id IN :ids AND p.deletedAt IS NULL")
fun findAllByIdWithLock(ids: List<Long>): List<Product>

동시성 테스트 — 재고 5개에 10명이 동시 주문

kotlin — StockConcurrencyTest.kt
@DisplayName("재고 5개인 상품에 10개 스레드가 동시에 주문하면, 정확히 5개만 성공한다.")
@Test
fun maintainsStockIntegrity_whenConcurrentOrders() {
    // arrange
    val threadCount = 10
    val initialStock = 5

    val latch = CountDownLatch(1)
    val successCount = AtomicInteger(0)
    val failCount = AtomicInteger(0)

    // act
    repeat(threadCount) { index ->
        executorService.submit {
            latch.await()
            orderFacade.createOrder(userId = index.toLong() + 1, criteria = criteria)
            successCount.incrementAndGet()
        }
    }
    latch.countDown()

    // assert
    assertThat(successCount.get()).isEqualTo(initialStock)                // 5개만 성공
    assertThat(failCount.get()).isEqualTo(threadCount - initialStock)     // 5개 실패
    assertThat(updatedProduct.stock).isEqualTo(0)                         // 재고 0
}

락이 없었다면 10개 스레드가 모두 "재고 5개 남았네" 읽고 동시에 차감해서 재고가 음수가 됐을 겁니다. 비관적 락 덕분에 정확히 5개만 성공.


잠깐, 도메인 모델에 너무 집착한 건 아닐까?

여기까지 정리하고 나니 한 가지 찜찜한 게 남았습니다.

재고 섹션에서 저는 "Rich Domain Model이 Read-Modify-Write를 강제했기 때문에 비관적 락을 선택했다"고 썼습니다. 그런데 이걸 뒤집어 보면 이런 질문이 됩니다.

"도메인 모델을 지키려고, 성능상 더 나은 선택지를 포기한 거 아닌가?"

솔직히 맞습니다.

재고 Atomic Update가 가능했던 세계

다시 한번 봐보겠습니다.

sql
UPDATE products SET stock = stock - 3 WHERE id = 1 AND stock >= 3;

이 한 줄이면 됩니다. DB가 stock >= 3 검증과 stock - 3 차감을 원자적으로 처리합니다. 락 보유 시간은 거의 0이고, 애플리케이션이 재고를 읽을 필요도 없습니다.

멘토님도 세션에서 정확히 이 부분을 짚어주셨습니다.

"조건 검증 + 상태 변경을 DB에 위임할 수 있을 때 Atomic Update가 1순위."

"애플리케이션의 재고를 읽고 계산할 필요 없이 DB 위임으로 충돌을 줄일 수 있다."



그런데 저는 product.reserve()라는 도메인 메서드를 먼저 만들어놓고, "이미 R-M-W 패턴이니까 Atomic Update는 안 되지"라고 결론 내렸습니다.

진짜 문제: 도구를 목적으로 착각한 것

"Atomic Update를 쓰려면 도메인 로직을 쿼리로 내려야 하는데, 그러면 도메인 모델이 빈약해지는 트레이드오프를 어떻게 판단하시나요?"

이 질문을 던지면서도 속으로는 "Rich Domain Model이 맞으니까 비관적 락이 정답이겠지"라고 생각하고 있었습니다.

DDD, 헥사고날 아키텍처 같은 설계 원칙을 지켜야 할 목표로 놓고 있었던 거죠.

 

그런데 생각해보면, product.reserve() 안에 있는 로직은 결국 이겁니다:

pseudocode
1. quantity > 0 검증
2. stock >= quantity 검증
3. stock -= quantity

이건 SQL WHERE절 하나로 충분히 표현 가능합니다. 굳이 엔티티를 메모리에 올리고, 비관적 락으로 줄 세우고, 트랜잭션이 끝날 때까지 커넥션을 점유할 이유가 있었을까요?

트레이드오프를 직시하면

  Rich Domain Model + 비관적 락 Atomic Update
도메인 표현력 product.reserve() — 비즈니스 의도가 명확 SQL에 로직이 숨음
단위 테스트 Mock 없이 순수 도메인 테스트 가능 Repository 레벨 테스트 필요
동시성 성능 트랜잭션 끝까지 락 보유, 커넥션 점유 락 보유 시간 ≈ 0, 커넥션 즉시 반환
확장성 트래픽 증가 시 커넥션 풀 고갈 위험 트래픽 증가에 강함

표에 적어놓고 보니 "커넥션 풀 고갈 위험"이 눈에 밟혔습니다. 이론으로만 끝내면 찜찜하니까, 진짜 고갈되는지 테스트로 증명해봤습니다.

커넥션 풀 고갈 — 테스트로 증명하기

테스트 환경을 극단적으로 만들었습니다. @TestPropertySource로 커넥션 풀을 5개로 줄이고, connection-timeout을 HikariCP 최솟값인 250ms로 설정했습니다.

kotlin — ConnectionPoolExhaustionTest.kt
@SpringBootTest
@TestPropertySource(
    properties = [
        "datasource.mysql-jpa.main.maximum-pool-size=5",
        "datasource.mysql-jpa.main.minimum-idle=5",
        "datasource.mysql-jpa.main.connection-timeout=250",
    ],
)
class ConnectionPoolExhaustionTest {

재고 100개인 상품에 100개 스레드가 동시에 1개씩 주문합니다. 재고는 충분합니다. 비즈니스 로직상 100건 모두 성공해야 합니다.

kotlin — 핵심 검증 로직

 

@DisplayName("커넥션 풀(5개)보다 훨씬 많은 스레드(100개)가 비관적 락을 사용하면, 커넥션 풀 고갈로 일부 주문이 실패한다.")
@Test
fun connectionPoolExhaustion_whenConcurrentPessimisticLockOrders() {
    val threadCount = 100
    val initialStock = 100
    // ... 상품 생성, CountDownLatch + ExecutorService로 동시 실행

    // assert
    val updatedProduct = productJpaRepository.findById(product.id).get()
    assertAll(
        // 커넥션 풀 고갈로 일부 스레드가 실패해야 한다
        { assertThat(failCount.get()).isGreaterThan(0) },
        // 재고가 남아있다 = 비즈니스 로직이 아닌 인프라 한계로 실패
        { assertThat(updatedProduct.stock).isGreaterThan(0) },
        // 성공 + 실패 = 전체 스레드 수
        { assertThat(successCount.get() + failCount.get()).isEqualTo(threadCount) },
    )
}

결과는 명확했습니다. 재고가 남아있는데도 주문이 실패합니다.

비관적 락이 SELECT FOR UPDATE로 row를 잠그면, 그 트랜잭션이 끝날 때까지 커넥션을 반환하지 않습니다. 5개 커넥션이 모두 락 대기 중이면, 나머지 95개 스레드는 커넥션 자체를 얻지 못하고 250ms 후 SQLTransientConnectionException으로 실패합니다.

재고는 충분하다. 비즈니스 로직은 문제없다.

그런데 주문이 실패한다. 인프라가 병목이 된 것이다.

 

멘토님이 하신 말씀이 떠오릅니다.

"DB 커넥션 풀을 소중하게 여겨야 된다는 사명감을 안고 달리는 회사에 있다 보니까,
비관적 락으로 풀어도 절대 문제 생기지 않는다고 확신할 수 있는 부분에 대해서만 비관적 락을 썼다."

실무에서는 도메인 모델의 우아함보다 커넥션 풀 몇 개를 아끼느냐가 서비스의 생사를 가를 수 있다는 거죠.

이론이 아니라 테스트 코드가 보여주는 현실입니다.

그래서 실무에서는 비관적 락을 잘 안 쓴다

이 테스트를 돌리고 나니 확실히 깨달았습니다. 비관적 락은 정합성은 완벽하지만, 처리량에 구조적 한계가 있습니다.

트래픽이 적으면 문제없습니다. 하지만 인기 상품에 주문이 몰리는 순간, 커넥션 풀이 병목이 됩니다.

커넥션 풀을 늘리면 DB 부하가 올라가고, 그렇다고 안 늘리면 주문이 실패하고. 진퇴양난입니다.

그래서 실무에서는 비관적 락 대신 다른 선택지를 씁니다:

  • Atomic Update: UPDATE SET stock = stock - 1 WHERE stock >= 1. 락 보유 시간 ≈ 0, 커넥션 즉시 반환
  • 낙관적 락: 먼저 실행, 충돌 나면 재시도. 충돌이 적은 도메인에 적합
  • Redis 분산 락: DB 커넥션을 아예 안 쓰고 Redis에서 순서를 제어
  • 메시지 큐: 주문 요청을 큐에 넣고 순차 처리. 트래픽 급증에도 안정적

이번 포스팅에서는 비관적/낙관적 락에 딥다이브하는 시간이었지만, 비관적 락의 구조적 한계를 직접 확인했으니

다음 포스팅에서 분산 락과 메시지 큐 같은 실무적 해결책을 다뤄보겠습니다.


정리 — 도메인이 전략을 골라주되, 맹목적으로 따르지 말자

한눈에 보는 비교표

도메인 전략 왜 이 전략인가 놓친 것은 없나?
좋아요 Atomic Update 단순 +1/-1. 현재 값을 읽을 필요 없음 도메인 특성과 기술 선택이 일치하는 케이스
쿠폰 비관적 락 주문 트랜잭션 안에서 충돌 시 재고까지 롤백 단독 API였다면 낙관적 락이 맞았을 것
재고 비관적 락 Rich Domain Model이 R-M-W를 강제 Atomic Update가 성능상 더 나았을 수 있음

돌아보며

처음에는 "비관적 락 하나로 통일하면 되지"라고 생각했습니다.

멘토님의 "Atomic Update → 낙관적 락 → 비관적 락 순으로 검토하라"는 피드백을 받고 도메인별로 다시 점검했더니, 좋아요는 Atomic Update가 딱 맞는 케이스였고, 쿠폰은 낙관적 락을 고려했다가 주문 플로우의 특성 때문에 포기했고, 재고는 도메인 모델 설계가 이미 비관적 락을 결정해놓은 상태였습니다.

그런데 여기서 멈추면 안 됩니다. 비관적 락을 선택했더니 커넥션 풀 고갈이라는 구조적 한계를 만났습니다. 테스트로 직접 증명했듯이, 재고가 충분한데도 주문이 실패하는 상황은 비즈니스적으로 용납할 수 없습니다.

도메인 아키텍처는 의사결정을 돕는 도구이지, 다른 선택지를 차단하는 벽이 되면 안 됩니다. "Rich Domain Model이니까"라는 이유로 Atomic Update를 검토조차 안 한 건 제 실수였습니다.

중요한 건 "비관적 락을 썼다"가 아니라, "이 선택의 비용을 알고 있으며, 한계를 넘어서면 어떻게 대응할 것인지"를 설명할 수 있느냐입니다.

가장 가벼운 도구부터 꺼내고, 그것으로 안 될 때 다음 도구로 가자.

"안 된다"가 기술적 제약인지, 내가 만든 설계적 제약인지는 구분하자.

다음 포스팅에서는 이 한계를 넘어서는 방법들을 다뤄보겠습니다.