동시에 두 사용자가 같은 데이터를 수정하면 마지막 저장이 앞선 변경을 덮어쓸 수 있습니다. 트래픽이 크지 않아도 관리자 화면과 배치 작업에서 자주 생기는 문제입니다. 낙관적 락을 이해하면 불필요한 DB 락 없이도 갱신 충돌을 감지할 수 있습니다.
낙관적 락은 ORM 레벨에서 version 컬럼 하나로 구현할 수 있고, 비관적 락보다 처리량이 높습니다. 단, 충돌이 잦은 상황에서는 재시도 폭풍이 역효과를 낼 수 있습니다. 시나리오를 보고 어느 방식이 맞는지 판단하는 것이 핵심입니다.
- 1비관적 락 vs 낙관적 락 — 처리량 vs 충돌 감지 트레이드오프
- 2version 컬럼 스키마 설계 — 정수 버전 vs updated_at
- 3ORM별 낙관적 락 구현 — JPA @Version, SQLAlchemy, Prisma
- 4충돌 시 재시도 로직 — 지수 백오프, 최대 재시도 횟수
- 5낙관적 락 vs SELECT FOR UPDATE 선택 기준
- 6수강신청 시나리오 실전 적용
낙관적 락 & 동시성 충돌 처리 패턴
수강신청 기능을 배포하고 나서 1시간 뒤, "정원이 30명인 강의에 32명이 등록됐어요"라는 메시지가 날아왔습니다. 코드를 열어보니 로직 자체는 맞았습니다. 잔여 인원을 읽고, 0보다 크면 INSERT하는 순서였으니까요. 문제는 두 요청이 거의 동시에 잔여 인원 2를 읽고, 둘 다 "자리 있다"고 판단한 뒤 각자 INSERT를 했다는 것입니다. 한 명이 먼저 커밋하는 사이 다른 한 명의 트랜잭션은 이미 읽기가 끝난 상태였고, 아무도 그 사실을 몰랐습니다. 그때 version 컬럼 하나만 있었어도 두 번째 트랜잭션이 "내가 읽은 이후 누군가 먼저 바꿨다"는 걸 커밋 시점에 감지하고 재시도할 수 있었습니다. 낙관적 락은 잠금을 걸지 않고도 이 충돌을 잡아내는 방법이고, ORM에서 애노테이션 하나로 구현됩니다.
학습 목표
version컬럼을 활용한 낙관적 락의 동작 원리를 설명할 수 있다- JPA
@Version, SQLAlchemyversion_id_col, Prisma raw 패턴으로 ORM별 구현을 작성할 수 있다 OptimisticLockException충돌 시 지수 백오프 재시도 로직을 구현할 수 있다- 낙관적 락과 비관적 락의 트레이드오프를 시나리오별로 판단할 수 있다
- 수강신청/좌석 예약 시나리오에 낙관적 락을 직접 적용할 수 있다
비관적 락 vs 낙관적 락 — 처리량과 충돌 감지의 트레이드오프

