오늘 HN에서 "SQLite is all you need for durable workflows"가 377점 받고 올라왔다. 제목만 보면 또 흔한 "SQLite가 최고다" 류 떡밥인가 싶지만, durable workflow라는 키워드가 붙으면 얘기가 좀 다르다. 실무에서 Temporal이나 AWS Step Functions 같은 워크플로 엔진을 한 번이라도 운영해본 사람이면 "이걸 SQLite로?" 하는 의문이 바로 들 거다. 정리해보자.

왜 지금 이게 화제인가

먼저 durable workflow가 뭔지부터. 결제 처리, 주문 파이프라인, 이메일 발송 후 N일 뒤 리마인드 보내기 같은 여러 단계로 이루어지고, 중간에 프로세스가 죽어도 멈춘 지점부터 다시 이어가야 하는 작업을 말한다.

이걸 직접 짜본 사람은 안다. 그냥 함수 호출 이어붙이면 서버가 3번째 스텝에서 OOM으로 죽는 순간 끝장이다. 1, 2번 스텝은 이미 실행됐는데(예: 카드 승인 완료), 재시작하면 처음부터 다시 돌아 중복 결제가 난다. 그래서 나온 게 Temporal, Cadence, AWS Step Functions, Azure Durable Functions 같은 솔루션이다.

문제는 이 엔진들이 하나같이 인프라가 무겁다는 거다. Temporal만 해도 자체적으로 Cassandra나 PostgreSQL, Elasticsearch를 깔아야 하고, 워커 클러스터에 frontend/history/matching 서비스까지 띄워야 한다. 작은 팀이 "주문 처리 단계 5개를 안정적으로 굴리고 싶다"는 이유로 도입하기엔 운영 부담이 너무 크다. 여기서 "그냥 SQLite 하나로 안 되나?"라는 발상이 나온 거다.

핵심: durable execution의 원리와 SQLite의 역할

durable workflow의 핵심은 단순하다. 각 스텝의 실행 결과를 영속 저장소에 기록(append)하고, 재시작 시 그 기록을 리플레이해서 이미 끝난 스텝은 건너뛴다. 이걸 event sourcing 혹은 journaling이라 부른다.

의사코드로 보면 이렇다.

async def order_workflow(ctx, order_id):
    # ctx.step()은 "이미 실행했으면 저장된 결과를 반환,
    # 아니면 실행하고 결과를 DB에 기록"한다
    payment = await ctx.step("charge", lambda: charge_card(order_id))
    await ctx.step("reserve", lambda: reserve_stock(order_id))
    await ctx.sleep("wait", timedelta(days=1))   # 1일 대기도 durable하게
    await ctx.step("notify", lambda: send_email(order_id))

여기서 ctx.step이 마법의 핵심이다. 동작을 풀어보면:

  1. charge 스텝을 실행하기 전, DB에 "charge 시도 시작" 기록
  2. 실제 charge_card 실행
  3. 결과를 DB에 커밋. 여기까지가 하나의 트랜잭션
  4. 만약 2번과 3번 사이에 프로세스가 죽으면? 재시작 시 "charge가 완료 안 됨"으로 보고 재시도
  5. 이미 charge가 완료됐으면 저장된 결과를 그대로 반환하고 다음으로 넘어감

여기서 SQLite가 빛난다. 워크플로 상태와 스텝 실행 로그를 저장하는 게 결국 트랜잭션 보장되는 작은 KV/로그 저장소면 충분한데, SQLite가 정확히 그거다. WAL 모드 켜면 동시 읽기도 잘 버티고, 단일 파일이라 운영할 게 없다. 별도 DB 프로세스도, 네트워크 홉도 없다.

PRAGMA journal_mode = WAL;
PRAGMA synchronous = NORMAL;  -- durability vs 성능 트레이드오프, 뒤에서 설명

CREATE TABLE workflow_steps (
  run_id   TEXT NOT NULL,
  step_id  TEXT NOT NULL,
  status   TEXT NOT NULL,        -- pending / completed / failed
  result   BLOB,
  created_at INTEGER,
  PRIMARY KEY (run_id, step_id)
);

