티스토리 뷰
약간 특이한 자판기가 있습니다. Nickel(5¢, 센트), Dime(10¢), Quarter(25¢) 세 종류의 동전만 넣을 수 있고, 상품은 사과 주스와 오렌지 주스뿐이며 가격은 각각 30센트입니다. 그리고, 거스름 돈 기능이 없어서 30¢까지만 동전 투입이 가능합니다. 예를 들어, 25¢가 이미 들어있는 상태에서는 Nickel(5¢) 동전만 투입할 수 있습니다. Dime이나 Quarter 동전을 넣는다면 그대로 뱉어냅니다.
이런 자판기를 코드로 어떻게 구현해야 할까요? 객체지향에 익숙한 개발자라면 아래와 비슷한 방식으로 구현할지도 모릅니다.
class VendingMachine {
int numNickels;
int numDimes;
int numQuarters;
OrangeJuice orangeJuice;
AppleJuice appleJuice;
/**
* 동전을 투입한다.
*/
public void insertNickels {...}
public void insertDimes {...}
public void insertQuarters {...}
/*
* 주스를 뽑는다.
*/
public OrangeJuice pushOrangeJuice {...}
public AppleJuice pushAppleJuice {...}
}
좋은 방법입니다! 하지만 이 글에서는 우리는 조금 다른 방식을 시도해보겠습니다. 먼저, 주어진 자판기가 아래의 조건들을 충족하는 것에 주목합시다.
- 투입된 동전의 금액에 따른 유한개의 상태를 갖는다.
- 0¢, 5¢, 10¢, 15¢, 20¢, 25¢, 30¢. 총 7개의 가능한 상태가 존재
- 초기 상태를 갖는다.
- 처음에는 투입 금액이 없으므로 0¢ 상태
- 유한 개의 입력을 받는다.
- Nickel 투입, Dime 투입, Quarter 투입, 오렌지 주스 선택, 사과 주스 선택. 총 5개의 입력이 존재
- 입력에 따른 상태 변화를 파악할 수 있다.
- ex1) 10¢가 들어있는 상태에서 Nickel 동전을 투입하면, 15¢ 상태로 변화
- ex2) 30¢가 들어있는 상태에서 오렌지 주스(또는 사과 주스)를 선택하면, 상품을 내주며 0¢ 상태로 변화
- ex3) 20¢가 들어있는 상태에서 오렌지 주스(또는 사과 주스)를 선택하면, 아무 변화 없음
이는 유한 상태 기계(finite-state machine, FSM)의 조건을 충족합니다. 유한 상태 기계의 정의는 아래와 같습니다.
유한 상태 기계 $M = (S, I, f, s_0, F)$은 아래와 같은 조건을 충족하는 5개의 구성요소로 이루어진다.
1. $S(\neq \emptyset)$: 유한개의 상태를 원소로 갖는 집합이며, 공집합일 수 없음
2. $I(\neq \emptyset)$: 유한개의 입력을 원소로 갖는 집합이며, 공집합일 수 없음
3. 상태 전이 함수$f$($f:S \times I \to S$): 특정 상태에서 특정 입력이 주어진 경우 어떤 상태로 전이되는지에 대한 함수
4. $s_0(\in S)$: 초기 상태
5. $F(\subset S)$: 최종 상태의 집합이며, 공집합일 수 있음
자판기 예시에 위의 정의를 대입해보겠습니다. 먼저, 상태 집합 $S$는 아래와 같습니다.
$S = \lbrace s_{0}, s_{5}, s_{10}, s_{15}, s_{20}, s_{25}, s_{30} \rbrace$, s뒤의 숫자는 투입한 금액
입력 집합 $I$도 다음과 같이 정의할 수 있습니다. Nickel, Dime, Quarter 동전을 투입하는 입력이 있으며, 주스를 뽑는 입력은 자판기 버튼을 눌러야 하니 Push Orange/Apple Juice라고 이름 짓겠습니다.
$I = \lbrace$Insert Nickel, Insert Dime, Insert Quarter, Push Orange Juice, Push Apple Juice$\rbrace$
상태 전이 함수 $f:S \times I \to S$는 상태 값과 입력값의 조합을 전이되는 상태 값으로 mapping 함으로써 정의할 수 있습니다. 예를 들어, 투입 금액이 없는 상태($s_{0}$)에서 5¢ 동전을 넣으면(Insert Nickel), 5¢가 투입된 상태($s_{5}$)로 상태가 전이됩니다.
즉, 아래와 같은 matrix 형태로 상태 전이 함수 $f$를 정의할 수 있습니다. 행은 상태 값, 열은 입력값을 나타내며 각 원소는 해당 상태(행)와 입력(열)의 조합으로 전이되는 상태를 의미합니다.
$f:S \times I \to S$
Insert Nickel | Insert Dime | Insert Quarter | Push Orange Juice | Push Apple Juice | |
$s_{0}$ | $s_{5}$ | $s_{10}$ | $s_{25}$ | $s_{0}$ | $s_{0}$ |
$s_{5}$ | $s_{10}$ | $s_{15}$ | $s_{30}$ | $s_{5}$ | $s_{5}$ |
$s_{10}$ | $s_{15}$ | $s_{20}$ | $s_{10}$ | $s_{10}$ | $s_{10}$ |
$s_{15}$ | $s_{20}$ | $s_{25}$ | $s_{15}$ | $s_{15}$ | $s_{15}$ |
$s_{20}$ | $s_{25}$ | $s_{30}$ | $s_{20}$ | $s_{20}$ | $s_{20}$ |
$s_{25}$ | $s_{30}$ | $s_{25}$ | $s_{25}$ | $s_{25}$ | $s_{25}$ |
$s_{30}$ | $s_{30}$ | $s_{30}$ | $s_{30}$ | $s_{0}$ | $s_{0}$ |
이해를 돕기 위해 몇 가지 시나리오를 더 살펴보겠습니다.
- 초기 상태는 당연히 아무런 금액도 투입을 하지 않았으니, $s_{0}$입니다.
- 자판기의 조건에는 거스름 돈 기능이 없고, 30¢에 정확히 맞추어 동전을 넣어야 한다고 가정했습니다. 따라서, $s_{25}$ 상태와 Insert Dime 입력 조합의 다음 상태는 그대로 $s_{25}$입니다. 투입한 Dime 동전을 그대로 뱉어내고 계속 같은 상태($s_{25}$)를 유지할 것이기 때문입니다.
- 주스의 가격은 30¢라고 하였습니다. 따라서, $s_{30}$ 상태와 Push Orange Juice 입력의 조합은 $s_{0}$ 상태로 전이됩니다. 30¢가 들어있는 상태에서 30¢짜리 주스를 뽑았기 때문입니다.
- 지금 다루는 자판기 시나리오에서는 최종 상태를 특별히 상정하고 있지 않습니다. 따라서 최종 상태의 집합 $F$는 $\emptyset$입니다. 만약 최종 상태를 고려하고 싶다면, 자판기가 동작을 끝내고 종료되는 조건을 지정하면 됩니다. 예를 들어, 주스를 받으면 우리의 유한 상태 자판기가 종료되도록 설계하려면 어떻게 해야 할까요? 주스를 뽑고 난 후의 상태를 $s_{juice}$라고 정의한 후, 최종 상태의 집합을 $F=\lbrace s_{juice}\rbrace$으로 정의하면 됩니다.
자판기를 모델링한 유한 상태 기계는 아래 그림과 같이 표현할 수 있습니다.
실습 프로젝트
이제 앞서 이야기한 내용을 실습 프로젝트로 진행하겠습니다. 실습에는 Spring Statemachine을 이용할 것이며, 구현 및 테스트에 용이하도록 web 서버에 자판기 서비스를 올려 rest API 형식으로 동작하도록 하겠습니다.
의존성 추가
실습 프로젝트에서는 maven pom.xml 파일에 아래와 같이 spring statemachine 의존성을 추가하였습니다. spring-statemachine-starter를 이용한 의존성 추가는 공식 홈페이지를 참고하시어 gradle 버전, maven 버전에서 확인하실 수 있습니다.
<dependency>
<groupId>org.springframework.statemachine</groupId>
<artifactId>spring-statemachine-core</artifactId>
<version>2.2.3.RELEASE</version>
</dependency>
유한 상태 기계 구현
앞에서 이야기한 내용을 다시 돌아보면, 유한 상태 기계는 1) 상태 값의 집합, 2) 입력값의 집합, 그리고 3) 입력에 따라 상태가 어떻게 변화하는지(상태 전이 함수)를 정의함으로써 구성할 수 있습니다. Spring statemachine에서 상태는 State, 입력은 Event, 상태 전이 함수는 transition을 통해 구현할 수 있습니다. 하나씩 살펴보겠습니다.
State(상태)
$S = \lbrace s_{0}, s_{5}, s_{10}, s_{15}, s_{20}, s_{25}, s_{30} \rbrace$, s뒤의 숫자는 투입한 금액
자판기는 투입한 금액에 따라 7개의 상태를 갖습니다. 이를 enum class로 정의합니다.
public enum States {
S0, S5, S10, S15, S20, S25, S30
}
Event(입력)
$I = \lbrace$Insert Nickel, Insert Dime, Insert Quarter, Push Orange Juice, Push Apple Juice$\rbrace$
Nickel, Dime, Quarter 동전을 투입하는 입력과 오렌지 주스, 사과 주스 버튼을 누르는 입력, 총 5개의 입력이 존재합니다. 이를 enum class로 정의합니다.
public enum Events {
InsertNickel, // insert 5 cents
InsertDime, // insert 10 cents
InsertQuarter, // insert 25 cents
PushAppleJuice, // push apple juice
PushOrangeJuice, // push orange juice
}
State Configuration
앞서 정의한 States, Events를 generic으로 한 StateMachineConfigurerAdapter<States, Events>를 상속받은 StateMachineConfig 클래스를 생성합니다. 그리고 @Configuration과 @EnableStateMachine 어노테이션을 추가합니다. Statemachine의 구체적인 동작을 여기서 설정하겠습니다.
...
import org.springframework.statemachine.config.EnableStateMachine;
import org.springframework.statemachine.config.StateMachineConfigurerAdapter;
...
@Configuration
@EnableStateMachine
public class StateMachineConfig extends StateMachineConfigurerAdapter<States, Events> {
...
}
StateMachineConfigurerAdapter에서 제공하는 다양한 configure 메서드를 override할 수 있습니다. 먼저 state를 설정하겠습니다. StateMachineStateConfigurer 객체를 인자로 받는 configure 메서드를 아래와 같이 구현합니다. 자판기 예제에는 최종 상태를 고려하지 않고 있지만, 최종 상태(종료 상태)를 설정하고 싶으면 .end(state)구문을 추가하면 됩니다.
@Override
public void configure(StateMachineStateConfigurer<States, Events> states)
throws Exception {
states
.withStates()
.initial(States.S0)
//.end(States.End) // 최종 상태가 존재하는 경우
.state(States.S5)
.state(States.S10)
.state(States.S15)
.state(States.S20)
.state(States.S25)
.state(States.S30);
}
State를 설정하였으니, 이제 event(입력)와 transition(상태 전이 함수)을 세팅하겠습니다. StateMachineTransitionConfigurer 객체를 인자로 받는 configure 메서드를 구현합니다. 아래는 Nickel 동전을 투입하는 이벤트(InsertNickel)에 대한 transition 예시입니다. 주스를 뽑는 이벤트(PushOrangeJuice/PushAppleJuice)의 설정도 함께 확인하실 수 있습니다.
@Override
public void configure(StateMachineTransitionConfigurer<States, Events> transitions)
throws Exception {
transitions
.withExternal()
.source(States.S0).target(States.S5).event(Events.InsertNickel)
// S0 상태에서 InsertNickel 입력이 주어진 경우 S5 상태로 transition
.and()
.withExternal()
.source(States.S5).target(States.S10).event(Events.InsertNickel)
.and()
.withExternal()
.source(States.S10).target(States.S15).event(Events.InsertNickel)
.and()
.withExternal()
.source(States.S15).target(States.S20).event(Events.InsertNickel)
.and()
.withExternal()
.source(States.S20).target(States.S25).event(Events.InsertNickel)
.and()
.withExternal()
.source(States.S25).target(States.S30).event(Events.InsertNickel)
.and()
.withInternal()
.source(States.S30).event(Events.InsertNickel)
// S30 상태에서 InsertNickel 입력이 주어진 경우, 다시 S30 상태로 transition
...(생략)...
.and()
.withExternal()
.source(States.S30).target(States.S0).event(Events.PushAppleJuice)
// S30 상태에서 주스를 뽑으면 S0 상태로 transition
.and()
.withExternal()
.source(States.S30).target(States.S0).event(Events.PushOrangeJuice);
}
자판기 서비스, 컨트롤러 구현
앞서 설정한 상태 값과 이벤트를 실제로 동작시키기 위한 자판기 서비스를 간단히 만들어 보겠습니다. StateMachine과 투입된 금액(insertedCents), 그리고 자판기가 내어줄 음료(beverage)를 싱글톤으로 선언합니다. StateMachine 객체의 sendEvent 메서드를 통해 이벤트를 발생시킬 수 있습니다.
@Service
@AllArgsConstructor
public class VendingMachineService {
private final StateMachine<States, Events> stateMachine; // 자판기 statemachine
@Getter @Setter
public static int insertedCents = 0; // 투입된 금액
@Getter @Setter
public static String beverage; // 뽑은 음료수
@PostConstruct
private void init() {
stateMachine.start();
log.info("vending machine created");
}
public ResponseModel insertNickel() {
stateMachine.sendEvent(Events.InsertNickel); // InsertNickel 이벤트 발생
return new ResponseModel(insertedCents, null, stateMachine.getState().getId().toString());
}
...(중략)...
public ResponseModel pushOrangeJuice() {
stateMachine.sendEvent(Events.PushOrangeJuice); // PushOrangeJuice 이벤트 발생
return new ResponseModel(insertedCents, beverage, stateMachine.getState().getId().toString());
}
}
이벤트를 발생시킨 후, 자판기의 현황을 편리하게 확인하면 좋겠습니다. 투입 금액, 뽑은 음료, 현재 상태를 반환하도록 ResponseModel을 작성합니다.
@AllArgsConstructor
public class ResponseModel {
public int insertedCents;
public String beverage;
public String currentState;
}
우리의 자판기는 5개의 이벤트를 갖고 있습니다. 각각을 rest API로 호출할 수 있도록 controller를 만들어줍니다.
@RestController
@AllArgsConstructor
public class VendingMachineController {
private final VendingMachineService service;
@GetMapping("/insert/nickel")
public ResponseModel insertNickel() {
return service.insertNickel();
}
@GetMapping("/insert/dime")
public ResponseModel insertDime() {
return service.insertDime();
}
@GetMapping("/insert/quarter")
public ResponseModel insertQuarter() {
return service.insertQuarter();
}
@GetMapping("/push/orange")
public ResponseModel pushOrangeJuice() {
return service.pushOrangeJuice();
}
@GetMapping("/push/apple")
public ResponseModel pushAppleJuice() {
return service.pushAppleJuice();
}
}
지금까지만으로도 기본적인 유한 상태 자판기의 기능은 모두 갖추었습니다. 하지만, Spring Statemachine에서 제공하는 유용한 기능들이 있습니다. 하나씩 살펴보겠습니다.
Guard, Action
이벤트가 발생하면 상태 간의 전이가 발생하게 됩니다. 만약 특정 조건이 만족될 때만 상태 전이를 허용하고 싶다면 Guard를 사용하면 됩니다. 자판기는 주스를 30¢의 가격에 판매하고 있습니다. PushOrangeJuice, PushAppleJuice 이벤트를 발생시킨 경우 투입 금액이 30¢보다 적으면 상태 전이를 막도록 guard를 활용할 수 있습니다. 아래 예시 코드처럼, Guard <States, Events> 인터페이스를 구현하여 boolean을 return하는 evaluate 메서드를 override함으로써 guard 조건을 설정할 수 있습니다.
import com.example.vendingmachine.enums.Events;
import com.example.vendingmachine.enums.States;
import com.example.vendingmachine.service.VendingMachineService;
import org.springframework.statemachine.StateContext;
import org.springframework.statemachine.guard.Guard;
public class PushGuard implements Guard<States, Events> {
@Override
public boolean evaluate(StateContext<States, Events> stateContext) {
int currentCents = VendingMachineService.getInsertedCents();
if(currentCents < 30){
System.out.println("Not enough money!");
VendingMachineService.setBeverage(null);
return false; // 투입 금액이 30센트보다 적으면, guard 통과 불가
}
return true; // guard 통과
}
}
상태가 전이된 후, 일괄적으로 특정 동작을 수행하고 싶을 수도 있습니다. 이런 경우 Action을 이용합니다. 예를 들어, 동전을 투입하는 이벤트를 발생시킨 후, 투입 금액만큼 서비스의 insertedCents 멤버 변수 값을 업데이트하도록 Action을 정의하겠습니다. Action<States, Events> 인터페이스의 execute 메서드를 override하여 상태 전이 후 일괄 동작을 구현합니다.
import com.example.vendingmachine.enums.Events;
import com.example.vendingmachine.enums.States;
import com.example.vendingmachine.service.VendingMachineService;
import org.springframework.statemachine.StateContext;
import org.springframework.statemachine.action.Action;
public class InsertAction implements Action<States, Events> {
@Override
public void execute(StateContext<States, Events> stateContext) {
int currentCents = VendingMachineService.getInsertedCents();
switch(stateContext.getEvent()) {
case InsertNickel:
VendingMachineService.setInsertedCents(currentCents + 5); // Nickel 투입. 5센트 추가
break;
case InsertDime:
VendingMachineService.setInsertedCents(currentCents + 10); // Dime 투입. 10센트 추가
break;
case InsertQuarter:
VendingMachineService.setInsertedCents(currentCents + 25); // Quarter 투입. 25센트 추가
break;
}
}
}
이렇게 정의한 Guard와 Action은 bean으로 생성한 후, 앞서 이야기한 StateMachineConfig 클래스의 transition 설정 단계에서 추가합니다.
@Configuration
@EnableStateMachine
public class StateMachineConfig extends StateMachineConfigurerAdapter<States, Events> {
@Bean
InsertAction insertAction() {
return new InsertAction();
}
@Bean
InsertGuard insertGuard() {
return new InsertGuard();
}
@Bean
PushAction pushAction() {
return new PushAction();
}
@Bean
PushGuard pushGuard() {
return new PushGuard();
}
@Override
public void configure(StateMachineTransitionConfigurer<States, Events> transitions)
throws Exception {
transitions
.withExternal()
.source(States.S0).target(States.S5).event(Events.InsertNickel)
.guard(insertGuard()).action(insertAction()) // guard, action 등록
.and()
.withExternal()
.source(States.S5).target(States.S10).event(Events.InsertNickel)
.guard(insertGuard()).action(insertAction()) // guard, action 등록
...(생략)...
}
...(생략)...
}
Listener
다양하게 제공되는 리스너들을 이용할 수 있습니다. 상태가 전이된 경우, 이전 상태와 전이된 상태를 log로 남긴다면 개발에 큰 도움이 될 것입니다. StateMachineConfig 클래스에서 아래와 같이 Listener를 등록합니다. 위의 그림처럼, 다양한 시점에서 동작하는 리스너가 제공됩니다. 상태 전이가 완료된 후(stateChanged), 이전 상태에서 벗어나는 시점(stateExited), 상태 전이가 시작되는 시점(transitionStarted) 등의 리스너들이 등록할 수 있습니다.
@Override
public void configure(StateMachineConfigurationConfigurer<States, Events> config) throws Exception {
StateMachineListenerAdapter<States, Events> adapter = new StateMachineListenerAdapter<>(){
@Override
public void stateChanged(State<States, Events> fromState, State<States, Events> toState) {
// 리스너의 동작을 구현
log.info("State changed from {} to {}. Current cents {}",
fromState == null ? "start": fromState.getId().toString(),
toState.getId().toString(),
VendingMachineService.getInsertedCents());
}
};
config.withConfiguration().listener(adapter); // 리스너를 등록
}
이제 state가 변경될 때마다 리스너가 log를 남겨주는 것을 확인할 수 있습니다.
Interceptor
리스너 대신 인터셉터를 이용할 수도 있습니다. 자판기 프로젝트에서는 인터셉터를 적용하지는 않고 소개만 하고 넘어가겠습니다.
인터셉터는 리스너와 비슷한 용도로 사용하지만 더욱 저수준까지 내려가 동작합니다. 인터셉터는 statemachine의 상태 전이 중간에 interrupt하여 transition을 멈출 수 있습니다. 상태 전이를 intercept한 후, transition logic을 변경해버리는 것도 가능합니다. 아래는 Spring statemachine 공식 페이지에서 소개하는 인터셉터 설정 예시입니다.
stateMachine.getStateMachineAccessor()
.withRegion().addStateMachineInterceptor(new StateMachineInterceptor<String, String>() {
@Override
public Message<String> preEvent(Message<String> message, StateMachine<String, String> stateMachine) {
return message;
}
@Override
public StateContext<String, String> preTransition(StateContext<String, String> stateContext) {
return stateContext;
}
@Override
public void preStateChange(State<String, String> state, Message<String> message,
Transition<String, String> transition, StateMachine<String, String> stateMachine,
StateMachine<String, String> rootStateMachine) {
}
@Override
public StateContext<String, String> postTransition(StateContext<String, String> stateContext) {
return stateContext;
}
@Override
public void postStateChange(State<String, String> state, Message<String> message,
Transition<String, String> transition, StateMachine<String, String> stateMachine,
StateMachine<String, String> rootStateMachine) {
}
@Override
public Exception stateMachineError(StateMachine<String, String> stateMachine,
Exception exception) {
return exception;
}
});
동작 예시
이제 우리의 유한 상태 자판기가 완성되었습니다! 자판기를 web 서버로 올리고, Postman을 이용하여 테스트해보겠습니다.
먼저, InsertNickel 이벤트를 발생시켰습니다. 초기 상태(S0)에서 Nickel(5¢)이 투입되어 아래와 같이 동작합니다. 앞서 정의한 ResponseModel 객체를 반환하여, 세 가지 정보(현재 투입금액, 음료수, 상태)를 확인할 수 있습니다.
- insertedCents 값은 5¢로 변경
- current state는 'S5'상태로 변경
- 음료수를 뽑는 이벤트가 아니었으니, beverage는 null
이 상태에서, Dime(10¢)을 투입하는 이벤트를 발생시켜보겠습니다. 앞서 상태 전이 함수(transition)에서 정의한 대로, 'S15'로 상태가 변경되었습니다.
S15상태에서 Quarter(25¢)를 투입하면 어떻게 될까요? 우리의 자판기는 30¢가 넘어가는 투입 금액은 그대로 뱉어내고 상태 변화가 없습니다. 따라서 'S15' 상태가 그대로 유지됩니다.
주스를 뽑기 위해 30¢가 필요하므로 'S30' 상태로 가야 합니다. 'S15' 상태에서 Nickel과 Dime을 투입하여 S30 상태로 이동합니다.
'S30' 상태에서 Push Apple Juice 이벤트를 발생시키면, 투입 금액 30¢을 모두 소진하므로, 다시 'S0' 상태로 이동합니다. 그리고, 이번엔 음료수를 뽑았으므로, beverage 변수에 'Apple Juice'가 업데이트되어 반환됩니다.
이 상태(S0)에서 오렌지 주스를 뽑으면 어떻게 될까요? 투입 금액이 30¢가 되지 않으니, 앞서 설정한 Push Guard를 통과하지 못하고 'S0' 상태가 유지됩니다. 음료가 산출되지 않아 beverage는 null로 반환됩니다.
글을 마무리하며
지금까지 유한 상태 기계의 이론을 살펴보고, Spring Statemachine를 이용한 실습 프로젝트를 진행하였습니다. Statemachine은 구현하려는 서비스가 유한 상태 기계의 조건에 부합한다면, 적용을 고려해볼 만한 좋은 방법입니다. 특히, 요즘 각광받는 Event-Driven 아키텍처와 동작 방식이 유사하여 적용하기에 바람직합니다. 이 글이 Spring Statemachine에 관심을 갖는 분들에게 조금이나마 도움이 되길 바랍니다.
* 지금까지 진행한 토이 프로젝트는 github repository에서 확인하실 수 있습니다.
References
Discrete Mathematics and Its Applications, 8th edition, Kenneth H. Rosen, McGraw Hill, 2018
Spring Statemachine, https://spring.io/projects/spring-statemachine
Finite-state machine - Wikipedia, https://en.wikipedia.org/wiki/Finite-state_machine
'Backend' 카테고리의 다른 글
성능 테스트를 위한 격리 - 단순한 모델 (0) | 2022.11.17 |
---|---|
성능 테스트를 위한 격리 - hoverfly (0) | 2022.11.11 |
Java Generic 을 파헤쳐보자 - 활용편 (0) | 2022.10.14 |
Java의 날짜, 시간에 대한 기본적인 정책 (0) | 2022.10.12 |
로그인 비밀번호를 지켜라 (4) | 2022.10.07 |