그순간

천진난만 Go 성적계산기 도입 이야기

김태은·2026년 7월 30일·35 조회
천진난만 Go 성적계산기 도입 이야기

성적 계산식이란

Hello, GSM에는 성적 계산 로직이 있습니다. 고교 입학지원시스템이므로 당연합니다. 중학교 생활기록부에 기재된 정보로 성적을 계산하고, 본교 입학 지원에 사용하는 환산점으로 바꿔야 하기 때문입니다. 매년 새 입학 요강이 나올 때마다 이 로직을 수정해 왔습니다.

2025년 성적 산출식 변경 PR 스크린샷

계산식의 사용처

Hello, GSM의 핵심 도메인인 만큼 여러 기능에서 사용합니다. 입학 원서를 작성할 때 점수를 환산하는 용도가 첫 번째입니다. 두 번째는 모의 성적 계산 기능입니다. 사용자가 자신의 성적을 직접 입력해보고 환산점을 미리 확인하는 기능입니다.

모의 성적 계산 결과 모달 스크린샷

그런데 여기서 문제가 생깁니다.

계산식이 서버에 있어서 생긴 문제

성적 계산식은 서버 애플리케이션에 있었습니다. 과거에는 클라이언트에 있었지만, 웹 클라이언트로 동작하는 Hello, GSM에서 비대한 계산 로직이 프론트엔드에 있는 구조는 사용자 경험과 개발자 경험 모두에 좋지 않았습니다. 지금 기준으로는 에이전트 경험(Agent Experience, AX)까지 고려할 문제입니다. 당시에는 에이전트 기술이 초창기였지만요. 이런 이유로 2024년에 계산식 구현을 서버 애플리케이션으로 옮겼고, 기능 자체는 정상으로 동작했습니다.

문제는 비용이었습니다. 서버 애플리케이션은 구동만으로 인프라 비용이 발생합니다. 애플리케이션을 띄우려면 데이터베이스와 모니터링 시스템이 함께 필요하고, 이를 모두 합하면 한 달에 40만 원 이상이 나갑니다. 사용자가 한 명도 없어도 마찬가지입니다.

학교는 인프라 비용 절감을 꾸준히 요구했고, 팀은 입학지원 기간이 아닐 때 관련 인프라를 정리하는 방식으로 대응했습니다. 그러자 모의 성적 계산 기능을 입학지원 기간에만 쓸 수 있게 됐습니다.

입학 희망자가 자기 위치를 가늠하려면 입학 요강 서류를 찾아 직접 계산하거나, 모의 성적 계산을 써야 합니다. 그런데 그 기능을 원서 접수가 임박해서야 쓸 수 있었습니다. 사용자의 기대와 서비스가 제공하는 것이 어긋난 상황이었습니다.

서버 인프라를 입학지원 기간보다 1~2개월 앞서 구동하고 있어 완화 효과는 어느 정도 있었습니다. 그러나 핵심 도메인도 아닌 기능 하나 때문에 인프라 비용을 계속 지불하는 것은 수지가 맞지 않는다고 판단했습니다. 그렇게 팀 내부 논의가 시작됐습니다.

인프라 비용을 줄이기 위한 대안 검토

Hello, GSM은 클라우드 제공자로 AWS를 사용합니다. 분산 환경이 아니므로 EC2에 서버 애플리케이션을 배포하고 있고, 인스턴스 타입은 t4g.small이었습니다. 아주 비싼 패밀리는 아니지만, 기능 하나를 위해 계속 켜두기에는 낭비였습니다. 세 가지 대안을 검토했습니다.

대안 1. 계산식 전용 독립 애플리케이션

Fastify나 Ktor 같은 경량 서버 프레임워크로 작은 인스턴스를 따로 띄우는 방안입니다. t4g.micro 같은 경량 인스턴스에 성적 계산식만 담은 독립 서버를 배치하는 방식이었습니다.

t4g.micro와 t4g.small 성능 비교표

기존 t4g.small보다 청구액이 적고 CPU 크레딧에도 차이가 있어 절감 폭이 예상보다 클 수 있다는 가정이었습니다.