비유하자면 Temporal이 "전기, 수도, 가스 다 끌어와야 하는 대형 공장"이라면, SQLite 기반은 "전기 콘센트 하나로 돌아가는 작업대"다. 처리량과 규모가 작업대 수준이면 공장을 지을 이유가 없다.

실무 관점: 도입 전에 반드시 따져야 할 것들

1. 워크플로 함수는 반드시 deterministic해야 한다

이건 SQLite든 Temporal이든 durable execution 모델의 공통 함정인데, 처음 쓰는 사람이 100% 밟는 지뢰다. 리플레이로 상태를 복원하기 때문에, 워크플로 함수 본문에서 datetime.now(), random(), 외부 API 직접 호출 같은 비결정적 코드를 쓰면 안 된다. 리플레이할 때마다 값이 달라져서 분기가 꼬인다.

현재 시각이 필요하면 ctx.now()처럼 엔진이 제공하는, 결과가 기록되는 함수를 써야 한다. 외부 호출은 전부 ctx.step()으로 감싸야 한다. 이 규칙을 모르고 짜면 "평소엔 잘 되는데 재시작하면 가끔 이상하게 동작하는" 최악의 디버깅 지옥에 빠진다.

2. SQLite의 동시성 한계 = 곧 처리량 천장

SQLite는 WAL 모드여도 쓰기는 한 번에 하나만 된다(single writer). durable workflow는 스텝마다 쓰기가 발생하므로, 초당 수천 건 이상의 워크플로 전이가 필요하면 SQLite는 병목이 된다. 단일 노드에서 초당 수백~수천 트랜잭션 수준이 현실적인 상한선으로 보인다(워크로드·디스크에 따라 크게 다름, 직접 벤치 필요).

반대로 말하면, 워크플로 전이가 초당 수십~수백 건 수준이고 단일 노드로 충분한 규모면 SQLite가 오히려 더 빠르고 단순하다. 네트워크 왕복이 없으니까.

3. 단일 노드 = SPOF, 그리고 수평 확장 불가

가장 큰 트레이드오프다. SQLite 파일은 한 머신에 묶여 있다. 그 노드가 죽으면 워크플로 처리가 멈춘다. Temporal이 무거운 이유 중 하나가 바로 이 HA(고가용성)와 수평 확장을 기본 제공하기 때문이다.

대응책으로는:

  • EBS 같은 영속 볼륨 + 빠른 재기동으로 RTO를 줄인다 (active-passive)
  • Litestream으로 S3에 실시간 백업해서 데이터 유실만 막는다 (복구는 수동)
  • 정말 HA가 필요하면 이 패턴 자체가 부적합하다. 솔직하게 인정하고 다른 걸 써야 한다

4. synchronous 설정과 durability 트레이드오프

위 스니펫에서 synchronous = NORMAL로 했는데, 이건 OS 크래시(전원 차단 등) 시 마지막 트랜잭션 일부가 날아갈 수 있다. "durable"을 표방하면서 데이터 유실 가능성을 열어두는 건 모순이다. 금전 거래처럼 한 건도 잃으면 안 되는 워크로드면 synchronous = FULL로 가야 하고, 그만큼 쓰기 지연이 늘어난다. 무엇을 보장하고 싶은지부터 정하고 설정해야 한다.

대안 비교

  • Temporal: 규모 크고 HA 필수, 전담 인프라 인력 있을 때. 오버스펙 주의.
  • DBOS (PostgreSQL 기반): durable workflow를 Postgres 위에서. 이미 Postgres 쓰는 팀이면 SQLite보다 자연스러운 선택지일 수 있다.
  • SQLite 기반: 단일 서비스, 소규모 팀, 운영 단순함이 최우선일 때.
  • 그냥 메시지 큐 + 멱등 처리: 워크플로가 단순한 fan-out 수준이면 굳이 엔진 안 써도 된다.

