[Go] encoding/csv 패키지를 이용해 csv 다운로드 API 구현하기

2025. 6. 12. 21:42· Project
목차
  1. JSON Response vs CSV Response
  2. Go의 encoding/csv 패키지 간단 분석
  3. Repository Layer: 데이터를 CSV 형식으로 변환
  4. Service & Network Layer: 메모리 버퍼 방식 사용하여 HTTP 응답으로 CSV 제공
  5. 성능 최적화: 스트리밍 방식
  6. 메모리 버퍼 방식 VS 스트리밍 방식

서버를 개발하다 보면 데이터를 csv 형태로 내보내는 기능을 구현해야할 때가 있다.

특히 통계 데이터나 리포트를 제공하는 서비스에서는 필수적인 기능이다.

 

이번 포스트에서는 실제 프로덕션 코드를 기반으로 golang에서 csv 다운로드 API를 구현하는 방법을 자세히 살펴보자.

CSV 파일이란?
CSV(Comma Separated Values) 파일은 각각의 데이터 값을 콤마(,)로 구분하는 파일의 형식이다.
이 파일은 데이터 레코드로 구성되며, 각 레코드가 테이블의 단일 행에 해당하고, 각 필드는 쉼표로 구분한다.
각 열에 어떤 정보가 저장되어 있는지 설명하는 열 머리글은 파일의 첫 줄에 자주 표시한다.

 


JSON Response vs CSV Response

일반적으로 API를 개발할 때 우리는 JSON 형태의 응답에 익숙하다.

 

JSON response API는 클라이언트 앱이 파싱하여 화면에 렌더링 하는것이 목적이기 때문에,

클라이언트가 요청을 보내면 서버는 Content-Type: application/json 헤더와 함께 상태코드(Status Code) 그리고 구조화된 JSON 데이터를 Response Body에 담아 반환한다.

 

하지만 CSV response API는 파일 자체를 사용자가 다운로드하여 활용할 수 있도록 하는 것이 목적이다.

따라서 서버는 브라우저가 파일 다운로드를 시작하도록 Content-Dispotition: attachment 헤더를 설정해줘야 하고, 스프레드 시트 어플리케이션에서 바로 열 수 있는 표 형태의 데이터를 제공해야 한다.

 

Golang에서 표준 라이브러리로 encoding/csv 패키지를 제공하고 있다.

CSV 데이터 인코딩 및 디코딩 기능을 제공하는데 이 글에서도 encoding/csv 패키지를 이용하여 API를 구현할 생각이다.

go의 encoding/csv 패키지가 [][]string 타입만을 다루기 때문에, CSV response API 에서는 모든 데이터를 문자열 배열로 변환하고 순서를 보장해야하도록 주의해야한다.

 

Go의 encoding/csv 패키지 간단 분석

Go의 표준 라이브러리인 encoding/csv 패키지는 RFC 4180 명세를 따르는 CSV 파일을 처리하기 위해 설계 되었다.

이 패키지의 핵심은 Writer와 Reader 타입인데, 특히 CSV 파일 생성에 사용되는 Writer는 io.Writer 인터페이스를 구현하는 모든 구현체에 csv 데이터를 쓸 수 있다. 이는 파일, 네트워크 연결, 메모리 버퍼 등 다양한 출력 대상을 지원하기 위함이다.

