> Java > java지도 시간 > 본문

Java의 Hibernate 프레임워크 데이터베이스 작업에서 잠금 및 쿼리 유형의 사용

高洛峰
풀어 주다: 2016-12-27 13:35:51
원래의
1751명이 탐색했습니다.

Hibernate 및 데이터베이스 잠금
1. 잠금을 사용하는 이유는 무엇입니까?

잠금 메커니즘이 존재하는 이유를 이해하려면 먼저 트랜잭션의 개념을 이해해야 합니다.
트랜잭션은 ACID 특성을 가져야 하는 데이터베이스에서의 일련의 관련 작업입니다.

A(원자성): 모두 성공하거나 모두 취소됩니다.

C(일관성): 데이터베이스의 일관성을 유지합니다.

I(격리): 동일한 데이터에 대해 서로 다른 트랜잭션이 작동하는 경우에는 자체 데이터 공간이 있어야 합니다.

D(내구성): 트랜잭션이 성공적으로 종료되면 데이터베이스에 대한 업데이트가 영구적으로 유지되어야 합니다.

저희가 흔히 사용하는 관계형 데이터베이스 RDBMS는 이러한 트랜잭션 특성을 구현합니다. 그 중 원자성,
일관성, 내구성은 모두 로그로 보장됩니다. 격리는 오늘 우리가 집중하고 있는
잠금 메커니즘에 의해 달성됩니다. 이것이 바로 잠금 메커니즘이 필요한 이유입니다.

격리를 차단하고 통제할 수 없다면 어떤 결과가 발생할 수 있나요?

업데이트 손실: 거래 1에서 제출한 데이터를 거래 2에서 덮어썼습니다.

더티 읽기: 트랜잭션 2는 트랜잭션 1의 커밋되지 않은 데이터를 쿼리합니다.

잘못된 읽기: 거래 2는 거래 1이 제출한 새 데이터를 쿼리합니다.

반복 불가능한 읽기: 트랜잭션 2는 트랜잭션 1이 제출한 업데이트된 데이터를 쿼리합니다.

Hibernate 예제를 살펴보겠습니다. 두 스레드는 tb_account 테이블의
에 있는 동일한 데이터 행 col_id=1을 작업하기 위해 두 개의 트랜잭션을 시작합니다. 의 로그가 교차 인쇄됩니다.

package com.cdai.orm.hibernate.annotation; 
  
import java.io.Serializable; 
  
import javax.persistence.Column; 
import javax.persistence.Entity; 
import javax.persistence.Id; 
import javax.persistence.Table; 
  
@Entity
@Table(name = "tb_account") 
public class Account implements Serializable { 
  
  private static final long serialVersionUID = 5018821760412231859L; 
  
  @Id
  @Column(name = "col_id") 
  private long id; 
    
  @Column(name = "col_balance") 
  private long balance; 
  
  public Account() { 
  } 
    
  public Account(long id, long balance) { 
    this.id = id; 
    this.balance = balance; 
  } 
  
  public long getId() { 
    return id; 
  } 
  
  public void setId(long id) { 
    this.id = id; 
  } 
  
  public long getBalance() { 
    return balance; 
  } 
  
  public void setBalance(long balance) { 
    this.balance = balance; 
  } 
  
  @Override
  public String toString() { 
    return "Account [id=" + id + ", balance=" + balance + "]"; 
  } 
    
}
로그인 후 복사
package com.cdai.orm.hibernate.transaction; 
  
import org.hibernate.Session; 
import org.hibernate.SessionFactory; 
import org.hibernate.Transaction; 
import org.hibernate.cfg.AnnotationConfiguration; 
  
import com.cdai.orm.hibernate.annotation.Account; 
  
public class DirtyRead { 
  