정리

한 줄 요약: durable workflow의 본질은 "트랜잭션 가능한 스텝 로그 + 리플레이"이고, 규모가 단일 노드로 충분하면 그 저장소를 SQLite로 두는 게 운영상 가장 단순하다.

이런 사람에게 추천한다 — 스타트업/소규모 팀에서 결제·주문·예약 같은 다단계 작업을 안정적으로 굴려야 하는데, Temporal 클러스터 운영할 여력은 없는 경우. 반대로 초당 수천 건 이상 처리하거나 HA가 계약 조건인 환경이면 처음부터 무거운 엔진을 쓰는 게 맞다. "SQLite로 충분한가"는 결국 처리량과 가용성 요구사항이 단일 노드 안에 들어오느냐로 결정된다. 멋져 보여서가 아니라, 본인 워크로드 숫자를 먼저 재보고 고르자.

참고 자료

사진: Surface / Unsplash

728x90

Postgres 하나로 Durable Workflow 짜기 — DBOS 방식이 실무에서 먹히는 이유

오늘 HN 목록 보다가 "Building durable workflows on Postgres"(DBOS) 글이 292점 받고 올라와 있길래 골랐다. AI 관련 글이 절반인 와중에 이건 인프라/백엔드 하는 사람한테 훨씬 실용적인 주제다. 워크플로우 오케스트레이션이라고 하면 보통 Temporal, Airflow, Step Functions 떠올리는데, "그거 그냥 Postgres로 다 되는 거 아니냐"는 주장이라 한번 정리해볼 만하다.

왜 지금 이게 화제인가

현업에서 결제, 주문, 정산 같은 워크플로우 짜본 사람은 다 안다. "여러 단계로 이뤄진 작업이 중간에 죽으면 어디부터 다시 해야 하나" 이게 진짜 골칫거리다.

예를 들어 결제 플로우가 이렇다고 치자.

1. 재고 차감
2. 결제 승인 (외부 PG 호출)
3. 포인트 적립
4. 알림 발송

2번에서 PG 호출하다가 서버 프로세스가 죽었다. 결제는 됐는데 포인트는 안 붙었다. 재배포로 인스턴스가 내려갔을 수도 있고, OOM으로 죽었을 수도 있다. 이 "중간에 죽어도 정확히 멈춘 지점부터 다시 이어서 한다"는 게 durable execution(내구성 있는 실행)의 핵심이다.

지금까지 이걸 제대로 하려면 Temporal 같은 별도 워크플로우 엔진을 띄워야 했다. 근데 그게 운영 부담이 만만치 않다. DBOS의 주장은 "이미 너희가 쓰는 Postgres 트랜잭션이면 충분하다"는 거다.

핵심: 어떻게 Postgres만으로 되나

아이디어 자체는 의외로 단순하다. 각 단계(step)의 실행 결과를 Postgres 테이블에 기록해두고, 워크플로우가 재시작되면 이미 완료된 단계는 건너뛰고 결과만 읽어온다. 이게 흔히 말하는 메모이제이션(memoization)이다.

DBOS 라이브러리 기준으로 쓰면 대충 이런 모양이다.

import { DBOS } from "@dbos-inc/dbos-sdk";

class Checkout {
  @DBOS.step()
  static async chargePayment(orderId: string) {
    // 외부 PG 호출
    return await pg.charge(orderId);
  }

  @DBOS.step()
  static async addPoints(userId: string, amount: number) {
    return await pointService.add(userId, amount);
  }

  @DBOS.workflow()
  static async run(orderId: string, userId: string) {
    const result = await Checkout.chargePayment(orderId);
    await Checkout.addPoints(userId, result.amount);
    return result;
  }
}

여기서 마법은 @DBOS.step()에 있다. 이 데코레이터가 붙은 함수는 실행되면 결과가 Postgres에 저장된다. 만약 addPoints 직전에 프로세스가 죽었다가 워크플로우가 재시작되면, chargePayment는 다시 호출되지 않고 저장된 결과를 그대로 반환한다. 이미 결제가 된 걸 또 긁는 사고를 막아주는 구조다.