또한, 패키지에서 필드 구분자(Default: 쉼표(,)), 인용 문자(Defuat: 큰 따옴표(")), 줄바꿈 문자 등을 커스터마이징 할 수 있어 다양한 csv 형식을 지원한다.

[사소한 참고 사항]
CSV 구분자 선택은 지역별 관습과 데이터 특성을 고려해야하는 중요한 요소다.
미국과 영국 등 영어권 국가에서는 쉼표(,)를 사용하지만,
독일, 프랑스 등 유럽의 많은 국가에서는 소수점 구분자로 쉼표를 사용하므로 CSV 필드 구분자로 세미콜론(;)을 사용한다.
탭 문자(\t)를 구분자로 사용하는 형식도있으며, 이는 데이터에 쉼표가 많이 포함된 경우에 유용하다.

 

encoding/csv 패키지가 다른 포맷 패키지(encoding/json, encoding/xml 등)와 다른 점은 스키마가 없다는 점이다.

JSON이나 XML은 구조체 태그를 통해 필드 매핑을 정의할 수 있지만, CSV는 순수하게 2차원 배열([][]string) 만을 다룬다.

이는 CSV의 단순성을 반영하지만, 동시에 개발자가 데이터 구조와 순서를 직접 관리해야한다는 부담도 있다.

특히, Go는 강타입 언어이기 때문에 모든 숫자, 날짜, boolean 값을 문자열로 변환해야 하므로 타입 안정성을 보장하기 위한 추가적인 검증 로직이 필요하다.

 

Repository Layer: 데이터를 CSV 형식으로 변환

func (r *Repository) CreateCsvData() ([][]string, error) {
    // 데이터베이스에서 통계 데이터 조회
    statisticsData, err := r.findRangeStatistics()
    if err != nil {
        return nil, err
    }

	// csv 파일 생성
    var csvData [][]string
    headers := []string{"Date"} // 기본 헤더
    isFirstRow := true
    
    for _, record := range statisticsData {
        var values []string
        values = append(values, strconv.Itoa(int(record.Timestamp)))
        
        // 동적으로 컬럼 헤더와 값 생성
        for key, value := range record.Metrics {
            if isFirstRow {
                headers = append(headers, key)
            }
            values = append(values, strconv.Itoa(int(value)))
        }

        // 첫 번째 반복에서만 헤더 추가
        if isFirstRow {
            csvData = append(csvData, headers)
        }
        csvData = append(csvData, values)
        isFirstRow = false
    }

    return csvData, nil
}

 

1) 2차원 배열 구조 선택

var csvData [][]string

csv 데이터를 [][]string 형태로 구성하는 것은 앞서 말했듯, encoding/csv 패키지가 요구하는 필수 조건이다.

이 구조는 CSV의 행(row)과 열(column)의 개념을 직관적으로 반영한다.

또한, Go의 슬라이스는 동적으로 크기가 조정되므로 데이터 크기를 미리 알 필요가 없다는 장점이 있다. 메모리 효율성면에서도 연속된 메모리 공간에 데이터를 저장하여 캐시 친화적인 접근이 가능하다.

하지만 앞서 말했듯, 타입 안정성을 포기해야한다는 단점이 있다는걸 명심하자!

 

2) 동적 헤더 생성

    headers := []string{"Date"} // 기본 헤더
    isFirstRow := true
    
    for _, record := range statisticsData {
        for key, value := range record.Metrics {
            if isFirstRow {
                headers = append(headers, key)
            }
        }

        // 첫 번째 반복에서만 헤더 추가
        if isFirstRow {
            csvData = append(csvData, headers)
        }
        isFirstRow = false
    }

 

CSV 파일의 헤더를 동적으로 생성하고 있는데 첫번째 레코드에서만 헤더를 구성하게 했다.

이는 만약 데이터 스키마가 변경되어도 유연하게 대응할 수 있는 장점이 있다. 하지만 첫번째 레코드에 모든 가능한 필드가 포함되지 않으면 일부 컬럼이 누락될 수 있으니, 더 안전하게 구현하고 싶다면 별도의 스키마 정의를 통해 헤더를 미리 정의하는걸 추천한다.

 

코드를 보면 DB에서 데이터를 map 형식으로 가져왔는데(statisticsData), findRangeStatistics() 메소드 내부에서 sort 조건으로 순서를 보장해주고 있다.

헤더 순서를 일관하게 유지하기 위해서는 정렬을 미리 해줘야하는 걸 잊지 말자.

 

