Lock은 데이터베이스의 특징을 결정짓는 가장 핵심적인 메커니즘이다. 자신이 사용하는 데이터베이스의 고유한 Lock 메커니즘을 이해하지 못한다면, 고품질, 고성능 애플리케이션을 구축하기 어렵다.

 

 

오라클 Lock

오라클은 공유 리소스와 사용자 데이터를 보호할 목적으로 DML Lock, DDL Lock, 래치, 버퍼 Lock, 라이브러리 캐시 Lock/pin 등 다양한 종류의 Lock을 사용한다. 이 외에도 내부에 더 많은 종류의 Lock 이 존재한다.

 

애플링케이션 개발 측면에서 가장 중요하게 다루어야 할 Lock은 무엇보다 DML Lock 이다. DML Lock은 다중 트랜잭션이 동시에 액세스하는  사용자 데이터의 무결성을 보호해 준다.

  • 테이블 Lock
  • 로우 Lock

DML 로우 Lock

DML 로우 Lock은, 두 개의 동시 트랜잭션이 같은 로우를 변경하는 것을 방지한다. 하나의 로우를 변경하려면 로우 Lock을 먼저 설정해야 한다. 어떤 DBMS이든지 DML 로우 Lock에는 배타적 모드를 사용하므로 UPDATE 또는 DELETE를 진행 중인(아직 커밋하지 않은) 로우를 다른 트랜잭션이 UPDATE 하거나 DELETE 할 수 없다.

INSERT에 대한 로우 Lock 경합은 Unique 인덱스가 있을 때만 발생한다. 즉, Unique 인덱스가 있는 상황에서 두 트랜잭션이 같은 값을 입력하라고 할 때, 블로킹이 발생한다. 블로킹이 발생하면, 후행 트랜잭션이 기다렸다가 선행 트랜잭션이 커밋하면 INSERT에 실패하고, 롤백하면 성공한다. 두 트랜잭션이 서로 다른 값을 입력하거나 Unique 인덱스가 아예 없으면, INSERT에 대한 로우 Lock 경합은 발생하지 않는다.

 

MVCC 모델을 사용하는 오라클은(for update절이 없는) SELECT 문에 로우 Lock을 사용하지 않는다. 오라클은 다른 트랜잭션이 변경한 로우를 읽을 때 복사본 블록을 만들어서 쿼리가 '시작된 지점'으로 되돌려서 읽는다. 변경이 진행 중인(아직 커밋하지 않은) 로우를 읽을 때도 Lock이 풀릴 때까지 기다리지 않고 복사본을 만들어서 읽는다. 따라서 SELECT 문에 Lock을 사용할 필요가 없다.

 

결국, 오라클에서는 DML과 SELECT는 서로 진행을 방해하지 않는다. 물론 SELECTㄲ리도 서로 방해하지 않는다. DML끼리는 서로 방해할 수 있는데, 이는 어떤 DBMS를 사용하더라도 마찬가지다.

참고로, MVCC 모델을 사용하지 않는 DBMS는 SELECT 문에 공유 Lock을 사용한다. 공유 Lock끼리는 호환된다. 두 트랜잭션이 같이 Lock을 설정할 수 있다는 뜻이다. 반면, 공유 Lock과 배타적 Lock은 호환되지 않기 때문에 DML과 SELECT가 서로 진행을 방해할 수 있다. 

즉, 다른 트랜잭션이 읽고 있는 로우를 변경하려면 다음 레코드로 이동할 때까지 기다려야 하고, 다른 트랜잭션이 변경 중인 로우를 읽으려면 커밋할 때까지 기다려야 한다.

 

 

DML 테이블 Lock

오라클은 DML 로우 Lock을 설정하기에 앞서 테이블 Lock을 먼저 설정한다. 현재 트랜잭션이 갱신 중인 테이블 구조를 다른 트랜잭션이 변경하지 못하게 막기 위해서다. 테이블 Lock을 "TM Lock"이라고 부르기도  한다.

테이블 Lock (O 표시는 두 모드 간에 호환성이 있음을 의미한다.)

 

선행 트랜잭션과 호환되지 않는 모드로 테이블 Lock을 설정하려는 후행 트랜잭션은 대기하거나 작업을 포기해야 한다.

 

테이블 Lock이라고 하면, 테이블 전체에 Lock이 걸린다고 생각하기 쉽다. 그래서 다른 트랜잭션이 더는 레코드를 추가하거나 갱신하지 못한다고 생각하는 사람이 많다. 하지만 DML을 수행하기 전에 항상 테이블 Lock을 먼저 설정하므로 그렇게 이해하는 것은 맞지 않다. 하나의 로우를 변경하기 위해 테이블 전체에 Lock을 건다면 동시성이 좋은 어플리케이션을 구현하기 어렵다.

 

오라클에서 말하는 테이블 Lock은, 자신(테이블 Lock을 설정한 트랜잭션)이 해당 테이블에서 현재 어떤 작업이 수행 중인지를 알리는 일종의 푯말이다. 위의 표처럼 테이블 Lock에는 여러 가지 모드가 있고, 어떤 모드를 사용하는지에 따라 후행 트랜잭션이 수행할 수 있는 작업의 범위가 결정된다. 

 

예를 들어, DDL을 이용해 테이블 구조를 변경하려는 트랜잭션은 해당 테이블에 TM Lock이 설정돼 있는지를 먼저 확인한다. TM Lock을 RX(=SX) 모드로 설정한 트랜잭션이 하나라도 있으면, 현재 테이블을 갱신 중인 트랜잭션이 있다는 신호다. 따라서 ORA-00054 메시지를 남기고 작업을 멈춘다. 

 

Lock을 푸는 열쇠, 커밋