비유하자면 게임 세이브 포인트다. 보스 잡고 세이브 찍었으면, 다음 구간에서 죽어도 보스부터 다시 잡진 않는다. 워크플로우 상태와 각 스텝 결과가 전부 DB 트랜잭션 안에서 원자적으로 커밋되니까, "절반만 저장된" 어정쩡한 상태가 안 생긴다. 이게 Postgres의 ACID를 그대로 빌려쓰는 부분이다.

그리고 워크플로우 실행 코드 자체가 애플리케이션 프로세스 안에서 돌아간다는 점이 Temporal과 가장 다르다. Temporal은 워커-서버 구조로 별도 클러스터가 필요한데, DBOS는 라이브러리 임베드 방식이라 "내 앱 + Postgres" 조합이면 끝난다.

실무 관점: 도입 전에 따져볼 것들

1) 스텝은 멱등(idempotent)하게 짜야 한다. 이건 durable execution 도구를 뭘 쓰든 공통이다. DBOS가 완료된 스텝을 건너뛴다고 해도, "정확히 한 번 실행됐는지" 보장은 스텝이 커밋되기 직전에 죽는 경계 케이스에서 까다롭다. 외부 PG 호출 같은 건 idempotency key를 같이 넘겨서 PG 쪽에서도 중복 승인을 막아야 안전하다. 라이브러리만 믿고 멱등성 설계를 생략하면 언젠가 중복 결제로 사고 난다.

2) Postgres가 단일 장애 지점이자 병목이 된다. 모든 워크플로우 상태가 한 DB로 몰린다. 워크플로우 처리량이 높아지면 스텝마다 INSERT/UPDATE가 발생하니 DB write 부하가 커진다. 트래픽 큰 서비스라면 워크플로우 전용 DB를 분리하거나, 어차피 쓰는 Postgres라도 커넥션 풀과 vacuum 전략을 미리 생각해둬야 한다. "Postgres is all you need"는 규모가 적당할 때 맞는 말이지, 무한정 확장된다는 뜻은 아니다.

3) 스텝 시그니처를 함부로 못 바꾼다. 진행 중인 워크플로우가 DB에 남아있는데 코드에서 스텝 순서를 바꾸거나 추가하면, 재시작 시 저장된 상태와 코드가 안 맞아 깨질 수 있다. 이건 Temporal의 versioning 이슈와 같은 종류의 함정이다. 워크플로우 코드 변경은 배포 전략을 따로 신경 써야 한다(공식 문서에서 버전 관리 정책 확인 필요).

대안과 비교

  • Temporal: 기능과 생태계는 가장 성숙. 대신 별도 클러스터 운영 부담. 규모 크고 워크플로우가 복잡하면 여전히 강력한 선택.
  • Airflow: 배치/데이터 파이프라인용. 실시간 트랜잭션성 워크플로우엔 결이 안 맞는다.
  • Step Functions: AWS 락인. 매니지드라 편하지만 로컬 개발/디버깅이 불편하고 상태 전이마다 비용이 붙는다.
  • 직접 구현: outbox 패턴 + 상태 테이블로 비슷하게 만들 수 있다. DBOS는 이 패턴을 라이브러리로 추상화해준 거라고 보면 된다.

개인적으로는 이미 Postgres 박혀 있는 모놀리식~중소 규모 서비스에서 "결제/정산 같은 단계형 작업의 신뢰성"만 올리고 싶을 때 가성비가 가장 좋아 보인다. 운영 컴포넌트를 안 늘려도 되는 게 제일 큰 메리트다.

정리

한 줄 요약: 이미 쓰는 Postgres 트랜잭션을 세이브 포인트 삼아, 중간에 죽어도 이어서 실행되는 워크플로우를 별도 인프라 없이 만든다.