3) 타입 변환 처리

values = append(values, strconv.Itoa(int(record.Timestamp)))
values = append(values, strconv.Itoa(int(value)))

CSV는 기본적으로 텍스트 기반 포맷이므로, 모든 데이터를 문자열로 변환하는 과정은 필수적이다.

strconv 패키지는 Go의 표준 라이브러리 중 하나로, 다양한 숫자 타입을 문자열로 변환할 수 있도록 해준다.

내가 진행한 프로젝트의 경우에는 통계값이 모두 정수였기 때문에 strconv.Itoa를 통해서 쉽게 변환이 가능했다. 다른 타입의 데이터인 경우에는 패키지 내의 다른 메소드들을 사용해주면 된다.

 

참고로, 문자열에 쉼표나 따옴표가 포함된 경우, encoding/csv 패키지가 자동으로 이스케이프 처리를 해주기 때문에 개발자가 별도로 처리할 필요는 없다.

 

Service & Network Layer: 메모리 버퍼 방식 사용하여 HTTP 응답으로 CSV 제공

import (
    "bytes"
    "encoding/csv"
    "net/http"
    "github.com/gin-gonic/gin"
)

type DataRequest struct {
    StartDate  int64  `form:"start_date" binding:"required"`
    EndDate    int64  `form:"end_date" binding:"required"`
}

func ExportCSV(c *gin.Context) error {
    // CSV 데이터 생성
    startDate := h.normalizeDate(req.StartDate)
    endDate := h.normalizeDate(req.EndDate)

    csvData, err := h.repository.CreateCsvData(startDate, endDate)
    if err != nil {
        return err
    }

    // CSV 파일 생성 및 응답
    var buffer bytes.Buffer
    writer := csv.NewWriter(&buffer)

    // 모든 데이터를 한번에 작성
    if err := writer.WriteAll(csvData); err != nil {
        return err
    }

    // 에러 확인
    if err := writer.Error(); err != nil {
        return err
    }

    // HTTP 헤더 설정
    c.Writer.Header("Content-Type", "text/csv")
    c.Writer.Header("Content-Disposition", "attachment;filename=data_export.csv")

    // CSV 데이터 응답
    c.Writer.Write(buf.Bytes())
    c.Data(http.StatusOK, "text/csv", buffer.Bytes())
}

 

1) bytes.Buffer 활용

var buffer bytes.Buffer
writer := csv.NewWriter(&buffer)

bytes.Buffer 역시 Go 표준 라이브러리에서 제공하는  메모리 기반 I/O를 위한 핵심 타입으로 io.Writer 인터페이스를 구현한다. 따라서 csv.Writer(&buf)와 같이 사용이 가능하다.

더보기

[bytes.Buffer의 io.Writer 구현]

아래 Write 메서드가 io.Writer 인터페이스의 요구사항을 만족시킨다.

func (b *Buffer) Write(p []byte) (n int, err error) {
	b.lastRead = opInvalid
    m, ok := b.tryGrowByReslice(len(p))
    if !ok {
    	m = b.grow(len(p))
    }
    return copy(b.buf[m:], p), nil
}

실제로 bytes.Buffer는 io.Writer 뿐만 아니라 여러 인터페이스를 구현한다.

 

[성능 최적화 팁]

만약 예상되는 데이터 크기를 안다면 미리 버퍼 크기를 할당할 수 있다

// 방법 1: Grow 메서드 사용
var buf bytes.Buffer
buf.Grow(1024 * 1024) // 1MB 미리 할당

// 방법 2: 초기 슬라이스로 생성
initialData := make([]byte, 0, 1024*1024) // 용량 1MB, 길이 0
buf := bytes.NewBuffer(initialData)