블로킹(Blocking)은 선행 트랜잭션이 설정한 Lock 때문에 후행 트랜잭션이 작업을 진행하지 못하고 멈춰 있는 상태를 말한다. 이것을 해소하는 방법은 커밋(또는 롤백)뿐이다.

교착상태(DeadLock)는 두 트랜잭션이 각각 특정 리소스에 Lock을 설정한 상태에서 맞은편 트랜잭션이 Lock을 설정한 리소스에 또 Lock을 설정하려고 진행하는 상황을 말한다. 교착상태가 발생하면 둘 중 하나가 뒤로 물러나지 않으면 영영 풀릴 수 없다. 

오라클에서 교착상태가 발생하면, 이를 먼저 인지한 트랜잭션이 문장 수준 롤백을 진행한 후에 아래 에러 메시지를 던진다. 교착상태를 발생시킨 문장 하나만 롤백하는 것이다.

 

ORA-00060 : deadlock detected while waiting for resource

 

이제 교착상태는 해소됐지만 블로킹 상태에 놓이게 된다. 따라서 이 메시지를 받은 트랜잭션은 커밋 또는 롤백을 결정해야 한다. 만약 프로그램 내에서 이 에러에 대한 예외처리를 하지 않는다면 대기 상태를 지속하게 되므로 주의가 필요하다.

 

트랜잭션이 너무 길면, 트랜잭션을 롤백해야 할 때 너무 많은 시간이 걸려 고생할 수 있다. 따라서 같은 데이터를 갱신하는 트랜잭션이 동시에 실행되지 않도록 애플리케이션을 설계해야 하고, DML Lock 때문에 동시성이 저하되지 않도록 적절한 시점에 커밋해야 한다.

 

배치 커밋 명령어

  • WAIT(Default) : LGWR가 로그버퍼를 파일에 기록했다는 완료 메시지를 받을 때까지 기다린다(동기식 커밋)
  • NOWWAIT : LGWR의 완료 메시지를 기다리지 않고 바로 다음 트랜잭션을 진행한다(비동기식 커밋)
  • IMMEDIATE(Default) : 커밋 명령을 받을 때마다 LGWR가 로그 버퍼를 파일에 기록한다.
  • BATCH : 세션 내부에 트랜잭션 데이터를 일정량 버퍼링했다가 일괄 처리한다.

이들 옵션을 조합해 아래와 같이 커밋 명령을 사용할 수 있다.

SQL > COMMIT WRITE IMMEDIATE WAIT;
SQL > COMMIT WRITE IMMEDIATE NOWAIT;
SQL > COMMIT WRITE BATCH WAIT;
SQL > COMMIT WRITE BATCH NOWAIT;

 

 

 

 

트랜잭션 동시성 제어

동시성 제어는 비관적 동시성 제어와 낙관적 동시성 제어로 나뉜다. 비관적 동시성 제어(Pessimistic Concurrency Control)는 사용자들이 같은 데이터를 동시에 수정할 것으로 가정한다. 따라서 한 사용자가 데이터를 읽는 시점에 Lock을 걸고 조회 또는 갱신처리가 완료될 때까지 이를 유지한다. Lock은 첫 번째 사용자가 트랜잭션을 완료하기 전까지 다른 사용자들이 같은 데이터를 수정할 수 없게 만들기 때문에 비관적 동시성 제어를 잘못 사용하면 동시성이 나빠진다. 

 

반면, 낙관적 동시성 제어(Optimistic Concurrency Control)는 사용자들이 같은 데이터를 동시에 수정하지 않을 것으로 가정한다. 따라서 데이터를 읽을 때 Lock을 설정하지 않는다. 그런데 낙관적 입장에서 섰다고 해서 동시 트랜잭션에 의한 잘못된 데이터 갱신을 신경 쓰지 않아도 된다는 것은 아니다. 읽는 시점에 Lock을 사용하지 않았지만, 데이터를 수정하고자 하는 시점에 앞서 읽은 데이터가 다른 사용자에 의해 변경되었는지 반드시 검사해야 한다.

 

 

비관적 동시성 제어

우수 고객을 대상으로 적립포인트를 제공하는 이벤트를 제공한다고 가정해보자 이때 밑에 예시처럼 고객의 다양한 실적정보를 읽고 복잡한 산출공식을 이용해 적립포인트를 계산하는 동안(SELECT 문 이후, UPDATE 문 이전) 다른 트랜잭션이 같은 고객의 실적정보를 변경하다면 문제가 생길 수 있다.

select 적립포인트, 방문횟수, 최근방문일시, 구매실적 from 고객
where 고객포인트 = :cust_num;

-- 새로운 적립포인트 계산
update 고객 set 적립포인트 = :적립포인트 where 고객번호 = :cust_num

 

하지만, 아래와 같이 SELECT 문에 FOR UPDATE를 사용하면 고객 레코드에 Lock을 설정하므로 데이터가 잘못 갱신되는 문제를 방지할 수 있다.

select 적립포인트, 방문횟수, 최근방문일시, 구매실적 from 고객
where 고객포인트 = :cust_num for update;

 

비관적 동시성 제어는 자칫 시스템 동시성을 심각하게 떨어뜨릴 우려가 있지만, FOR UPDATE에 WAIT 또는 NOWAIT 옵션을 함께 사용하면 Lock을 얻기 위해 무한정 기다리지 않아도 된다.

for update nowait -- 대기없이 Exception(ORA-00054)을 던짐
for update wait 3 -- 3초 대기 후 Exception(ORA-30006)을 던짐

 

