Skip to content

About

명지대학교 응용소프트웨어전공 '서버아키텍처설계' 레포지토리입니다.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

수강신청 서버 아키텍처

문제 정의

수강신청은 정해진 시각에 수천 명이 동시에 같은 버튼을 누르는 서비스입니다. 기준으로 삼은 것은 명지대 2026년 8월 11일 수강신청으로, 대상은 인문대학, 사회과학대학, 인공지능소프트웨어융합대학 학생 3,660명이었고 이날 신청 페이지 진입 시점에 장애가 있었습니다.

8/11 수강신청 당일 에브리타임 글: 앞에 337명 대기 중 안내와 재접속하면 대기시간이 길어진다는 문구, 보자마자 새로고침했다는 댓글

당일 대기열 안내는 재접속하면 더 기다린다고 했지만, 실제로는 기다린 학생은 계속 접속하지 못하고 새로고침한 학생이 먼저 들어갔습니다. 이 상황에서 풀어야 할 문제는 세 가지입니다.

  1. 서버와 DB 과부하: 3,660명이 1~3초 안에 몰리고, 학생 한 명이 약 6과목을 신청해 약 22,000건의 신청이 짧은 시간에 들어옵니다.
  2. 정원 초과: 인기 분반 하나에 신청이 몰릴 때 서버 여러 대가 동시에 자리를 차감해도 정원을 넘기면 안 됩니다.
  3. 공정성: 기다린 순서대로 들어가야 하고, 새로고침이 이득이 되거나 멈춘 요청에 갇히면 안 됩니다.

문제 해결 과정

수강신청 시스템 인프라 아키텍처

아래는 개발하면서 남긴 기록(REPORT_LOG.md)과 측정 기록을 생각하고 결정한 순서대로 정리한 것입니다.

출발점

목표는 단순했습니다. 위의 8/11 상황, 즉 3,660명이 같은 순간에 몰려도 서버가 버티고, 정원이 넘치지 않고, 기다린 순서대로 들어가는 아키텍처를 직접 설계해 보는 것이었습니다.

8/11에 무엇이 터졌는지 생각해 보니 성격이 다른 두 가지가 섞여 있었습니다. 정각의 진입은 모두가 같은 순간 같은 무거운 일을 하는 요청량 문제이고, 진입 뒤 과목별 신청은 인기 분반 하나의 정원에 동시 쓰기가 몰리는 경합 문제입니다. 하나의 방법으로 둘을 다 풀기는 어려워서, 둘을 따로 풀기로 했습니다.

몰리는 사람을 어떻게 받을까

나중에 서버, DB, 로드밸런서를 각각 다른 VM에 올려 실제처럼 나눠 보려면, 데이터가 txt와 메모리에 있으면 안 되고 각 구성 요소를 따로 띄울 수 있어야 했습니다. 또 교수님 컴퓨터에서도 한 번에 실행되어야 했습니다. 그래서 데이터는 PostgreSQL에 두고, 구성 요소를 Docker 컨테이너로 나눠 docker compose 한 줄로 올리기로 했습니다. 정각의 요청량 폭주는 서버가 감당할 수 있는 인원만 들여보내면 되므로, 그 앞에 대기열을 두기로 했습니다.

대기열을 어디에 둘지 생각해 보니, 처음 원칙은 라이브러리를 최소화하는 것이라 서버 메모리나 DB에 직접 구현하는 방법도 있었습니다. 하지만 서버가 여러 대가 되면 메모리 대기열은 서버마다 따로 생기고, DB에 두면 정각의 폭주가 그대로 DB로 갑니다. 반면 Redis는 서버 여러 대가 같은 줄을 보고 메모리에서 빠르게 처리되며 현업에서도 대기열과 캐시에 쓰는 구성입니다. 그래서 원칙을 조금 깨더라도 Redis를 선택했습니다.