타당한 의견이었지만 보류했습니다. 미운영 기간에도 이 서버를 위한 모니터링 인프라가 필요하기 때문입니다. 배보다 배꼽이 커질 수 있다는 우려가 컸습니다.

대안 2. 클라이언트로 회귀

TypeScript를 쓰는 지금의 프론트엔드라면 성적 계산식을 충분히 감당할 수 있지 않겠냐는 의견이었습니다. 이전 구조의 문제를 그대로 되가져오는 방안이라 곧바로 기각했습니다.

당시 프론트엔드에 있던 계산 코드 모음

2024년 이전 클라이언트 저장소에는 Utils/Calculate 아래에 계산 유틸리티가 12개 파일로 나뉘어 있었습니다. 학기별 배점, 자유학기제와 자유학년제 분기, 예체능, 출결, 봉사, 검정고시가 각각 별도 파일이었습니다. 예를 들어 자유학기제 계산은 다음과 같습니다.

// packages/hello-gsm-admin/src/Utils/Calculate/FreeSemesterCalculate.ts
const FreeSemesterCalculate = (
  array: Array<number>,
  semester: string,
  freeSemester: string | null,
) => {
  array = array.filter(arr => arr !== 0); // 값이 0('없음')이면 배열에서 제거
  const arraySum = Sum(array);

  let result = 0;
  if (freeSemester === semester) return 0; // 자유학기제를 시행한 학기는 0점 처리

  switch (semester) {
    case '1-1':
      result = Rounds(18 * Rounds(arraySum / (array.length * 5), 5), 3);
      break;
    case '1-2':
      switch (freeSemester) {
        // 1학년 2학기 점수는 자유학기제를 시행한 학기에 따라 다르게 처리함
        case '1-1':
          result = Rounds(18 * Rounds(arraySum / (array.length * 5), 5), 3);
          break;
        case '2-1':
          result = Rounds(36 * Rounds(arraySum / (array.length * 5), 5), 3);
          break;
      }
      break;
    // 2-1, 2-2, 3-1 생략
  }
  return result;
};

배점 상수가 코드 곳곳에 흩어져 있고, 반올림 함수 Rounds를 중첩해서 부동소수점 오차를 눌러가며 계산합니다. 입학 요강이 바뀌면 이 파일들을 모두 열어봐야 합니다. 매년 요강이 바뀌는 도메인에서 감당하기 어려운 구조였습니다. 투자는 많이 필요한데 DX는 오히려 후퇴하는 선택이었습니다.

대안 3. AWS Lambda

여기서 뾰족한 대안이 나왔습니다. AWS Lambda를 쓰는 것입니다. 당시에는 프리티어 계정이 아니어도 무료 요청 구간이 있었고, 팀에는 이미 모니터링 시스템을 구축하며 알림 발행에 Lambda를 사용한 선례가 있었습니다.

AWS Lambda 요금표

한 달 60억 GB-초 구간까지 GB-초당 0.0000166667달러, 요청은 100만 건당 0.20달러입니다. 실제 서비스 규모로 환산하면 99% 이상을 절감할 수 있는 조건이었습니다.

Lambda를 간단히 설명하면 이렇습니다. EC2 같은 완전한 컴퓨팅 자원은 컴퓨터로 할 수 있는 일이라면 무엇이든 할 수 있다는 장점이 있지만, 컴퓨터 한 대를 통째로 빌리는 만큼 비용이 따라옵니다. 반면 Lambda는 코드 조각을 올려두면 그 코드만 실행해주는 서버리스 컴퓨팅 서비스입니다. 이름 그대로 함수 단위로 실행할 런타임만 제공받는 구조입니다.

콜드 스타트 문제와 Go 선택

Lambda는 원하던 조건에 맞았지만 한 가지 문제가 있었습니다. 운영체제와 터미널이 아니라 런타임을 제공받는 구조이므로, 요청이 꾸준히 들어오지 않으면 콜드 스타트가 발생합니다.