WAIT 또는 NOWAIT 옵션을 사용하면, 다른 트랜잭션에 의해 Lock이 걸렸을 때, Exception을 만나게 되므로 "다른 사용자에 의해 변경 중이므로 다시 시도하십시오" 라는 메시지를 출력하면서 트랜잭션을 종료할 수 있다. 따라서 오히려 동시성을 증가시키게 된다.

더보기

큐(Queue) 테이블 동시성 제어

 

큐 테이블에 쌓인 고객 입금 정보를 일정한 시간 간격으로 읽어서 입금 테이블에 반영하는 데몬 프로그램이 있다고 가정하다.

데몬이 여러 개이므로 Lock이 걸릴 수 있는 상황이다. Lock이 걸리면 3초간 대기했다가 다음에 다시 시도하게 하려고 아래와 같이 for update wait 3 옵션을 지정했다. 큐에 쌓인 데이터를 한 번에 다 읽어서 처리하면 Lock이 풀릴 때까지 다른 데몬이 오래 걸릴 수 있으므로 고객 정보를 100개씩만 읽도록 했다.

 

select cust_id, rcpt_amt from cust_rcpt_Q

where yn_upd = 'Y' and rownum <= 100 FOR UPDATE WAIT 3;

 

이럴 때 아래와 같이 skip locked 옵션을 사용하면, Lock이 걸린 레코드는 생략하고 다음 레코드를 계속 읽도록 구현할 수 있다.

 

select cust_id, rcpt_nm from cust_rcpt_Q

where yn_upd = 'Y' FOR UPDATE SKIP LOCKED;

 

 

낙관적 동시성 제어 

낙관적 동시성 제어 예시를 보자.

select 적립포인트, 방문횟수, 최근방문일시, 구매실적 into :a, :b, :c, :d
from 고객
where 고객번호 = :cust_num;

-- 새로운 적립포인트 계산
update 고객 set 적립포인트 = :적립포인트
where 고객번호 = :cust_num
and 적립포인트 = :a
and 방문횟수 = :b
and 최근방문일시 = :c
and 구매실적 = :d;

if sql%rowcount = 0 than
  alert("다른 사용자에 의해 변경되었습니다.");
end if;

 

SELECT 문에서 읽은 컬럼이 매우 많다면 UPDATE 문에 조건절을 일일이 기술하는 것이 귀찮을 것이다. 만약 UPDATE 대상 테이블에 최종변경일시를 관리하는 컬럼이 있다면, 이를 조건절에 넣어 간단하게 해당 레코드의 갱신여부를 판단할 수 있다.

select 적립포인트, 방문횟수, 최근방문일시, 구매실적, 변경일시
into :a, :b, :c, :d, :mod_dt
from 고객
where 고객번호 = :cust_num;

-- 새로운 적립포인트 계산
update 고객 set 적립포인트 = :적립포인트, 변경일시 = SYSDATE
where 고객번호 = :cust_num
and 변경일시 = :mod_dt; -> 최종 변경일시가 앞서 읽은 값과 같은지 비교

if sql%rowcount = 0 than
  alert("다른 사용자에 의해 변경되었습니다.");
end if;

 

낙관적 동시성 제어에서도 UPDATE 전에 아래 SELECT 문을 한 번 더 수행함으로써 Lock에 대한 예외처리를 한다면, 다른 트랜잭션이 설정한 Lock을 기다리지 않게 구현할 수 있다.

select 고객번호
from 고객
where 고객번호 = :cust_num
and 변경일시 = :mod_dt
for update nowait;

 

 

 

동시성 제어 없는 낙관적 프로그래밍

낙관적 동시성 제어를 사용하면 Lock이 유지되는 시간이 매우 짧아져 동시성을 높이는 데 매우 유리하다. 하지만 다른 사용자가 같은 데이터를 변경했는지 검사하고 그에 따라 처리 방향성을 결정하는 귀찮은 절차가 뒤따른다. 

 

예를 들어, 온라인 쇼핑몰에서 특정 상품을 조회해서 결제를 완료하는 순간까지를 하나의 트랜잭션으로 정의했다고 가정해보자.

 

위의 그림에서 보듯, TX1이 t1 시점에 상품을 조회할 때는 가격기 1,000원이었다. 주문을 진행하는 동안 TX2에 의해 가격이 1,200원으로 수정되었다면, TX1이 최종 결제 버튼을 클릭하는 순간 어떻게 처리해야 할까? 상품 가격의 변경 여부를 체크함으로써 해당 주문을 취소시키거나 사용자에게 변경사실을 알리고 처리방향을 확인받는 프로레스를 거쳐야 한다.

insert into 주문
select :상품코드, :고객ID, :주문일시, :상점번호, ....
from 상품
where 상품코드 = :상품코드
and 가격 = :가격; -- 주문을 시작한 시점 가격

if sql%rowcount = 0 than
 alert("상품 가격이 변경되었습니다.");
end if;

 

하지만 이런 로직은 찾기 힘들다. 주문을 진행하는 동안 상품 공급업체가 가격을 변경하지 않을 것이라고 낙관적으로 생각하기 때문이다.

 

 

'Back-End > DB' 카테고리의 다른 글

SQL 공유 및 재사용  (0) 2024.07.26
SQL 파싱과 최적화  (3) 2024.07.25
인덱스의 기본 (2)  (0) 2023.10.12
인덱스의 기본  (3) 2023.10.11
인덱스를 사용하는 이유  (0) 2023.09.14

HTTP 커넥션을 생성하고 최적화하는 HTTP 기술에 대해서 알아보자. 우선 잘못 이해하는 Connection 헤더에 대해 먼저 알아보자.

 

HTTP는 클라이언트와 서버 사이에 프락시 서버, 캐시 서버 등과 같은 중개 서버가 놓이는 것을 허락한다. HTTP 메시지는 클라이언트에서 서버(혹은 리버스 서버)까지 중개 서버들을 하나하나 거치면서 전달된다.

 

