I decided to try to ease my pain by creating a custom tag to loop over data in a consistent way. Here's what I came up with.
Usage
<cfimport prefix="for" taglib="/com/tags" />
<for:each key="key" in="#data#" value="value" index="i">
... stuff ...
</for:each>
Examples
<cfimport prefix="for" taglib="/com/tags" />
<cfoutput>
Array
<cfset people = [] />
<cfset people[1] = "LeBron James" />
<cfset people[2] = "Dwyane Wade" />
<cfset people[3] = "Kobe Bryant" />
<for:each in="#people#">
Hello, my name is #it#
</for:each>
Struct
<cfset people = {} />
<cfset people["LeBronJames"] = "LeBron James" />
<cfset people["DwyaneWade"] = "Dwyane Wade" />
<cfset people["KobeBryant"] = "Kobe Bryant" />
<for:each in="#people#" value="name">
Hello, my name is #name#
</for:each>
List
<cfset people = "LeBron James,Dwyane Wade,Kobe Bryant" />
<for:each in="#people#">
Hello, my name is #it#
</for:each>
Query
<cfset people = queryNew("firstName,lastName") />
<cfset queryAddRow(people) />
<cfset querySetCell(people, "firstName", "LeBron") />
<cfset querySetCell(people, "lastName", "James") />
<cfset queryAddRow(people) />
<cfset querySetCell(people, "firstName", "Dwyane") />
<cfset querySetCell(people, "lastName", "Wade") />
<cfset queryAddRow(people) />
<cfset querySetCell(people, "firstName", "Kobe") />
<cfset querySetCell(people, "lastName", "Bryant") />
<for:each in="#people#" value="person">
Hello, my name is #person.firstName# #person.lastName#
</for:each>
Nested Array
<cfset people = [] />
<cfset people[1] = {firstName="LeBron", lastName="James"} />
<cfset people[2] = {firstName="Dwyane", lastName="Wade"} />
<cfset people[3] = {firstName="Kobe", lastName="Bryant"} />
<for:each in="#people#" value="person">
Hello, my name is #person.firstName# #person.lastName#
</for:each>
Nested Struct
<cfset people = {} />
<cfset people["LeBronJames"] = {firstName="LeBron", lastName="James"} />
<cfset people["DwyaneWade"] = {firstName="Dwyane", lastName="Wade"} />
<cfset people["KobeBryant"] = {firstName="Kobe", lastName="Bryant"} />
<for:each in="#people#" value="person">
Hello, my name is #person.firstName# #person.lastName#
</for:each>
</cfoutput>
And here's the code
/com/tags/for/each.cfm
<cfif thisTag.executionMode eq "start">
<cfparam name="attributes.value" default="it" />
<cfparam name="attributes.in" default="" />
<cfparam name="attributes.start" default="1" />
<cfparam name="attributes.delimeter" default="," />
<cfset attributes.type = getType(attributes.in) />
<cfset attributes.length = getLength(attributes.in, attributes.type, attributes.delimeter) />
<cfif not structKeyExists(attributes, "end")>
<cfset attributes.end = attributes.length />
</cfif>
<cfif attributes.length>
<cfset processLoop(attributes) />
<cfelse>
<cfexit method="exittag" />
</cfif>
<cfset content = [] />
<cfelse>
<cfset arrayAppend(content, thisTag.generatedContent) />
<cfset thisTag.generatedContent = "" />
<cfset attributes.start++ />
<cfif attributes.start lte attributes.end>
<cfset processLoop(attributes) />
<cfexit method="loop" />
</cfif>
<cfoutput>
#arrayToList(content, "")#
</cfoutput>
</cfif>
<cffunction name="processLoop" access="private" output="false" returntype="void">
<cfargument name="attributes" required="true" type="struct" />
<cfif structKeyExists(attributes, "index")>
<cfset caller[attributes.index] = attributes.start />
</cfif>
<cfif structKeyExists(attributes, "key")>
<cfset caller[attributes.key] = getKey(attributes.in, attributes.type, attributes.delimeter, attributes.start) />
</cfif>
<cfif structKeyExists(attributes, "value")>
<cfset caller[attributes.value] = getValue(attributes.in, attributes.type, attributes.delimeter, attributes.start) />
</cfif>
</cffunction>
<cffunction name="getType" access="private" output="false" returntype="string">
<cfargument name="data" required="true" type="any" />
<cfif isArray(arguments.data)>
<cfreturn "array" />
<cfelseif isStruct(arguments.data)>
<cfreturn "struct" />
<cfelseif isQuery(arguments.data)>
<cfreturn "query" />
<cfelse>
<cfreturn "string" />
</cfif>
</cffunction>
<cffunction name="getLength" access="private" output="false" returntype="numeric">
<cfargument name="data" required="true" type="any" />
<cfargument name="type" required="true" type="string" />
<cfargument name="delimeter" required="true" type="string" />
<cfswitch expression="#arguments.type#">
<cfcase value="array">
<cfreturn arrayLen(arguments.data) />
</cfcase>
<cfcase value="struct">
<cfreturn structCount(arguments.data) />
</cfcase>
<cfcase value="query">
<cfreturn arguments.data.recordCount />
</cfcase>
<cfcase value="string">
<cfreturn listLen(arguments.data, arguments.delimeter) />
</cfcase>
</cfswitch>
</cffunction>
<cffunction name="getKey" access="private" output="false" returntype="string">
<cfargument name="data" required="true" type="any" />
<cfargument name="type" required="true" type="string" />
<cfargument name="delimeter" required="true" type="string" />
<cfargument name="index" required="true" type="numeric" />
<cfset var result = "" />
<cfset var i = "" />
<cfswitch expression="#arguments.type#">
<cfcase value="array">
<cfset result = arguments.index />
</cfcase>
<cfcase value="struct">
<cfset result = listGetAt(listSort(structKeyList(arguments.data), "text"), arguments.index) />
</cfcase>
<cfcase value="query">
<cfset result = arguments.index />
</cfcase>
<cfcase value="string">
<cfset result = listGetAt(arguments.data, arguments.index, arguments.delimeter) />
</cfcase>
</cfswitch>
<cfreturn result />
</cffunction>
<cffunction name="getValue" access="private" output="false" returntype="any">
<cfargument name="data" required="true" type="any" />
<cfargument name="type" required="true" type="string" />
<cfargument name="delimeter" required="true" type="string" />
<cfargument name="index" required="true" type="numeric" />
<cfset var result = "" />
<cfset var i = "" />
<cfswitch expression="#arguments.type#">
<cfcase value="array">
<cfset result = arguments.data[arguments.index] />
</cfcase>
<cfcase value="struct">
<cfset result = arguments.data[listGetAt(listSort(structKeyList(arguments.data), "text"), arguments.index)] />
</cfcase>
<cfcase value="query">
<cfset result = {} />
<cfloop list="#arguments.data.columnList#" index="i">
<cfset result[i] = arguments.data[i][arguments.index] />
</cfloop>
</cfcase>
<cfcase value="string">
<cfset result = listGetAt(arguments.data, arguments.index, arguments.delimeter) />
</cfcase>
</cfswitch>
<cfreturn result />
</cffunction>
It's been quite a while since I've used custom tags, so the code could probably be better, but it seems to do the job.