누가 언제 쓰면 좋냐면 — 결제/주문/정산처럼 "중간에 끊기면 곤란한" 다단계 작업이 있고, Temporal 클러스터까지 띄우기는 부담스러운 팀. 반대로 초당 수천 건 워크플로우가 도는 대규모거나, 이미 워크플로우 엔진 운영 노하우가 있다면 굳이 갈아탈 이유는 없다. 도입하더라도 스텝 멱등성 설계와 DB 부하 모니터링은 처음부터 깔고 가야 나중에 안 운다.

참고 자료

※ 라이브러리 API와 버전 관리 정책은 빠르게 바뀔 수 있으니 도입 전 공식 문서로 최신 내용 확인 필요.

사진: Surface / Unsplash

728x90

Firebase웹에서 컬렉션과 샘플 데이터까지 만들었다고 가정할 시 

맥에서 flutter를 사용하여 Firebase와 연동하려고 하는 방법을 설명한다 

 

제일 처음 터미널을 켜서 아래와 같이 입력한다 

curl -sL https://firebase.tools | bash

 

설치 도중 기기의 암호를 입력하는 단계가 있으므로 잊지말고 암호를 입력하자

그러면 설치가 완료 되는데, 

 

완료 이후  아래와 같은 명령어로 firebase에 로그인을 해준다

firebase login

 

명령어를 실행하면 웹에서 연동된 구글 계정으로 로그인 할 수 있다. 

로그인한 이후 

 

다시 터미널로 돌아가서 flutter와 firebase간 연동을 쉽게해주는 dart 패키지를 받을건데  명령어는 다음과 같다

dart pub global activate flutterfire_cli

 

설치가 완료되면 다음 사진과 같이 화면이 뜰텐데

경고의 내용은 심플하게 경로에있는 내용을 현재 쉘rc 파일에 환경설정으로 추가하란 말이다.  

 

경고를 따라서 다음과같이 명령어를 입력하고 내용을 복사하여 ~/.zshrc에 환경변수를 선언했다

vim ~/.zshrc

 

환경변수를 선언하고 vscode terminal에서 다음과 같이 명령어를 치면 사진과 같이 있는 프로젝트를 선택할 수 있는데 

거기서 맞는 프로젝트를 선택하면 된다. 

flutterfire configure

 

프로젝트를 선택했으면 다음으로 어떤 플랫폼에 연동할건지 선택을 할 수 있는데

이번 프로젝트는 앱으로만 만들예정이므로 android 및 ios만 선택하고 나머지는 space키로 선택해제 한다

 

다음으로는 android가 선택이 되어있으므로 패키지 명을 입력해줘야 하는데

입력은 com.example.flutter_Firebase_blog_app으로 해당 이름은 root/android/app/build.gradle에서 찾을 수 있다. 

 

아래 사진에서처럼 namespace를 확인해주면 된다. 

 

하지만 진행하다가 다음과 같은 에러를 마주했는데 찾아보니 ruby version이 너무 낮아서 생기는 문제였다 