  public static void main(String[] args) { 
  
    final SessionFactory sessionFactory = new AnnotationConfiguration(). 
        addFile("hibernate/hibernate.cfg.xml").        
        configure(). 
        addPackage("com.cdai.orm.hibernate.annotation"). 
        addAnnotatedClass(Account.class). 
        buildSessionFactory(); 
      
    Thread t1 = new Thread() { 
        
      @Override
      public void run() { 
        Session session1 = sessionFactory.openSession(); 
        Transaction tx1 = null; 
        try { 
          tx1 = session1.beginTransaction(); 
          System.out.println("T1 - Begin trasaction"); 
          Thread.sleep(500); 
            
          Account account = (Account)  
              session1.get(Account.class, new Long(1)); 
          System.out.println("T1 - balance=" + account.getBalance()); 
          Thread.sleep(500); 
            
          account.setBalance(account.getBalance() + 100); 
          System.out.println("T1 - Change balance:" + account.getBalance()); 
            
          tx1.commit(); 
          System.out.println("T1 - Commit transaction"); 
          Thread.sleep(500); 
        } 
        catch (Exception e) { 
          e.printStackTrace(); 
          if (tx1 != null) 
            tx1.rollback(); 
        }  
        finally { 
          session1.close(); 
        } 
      } 
        
    }; 
      
    // 3.Run transaction 2 
    Thread t2 = new Thread() { 
        
      @Override
      public void run() { 
        Session session2 = sessionFactory.openSession(); 
        Transaction tx2 = null; 
        try { 
          tx2 = session2.beginTransaction(); 
          System.out.println("T2 - Begin trasaction"); 
          Thread.sleep(500); 
            
          Account account = (Account)  
              session2.get(Account.class, new Long(1)); 
          System.out.println("T2 - balance=" + account.getBalance()); 
          Thread.sleep(500); 
            
          account.setBalance(account.getBalance() - 100); 
          System.out.println("T2 - Change balance:" + account.getBalance()); 
            
          tx2.commit(); 
          System.out.println("T2 - Commit transaction"); 
          Thread.sleep(500); 
        } 
        catch (Exception e) { 
          e.printStackTrace(); 
          if (tx2 != null) 
            tx2.rollback(); 
        }  
        finally { 
          session2.close(); 
        } 
      } 
        
    }; 
      
    t1.start(); 
    t2.start(); 
      
    while (t1.isAlive() || t2.isAlive()) { 
      try { 
        Thread.sleep(2000L); 
      } catch (InterruptedException e) { 
      } 
    } 
      
    System.out.println("Both T1 and T2 are dead."); 
    sessionFactory.close(); 
      
  } 
  
}
로그인 후 복사

격리(Isolation)는 신중한 고려가 필요한 문제이고, 잠금에 대한 이해가 필요하다고 볼 수 있다.

2. 자물쇠는 몇 종류가 있나요?
T1 - Begin trasaction
T2 - Begin trasaction
Hibernate: select account0_.col_id as col1_0_0_, account0_.col_balance as col2_0_0_ from tb_account account0_ where account0_.col_id=?
Hibernate: select account0_.col_id as col1_0_0_, account0_.col_balance as col2_0_0_ from tb_account account0_ where account0_.col_id=?
T1 - balance=100
T2 - balance=100
T2 - Change balance:0
T1 - Change balance:200
Hibernate: update tb_account set col_balance=? where col_id=?
Hibernate: update tb_account set col_balance=? where col_id=?
T1 - Commit transaction
T2 - Commit transaction
Both T1 and T2 are dead.
로그인 후 복사

일반적인 잠금에는 공유 잠금, 업데이트 잠금, 배타적 잠금이 포함됩니다.

1. 공유 잠금: 데이터 읽기 작업에 사용되며 다른 트랜잭션을 동시에 읽을 수 있습니다. 트랜잭션이 select 문을 실행하면
데이터베이스는 자동으로 트랜잭션에 공유 잠금을 할당하여 읽은 데이터를 잠급니다.

2. 배타적 잠금: 데이터를 수정하는 데 사용되며 다른 트랜잭션에서는 이를 읽거나 수정할 수 없습니다. 트랜잭션이 삽입,

업데이트 및 삭제를 실행할 때 데이터베이스가 자동으로 할당됩니다.

3. 업데이트 잠금: 업데이트 작업 중 공유 잠금으로 인한 교착 상태를 방지하는 데 사용됩니다. 예를 들어 트랜잭션 1과 2는

공유 잠금을 유지하고 배타적 잠금을 얻기 위해 기다립니다. 업데이트를 실행할 때 트랜잭션은 먼저 업데이트 잠금을 획득한 다음
업데이트 잠금을 배타적 잠금으로 업그레이드하여 교착 상태를 방지합니다.

