[Authentication] NextAuth, Keycloak, Spring Boot 삼박자 맞추기 - 02 : JWT, Spring

Sierra·2023년 6월 24일
1
post-thumbnail

😃 Intro

지난 포스팅에서는 Keycloak 의 기초적인 설정 법에 대해 다뤄 보았습니다.

Keycloak을 쉽게 설정하기 위해 Docker를 사용했고, keycloak 내의 데이터를 백업하기 위해 PostgreSQL과 같은 RDBMS를 사용하게 되며, JWT 기반의 인증을 진행하게 된다는 것 또한 지난 포스팅에서 다뤘습니다.

하지만 서비스에 따라 토큰의 Payload에 다른 데이터를 추가해야 하는 경우가 생깁니다. 저 같은 경우엔, 특정 클라이언트의 Role 정보가 필요했었고, 그 외에 Role 정보는 불필요했습니다.

이번 포스팅에서는 토큰 내에 정보를 추가하는 법, 토큰 그 자체를 커스터마이징 하는 법, Spring Boot에서 토큰을 활용해서 리소스를 보호하는 법에 대해 알아 보겠습니다.

🔑 Realm Settings

Realm 내의 Client 마다 공통적으로 가지는 옵션은 Realm Settings 에서 설정 할 수 있습니다. Realm Settings 내의 옵션들을 자세히 살펴보면, SSL 인증서 요구 여부(당연히 운영기에서는 SSL 인증서가 필요하겠죠.) 부터 로그인 시 옵션들, 예를 들면 유저 아이디와 이메일을 동일하게 처리 할 것인지와 같은 옵션들을 커스텀 할 수 있습니다. 심지어 Keycloak이 기본적으로 제공 해 주는 로그인 화면을 커스터마이징 할 수도 있습니다.

Keycloak의 OpenID Connect API 를 사용해서 직접 NextAuth 와 연동한 저와 같은 사례도 존재하지만, 사실 방법이 그것만 있는 것은 아닙니다.

https://www.keycloak.org/docs/latest/securing_apps/#installation-2

공식 문서에서 2. Using OpenID Connect to secure applications and services 섹션을 살펴보면 많은 인사이트를 얻을 수 있습니다. Java, Node.js 등의 애플리케이션에서 Keycloak Adapter를 활용해서 해당 API들과 연동하는 방법들에 대해 가이드가 작성 되어있습니다. 만약 따로 Frontend 를 NextJS를 통해 개발하지 않고 Thymeleaf 를 통해 View 페이지를 작성하는 구조였다면 Spring 에 직접 Keycloak Adapter 를 셋업해서 인증 및 로그아웃을 구현해야 했을 것입니다. 아니면 OAuth2 서버 마냥 Keycloak 을 쓰는 경우도 있을 겁니다. 이런 경우에 Keycloak 에서 기본적으로 제공 해 주는 Login URL 로 유저 페이지를 리다이렉션 시켜 인증을 처리하곤 합니다.

가장 기본적인 Keycloak 의 인증 페이지의 UI는 제공 되지만, 해당 페이지는 커스터마이징이 가능합니다.

기초 UI커스터마이징
출처 : https://keycloak.discourse.group/t/custom-login-ans-signup-page-instead-of-custom-theme/1792/2

이번 포스팅의 기준은 이런 로그인 URL 리다이렉션 기능을 사용하지 않는다는 가정입니다. 이런 기능은 OAuth2 서비스 처럼 Keycloak 을 사용하는 경우에는 유용합니다. 하지만 우리가 이미 프론트엔드에 로그인 화면을 만들어 둔 상황이라면, 직접 OpenID Connect API 를 연동해야합니다. 공식 문서의 2.5. Other OpenID Connect libraries 를 살펴보면 자세한 내용이 언급 되어 있습니다.

명세 된 엔드포인트 중 주로 사용하게 될 엔드포인트는 tokenuserInfo 입니다. token 엔드포인트를 통해 토큰 발급 및 재발급이 진행되고 해당 토큰을 통해 userInfo 엔드포인트에서 유저의 공개 된 정보를 전달 받을 수 있습니다. 물론 토큰 내에 유저의 정보를 넣어서 직접 파싱 하는것도 방법입니다만 아직 시도 해 본 적은 없네요. 토큰 정보를 요청 하는 예시는 아래와 같습니다.