(https://totally-developer.tistory.com/176 를 참조하여 ruby 버전 올리고 진행함)

 

 

해결하면 다음과 같이 완료된 모습을 볼 수 있다

 

 

아래명령어를 통해 flutter 패키지에 firebase core 플러그인과 firestore관련 플러그인을 추가해준다 

flutter pub add firebase_core
flutter pub add cloud_firestore

 

에러가 많았다..

 

하지만 결국 해냈고 

 

앱이 실행되는게 확인 되면(빌드하는데 4분걸렸다..) 

main함수에 다음과같이 runApp 위에 다음과 같이 추가해준다 

void main() async {
  WidgetsFlutterBinding.ensureInitialized();

  await Firebase.initializeApp(
    options: DefaultFirebaseOptions.currentPlatform,
  );

  runApp(const ProviderScope(child: MyApp()));
}
728x90

자주 사용하는 ListView 에서 아이템간 공간을 확보하고 싶을때 쓸수 있는 요긴한 방법이다. 

 

사용법은 아래와 같은데, 

일단 ItemCount를 받아야 하고

어떤 아이템을 Building할건지 정한다음 

seperatorBuilder에서 각 아이템간 삽입할 아이템을 정의하면 된다. 

 

아래 예제 같은경우에 각 요소마다 SizedBox(height:10)을 삽입하고 있는걸 확인할 수 있다. 

  Expanded(
    child: ListView.separated(
      itemCount: 10,
      itemBuilder: (context, index) => item(),
      separatorBuilder: (context, index) => SizedBox(
        height: 10,
      ),
    ),
  ),

 

728x90

플러터에서 Navigator를 사용하려면 context를 반드시 받아야 하는데,

만약 Widget을 method로 빼서 사용한다면 context를 인자로 넘겨줘야 할것이다. 

하지만 그렇게 넘겨주게 되면 번거로운작업이 되고 햇갈릴 가능성이 농후하기 때문에 그때는 Builder Widget을 사용하면 된다. 

 

사용방법은 기존에 있는 Widget최상단에서 Builder WIdget으로 감싼뒤 

안에 builder속성에 context를 넣고 기존에 있는 Widget을 리턴하면 

context를 사용할 수 있다. 

 

예제는 아래와 같다. 

 Widget item() {
    return Builder(builder: (context) {
      return GestureDetector(
        onTap: () {
          Navigator.push(
              context, MaterialPageRoute(builder: (context) => DetailPage()));
        },
        child: Container(
          width: double.infinity,
          height: 120,
          child: Stack(
            children: [
              Positioned(
                width: 120,
                height: 120,
                right: 0,
                child: ClipRRect(
                  borderRadius: BorderRadius.circular(20),
                  child: Image.network(
                    'https://picsum.photos/200/300',
                    fit: BoxFit.cover,
                  ),
                ),
              ),
              Container(
                width: double.infinity,
                height: double.infinity,
                margin: EdgeInsets.only(right: 100),
                decoration: BoxDecoration(
                  color: Colors.white,
                  borderRadius: BorderRadius.circular(20),
                ),
                padding: EdgeInsets.all(20),
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: [
                    Text(
                      'data',
                      style:
                          TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
                    ),
                    Spacer(),
                    Text(
                      'dighdighdighdighdighdighdighdighdighdighdighdighdighdighdigh',
                      overflow: TextOverflow.ellipsis,
                      style: TextStyle(color: Colors.grey, fontSize: 12),
                    ),
                    SizedBox(
                      height: 4,
                    ),
                    Text(
                      '2024/12/03 : 13:01:05',
                      style: TextStyle(color: Colors.grey, fontSize: 12),
                    ),
                  ],
                ),
              ),
            ],
          ),
        ),
      );
    });
  }

 

728x90

플러터에서 웹뷰는 iOS, Android의 네이티브 컴포넌트를  사용하여 띄움

따라서 패키지를 추가시켜줘야함

크게 2가지 webView가 있는데

  -> flutter_webview: flutter공식팀에서 만들고 관리하지만 기능이 많이 없음

  -> flutter_inappwebview: 3rd party지만 여러 기능이 있음 

기능이 많이 없는관계로 보통 inappwebview를 많이 쓴다고 한다 

 

추가하는것은 간단하게 flutter pub add flutter_inappwebview로 패키지를 추가해주고 

다음과 같이 추가하여 쓸수 있다. 