내부적으로 슬라이스를 사용하여 동적으로 크기를 조정한다는 장점이 있다. 이는 파일 시스템이나 네트워크 I/O와 달리 순수하게 메모리에서 작업하므로 매우 빠른 성능을 제공한다. 또한 bytes.Buffer를 사용함으로써 CSV 데이터를 메모리에서 완전히 생성한 후 HTTP 응답으로 전송하는 안전한 방식을 채택할 수, 에러 처리와 데이터 검증 측면에서 장점이 있다.

 

하지만 모든 데이터를 메모리에 로드하므로 대용량 데이터 처리 시에는 메모리 부족 문제가 생길 수 있다. 이런 경우에는 HTTP 응답 스트립에 직접 쓰는 방식을 고려할 수 있는데 아래와 같이 구현할 수 있다.

csv.NewWriter(c.Writer)

 

gin.Context의 Writer 필드도 io.Writer 인터페이스를 구현하므로 csv.NewWriter()의 인자로 직접 전달할 수 있다.

더보기

[gin.Context의 Writer 필드의 io.Writer 인터페이스 구현]

type Writer interface{
	Write([]byte) (n int, err error)
}

Gin 프레임 워크에서 gin.Context의 Writer 필드는 gin.ResponseWriter 타입이다:

type Context struct {
	Writer ResponseWriter
    // ... 다른 필드들
}

type ResponseWrieter interface {
	http.ResponseWriter
    // ... 추가 메서드들
}

여기서 중요한 점은 http.ResponseWriter가 io.Writer 인터페이스를 임베드(embed) 하고 있다는 것이다.

type ResponseWriter interface {
	Header() Header
    Write([]byte) (int, error) // io.Writer의 Write 메서드
    WriteHeader(statusCode int)
}

따라서 c.Writer는 자동으로 io.Writer 인터페이스를 만족하게 된다.

 

2) WriteAll vs Write

- Writer.WriteAll(records [][]string)

if err := writer.WriteAll(csvData); err != nil {
	// 에러 처리
}

if err != writer.Error(); err != nil {
	// 에러 처리
}

모든 레코드를 한 번에 버퍼에 쓰는 방식으로, 메모리에 모든 데이터를 로드해야하므로 메모리 사용량은 높지만 I/O 호출 횟수 최소화 가능하다. 따라서 대용량 데이터에는 적합하지 않다.

WriteAll은 내부에서 Writer.Flush() 메소드를 호출하고 있기 때문에 별도로 호출을 할 필요는 없다.

다만 Flush에서 발생한 오류는 별도로 에러 처리를 해줘야한다.

 

- Writer.Write(record []string)

// 대안: 한 줄씩 write (메모리 효율적)
for _, record := range csvData {
	if err := writer.Write(record); err != nil {
    	return err
    }
}

한 번 호출에 단일 CSV 레코드만 버퍼에 쓰는 방식

반복 호출하게 되면 메모리 사용량은 적지만 더 많은 system call을 발생 시킨다. (Flush를 매번 호출할 경우)

그리고 Write 이후에는 반드시 Writer.Flush() 메소드를 호출해서 레코드가 io.Writer에 기록이 되도록 확실하게 해야한다(ensure)

 

3) Flush의 중요성

writer.Flush()
if err := writer.Error(); err != nil {
	return err
}

Flush() 메서드는 버퍼링된 데이터를 실제 출력 대상에 쓰는 역할을 한다.

Go의 bufio 패키지를 기반으로 하는 csv.Writer는 성능 향상을 위해 내부적으로 버퍼를 사용하여 버퍼가 가득 차거나 명시적으로 Flush()를 호출할 때까지 실제 쓰기 작업을 지연시킨다. 이는 system call 횟수를 줄여 성능을 향상시키지만, 개발자가 Flush()를 호출하지 않으면 마지막 부분의 데이터가 유실될 수 있다. 특히 네트워크나 파일 I/O에서는 더욱 주의를 해야하며, 아래와 같은 패턴을 사용하여 함수 종료 시 항상 Flush()가 수행되도록 보장하는 것이 좋다.