HTTP Connection 헤더 필드는 커넥션 토큰을 쉼표로 구분하여 가지고 있으며, 그 값들은 다른 커넥션에 전달되지 않는다. 예를 들어, 다음 메시지를 보낸 다음 끊어져야 할 커넥션은 Connection: close 라고 명시할 수 있다.

 

Connection 헤더에는 다음 세 가지 종류의 토큰이 전달될 수 있기 때문에 혼란스러울 수 있다.

  • HTTP 헤더 필드 명은, 이 커넥션에만 해당되는 헤더들을 나열한다.
  • 임시적인 토큰 값은, 커넥션에 대한 비표준 옵션을 의미한다.
  • close 값은, 커넥션이 작업이 완료되면 종료되어야 함을 의미한다.

커넥션 토큰이 HTTP 헤더 필드 명을 가지고 있으면, 해당 필드들은 현재 커넥션 만을 위한 정보이므로 다음 커넥션에 전달하면 안 된다.

Connection 헤더에 있는 모든 헤더 필드는 메시지를 다른 곳으로 전달하는 시점에 삭제되어야 한다.

 

HTTP 애플리케이션이 Connection 헤더와 함께 메시지를 전달받으면, 수신자는 송신자에게서 온 요청에 기술되어 있는 모든 옵션을 적용한다. 그리고 다음 홉에 메시지를 전달하기 전에 Connection 헤더와 Connection 헤더에 기술되어 있던 모든 헤더를 삭제한다.

 

 

순차적인 트랜잭션 처리에 의한 지연

커넥션 관리가 제대로 안되면 TCP 성능이 매우 안 좋아질 수 있다. 예를 들어 3개의 이미지가 있는 웹페이지가 있다고 해보자. 브라우저가 이 페이지를 보여주려면 네 개의 HTTP 트랜잭션을 만들어야 한다. 하나는 해당 HTML을 받기 위해, 나머지 세 개는 첨부된 이미지를 받기 위한 것이다. 각 트랜잭션이 새로운 커넥션을 필요로 한다면, 커넥션을 맺는데 발생하는 지연과 함께 느린 시작 지연이 발생할 것이다.

네 개의 트랜잭션

 

이를 해결하기 위해 다음과 같은 기술이 있다.

  1. 병렬(parallel) 커넥션 : 여러 개의 TCP 커넥션을 통한 동시 HTTP 요청
  2. 지속(persistent) 커넥션 : 커넥션을 맺고 끊는 데서 발생하는 지연을 제거하기 위한 TCP 커넥션의 재활용
  3. 파이프라인(pipelined) 커넥션 : 공유 TCP 커넥션을 통한 병렬 HTTP 요청
  4. 다중(multiplexed) 커넥션 : 요청과 응답들에 대한 중재

 

 

1. 병렬 커넥션 

HTTP 클라이언트가 여러 개의 커넥션을 맺음으로써 여러 개의 HTTP 트랜잭션을 병렬로 처리할 수 있게 된다. 만약 4개의 이미지를 전달받는다면 할당받은 각 TCP 커넥션상의 트랜잭션을 토해 병렬로 내려받는다.

병렬 커넥션

 

각 커넥션의 지연 시간을 겹쳐 총 지연시간을 줄이고, HTML 먼저 내려받고 남은 세 개의 트랜잭션이 각각 별도의 커넥션에서 동시에 처리된다. 이미지들을 병렬로 내려받아 커넥션 지연이 겹쳐짐으로써 총 지연시간이 줄어든다.

 

하지만 병렬 커넥션이 항상 더 빠르지는 않다.

만약 클라이언트의 네트워크 대역폭이 좁다면 대부분 시간을 데이터를 전송하는 데만 쓸 것이다. 여러 개의 객체를 병렬로 내려받는 경우, 이 제한된 대역폭 내에서 각 객체를 전송받는 것은 느리기 때문에 성능상의 장점은 거의 없어진다.

 

또한 다수의 커넥션은 메모리를 많이 소모하고 자체적인 성능 문제를 발생시킨다.  백 명의 가상 사용자가 각각 100개의 커넥션을 맺고 있다면, 서버는 총 10,000개의 커넥션을 맺게 되는 것이다. 이는 서버의 성능을 크게 떨어뜨린다. 

 

브라우저는 이러한 이유로 실제 병렬 커넥션을 사용하긴 하지만 적은 수(대부분 4개)의 병렬 커넥션만을 허용한다.

 

병렬 커넥션의 단점

  • 각 트랜잭션마다 새로운 커넥션을 맺고 끊기 때문에 시간과 대역폭이 소요된다.
  • 각각의 새로운 커넥션은 TCP 느린 시작 때문에 성능이 떨어진다.
  • 실제로 연결할 수 있는 병렬 커넥션의 수에는 제한이 있다.

 

 

2. 지속 커넥션

HTTP/1.1을 지원하는 기기는 처리가 완료된 후에도 TCP 커넥션을 유지하여 앞으로 있을 HTTP 요청에 재사용 할 수 있다. 처리가 완료된 후에도 계속 연결된 상태로 있는 TCP 커넥션을 지속 커넥션이라고 부른다. 비지속 커넥션은 각 처리가 끝날 때마다 커넥션을 끊지만, 지속 커넥션은 클라이언트나 서버가 커넥션을 끊기 전까지는 트랜잭션 간에도 커넥션을 유지한다. 해당 서버에 이미 맺어져 있는 지속 커넥션을 재사용함으로써, 커넥션을 맺기 위한 준비작업에 따르는 시간을 절약할 수 있다. 게다가 이미 맺어져 있는 커넥션은 TCP의 느린 시작으로 인한 지연을 피함으로써 더 빠르게 데이터를 전송할 수 있다.

 