curl \
  -d "client_id=myclient" \
  -d "client_secret=40cc097b-2a57-4c17-b36a-8fdf3fc2d578" \
  -d "username=user" \
  -d "password=password" \
  -d "grant_type=password" \
  "http://localhost:8080/realms/master/protocol/openid-connect/token"

grant_type 이 password 라면 username과 password, 만약 refresh_token 이라면 refresh_token 정보를 통해 토큰이 갱신됩니다.

그럼 이 토큰은 어떻게 커스터마이징 할 수 있을까요? Sessions, Tokens 탭에서 커스터마이징이 가능합니다.

🔐 Sessions

해당 섹션에서는 SSO 세션, 즉 유저의 로그인 세션이 얼마나 오래 유지될 수 있는지 셋업 할 수 있습니다. 유저가 로그인을 해서 토큰이 발급 된다면, 토큰의 유효시간과 다르게 Keycloak 서버 내에 유저의 세션이 생성 됩니다. 이 세션에 대한 설정을 여기서 진행 할 수 있습니다. 각 설정들에 대해 설명 드리자면 아래와 같습니다.

  • SSO Session Idle (SSO 세션 유효 시간)
    • 이 옵션이 Refresh Token 의 유효 시간을 결정함
  • SSO session Max (SSO 세션이 만료되고 무효화 되기 전 까지의 최대 시간)
    • Refresh Token 의 만료 여부와 무관하게 세션 그 자체가 살아있는 시간
  • SSO Session Idle Remenber Me(Remember me 기능이 활성화 되어 있을 때 SSO 세션 유효 시간)
  • SSO Session Max Remember Me(Remember me 기능이 활성화 되어 있을 때 SSO 세션이 만료되고 무효화 되기 전 까지의 최대 시간)

Client Session Idle 옵션은 클라이언트 별로 커스텀할게 아니라면 굳이 당장은 건드리지 않아도 됩니다.

  • Client Session Idle(개별 클라이언트 마다 재 정의 가능한 클라이언트 별 유효시간)

  • Client Session Max(Refresh Token 이 만료되고 무효화 되기 까지의 최대 시간)

  • Offline Session Idle, Offline Session Max Limited(Offline Access 를 위한 옵션들)

  • Login Timeout : 이 시간보다 로그인이 오래 걸린다면 모든 로그인 프로세스를 다시 시작해야 함

🎫 Tokens

토큰에 대한 상세 설정은 여기서 진행 할 수 있습니다. Access Token 의 Lifespan 을 조정하면 되는데 보통은 30분 이내로 지정하는 것을 추천합니다.

🧐 Client Scopes


client Scopes 옵션은 Client 마다 공유되는 권한과 정보들을 지정 해 줄 수 있는 옵션입니다. 해당 정보들에 대해 상세 옵션을 통해 토큰 정보 혹은 userInfo 등에 대해 정보 공유 여부를 설정 해 줄 수 있습니다.

만약 유저의 Role 정보 중 특정 Client 의 Role 정보를 JWT 토큰 내에 공개하고 싶다면 Add to access token 토글을 On 하면 됩니다.

저같은 경우엔 주로 UserRole 에 대한 정보를 공개하는 편입니다. Realm 내에서 이 유저가 어떤 역할인지에 따라서 전체 클라이언트 내에서 분기처리를 해 줄수도 있기 때문입니다.

☀ Spring 내에서 Keycloak JWT를 사용하는 법

☀ OAuth2 Resource Server

인증 및 토큰 발급을 Keycloak 서버에 위임하였다면, 이제 발급 된 JWT 토큰으로 서비스를 사용할 수 있어야 합니다. Spring API 서버는 오직 리소스를 제공해주는 역할만 합니다. 그렇기 때문에 OAuth2 Resource server dependency 를 통해 Keycloak 에서 발급 된 JWT 토큰을 사용할 수 있도록 설정 해 주어야 합니다.

출처 : https://www.baeldung.com/spring-security-oauth-resource-server

Spring Boot 를 활용해서 OAuth2 로그인을 개발하게 된다면 보통 OAuth2 Client Dependency를 사용 할 것입니다.