defer wrtier.Flush()

 

4) HTTP 헤더 설정

c.Header("Content-Type", "text/csv")
c.Header("Content-Disposition", "attachment;filename=data_export.csv")
  • Content-Type: text/csv: 브라우저에게 Response Body가 CSV 형식임을 알려주며, 이에 따라 브라우저는 적절한 MIME 타입 처리를 수행한다.
  • Content-Disposition: attachment: 브라우저가 콘텐츠를 인라인으로 시작하지 않고 파일 다운로드를 시작하도록 지시하는 중요한 헤더다. 이 헤더가 없으면 브라우저는 CSV 데이터를 텍스트로 표시할 수 없다. 파일명에 특수 문자나 유니코드 문자가 포함된 경우에는 RFC6266에 따라 인코딩해야하며, 브라우저 호환성을 위해 ASCII 문자만 사용하는 것이 안전하다.
  • Content-Length 헤더를 설정하면 브라우저가 다운로드 진행률을 표시할 수 있으니 참고하자.

 

성능 최적화: 스트리밍 방식

func ExportCSVStreamWithTimeouts(c *gin.Context) {
    // 1. 전체 요청 타임아웃 설정 (10분)
    ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Minute)
    defer cancel()
    
    // ... 검증 프ㄹ로세스 ... //
    
    // HTTP 헤더 설정
    c.Header("Content-Type", "text/csv")
    c.Header("Content-Disposition", "attachment;filename=data.csv")
    
    writer := csv.NewWriter(c.Writer)
    defer writer.Flush()
    
    // csv 헤더 작성
    if err := writer.Write([]string{"헤더", "미리", "설정"}); err != nil {
        return
    }
    
    // 2. 청크 단위로 데이터 처리
    pageSize := 1000
    offset := 0
    
    for {
        // 3. 각 청크 조회에 타임아웃 적용
        dataChunk, hasMore, err := h.repository.GetDataChunkWithTimeout(ctx, offset, pageSize)
        if err != nil {
            return // 연결 종료
        }
        
        // 4. 클라이언트 연결 상태 확인
        select {
        case <-ctx.Done():
            h.log.Info("Client disconnected during CSV streaming", "offset", offset)
            return
        default:
            // 계속 진행
        }
        
        // CSV 데이터 작성
        for _, record := range dataChunk {
            csvRow := []string{
                strconv.Itoa(record.Data1),
                strconv.Itoa(record.Data2),
                strconv.Itoa(record.Data3),
            }
            
            if err := writer.Write(csvRow); err != nil {
                return
            }
        }
        
        // 5. 청크 단위로 flush
        writer.Flush()
        if err := writer.Error(); err != nil {
            return
        }
        
        if !hasMore {
            break
        }
        offset += pageSize
        
        // 6. 과도한 메모리 사용 방지를 위한 체크
        if offset > 1000000 { // 100만 행 제한
            h.log.Warn("CSV export limit reached", "maxRows", 1000000)
            break
        }
    }
}

대용량 데이터를 처리하면서 메모리 사용량을 일정하게 유지할 수 있도록 하는 스트리밍 방식이다.

 

이 방식에서는 데이터를 청크 단위로 DB에서 조회하고, 각 청크를 즉시 HTTP 응답 스트림에 작성한다.

Go의 HTTP 서버는 기본적으로 청크 전송 인코딩(chunked transfer encoding)을 지원하므로, 전체 응답 크기를 미리 알 필요가 없다. 이는 특히 수백만 행의 데이터를 처리할 때 메모리 부족으로 인한 서버 크래시를 방지할 수 있다.

또한 사용자는 데이터 생성이 완료되기 전에도 다운로드를 시작할 수 있어 사용자 경험이 향상된다.

 