줄을 무엇으로 세울지 생각해 보니 많은 서비스가 쓰는 익명 번호표가 먼저 떠올랐습니다. 번호표는 로그인 전에 줄을 세울 수 있어 사이트 전체를 보호하지만, 새로고침이나 재시작으로 잃으면 맨 뒤로 밀립니다. 학번으로 줄을 세우면 이미 있는 학생을 덮어쓰지 않게(ZADD NX) 해서 새로고침해도 순번이 유지되고 한 학생이 여러 번 줄을 설 수도 없지만, 로그인이 대기열 앞에 있어 보호를 받지 못합니다. 로그인 부하는 오픈 전에 미리 로그인하게 하면 시간에 나눌 수 있으므로, 학번을 키로 쓰는 쪽을 선택했습니다. 같은 이유로 순번 조회도 클라이언트가 마음대로 하지 않고 서버가 응답에 담아 준 간격으로만 하게 했습니다.

8/11은 정확히 어디서 무너졌을까

얼마나 버텨야 하는지 정하려면 실제 인원이 필요했습니다. 명지대 학생 현황으로 세어 보니 8/11 대상은 인문대학 1,748명, 사회과학대학 1,347명, 인공지능소프트웨어융합대학 565명으로 3,660명이었고, 한 명이 약 6과목을 신청하면 약 22,000건입니다. 이 상황을 대기열이 없을 때(A)와 있을 때(B)로 재현해야 효과를 말할 수 있고, 부하 생성기나 로드밸런서까지 직접 만들면 과제의 핵심이 흐려집니다. 그래서 과제가 요구하는 gRPC 서버와 클라이언트만 직접 만들고, 부하 생성(k6), 로드밸런서, 저장소, 모니터링은 기성 도구를 쓰기로 했습니다.

장애 원인은 처음에 응답이 느려지자 학생들이 새로고침을 반복해 부하가 몇 배로 늘었다고 추정했습니다. 그런데 당일을 다시 떠올려 보니 기다린 학생은 계속 접속하지 못했고, 새로고침한 학생은 바로 들어갔습니다. 서버에 여유가 있었는데 기존 요청이 느린 게 아니라 죽어 있었다는 뜻이라, 재시도 폭주라는 추정은 약해졌습니다. 그래서 원인을 멈춘 서버가 로드밸런서 목록에 남아 있었거나, 요청에 제한 시간이 없었거나, 대기열 자리가 반납되지 않은 것으로 다시 좁혔습니다.

세 가지 원인을 하나씩 보면 대응이 정해졌습니다. 멈춘 서버는 로드밸런서가 빼야 하고, 새 요청이 어느 서버로 가도 되려면 세션 고정이 없어야 하고, 버려진 요청이 계속 기다리지 않으려면 모든 호출에 제한 시간이 있어야 하고, 자리가 반드시 돌아오려면 입장권에 유효 시간이 있어야 합니다. 그래서 이 네 가지를 설계에 넣기로 했습니다.

정원 경합은 DB 행 잠금으로 처리하는 방법이 먼저 떠올랐습니다. 하지만 인기 분반 하나에 신청이 몰리면 잠금 대기가 길어지고, 기다리는 요청이 연결을 오래 붙잡아 전체 지연으로 번질 수 있습니다. Redis에서 확인과 차감을 한 번에 하면 잠금 없이 하나씩 처리되므로, 정원은 Redis 원자적 차감으로 정하고 DB 트랜잭션은 짧게 두기로 했습니다.

입장은 누가, 얼마나 정할까

입장 인원을 정하는 방법을 비교해 보면, 응답 속도를 보고 한도를 자동으로 조절하는 방식과 운영자가 고정 한도를 정하는 방식이 있었습니다. 자동 조절 방식은 주로 서버 자기 보호 계층에서 쓰이고, 대기열에 쓰면 입장 속도가 출렁여 대기 시간을 예측하기 어렵습니다. Cloudflare Waiting Room 같은 공개된 제품도 고정 한도를 씁니다. 그래서 동시 입장 한도를 정해 두고 "한도 - 살아 있는 입장권 수"만큼만 들여보내는 방식을 선택했습니다.

입장을 누가 처리할지 생각해 보니, 서버마다 처리하게 하면 서버 여러 대가 각자 빈자리를 세서 한도가 서버 수만큼 늘어납니다. Redis 스크립트로 서버들이 나눠 처리하는 방법도 가능했지만 복잡해집니다. 그래서 결정 주체를 하나로 두는 단순한 쪽을 택해, queue-worker 컨테이너 하나가 입장을 처리하게 했습니다. 그리고 부하 테스트 중 무엇이 병목인지 그래프로 봐야 했기 때문에 Prometheus와 Grafana를 함께 넣기로 했습니다.