그렇다면 지속 커넥션과 병렬 커넥션 중 어떤것이 더 빠를까?

 

지속 커넥션은 병렬 커넥션에 비해 몇 가지 장점이 있다. 커넥션을 맺기 위한 사전 작업과 지연을 줄여주고, 튜닝된 커넥션을 유지하며, 커넥션의 수를 줄여준다.  하지만 지속 커넥션을 잘못 관리할 경우, 계속 연결된 상태로 있는 수많은 커넥션이 쌓이게 된다. 이는 로컬의 리소스 그리고 원격의 클라이언트와 서버의 리소스에 불필요한 소모를 발생시킨다.

 

지속 커넥션은 병렬 커넥션과 함께 사용될 때에 가장 효과가 좋다.  두 가지 지속 커넥션 타입이 있다.

  1. HTTP/1.0+ : keep-alive
  2. HTTP/1.1 : 지속 커넥션

HTTP/1.0 keep-alive 커넥션을 구현한 클라이언트는 커넥션을 유지하기 위해서 요청에 Connection:Keep-Alive 헤더를 포함시킨다. 이 요청을 받은 서버는 그다음 요청도 이 커넥션을 통해 받고자 한다면, 응답 메시지에 같은 헤더를 포함시켜 응답한다. 응답에 Connection:Kepp-Alive 헤더가 없으면, 클라이언트는 서버가 keep-alive를 지원하지 않으며, 응답 메시지가 전송되고 나면 서버 커넥션을 끊을 것이라 추정한다.

keep alive 핸드 셰이크

 

Keep-Alive 헤더 사용 다음의 예는 서버가 약 5개의 추가 트랜잭션이 처리될 동안 커넥션을 유지하거나, 2분 동안 커넥션을 유지하라는 내용의 Keep-Alive 응답 헤더다.

 

 

HTTP/1.1의 지속 커넥션

HTTP/1.1에서는 keep-alive 커넥션을 지원하지 않는 대신, 설계가 더 개선된 지속 커넥션을 지원한다. 

기본적으로 활성화 되어 있으며 별도 설정을 하지 않는 한, 모든 커넥션을 지속 커넥션으로 취급한다. HTTP/1.1 애플리케이션은 트랜잭션이 끝난 다음 커넥션을 끊으려면 Connection: close 헤더를 명시해야 한다. 하지만 헤더가 없더라도 커넥션을 영원히 유지하겠다는 것을 뜻하지는 않는다.

 

'네트워크' 카테고리의 다른 글

캐시  (1) 2024.08.07
웹 서버  (1) 2024.08.06
HTTP 완벽 가이드 - 커넥션 관리  (0) 2024.07.21
HTTP 완벽 가이드 - HTTP 메시지  (1) 2024.07.11
HTTP 완벽 가이드 - URL과 리소스  (0) 2024.07.10

#함수 생성 시점과 함수 호이스팅

// 함수 참조
console.dir(add); // f add(x, y)
console.dir(sub); // undefined

// 함수 호출
console.log(add(2, 5)) // 7
console.log(sub(2, 5)) // TypeError : sub is not a function

// 함수 선언문
function add(x, y) {
	return x + y;
}

// 함수 표현식
var sub = funtion (x, y) {
	return x - y;
};

위와 같이 함수 선언문으로 정의한 함수는 함수 선언문 이전에 호출할 수 있다. 그러나 함수 표현식으로 정의한 함수는 함수 표현식 이전에 호출할 수 없다. 이는 함수 선언문으로 정의한 함수와 함수 표현식으로 정의한 함수는 생성 시점이 다르기 때문이다.

모든 선언문이 그렇듯 함수 선언문도 코드가 한 줄씩 순차적으로 실행되는 시점인 런타임(runtime) 이전에 자바스크립트 엔진에 의해 먼저 실행된다. 다시 말해, 함수 선언문으로 함수를 정의하면 런타임 이전에 함수 객체가 먼저 생성된다. 그리고 자바스크립트 엔진은 함수 이름과 동일한 이름의 식별자를 암묵적으로 생성하고 생성된 함수 객체를 할당한다.

즉, 코드가 한 줄씩 순차적으로 실행되기 시직하는 런타임에는 이미 함수 객체가 생성되어 있고  함수 이름과 동일한 식별자에 할당까지 완료된 상태다. 따라서 함수 선언문 이전에 함수를 참조할 수 있으며 호출할 수도 있다. 

이처럼 함수 선언문이 코드의 선두로 끌어 올려진 것처럼 동작하는 자바스크립트 고유의 특징을 함수 호이스팅(function hoisting)이라 한다.

함수 호이스팅과 변수 호이스팅은 미묘한 차이가 있으므로 주의해야 한다. var 키워드를 사용한 변수 선언문과 함수 선언문은 런타임 이전에 자바스크립트 엔진에 의해 번저 실행되어 식별자를 생성한다는 점에서 동일하다. 하지만 var 키워드로 선언된 변수는 undefined로 초기화되고, 함수 선언문을 통해 암묵적으로 생성된 식별자는 함수 객체로 초기화된다. 따라서 var 키워드를 사용한 변수 선언문 이전에 변수를 참조하면 변수 호이스팅에 의해 undefined로 평가되지만 함수 선언문으로 정의한 함수를 함수 선언문 이전에 호출하면 함수 호이스팅에 의해 호출이 가능하다.

함수 표현식은 변수에 할당되는 값이 함수 리터럴인 문이다. 따라서 함수 표현식은 변수 선언문과 변수 할당문을  한 번에 기술한 축약 표현과 동일하게 작동한다.