Spring Boot 애플리케이션만 놓고 봐도 차이가 큽니다. 웜 스타트라면 4초 이내에 뜨고, 데몬으로 상주한다면 즉시 응답합니다. 그러나 콜드 스타트에서는 애플리케이션 규모에 따라 분 단위가 걸립니다.

이건 치명적이었습니다. 성적 계산식은 가장 많이 쓰이는 시간대, 즉 원서 접수 기간에도 콜드 스타트를 막을 만큼 요청이 쏟아지지 않습니다. 요청 사이 간격이 평균 10분 이상입니다. 그래서 두 가지 해결책을 검토했습니다.

검토 1. 하트비트 프록시 서버

Lambda 함수 앞에 게이트웨이 역할을 하는 프록시 서버를 두고, 여기에 하트비트 기능을 넣어 함수를 계속 대기 상태로 유지하는 방안입니다.

하트비트 프록시 구조 다이어그램

두 가지 문제가 있었습니다.

첫째, 발생하지 않았을 비용을 새로 만듭니다. 당시 Lambda에 무료 사용량 구간이 있었지만 과금이 가능한 구조였습니다. 적은 돈이라도 아껴야 하는 상황에서 무료 사용량을 하트비트로 소진하며 과금 위험을 떠안을 수는 없었습니다.

둘째, 자기모순입니다. 상시 구동되는 인스턴스를 없애려고 도입하는 방안인데, 그 방안을 보완하는 수단이 다시 상시 구동되는 인스턴스였습니다. 기능이 단순하니 t4g.nano 같은 초경량 인스턴스를 쓰거나 API Gateway를 조정하는 방법도 있었지만, 어느 쪽이든 과금은 발생합니다.

검토 2. Go 바이너리

팀에는 이미 Go를 쓴 선례가 있었습니다. 개발용 더미 SQL 생성기, Discord 알림 임베드 전송 애플리케이션이 Go로 구현되어 있었고, 핵심 도메인 중 하나인 환산점 기반 합격·불합격 분류기도 Go였습니다.

Go 빌드 산출물을 Lambda에 올려 실행하면 초기화 구간을 크게 줄일 수 있다는 점을 확인하고 이 방안을 채택했습니다.

Lambda 메모리를 512MB로 설정했을 때 초기화 구간(Init Duration)은 0.7ms 이하였습니다. 콜드 스타트를 걱정할 필요가 없는 수치입니다. 관측된 최악값은 3.1초였지만, 이는 네트워크 지연과 Lambda 설정 변경 시점이 함수 기동 시점과 겹치면서 나온 예외적인 값입니다.

Go가 빠른 이유

  • 네이티브 기계어 변환: Go는 자바의 가상 머신(JVM)이나 파이썬의 인터프리터를 거치지 않습니다. 빌드 시점에 운영체제가 바로 이해하는 기계어로 변환됩니다.
  • 가벼운 런타임: 가비지 컬렉션(GC)과 고루틴(Goroutine)을 관리하는 최소한의 런타임만 바이너리에 포함되어 오버헤드가 적습니다.
  • 효율적인 메모리 구조: 데이터 구조가 메모리에 연속으로 배치되도록 유도해 CPU 캐시 효율이 높습니다.

이런 구조 덕분에 Go는 빠릅니다. 별다른 우회 로직 없이 Go 바이너리를 Lambda에 올리는 방향으로 결정했습니다.

구현

Lambda 핸들러

API Gateway 요청을 받아 검증하고 계산 결과를 돌려주는 단일 핸들러입니다.