스트리밍 방식 구현 시 주의해야할 점은 에러 처리다. 일반적인 API에서는 에러가 발생하면 적절한 HTTP Status code와 에러 메세지를 반환할 수 있지만, 스트리밍 중에는 이미 HTTP 헤더와 일부 데이터가 전송된 상태이므로 상태 코드를 변경할 수 없다. 따라서 데이터 유효성 검사나 권한 확인 등은 스트리밍을 시작하기 전에 완료하는게 좋다.

또한 스트리밍 중 에러가 발생하면 로그를 남기고 연결을 종료하는 것이 최선이다. DB 연결 타임 아웃이나 네트워크 지연을 고려하여 http 서버나 gin context에 적절한 타임아웃을 설정해야 한다. 아래는 http server 관련 타임아웃 설정 예시 코드다.

func setupServer() *http.Server {
    router := gin.Default()
    
    server := &http.Server{
        Addr:           ":8080",
        Handler:        router,
        ReadTimeout:    30 * time.Second,  // 클라이언트 요청 읽기 타임아웃
        WriteTimeout:   300 * time.Second, // 응답 쓰기 타임아웃 (CSV 스트리밍용으로 길게 설정)
        IdleTimeout:    120 * time.Second, // Keep-alive 연결 유지 시간
        MaxHeaderBytes: 1 << 20,           // 1MB
    }
    
    return server
}

 

성능 측면에서 청크 크기 결정도 중요한데,

청크가 너무 작으면 DB 쿼리 횟수가 증가하여 전체 처리 시간이 늘어나고,

너무 크면 메모리 사용량이 증가하며 첫번째 청크를 받기까지의 대기 시간이 길어지기 때문이다.

일반적으로 1000~10000행 정도가 적절하며, 실제 데이터 크기와 서버 성능을 고려하여 조정해야한다.

또한 DB cursor를 사용하여 서버 측에서 결과 집합을 유지하는 방식도 고려할 수 있지만, 이는 DB 연결을 오래 유지해야 하므로 connection pool 관리에 주의해야 한다. (ex) 스트리밍용 연결을 별도로 관리, 동시 스트리밍 세션 수 제한, 쿼리 타임아웃 적용 등)

 

메모리 버퍼 방식 VS 스트리밍 방식

  메모리 버퍼 스트리밍
장점 - 에러 처리가 용이 (CSV 생성 실패 시, HTTP 상태 코드 변경 가능)
- 데이터 크기를 미리 알 수 있어 Content-Length 헤더 설정 가능
- 중간에 데이터 검증 및 후처리 가능
- 메모리 사용량이 일정함
- 대용량 데이터 처리 가능
- 사용자가 즉시 다운로드 시작 가능
단점 - 모든 데이터를 메모리에 로드해야 함
- 대용량 데이터 처리 시 메모리 부족 위험
- 중간에 에러 발생 시 HTTP 상태 코드 변경 불가
- 데이터 크기를 미리 알 수 없음

 


python으로 csv 파일 다운로드는 개발해 봤는데 golang으로 개발은 처음해본 기능이다.

사실 그렇게 어려운 기능은 아니지만 처음 개발하는거라 좀 두렵기도 했고, 완성하지 못할까봐 걱정도 미리 앞섰지만,

그래도 새로운 기능들을 맡아 성장하고 싶은 마음에 적극적으로 의사를 밝혀서 맡게 되었다.

그리고 역시나 생각보다 간단하게 구현을 완료했다.

구현할 때는 동작원리까지 파고들 시간이 없어서 정말 구현을 위한 구현만 했는데

포스트를 쓰면서 동작 원리와 다른 구현 방식, 그리고 장,단점에 대해서 알 수 있었다.

 


참고 자료

  • https://medium.com/readytowork-org/handling-csv-in-go-gin-600dc2085652
  • https://pkg.go.dev/encoding/csv
  • https://stackoverflow.com/questions/68162651/go-how-to-response-csv-file
  • 그리고 cursor AI의 도움 (이 글은 24.10.10 에 쓰기 시작해서 현재 업로드됨..ㅎ)