변수 선언은 런타임 이전에 실행되어 undefined로 초기화되지만 변수 할당문의 값은 할당문이 실행되는 시점, 즉 런타임에 평가되므로 함수 표현식의 함수 리터럴도 할당문이 실행되는 시점에 평가되어 함수 객체가 된다.

따라서 함수 표현식으로 함수를 정의하면 함수 호이스팅이 발생하는 것이 아니라 변수 호이스팅이 발생한다.

함수 표현식 이전에 함수를 참조하면 undefined로 평가된다. 따라서 이때 함수를 호출하면 undefined를 호출하는 것과 마찬가지이므로 타입 에러가 발생한다. 따라서 함수 표현식으로 정의한 함수는 반드시 함수 표현식 이후에 참조 또는 호출해야 한다.

함수 호이스팅은 함수를 호출하기 전에 반드시 함수를 선언해야 한다는 당연한 규칙을 무시하므로 JSON을 창안한 더글라스 크락포드는 함수 선언문 대신 함수 표현식을 사용할 것을 권장한다.

 

# Function 생성자 함수

자바스크립트가 기본 제공하는 빌트인 함수인 Function 생성자 함수에 매개변수 목록과 함수 몸체를 문자열로 전달하면서 new 연산자와 함께 호출하면 함수 객체를 생성해서 반환한다. new 연산자 없이 호출해도 결과는 동일하다.

예제) Function 생성자 함수로 add 함수 호출

var add = new Function('x', 'y', 'return x + y');

console.log(add(2, 5)); // 7

 

Function 생성자 함수로 함수를 생성하는 방식은 일반적이지 않으며 바람직하지도 않다. Function 생성자 함수로 생성한 함수는 클로저(closure)를 생성하지 않는 등, 함수 선언문이나 함수 표현식으로 생성한 함수와 다르게 동작한다.

var add1 = (function () {
	var a = 10;
    return function (x, y) {
    return x + y + a;
};
}());

console.log(add1(1, 2)); // 13

var add2 = (function() {
	var a = 10;
    return new Function('x', 'y', 'return x + y');
}());

console.log(add(2, 1)); // ReferenceError : a is not defined

 

# 화살표 함수

ES6에서 도입된 화살표 함수(arrow function)는 function 키워드 대신 화살표(fat arrow) => 를 사용해 좀 더 간략한 방법으로 함수를 선언할 수 있다. 화살표 함수는 익명 함수로 정의한다.

// 화살표 함수
const add = (x, y) => x + y;
console.log(add(2, 5)); // 7

화살표 함수는 생성자 함수로 사용할 수 없고 기존 함수와 this 바인딩 방식이 다르고, prototype 프로퍼티가 없으며 arguments 객체를 생성하지 않는다.

 

- 함수 호출

함수는 함수를 가리키는 식별자와 한 쌍의 소괄호인 함수 호출 연산자로 호출한다. 

# 매개변수와 인수

함수를 실행하기 위해 필요한 값을 함수 외부에서 함수 내부로 전달할 필요가 있는 경우, 매개변수(parameter) 인자를 통해 인수(argument)를 전달한다. 인수는 값으로 평가될 수 있는 표현식이어야 한다. 인수는 함수를 호출할 때 지정하며, 개수와 타입에 제한이 없다.

// 함수 선언문
function add(x, y) {
	return x + y;
}

// 함수 호출
// 인수 1과 2가 매개변수 x와 y에 순서대로 할당되고 함수 몸체의 문들이 실행된다.
var result = add(1, 2);

매개변수는 함수를 정의할 때 선언하며, 함수 몸체 내부에서 변수와 동일하게 취급된다. 즉, 함수가 호출되면 함수 몸체 내에서 암묵적으로 매개변수가 생성되고 일반 변수와 마찬가지로 undefined로 초기화된 이후 인수가 순서대로 할당된다.

매개변수는 함수 몸체 내부에서만 참조할 수 있고 함수 몸체 외부에서는 참조할 수 없다. 즉, 매개변수의 스코프(유효 범위)는 함수 내부다.

function add(x, y) {
	console.log(x, y); // 2 5
    return x + y;
}


add(2, 5);

// add 함수의 매개변수 x, y는 함수 몸체 내부에서만 참조할 수 있다.
console.log(x, y); // ReferenceError : x is not defined

함수는 매개변수의 개수와 인수의 개수가 일치하는지 체크하지 않는다. 즉, 함수를 호출할 때 매개변수의 개수만큼 인수를 전달하는 것이 일반적이지만 그렇지 않은 경우에도 에러가 발생하지 않는다. 인수가 부족해서 인수가 할당되지 않은 매개변수의 값은 undefined이다.

마찬가지로 인수가 더 많은 경우 초과된 인수는 무시된다.

 

# 인수 확인

function add(x, y) {
	return x + y;
}

위의 함수를 정의한 개발자의 의도는 아마도 2개의 숫자 타입 인수를 전달받아 그 합계를 반환하려는 것으로 추측된다.

하지만 코드상으로는 어떤 타입의 인수를 전달해야 하는지, 어떤 타입의 값을 반환하는지 명확하지 않다. 따라서 위 함수는 다음과 같이 호출될 수 있다.

function add(x, y) {
	return x + y;
}

console.log(add(2)); // NaN
console.log(add('a', 'b')); // 'ab'

이러한 상황이 발생한 이유는 다음과 같다.

1. 자바스크립트 함수는 매개변수와 인수의 개수가 일치하는지 확인하지 않는다.

2. 자바스크립트는 동적 타입 언어다. 따라서 자바스크립트 함수는 매개변수의 타입을 사전에 지정할 수 없다.

따라서 자바스크립트의 경우 함수를 정의할 때 적절한 인수가 전달되었는지 확인할 필요가 있다.

function add(x, y) {
	if(typeof x !== 'number' || typeof y !== 'number') {
    	//매개변수를 통해 전달된 인수의 타입이 부적절한 경우 에러를 발생시킨다.
        throw new TypeError('인수는 모두 숫자 값이어야 합니다.');
    }
    
    return x + y;
   
}

console.log(add(2)); 	// TypeError : 인수는 모두 숫자 값이어야 합니다.
console.log(add('a', 'b')); // TypeError : 인수는 모두 숫자 값이어야 합니다.

이처럼 함수 내부에서 적절한 인수가 전달되었는지 확인하더라도 부적절한 호출을 사전에 방지할 수는 없고 에러는 런타임에 발생하게 된다. 따라서 타입스크립트와 같은 정적 타입을 선언할 수 있는 자바스크립트의 상위 확장을 도입해서 컴파일 시점에 부적절한 호출을 방지할 수 있게 하는 것도 하나의 방법이다.

또 arguments 객체를 통해 인수 개수를 확인할 수 있는 방법도 있다.

function add(a, b, c) {
	a = a || 0;
    b = b || 0;
    c = c || 0;
    return a + b + c;
}

console.log(add(1, 2, 3)); // 6
console.log(add(1, 2)); // 3

ES6에서 도입된 매개변수 기본값을 사용하면 함수 내에서 수행하던 인수 체크 및 초기화를 간소화할 수 있다. 매개변수 기본값은 매개변수에 인수를 전달하지 않았을 경우와 undefined를 전달한 경우에만 유효하다.

 

# 매개변수의 최대 개수

매개변수는 순서에 의미가 있고, 최대 개수를 제한하고 있지 않지만 이상적인 함수는 한 가지 일만 해야 하며 가급적 작게 만들어야 한다.

 

# 반환문

함수는 return 키워드와 표현식(반환값)으로 이뤄진 반환문을 사용해 실행 결과를 함수 외부로 반환(return) 할 수 있다.

function multiply(x, y) {
	return x + y; // 반환문
}

// 함수 호출은 반환겂으로 평가된다.
var result = multiply(3, 5);
console.log(result); // 15

multiply 함수는 두 개의 인수를 전달받아 곱한 결과값을 return 키워드를 사용해 반환한다. 함수는 return 키워드를 사용해 자바스크립트에서 사용 가능한 모든 값을 반환할 수 있다. 함수 호출은 표현식이다. 함수 호출 표현식은 return 키워드가 반환한 표현식의 평가 결과, 즉 반환값으로 평가된다.

반환문은 두 가지 역할을 한다. 

첫째, 반환문은 함수의 실행을 중단하고 함수 몸체를 빠져나간다. 따라서 반환문 이후에 다른 문이 존재하면 그 문은 실행되지 않고 무시된다.

둘째, 반환문은 return 키워드 뒤에 오는 표현식을 평가해 반환한다. return 키워드 뒤에 반환값으로 사용할 표현식을 명시적으로 지정하지 않으면 undefined가 반환된다. 

function foo () {
	return;
}

console.log(foo()); // undefined
// 반환문은 생략할 수 있고 이때 함수는 함수 몸체의 마지막 문까지 실행한 후 암묵적으로 undefined를 반환한다.


function foo () {
	// 반환문을 생략하면 암묵적으로 undefined가 반환된다.
}

console.log(foo()); // undefined

function multiply(x, y) {
	// return 키워드와 반환값 사이에 줄바꿈이 있으면
    return // 세미콜론 자동 삽입 기능(ASI)에 의헤 세미콜론이 추가된다.
    x + y; // 무시된다.
}

 

 

-  참조에 의한 전달과 외부 상태의 변경

원시 값은 값에 의한 전달(pass by value), 객체는 참조에 의한 전달(pass by reference) 방식으로 동작한다. 매개변수도 함수 몸체 내부에서 변수와 동일하게 취급되므로 매개변수 또한 타입에 따라 값에 의한 전달, 참조에 의한 전달 방식을 그대로 따른다.

// 매개변수 primitive는 원시 값을 전달받고, 매개변수 obj는 객체를 전달받는다.
function changeVal(primitive, obj) {
	primitive += 100;
    obj.name = 'Kim';
}

// 외부 상태
var num = 200;
var person = { name : 'Lee' };

console.log(num); // 100
console.log(person); // { name : "Lee" }

// 원시 값은 값 자체가 복사되어 전달되고 객체는 참조 값이 복사되어 전달된다.
changVal(num, person);

// 원시 값은 원본이 훼손되지 않는다.
console.log(num); // 100

// 객체는 원본이 훼손된다.
console.log(person); // { name : "Kim" }

changeVal 함수는 매개변수를 통해 전달받은 원시 타입 인수와 객체 타입 인수를 함수 몸체에서 변경한다. 더 엄밀히 말하자면 원시 타입 인수를 전달받은  매개변수 primitive의 경우, 원시 값은 변경 불가능한 값(immutable value)이므로 직접 변경할 수 없기 때문에 재할당을 통해 할당된 원시 값을 새로운 원시 값으로 교체했고, 객체 타입 인수를 전달받은 매개변수 obj의 경우, 객체는 변경 가능한 값(mutable value)이므로 직접 변경할 수 있기 때문에 재할당 없이 할당된 객체를 변경했다.

 

- 다양한 함수의 형태

# 즉시 실행 함수

함수 정의와 동시에 즉시 호출되는 함수를 즉시 실행 함수라고 한다. 즉시 실행 함수는 단 한 번만 호출되며 다시 호출할 수 없다. 

