LabHub
시작하기
배우기 러닝패스 코스

좋은 서비스를 만드는 CS — 교과서 개념을 측정으로 다시 배운다

닉네임 360개로 길이·자르기·같음·날짜를 못박는다

LabHub 에서 이어서 보기

목표

닉네임 목록으로 UTF-8 바이트·코드 포인트·사람이 보는 글자 수를 따로 세고, 바이트 한도와 글자 한도로 깨지지 않게 자르고, 정규화와 대소문자 접기로 사용자명 충돌을 셉니다. 이어서 UTC 이벤트를 사용자 현지 날짜로 묶고, 서머타임을 건너는 "매일 09:00" 예약을 UTC 순간으로 바꾼 뒤, 전부를 가입 심사 함수 하나로 모읍니다.

왜 중요한가

화면·서버·저장소가 서로 다른 길이를 세면 "6글자까지" 라는 규칙이 세 개가 됩니다. 바이트째 자르면 글자가 깨지고, 코드 포인트째 자르면 이모지 가족이 한 사람만 남고 악센트가 떨어집니다. 같은 이름은 정준 동등(NFC)과 호환 동등(NFKC), 대소문자 접기(casefold) 가운데 무엇을 같다고 볼지 정해야 비로소 하나로 셀 수 있습니다. 날짜도 "어디의 오늘인가" 를 정하지 않으면 일별 숫자가 옆 날짜로 샙니다. 그래서 채점기는 여러분이 적은 숫자만 보지 않고, 여러분의 함수를 재료가 아닌 입력에 다시 돌려 기준 구현과 대조합니다.

재료

/opt/fixtures/svccs/text/ 아래에 있습니다. 읽기만 하세요. CSV 는 모두 UTF-8, 첫 줄이 머리글입니다(csv 모듈로 newline="" 을 주고 읽으세요).

names.csv    user_id,nickname      기존 회원 닉네임 360줄(완성형·조합형 한글, 결합 문자, 이모지, 전각, ß …)
signups.csv  row,nickname          새 가입 신청 80줄. 빈 칸도 있다
users.csv    user_id,tz            이벤트를 낸 사용자와 IANA 시간대 이름
events.csv   event_id,user_id,ts_utc   UTC 로 찍힌 이벤트. ts_utc 는 2026-03-07T23:40:00Z 꼴
params.json  byte_limit · max_graphemes · schedule{zones, start, days, local_time}

글자 규칙(이 실습의 단순화 규칙)

문자열을 앞에서부터 코드 포인트 하나씩 본다. 맨 앞 코드 포인트는 새 글자를 시작한다.
그 뒤로는 아래 가운데 하나라도 맞으면 앞 글자에 붙이고, 아니면 새 글자를 시작한다.
 1. 지금 코드 포인트가 결합 표시(unicodedata.category 가 Mn 또는 Me)·변형 선택자
    (U+FE00~U+FE0F)·피부색 수식자(U+1F3FB~U+1F3FF)·ZWJ(U+200D) 가운데 하나
 2. 바로 앞 코드 포인트가 ZWJ
 3. 한글 묶음 — 앞이 L 이고 지금이 L·V·LV·LVT / 앞이 V 나 LV 이고 지금이 V·T /
    앞이 T 나 LVT 이고 지금이 T
    L = U+1100~U+115F, V = U+1160~U+11A7, T = U+11A8~U+11FF,
    완성형 U+AC00~U+D7A3 은 (코드 − 0xAC00) % 28 == 0 이면 LV, 아니면 LVT