'Project' 카테고리의 다른 글

[Go] 캔들 차트 적재 서버 후속편: Refactoring  (0) 2025.06.25
[Go] Websocket 개발기  (0) 2025.06.11
[Go] 블록체인 플랫폼에서 실시간 ohlcv 차트(캔들 차트) 설계 방법 (부제: Restructuring)  (0) 2025.06.05
[Go] util 함수 개발기 - klaytn 패키지의 unpack 함수에서 abi 의존성 제거하기  (0) 2024.07.23
  1. JSON Response vs CSV Response
  2. Go의 encoding/csv 패키지 간단 분석
  3. Repository Layer: 데이터를 CSV 형식으로 변환
  4. Service & Network Layer: 메모리 버퍼 방식 사용하여 HTTP 응답으로 CSV 제공
  5. 성능 최적화: 스트리밍 방식
  6. 메모리 버퍼 방식 VS 스트리밍 방식
'Project' 카테고리의 다른 글
  • [Go] 캔들 차트 적재 서버 후속편: Refactoring
  • [Go] Websocket 개발기
  • [Go] 블록체인 플랫폼에서 실시간 ohlcv 차트(캔들 차트) 설계 방법 (부제: Restructuring)
  • [Go] util 함수 개발기 - klaytn 패키지의 unpack 함수에서 abi 의존성 제거하기
빵빵0
빵빵0
(아직은) 공부하고 정리하는 블로그입니다.
빵빵0
Hack Your World
빵빵0
전체
오늘
어제
  • 분류 전체보기 (81) N
    • Error Handling (7)
    • Project (5)
      • MEV (2) N
    • Computer Science (4)
      • Data Structure (2)
      • Database (1)
      • Cloud (0)
      • OS (0)
      • Infra, Network (1)
      • AI (0)
    • Language (4)
      • Go (3)
      • Rust (1)
      • Python (0)
      • Java (0)
    • Algorithm (39)
      • BaekJoon (18)
      • Programmers (7)
      • LeetCode (6)
      • NeetCode (8)
    • SW Books (9)
      • gRPC Up & Running (1)
      • System Design Interview (2)
      • 스프링 입문을 위한 자바 객체지향의 원리와 이해 (6)
      • 블록체인 해설서 (0)
      • 후니의 쉽게 쓴 CISCO 네트워킹 (0)
    • Own (3)
      • Personal (1)
      • Novel (0)
      • Memo (2)
    • BlockChain (4)
      • Issues (0)
      • Research (4)
      • Tech (0)

블로그 메뉴

  • 홈
  • 태그
  • 방명록

공지사항

인기 글

태그

  • go
  • Event
  • candlechart
  • two pointer
  • 그리디
  • 2024
  • MongoDB
  • NeetCode
  • Palindrome
  • 스택
  • ethereum
  • Programmers
  • Hash Table
  • Greedy
  • 백준
  • chart
  • 블록체인
  • decimal
  • BaekJoon
  • 해시
  • LeetCode
  • Python
  • BEAKJOON
  • 프로그래머스
  • BFS
  • KBW
  • 큐
  • golang
  • blockchain
  • DP

최근 댓글

최근 글

hELLO · Designed By 정상우.v4.2.0
빵빵0
[Go] encoding/csv 패키지를 이용해 csv 다운로드 API 구현하기
상단으로

티스토리툴바

단축키

내 블로그

내 블로그 - 관리자 홈 전환
Q
Q
새 글 쓰기
W
W

블로그 게시글

글 수정 (권한 있는 경우)
E
E
댓글 영역으로 이동
C
C

모든 영역

이 페이지의 URL 복사
S
S
맨 위로 이동
T
T
티스토리 홈 이동
H
H
단축키 안내
Shift + /
⇧ + /

* 단축키는 한글/영문 대소문자로 이용 가능하며, 티스토리 기본 도메인에서만 동작합니다.