브라우저에서 gRPC 서버를 어떻게 부를까

기능이 많아져 클라이언트를 콘솔에서 웹(React)으로 바꾸려고 보니, 브라우저는 gRPC를 직접 쓸 수 없어 중간 변환이 필요했습니다. nginx는 gRPC 프록시는 되지만 gRPC-Web 변환 기능이 기본으로 없고, Envoy는 gRPC-Web 필터를 기본으로 제공하고 공식 문서가 안내하는 기본 프록시이면서 로드밸런싱, 헬스체크, 타임아웃도 함께 해 줍니다. 서버 코드를 바꾸지 않는 것이 조건이었으므로 gRPC-Web과 Envoy를 선택했습니다.

나중에 다시 보니 브라우저에서 gRPC-Web을 쓰는 것은 현업에서 드물고, 밖은 REST, 안은 gRPC인 구조가 일반적이었습니다. Envoy의 gRPC-JSON transcoder를 쓰면 proto에 HTTP 경로만 적으면 되어 Java 서버 코드는 여전히 바뀌지 않습니다. 그래서 REST/JSON 게이트웨이로 바꾸기로 했습니다.

DB 저장에 Kafka가 필요할까

쓰기량부터 계산해 보니 22,000건을 3060초에 저장하면 초당 약 400700건이라, PostgreSQL 한 대로도 감당할 수 있는 범위였습니다. 쓰기량만 보면 Kafka는 필요 없었습니다. 그래서 Kafka가 필요해지는 신호를 따로 따져 봤습니다. 지속적인 쓰기가 DB 한계에 가까울 때, 같은 이벤트를 여러 서비스가 소비해야 할 때, 장애 동안 이벤트를 보관하거나 다시 재생해야 할 때입니다.

여기에 "DB를 쉽게 키울 수 없는 상황"을 전제로 두면, 순간 폭주를 흡수하고 DB가 잠시 멈춰도 기록을 잃지 않는 장치가 필요해집니다. 다만 Kafka가 푸는 것은 순간 폭주뿐이고, 들어오는 양이 DB 처리량보다 계속 많으면 밀린 양이 끝없이 쌓인다는 한계가 있습니다. 이 한계를 알고 쓰기로 하고, Kafka를 넣기로 했습니다.

넣기 전에 "성공이라고 알렸는데 저장이 실패하면?"을 먼저 따져 봤습니다. Kafka 기록이 실패하면 학생에게 성공을 알리기 전이므로 자리를 되돌리면 되고, consumer나 DB가 실패하면 메시지가 Kafka에 남아 있으니 다시 읽으면 됩니다. 다시 읽으면 중복이 생길 수 있고, 재시도해도 안 되는 메시지도 있을 수 있습니다. 그래서 Kafka가 저장을 확인(acks=all)한 뒤에만 성공을 알리고, DB 저장 뒤에만 읽은 위치를 확정하고, 중복은 ON CONFLICT로 무시하고, 끝내 실패한 메시지는 실패 전용 토픽(DLT)으로 보내기로 했습니다.

로그인과 로그

DB 초기화 스크립트는 컨테이너가 처음 만들어질 때만 실행되어, 스키마가 바뀔 때마다 DB를 지우고 다시 만들어야 했습니다. 서버가 시작할 때 아직 적용되지 않은 버전만 적용하면 이 문제가 없어지므로 Flyway로 버전을 관리하기로 했고, 서버 두 대가 같은 순간 시작해도 한 대만 적용되는 것을 확인했습니다.

인증을 생각해 보니 서버가 두 대라 어느 서버가 요청을 받아도 같은 사용자로 알아봐야 했습니다. 서버 메모리에 세션을 두면 서버끼리 세션을 공유하거나 한 서버에 고정해야 하는데, 세션 고정은 앞에서 8/11 원인으로 추정한 것과 맞지 않습니다. 토큰에 사용자 정보를 담고 두 서버가 같은 비밀키로 검증하면 저장할 세션이 없습니다. 그래서 JWT를 선택했고, app2가 발급한 토큰이 app1에서 그대로 통과하는 것을 확인했습니다.