또한 이러한 잠금은 데이터베이스의 다양한 개체에 적용될 수 있습니다. 즉, 이러한 잠금은 서로 다른 세부사항을 가질 수 있습니다.
예: 데이터베이스 수준 잠금, 테이블 수준 잠금, 페이지 수준 잠금, 키 수준 잠금, 행 수준 잠금.

그래서 잠금의 종류가 너무 많습니다. 우리는 DBA가 아닙니다.

어떻게 해야 할까요? 다행스럽게도 잠금 메커니즘은 일반 사용자에게 투명합니다. 데이터베이스는 적절한

잠금을 자동으로 추가하고 적절한 시기에 다양한 잠금을 자동으로 업그레이드 및 다운그레이드합니다. 우리가 해야 할 일은
다양한 비즈니스 요구 사항에 따라 격리 수준을 설정하는 방법을 배우는 것입니다.


3. 격리 수준은 어떻게 설정하나요?

일반적으로 데이터베이스 시스템은 사용자가 선택할 수 있는 4가지 트랜잭션 격리 수준을 제공합니다.

1. 직렬화 가능(직렬화 가능): 두 트랜잭션이 동시에 동일한 데이터를 조작하는 경우 트랜잭션 2 멈추고 기다릴 수만 있습니다.

2. 반복 읽기: 트랜잭션 1은 트랜잭션 2의 새로 삽입된 데이터를 볼 수 있지만

기존 데이터에 대한 업데이트는 볼 수 없습니다.

3. Read Committed(커밋된 데이터 읽기): 트랜잭션 1은 트랜잭션 2에 새로 삽입되고 업데이트된 데이터를 볼 수 있습니다.

4. 커밋되지 않은 읽기(커밋되지 않은 데이터 읽기): 트랜잭션 1은 트랜잭션 2의 커밋되지 않은 삽입 및 업데이트
데이터를 볼 수 있습니다.

4. 애플리케이션 잠금


데이터베이스가 Read Commited 격리 수준을 채택하면 애플리케이션에서 비관적 잠금 또는 낙관적 잠금을 사용할 수 있습니다.

1. 비관적 잠금: 현재 트랜잭션이 운영하는 데이터는 반드시 다른 트랜잭션에서 접근할 것이라고 가정하므로, 비관적으로 애플리케이션
프로그램에서 배타적 잠금을 지정하여 데이터 리소스를 잠급니다. 다음 형식은 MySQL 및 Oracle에서 지원됩니다.

은 쿼리된 레코드를 잠그기 위해 배타적 잠금을 사용하도록 명시적으로 허용합니다. 다른 트랜잭션은

에 의해 잠긴 데이터를 쿼리, 업데이트 또는 삭제해야 합니다. 거래가 완료될 때까지.

Hibernate에서는 비관적 잠금을 사용하기 위해 로드할 때 LockMode.UPGRADE를 전달할 수 있습니다. 이전 예를 수정하여
select ... for update
로그인 후 복사
트랜잭션 1과 2의 get 메소드 호출 시 LockMode 매개변수를 하나 더 전달합니다. 로그에서 볼 수 있듯이 트랜잭션 1과 2

는 더 이상 교차 절단을 실행하지 않습니다. 트랜잭션 2는 데이터를 읽기 전에 트랜잭션 1이 끝날 때까지 기다리므로 최종 col_balance 값은 100입니다. 🎜>.


Hibernate는 SQLServer 2005에 대해 SQL을 실행합니다.

col_id 1을 사용하여 선택한 데이터 행에 행 잠금 및 업데이트 잠금을 추가합니다.
package com.cdai.orm.hibernate.transaction; 
  
import org.hibernate.LockMode; 
import org.hibernate.Session; 
import org.hibernate.SessionFactory; 
import org.hibernate.Transaction; 
  
import com.cdai.orm.hibernate.annotation.Account; 
import com.cdai.orm.hibernate.annotation.AnnotationHibernate; 
  
public class UpgradeLock { 
  