// main.go
func handler(ctx context.Context, request events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) {
	// API KEY 인증 확인
	if err := authorizeCheckForPrivateAPI(request.Headers); err != nil {
		log.Printf("Authorization failed: %v", err)
		return createErrorResponse(401, "UNAUTHORIZED", err.Error()), nil
	}

	if request.Body == "" {
		return createErrorResponse(400, "EMPTY_BODY", "Request body is empty"), nil
	}

	var reqDto types.MiddleSchoolAchievementReqDto
	if err := json.Unmarshal([]byte(request.Body), &reqDto); err != nil {
		return createErrorResponse(400, "INVALID_JSON", fmt.Sprintf("Invalid JSON format: %s", err.Error())), nil
	}

	if err := validator.ValidateRequest(reqDto); err != nil {
		return createErrorResponse(400, "VALIDATION_ERROR", err.Error()), nil
	}

	graduationType := types.GraduationType(reqDto.GraduationType)
	calcDto := calculator.BuildCalcDtoWithFillEmpty(reqDto, graduationType)
	result := calculator.Execute(calcDto, graduationType)

	responseBody, err := json.Marshal(result)
	if err != nil {
		return createErrorResponse(500, "MARSHAL_ERROR", "Failed to create response"), nil
	}

	return events.APIGatewayProxyResponse{
		StatusCode: 200,
		Headers:    map[string]string{"Content-Type": "application/json"},
		Body:       string(responseBody),
	}, nil
}

func main() {
	lambda.Start(handler)
}

호출은 내부 API 키를 헤더로 검증합니다. 키가 없거나 일치하지 않으면 계산에 들어가기 전에 401로 끊습니다.

// main.go
const xHGAPIKeyHeader = "x-hg-api-key"

func init() {
	xHellogsmInternalAPIKey = os.Getenv("X_HG_INTERNAL_API_KEY")
	if xHellogsmInternalAPIKey == "" {
		log.Fatal("X_HG_INTERNAL_API_KEY 환경변수가 설정되지 않았습니다")
	}
}

점수 계산에는 float64 대신 math/big의 big.Rat을 사용했습니다. 성적 환산은 나눗셈이 반복되므로 부동소수점 오차가 누적되면 합격선 근처에서 결과가 흔들릴 수 있기 때문입니다. 앞서 본 프론트엔드 구현이 Rounds를 중첩해 오차를 눌렀던 것과 대비되는 지점입니다.

// internal/calculator/ged.go
func CalcGedTotalSubjectsScore(averageScore *big.Rat) *big.Rat {
	// (평균점수 - 50) / 50 * 240
	temp := new(big.Rat).Sub(averageScore, big.NewRat(50, 1))
	temp.Quo(temp, big.NewRat(50, 1))
	result := new(big.Rat).Mul(temp, big.NewRat(240, 1))
	result = RoundToThreeDecimals(result)

	// 0보다 작으면 0을 반환
	if result.Cmp(big.NewRat(0, 1)) < 0 {
		return big.NewRat(0, 1)
	}

	return result
}

빌드는 CGO를 끄고 Linux 대상 정적 바이너리를 만든 뒤 ZIP으로 묶습니다. 산출물 이름을 bootstrap으로 두는 것이 Lambda 커스텀 런타임의 규약입니다.

# build.sh
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o bootstrap main.go
zip -r function.zip bootstrap

원서 작성: 서버가 호출하기

원서 작성 시 점수 환산은 서버 애플리케이션이 담당합니다. Feign 클라이언트 인터페이스 하나로 정리됩니다.

// global/thirdParty/feign/client/lambda/LambdaScoreCalculatorClient.java
@FeignClient(name = "lambda-score-calculator-client", url = "${lambda-score-calculator.url}", configuration = LambdaScoreCalculatorFeignConfig.class)
public interface LambdaScoreCalculatorClient {
    @PostMapping
    CalculatedScoreResDto calculateScore(@RequestBody LambdaScoreCalculatorReqDto reqDto);
}

API 키는 RequestInterceptor가 모든 요청 헤더에 붙여줍니다. 호출부는 키의 존재를 알 필요가 없습니다.

// global/thirdParty/feign/config/LambdaScoreCalculatorFeignConfig.java
@Bean
public RequestInterceptor lambdaApiKeyInterceptor() {
    return template -> template.header(X_HG_API_KEY, lambdaApiKey);
}

원서를 생성하는 서비스에서는 다음과 같이 호출합니다. 계산 자체는 외부로 나가고, 서버는 결과를 받아 저장하는 역할만 맡습니다.