토큰 검사, 역할별 권한, 과제가 요구한 로그(사용자 ID, 명령, 시각)는 모든 요청에 똑같이 붙어야 했습니다. 서비스 메서드마다 넣으면 빠뜨리기 쉽고, 거절된 요청은 메서드까지 오지도 않습니다. 그래서 gRPC interceptor에 두기로 했고, 가장 바깥 interceptor가 거절과 실패까지 포함해 한 줄씩 기록하되 비밀번호와 토큰은 남기지 않게 했습니다.

비밀번호 해시는 처음에 PBKDF2(60만 회)를 직접 조립했고, VM 로그인 평균이 236ms였습니다. 정각에 로그인이 몰리면 안 된다는 것을 이 수치로 확인했습니다. 이후 직접 조립하는 대신 알고리즘, 비용, 솔트가 문자열 하나에 들어가는 bcrypt 라이브러리로 바꾸려 했는데, 비용 12에서는 평균 724ms로 너무 느렸습니다. 비용을 1 낮출 때마다 시간이 절반이 되므로, OWASP 최솟값인 비용 10을 선택했습니다.

수강신청을 붙이고 동시성을 확인하기

수강신청을 어디에 둘지 생각해 보니, 신청 API는 서버에 두면 되지만 입장 처리는 앞에서 정한 대로 서버 수와 상관없이 하나만 돌아야 했고, DB 저장은 Kafka 뒤에서 서버와 떨어져 돌아야 했습니다. 그래서 수강신청은 서버에 EnrollmentService로 붙이고, queue-worker와 enrollment-consumer는 실행 시작점만 다른 프로세스로 sa-infra에 따로 두기로 했습니다. 신청 성공 여부는 Redis Lua 한 번(중복, 정원, 차감)으로 바로 정하고, 선수과목, 시간 충돌, 같은 과목 다른 분반, 학점 한도는 그 앞에서 검사하게 했습니다.

가장 확인하고 싶었던 것은 정원이었습니다. 정원 5명인 분반에 학생 17명이 동시에 출발하도록 맞춰 신청하게 했더니, 서버 두 대가 8건, 9건씩 나눠 받았는데 성공 5, 정원 마감 12, 오류 0, 전체 367ms였고 Redis 남은 자리 0, DB 기록 정확히 5건이었습니다. 대기열은 한도 3명으로 줄여 10명을 세웠을 때 순번이 10, 7, 4, 1, 입장 순으로 줄었습니다.

실제 인원으로 부하를 걸어 보니

40명 점검에서 일부 학생이 한참 뒤에야 입장했습니다. 원인을 찾아보니 입장권이 마지막 요청 후 300초가 지나야만 풀려서, 신청을 마치고 나가도 자리가 돌아오지 않았습니다. 이대로면 3,660명이 다 들어가는 데 약 6시간이 걸립니다. 유효 시간만으로는 자리가 너무 늦게 돌아오므로, 신청을 마치면 입장권을 반납하고 응답 없는 대기자는 줄에서 빼는 기능을 넣기로 했습니다.

3,660명 로그인을 시험하자 sa-lb의 nginx가 연결 약 1,000개에서 파일 디스크립터가 바닥나 연결을 끊었습니다. 브라우저도 연결을 유지하므로 이 설정으로는 동시 접속 약 1,000명이 상한이었고, 그래서 nginx 연결과 파일 한도를 올렸습니다. 로그인(bcrypt)은 두 서버 합쳐 초당 약 18명이 한계였는데, 8/11 장애는 로그인이 아니라 진입에서 났으므로 재현은 미리 받은 토큰으로 시작하기로 했습니다.

입장권을 미리 준 150, 300, 700명 리허설에서는 정원 초과가 모두 0이었지만 처리량이 초당 57~81건에서 더 오르지 않았습니다. 정합성은 부하와 상관없이 지켜지고 무너지는 것은 응답 시간이라는 것을 알게 되어, 다음은 처리량의 병목을 찾기로 했습니다.

병목을 하나씩 옮겨 가며 고치기