  @SuppressWarnings("deprecation") 
  public static void main(String[] args) { 
  
    final SessionFactory sessionFactory = AnnotationHibernate.createSessionFactory();  
  
    // Run transaction 1 
    Thread t1 = new Thread() { 
        
      @Override
      public void run() { 
        Session session1 = sessionFactory.openSession(); 
        Transaction tx1 = null; 
        try { 
          tx1 = session1.beginTransaction(); 
          System.out.println("T1 - Begin trasaction"); 
          Thread.sleep(500); 
            
          Account account = (Account)  
              session1.get(Account.class, new Long(1), LockMode.UPGRADE); 
          System.out.println("T1 - balance=" + account.getBalance()); 
          Thread.sleep(500); 
            
          account.setBalance(account.getBalance() + 100); 
          System.out.println("T1 - Change balance:" + account.getBalance()); 
            
          tx1.commit(); 
          System.out.println("T1 - Commit transaction"); 
          Thread.sleep(500); 
        } 
        catch (Exception e) { 
          e.printStackTrace(); 
          if (tx1 != null) 
            tx1.rollback(); 
        }  
        finally { 
          session1.close(); 
        } 
      } 
        
    }; 
      
    // Run transaction 2 
    Thread t2 = new Thread() { 
        
      @Override
      public void run() { 
        Session session2 = sessionFactory.openSession(); 
        Transaction tx2 = null; 
        try { 
          tx2 = session2.beginTransaction(); 
          System.out.println("T2 - Begin trasaction"); 
          Thread.sleep(500); 
            
          Account account = (Account)  
              session2.get(Account.class, new Long(1), LockMode.UPGRADE); 
          System.out.println("T2 - balance=" + account.getBalance()); 
          Thread.sleep(500); 
            
          account.setBalance(account.getBalance() - 100); 
          System.out.println("T2 - Change balance:" + account.getBalance()); 
            
          tx2.commit(); 
          System.out.println("T2 - Commit transaction"); 
          Thread.sleep(500); 
        } 
        catch (Exception e) { 
          e.printStackTrace(); 
          if (tx2 != null) 
            tx2.rollback(); 
        }  
        finally { 
          session2.close(); 
        } 
      } 
        
    }; 
      
    t1.start(); 
    t2.start(); 
      
    while (t1.isAlive() || t2.isAlive()) { 
      try { 
        Thread.sleep(2000L); 
      } catch (InterruptedException e) { 
      } 
    } 
      
    System.out.println("Both T1 and T2 are dead."); 
    sessionFactory.close(); 
  
  } 
  
}
로그인 후 복사
T1 - Begin trasaction
T2 - Begin trasaction
Hibernate: select account0_.col_id as col1_0_0_, account0_.col_balance as col2_0_0_ from tb_account account0_ with (updlock, rowlock) where account0_.col_id=?
Hibernate: select account0_.col_id as col1_0_0_, account0_.col_balance as col2_0_0_ from tb_account account0_ with (updlock, rowlock) where account0_.col_id=?
T2 - balance=100
T2 - Change balance:0
Hibernate: update tb_account set col_balance=? where col_id=?
T2 - Commit transaction
T1 - balance=0
T1 - Change balance:100
Hibernate: update tb_account set col_balance=? where col_id=?
T1 - Commit transaction
Both T1 and T2 are dead.
로그인 후 복사
2. 낙관적 잠금: 현재 트랜잭션에서 운영되는 데이터는 다른 트랜잭션에서 동시에 액세스할 수 없다고 가정하므로 데이터베이스의 격리 수준에 전적으로 의존합니다

. 잠금 작업을 관리합니다. 드물게 발생할 수 있는

동시성 문제를 방지하려면 애플리케이션에서 버전 제어를 사용하세요.
select account0_.col_id as col1_0_0_, account0_.col_balance as col2_0_0_ from tb_account account0_ with (updlock, rowlock) where account0_.col_id=?
로그인 후 복사

Hibernate에서는 버전 주석을 사용하여 버전 번호 필드를 정의합니다.

Java의 Hibernate 프레임워크 데이터베이스 작업에서 잠금 및 쿼리 유형의 사용

将DirtyLock中的Account对象替换成AccountVersion,其他代码不变,执行出现异常。

package com.cdai.orm.hibernate.transaction; 
  
import javax.persistence.Column; 
import javax.persistence.Entity; 
import javax.persistence.Id; 
import javax.persistence.Table; 
import javax.persistence.Version; 
  
@Entity
@Table(name = "tb_account_version") 
public class AccountVersion { 
  
  @Id
  @Column(name = "col_id") 
  private long id; 
    
  @Column(name = "col_balance") 
  private long balance; 
    
  @Version
  @Column(name = "col_version") 
  private int version; 
  