// domain/oneseo/service/CreateOneseoService.java
private CalculatedScoreResDto calculateMiddleSchoolAchievement(GraduationType graduationType,
        MiddleSchoolAchievementReqDto middleSchoolAchievement,
        Oneseo oneseo) {
    LambdaScoreCalculatorReqDto lambdaRequest = LambdaScoreCalculatorReqDto.from(middleSchoolAchievement,
            graduationType);
    CalculatedScoreResDto calculatedScore = lambdaScoreCalculatorClient.calculateScore(lambdaRequest);

    saveCalculatedScoreToDb(calculatedScore, oneseo);

    return calculatedScore;
}

모의 성적 계산: 클라이언트가 직접 호출하기

모의 성적 계산은 다릅니다. 결과를 저장할 필요가 없고 인증도 필요 없으므로, 클라이언트가 서버 애플리케이션을 거치지 않고 Lambda를 직접 호출합니다.

// packages/api/src/hooks/oneseo/usePostMockScore.ts
export const usePostMockScore = (type: GraduationType, options?: /* 생략 */) =>
  useMutation({
    mutationKey: oneseoQueryKeys.postMockScore(type),
    mutationFn: async (data: MiddleSchoolAchievementType | GEDAchievementType) => {
      const response = await axios.post<MockScoreType>(
        process.env.NEXT_PUBLIC_MOCK_SCORE_URL!,
        { ...data, graduationType: type },
        { headers: { 'X-HG-API-KEY': process.env.NEXT_PUBLIC_MOCK_SCORE_API_KEY! } },
      );
      return response.data;
    },
    ...options,
  });

이 한 가지가 이번 작업의 핵심입니다. 모의 성적 계산 경로에서 서버 애플리케이션과 데이터베이스가 완전히 빠졌습니다. 그래서 입학지원 기간이 아니어서 서버 인프라를 내려둔 상태에서도 사용자가 환산점을 확인할 수 있습니다.

클라이언트는 계산식을 갖지 않고 API만 호출하므로, 2024년 이전 구조의 문제도 되돌아오지 않습니다. 입학 요강이 바뀌면 Go 저장소 한 곳만 고치면 됩니다.

결과

회의를 마친 직후 저장소를 만들고 개발에 들어갔고, 이 글을 쓰는 2026년까지 문제없이 사용하고 있습니다.

상용 환경 배포 시점을 한 달 반 늦출 수 있게 되면서, 월 40만 원대의 인프라 비용을 기준으로 연 40~60만 원을 절감했습니다. 모의 성적 계산 기능은 이제 서버 애플리케이션의 구동 여부와 무관하게 항상 사용할 수 있습니다. 입학 희망자가 원서 접수 기간을 기다리지 않고 자기 위치를 가늠할 수 있게 됐다는 점이 이번 작업에서 가장 크게 얻은 부분입니다.

다만 2026년 현재는 AWS Lambda의 무료 사용량 구간이 제거되면서 과금이 필연적으로 발생하는 구조입니다. 하지만, 그 과금 정도가 EC2에 비교하면 여전히 99% 이상의 비용절감 효과를 가져갈 수 있을 정도로 차이가 심하기 때문에 팀에서는 만족하며 사용 중에 있습니다.

무엇을 내주었는가

계산이 외부 HTTP 호출로 바뀌었으니 외부 I/O가 하나 늘었습니다. 다만 체감할 만한 지연은 없었습니다. 운영하면서 이 부분이 문제로 느껴진 적은 없습니다.

단일 장애 지점이 생긴 것은 사실입니다. Lambda가 죽으면 원서 작성과 모의 성적 계산이 모두 멈춥니다. 지금까지 장애가 난 적은 없고, AWS Lambda가 그 정도로 무너지는 상황이라면 서비스 전체가 이미 정상일 수 없다는 점에서 감수할 만한 위험이라고 판단했습니다.

유지보수 부담은 오히려 줄었습니다. 저장소가 분리되면서 계산 로직이 한곳에 모였고, 서버 애플리케이션과의 결합이 사실상 사라졌습니다. 입학 요강이 바뀌어도 고칠 곳이 한 군데입니다.

목차