이 경우엔 OAuth2 서비스를 통한 로그인에 Spring 이 직접 관여하게 됩니다. 하지만 Resource Server Dependency를 사용하게 된다면, 말 그대로 Spring 서버는 리소스를 제공해주는 역할만 진행합니다. 위 이미지는 Resource Server와 Authentication Server (Keycloak) 을 분리 했을때의 Sequence Diagram 입니다.

Spring Boot를 쓴 다는 가정에선 Spring Boot Starter Oauth2 resource server Dependency 를 추가하면 됍니다.

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          jwt-set-uri : ${http://localhost:8080/realms/test/protocol/openid-connect/certs} 
          issuer-uri : ${http://localhost:8080/realms/test} 

issuer-uri 는 토큰을 발급 한 곳의 주소를 의미합니다. jwt-set-uri 는 해당 JWT 토큰에 대한 Public key와 같은 토큰 정보를 가져오는 데 필요한 키값들을 가져올 수 있는 엔드포인트가 작성 됩니다. Keycloak의 해당 엔드포인트에 접근 해 보면 아래와 같은 값이 전달되는데, 해당 값들을 통해 토큰을 검증할 수 있습니다.

{
    "keys": [
        {
            "kid": "NuNedqTYcQ1Fkmitppi_SNM1CIZe-yDwm8AGZfM85lA",
            "kty": "RSA",
            "alg": "RS256",
            "use": "sig",
            "n": "uKGJmgarDivNyGtJPOQpyVGkp6-A2gs18UrcG7tDrVhCTcu1aP3pdL34jXDDOwPng8CLgD3z_rbsDUcLbZiYoHN3M1nejNUJLe0EjmfwucFWRhorFTIjP1WxthHCh-yBV7E_x2WYuUM10h0GHnr2qHFt6G4MNl0YsJcqlhkij93VpZ-v27mBsj4nwUx5e3QGGANwZIpWiirHqwFx5M_VLSpWxr4eBHRAcIojfBg0p00a9K63qotMpKz-U-PmA-NvWfSRzT_JwUkWRriua_bQJOUHn0d3RUBkZ3UpDKxUI520iRWqVtmfopCWx4Ijs77s3SDHZWpPasPOPY__dyUxfQ",
            "e": "AQAB",
            "x5c": [
                "MIICozCCAYsCBgGIw9s7kDANBgkqhkiG9w0BAQsFADAVMRMwEQYDVQQDDAp0ZXN0LXJlYWxtMB4XDTIzMDYxNjEwNTY0NFoXDTMzMDYxNjEwNTgyNFowFTETMBEGA1UEAwwKdGVzdC1yZWFsbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBALihiZoGqw4rzchrSTzkKclRpKevgNoLNfFK3Bu7Q61YQk3LtWj96XS9+I1wwzsD54PAi4A98/627A1HC22YmKBzdzNZ3ozVCS3tBI5n8LnBVkYaKxUyIz9VsbYRwofsgVexP8dlmLlDNdIdBh569qhxbehuDDZdGLCXKpYZIo/d1aWfr9u5gbI+J8FMeXt0BhgDcGSKVooqx6sBceTP1S0qVsa+HgR0QHCKI3wYNKdNGvSut6qLTKSs/lPj5gPjb1n0kc0/ycFJFka4rmv20CTlB59Hd0VAZGd1KQysVCOdtIkVqlbZn6KQlseCI7O+7N0gx2VqT2rDzj2P/3clMX0CAwEAATANBgkqhkiG9w0BAQsFAAOCAQEAaYcXJbHr1SZDtMAHjw+tChSCBjs1ulJqIIUS6i0iEicScte/fMfOhB0kUPLj6a1+fxNPcgA2oKvU+ZT7+BdCVRGRUMpDxQHU2ckaKaBdvJsXGpOS/9649TCuc4c/hAYdc1yCENq/s/5egroz4q5He9NnmD500nkNcFSp7ifkBB3omV+kyFUY8rEh+9tDnm8HoRqNtnmZbzO/4fpj+qZR977PEQ8pd4vVW+Rw8zTX2Uihg5QmVqRIoAPl5h/He+MGrxNNs8litiM9KAB0N5UotxAZ42tNpLSZpyiZuuHowiasxxRWme/lpD63dq573W/Xeo7joEpbFOxp0+iIX3kPUQ=="
            ],
            "x5t": "AHfnpEMyyfLNEeOzFjjR4bmDrq0",
            "x5t#S256": "4FDpoL8bY0vbqsjhi-eyCEPEmzhRnmF08LGVlqg4-Pk"
        },
        {
            "kid": "vzuvKXV1PKn_UV7b_g3yMZ8Va3ALh0f9esokghlrAB8",
            "kty": "RSA",
            "alg": "RSA-OAEP",
            "use": "enc",
            "n": "qcYbKHQQWZJr_MEHojNtQbp_mfDjbknf1m0mBh23p86KOqOjHj1phZ6YGwnSfVzVxjE_CguYLW-bEhtDqu2lb7fVZuxP84oT8iqih83OfI8KbeM2uEYOals0qu1mVkSJuPckov_zvRyldFG1P9ZDDdBkU-ikoOIyvqO7Pe0sWSWantueU3X55d9bLRhyaKJsykYYtTEcbNym_DWSXrTVkYxL_0b5EMirljvD4KyszxrkV4HCGBznbO9-lFsxb7RWnlk-kFoahMTazk09hqKf5swf6wNN3BR9rZmRzq8aWlEIRHgWjBK-bzOHt6y_4597CrY-TNv3pVzp6lXqKwSUEw",
            "e": "AQAB",
            "x5c": [
                "MIICozCCAYsCBgGIw9s9IDANBgkqhkiG9w0BAQsFADAVMRMwEQYDVQQDDAp0ZXN0LXJlYWxtMB4XDTIzMDYxNjEwNTY0NVoXDTMzMDYxNjEwNTgyNVowFTETMBEGA1UEAwwKdGVzdC1yZWFsbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAKnGGyh0EFmSa/zBB6IzbUG6f5nw425J39ZtJgYdt6fOijqjox49aYWemBsJ0n1c1cYxPwoLmC1vmxIbQ6rtpW+31WbsT/OKE/IqoofNznyPCm3jNrhGDmpbNKrtZlZEibj3JKL/870cpXRRtT/WQw3QZFPopKDiMr6juz3tLFklmp7bnlN1+eXfWy0YcmiibMpGGLUxHGzcpvw1kl601ZGMS/9G+RDIq5Y7w+CsrM8a5FeBwhgc52zvfpRbMW+0Vp5ZPpBaGoTE2s5NPYain+bMH+sDTdwUfa2Zkc6vGlpRCER4FowSvm8zh7esv+Ofewq2Pkzb96Vc6epV6isElBMCAwEAATANBgkqhkiG9w0BAQsFAAOCAQEAqIK8OGu54BjuhqA7I/iYu023rwO7IXk2Irpn4R8D0OCRbyIWDEbTpCmWHPcTa5UIE/P8KcV3ijJFrZqM7cldBJDU1CByUAsL0npEA9k6tMqS1gWhS/oRNnLld4oF3W6JJ/7QuJ/T2fd7pJCBMJnLF8pqtPrx5xpIwFQ8NbKhGpLOFDKKIRKLMBqatsyYFVWaNnfwzvb1OPnoRPkII1qhWPWkw9P2U6U8Teir08OqTSlfgFlL88C5skcMSxteaGBqVhBigQRBWg+wQrFDv/w1dSJjHwDIwp5isopCCqSUJMv7T7qc8koSSjHZR0+/nlNlYvMjPzFgCbM7lBPG0HZeSA=="
            ],
            "x5t": "V1pt-DXglkK0rypqkIZzmy9IQuo",
            "x5t#S256": "b5myecfBwcN_B_1KCtA2bu1vTA5X1w7gGfm2uSWT8vI"
        }
    ]
}

여기까지 진행하셨다면 이제 Spring 서버는 Resource 서버로써 Keycloak 에서 전달 된 JWT 토큰을 검증하고 활용할 수 있습니다. 시퀀스 다이어그램은 아래와 같습니다.

출처 : https://ravthiru.medium.com/rbac-using-keycloak-with-spring-boot-3-0-af6bf3edcd38

📰 Spring Security Config

이제 Spring Security 에 대한 셋업을 해 주어야 하는데, 구글에 찾아보면 websecurityconfigureradapter 등에 대한 셋업을 해 주어야 하는데, Spring Boot 3.0.0 버전 이후로는 Spring Security가 상당히 많은 변화를 겪었고, websecurityconfigureradapter 는 삭제되었습니다. Keycloak-adapter 라이브러리를 사용하게 된다면 keycloak 에 맞게 변형 된 KeycloakWebSecurityConfigurerAdapter 어뎁터를 통해 security config 을 작성해야 하지만, 사실 OAuth2 Resource server 셋업을 한 이상 그러한 셋업을 따로 해 줄 필요는 없습니다. 그저 또 다른 OAuth2 제공자로써 Keycloak 이 역할을 할 뿐이기 때문입니다.

@Configuration
@RequiredArgsConstructor
@EnableWebSecurity
@EnableMethodSecurity(jsr250Enabled = true)
public class SecurityConfig {

    private final JwtAuthConverter jwtAuthConverter;
    
	//세션 정책 설정
    @Bean
    protected SessionAuthenticationStrategy sessionAuthenticationStrategy() {
        return new RegisterSessionAuthenticationStrategy(new SessionRegistryImpl());
    }


    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
                .csrf().disable()
                .authorizeHttpRequests()
                .requestMatchers(CorsUtils::isPreFlightRequest).permitAll()
                .requestMatchers("/public/**", "/v3/api-docs/**", "/swagger-ui/**",
                        "/swagger-resources/**").permitAll()
                .requestMatchers("/api/**").authenticated()
                .and()
                .sessionManagement()
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                .oauth2ResourceServer()
                .jwt().jwtAuthenticationConverter(jwtAuthConverter);
        return http.build();
    }
}

상당히 심플한 구성입니다. 사실 기존에 다른 JWT 토큰을 사용하는 셋업과 크게 다르지는 않습니다. 세션 생성 정책은 STATELESS 로 픽스하고 jwtAuthConverter를 통해 jwt 내의 데이터를 파싱해서 사용하겠다는 코드를 작성 해 두었는데, jwtAuthConverter 코드와 jwtAuthConverterProperties 코드를 보겠습니다. 이 코드들은 RBAC를 위해 JWT 토큰 내의 Role 정보를 가져오는 데 필요한 코드입니다.

@Data
@Validated
@Configuration
@ConfigurationProperties(prefix = "jwt.auth.converter")
public class JwtAuthConverterProperties {

    private String resourceId;
    private String principalAttribute;
}
@Component
@Slf4j
public class JwtAuthConverter implements Converter<Jwt, AbstractAuthenticationToken> {
    private final JwtGrantedAuthoritiesConverter jwtGrantedAuthoritiesConverter;
    private final JwtAuthConverterProperties properties;

    public JwtAuthConverter(JwtAuthConverterProperties properties) {
        this.jwtGrantedAuthoritiesConverter = new JwtGrantedAuthoritiesConverter();
        this.properties = properties;
    }


    @Override
    public AbstractAuthenticationToken convert(@NotNull Jwt source) {
        Collection<GrantedAuthority> authorities = Stream.concat(
                jwtGrantedAuthoritiesConverter.convert(source).stream(),
                extractResourceRoles(source).stream()).collect(Collectors.toSet());
        return new JwtAuthenticationToken(source, authorities, getPrincipalClaimName(source));
    }

    private String getPrincipalClaimName(Jwt jwt) {
        String claimName = JwtClaimNames.SUB;
        return jwt.getClaim(claimName);
    }

    private Collection<? extends GrantedAuthority> extractResourceRoles(Jwt jwt) {
        Map<String, Object> resourceAccess = jwt.getClaim("resource_access");
        Map<String, Object> resource;
        Collection<String> resourceRoles;
        if (resourceAccess == null
                || (resource = (Map<String, Object>) resourceAccess.get(properties.getResourceId())) == null
                || (resourceRoles = (Collection<String>) resource.get("roles")) == null) {
            return Set.of();
        }
        return resourceRoles.stream()
                .map(SimpleGrantedAuthority::new)
                .collect(Collectors.toSet());
    }

}

토큰 내의 정보들 중 resource_access 데이터를 통해 유저 role 정보를 가져오는 코드입니다. 해당 로직을 통해 유저의 Role 에 따른 Access Control 즉, RBAC를 구현할 수 있습니다.

@Operation(summary = "유저 추가", description = "새로운 유저 생성 API 입니다. 유저는 회원가입이 따로 없고 해당 API로 어드민이 생성 해 줍니다.")
@RolesAllowed({"SUPER_ADMIN", "ADMIN"})
@PostMapping
public ResponseEntity<BaseRes<CreateUserRes>> addUser(@RequestBody CreateUserReq req) throws NoSuchAlgorithmException {
    CreateUserRes res = userAdminService.createUser(req);
    return ResponseEntity.ok().body(BaseRes.success(res));
}

@Operation(summary = "유저 업데이트", description = "유저 정보 업데이트 API 입니다.")
@RolesAllowed({"SUPER_ADMIN"})
@PutMapping
public ResponseEntity<BaseRes<UUID>> updateUserInfo(@RequestBody UpdateUserReq req) {
    UUID res = userAdminService.updateUserInfo(req);
    return ResponseEntity.ok().body(BaseRes.success(res));
}

예를 들어 Admin 서비스가 있다고 가정 해 봅시다. 유저의 추가는 Admin 권한이 있다면 가능하지만, 유저의 정보 변경은 SUPER_ADMIN 만 가능하도록 하고 싶다면, 위와 같은 @RolesAllowed 어노테이션을 통해 API 접근을 제한할 수 있습니다. 문제는 위와 같은 정보는 JWT 토큰 내에 배치 되어있어야 한다는 것입니다. 또한 해당 정보들을 JWT토큰을 파싱하는 과정에서 전달받을 수 있어야 한다는 것이죠.

앞서 언급했듯이 keycloak 내의 client scope 옵션을 통해 유저의 realm 혹은 client role을 토큰 내에 공개할 수 있고, 공개된 정보를 jwtAuthConverter등의 코드를 통해 Principle 객체를 생성할 때 전달할 수 있습니다. 위의 과정이 제대로 구현 되었다면, Keycloak 에서 설정 해 둔 유저의 Role 에 따라 API 접근을 막을 수 있습니다.

😃 Outro

지금까지 Keycloak 내에서 JWT 토큰을 커스터마이징 하는 법, Spring 내에서 OAuth2 Resource server dependency 를 활용해서 API 접근을 제한하고 인가를 구현하는 법에 대해 알아봤습니다.

다음 포스팅에서는 NextAuth의 Credential Provider 어뎁터와 Keycloak OpenID Connect API 를 연동하는 법에 대해 알아보겠습니다.

🧐 Reference

https://ravthiru.medium.com/rbac-using-keycloak-with-spring-boot-3-0-af6bf3edcd38
https://keycloak.discourse.group/t/custom-login-ans-signup-page-instead-of-custom-theme/1792/2

profile
블로그 이전합니다 : https://swj-techblog.vercel.app/

4개의 댓글

comment-user-thumbnail
2023년 9월 6일

잘읽었습니다!

덕분에 session 옵션에 대해서 잘 이해했습니다.

중간에 access token의 생명주기 시간을 30분으로하는 것을 추천하는게 아니라 SSO session idle 즉 refresh token 만료 시간보다 짧게할 것을 추천하는 것 같아요!

1개의 답글
comment-user-thumbnail
2024년 5월 2일

저도 정확히 똑같은 고민을 하고 있었는데
( spring security + react + nextjs + next-auth )
다음 포스팅이 없으신거같아 아쉽지만 .. 해당 포스팅으로 많은 도움이 될 것 같습니다..

정말 감사합니다..!!

답글 달기
comment-user-thumbnail
2024년 5월 2일

혹시 next-auth 를 사용할 때 google 같은 provider 말고 포스트에 작성하신것처럼
직접 구성한 jwt 인증 서버를 사용 할 때
로그인 이후에 jwt 를 서버에서 body 나 header 에 return 받는게 아니라
next-auth 에 jwt 콜백에서 서버로 요청을 보내서 jwt 를 반환받아 오는건가요??
혹시 가능하시면 next-auth 로 연계되는 시나리오를 알 수 있을까요..?

답글 달기