  public AccountVersion() { 
  } 
  
  public AccountVersion(long id, long balance) { 
    this.id = id; 
    this.balance = balance; 
  } 
  
  public long getId() { 
    return id; 
  } 
  
  public void setId(long id) { 
    this.id = id; 
  } 
  
  public long getBalance() { 
    return balance; 
  } 
  
  public void setBalance(long balance) { 
    this.balance = balance; 
  } 
  
  public int getVersion() { 
    return version; 
  } 
  
  public void setVersion(int version) { 
    this.version = version; 
  } 
    
}
로그인 후 복사

log如下:

T1 - Begin trasaction
T2 - Begin trasaction
Hibernate: select accountver0_.col_id as col1_0_0_, accountver0_.col_balance as col2_0_0_, accountver0_.col_version as col3_0_0_ from tb_account_version accountver0_ where accountver0_.col_id=?
Hibernate: select accountver0_.col_id as col1_0_0_, accountver0_.col_balance as col2_0_0_, accountver0_.col_version as col3_0_0_ from tb_account_version accountver0_ where accountver0_.col_id=?
T1 - balance=1000
T2 - balance=1000
T1 - Change balance:900
T2 - Change balance:1100
Hibernate: update tb_account_version set col_balance=?, col_version=? where col_id=? and col_version=?
Hibernate: update tb_account_version set col_balance=?, col_version=? where col_id=? and col_version=?
T1 - Commit transaction
2264 [Thread-2] ERROR org.hibernate.event.def.AbstractFlushingEventListener - Could not synchronize database state with session
org.hibernate.StaleObjectStateException: Row was updated or deleted by another transaction (or unsaved-value mapping was incorrect): [com.cdai.orm.hibernate.transaction.AccountVersion#1]
   at org.hibernate.persister.entity.AbstractEntityPersister.check(AbstractEntityPersister.java:1934)
   at org.hibernate.persister.entity.AbstractEntityPersister.update(AbstractEntityPersister.java:2578)
   at org.hibernate.persister.entity.AbstractEntityPersister.updateOrInsert(AbstractEntityPersister.java:2478)
   at org.hibernate.persister.entity.AbstractEntityPersister.update(AbstractEntityPersister.java:2805)
   at org.hibernate.action.EntityUpdateAction.execute(EntityUpdateAction.java:114)
   at org.hibernate.engine.ActionQueue.execute(ActionQueue.java:268)
   at org.hibernate.engine.ActionQueue.executeActions(ActionQueue.java:260)
   at org.hibernate.engine.ActionQueue.executeActions(ActionQueue.java:180)
   at org.hibernate.event.def.AbstractFlushingEventListener.performExecutions(AbstractFlushingEventListener.java:321)
   at org.hibernate.event.def.DefaultFlushEventListener.onFlush(DefaultFlushEventListener.java:51)
   at org.hibernate.impl.SessionImpl.flush(SessionImpl.java:1206)
   at org.hibernate.impl.SessionImpl.managedFlush(SessionImpl.java:375)
   at org.hibernate.transaction.JDBCTransaction.commit(JDBCTransaction.java:137)
   at com.cdai.orm.hibernate.transaction.VersionLock$2.run(VersionLock.java:93)
Both T1 and T2 are dead.
로그인 후 복사

由于乐观锁完全将事务隔离交给数据库来控制,所以事务1和2交叉运行了,事务1提交
成功并将col_version改为1,然而事务2提交时已经找不到col_version为0的数据了,所以
抛出了异常。

Java의 Hibernate 프레임워크 데이터베이스 작업에서 잠금 및 쿼리 유형의 사용

Hibernate查询方法比较
Hibernate主要有三种查询方法:

1.HQL (Hibernate Query Language)

和SQL很类似,支持分页、连接、分组、聚集函数和子查询等特性,
但HQL是面向对象的,而不是面向关系数据库中的表。正因查询语句
是面向Domain对象的,所以使用HQL可以获得跨平台的好处,Hibernate
会自动帮我们根据不同的数据库翻译成不同的SQL语句。这在需要支持
多种数据库或者数据库迁移的应用中是十分方便的。

但得到方便的同时,由于SQL语句是由Hibernate自动生成的,所以这不
利于SQL语句的效率优化和调试,当数据量很大时可能会有效率问题,
出了问题也不便于排查解决。

2.QBC/QBE (Query by Criteria/Example)

QBC/QBE是通过组装查询条件或者模板对象来执行查询的。这在需要
灵活地支持许多查询条件自由组合的应用中是比较方便的。同样的问题
是由于查询语句是自由组装的,创建一条语句的代码可能很长,并且
包含许多分支条件,很不便于优化和调试。

3.SQL

Hibernate也支持直接执行SQL的查询方式。这种方式牺牲了Hibernate跨
数据库的优点,手工地编写底层SQL语句,从而获得最好的执行效率,
相对前两种方法,优化和调试方便了一些。

下面来看一组简单的例子。

package com.cdai.orm.hibernate.query; 
  
import java.util.Arrays; 
import java.util.List; 
  
import org.hibernate.Criteria; 
import org.hibernate.Query; 
import org.hibernate.Session; 
import org.hibernate.SessionFactory; 
import org.hibernate.cfg.AnnotationConfiguration; 
import org.hibernate.criterion.Criterion; 
import org.hibernate.criterion.Example; 
import org.hibernate.criterion.Expression; 
  
import com.cdai.orm.hibernate.annotation.Account; 
  
public class BasicQuery { 
  
  public static void main(String[] args) { 
  
    SessionFactory sessionFactory = new AnnotationConfiguration(). 
                      addFile("hibernate/hibernate.cfg.xml").        
                      configure(). 
                      addPackage("com.cdai.orm.hibernate.annotation"). 
                      addAnnotatedClass(Account.class). 
                      buildSessionFactory(); 
  
    Session session = sessionFactory.openSession(); 
  
    // 1.HQL 
    Query query = session.createQuery("from Account as a where a.id=:id"); 
    query.setLong("id", 1); 
    List result = query.list(); 
    for (Object row : result) { 
      System.out.println(row); 
    } 
  
    // 2.QBC 
    Criteria criteria = session.createCriteria(Account.class); 
    criteria.add(Expression.eq("id", new Long(2))); 
    result = criteria.list(); 
    for (Object row : result) { 
      System.out.println(row); 
    } 
      
    // 3.QBE 
    Account example= new Account(); 
    example.setBalance(100); 
    result = session.createCriteria(Account.class). 
            add(Example.create(example)). 
            list(); 
    for (Object row : result) { 
      System.out.println(row); 
    } 
      
    // 4.SQL 
    query = session.createSQLQuery( 
        " select top 10 * from tb_account order by col_id desc "); 
    result = query.list(); 
    for (Object row : result) { 
      System.out.println(Arrays.toString((Object[]) row)); 
  } 
      
    session.close(); 
  } 
  
}
로그인 후 복사
Hibernate: select account0_.col_id as col1_0_, account0_.col_balance as col2_0_ from tb_account account0_ where account0_.col_id=?
Account [id=1, balance=100]
Hibernate: select this_.col_id as col1_0_0_, this_.col_balance as col2_0_0_ from tb_account this_ where this_.col_id=?
Account [id=2, balance=100]
Hibernate: select this_.col_id as col1_0_0_, this_.col_balance as col2_0_0_ from tb_account this_ where (this_.col_balance=?)
Account [id=1, balance=100]
Account [id=2, balance=100]
Hibernate: select top 10 * from tb_account order by col_id desc
[2, 100]
[1, 100]
로그인 후 복사

从log中可以清楚的看到Hibernate对于生成的SQL语句的控制,具体选择
哪种查询方式就要看具体应用了。

更多Java的Hibernate框架数据库操作中锁的使用和查询类型相关文章请关注PHP中文网!

관련 라벨:
원천:php.cn
본 웹사이트의 성명
본 글의 내용은 네티즌들의 자발적인 기여로 작성되었으며, 저작권은 원저작자에게 있습니다. 본 사이트는 이에 상응하는 법적 책임을 지지 않습니다. 표절이나 침해가 의심되는 콘텐츠를 발견한 경우 admin@php.cn으로 문의하세요.
최신 이슈
인기 튜토리얼
더>
최신 다운로드
더>
웹 효과
웹사이트 소스 코드
웹사이트 자료
프론트엔드 템플릿
회사 소개 부인 성명 Sitemap
PHP 중국어 웹사이트:공공복지 온라인 PHP 교육,PHP 학습자의 빠른 성장을 도와주세요!