개선 전 빌드로 3,660명 기준선을 재 보니, 대기열 없는 A에서 sa-db(1 vCPU)가 측정 구간 대부분 CPU 90% 이상, 평균 98%였습니다. 이유를 찾아보니 수강신청과 강의 목록이 요청마다 학기 분반 308개를 DB에서 다시 읽고 있었고, 앱 서버는 CPU 40% 정도로 여유가 있는데도 DB 연결 10개, Redis 연결 8개를 기다렸습니다. 요청이 쌓이자 Envoy의 동시 요청 기본 상한 1,024에 걸려 약 7,055건이 503으로 거절됐습니다. 8/11에 학생들이 본 접속 오류와 같은 모습이었지만 정원 초과는 0이었습니다. 대기열을 켠 B는 신청 오류율을 16.28%에서 5.01%로 낮췄지만 DB는 여전히 평균 91%였습니다.

DB 부하를 줄이려면 같은 분반 목록을 매번 읽지 않아야 했습니다. 개설 강의 목록은 자주 바뀌지 않고, 바뀌는 것은 남은 좌석인데 좌석은 이미 Redis가 정확히 알고 있습니다. 그래서 강의 목록은 서버 메모리에 두고 60초마다 갱신하고(OfferingCatalog), 남은 좌석은 Redis에서 읽고, 연결 풀을 Redis 128개, DB 30개로 늘리기로 했습니다. 3,660명 B에서 sa-db CPU는 평균 25%, 수강신청 p95는 11.7초에서 2.0초로 내려갔습니다.

DB가 풀리자 병목은 대기열 조회로 옮겨 갔습니다. 3,000명 넘는 대기자가 2초마다 순번을 물어 조회가 60초 제한에 걸렸고, 1,830명으로 줄여 보니 이번에는 대기열 조회가 Envoy 상한 1,024에 걸려 HTTP 실패율 15.15%, 중도 포기 239명이 났습니다. 대기자 수만큼 조회가 동시에 열리는 구조라 기본 상한은 너무 작았으므로, 상한을 10,000으로 올렸고 실패율 0.00%, 중도 포기 0명이 됐습니다. 그래도 조회 p99가 36.5초였습니다. 조회 간격을 늘리면 입장을 알아채는 데 시간이 더 걸리지만 조회 부하는 그만큼 줄어듭니다. 최대 5초 늦게 알아채는 것은 받아들일 만하다고 보고 간격을 2초에서 5초로 늘렸고, 조회 p99는 5.9초가 됐습니다.

병목 조치 결과
요청마다 분반 308개를 DB에서 다시 읽음, 작은 연결 풀 강의 목록 메모리 캐시, 좌석은 Redis, 풀 확대 sa-db CPU 평균 91%에서 25%, 수강신청 p95 11.7초에서 2.0초
Envoy 동시 요청 상한 1,024 상한 10,000 1,830명 HTTP 실패율 15.15%에서 0.00%
대기자의 2초 간격 조회 조회 간격 5초 조회 p99 36.5초에서 5.9초

규칙 위반을 사용자에게 정확히 알리기

시간이 겹치는 강의를 신청했을 때 학생이 이유를 정확히 알아야 했습니다. 서버가 CustomException을 던지고 이를 gRPC Status로 바꾸면, 메시지("신청한 강의(...)와 시간이 겹쳐 수강신청할 수 없습니다.")가 그대로 웹 알림창까지 갑니다. 남은 것은 상태 코드였습니다. 이 서버에서 FAILED_PRECONDITION은 입장권 만료와 오픈 전 진입에 쓰고, 웹은 이 코드를 받으면 대기열로 돌려보냅니다. 같은 코드를 쓰면 규칙 위반 하나로 학생이 줄 밖으로 밀려나므로, 다른 코드인 ABORTED를 선택했습니다.

결과

항목 결과
동시 신청: 정원 5명 분반에 17명 동시 신청 성공 5, 정원 마감 12, 오류 0, 전체 367ms. 서버 분배 app1 8건, app2 9건. Redis 남은 자리 0, DB 신청 행 5건
1,830명 B (B-1015-053649) 신청 10,980건 중 성공 7,311, 정원 마감 3,669, 오류 0. HTTP 실패율 0.00%, 중도 포기 0명
1,830명 B 정합성 DB 신청 행 7,311 = 성공 7,311, 정원 초과 분반 0개, Redis 좌석 불일치 0개, 마지막 성공 후 1.5초 안에 Kafka 반영 끝
1,830명 B 시간 마지막 학생 입장 오픈 55.3초, 마지막 신청 성공 오픈 59.1초, 수강신청 p95 2.1초
발표자 입장 (1,830명 중, 오픈 0.45초 뒤 클릭) 첫 순번 104, 조회 1번, 시간 초과 0번, 화면에 입장 표시 오픈 6.6초
3,660명 B 수강신청 p95 개선 전 11.7초, 개선 후 2.0초
3,660명 B sa-db CPU 평균 / 최고 개선 전 91% / 100%, 개선 후 25% / 91%
3,660명 B 입장 후 머문 시간 중앙값 개선 전 36.8초, 개선 후 5.7초
정원 초과 모든 실행(A, B, 리허설 150, 300, 700명 포함)에서 0건