국기(지역 표시 문자 쌍)·Prepend·SpacingMark 는 다루지 않는다(UAX #29 전체가 아니다).

단계

  1. /root/svccs/text/lengths.jsonnames.csv 닉네임의 rows(줄 수)·utf8_bytes(UTF-8 바이트 합)·code_points(코드 포인트 합)·rows_multibyte(바이트 수 ≠ 코드 포인트 수인 줄 수)·max_utf8_bytes(한 줄의 최대 바이트)를 적습니다.
  2. /root/svccs/text/textkit.pygraphemes(s) 를 만듭니다 — 위 '글자 규칙' 대로 나눈 문자열 목록(이어 붙이면 s)을 돌려줍니다. 그리고 /root/svccs/text/graphemes.jsongraphemes(글자 수 합)·rows_cp_ne_graphemes(코드 포인트 수 ≠ 글자 수인 줄 수)·longest_cluster_cp(한 글자가 가진 코드 포인트 수의 최댓값)를 적습니다.
  3. 같은 파일에 cut_bytes(s, limit) 를 만듭니다 — 코드 포인트를 쪼개지 않고 UTF-8 로 limit 바이트 이하가 되는 가장 긴 앞부분입니다. params.jsonbyte_limit 을 L 로 /root/svccs/text/cut.jsonbyte_limit·rows_over_limit(바이트가 L 을 넘는 줄 수)·rows_naive_broken(s.encode("utf-8")[:L] 을 엄격 디코딩하면 UnicodeDecodeError 가 나는 줄 수)·utf8_bytes_after_cut(모든 줄을 cut_bytes(s, L) 한 뒤의 바이트 합)을 적습니다.
  4. 같은 파일에 fit(s, max_bytes, max_graphemes) 를 만듭니다 — 글자(2단계 규칙)를 통째로만 담아 글자 수 ≤ max_graphemes, 바이트 ≤ max_bytes 를 함께 지키는 가장 긴 앞부분입니다. B = byte_limit, G = max_graphemes/root/svccs/text/fit.jsonmax_bytes·max_graphemes·rows_changed(fit 결과가 원문과 다른 줄 수)·split_by_cut_bytes(cut_bytes(s, B) 의 끝이 글자 경계가 아닌 줄 수)·split_by_cp_slice(s[:G] 의 끝이 글자 경계가 아닌 줄 수)를 적습니다. 글자 경계 = 글자들을 앞에서부터 이어 붙일 때 생기는 코드 포인트 위치(0 과 len(s) 포함)입니다.
  5. 같은 파일에 username_key(s) = NFKC(casefold(NFKC(s))) 를 만듭니다. /root/svccs/text/unique.json 에 서로 다른 개수 distinct_raw(원문)·distinct_nfc(NFC)·distinct_nfc_lower(NFC 뒤 lower)·distinct_nfc_casefold(NFC 뒤 casefold)·distinct_key(username_key), 그리고 collision_pairs(같은 키를 가진 줄끼리 만들 수 있는 쌍의 수, 키마다 n·(n−1)/2 의 합)·collision_groups(같은 키를 가진 user_id 목록들, 2개 이상인 것만)를 적습니다.
  6. 같은 파일에 local_date(ts_utc, tz) 를 만듭니다 — events.csv 꼴의 UTC 시각을 IANA 시간대 tz 의 현지 날짜 "YYYY-MM-DD" 로 바꿉니다. 각 이벤트를 users.csv 의 그 사용자 시간대로 묶어 /root/svccs/text/daily.jsonevents(줄 수)·by_local_date(현지 날짜 → 이벤트 수)·by_utc_date(ts_utc 의 앞 10글자 → 이벤트 수)·events_on_other_date(현지 날짜 ≠ UTC 날짜인 이벤트 수)를 적습니다.
  7. 같은 파일에 daily_at(tz, start_date, days, hhmm) 를 만듭니다 — start_date("YYYY-MM-DD")부터 days 일 동안 매일 현지 hhmm("09:00" 꼴)의 순간을 "YYYY-MM-DDTHH:MM:SSZ"(UTC) 목록으로 돌려줍니다. params.jsonschedule/root/svccs/text/schedule.json 에 시간대마다 {"utc": 목록, "gaps_hours": 이웃한 두 순간의 실제 간격(시간, 넷째 자리) 목록, "short_days": 24 미만인 간격 수, "long_days": 24 초과인 간격 수} 를 적습니다.
  8. 같은 파일에 check_signup(nickname, taken, max_bytes, max_graphemes) 를 만들고 아래 '가입 규칙' 대로 signups.csv 를 순서대로 심사해 /root/svccs/text/signups.json 에 상태별 개수 ok·taken·too_many_chars·too_many_bytes·empty 와 줄 순서대로의 verdicts 목록을 적습니다(한도는 byte_limit·max_graphemes).

가입 규칙

shown = NFC(nickname)                        저장·표시는 NFC 로 한다
1. shown 이 공백(str.isspace)·ZWJ·규칙 1의 결합류로만 되어 있거나 비었으면  "empty"
2. graphemes(shown) 의 개수가 max_graphemes 를 넘으면                    "too_many_chars"
3. shown 의 UTF-8 바이트가 max_bytes 를 넘으면                           "too_many_bytes"
4. username_key(nickname) 이 taken 안에 있으면                          "taken"
5. 아니면                                                              "ok"
taken 은 처음에 names.csv 모든 닉네임의 키이고, "ok" 를 받은 신청의 키가 차례로 더해진다.
채점기는 taken 을 목록(list)으로 넘길 수도 있다 — in 으로만 쓰세요.

참고

바이트와 코드 포인트는 다르다

names.csv 닉네임의 줄 수·UTF-8 바이트 합·코드 포인트 합·둘이 다른 줄 수·최대 바이트를 /root/svccs/text/lengths.json 에 rows·utf8_bytes·code_points·rows_multibyte·max_utf8_bytes 로 적는다.

파이썬 str 은 코드 포인트의 시퀀스라 len() 은 코드 포인트 수입니다. 바이트는 s.encode("utf-8") 의 길이입니다. 한글 완성형 한 음절은 3바이트, ASCII 는 1바이트입니다.

사람이 보는 글자를 센다

/root/svccs/text/textkit.py 에 '글자 규칙' 대로 graphemes(s) 를 만들고, /root/svccs/text/graphemes.json 에 graphemes·rows_cp_ne_graphemes·longest_cluster_cp 를 적는다. 채점기가 함수를 직접 불러 결합 문자·ZWJ·자모가 섞인 변형 문자열로 대조한다.

지금 코드 포인트를 앞 글자에 붙일지 말지만 정하면 됩니다. unicodedata.combining() 은 변형 선택자(U+FE0F)와 ZWJ 에 0 을 돌려주므로 그것만으로는 모자랍니다 — category 와 범위를 함께 보세요. 자모 ㄱ(L) 뒤의 ㅏ(V) 는 붙고, 받침(T) 뒤의 ㄱ(L) 은 새 글자입니다.

바이트 한도로 자르되 글자를 깨지 않는다

textkit.py 에 cut_bytes(s, limit) 를 만들고, byte_limit 로 /root/svccs/text/cut.json 에 byte_limit·rows_over_limit·rows_naive_broken·utf8_bytes_after_cut 을 적는다. 채점기는 변형 문자열과 여러 한도로 cut_bytes 를 부른다.

UTF-8 은 한 코드 포인트가 1~4바이트이고, 이어지는 바이트는 모두 10xxxxxx 꼴입니다. 코드 포인트를 하나씩 담으며 바이트를 더하다가 넘기 직전에 멈추면 됩니다. 바이트째 자른 뒤 errors="replace" 로 읽으면 끝에 U+FFFD 가 남습니다.

글자 단위로 자른다

textkit.py 에 fit(s, max_bytes, max_graphemes) 를 만들고, /root/svccs/text/fit.json 에 max_bytes·max_graphemes·rows_changed·split_by_cut_bytes·split_by_cp_slice 를 적는다. 채점기는 변형 문자열과 여러 한도로 fit 을 부른다.

graphemes(s) 로 나눈 글자를 하나씩 담으며, 글자 수와 바이트 두 한도 가운데 하나라도 넘기 직전에 멈춥니다. 코드 포인트 단위로 안전하게 자른 결과도 가족 이모지를 한 사람만 남기거나 악센트를 떼어 낼 수 있습니다 — 그 수가 split_by_cut_bytes 입니다.

같은 이름을 정한다

textkit.py 에 username_key(s) = NFKC(casefold(NFKC(s))) 를 만들고, /root/svccs/text/unique.json 에 distinct_raw·distinct_nfc·distinct_nfc_lower·distinct_nfc_casefold·distinct_key·collision_pairs·collision_groups 를 적는다. 채점기는 username_key 를 변형 문자열로도 부른다.

NFC 는 완성형과 자모 분해형을 맞추고, casefold 는 ß 를 ss 로 접으며, NFKC 는 전각 문자·합자·켈빈 기호 같은 호환 문자를 맞춥니다. 규칙을 하나 얹을 때마다 서로 다른 개수가 줄어야 합니다. 쌍의 수는 같은 키 n개마다 n·(n−1)/2 입니다.

UTC 이벤트를 현지 날짜로 묶는다

textkit.py 에 local_date(ts_utc, tz) 를 만들고, events.csv 를 사용자 시간대의 날짜로 묶어 /root/svccs/text/daily.json 에 events·by_local_date·by_utc_date·events_on_other_date 를 적는다. 채점기는 다른 시간대·날짜의 변형 이벤트로 local_date 를 부른다.

ts_utc 를 UTC 의 aware datetime 으로 읽고(끝의 Z 가 UTC 입니다) astimezone(ZoneInfo(tz)) 로 옮긴 뒤 .date() 를 봅니다. 서울은 UTC 보다 앞서 있어서 UTC 날짜로 묶으면 서울의 이른 아침이 전날로 넘어갑니다.

매일 09:00 현지 예약을 UTC 로

textkit.py 에 daily_at(tz, start_date, days, hhmm) 를 만들고, params.json 의 schedule 로 /root/svccs/text/schedule.json 에 시간대마다 utc·gaps_hours·short_days·long_days 를 적는다. 채점기는 다른 시간대·기간으로 daily_at 을 부른다.

첫 순간을 UTC 로 바꾼 뒤 24시간씩 더하면 서머타임 전환 뒤로 현지 시각이 한 시간 밀립니다. 현지 날짜마다 hhmm 을 새로 만들어 UTC 로 바꾸세요. 간격은 UTC 순간끼리 뺍니다 — 같은 ZoneInfo 를 가진 aware datetime 끼리 빼면 tzinfo 가 무시되어 늘 24시간이 나옵니다.

가입 심사 한 함수로 모은다

textkit.py 에 '가입 규칙' 대로 check_signup(nickname, taken, max_bytes, max_graphemes) 를 만들고, signups.csv 를 순서대로 심사해 /root/svccs/text/signups.json 에 ok·taken·too_many_chars·too_many_bytes·empty·verdicts 를 적는다. 채점기는 변형 신청과 taken 목록으로도 check_signup 을 부른다.

판정 순서가 규칙의 일부입니다. 바이트 한도는 NFC 로 바꾼 뒤에 잽니다 — 조합형으로 온 한글은 원문 그대로 재면 세 배가 됩니다. 받아 준 신청의 키를 taken 에 더하지 않으면 같은 날 들어온 두 신청이 둘 다 통과합니다.