InAppWebView(
    // InAppWebView 최초 요청할 URL
    initialUrlRequest: URLRequest(
      url: WebUri("https://www.naver.com/"),
    ),
    // WebView 설정 : 외우지 마시오! 버전바뀌면 사용법 바뀌니까 이런설정을 할 수 있다 정도로만 학습바랍니다!
    initialSettings: InAppWebViewSettings(
      // 사용자 제스쳐 없이도 비디오, 오디오 자동재생 가능여부
      mediaPlaybackRequiresUserGesture: true,
      // 페이지 javascript 활성화여부. 웹 브라우저에서는 js 필수불가결한 요소라 true!
      javaScriptEnabled: true,
      // 요청하는 클라이언트의 브라우저 종류, 운영체제, 장치 정보 등을 서버에서 알 수 있게 보낼때 같이 보냄
      // 디폴트로 웹뷰로 되어 있는데 일부 웹페이지에서는 웹뷰로 접속시 차단하는 페이지도 있으니 꼭 설정!
      userAgent:
          'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.36',
    ),
    // WebView 네이티브 컴포넌트가 만들어지면 호출됨
    onWebViewCreated: (controller) {
      print('onWebViewCreated');
    },
    // 페이지 로딩이 시작될 때 호출됨
    onLoadStart: (controller, url) {
      print('onLoadStart');
    },
    // 페이지 로딩이 완료되면 호출됨
    onLoadStop: (controller, url) {
      print('onLoadStop');
    },
    // 웹뷰 내 웹 페이지에서 GPS, 카메라 등의 권한을 요청했을때 호출됨
    onPermissionRequest: (controller, request) async {
      print('onPermissionRequest');
      return null;
    },
  )
728x90

TextField 사용시 반드시 해줘야 하는게 있다. 

바로 controller의 dispose인데

만약 까먹지 않고 하지 않았다고 하면 페이지는 사라져도 controller는 메모리에 계속 남게되어 leak이 된다. 

 

dispose하는 방법은 간단한데, 

Widget을 StatefulWidget으로 변경한뒤 dispose 메서드를 override해주면서 

controller의 dispose함수를 같이 호출해 주는것이다. 

 

아래 코드를 보면 void dispose부분에서 controller의 dispose메서드를 호출하는부분을 확인할 수 있다. 

import 'package:flutter/material.dart';

class HomePage extends StatefulWidget {
  @override
  State<HomePage> createState() => _HomePageState();
}

class _HomePageState extends State<HomePage> {
  TextEditingController _controller = TextEditingController();

  @override
  void dispose() {
    // TODO: implement dispose
    _controller.dispose();
    super.dispose();
  }

  void onSearch(String text) {
    print(text);
  }

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: () {
        FocusScope.of(context).unfocus();
      },
      child: Scaffold(
        appBar: AppBar(
          title: TextField(
            onSubmitted: onSearch,
            controller: _controller,
            maxLines: 1,
            decoration: InputDecoration(
              hintText: 'Search any book',
              border: MaterialStateOutlineInputBorder.resolveWith((states) {
                if (states.contains(WidgetState.focused)) {
                  return OutlineInputBorder(
                    borderRadius: BorderRadius.circular(10),
                    borderSide: BorderSide(
                      color: Colors.blue,
                      width: 2,
                    ),
                  );
                } else {
                  return OutlineInputBorder(
                    borderRadius: BorderRadius.circular(10),
                  );
                }
              }),
            ),
          ),
        ),
        body: Center(
          child: Text('This is the home page'),
        ),
      ),
    );
  }
}

 

 

728x90

플러터에서 사용자로부터 입력을 받을때 TextField를 사용하는경우가 많을것이다. 

 

TextField를 누르면 보통 유저에게 키보드가 나타나게 되는데

플러터에서는 입력을 마치고 빈화면을 누를때 키보드가 자동으로 사라지지 않는다!

 

이를 해결하기 위해 다음과 같이 코드를 짜야한다 

 

코드는 아래와 같다 

import 'package:flutter/material.dart';

class HomePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: () {
        FocusScope.of(context).unfocus();
      },
      child: Scaffold(
        appBar: AppBar(
          title: TextField(),
        ),
        body: Center(
          child: Text('This is the home page'),
        ),
      ),
    );
  }
}

 

잘 보면 Scaffold를 GestureDetector로 감싸서 앱이 클릭되었을때 기능이 실행되게 하고 

클릭되었을때는 현재 포커스를 잡고있는 컨텍스트를 풀어주는 것을 확인 할 수 있다. 

 

사소하지만 필수적으로 구현되어야 하는 기능이다.

728x90

+ Recent posts