측정 기록은 demo/results/BASELINE.md, demo/results/TUNING.md, demo/results/RESULTS.md에 있습니다.

의의

  • 정합성과 처리량을 나눠서 풀었습니다. 정원은 Redis Lua 한 곳에서만 결정하므로, 응답이 60초씩 밀리던 과부하 상황에서도 정원 초과와 Redis, DB 불일치가 한 번도 나지 않았습니다.
  • 8/11 장애의 모습(서버는 살아 있는데 요청이 503이나 시간 초과로 끝남)을 Envoy 서킷 브레이커와 대기열 조회 폭주로 실제로 재현하고, 원인을 수치로 확인했습니다.
  • 추측이 아니라 측정으로 병목을 옮겨 가며 고쳤습니다. DB, 커넥션 풀, Envoy 상한, 대기열 조회 순으로 병목이 바뀌는 것을 CPU와 응답 시간으로 확인했습니다.
  • 학번 기반 대기열로 새로고침해도 순번이 유지되고, 입장권 유효 시간과 반납으로 자리가 묶여 줄이 멈추는 문제를 막았습니다.

한계

  • 3,660명 전체 B는 개선 후에도 약 5분 걸리고 신청 오류율 4.00%가 남았습니다. 0 오류를 확인한 것은 1,830명입니다. 같은 1,830명을 나중에 다시 돌린 실행(B-1015-112537)에서는 오류 23건(0.21%), 수강신청 p95 8.9초로 결과가 흔들렸습니다.
  • k6에 정한 응답 시간 목표(수강신청 p95 1초, 대기열 조회 p95 0.5초 등)는 모든 실행에서 넘지 못했습니다.
  • 개설 강의 캐시는 서버마다 60초마다 갱신합니다. 한 서버에서 과목을 추가하거나 지우면 다른 서버는 최대 60초 동안 이전 목록을 볼 수 있습니다.
  • 대기열 조회 간격이 5초라 입장 처리 뒤 화면에 입장이 뜨기까지 최대 5초가 더 걸립니다.
  • 앱 VM은 2 vCPU, sa-lb와 sa-db는 1 vCPU입니다. 개선 후에도 앱 VM과 sa-lb CPU 최고값이 99~100%에 닿았습니다.
  • 앱 서버 JVM 지표와 VM 노드 지표는 Prometheus에 없어 vmstat으로 따로 쟀고, Envoy의 서버별(endpoint) 통계도 켜지 않았습니다.
  • Redis, Kafka, PostgreSQL이 각각 한 대뿐이라 단일 장애 지점이고, Kafka 복제로 유실을 막는 보장도 없습니다. 서버가 자리를 차감한 직후 Kafka 기록 전에 죽으면 자리만 줄어드는데, 이를 맞추는 Redis, DB 대조 작업은 만들지 않았습니다.
  • JWT는 라이브러리 없이 javax.crypto로 직접 구현해 검증된 라이브러리만큼 검토되지 않았습니다.
  • 로그인(bcrypt)은 두 서버 합쳐 초당 약 18명이 한계라, 재현 시나리오는 미리 로그인한 토큰으로 시작합니다.
  • VM 다섯 대와 부하 생성기(k6)가 모두 맥 한 대 위에서 돌아 서로 CPU를 나눠 씁니다. 실제 서비스 규모의 수치로 그대로 옮길 수는 없습니다.
  • 학점 한도 초과와 DLT 경로는 시드 데이터로 재현할 조합이 없어 직접 확인하지 못했습니다.

About

명지대학교 응용소프트웨어전공 '서버아키텍처설계' 레포지토리입니다.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages