PROJECT/Stamp Crush

[우테코] 인덱스를 활용한 스탬프크러쉬의 쿼리 성능 개선

깃짱 2023. 10. 7. 14:00
반응형

 

 

 

 

안녕! 

우아한테크코스 5기 [스탬프크러쉬]팀 깃짱이라고 합니다. 

 

스탬프크러쉬 서비스의 소스 코드 바로가기

사장모드: stampcrush.site/admin

고객모드: stampcrush.site

 

💋 인트로

안녕하세요, 우아한테크코스 5기 깃짱입니다.

이번 포스팅에서는 제가 진행하고 있는 프로젝트인, 스탬프크러쉬에서 자주 사용되는 쿼리들의 실행 계획을 확인하고, 개선하는 과정에 대해서 설명하고자 합니다.

 

💋 실행 계획

✔️ 실행 계획 확인의 필요성

우리가 애플리케이션을 구성할 때 MySQL과 같은 DBMS를 사용하는 이유는, 데이터를 안전하게 저장, 관리하고, 빠르게 조회하기 위해서입니다.

MySQL에는 쿼리를 쿼리를 최적으로 처리될 수 있도록 실행 계획을 수립하기 위해서, MySQL 엔진의 뇌라고 볼 수 있는 옵티마이저가 존재합니다. MySQL 옵티마이저는 쿼리를 분석해 실행 계획을 만들고, 이후에 수립한 실행 계획대로 실제로 스토리지 엔진을 통해서 디스크에 접근해 데이터를 읽어옵니다.

하지만, 옵티마이저가 항상 좋은 실행 계획을 만드는 것은 아닙니다. 따라서 MySQL에서는 사용자가 실행 계획을 보완할 수 있도록 EXPLAIN 명령어로 실행 계획을 확인할 수 있도록 해서 합리적이지 못한 실행 계획의 경우에 개발자가 직접 실행 계획을 수정할 수 있도록 도와줍니다.

 

✔️ 실행 계획 확인 명령어

기존에 EXPLAIN 명령어를 통해서 실행 계획을 확인할 수 있었는데, MySQL 8.0.18 버전부터는 EXPLAIN ANALYZE 명령어를 통해서 실행 계획의 단계별로 소요된 시간 정보도 확인할 수 있게 되었습니다.

EXPLAIN ANALYZE는 실제로로 쿼리가 완료되어야 실행 계획을 확인할 수 있으므로, 실행 시간이 너무 오래 걸린다면 EXPLAIN 명령어로 쿼리를 어느 정도 튜닝한 후에 해당 명령어를 실행하는 것이 좋습니다.

 

✔️ 실행 계획 필드의 의미

스탬프크러쉬 서비스 쿼리의 실행 계획을 개선하기 이전에 앞서, EXPLAIN ANALYZE 명령어를 통해서 출력한 실행 계획 필드의 의미를 이해할 수 있어야 합니다.

 

먼저, 예시를 통해 EXPLAIN ANALYZE 출력 결과를 분석해 봅시다!

SELECT first_name, last_name, SUM(amount) AS total
FROM staff INNER JOIN payment
  ON staff.staff_id = payment.staff_id
	 AND
	 payment_date LIKE '2005-08%'
GROUP BY first_name, last_name;
 
+------------+-----------+----------+
| first_name | last_name | total	|
+------------+-----------+----------+
| Mike	   | Hillyer   | 11853.65 |
| Jon		| Stephens  | 12218.48 |
+------------+-----------+----------+
2 rows in set (0,02 sec)

이 명령어는 데이터베이스에서 staffpayment 테이블을 조인하여 2005년 8월에 이루어진 결제 내역을 각 직원별로 합산한 결과를 출력하는 쿼리입니다. 현재 결과로는 Mike, Jon 두 사람만이 출력되었습니다.

 

EXPLAIN ANALYZE 명령어를 통해서 방금 실행한 쿼리를 분석해 보겠습니다.

EXPLAIN ANALYZE
SELECT first_name, last_name, SUM(amount) AS total
FROM staff INNER JOIN payment
  ON staff.staff_id = payment.staff_id
	 AND
	 payment_date LIKE '2005-08%'
GROUP BY first_name, last_name;
 
-> Table scan on <temporary>  (actual time=0.001..0.001 rows=2 loops=1)
	-> Aggregate using temporary table  (actual time=58.104..58.104 rows=2 loops=1)
		-> Nested loop inner join  (cost=1757.30 rows=1787) (actual time=0.816..46.135 rows=5687 loops=1)
			-> Table scan on staff  (cost=3.20 rows=2) (actual time=0.047..0.051 rows=2 loops=1)
			-> Filter: (payment.payment_date like '2005-08%')  (cost=117.43 rows=894) (actual time=0.464..22.767 rows=2844 loops=2)
				-> Index lookup on payment using idx_fk_staff_id (staff_id=staff.staff_id)  (cost=117.43 rows=8043) (actual time=0.450..19.988 rows=8024 loops=2)

 

출력된 실행 계획 중, 일부를 더 자세히 살펴봅시다.

Filter: (payment.payment_date like '2005-08%')
(cost=117.43 rows=894)
(actual time=0.464..22.767 rows=2844 loops=2)

 

두 번째 줄에 등장하는 (cost=117.43 rows=894)costrows는 옵티마이저가 예측한 통계 정보입니다. cost는 옵티마이저가 데이터 액세스, 정렬, 조인 등의 작업에 필요한 리소스 소비량을 기준으로 예상 비용을 산출한 총 예상 비용입니다. rows는 예상되는 결과 행의 수를 나타내며, 예측한 값이기 때문에 실제 결과와 다를 수 있습니다.

 

마지막 줄에 등장하는 (actual time=0.464..22.767 rows=2844 loops=2)actual time, rows, loops는 실제 쿼리의 실행 결과입니다.

actual time은 해당 실행 계획을 실행하는 데 걸린 실제 시간으로, 단위는 ms입니다. 두 개의 숫자 중에서 첫 번째 숫자 값인 0.464첫 번째 레코드를 가져오는데 걸린 시간(Actual time to get first row)을 의미하고, 두 번째 숫자 값인 22.767모든 레코드를 가져오는데 걸린 시간(Actual time to get all rows)을 의미합니다. loops가 1 이상인 경우에는 평균 값을 의미합니다.

rows실제로 읽은 행의 수(Actual number of rows read)를 나타냅니다. 앞서 예측한 값인 894에 비해 세 배 가까이 많은 2844개의 행이 실제로는 반환되었음을 확인할 수 있습니다.

loops실행 계획이 반복 실행된 횟수(Actual number of loops)를 나타냅니다. 일반적으로, loops 값이 높으면 해당 실행 계획이 반복 작업을 수행해야 하는 비용이 높다는 것을 의미합니다. 이는 실행 계획이 반복 작업을 수행하는 횟수가 많아질수록 성능 저하를 초래할 수 있습니다.

 

 

참고로, EXPLAIN ANALYZE 명령어는 항상 결과를 TREE 포맷으로 보여줍니다. (EXPLAIN 명령어와 달리 포맷을 직접 설정할 수 없습니다.)

이 포맷에서 들여쓰기가 같은 레벨은 위에서부터 실행되고, 들여쓰기가 다른 레벨에서는 가장 안쪽에 위치한 라인이 먼저 실행됩니다.

 

이제는 스탬프크러쉬 서비스에서 자주 사용하는 쿼리의 실행 계획을 직접 살펴보고, 개선해 봅시다!

💋 전화번호로 조회 → 전화번호에 인덱스 설정

✔️ 사전 정보

  • customer 테이블의 총 레코드 수 → 5083개
mysql> select count(*) from customer;
+----------+
| count(*) |
+----------+
|	 5083 |
+----------+
1 row in set (0.00 sec)
  • customer 테이블의 정보
mysql> describe table customer;
+----+-------------+----------+------------+------+---------------+------+---------+------+------+----------+-------+
| id | select_type | table	| partitions | type | possible_keys | key  | key_len | ref  | rows | filtered | Extra |
+----+-------------+----------+------------+------+---------------+------+---------+------+------+----------+-------+
|  1 | SIMPLE	  | customer | NULL	   | ALL  | NULL		  | NULL | NULL	| NULL | 4653 |   100.00 | NULL  |
+----+-------------+----------+------------+------+---------------+------+---------+------+------+----------+-------+
1 row in set, 1 warning (0.00 sec)

 

 

✔️ 성능을 측정할 쿼리

explain analyze select
		c1_0.customer_id,
		c1_0.customer_type,
		c1_0.email,
		c1_0.encrypted_password,
		c1_0.login_id,
		c1_0.nickname,
		c1_0.oauth_id,
		c1_0.oauth_provider,
		c1_0.phone_number 
	from
		customer c1_0 
	where
		c1_0.phone_number='01038626099';

 

✔️ 인덱스 설정 전

+-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| EXPLAIN																																																   |
+-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| -> Filter: (c1_0.phone_number = '01038626099')  (cost=469 rows=465) (actual time=0.13..5.19 rows=1 loops=1)
	-> Table scan on c1_0  (cost=469 rows=4652) (actual time=0.0534..4.49 rows=5083 loops=1)
 |
+-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+

인덱스 설정 전의 실행 계획을 살펴보면, 필터링 작업과 테이블 스캔 작업으로 크게 나눌 수 있습니다.

필터링 조건인 c1_0.phone_number = '01038626099'을 만족하는 데이터를 찾기 위해 필터링 작업(filter)을 수행하고, 이후에는 c1_0 테이블을 전체 스캔(table scan)하여 데이터를 가져옵니다.

 

 

✔️ phone_number에 인덱스 설정

mysql> create index index_phone_number on customer (phone_number);
Query OK, 0 rows affected (0.08 sec)
Records: 0  Duplicates: 0  Warnings: 0

mysql> show index from customer;
+----------+------------+--------------------+--------------+--------------+-----------+-------------+----------+--------+------+------------+---------+---------------+---------+------------+
| Table	| Non_unique | Key_name		   | Seq_in_index | Column_name  | Collation | Cardinality | Sub_part | Packed | Null | Index_type | Comment | Index_comment | Visible | Expression |
+----------+------------+--------------------+--------------+--------------+-----------+-------------+----------+--------+------+------------+---------+---------------+---------+------------+
| customer |		  0 | PRIMARY			|			1 | customer_id  | A		 |		4652 |	 NULL |   NULL |	  | BTREE	  |		 |			   | YES	 | NULL	   |
| customer |		  1 | index_phone_number |			1 | phone_number | A		 |		4652 |	 NULL |   NULL | YES  | BTREE	  |		 |			   | YES	 | NULL	   |
+----------+------------+--------------------+--------------+--------------+-----------+-------------+----------+--------+------+------------+---------+---------------+---------+------------+
2 rows in set (0.00 sec)

 

✔️ 인덱스 설정 결과

+------------------------------------------------------------------------------------------------------------------------------------------------+
| EXPLAIN																																		|
+------------------------------------------------------------------------------------------------------------------------------------------------+
| -> Index lookup on c1_0 using index_phone_number (phone_number='01038626099')  (cost=0.35 rows=1) (actual time=0.0286..0.0302 rows=1 loops=1)
 |
+------------------------------------------------------------------------------------------------------------------------------------------------+

인덱스 설정 후의 실행 계획을 살펴보면, 인덱스를 이용하여 c1_0.phone_number = '01038626099' 조건에 해당하는 데이터를 찾습니다. 예상 비용은 0.35이고, 실제 소요 시간은 0.0286에서 0.0302까지 변동하며, 가져오는 행의 수도 1개입니다.

 

 

✔️ 인덱스 설정 결과 비교

 

  인덱스 설정 전 phone_number에 인덱스 설정
스캔 방식 풀 테이블 스캔 phone_number 인덱스 사용
스캔한 열 수 전체(5083) 1
소요 시간 0.0534..4.49 0.0286..0.0302

 

 

따라서, 인덱스 설정 후에는 필터링 작업과 전체 테이블 스캔이 필요하지 않고, 인덱스를 통해 더 효율적으로 데이터를 찾을 수 있기 때문에 성능이 좋아진 것으로 볼 수 있습니다.

 

💋 OAuth 인증을 위한 커버링 인덱스 설정

✔️ 성능을 측정할 쿼리

EXPLAIN ANALYZE 
SELECT customer_id FROM customer 
WHERE oauth_provider = 'KAKAO' AND oauth_id = 2972148695;

 

✔️ 인덱스 설정 전

위에서 설정한 phone_number에 대한 인덱스만 설정되어 있는 상황입니다.

mysql> show index from customer;
+----------+------------+--------------------+--------------+--------------+-----------+-------------+----------+--------+------+------------+---------+---------------+---------+------------+
| Table	| Non_unique | Key_name		   | Seq_in_index | Column_name  | Collation | Cardinality | Sub_part | Packed | Null | Index_type | Comment | Index_comment | Visible | Expression |
+----------+------------+--------------------+--------------+--------------+-----------+-------------+----------+--------+------+------------+---------+---------------+---------+------------+
| customer |		  0 | PRIMARY			|			1 | customer_id  | A		 |		4652 |	 NULL |   NULL |	  | BTREE	  |		 |			   | YES	 | NULL	   |
| customer |		  1 | index_phone_number |			1 | phone_number | A		 |		4652 |	 NULL |   NULL | YES  | BTREE	  |		 |			   | YES	 | NULL	   |
+----------+------------+--------------------+--------------+--------------+-----------+-------------+----------+--------+------+------------+---------+---------------+---------+------------+
2 rows in set (0.00 sec)

성능 측정 결과는 아래와 같습니다.

+--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| EXPLAIN																																																												|
+--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| -> Filter: ((customer.oauth_id = 2972148695) and (customer.oauth_provider = 'KAKAO'))  (cost=469 rows=46.5) (actual time=0.0836..2.54 rows=1 loops=1)
	-> Table scan on customer  (cost=469 rows=4652) (actual time=0.0394..2.2 rows=5083 loops=1)
 |
+--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+

 

 

 

아래처럼 세 가지 인덱스 설정을 고려해볼 수 있습니다.

  1. oauth_provider, oauth_id 순서로 인덱스 설정
  2. oauth_id, oauth_provider 순서로 인덱스 설정
  3. oauth_provider, oauth_id 복합 unique 인덱스 설정
  4. oauth_id, oauth_provider 복합 unique 인덱스 설정

 

직접 실습을 통해서 설정해 봅시다!

 

✔️ oauth_provider, oauth_id 순서로 인덱스 설정 및 결과

 

mysql> create index index_oauth_provider_oauth_id on customer (oauth_provider, oauth_id);
Query OK, 0 rows affected (0.05 sec)
Records: 0  Duplicates: 0  Warnings: 0

mysql> show index from customer;
+----------+------------+-------------------------------+--------------+----------------+-----------+-------------+----------+--------+------+------------+---------+---------------+---------+------------+
| Table	| Non_unique | Key_name					  | Seq_in_index | Column_name	| Collation | Cardinality | Sub_part | Packed | Null | Index_type | Comment | Index_comment | Visible | Expression |
+----------+------------+-------------------------------+--------------+----------------+-----------+-------------+----------+--------+------+------------+---------+---------------+---------+------------+
| customer |		  0 | PRIMARY					   |			1 | customer_id	| A		 |		4652 |	 NULL |   NULL |	  | BTREE	  |		 |			   | YES	 | NULL	   |
| customer |		  1 | index_phone_number			|			1 | phone_number   | A		 |		4652 |	 NULL |   NULL | YES  | BTREE	  |		 |			   | YES	 | NULL	   |
| customer |		  1 | index_oauth_provider_oauth_id |			1 | oauth_provider | A		 |		   2 |	 NULL |   NULL | YES  | BTREE	  |		 |			   | YES	 | NULL	   |
| customer |		  1 | index_oauth_provider_oauth_id |			2 | oauth_id	   | A		 |		   7 |	 NULL |   NULL | YES  | BTREE	  |		 |			   | YES	 | NULL	   |
+----------+------------+-------------------------------+--------------+----------------+-----------+-------------+----------+--------+------+------------+---------+---------------+---------+------------+
4 rows in set (0.01 sec)

 

성능 측정 결과는 아래와 같습니다.

+-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| EXPLAIN																																												 |
+-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| -> Covering index lookup on customer using index_oauth_provider_oauth_id (oauth_provider='KAKAO', oauth_id=2972148695)  (cost=0.95 rows=1) (actual time=0.0216..0.0253 rows=1 loops=1)
 |
+-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+

 

✔️ oauth_id, oauth_provider 순서로 인덱스 설정 및 결과

 

mysql> create index index_oauth_id_oauth_provider on customer (oauth_id, oauth_provider);
Query OK, 0 rows affected (0.06 sec)
Records: 0  Duplicates: 0  Warnings: 0

mysql> show index from customer;
+----------+------------+-------------------------------+--------------+----------------+-----------+-------------+----------+--------+------+------------+---------+---------------+---------+------------+
| Table	| Non_unique | Key_name					  | Seq_in_index | Column_name	| Collation | Cardinality | Sub_part | Packed | Null | Index_type | Comment | Index_comment | Visible | Expression |
+----------+------------+-------------------------------+--------------+----------------+-----------+-------------+----------+--------+------+------------+---------+---------------+---------+------------+
| customer |		  0 | PRIMARY					   |			1 | customer_id	| A		 |		4652 |	 NULL |   NULL |	  | BTREE	  |		 |			   | YES	 | NULL	   |
| customer |		  1 | index_phone_number			|			1 | phone_number   | A		 |		4652 |	 NULL |   NULL | YES  | BTREE	  |		 |			   | YES	 | NULL	   |
| customer |		  1 | index_oauth_id_oauth_provider |			1 | oauth_id	   | A		 |		   7 |	 NULL |   NULL | YES  | BTREE	  |		 |			   | YES	 | NULL	   |
| customer |		  1 | index_oauth_id_oauth_provider |			2 | oauth_provider | A		 |		   7 |	 NULL |   NULL | YES  | BTREE	  |		 |			   | YES	 | NULL	   |
+----------+------------+-------------------------------+--------------+----------------+-----------+-------------+----------+--------+------+------------+---------+---------------+---------+------------+
4 rows in set (0.01 sec)

 

성능 측정 결과는 아래와 같습니다.

+-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| EXPLAIN                                                                                                                                                                                 |
+-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| -> Covering index lookup on customer using index_oauth_id_oauth_provider (oauth_id=2972148695, oauth_provider='KAKAO')  (cost=0.95 rows=1) (actual time=0.0204..0.0238 rows=1 loops=1)
 |
+-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
1 row in set (0.00 sec)

 

✔️ oauth_provider, oauth_id 복합 unique 인덱스 설정 및 결과

mysql> CREATE UNIQUE INDEX idx_customer_oauth_provider_oauth_id ON customer (oauth_provider, oauth_id);
Query OK, 0 rows affected (0.05 sec)
Records: 0  Duplicates: 0  Warnings: 0

mysql> show index from customer;
+----------+------------+--------------------------------------+--------------+----------------+-----------+-------------+----------+--------+------+------------+---------+---------------+---------+------------+
| Table    | Non_unique | Key_name                             | Seq_in_index | Column_name    | Collation | Cardinality | Sub_part | Packed | Null | Index_type | Comment | Index_comment | Visible | Expression |
+----------+------------+--------------------------------------+--------------+----------------+-----------+-------------+----------+--------+------+------------+---------+---------------+---------+------------+
| customer |          0 | PRIMARY                              |            1 | customer_id    | A         |        4652 |     NULL |   NULL |      | BTREE      |         |               | YES     | NULL       |
| customer |          0 | idx_customer_oauth_provider_oauth_id |            1 | oauth_provider | A         |           2 |     NULL |   NULL | YES  | BTREE      |         |               | YES     | NULL       |
| customer |          0 | idx_customer_oauth_provider_oauth_id |            2 | oauth_id       | A         |           7 |     NULL |   NULL | YES  | BTREE      |         |               | YES     | NULL       |
| customer |          1 | index_phone_number                   |            1 | phone_number   | A         |        4652 |     NULL |   NULL | YES  | BTREE      |         |               | YES     | NULL       |
+----------+------------+--------------------------------------+--------------+----------------+-----------+-------------+----------+--------+------+------------+---------+---------------+---------+------------+
4 rows in set (0.01 sec)
+--------------------------------------------------------------------------------------------------+
| EXPLAIN                                                                                          |
+--------------------------------------------------------------------------------------------------+
| -> Rows fetched before execution  (cost=0..0 rows=1) (actual time=66e-6..115e-6 rows=1 loops=1)
 |
+--------------------------------------------------------------------------------------------------+
1 row in set (0.00 sec)

✔️ oauth_id, oauth_provider 복합 unique 인덱스 설정 및 결과

mysql> show index from customer;
+----------+------------+--------------------------------------+--------------+----------------+-----------+-------------+----------+--------+------+------------+---------+---------------+---------+------------+
| Table    | Non_unique | Key_name                             | Seq_in_index | Column_name    | Collation | Cardinality | Sub_part | Packed | Null | Index_type | Comment | Index_comment | Visible | Expression |
+----------+------------+--------------------------------------+--------------+----------------+-----------+-------------+----------+--------+------+------------+---------+---------------+---------+------------+
| customer |          0 | PRIMARY                              |            1 | customer_id    | A         |        4652 |     NULL |   NULL |      | BTREE      |         |               | YES     | NULL       |
| customer |          0 | idx_customer_oauth_id_oauth_provider |            1 | oauth_id       | A         |           7 |     NULL |   NULL | YES  | BTREE      |         |               | YES     | NULL       |
| customer |          0 | idx_customer_oauth_id_oauth_provider |            2 | oauth_provider | A         |           7 |     NULL |   NULL | YES  | BTREE      |         |               | YES     | NULL       |
| customer |          1 | index_phone_number                   |            1 | phone_number   | A         |        4652 |     NULL |   NULL | YES  | BTREE      |         |               | YES     | NULL       |
+----------+------------+--------------------------------------+--------------+----------------+-----------+-------------+----------+--------+------+------------+---------+---------------+---------+------------+
4 rows in set (0.01 sec)
+--------------------------------------------------------------------------------------------------+
| EXPLAIN                                                                                          |
+--------------------------------------------------------------------------------------------------+
| -> Rows fetched before execution  (cost=0..0 rows=1) (actual time=73e-6..114e-6 rows=1 loops=1)
 |
+--------------------------------------------------------------------------------------------------+
1 row in set (0.00 sec)

 

✔️ 인덱스 설정 결과 비교

  인덱스 설정 전 oauth_provider, oauth_id 순서로 인덱스 설정 oauth_id, oauth_provider 순서로 인덱스 설정 oauth_provider, oauth_id 복합 unique 인덱스 설정 oauth_id, oauth_provider 복합 unique 인덱스 설정
스캔 방식 Filter 후 Full Table Scan 설정한 인덱스 사용 설정한 인덱스 사용 설정한 인덱스 사용 설정한 인덱스 사용
스캔한 열 수 Filter: 1,        
Full Table Scan: 5083 1 1 1 1  
소요 시간 0.0286..0.0302 0.0216..0.0253 0.0204..0.0238 66e-6..115e-6 73e-6..114e-6

 

  • oauth_provider, oauth_id 순서로 인덱스 설정한 경우, 커버링 인덱스를 설정했기 때문에 소요 시간이 조금 단축되기는 했지만, 먼저 설정한 인덱스인 oauth_provider 의 cardinality가 낮아서 큰 효과는 없었습니다.
  • oauth_id, oauth_provider 순서로 인덱스 설정한 경우, 커버링 인덱스를 설정해 소요 시간이 단축되었고, oauth_provider, oauth_id 순서로 인덱스 설정한 경우에 비해서 먼저 설정한 인덱스인 oauth_id의 cardinality가 높아서 소요시간이 더 단축됩니다.

 

💋 참고자료

 

도움이 되었다면, 공감/댓글을 달아주면 깃짱에게 큰 힘이 됩니다!🌟

 

 

반응형