비관적 락 — "충돌이 일어날 것"
비관적 락은 데이터를 읽는 순간부터 잠금을 겁니다. 다른 트랜잭션은 잠금이 해제될 때까지 기다립니다.
-- 비관적 락: 읽는 순간 행을 잠금
BEGIN;
SELECT * FROM inventory WHERE product_id = 42 FOR UPDATE;
-- 이 시점부터 다른 트랜잭션은 같은 행에 접근 시 대기
UPDATE inventory SET stock = stock - 1 WHERE product_id = 42;
COMMIT;
실행 완료 또는 조회 결과가 표시됩니다.
- 버전 증가—UPDATE 성공 시 version 컬럼이 함께 증가하는지 확인합니다.
- 충돌 감지—영향 받은 행 수가 0이면 동시 수정 충돌로 처리하는지 봅니다.
- 재시도 정책—사용자 재입력과 자동 재시도를 구분합니다.
장점: 충돌이 절대 발생하지 않음. 데이터 일관성 최강.
단점: 잠금 대기로 처리량 저하. 트래픽이 몰리면 대기열이 폭발. 데드락 가능성.
낙관적 락 — "충돌이 드물 것"
낙관적 락은 읽을 때 잠금을 걸지 않습니다. 대신 수정할 때 "내가 읽은 이후 다른 사람이 먼저 바꿨는가?"를 version으로 확인합니다.
-- Step 1: 잠금 없이 읽기 (version도 함께 읽음)
SELECT stock, version FROM inventory WHERE product_id = 42;
-- stock=1, version=7 을 읽었다고 가정
-- Step 2: 수정 시 version 조건 추가
UPDATE inventory
SET stock = stock - 1, version = version + 1
WHERE product_id = 42 AND version = 7; -- ← 핵심!
-- affected_rows = 1: 성공 (아무도 중간에 수정하지 않음)
-- affected_rows = 0: 충돌 감지 → 재시도 또는 에러 처리
장점: 잠금 없음 → 높은 처리량. 읽기 위주 서비스에서 강력.
단점: 충돌 발생 시 재시도 필요. 충돌이 잦으면 재시도 폭풍.
처리량 비교
| 상황 | 비관적 락 처리량 | 낙관적 락 처리량 |
|---|---|---|
| 충돌률 1% 미만 | 낮음 (불필요한 대기) | 높음 |
| 충돌률 10~30% | 보통 | 낮음 (재시도 비용) |
| 충돌률 50% 이상 | 예측 가능 | 매우 낮음 (재시도 폭풍) |
실습 1: version 컬럼 스키마 설계
낙관적 락에 필요한 스키마를 설계합니다. 버전 추적 방식에는 두 가지가 있습니다.
방법 A: 정수 버전 컬럼 (권장)
CREATE TABLE inventory (
id BIGSERIAL PRIMARY KEY,
product_id BIGINT NOT NULL UNIQUE,
stock INTEGER NOT NULL CHECK (stock >= 0),
version INTEGER NOT NULL DEFAULT 0, -- ← 낙관적 락용 버전
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- 인덱스: product_id로 조회가 많으므로
CREATE INDEX idx_inventory_product_id ON inventory(product_id);
정수 버전의 장점:
- 비교가 단순하고 명확 (
version = 7) - 오버플로우 걱정이 없음 (BIGINT 사용 시)
- ORM 기본 지원 방식 (JPA, SQLAlchemy 모두 정수 버전 권장)
방법 B: updated_at 타임스탬프 버전
CREATE TABLE seats (
id BIGSERIAL PRIMARY KEY,
seat_number VARCHAR(10) NOT NULL,
is_reserved BOOLEAN DEFAULT FALSE,
updated_at TIMESTAMPTZ DEFAULT NOW() -- ← 버전 대신 타임스탬프
);
-- 조회 시 updated_at도 함께 읽기
SELECT is_reserved, updated_at FROM seats WHERE id = 101;
-- 수정 시 조건으로 확인
UPDATE seats
SET is_reserved = TRUE, updated_at = NOW()
WHERE id = 101 AND updated_at = '2024-03-15 14:23:01.123456+00';
타임스탬프 버전의 단점:
- 마이크로초 이내 동시 요청 시 같은 타임스탬프 → 충돌 미감지 가능
- DB 서버 시간 동기화 의존
- 실무에서 정수 버전 컬럼을 강력히 권장
테스트 데이터 삽입
INSERT INTO inventory (product_id, stock, version)
VALUES (42, 10, 0),
(99, 1, 0);
-- 확인
SELECT * FROM inventory;
ORM별 낙관적 락 구현 — JPA, SQLAlchemy, Prisma
JPA (Java) — @Version 애노테이션
JPA에서는 @Version 하나로 낙관적 락이 자동 활성화됩니다. 프레임워크가 UPDATE 시 WHERE version 조건을 자동으로 추가합니다.
@Entity
@Table(name = "inventory")
public class Inventory {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private Long productId;
private Integer stock;
@Version // ← 이것만 추가하면 낙관적 락 활성화
private Integer version;
}
@Service
@Transactional
public class InventoryService {
public void decreaseStock(Long productId, int quantity) {
Inventory inventory = inventoryRepository
.findByProductId(productId)
.orElseThrow(() -> new RuntimeException("상품 없음"));
if (inventory.getStock() < quantity) {
throw new RuntimeException("재고 부족");
}
inventory.setStock(inventory.getStock() - quantity);
// save() 시점에 JPA가 자동으로:
// UPDATE inventory SET stock=?, version=version+1
// WHERE id=? AND version=? ← 자동 추가
inventoryRepository.save(inventory);
// 다른 트랜잭션이 먼저 수정했다면 → OptimisticLockException 발생
}
}
JPA가 실제로 실행하는 쿼리 (로그로 확인 가능):
UPDATE inventory SET stock=9, version=1 WHERE id=1 AND version=0;
-- affected rows=0이면 OptimisticLockException 자동 발생
SQLAlchemy (Python) — version_id_col
from sqlalchemy import Column, BigInteger, Integer, create_engine
from sqlalchemy.orm import DeclarativeBase, Session
class Base(DeclarativeBase):
pass
class Inventory(Base):
__tablename__ = "inventory"
id = Column(BigInteger, primary_key=True)
product_id = Column(BigInteger, nullable=False)
stock = Column(Integer, nullable=False)
version = Column(Integer, nullable=False, default=0)
__mapper_args__ = {
"version_id_col": version # ← 낙관적 락 활성화
}
def decrease_stock(session: Session, product_id: int, quantity: int):
inventory = session.query(Inventory)\
.filter(Inventory.product_id == product_id)\
.one()
if inventory.stock < quantity:
raise ValueError("재고 부족")
inventory.stock -= quantity
# commit() 시점에 SQLAlchemy가 version 조건 자동 추가
# StaleDataError 발생 시 충돌 감지
session.commit()
충돌 발생 시 SQLAlchemy는 sqlalchemy.orm.exc.StaleDataError를 발생시킵니다.
Prisma (Node.js/TypeScript) — Raw 쿼리 패턴
Prisma는 공식 낙관적 락 지원이 없습니다. Raw 쿼리로 직접 구현합니다.
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function decreaseStock(productId: number, quantity: number): Promise<void> {
// Step 1: 현재 버전과 재고 읽기
const inventory = await prisma.inventory.findUniqueOrThrow({
where: { productId },
select: { id: true, stock: true, version: true }
});
if (inventory.stock < quantity) {
throw new Error('재고 부족');
}
// Step 2: version 조건을 포함한 UPDATE
const result = await prisma.$executeRaw`
UPDATE inventory
SET stock = ${inventory.stock - quantity},
version = ${inventory.version + 1}
WHERE id = ${inventory.id}
AND version = ${inventory.version}
`;
// Step 3: affected rows 확인
if (result === 0) {
throw new OptimisticLockError('동시 수정 감지 — 재시도 필요');
}
}
class OptimisticLockError extends Error {
constructor(message: string) {
super(message);
this.name = 'OptimisticLockError';
}
}
실습 2: 충돌 시 재시도 로직 — 지수 백오프
충돌이 발생했을 때 그냥 예외를 던지면 사용자 경험이 나빠집니다. 대부분의 충돌은 재시도하면 성공합니다. 단, 무한 재시도는 시스템을 망칩니다.
지수 백오프 재시도 패턴 (Python)
import time
import random
from sqlalchemy.orm import exc as orm_exc
def with_optimistic_retry(func, max_retries: int = 3, base_delay: float = 0.1):
"""
낙관적 락 충돌 시 지수 백오프로 재시도.
max_retries=3, base_delay=0.1초 → 0.1s, 0.2s, 0.4s 대기
"""
for attempt in range(max_retries + 1):
try:
return func()
except orm_exc.StaleDataError:
if attempt == max_retries:
# 최대 재시도 초과 → 상위로 예외 전파
raise RuntimeError(
f"낙관적 락 충돌: {max_retries}회 재시도 후 실패"
)
# 지수 백오프 + 지터(jitter) — 동시 재시도 폭풍 방지
delay = base_delay * (2 ** attempt) + random.uniform(0, 0.05)
print(f"충돌 감지, {delay:.2f}초 후 재시도 (시도 {attempt + 1}/{max_retries})")
time.sleep(delay)
session.rollback() # 재시도 전 세션 초기화 필수!
# 사용 예시
def purchase_item(product_id: int, quantity: int):
def _do_purchase():
with Session(engine) as session:
decrease_stock(session, product_id, quantity)
with_optimistic_retry(_do_purchase, max_retries=3)
지수 백오프 재시도 패턴 (Java + Spring)
@Service
public class InventoryService {
private static final int MAX_RETRIES = 3;
public void decreaseStockWithRetry(Long productId, int quantity) {
int attempt = 0;
while (attempt <= MAX_RETRIES) {
try {
decreaseStock(productId, quantity);
return; // 성공
} catch (OptimisticLockingFailureException e) {
attempt++;
if (attempt > MAX_RETRIES) {
throw new RuntimeException("재고 차감 실패: 동시 요청 충돌", e);
}
long delayMs = (long) (100 * Math.pow(2, attempt - 1))
+ ThreadLocalRandom.current().nextLong(50);
log.warn("낙관적 락 충돌, {}ms 후 재시도 ({}/{})", delayMs, attempt, MAX_RETRIES);
try {
Thread.sleep(delayMs);
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
throw new RuntimeException("재시도 중단", ie);
}
}
}
}
}
재시도 시 반드시 지켜야 할 규칙:
- 세션/EntityManager 초기화 — 실패한 트랜잭션 컨텍스트를 그대로 재사용하면 캐시된 구버전 데이터로 또 실패
- 지터(jitter) 추가 — 모든 요청이 같은 타이밍에 재시도하면 또 충돌 → 랜덤 지연 추가
- 최대 재시도 횟수 설정 — 무한 재시도는 스레드 고갈과 DB 부하를 유발
낙관적 락 vs SELECT FOR UPDATE 선택 기준
이전 모듈에서 배운 SELECT FOR UPDATE(비관적 락)와 낙관적 락은 같은 문제를 해결하지만 적합한 시나리오가 다릅니다.
언제 낙관적 락을 쓰는가
✅ 낙관적 락이 적합한 경우:
- 읽기 : 쓰기 비율이 높은 경우 — 상품 상세 페이지 조회가 많고 구매는 가끔
- 충돌률이 낮은 경우 — 상품이 다양하고 같은 상품을 동시에 구매하는 빈도가 낮음
- 응답 시간이 중요한 경우 — 잠금 대기 없이 즉시 응답 가능
- 분산 시스템 / 마이크로서비스 — DB 레벨 잠금이 아닌 애플리케이션 레벨에서 처리
일반 이커머스 상품 구매:
→ 재고 100개, 동시 구매자 5~10명
→ 충돌 확률 낮음 → 낙관적 락 ✅
언제 비관적 락을 쓰는가
✅ SELECT FOR UPDATE가 적합한 경우:
- 충돌률이 높은 경우 — 한정 수량 이벤트, 선착순 예약
- 재시도가 불가능한 경우 — 결제 완료 후 포인트 적립처럼 멱등성 보장이 어려운 경우
- 잠금 대기가 허용되는 경우 — 배치 처리, 관리자 작업
인기 콘서트 좌석 예약 (10분 만에 매진):
→ 재고 1,000석, 동시 접속자 50,000명
→ 충돌 확률 매우 높음 → SELECT FOR UPDATE ✅
→ 또는: 큐 기반으로 직렬화 처리
결정 트리
충돌이 자주 발생하는가?
├── 예 → SELECT FOR UPDATE (비관적 락)
│ 또는 큐 기반 직렬화
└── 아니오 →
재시도가 가능한가?
├── 예 → 낙관적 락 ✅
└── 아니오 → SELECT FOR UPDATE
| 기준 | 낙관적 락 | SELECT FOR UPDATE |
|---|---|---|
| 처리량 | 높음 | 낮음 (잠금 대기) |
| 충돌 시 비용 | 재시도 | 없음 (선점) |
| 데드락 위험 | 없음 | 있음 |
| ORM 지원 | 네이티브 | 쿼리 레벨 |
| 분산 환경 | 적합 | 단일 DB에 종속 |
실습 3: 수강신청 시나리오에 낙관적 락 적용
수강신청 시스템을 직접 구현합니다. 강의당 정원이 30명이고, 동시에 여러 학생이 신청하는 상황입니다.
스키마 설계
CREATE TABLE courses (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(200) NOT NULL,
capacity INTEGER NOT NULL, -- 정원
enrolled INTEGER NOT NULL DEFAULT 0, -- 현재 수강 인원
version INTEGER NOT NULL DEFAULT 0, -- 낙관적 락 버전
CHECK (enrolled >= 0),
CHECK (enrolled <= capacity)
);
CREATE TABLE enrollments (
id BIGSERIAL PRIMARY KEY,
course_id BIGINT NOT NULL REFERENCES courses(id),
student_id BIGINT NOT NULL,
enrolled_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE (course_id, student_id) -- 중복 수강 방지
);
-- 테스트 강의 삽입
INSERT INTO courses (name, capacity, enrolled, version)
VALUES ('PostgreSQL 고급 과정', 30, 28, 0); -- 잔여 2석
수강신청 로직 (순수 SQL)
-- Step 1: 잠금 없이 현재 상태 조회
SELECT id, capacity, enrolled, version
FROM courses
WHERE id = 1;
-- 결과: id=1, capacity=30, enrolled=28, version=5
-- Step 2: 정원 확인 후 version 조건 포함 UPDATE
-- (애플리케이션에서 enrolled < capacity 검증 후 실행)
UPDATE courses
SET enrolled = enrolled + 1,
version = version + 1
WHERE id = 1
AND version = 5 -- ← 낙관적 락
AND enrolled < capacity; -- ← DB 레벨 이중 안전장치
-- affected rows 확인:
-- 1 → 성공 → enrollments에 INSERT
-- 0 → 실패 (충돌 또는 정원 초과) → 재조회 후 판단
수강신청 서비스 (Python 전체 구현)
from sqlalchemy import text
from sqlalchemy.orm import Session
class EnrollmentService:
def enroll(self, session: Session, course_id: int, student_id: int) -> str:
"""
낙관적 락 기반 수강신청.
Returns: "success" | "full" | "already_enrolled"
"""
max_retries = 3
for attempt in range(max_retries):
# Step 1: 현재 상태 조회 (잠금 없음)
row = session.execute(
text("SELECT id, capacity, enrolled, version FROM courses WHERE id = :id"),
{"id": course_id}
).fetchone()
if row is None:
raise ValueError("강의를 찾을 수 없습니다")
# Step 2: 정원 초과 확인 (애플리케이션 레벨)
if row.enrolled >= row.capacity:
return "full"
# Step 3: 중복 수강 확인
existing = session.execute(
text("SELECT 1 FROM enrollments WHERE course_id=:c AND student_id=:s"),
{"c": course_id, "s": student_id}
).fetchone()
if existing:
return "already_enrolled"
# Step 4: 낙관적 락 UPDATE
affected = session.execute(
text("""
UPDATE courses
SET enrolled = enrolled + 1, version = version + 1
WHERE id = :id AND version = :version AND enrolled < capacity
"""),
{"id": course_id, "version": row.version}
).rowcount
if affected == 1:
# 성공: 수강 이력 기록
session.execute(
text("INSERT INTO enrollments (course_id, student_id) VALUES (:c, :s)"),
{"c": course_id, "s": student_id}
)
session.commit()
return "success"
else:
# 충돌: 재시도
session.rollback()
if attempt < max_retries - 1:
time.sleep(0.05 * (2 ** attempt)) # 지수 백오프
return "conflict" # 최대 재시도 초과
동시성 테스트
-- 두 터미널에서 동시에 실행해 충돌 재현
-- 터미널 A:
BEGIN;
SELECT enrolled, version FROM courses WHERE id = 1; -- enrolled=28, version=5
-- (여기서 잠깐 멈춤)
-- 터미널 B: (먼저 커밋)
BEGIN;
UPDATE courses SET enrolled=29, version=6 WHERE id=1 AND version=5;
COMMIT; -- 성공
-- 터미널 A: (늦게 시도)
UPDATE courses SET enrolled=29, version=6 WHERE id=1 AND version=5;
-- affected rows = 0 → 충돌 감지 → ROLLBACK 후 재시도
ROLLBACK;
언제 발생하나
JPA에서 save() 또는 커밋 시점에 version 불일치가 감지되면 이 예외가 발생합니다.
javax.persistence.OptimisticLockException:
Row was updated or deleted by another transaction
(or unsaved-value mapping was incorrect):
[com.example.Inventory#42]
Python SQLAlchemy에서는:
sqlalchemy.orm.exc.StaleDataError:
UPDATE statement on table 'inventory' expected to update 1 row(s);
0 were matched.
흔한 원인 3가지
원인 1: 재시도 시 EntityManager/세션 미초기화
// ❌ 잘못된 패턴: 같은 EntityManager로 재시도
for (int i = 0; i < 3; i++) {
inventory.setStock(inventory.getStock() - 1);
repo.save(inventory); // 두 번째 시도도 구버전 version으로 실패
}
// ✅ 올바른 패턴: 재시도마다 새로 조회
for (int i = 0; i < 3; i++) {
try {
Inventory fresh = repo.findById(id).get(); // 재조회!
fresh.setStock(fresh.getStock() - 1);
repo.save(fresh);
break;
} catch (OptimisticLockingFailureException e) {
// 재조회 후 재시도
}
}
원인 2: @Transactional 없이 @Version 사용
// ❌ 트랜잭션 없으면 낙관적 락이 작동하지 않음
public void decreaseStock(Long id) { ... } // @Transactional 누락
// ✅ 반드시 트랜잭션 내에서 실행
@Transactional
public void decreaseStock(Long id) { ... }
원인 3: N+1 업데이트로 version이 예상보다 빠르게 증가
Lazy Loading으로 연관 엔티티까지 update되면서 version이 예상과 다르게 증가하는 경우. @DynamicUpdate를 사용하거나 실제로 변경된 필드만 UPDATE하도록 쿼리를 분리합니다.
해결 방법 요약
- 재시도 시 반드시 세션/EntityManager에서 최신 데이터 재조회
- 낙관적 락 코드는 반드시 @Transactional 내에서 실행
- 재시도 횟수 초과 시 사용자에게 명확한 메시지 ("요청이 많아 처리 실패, 다시 시도해주세요")
이커머스 결제 재고 차감 — 왜 낙관적 락을 선택했나
실제 이커머스 서비스 결제 시스템 설계 당시의 결정 과정입니다.
상황: 결제 완료 후 재고 차감 로직에서 동시 요청이 들어올 때 재고가 마이너스가 되는 문제 발생.
처음 시도: SELECT FOR UPDATE
BEGIN;
SELECT stock FROM inventory WHERE product_id = ? FOR UPDATE;
UPDATE inventory SET stock = stock - 1 WHERE product_id = ?;
COMMIT;
피크 타임에 초당 500건 결제가 들어오면서 문제가 생겼습니다. 결제 평균 응답시간이 200ms → 1.2초로 늘어났고, 데드락이 간헐적으로 발생했습니다. 잠금 대기가 연쇄적으로 쌓이면서 커넥션 풀이 고갈됐습니다.
전환 결정: 낙관적 락
분석해보니 상품 종류가 10만 가지인데 같은 상품을 동시에 구매하는 경우는 전체의 2% 미만이었습니다. 충돌률이 낮은 상황에서 SELECT FOR UPDATE는 과도한 잠금이었습니다.
낙관적 락으로 전환 후:
- 결제 응답시간: 1.2초 → 220ms (잠금 대기 제거)
- 재시도 발생률: 평균 1.8% (충돌률과 거의 동일)
- 데드락: 0건
주의한 것들:
- 한정 수량 이벤트는 예외 — 특가 상품처럼 동시 경쟁이 극심한 케이스는 별도로 큐 기반 처리
- 재고 0 감지 정확성 — 낙관적 락에서도
CHECK (stock >= 0)제약을 DB에 유지해 최후 방어선 확보 - 재시도 메트릭 모니터링 — 재시도율이 5%를 넘으면 해당 상품은 비관적 락으로 전환하는 자동화 로직 추가
교훈: 낙관적 락과 비관적 락은 "더 좋은 것"이 없습니다. 충돌률과 재시도 허용 가능성을 데이터로 측정한 뒤 결정해야 합니다. 처음부터 두 방식을 모두 지원하는 추상화 레이어를 만들어두면 나중에 유연하게 전환할 수 있습니다.
다음 모듈에서는 EXPLAIN ANALYZE로 쿼리 실행 계획을 읽고 인덱스 최적화 기회를 찾는 방법을 다룹니다.