// 익명 즉시 실행 함수
(function () {
	var a = 3;
    var b = 5;
    return a * b;
}());

// 기명 즉시 실행 함수
(function foo() {
	var a = 3;
    var b = 5;
    return a * b;
}());


foo(); // ReferenceError : foo is not defined

즉시 실행 함수는 반드시 그룹 연산자 (...)로 감싸야 한다. 또 즉시 실행 함수도 일반 함수처럼 값을 반환할 수 있고 인수를 전달할 수도 있다.

 

# 재귀 함수

함수가 자기 자신을 호출하는 것을 재귀 호출(recursive call)이라 한다. 재귀 함수(recursive function)는 자기 자신을 호출하는 행위, 즉 재귀 호출을 수행하는 함수를 말한다.

재귀 함수는 반복되는 처리를 위해 사용한다. 

예) 10 부터 0까지 출력하는 함수

function countdown(n) {
	for(var i = n; i >= 0; i--) console.log(i);
}


countdown(10);


// 반복문 없이
function countdown(n) {
	if(n < 0) return;
    console.log(n);
    countdown(n-1); // 재귀 호출
}

countdown(10);

자기 자신을 호출하는 재귀 함수를 사용하면 반복되는 처리를 반복문 없이 구현할 수 있다.

// 팩토리얼(계승)은 1부터 자신까지의 모든 양의 정수의 곱이다.
// n! = 1 * 2...* (n - 1) * n
function fatorial(n) {
	//	탈촐 조건 : n이 1 이하일 때 재귀 호출을 멈춘다.
    if(n <= 1) return n;
    // 재귀 호출
    return n * (n - 1);
}

console.log(factorial(5)); // 120

재귀 함수는 자신을 무한 재귀 호출한다. 따라서 재귀 함수 내에는 반드시 탈출 조건을 만들어야 한다. 탈출 조건이 없으면 무한 호출되어 스택 오버플로(stack overflow)에러가 발생한다.

 

# 중첩 함수

함수 내부에 정의된 함수를 중첩 함수(nested function) 또는 내부 함수(inner function)이라 한다. 그리고 중첩 함수를 포함하는 함수는 외부 함수(outer function)라 부른다. 중첩 함수는 외부 함수 내부에서만 호출할 수 있다. 

function outer() {
	var x = 1;
    
    // 중첩 함수
    function inner() {
    	var y = 2;
        // 외부 함수의 변수를 참조할 수 있다.
        console.log(x + y); // 3
     }
     
     inner();
     
 }
 
 outer();

 

# 콜백 함수

// n만큼 어떤 일을 반복한다.
function repeat(n) {
	// i를 출력한다.
    for(var i = 0; i < n; i++) console.log(i);
}

repeat(5); // 0 1 2 3 4


// 다른 일을 하는 함수 정의
// n만큼 어떤 일을 반복한다.
function repeat1(n) {
	// i를 반환한다.
    for(var i = 0; i < n; i++) console.log(i);
}

repeat1(5); // 0 1 2 3 4 

// n만큼 어떤 일을 반복한다.
function repeat2(n) {
	for(var i = 0; i < n; i++) {
    	// i가 홀수일 때만 출력한다.
        if(i % 2) console.log(i);
    }
}

repeat2(5); // 1 3

위의 예제는 함수들은 반복하는 일은 변하지 않고 공통적으로 수행하지만 반복하면서 하는 일의 내용은 다르다. 

즉, 함수의 일부분만이 다르기 때문에 매번 함수를 새롭게 정의해야 한다. 이 문제는 함수를 합성하는 것으로 해결할 수 있다.

// 외부에서 전달받은 f를 n만큼 반복 호출한다.
function repeat(n, f) {
	for(var i = 0; i < n; i++) {
    	f(i); // i를 전달하면서 f를 호출
    }
}

var logAll = function(i) {
	console.log(i);
};

// 반복 호출할 함수를 인수로 전달한다.
repeat(5, logAll); // 0 1 2 3 4

var logOdds = function(i) {
	if(i % 2) console.log(i);
};


// 반복 호출할 함수를 인수로 전달한다.
repeat(5, logOdds); // 1 3

이처럼 함수의 매개변수를 통해 다른 함수의 내부로 전달되는 함수를 콜백 함수(callback function)라고 하며, 매개변수를 통해 함수의 외부에서 콜백 함수를 전달받은 함수를 고차 함수(Higer-Order Function)라고 한다.

고차 함수는 콜백 함수를 자신의 일부분으로 합성한다. 고차 함수는 매개변수를 통해 전달받은 콜백 함수의 호출 시점을 결정해서 호출한다. 다시 말해, 콜백 함수는 고차 함수에 의해 호출되며 이때 고차 함수는 필요에 따라 콜백 함수에 인수를 전달할 수 있다.

따라서 고차함수에 콜백 함수를 전달할 때 콜백 함수를 호출하지 않고 함수 자체를 전달해야 한다.

// 콜백 함수를 사용한 이벤트 처리
// myButton 버튼을 클릭하면 콜백 함수를 처리한다.
document.getElementById('myButton').addEventListener('click', function() {
	console.log('button clicked');
});

// 콜백 함수를 사용한 비동기 처리
// 1초 후에 메세지를 출력한다.
setTimeout(function () {
	console.log('1초 경과');
}, 1000);

 

'Front-End > JavaScript' 카테고리의 다른 글

자바스크립트에서의 this 졸업하기  (1) 2025.03.14
원시 값과 객체의 비교  (1) 2024.07.24
함수(1)  (1) 2024.06.18
함수 표현식  (1) 2024.06.17
스코프  (4) 2024.06.14

+ Recent posts